|
| 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 | +} |
0 commit comments