diff --git a/README.md b/README.md index ec80f47..6d89562 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Typed source and adapter compiler for coding-agent surfaces. -Current implementation: global command, rule, skill, prompt, and plugin-package compiler for Claude Code, Codex, Gemini CLI, Cline, Antigravity, Cursor, GitHub Copilot, VS Code, OpenCode, and Trae. Planned surfaces still include richer hooks, MCP config, ignores, and first-class subagent bundles. +Current implementation: global command, rule, skill, prompt, and plugin-package compiler for Claude Code, Codex, Gemini CLI, Cline, Kilo, Antigravity, Cursor, GitHub Copilot, VS Code, OpenCode, and Trae. Planned surfaces still include richer hooks, MCP config, ignores, and first-class subagent bundles. ## Model @@ -32,6 +32,7 @@ In scope: - Codex - Gemini CLI - Cline +- Kilo - Antigravity - OpenCode, behind primitive verification - VS Code generic settings @@ -47,64 +48,70 @@ Out of scope: - Positron - Void -## Commands +## Quick Start ```bash npm run inventory npm run check -npm run check:commands -npm run check:generated -npm run check:rules npm run test npm run doctor -npm run build -- --target cline --dry-run -npm run build -- --target claude-code --dry-run -npm run build -- --target codex --dry-run -npm run build -- --target cursor --dry-run -npm run build -- --target opencode --dry-run -npm run build -- --target antigravity --dry-run -npm run build -- --target gemini-cli --dry-run -node scripts/agent-surface.mjs build --target gemini-cli --pack all --dry-run -node scripts/agent-surface.mjs build --target cline --pack destructive --dry-run -node scripts/agent-surface.mjs commands --json -node scripts/agent-surface.mjs commands --phase ship --json -node scripts/agent-surface.mjs commands --risk writes --json -node scripts/agent-surface.mjs commands --pack all --json -node scripts/agent-surface.mjs check -node scripts/agent-surface.mjs check commands -node scripts/agent-surface.mjs check generated -node scripts/agent-surface.mjs check generated --target gemini-cli -node scripts/agent-surface.mjs install --target cline --pack default --scope project --dry-run -node scripts/agent-surface.mjs install --target cline --pack default --scope user --dry-run +npm run build -- --target all +``` + +Use dry-run before live installs: + +```bash node scripts/agent-surface.mjs install --target claude-code --scope user --dry-run node scripts/agent-surface.mjs install --target codex --scope user --dry-run +node scripts/agent-surface.mjs install --target gemini-cli --scope user --dry-run +node scripts/agent-surface.mjs install --target cline --scope user --dry-run +node scripts/agent-surface.mjs install --target kilo --scope user --dry-run node scripts/agent-surface.mjs install --target cursor --scope user --dry-run -node scripts/agent-surface.mjs install --target copilot --scope user --dry-run node scripts/agent-surface.mjs install --target vscode --scope user --dry-run +node scripts/agent-surface.mjs install --target copilot --scope user --dry-run node scripts/agent-surface.mjs install --target opencode --scope user --dry-run node scripts/agent-surface.mjs install --target trae --scope user --dry-run node scripts/agent-surface.mjs install --target antigravity --scope user --dry-run -node scripts/agent-surface.mjs install --target gemini-cli --scope project --dry-run -node scripts/agent-surface.mjs install --target cline --dest /tmp/agent-surface-cline -node scripts/agent-surface.mjs install --target cline --scope project --allow-scope-root -node scripts/agent-surface.mjs run --task T1 --class build_test --timeout 120000 --out .agent-surface/workflows//rounds/round-001/evidence/T1 -- npm test -node scripts/agent-surface.mjs workflow patch begin --run --round 1 --task T1 --file src/example.ts -node scripts/agent-surface.mjs workflow patch end --run --round 1 --task T1 -node scripts/agent-surface.mjs workflow patch verify --run --round 1 --task T1 ``` -Rule scenario checks: +`install` prints the files it will write, stale managed files it will remove, blocked paths, and the manifest path that tracks generated files. Live writes require explicit `--dest` or `--allow-scope-root` after reviewing the dry run. Existing unmanaged files block the install. Managed files changed since the last manifest block the install. Overwrites and stale managed removals are backed up under `.agent-surface/backups/`. + +## CLI Reference + +Common checks: ```bash -node scripts/agent-surface.mjs check rules --scenario python-source -node scripts/agent-surface.mjs check rules --scenario python-tooling -node scripts/agent-surface.mjs check rules --scenario rust-source -node scripts/agent-surface.mjs check rules --scenario go-ci -node scripts/agent-surface.mjs check rules --scenario typescript-eslint -node scripts/agent-surface.mjs check rules --scenario shell-script +npm run check +npm run check:commands +npm run check:generated +npm run check:rules +npm test ``` -`install` prints the files it will write, stale managed files it will remove, blocked paths, and the manifest path that tracks generated files. Live writes require explicit `--dest` or `--allow-scope-root` after reviewing the dry run. Existing unmanaged files block the install. Managed files changed since the last manifest block the install. Overwrites and stale managed removals are backed up under `.agent-surface/backups/`. +Registry and pack inspection: + +```bash +node scripts/agent-surface.mjs commands --json +node scripts/agent-surface.mjs commands --phase ship --json +node scripts/agent-surface.mjs commands --risk writes --json +node scripts/agent-surface.mjs commands --pack all --json +``` + +Pack-specific rendering: + +```bash +node scripts/agent-surface.mjs build --target gemini-cli --pack all --dry-run +node scripts/agent-surface.mjs build --target cline --pack destructive --dry-run +``` + +Workflow evidence helpers: + +```bash +node scripts/agent-surface.mjs run --task T1 --class build_test --timeout 120000 --out .agent-surface/workflows//rounds/round-001/evidence/T1 -- npm test +node scripts/agent-surface.mjs workflow patch begin --run --round 1 --task T1 --file src/example.ts +node scripts/agent-surface.mjs workflow patch end --run --round 1 --task T1 +node scripts/agent-surface.mjs workflow patch verify --run --round 1 --task T1 +``` `run` requires explicit approval for `network`, `filesystem_destructive`, `deployment`, and `database_mutation` command classes through `--approved ` or `AGENT_SURFACE_APPROVED_CLASSES`. @@ -116,6 +123,7 @@ Global target surfaces currently generated: - Codex: `~/.agents/skills//SKILL.md`, per-skill `agents/openai.yaml`, and `~/.codex/AGENTS.md`. - Gemini CLI: `~/.gemini/commands/**`, `~/.gemini/GEMINI.md`, and `~/.gemini/extensions/agent-surface/`. - Cline: `~/Documents/Cline/Workflows/*.md` and `~/Documents/Cline/Rules/agent-surface.md` for user scope; project scope still writes `.clinerules/`. +- Kilo: `~/.config/kilo/commands/*.md`, `~/.config/kilo/AGENTS.md`, `~/.config/kilo/rules/agent-surface.md`, and a safe `kilo.jsonc.instructions` merge for user scope; project scope writes `.kilo/commands/*.md`, root `AGENTS.md`, `.kilo/rules/agent-surface.md`, and project `kilo.jsonc`. - Antigravity: `~/.gemini/antigravity/global_workflows/*.md`. - Cursor: `~/.cursor/commands/*.md` and `~/.cursor/rules/*.mdc`. - GitHub Copilot: VS Code user-profile `instructions/agent-surface-copilot.instructions.md`. @@ -139,6 +147,16 @@ Use `agent-surface workflow patch begin/end/verify` around each task so `patch_r `workflow doctor` validates workflow role artifacts, event hash chains, and patch manifests, including patch refs and patch content hashes. +Experimental loop shape: + +```text +Codex plans and verifies the workflow. +Claude Code owns Claude-native subagent creation and delegation. +Local Ollama-backed Claude sessions can be launched with `ollama launch claude --model `. +Use worktrees when multiple workers may edit files. +Keep Codex as the final reviewer before `ship-commit`. +``` + GitHub install smoke path: ```bash @@ -153,4 +171,5 @@ Use the managed global installs in normal work, then tighten target-specific val 1. Claude Code: test standalone commands and the generated plugin with `claude --plugin-dir`. 2. Gemini CLI: verify `/commands reload`, extension discovery, and `~/.gemini/GEMINI.md` loading. 3. Cline: verify global Workflows and Rules appear in the UI. -4. Cursor, Copilot, VS Code, OpenCode, and Trae: verify the generated global instruction files are actually attached in live sessions. +4. Kilo: verify global commands and the `kilo.jsonc` Rules entry attach in the extension or CLI. +5. Cursor, Copilot, VS Code, OpenCode, and Trae: verify the generated global instruction files are actually attached in live sessions. diff --git a/adapters/kilo/README.md b/adapters/kilo/README.md new file mode 100644 index 0000000..ae2092d --- /dev/null +++ b/adapters/kilo/README.md @@ -0,0 +1,19 @@ +# Kilo adapter + +Current implementation renders command sources to Kilo workflow files and generated instructions. + +Implemented target paths: + +- user: `~/.config/kilo/commands/*.md` +- user: `~/.config/kilo/AGENTS.md` +- user: `~/.config/kilo/rules/agent-surface.md` +- user merge: `~/.config/kilo/kilo.jsonc` `instructions += "./rules/agent-surface.md"` +- project: `.kilo/commands/*.md` +- project: `AGENTS.md` +- project: `.kilo/rules/agent-surface.md` +- project merge: `kilo.jsonc` `instructions += ".kilo/rules/agent-surface.md"` +- custom: any reviewed `--dest` path + +Kilo workflows are Markdown slash commands. Kilo automatically loads `AGENTS.md`, but the extension Rules UI is backed by the `instructions` array in `kilo.jsonc`, so the installer merges only that array entry and preserves existing config keys. + +This adapter does not mutate MCP config, providers, or tool permissions. diff --git a/registry/targets.json b/registry/targets.json index 7bf582b..1fbaf9f 100644 --- a/registry/targets.json +++ b/registry/targets.json @@ -38,6 +38,15 @@ "rules" ] }, + "kilo": { + "status": "implemented", + "build_supported": true, + "install_supported": true, + "renders": [ + "commands-as-workflows", + "rules" + ] + }, "antigravity": { "status": "implemented", "build_supported": true, diff --git a/scripts/agent-surface.mjs b/scripts/agent-surface.mjs index 5e8256f..e459373 100755 --- a/scripts/agent-surface.mjs +++ b/scripts/agent-surface.mjs @@ -43,6 +43,13 @@ const targets = { installRoot: installRootCline, staticOutputs: clineStaticOutputs, }, + kilo: { + label: "Kilo workflows and instructions", + commandOutputRoot: kiloWorkflowRoot, + renderCommand: renderKiloWorkflow, + installRoot: installRootKilo, + staticOutputs: kiloStaticOutputs, + }, antigravity: { label: "Antigravity workflows", commandOutputRoot: "global_workflows", @@ -630,6 +637,9 @@ function validateGeneratedTarget(target, outputs) { } else if (target === "cline") { requirePath(path.join("Documents", "Cline", "Workflows", "flow.md")); requireContains(path.join("Documents", "Cline", "Rules", "agent-surface.md"), /agent-surface Cline global rules/); + } else if (target === "kilo") { + requirePath(path.join(".config", "kilo", "commands", "flow.md")); + requireContains(path.join(".config", "kilo", "AGENTS.md"), /agent-surface Kilo rules/); } else if (target === "antigravity") { requireContains(path.join("global_workflows", "flow.md"), /^---\ndescription: "/); } else if (target === "cursor") { @@ -797,6 +807,11 @@ async function installPlan(target, adapter, installRoot, scope, pack, rootSource : []; const staleRemovals = staleManaged.map((item) => item.output); const staleRemovalActions = []; + const configMerges = target === "kilo" ? [await prepareKiloConfigMerge(kiloConfigMerge(installRoot, scope))] : []; + + for (const item of configMerges) { + if (item.action === "blocked") blocked.push(item.error); + } for (const item of staleManaged) { if (!isSafeRelativePath(item.output)) { @@ -840,6 +855,7 @@ async function installPlan(target, adapter, installRoot, scope, pack, rootSource writes, staleRemovals, staleRemovalActions, + configMerges, blocked, manifest, }; @@ -863,6 +879,14 @@ function printInstallPlan(plan) { } console.log("planned manifest:"); console.log(` ${path.relative(plan.installRoot, plan.manifestPath)}`); + console.log("planned config merges:"); + if (plan.configMerges.length === 0) { + console.log(" none"); + } else { + for (const item of plan.configMerges) { + console.log(` ${item.relativeOutput} instructions += ${item.instruction}`); + } + } console.log("blocked:"); if (plan.blocked.length === 0) { console.log(" none"); @@ -877,6 +901,7 @@ async function applyInstallPlan(plan) { let skipped = 0; let removed = 0; let backups = 0; + let configMerges = 0; await mkdir(plan.installRoot, { recursive: true }); @@ -909,6 +934,12 @@ async function applyInstallPlan(plan) { backups += 1; } + for (const item of plan.configMerges) { + const result = await applyKiloConfigMerge(plan.installRoot, backupRoot, item); + backups += result.backup ? 1 : 0; + configMerges += result.changed ? 1 : 0; + } + await mkdir(path.dirname(plan.manifestPath), { recursive: true }); await writeFile(plan.manifestPath, `${JSON.stringify(plan.manifest, null, 2)}\n`); @@ -916,9 +947,69 @@ async function applyInstallPlan(plan) { console.log(` wrote: ${written}`); console.log(` skipped unchanged: ${skipped}`); console.log(` removed stale: ${removed}`); + console.log(` config merges: ${configMerges}`); console.log(` backups: ${backups === 0 ? "none" : path.relative(plan.installRoot, backupRoot)}`); } +function kiloConfigMerge(installRoot, scope) { + const relativeOutput = scope === "user" ? path.join(".config", "kilo", "kilo.jsonc") : "kilo.jsonc"; + const instruction = scope === "user" ? "./rules/agent-surface.md" : ".kilo/rules/agent-surface.md"; + return { + output: path.join(installRoot, relativeOutput), + relativeOutput, + instruction, + }; +} + +async function applyKiloConfigMerge(installRoot, backupRoot, merge) { + if (merge.action === "skip") return { changed: false, backup: false }; + if (merge.action === "blocked") fail(merge.error); + const existing = await readFileIfExists(merge.output); + if (existing !== null) await backupExisting(installRoot, backupRoot, merge.output); + await mkdir(path.dirname(merge.output), { recursive: true }); + await writeFile(merge.output, merge.content); + return { changed: true, backup: existing !== null }; +} + +async function prepareKiloConfigMerge(merge) { + if (!isSafeRelativePath(merge.relativeOutput)) { + return { ...merge, action: "blocked", error: `unsafe Kilo config path: ${merge.relativeOutput}` }; + } + + const existing = await readFileIfExists(merge.output); + if (existing === null) { + return { + ...merge, + action: "write", + content: `${JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + instructions: [merge.instruction], + }, null, 2)}\n`, + }; + } + + const text = existing.toString("utf8"); + const parsed = parseJsoncResult(text); + if (!parsed.ok) { + return { ...merge, action: "blocked", error: `${merge.relativeOutput}: invalid JSONC: ${parsed.error.message}` }; + } + if (parsed.value === null || typeof parsed.value !== "object" || Array.isArray(parsed.value)) { + return { ...merge, action: "blocked", error: `${merge.relativeOutput}: config must be an object` }; + } + + const instructions = parsed.value.instructions ?? []; + if (!Array.isArray(instructions)) { + return { ...merge, action: "blocked", error: `${merge.relativeOutput}: instructions must be an array` }; + } + if (!instructions.every((item) => typeof item === "string")) { + return { ...merge, action: "blocked", error: `${merge.relativeOutput}: instructions must contain only strings` }; + } + if (instructions.includes(merge.instruction)) return { ...merge, action: "skip" }; + + const content = mergeKiloInstructionJsonc(text, merge.instruction); + return { ...merge, action: "merge", content }; +} + async function backupExisting(installRoot, backupRoot, file) { const relativeOutput = path.relative(installRoot, file); if (!isSafeRelativePath(relativeOutput)) fail(`refusing to back up unsafe path: ${relativeOutput}`); @@ -943,6 +1034,8 @@ async function doctor() { checks.push(["gemini", commandVersion("gemini", ["--version"])]); checks.push(["claude", commandVersion("claude", ["--version"])]); checks.push(["codex", commandVersion("codex", ["--version"])]); + checks.push(["kilo", commandVersion("kilo", ["--version"])]); + checks.push(["kilo-config", await kiloConfigStatus()]); checks.push(["opencode", commandVersion("opencode", ["--version"])]); checks.push(["gh", commandVersion("gh", ["--version"])]); @@ -1512,6 +1605,10 @@ async function renderClineWorkflow(source) { return source.body; } +async function renderKiloWorkflow(source) { + return source.body; +} + async function renderClaudeCommand(source) { return source.body; } @@ -1646,6 +1743,21 @@ async function clineStaticOutputs(_commands, context) { ]; } +async function kiloStaticOutputs(_commands, context) { + return [ + { + source: "rules/*.mdc", + relativeOutput: kiloInstructionPath(context), + content: await renderInstructionDocument("AGENTS.md - agent-surface Kilo rules", "Kilo instructions"), + }, + { + source: "rules/*.mdc", + relativeOutput: kiloRulePath(context), + content: await renderInstructionDocument("agent-surface Kilo discoverable rules", "Kilo custom rules"), + }, + ]; +} + async function geminiStaticOutputs(commands) { const metadata = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); return [ @@ -1908,6 +2020,267 @@ function parseFrontmatterScalar(value) { return trimmed; } +function parseJsonc(text, label) { + const result = parseJsoncResult(text); + if (result.ok) return result.value; + fail(`${label}: invalid JSONC: ${result.error.message}`); +} + +function parseJsoncResult(text) { + try { + return { ok: true, value: JSON.parse(stripJsonc(text)) }; + } catch (error) { + return { ok: false, error }; + } +} + +function stripJsonc(text) { + return removeJsonTrailingCommas(removeJsoncComments(text)); +} + +function removeJsoncComments(text) { + let out = ""; + let inString = false; + let quote = ""; + let escaped = false; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + const next = text[index + 1]; + + if (inString) { + out += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + inString = false; + quote = ""; + } + continue; + } + + if (char === '"' || char === "'") { + inString = true; + quote = char; + out += char; + continue; + } + + if (char === "/" && next === "/") { + while (index < text.length && text[index] !== "\n") index += 1; + out += "\n"; + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1; + index += 1; + continue; + } + + out += char; + } + + return out; +} + +function removeJsonTrailingCommas(text) { + let out = ""; + let inString = false; + let escaped = false; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + + if (inString) { + out += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + + if (char === "\"") { + inString = true; + out += char; + continue; + } + + if (char === ",") { + let nextIndex = index + 1; + while (/\s/.test(text[nextIndex] ?? "")) nextIndex += 1; + if (text[nextIndex] === "}" || text[nextIndex] === "]") continue; + } + + out += char; + } + + return out; +} + +function mergeKiloInstructionJsonc(text, instruction) { + const tokens = jsoncTokens(text); + const instructionsRange = findJsoncPropertyArray(tokens, "instructions"); + if (instructionsRange) { + return insertJsoncArrayString(text, instructionsRange, instruction); + } + return insertJsoncRootProperty(text, tokens, "instructions", [instruction]); +} + +function insertJsoncArrayString(text, range, value) { + const valueJson = JSON.stringify(value); + const openLineStart = lineStart(text, range.open.start); + const closeLineStart = lineStart(text, range.close.start); + if (openLineStart === closeLineStart) { + return insertInlineJsoncArrayString(text, range, valueJson); + } + const closeIndent = text.slice(closeLineStart, range.close.start); + const valueIndent = `${closeIndent} `; + const items = range.tokens.filter((token) => token.type !== ","); + if (items.length === 0) { + return `${text.slice(0, closeLineStart)}${valueIndent}${valueJson}\n${text.slice(closeLineStart)}`; + } + + const lastToken = range.tokens.at(-1); + if (lastToken?.type === ",") { + return `${text.slice(0, closeLineStart)}${valueIndent}${valueJson}\n${text.slice(closeLineStart)}`; + } + + return `${text.slice(0, lastToken.end)},${text.slice(lastToken.end, closeLineStart)}${valueIndent}${valueJson}\n${text.slice(closeLineStart)}`; +} + +function insertInlineJsoncArrayString(text, range, valueJson) { + const items = range.tokens.filter((token) => token.type !== ","); + if (items.length === 0) { + return `${text.slice(0, range.open.end)}${valueJson}${text.slice(range.close.start)}`; + } + + const lastToken = range.tokens.at(-1); + const separator = lastToken?.type === "," ? " " : ", "; + const insertAt = lastToken?.type === "," ? lastToken.end : lastToken?.end ?? range.open.end; + return `${text.slice(0, insertAt)}${separator}${valueJson}${text.slice(insertAt)}`; +} + +function insertJsoncRootProperty(text, tokens, key, value) { + const rootOpen = tokens.find((token) => token.type === "{" && token.depth === 0); + const rootClose = tokens.findLast((token) => token.type === "}" && token.depth === 0); + if (!rootOpen || !rootClose) return `${JSON.stringify({ [key]: value }, null, 2)}\n`; + + const keyJson = JSON.stringify(key); + const valueJson = JSON.stringify(value, null, 2) + .split("\n") + .map((line, index) => (index === 0 ? line : ` ${line}`)) + .join("\n"); + const closeLineStart = lineStart(text, rootClose.start); + const closeIndent = text.slice(closeLineStart, rootClose.start); + const propIndent = `${closeIndent} `; + const property = `${propIndent}${keyJson}: ${valueJson}\n`; + const rootTokens = tokens.filter((token) => token.start > rootOpen.start && token.end <= rootClose.start); + + if (rootTokens.length === 0) { + return `${text.slice(0, closeLineStart)}${property}${text.slice(closeLineStart)}`; + } + + const lastToken = rootTokens.at(-1); + if (lastToken.type === ",") { + return `${text.slice(0, closeLineStart)}${property}${text.slice(closeLineStart)}`; + } + + return `${text.slice(0, lastToken.end)},${text.slice(lastToken.end, closeLineStart)}${property}${text.slice(closeLineStart)}`; +} + +function findJsoncPropertyArray(tokens, key) { + for (let index = 0; index < tokens.length - 2; index += 1) { + const keyToken = tokens[index]; + const colon = tokens[index + 1]; + const open = tokens[index + 2]; + if (keyToken.type !== "string" || keyToken.value !== key || keyToken.depth !== 1) continue; + if (colon.type !== ":" || open.type !== "[") continue; + + let depth = 0; + for (let closeIndex = index + 2; closeIndex < tokens.length; closeIndex += 1) { + const token = tokens[closeIndex]; + if (token.type === "[") depth += 1; + if (token.type === "]") depth -= 1; + if (depth === 0) { + return { + open, + close: token, + tokens: tokens.slice(index + 3, closeIndex), + }; + } + } + } + return null; +} + +function jsoncTokens(text) { + const tokens = []; + let depth = 0; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + const next = text[index + 1]; + + if (char === "/" && next === "/") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1; + index += 1; + continue; + } + + if (char === "\"") { + const start = index; + index += 1; + while (index < text.length) { + if (text[index] === "\\") { + index += 2; + continue; + } + if (text[index] === "\"") break; + index += 1; + } + const raw = text.slice(start, index + 1); + tokens.push({ type: "string", value: JSON.parse(raw), start, end: index + 1, depth }); + continue; + } + + if (char === "{" || char === "[") { + tokens.push({ type: char, start: index, end: index + 1, depth }); + depth += 1; + continue; + } + + if (char === "}" || char === "]") { + depth -= 1; + tokens.push({ type: char, start: index, end: index + 1, depth }); + continue; + } + + if (char === ":" || char === ",") { + tokens.push({ type: char, start: index, end: index + 1, depth }); + } + } + + return tokens; +} + +function lineStart(text, index) { + return text.lastIndexOf("\n", index - 1) + 1; +} + async function exportableCommands(pack) { if (!isSafeTargetName(pack)) fail(`unsafe command pack: ${pack}`); const commands = await readCommands(); @@ -2182,6 +2555,11 @@ async function readJsonIfExists(file) { return JSON.parse(await readFile(file, "utf8")); } +async function readJsoncIfExists(file) { + if (!(await exists(file))) return null; + return parseJsonc(await readFile(file, "utf8"), path.relative(root, file)); +} + async function readFileIfExists(file) { try { return await readFile(file); @@ -2451,6 +2829,10 @@ function installRootCline(scope) { return scope === "user" ? os.homedir() : process.cwd(); } +function installRootKilo(scope) { + return scope === "user" ? os.homedir() : process.cwd(); +} + function installRootAntigravity(scope) { if (scope !== "user") fail("antigravity install supports --scope user only unless --dest is supplied"); return path.join(os.homedir(), ".gemini", "antigravity"); @@ -2463,6 +2845,22 @@ function installRootVsCode(scope) { return path.join(os.homedir(), ".config", "Code", "User"); } +async function kiloConfigStatus() { + const configDir = path.join(os.homedir(), ".config", "kilo"); + if (!(await exists(configDir))) return "missing"; + + const metadata = await readJsonIfExists(path.join(configDir, "package.json")); + const pluginVersion = metadata?.dependencies?.["@kilocode/plugin"] ?? metadata?.devDependencies?.["@kilocode/plugin"]; + const config = await readJsoncIfExists(path.join(configDir, "kilo.jsonc")); + const instructions = Array.isArray(config?.instructions) ? config.instructions : []; + const markers = []; + if (pluginVersion) markers.push(`plugin ${pluginVersion}`); + if (await exists(path.join(configDir, "AGENTS.md"))) markers.push("AGENTS.md"); + if (await exists(path.join(configDir, "commands"))) markers.push("commands"); + if (instructions.includes("./rules/agent-surface.md")) markers.push("rules configured"); + return markers.length > 0 ? `present (${markers.join(", ")})` : "present"; +} + function clineWorkflowRoot(context) { return context.scope === "user" ? path.join("Documents", "Cline", "Workflows") : path.join(".clinerules", "workflows"); } @@ -2471,6 +2869,18 @@ function clineRuleRoot(context) { return context.scope === "user" ? path.join("Documents", "Cline", "Rules") : ".clinerules"; } +function kiloWorkflowRoot(context) { + return context.scope === "user" ? path.join(".config", "kilo", "commands") : path.join(".kilo", "commands"); +} + +function kiloInstructionPath(context) { + return context.scope === "user" ? path.join(".config", "kilo", "AGENTS.md") : "AGENTS.md"; +} + +function kiloRulePath(context) { + return context.scope === "user" ? path.join(".config", "kilo", "rules", "agent-surface.md") : path.join(".kilo", "rules", "agent-surface.md"); +} + function commandVersion(command, args) { const result = spawnSync(command, args, { encoding: "utf8" }); if (result.error) return "missing"; diff --git a/tests/agent-surface.test.mjs b/tests/agent-surface.test.mjs index 63cf530..ef66fa8 100644 --- a/tests/agent-surface.test.mjs +++ b/tests/agent-surface.test.mjs @@ -82,6 +82,7 @@ assert.equal(flowCommand.metadata_source, "frontmatter"); assert.equal(flowCommand.targets["claude-code"], path.join(".claude", "commands", "flow", "flow.md")); assert.equal(flowCommand.targets.codex, path.join(".agents", "skills", "flow", "SKILL.md")); assert.equal(flowCommand.targets.cline, path.join("Documents", "Cline", "Workflows", "flow.md")); +assert.equal(flowCommand.targets.kilo, path.join(".config", "kilo", "commands", "flow.md")); assert.equal(flowCommand.targets["gemini-cli"], path.join(".gemini", "commands", "flow", "flow.toml")); assert.equal(flowCommand.targets.cursor, path.join(".cursor", "commands", "flow.md")); const devFeatureCommand = defaultRegistry.commands.find((command) => command.name === "dev-feature"); @@ -139,7 +140,7 @@ for (const scenario of ["python-source", "python-tooling", "rust-source", "go-ci run(["build", "--target", "all"]); const generated = files(path.join(root, "dist")); -assert.equal(generated.length, 554); +assert.equal(generated.length, 615); assertGeminiTomlParses(); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "claude-code", ".claude", "commands", "flow", "flow.md"))), true); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "claude-code", ".claude", "commands", "boot", "facade.md"))), false); @@ -153,6 +154,9 @@ assert.equal(generated.some((file) => file.endsWith(path.join("dist", "gemini-cl assert.equal(generated.some((file) => file.endsWith(path.join("dist", "gemini-cli", ".gemini", "extensions", "agent-surface", "gemini-extension.json"))), true); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "claude-code", ".agent-surface", "claude-plugin", "agent-surface", ".claude-plugin", "plugin.json"))), true); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "cline", "Documents", "Cline", "Rules", "agent-surface.md"))), true); +assert.equal(generated.some((file) => file.endsWith(path.join("dist", "kilo", ".config", "kilo", "commands", "flow.md"))), true); +assert.equal(generated.some((file) => file.endsWith(path.join("dist", "kilo", ".config", "kilo", "AGENTS.md"))), true); +assert.equal(generated.some((file) => file.endsWith(path.join("dist", "kilo", ".config", "kilo", "rules", "agent-surface.md"))), true); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "cursor", ".cursor", "rules", "00-precedence-and-safety.mdc"))), true); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "copilot", "instructions", "agent-surface-copilot.instructions.md"))), true); assert.equal(generated.some((file) => file.endsWith(path.join("dist", "vscode", "instructions", "agent-surface.instructions.md"))), true); @@ -160,6 +164,7 @@ assert.equal(generated.some((file) => file.endsWith(path.join("dist", "opencode" assert.equal(generated.some((file) => file.endsWith(path.join("dist", "trae", ".trae", "user_rules.md"))), true); const generatedCheck = run(["check", "generated"]); assert.match(generatedCheck, /claude-code: generated outputs 120 ok/); +assert.match(generatedCheck, /kilo: generated outputs 61 ok/); assert.match(generatedCheck, /copilot: generated outputs 1 ok/); assert.match(generatedCheck, /generated check: ok/); const copilotGeneratedCheck = run(["check", "generated", "--target", "copilot"]); @@ -198,6 +203,14 @@ assert.match(clinePlan, /\.clinerules\/workflows\/workflow-boss\.md <- commands\ assert.match(clinePlan, /\.clinerules\/agent-surface\.md <- rules\/\*\.mdc/); assert.match(clinePlan, /\.agent-surface\/cline-manifest\.json/); +const kiloPlan = run(["install", "--target", "kilo", "--dest", "/tmp/agent-surface-kilo", "--dry-run"]); +assert.match(kiloPlan, /^target: kilo$/m); +assert.match(kiloPlan, /\.kilo\/commands\/workflow-boss\.md <- commands\/workflow-boss\.md/); +assert.match(kiloPlan, /AGENTS\.md <- rules\/\*\.mdc/); +assert.match(kiloPlan, /\.kilo\/rules\/agent-surface\.md <- rules\/\*\.mdc/); +assert.match(kiloPlan, /kilo\.jsonc instructions \+= \.kilo\/rules\/agent-surface\.md/); +assert.match(kiloPlan, /\.agent-surface\/kilo-manifest\.json/); + const destructivePlan = run(["install", "--target", "cline", "--pack", "destructive", "--dest", "/tmp/agent-surface-cline", "--dry-run"]); assert.match(destructivePlan, /^pack: destructive$/m); assert.match(destructivePlan, /\.clinerules\/workflows\/ops-nuke\.md <- commands\/ops-nuke\.md/); @@ -494,7 +507,59 @@ const clineUserScope = status(["install", "--target", "cline", "--scope", "user" assert.equal(clineUserScope.status, 0, `${clineUserScope.stdout}${clineUserScope.stderr}`); assert.match(clineUserScope.stdout, /Documents\/Cline\/Workflows\/workflow-boss\.md <- commands\/workflow-boss\.md/); -for (const target of ["cursor", "copilot", "vscode", "opencode", "trae"]) { +const kiloUserScope = status(["install", "--target", "kilo", "--scope", "user", "--dry-run"]); +assert.equal(kiloUserScope.status, 0, `${kiloUserScope.stdout}${kiloUserScope.stderr}`); +assert.match(kiloUserScope.stdout, /\.config\/kilo\/commands\/workflow-boss\.md <- commands\/workflow-boss\.md/); +assert.match(kiloUserScope.stdout, /\.config\/kilo\/AGENTS\.md <- rules\/\*\.mdc/); +assert.match(kiloUserScope.stdout, /\.config\/kilo\/rules\/agent-surface\.md <- rules\/\*\.mdc/); +assert.match(kiloUserScope.stdout, /\.config\/kilo\/kilo\.jsonc instructions \+= \.\/rules\/agent-surface\.md/); + +const invalidKiloDest = "/tmp/agent-surface-kilo-invalid"; +rmSync(invalidKiloDest, { recursive: true, force: true }); +mkdirSync(invalidKiloDest, { recursive: true }); +writeFileSync(path.join(invalidKiloDest, "kilo.jsonc"), "{\"instructions\":\"bad\"}\n"); +const invalidKiloInstall = status(["install", "--target", "kilo", "--dest", invalidKiloDest]); +assert.notEqual(invalidKiloInstall.status, 0); +assert.match(`${invalidKiloInstall.stdout}${invalidKiloInstall.stderr}`, /kilo\.jsonc: instructions must be an array/); +assert.equal(existsSync(path.join(invalidKiloDest, ".kilo")), false); +assert.equal(existsSync(path.join(invalidKiloDest, "AGENTS.md")), false); +assert.equal(existsSync(path.join(invalidKiloDest, ".agent-surface", "kilo-manifest.json")), false); +rmSync(invalidKiloDest, { recursive: true, force: true }); + +const existingKiloDest = "/tmp/agent-surface-kilo-existing"; +rmSync(existingKiloDest, { recursive: true, force: true }); +mkdirSync(existingKiloDest, { recursive: true }); +writeFileSync( + path.join(existingKiloDest, "kilo.jsonc"), + [ + "{", + " // keep this comment", + " \"instructions\": [", + " \"./existing-rule.md\",", + " ],", + " \"marker\": \",]\"", + "}", + "", + ].join("\n"), +); +run(["install", "--target", "kilo", "--dest", existingKiloDest]); +const mergedKiloConfig = readFileSync(path.join(existingKiloDest, "kilo.jsonc"), "utf8"); +assert.match(mergedKiloConfig, /\/\/ keep this comment/); +assert.match(mergedKiloConfig, /"marker": ",\]"/); +assert.match(mergedKiloConfig, /"\.\/existing-rule\.md"/); +assert.match(mergedKiloConfig, /"\.kilo\/rules\/agent-surface\.md"/); +rmSync(existingKiloDest, { recursive: true, force: true }); + +const inlineKiloDest = "/tmp/agent-surface-kilo-inline"; +rmSync(inlineKiloDest, { recursive: true, force: true }); +mkdirSync(inlineKiloDest, { recursive: true }); +writeFileSync(path.join(inlineKiloDest, "kilo.jsonc"), "{\"instructions\":[\"./existing-rule.md\"]}\n"); +run(["install", "--target", "kilo", "--dest", inlineKiloDest]); +const inlineKiloConfig = JSON.parse(readFileSync(path.join(inlineKiloDest, "kilo.jsonc"), "utf8")); +assert.deepEqual(inlineKiloConfig.instructions, ["./existing-rule.md", ".kilo/rules/agent-surface.md"]); +rmSync(inlineKiloDest, { recursive: true, force: true }); + +for (const target of ["cursor", "copilot", "vscode", "opencode", "trae", "kilo"]) { const targetDest = `/tmp/agent-surface-${target}-live`; rmSync(targetDest, { recursive: true, force: true }); const install = run(["install", "--target", target, "--dest", targetDest]); @@ -502,6 +567,10 @@ for (const target of ["cursor", "copilot", "vscode", "opencode", "trae"]) { const manifest = JSON.parse(readFileSync(path.join(targetDest, ".agent-surface", `${target}-manifest.json`), "utf8")); assert.equal(manifest.target, target); assert.equal(manifest.managed.length > 0, true); + if (target === "kilo") { + const kiloConfig = JSON.parse(readFileSync(path.join(targetDest, "kilo.jsonc"), "utf8")); + assert.deepEqual(kiloConfig.instructions, [".kilo/rules/agent-surface.md"]); + } rmSync(targetDest, { recursive: true, force: true }); }