Skip to content

Commit 0ba5985

Browse files
shreyas-lyzrclaude
andauthored
feat: add GitHub Copilot CLI adapter for export (#24)
Add `copilot` format to `gitagent export` that generates `.github/agents/<name>.agent.md` and `.github/skills/<name>/SKILL.md` files compatible with GitHub Copilot CLI. The adapter maps gitagent's SOUL.md, RULES.md, DUTIES.md, skills, knowledge, and compliance constraints into Copilot's agent.md format with YAML frontmatter. Closes #18 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abb3674 commit 0ba5985

File tree

3 files changed

+275
-2
lines changed

3 files changed

+275
-2
lines changed

src/adapters/copilot.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2+
import { join, resolve } from 'node:path';
3+
import yaml from 'js-yaml';
4+
import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js';
5+
import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js';
6+
7+
/**
8+
* Export a gitagent to GitHub Copilot CLI format.
9+
*
10+
* Copilot CLI uses:
11+
* - .github/agents/<name>.agent.md (agent definition with YAML frontmatter)
12+
* - .github/skills/<name>/SKILL.md (skill files)
13+
*
14+
* Returns structured output with all files that should be written.
15+
*/
16+
export interface CopilotExport {
17+
agentMd: string;
18+
agentFileName: string;
19+
skills: Array<{ name: string; content: string }>;
20+
}
21+
22+
export function exportToCopilot(dir: string): CopilotExport {
23+
const agentDir = resolve(dir);
24+
const manifest = loadAgentManifest(agentDir);
25+
26+
const agentFileName = slugify(manifest.name);
27+
const agentMd = buildAgentMd(agentDir, manifest);
28+
const skills = collectSkills(agentDir);
29+
30+
return { agentMd, agentFileName, skills };
31+
}
32+
33+
/**
34+
* Export as a single string (for `gitagent export -f copilot`).
35+
*/
36+
export function exportToCopilotString(dir: string): string {
37+
const exp = exportToCopilot(dir);
38+
const parts: string[] = [];
39+
40+
parts.push(`# === .github/agents/${exp.agentFileName}.agent.md ===`);
41+
parts.push(exp.agentMd);
42+
43+
for (const skill of exp.skills) {
44+
parts.push(`\n# === .github/skills/${skill.name}/SKILL.md ===`);
45+
parts.push(skill.content);
46+
}
47+
48+
return parts.join('\n');
49+
}
50+
51+
function slugify(name: string): string {
52+
return name
53+
.toLowerCase()
54+
.replace(/[^a-z0-9]+/g, '-')
55+
.replace(/^-|-$/g, '');
56+
}
57+
58+
function buildAgentMd(
59+
agentDir: string,
60+
manifest: ReturnType<typeof loadAgentManifest>,
61+
): string {
62+
const parts: string[] = [];
63+
64+
// YAML frontmatter — Copilot agent.md supports description and tools
65+
const frontmatter: Record<string, unknown> = {
66+
description: manifest.description,
67+
};
68+
69+
// Collect tool names from tools/ directory
70+
const toolNames = collectToolNames(agentDir);
71+
if (toolNames.length > 0) {
72+
frontmatter.tools = toolNames;
73+
}
74+
75+
parts.push('---');
76+
parts.push(yaml.dump(frontmatter).trimEnd());
77+
parts.push('---');
78+
parts.push('');
79+
80+
// Agent identity
81+
parts.push(`# ${manifest.name}`);
82+
parts.push('');
83+
84+
// SOUL.md
85+
const soul = loadFileIfExists(join(agentDir, 'SOUL.md'));
86+
if (soul) {
87+
parts.push(soul);
88+
parts.push('');
89+
}
90+
91+
// RULES.md
92+
const rules = loadFileIfExists(join(agentDir, 'RULES.md'));
93+
if (rules) {
94+
parts.push(rules);
95+
parts.push('');
96+
}
97+
98+
// DUTIES.md
99+
const duty = loadFileIfExists(join(agentDir, 'DUTIES.md'));
100+
if (duty) {
101+
parts.push(duty);
102+
parts.push('');
103+
}
104+
105+
// Skills
106+
const skillsDir = join(agentDir, 'skills');
107+
const skills = loadAllSkills(skillsDir);
108+
if (skills.length > 0) {
109+
parts.push('## Skills');
110+
parts.push('');
111+
for (const skill of skills) {
112+
const toolsList = getAllowedTools(skill.frontmatter);
113+
const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : '';
114+
parts.push(`### ${skill.frontmatter.name}`);
115+
parts.push(`${skill.frontmatter.description}${toolsNote}`);
116+
parts.push('');
117+
parts.push(skill.instructions);
118+
parts.push('');
119+
}
120+
}
121+
122+
// Knowledge (always_load documents)
123+
const knowledgeDir = join(agentDir, 'knowledge');
124+
const indexPath = join(knowledgeDir, 'index.yaml');
125+
if (existsSync(indexPath)) {
126+
const index = yaml.load(readFileSync(indexPath, 'utf-8')) as {
127+
documents?: Array<{ path: string; always_load?: boolean }>;
128+
};
129+
130+
if (index.documents) {
131+
const alwaysLoad = index.documents.filter(d => d.always_load);
132+
if (alwaysLoad.length > 0) {
133+
parts.push('## Knowledge');
134+
parts.push('');
135+
for (const doc of alwaysLoad) {
136+
const content = loadFileIfExists(join(knowledgeDir, doc.path));
137+
if (content) {
138+
parts.push(`### ${doc.path}`);
139+
parts.push(content);
140+
parts.push('');
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
// Compliance constraints
148+
if (manifest.compliance) {
149+
const constraints = buildComplianceSection(manifest.compliance);
150+
if (constraints) {
151+
parts.push(constraints);
152+
parts.push('');
153+
}
154+
}
155+
156+
// Memory
157+
const memory = loadFileIfExists(join(agentDir, 'memory', 'MEMORY.md'));
158+
if (memory && memory.trim().split('\n').length > 2) {
159+
parts.push('## Memory');
160+
parts.push(memory);
161+
parts.push('');
162+
}
163+
164+
return parts.join('\n').trimEnd() + '\n';
165+
}
166+
167+
function collectToolNames(agentDir: string): string[] {
168+
const toolsDir = join(agentDir, 'tools');
169+
if (!existsSync(toolsDir)) return [];
170+
171+
const files = readdirSync(toolsDir).filter(f => f.endsWith('.yaml'));
172+
const names: string[] = [];
173+
174+
for (const file of files) {
175+
try {
176+
const content = readFileSync(join(toolsDir, file), 'utf-8');
177+
const toolConfig = yaml.load(content) as { name?: string };
178+
if (toolConfig?.name) {
179+
names.push(toolConfig.name);
180+
}
181+
} catch { /* skip malformed tools */ }
182+
}
183+
184+
return names;
185+
}
186+
187+
function collectSkills(agentDir: string): Array<{ name: string; content: string }> {
188+
const skills: Array<{ name: string; content: string }> = [];
189+
const skillsDir = join(agentDir, 'skills');
190+
if (!existsSync(skillsDir)) return skills;
191+
192+
const entries = readdirSync(skillsDir, { withFileTypes: true });
193+
for (const entry of entries) {
194+
if (!entry.isDirectory()) continue;
195+
const skillMdPath = join(skillsDir, entry.name, 'SKILL.md');
196+
if (!existsSync(skillMdPath)) continue;
197+
198+
skills.push({
199+
name: entry.name,
200+
content: readFileSync(skillMdPath, 'utf-8'),
201+
});
202+
}
203+
204+
return skills;
205+
}
206+
207+
function buildComplianceSection(compliance: NonNullable<ReturnType<typeof loadAgentManifest>['compliance']>): string {
208+
const c = compliance;
209+
const constraints: string[] = [];
210+
211+
if (c.supervision?.human_in_the_loop === 'always') {
212+
constraints.push('- All decisions require human approval before execution');
213+
}
214+
if (c.supervision?.escalation_triggers) {
215+
constraints.push('- Escalate to human supervisor when:');
216+
for (const trigger of c.supervision.escalation_triggers) {
217+
for (const [key, value] of Object.entries(trigger)) {
218+
constraints.push(` - ${key}: ${value}`);
219+
}
220+
}
221+
}
222+
if (c.communications?.fair_balanced) {
223+
constraints.push('- All communications must be fair and balanced (FINRA 2210)');
224+
}
225+
if (c.communications?.no_misleading) {
226+
constraints.push('- Never make misleading, exaggerated, or promissory statements');
227+
}
228+
if (c.data_governance?.pii_handling === 'redact') {
229+
constraints.push('- Redact all PII from outputs');
230+
}
231+
if (c.data_governance?.pii_handling === 'prohibit') {
232+
constraints.push('- Do not process any personally identifiable information');
233+
}
234+
235+
if (c.segregation_of_duties) {
236+
const sod = c.segregation_of_duties;
237+
constraints.push('- Segregation of duties is enforced:');
238+
if (sod.assignments) {
239+
for (const [agentName, roles] of Object.entries(sod.assignments)) {
240+
constraints.push(` - Agent "${agentName}" has role(s): ${roles.join(', ')}`);
241+
}
242+
}
243+
if (sod.conflicts) {
244+
constraints.push('- Duty separation rules (no single agent may hold both):');
245+
for (const [a, b] of sod.conflicts) {
246+
constraints.push(` - ${a} and ${b}`);
247+
}
248+
}
249+
if (sod.handoffs) {
250+
constraints.push('- The following actions require multi-agent handoff:');
251+
for (const h of sod.handoffs) {
252+
constraints.push(` - ${h.action}: must pass through roles ${h.required_roles.join(' → ')}${h.approval_required !== false ? ' (approval required)' : ''}`);
253+
}
254+
}
255+
if (sod.isolation?.state === 'full') {
256+
constraints.push('- Agent state/memory is fully isolated per role');
257+
}
258+
if (sod.isolation?.credentials === 'separate') {
259+
constraints.push('- Credentials are segregated per role');
260+
}
261+
if (sod.enforcement === 'strict') {
262+
constraints.push('- SOD enforcement is STRICT — violations will block execution');
263+
}
264+
}
265+
266+
if (constraints.length === 0) return '';
267+
return `## Compliance Constraints\n\n${constraints.join('\n')}`;
268+
}

src/adapters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { exportToOpenAI } from './openai.js';
44
export { exportToCrewAI } from './crewai.js';
55
export { exportToOpenClawString, exportToOpenClaw } from './openclaw.js';
66
export { exportToNanobotString, exportToNanobot } from './nanobot.js';
7+
export { exportToCopilotString, exportToCopilot } from './copilot.js';

src/commands/export.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
exportToCrewAI,
99
exportToOpenClawString,
1010
exportToNanobotString,
11+
exportToCopilotString,
1112
} from '../adapters/index.js';
1213
import { exportToLyzrString } from '../adapters/lyzr.js';
1314
import { exportToGitHubString } from '../adapters/github.js';
@@ -20,7 +21,7 @@ interface ExportOptions {
2021

2122
export const exportCommand = new Command('export')
2223
.description('Export agent to other formats')
23-
.requiredOption('-f, --format <format>', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github)')
24+
.requiredOption('-f, --format <format>', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot)')
2425
.option('-d, --dir <dir>', 'Agent directory', '.')
2526
.option('-o, --output <output>', 'Output file path')
2627
.action(async (options: ExportOptions) => {
@@ -57,9 +58,12 @@ export const exportCommand = new Command('export')
5758
case 'github':
5859
result = exportToGitHubString(dir);
5960
break;
61+
case 'copilot':
62+
result = exportToCopilotString(dir);
63+
break;
6064
default:
6165
error(`Unknown format: ${options.format}`);
62-
info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github');
66+
info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot');
6367
process.exit(1);
6468
}
6569

0 commit comments

Comments
 (0)