From d646b3b2695b1a898d7360829724733f68cd15b7 Mon Sep 17 00:00:00 2001 From: Dinis Maria Nogueira Pereira <241636469+dinis-prog@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:08:12 +0100 Subject: [PATCH] feat(ide-sync): add Kimi Code as supported IDE target Adds Kimi Code (https://www.kimi-cli.com) to the IDE sync pipeline as a new target with format `kimi-skill`. Kimi consumes per-skill directories shaped as `/SKILL.md` and discovers them at the project Git root, so the transformer differs from the flat layouts used by Claude Code, Codex, Gemini, Cursor, and Antigravity. Changes - transformers/kimi.js: new transformer producing SKILL.md with YAML frontmatter, Activation Protocol directive, persona, Star Commands table, and the full raw agent definition. Includes: - skillId normalization (avoids `aios-aios-master` double prefix) - support for `preferredActivationAlias` - safe rendering of YAML array items that parse as objects (`- CRITICAL: x` was previously serialized as `[object Object]`) - index.js: register transformer, add `kimi` default target with `fallbackSources: ['.codex/agents']`, and write nested `/SKILL.md` paths during sync and validation expectation. - validator.js: walk markdown recursively so nested Kimi layouts are matched against expected files instead of being flagged as orphaned. - tests/ide-sync/kimi-transformer.test.js: covers skillId rules, preferredActivationAlias, [object Object] regression, Activation Protocol presence, and nested layout filenames (5/5 passing). Discovery contract Kimi resolves project root to the nearest `.git` ancestor, so the generated `.kimi/skills/` should live at that level (or be reachable via symlink). No changes to other IDE targets or behaviors. Co-Authored-By: Claude Opus 4.7 --- .../infrastructure/scripts/ide-sync/index.js | 27 +- .../scripts/ide-sync/transformers/kimi.js | 328 ++++++++++++++++++ .../scripts/ide-sync/validator.js | 28 +- tests/ide-sync/kimi-transformer.test.js | 92 +++++ 4 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 .aiox-core/infrastructure/scripts/ide-sync/transformers/kimi.js create mode 100644 tests/ide-sync/kimi-transformer.test.js diff --git a/.aiox-core/infrastructure/scripts/ide-sync/index.js b/.aiox-core/infrastructure/scripts/ide-sync/index.js index 43719a4148..c742bb5e93 100644 --- a/.aiox-core/infrastructure/scripts/ide-sync/index.js +++ b/.aiox-core/infrastructure/scripts/ide-sync/index.js @@ -31,6 +31,7 @@ const claudeCodeTransformer = require('./transformers/claude-code'); const cursorTransformer = require('./transformers/cursor'); const antigravityTransformer = require('./transformers/antigravity'); const githubCopilotTransformer = require('./transformers/github-copilot'); +const kimiTransformer = require('./transformers/kimi'); // ANSI colors for output const colors = { @@ -87,6 +88,12 @@ function loadConfig(projectRoot) { path: '.antigravity/rules/agents', format: 'cursor-style', }, + kimi: { + enabled: true, + path: '.kimi/skills', + format: 'kimi-skill', + fallbackSources: ['.codex/agents'], + }, }, redirects: { 'aiox-developer': 'aiox-master', @@ -128,6 +135,7 @@ function getTransformer(format) { 'condensed-rules': cursorTransformer, 'cursor-style': antigravityTransformer, 'github-copilot': githubCopilotTransformer, + 'kimi-skill': kimiTransformer, }; return transformers[format] || claudeCodeTransformer; @@ -183,7 +191,18 @@ function syncIde(agents, ideConfig, ideName, projectRoot, options) { try { const content = transformer.transform(agent); const filename = transformer.getFilename(agent); - const targetPath = path.join(result.targetDir, filename); + + // Kimi format uses subdirectories per skill: /SKILL.md + let targetPath; + if (ideConfig.format === 'kimi-skill' && transformer.getDirname) { + const skillDir = path.join(result.targetDir, transformer.getDirname(agent)); + targetPath = path.join(skillDir, filename); + if (!options.dryRun) { + fs.ensureDirSync(skillDir); + } + } else { + targetPath = path.join(result.targetDir, filename); + } if (!options.dryRun) { fs.writeFileSync(targetPath, content, 'utf8'); @@ -375,7 +394,11 @@ async function commandValidate(options) { try { const content = transformer.transform(agent); const filename = transformer.getFilename(agent); - expectedFiles.push({ filename, content }); + // Kimi format stores each skill in /SKILL.md — record nested path + const relPath = ideConfig.format === 'kimi-skill' && transformer.getDirname + ? path.join(transformer.getDirname(agent), filename) + : filename; + expectedFiles.push({ filename: relPath, content }); } catch (error) { // Skip agents that fail to transform } diff --git a/.aiox-core/infrastructure/scripts/ide-sync/transformers/kimi.js b/.aiox-core/infrastructure/scripts/ide-sync/transformers/kimi.js new file mode 100644 index 0000000000..03a7cf7ba3 --- /dev/null +++ b/.aiox-core/infrastructure/scripts/ide-sync/transformers/kimi.js @@ -0,0 +1,328 @@ +/** + * Kimi Transformer - Skill-based agent activator + * @story Kimi IDE Integration + * + * Format: SKILL.md with YAML frontmatter + markdown instructions + * Target: .kimi/skills/aios-{agent-id}/SKILL.md + */ + +/** + * Transform agent data to Kimi skill format + * @param {object} agentData - Parsed agent data from agent-parser + * @returns {string} - Transformed SKILL.md content + */ +function transform(agentData) { + const rawAgent = agentData.agent || {}; + const fallbackAgent = agentData._fallback || {}; + const persona = agentData.persona_profile || {}; + const comm = persona.communication || {}; + const greetingLevels = comm.greeting_levels || {}; + const yaml = agentData.yaml || {}; + + const id = agentData.id; + const skillId = getSkillId(agentData); + const activationId = getPreferredActivationId(agentData); + const name = rawAgent.name || fallbackAgent.name || id; + const title = rawAgent.title || fallbackAgent.title || 'AIOS Agent'; + const icon = rawAgent.icon || fallbackAgent.icon || '🤖'; + const whenToUse = rawAgent.whenToUse || fallbackAgent.whenToUse || `Use for ${title.toLowerCase()} tasks`; + const archetype = persona.archetype || rawAgent.archetype || 'Specialist'; + const zodiac = persona.zodiac || rawAgent.zodiac || ''; + const tone = comm.tone || rawAgent.tone || 'professional'; + + const description = buildDescription(activationId, name, title, whenToUse); + const namedGreeting = greetingLevels.named || `${icon} ${name} ready`; + + // Extract rich sections from parsed YAML + const identitySection = buildIdentitySection(rawAgent, persona, yaml); + const protocolSection = buildProtocolSection(yaml); + const commandsTable = buildCommandsTable(agentData.commands); + const workflowSection = buildWorkflowSection(yaml); + const guardrailsSection = buildGuardrailsSection(yaml); + const handoffsSection = buildHandoffsSection(yaml); + const outputContractSection = buildOutputContractSection(yaml); + + // Full raw content + const rawContent = agentData.raw || ''; + const rawContentSection = buildRawContentSection(rawContent, id); + + const content = `--- +name: ${JSON.stringify(skillId)} +description: ${JSON.stringify(description)} +--- + +# ${icon} @${id} — ${name}${archetype !== 'Specialist' ? ` (${archetype})` : ''} | ${title} + +## Activation Protocol + +When this skill is invoked: + +1. Adopt the persona below immediately. Do NOT narrate the activation, do NOT comment on Kimi's mechanism, do NOT preface with internal reasoning. +2. Print the greeting verbatim from the next section. +3. List commands EXACTLY as they appear in the Star Commands table — do not summarize, do not invent shortcuts. +4. Wait for user input unless a star command was provided alongside the activation. + +## Activation Greeting + +\`\`\` +${namedGreeting} +\`\`\` + +${identitySection}${protocolSection}${commandsTable}${workflowSection}${guardrailsSection}${handoffsSection}${outputContractSection}--- + +## Full Agent Definition — ${id} + +> This section contains the COMPLETE operating guide for this agent. Read it ENTIRELY and adopt the persona, principles, protocols, and guardrails defined below. Do NOT invent tasks, processes, or workflows that are not documented here. + +${rawContentSection}`; + + return content; +} + +function buildIdentitySection(rawAgent, persona, yaml) { + const name = rawAgent.name || ''; + const role = persona.role || yaml.persona?.role || ''; + const style = persona.style || yaml.persona?.style || ''; + const focus = persona.focus || yaml.persona?.focus || ''; + const identity = persona.identity || yaml.persona?.identity || ''; + + if (!name && !role && !style && !focus && !identity) return ''; + + let section = '## Identity\n\n'; + if (name) section += `- **Name:** ${name}\n`; + if (role) section += `- **Role:** ${role}\n`; + if (style) section += `- **Style:** ${style}\n`; + if (focus) section += `- **Focus:** ${focus}\n`; + if (identity) section += `- **Identity:** ${identity}\n`; + section += '\n'; + return section; +} + +function buildProtocolSection(yaml) { + const items = []; + + if (yaml.cognitive_protocol && Array.isArray(yaml.cognitive_protocol)) { + items.push('### Cognitive Protocol\n\n' + yaml.cognitive_protocol.map(p => `- ${renderItem(p)}`).join('\n') + '\n'); + } + + if (yaml.evidence_policy) { + const ep = yaml.evidence_policy; + let text = '### Evidence Policy\n\n'; + if (ep.required_min_sources) text += `- Required minimum sources: ${ep.required_min_sources}\n`; + if (ep.accepted_types && Array.isArray(ep.accepted_types)) text += `- Accepted types: ${ep.accepted_types.join(', ')}\n`; + if (ep.reject_if && Array.isArray(ep.reject_if)) text += `- Reject if: ${ep.reject_if.map(r => `"${r}"`).join(', ')}\n`; + items.push(text + '\n'); + } + + if (yaml.confidence_model) { + const cm = yaml.confidence_model; + let text = '### Confidence Model\n\n'; + if (cm.formula) text += `- Formula: \`${cm.formula}\`\n`; + if (cm.thresholds) { + text += '- Thresholds:\n'; + for (const [k, v] of Object.entries(cm.thresholds)) text += ` - ${k}: ${v}\n`; + } + items.push(text + '\n'); + } + + if (yaml.core_principles && Array.isArray(yaml.core_principles)) { + items.push('### Core Principles\n\n' + yaml.core_principles.map(p => `- ${renderItem(p)}`).join('\n') + '\n'); + } + + return items.length > 0 ? items.join('\n') + '\n' : ''; +} + +function buildCommandsTable(commands) { + if (!commands || !Array.isArray(commands) || commands.length === 0) { + return ''; + } + + const normalized = commands.map(cmd => { + if (typeof cmd === 'string') { + const dashMatch = cmd.trim().match(/^\*?([a-zA-Z0-9:_-]+)\s*[-:]\s*(.+)$/); + if (dashMatch) { + return { name: dashMatch[1], description: dashMatch[2], visibility: ['full', 'quick'] }; + } + return { name: cmd.replace(/^\*/, '') || 'unknown', description: 'No description', visibility: ['full', 'quick'] }; + } + return cmd; + }); + + const allRows = normalized.map(cmd => { + let vis = 'full'; + if (cmd.visibility) { + if (Array.isArray(cmd.visibility)) { + vis = cmd.visibility.join(', '); + } else if (typeof cmd.visibility === 'string') { + vis = cmd.visibility; + } + } + return `| \`*${cmd.name}\` | ${cmd.description || 'No description'} | ${vis} |`; + }); + + // Always include guide/yolo/exit + const hasGuide = normalized.some(c => c.name === 'guide'); + const hasYolo = normalized.some(c => c.name === 'yolo'); + const hasExit = normalized.some(c => c.name === 'exit'); + + if (!hasGuide) allRows.push('| `*guide` | Show comprehensive usage guide | full |'); + if (!hasYolo) allRows.push('| `*yolo` | Toggle permission mode | full |'); + if (!hasExit) allRows.push('| `*exit` | Exit agent mode | full |'); + + return `## Star Commands\n\n| Command | Description | Visibility |\n|---------|-------------|------------|\n${allRows.join('\n')}\n\n`; +} + +function buildWorkflowSection(yaml) { + const items = []; + + if (yaml.mandatory_flow && Array.isArray(yaml.mandatory_flow)) { + items.push('### Mandatory Flow\n\nExecute **strictly in this order**. No skips allowed.\n\n' + + yaml.mandatory_flow.map((step, i) => `${i + 1}. ${renderItem(step)}`).join('\n') + '\n'); + } + + if (yaml.phase_release_rules && Array.isArray(yaml.phase_release_rules)) { + items.push('### Phase Release Rules\n\n' + yaml.phase_release_rules.map(r => `- ${renderItem(r)}`).join('\n') + '\n'); + } + + if (yaml.activation_instructions && Array.isArray(yaml.activation_instructions)) { + items.push('### Activation Instructions\n\n' + yaml.activation_instructions.map(i => `- ${renderItem(i)}`).join('\n') + '\n'); + } + + return items.length > 0 ? '## Workflow\n\n' + items.join('\n') + '\n' : ''; +} + +function buildGuardrailsSection(yaml) { + const items = []; + + if (yaml.veto_conditions && Array.isArray(yaml.veto_conditions)) { + items.push('### Veto Conditions\n\n' + yaml.veto_conditions.map(v => `- ❌ ${renderItem(v)}`).join('\n') + '\n'); + } + + if (yaml.agent_rules && Array.isArray(yaml.agent_rules)) { + items.push('### Agent Rules\n\n' + yaml.agent_rules.map(r => `- ${renderItem(r)}`).join('\n') + '\n'); + } + + if (yaml.design_rules) { + const dr = yaml.design_rules; + let text = '### Design Rules\n\n'; + for (const [key, value] of Object.entries(dr)) { + if (typeof value === 'object' && value.rule) { + text += `- **${key}:** ${value.rule}\n`; + } else if (typeof value === 'string') { + text += `- **${key}:** ${value}\n`; + } + } + items.push(text + '\n'); + } + + return items.length > 0 ? '## Guardrails\n\n' + items.join('\n') + '\n' : ''; +} + +function buildHandoffsSection(yaml) { + if (!yaml.handoffs || !Array.isArray(yaml.handoffs) || yaml.handoffs.length === 0) { + return ''; + } + + const rows = yaml.handoffs.map(h => { + const to = h.to || h.target || 'unknown'; + const when = h.when || h.condition || ''; + return `- **→ @${to}:** ${when}`; + }).join('\n'); + + return `## Handoffs\n\n${rows}\n\n`; +} + +function buildOutputContractSection(yaml) { + if (!yaml.output_contract && !yaml.output) return ''; + + const oc = yaml.output_contract || yaml.output; + let text = '## Output Contract\n\n'; + + if (oc.required && Array.isArray(oc.required)) { + text += '**Required deliverables:**\n\n' + oc.required.map(r => `- ${r}`).join('\n') + '\n\n'; + } + + if (oc.done_when && Array.isArray(oc.done_when)) { + text += '**Done when:**\n\n' + oc.done_when.map(d => `- ✅ ${d}`).join('\n') + '\n\n'; + } + + return text; +} + +function buildDescription(id, name, title, whenToUse) { + let desc = whenToUse + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (desc.length > 300) { + desc = desc.substring(0, 297) + '...'; + } + + const triggerPhrases = [ + `activate ${id}`, + `switch to ${id}`, + `@${id}`, + ]; + + return `Activate the AIOS ${title} agent (${name}). ${desc} Trigger when user asks to ${id}, or says '${triggerPhrases.join("', '")}'.`; +} + +function buildRawContentSection(rawContent, id) { + if (!rawContent || rawContent.length === 0) { + return '> Agent definition not available.\n'; + } + + return rawContent; +} + +function capitalizeFirst(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +// Normalize an array item that may be a string OR a YAML-parsed object. +// `- CRITICAL: text` parses to { CRITICAL: 'text' } and must render as +// `**CRITICAL:** text`, not `[object Object]`. +function renderItem(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value !== 'object') return String(value); + + const entries = Object.entries(value); + if (entries.length === 0) return ''; + + return entries + .map(([k, v]) => { + const rendered = typeof v === 'string' ? v : JSON.stringify(v); + return `**${k}:** ${rendered}`; + }) + .join(' '); +} + +function getPreferredActivationId(agentData) { + const agent = agentData.agent || {}; + const preferred = agent.preferredActivationAlias || agent.preferred_activation_alias; + return String(preferred || agentData.id || '').trim(); +} + +function getSkillId(agentData) { + const id = getPreferredActivationId(agentData); + if (id.startsWith('aios-')) return id; + return `aios-${id}`; +} + +function getDirname(agentData) { + return getSkillId(agentData); +} + +function getFilename(_agentData) { + return 'SKILL.md'; +} + +module.exports = { + transform, + getSkillId, + getDirname, + getFilename, + format: 'kimi-skill', +}; diff --git a/.aiox-core/infrastructure/scripts/ide-sync/validator.js b/.aiox-core/infrastructure/scripts/ide-sync/validator.js index 66c04472bf..d225c639a7 100644 --- a/.aiox-core/infrastructure/scripts/ide-sync/validator.js +++ b/.aiox-core/infrastructure/scripts/ide-sync/validator.js @@ -28,6 +28,29 @@ function fileExists(filePath) { return fs.existsSync(filePath); } +/** + * Recursively collect *.md filenames under root, returned as paths relative to base. + * Used for IDE targets with nested layouts (e.g. Kimi: /SKILL.md). + */ +function walkMarkdown(root, base) { + const out = []; + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch (e) { + return out; + } + for (const entry of entries) { + const full = path.join(root, entry.name); + if (entry.isDirectory()) { + out.push(...walkMarkdown(full, base)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(path.relative(base, full)); + } + } + return out; +} + /** * Read file content if it exists * @param {string} filePath - Path to read @@ -113,10 +136,11 @@ function validateIdeSync(expectedFiles, targetDir, redirectsConfig) { } } - // Check for orphaned files (files in target not in expected) + // Check for orphaned files (files in target not in expected). + // Recursive walk so nested layouts like Kimi's /SKILL.md are handled. if (fs.existsSync(targetDir)) { try { - const actualFiles = fs.readdirSync(targetDir).filter(f => f.endsWith('.md')); + const actualFiles = walkMarkdown(targetDir, targetDir); for (const file of actualFiles) { if (!expectedFilenames.has(file)) { diff --git a/tests/ide-sync/kimi-transformer.test.js b/tests/ide-sync/kimi-transformer.test.js new file mode 100644 index 0000000000..965182bcf0 --- /dev/null +++ b/tests/ide-sync/kimi-transformer.test.js @@ -0,0 +1,92 @@ +/** + * Tests for the Kimi transformer (kimi-skill format). + * Covers: + * - skillId normalization (no double aios- prefix) + * - preferredActivationAlias support + * - YAML object items in array fields render as **KEY:** value (not [object Object]) + * - Activation Protocol directive is present + * - getDirname / getFilename for nested layout + */ + +const path = require('path'); +const kimi = require(path.resolve( + __dirname, + '..', + '..', + '.aiox-core', + 'infrastructure', + 'scripts', + 'ide-sync', + 'transformers', + 'kimi' +)); + +function buildAgentData(overrides = {}) { + return { + id: overrides.id || 'dev', + agent: { + name: 'Dex', + title: 'Full Stack Developer', + icon: '💻', + whenToUse: 'Use for code implementation and refactoring', + ...overrides.agent, + }, + persona_profile: { + archetype: 'Builder', + communication: { + greeting_levels: { named: '💻 Dex (Builder) ready' }, + tone: 'pragmatic', + }, + ...overrides.persona_profile, + }, + yaml: overrides.yaml || {}, + commands: overrides.commands || [ + { name: 'help', description: 'Show all commands', visibility: ['full'] }, + ], + raw: overrides.raw || '', + }; +} + +describe('kimi transformer', () => { + test('normalizes skill id without double aios- prefix', () => { + expect(kimi.getSkillId({ id: 'dev', agent: {} })).toBe('aios-dev'); + expect(kimi.getSkillId({ id: 'aios-master', agent: {} })).toBe('aios-master'); + }); + + test('respects preferredActivationAlias', () => { + const skillId = kimi.getSkillId({ + id: 'davi-ribas-community-growth-strategist', + agent: { preferredActivationAlias: 'davi-ribas' }, + }); + expect(skillId).toBe('aios-davi-ribas'); + }); + + test('renders YAML object items in arrays without [object Object]', () => { + const data = buildAgentData({ + yaml: { + core_principles: [ + { CRITICAL: 'Story has ALL info you need.' }, + 'Numbered Options - Always use numbered lists', + ], + }, + }); + const out = kimi.transform(data); + expect(out).not.toMatch(/\[object Object\]/); + expect(out).toMatch(/\*\*CRITICAL:\*\* Story has ALL info you need\./); + expect(out).toMatch(/Numbered Options - Always use numbered lists/); + }); + + test('emits Activation Protocol directive', () => { + const out = kimi.transform(buildAgentData()); + expect(out).toMatch(/## Activation Protocol/); + expect(out).toMatch(/Adopt the persona below immediately/); + expect(out).toMatch(/EXACTLY as they appear in the Star Commands table/); + }); + + test('produces nested layout: /SKILL.md', () => { + const data = buildAgentData({ id: 'dev' }); + expect(kimi.getFilename(data)).toBe('SKILL.md'); + expect(kimi.getDirname(data)).toBe('aios-dev'); + expect(kimi.format).toBe('kimi-skill'); + }); +});