Skip to content

Commit 053dd95

Browse files
committed
feat(adapter): add Kiro CLI export adapter (#46)
Export gitagent to AWS Kiro custom agent format: - .kiro/agents/<name>.json with name, description, prompt, model, tools, allowedTools, mcpServers, hooks - prompt.md referenced via file:// URI Fix: hook events use camelCase per Kiro docs (preToolUse, postToolUse, stop, agentSpawn, userPromptSubmit) — not PascalCase. Removed non-existent 'Notification' event. Closes #46
1 parent 795fa2e commit 053dd95

File tree

3 files changed

+313
-1
lines changed

3 files changed

+313
-1
lines changed

src/adapters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { exportToOpenCodeString, exportToOpenCode } from './opencode.js';
99
export { exportToCursorString, exportToCursor } from './cursor.js';
1010
export { exportToGeminiString, exportToGemini } from './gemini.js';
1111
export { exportToCodexString, exportToCodex } from './codex.js';
12+
export { exportToKiroString, exportToKiro } from './kiro.js';

src/adapters/kiro.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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+
import { buildComplianceSection } from './shared.js';
7+
8+
/**
9+
* Export a gitagent to AWS Kiro CLI custom agent format.
10+
*
11+
* Kiro CLI uses a JSON config file (`.kiro/agents/<name>.json`) with:
12+
* - name, description, prompt (inline or file:// URI)
13+
* - mcpServers, tools, allowedTools
14+
* - model, hooks, resources
15+
*
16+
* Reference: https://kiro.dev/docs/cli/custom-agents/configuration-reference/
17+
*/
18+
export interface KiroExport {
19+
config: Record<string, unknown>;
20+
prompt: string;
21+
}
22+
23+
export function exportToKiro(dir: string): KiroExport {
24+
const agentDir = resolve(dir);
25+
const manifest = loadAgentManifest(agentDir);
26+
27+
const prompt = buildPrompt(agentDir, manifest);
28+
const config = buildConfig(agentDir, manifest);
29+
30+
return { config, prompt };
31+
}
32+
33+
export function exportToKiroString(dir: string): string {
34+
const exp = exportToKiro(dir);
35+
const parts: string[] = [];
36+
37+
parts.push('# === .kiro/agents/<name>.json ===');
38+
parts.push(JSON.stringify(exp.config, null, 2));
39+
parts.push('\n# === prompt.md (referenced via file://./prompt.md) ===');
40+
parts.push(exp.prompt);
41+
42+
return parts.join('\n');
43+
}
44+
45+
function buildPrompt(
46+
agentDir: string,
47+
manifest: ReturnType<typeof loadAgentManifest>,
48+
): string {
49+
const parts: string[] = [];
50+
51+
parts.push(`# ${manifest.name}`);
52+
parts.push(`${manifest.description}`);
53+
parts.push('');
54+
55+
const soul = loadFileIfExists(join(agentDir, 'SOUL.md'));
56+
if (soul) {
57+
parts.push(soul);
58+
parts.push('');
59+
}
60+
61+
const rules = loadFileIfExists(join(agentDir, 'RULES.md'));
62+
if (rules) {
63+
parts.push(rules);
64+
parts.push('');
65+
}
66+
67+
const duty = loadFileIfExists(join(agentDir, 'DUTIES.md'));
68+
if (duty) {
69+
parts.push(duty);
70+
parts.push('');
71+
}
72+
73+
const skillsDir = join(agentDir, 'skills');
74+
const skills = loadAllSkills(skillsDir);
75+
if (skills.length > 0) {
76+
parts.push('## Skills');
77+
parts.push('');
78+
for (const skill of skills) {
79+
const toolsList = getAllowedTools(skill.frontmatter);
80+
const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : '';
81+
parts.push(`### ${skill.frontmatter.name}`);
82+
parts.push(`${skill.frontmatter.description}${toolsNote}`);
83+
parts.push('');
84+
parts.push(skill.instructions);
85+
parts.push('');
86+
}
87+
}
88+
89+
const toolsDir = join(agentDir, 'tools');
90+
if (existsSync(toolsDir)) {
91+
const toolFiles = readdirSync(toolsDir).filter(f => f.endsWith('.yaml'));
92+
if (toolFiles.length > 0) {
93+
parts.push('## Tools');
94+
parts.push('');
95+
for (const file of toolFiles) {
96+
try {
97+
const content = readFileSync(join(toolsDir, file), 'utf-8');
98+
const toolConfig = yaml.load(content) as {
99+
name?: string;
100+
description?: string;
101+
input_schema?: Record<string, unknown>;
102+
};
103+
if (toolConfig?.name) {
104+
parts.push(`### ${toolConfig.name}`);
105+
if (toolConfig.description) {
106+
parts.push(toolConfig.description);
107+
}
108+
if (toolConfig.input_schema) {
109+
parts.push('');
110+
parts.push('```yaml');
111+
parts.push(yaml.dump(toolConfig.input_schema).trimEnd());
112+
parts.push('```');
113+
}
114+
parts.push('');
115+
}
116+
} catch { /* skip malformed tools */ }
117+
}
118+
}
119+
}
120+
121+
const knowledgeDir = join(agentDir, 'knowledge');
122+
const indexPath = join(knowledgeDir, 'index.yaml');
123+
if (existsSync(indexPath)) {
124+
const index = yaml.load(readFileSync(indexPath, 'utf-8')) as {
125+
documents?: Array<{ path: string; always_load?: boolean }>;
126+
};
127+
128+
if (index.documents) {
129+
const alwaysLoad = index.documents.filter(d => d.always_load);
130+
if (alwaysLoad.length > 0) {
131+
parts.push('## Knowledge');
132+
parts.push('');
133+
for (const doc of alwaysLoad) {
134+
const content = loadFileIfExists(join(knowledgeDir, doc.path));
135+
if (content) {
136+
parts.push(`### ${doc.path}`);
137+
parts.push(content);
138+
parts.push('');
139+
}
140+
}
141+
}
142+
}
143+
}
144+
145+
if (manifest.compliance) {
146+
const constraints = buildComplianceSection(manifest.compliance);
147+
if (constraints) {
148+
parts.push(constraints);
149+
parts.push('');
150+
}
151+
}
152+
153+
return parts.join('\n').trimEnd() + '\n';
154+
}
155+
156+
function buildConfig(
157+
agentDir: string,
158+
manifest: ReturnType<typeof loadAgentManifest>,
159+
): Record<string, unknown> {
160+
const config: Record<string, unknown> = {};
161+
162+
config.name = manifest.name;
163+
if (manifest.description) {
164+
config.description = manifest.description;
165+
}
166+
167+
// Use file:// URI for prompt so the markdown file is maintained separately
168+
config.prompt = 'file://./prompt.md';
169+
170+
if (manifest.model?.preferred) {
171+
config.model = manifest.model.preferred;
172+
}
173+
174+
// Collect tools from skills and tool definitions
175+
const tools = collectTools(agentDir);
176+
if (tools.length > 0) {
177+
config.tools = tools;
178+
config.allowedTools = tools;
179+
}
180+
181+
// Map MCP servers from tools/*.yaml that declare mcp_server
182+
const mcpServers = collectMcpServers(agentDir);
183+
if (Object.keys(mcpServers).length > 0) {
184+
config.mcpServers = mcpServers;
185+
}
186+
187+
// Hooks
188+
const hooks = buildHooks(agentDir);
189+
if (hooks && Object.keys(hooks).length > 0) {
190+
config.hooks = hooks;
191+
}
192+
193+
// Sub-agents as welcome message hint
194+
if (manifest.agents && Object.keys(manifest.agents).length > 0) {
195+
const agentNames = Object.keys(manifest.agents);
196+
config.welcomeMessage = `This agent delegates to: ${agentNames.join(', ')}`;
197+
}
198+
199+
return config;
200+
}
201+
202+
function collectTools(agentDir: string): string[] {
203+
const tools: Set<string> = new Set();
204+
205+
const skillsDir = join(agentDir, 'skills');
206+
const skills = loadAllSkills(skillsDir);
207+
for (const skill of skills) {
208+
for (const tool of getAllowedTools(skill.frontmatter)) {
209+
tools.add(tool);
210+
}
211+
}
212+
213+
const toolsDir = join(agentDir, 'tools');
214+
if (existsSync(toolsDir)) {
215+
const files = readdirSync(toolsDir).filter(f => f.endsWith('.yaml'));
216+
for (const file of files) {
217+
try {
218+
const content = readFileSync(join(toolsDir, file), 'utf-8');
219+
const toolConfig = yaml.load(content) as { name?: string };
220+
if (toolConfig?.name) {
221+
tools.add(toolConfig.name);
222+
}
223+
} catch { /* skip malformed tools */ }
224+
}
225+
}
226+
227+
return Array.from(tools);
228+
}
229+
230+
function collectMcpServers(agentDir: string): Record<string, Record<string, unknown>> {
231+
const servers: Record<string, Record<string, unknown>> = {};
232+
233+
const toolsDir = join(agentDir, 'tools');
234+
if (!existsSync(toolsDir)) return servers;
235+
236+
const files = readdirSync(toolsDir).filter(f => f.endsWith('.yaml'));
237+
for (const file of files) {
238+
try {
239+
const content = readFileSync(join(toolsDir, file), 'utf-8');
240+
const toolConfig = yaml.load(content) as {
241+
mcp_server?: {
242+
name?: string;
243+
command?: string;
244+
args?: string[];
245+
env?: Record<string, string>;
246+
type?: string;
247+
url?: string;
248+
};
249+
};
250+
if (toolConfig?.mcp_server?.name) {
251+
const mcp = toolConfig.mcp_server;
252+
const entry: Record<string, unknown> = {};
253+
if (mcp.type) entry.type = mcp.type;
254+
if (mcp.command) entry.command = mcp.command;
255+
if (mcp.args) entry.args = mcp.args;
256+
if (mcp.env) entry.env = mcp.env;
257+
if (mcp.url) entry.url = mcp.url;
258+
servers[mcp.name!] = entry;
259+
}
260+
} catch { /* skip malformed tools */ }
261+
}
262+
263+
return servers;
264+
}
265+
266+
function buildHooks(agentDir: string): Record<string, unknown> | null {
267+
try {
268+
const hooksPath = join(agentDir, 'hooks', 'hooks.yaml');
269+
if (!existsSync(hooksPath)) return null;
270+
271+
const hooksYaml = readFileSync(hooksPath, 'utf-8');
272+
const hooksConfig = yaml.load(hooksYaml) as {
273+
hooks: Record<string, Array<{ script: string; description?: string }>>;
274+
};
275+
276+
if (!hooksConfig.hooks || Object.keys(hooksConfig.hooks).length === 0) return null;
277+
278+
// Kiro CLI hook events use camelCase: preToolUse, postToolUse, stop, agentSpawn, userPromptSubmit
279+
const eventMap: Record<string, string> = {
280+
'on_session_start': 'agentSpawn',
281+
'pre_tool_use': 'preToolUse',
282+
'post_tool_use': 'postToolUse',
283+
'pre_response': 'userPromptSubmit',
284+
'on_session_end': 'stop',
285+
};
286+
287+
const kiroHooks: Record<string, Array<{ command: string }>> = {};
288+
289+
for (const [event, hooks] of Object.entries(hooksConfig.hooks)) {
290+
const kiroEvent = eventMap[event];
291+
if (!kiroEvent) continue;
292+
293+
const validHooks = hooks.filter(hook =>
294+
existsSync(join(agentDir, 'hooks', hook.script))
295+
);
296+
if (validHooks.length === 0) continue;
297+
298+
kiroHooks[kiroEvent] = validHooks.map(hook => ({
299+
command: `hooks/${hook.script}`,
300+
}));
301+
}
302+
303+
return Object.keys(kiroHooks).length > 0 ? kiroHooks : null;
304+
} catch {
305+
return null;
306+
}
307+
}

src/commands/export.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
exportToCursorString,
1414
exportToGeminiString,
1515
exportToCodexString,
16+
exportToKiroString,
1617
} from '../adapters/index.js';
1718
import { exportToLyzrString } from '../adapters/lyzr.js';
1819
import { exportToGitHubString } from '../adapters/github.js';
@@ -77,9 +78,12 @@ export const exportCommand = new Command('export')
7778
case 'codex':
7879
result = exportToCodexString(dir);
7980
break;
81+
case 'kiro':
82+
result = exportToKiroString(dir);
83+
break;
8084
default:
8185
error(`Unknown format: ${options.format}`);
82-
info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex');
86+
info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex, kiro');
8387
process.exit(1);
8488
}
8589

0 commit comments

Comments
 (0)