Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ jobs:
contents: write
strategy:
fail-fast: false
# Four marketplace plus five flat — opencode is flat-only. Mirrors the CLI golden
# Four marketplace plus six flat — opencode and kilo are flat-only. Mirrors the CLI golden
# snapshot matrix.
matrix:
include:
Expand All @@ -147,6 +147,7 @@ jobs:
- { tool: copilot, mode: flat }
- { tool: codex, mode: flat }
- { tool: opencode, mode: flat }
- { tool: kilo, mode: flat }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ temp/
tmp/
setup-test/
tmp-test/
.aidd/cache/
aidd_docs/runs/
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of

## ✅ Prerequisites

- **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode (see [Compatibility](#-compatibility)).
- **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode / Kilo Code (see [Compatibility](#-compatibility)).
- **[Node](https://nodejs.org) 22 or later** on your `PATH`, only for the plugin that ships hooks ([what they do](docs/ARCHITECTURE.md#-bundled-hooks)); the workflows themselves are markdown and need nothing.

## 🔌 Compatibility
Expand All @@ -49,6 +49,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of
| **GitHub Copilot** | ✅ Supported | Marketplace · Flat |
| **Codex** | ✅ Supported | Marketplace · Flat |
| **OpenCode** | ✅ Supported | Flat |
| **Kilo Code** | ✅ Supported | Flat |
| **Gemini · Mistral** | 🚧 In progress | — |

<sub>**Marketplace** = installed and updated through your tool's plugin manager. **Flat** = files copied directly into your project, no plugin manager involved. Install steps per tool → [Other tools](#other-tools).</sub>
Expand Down Expand Up @@ -174,6 +175,16 @@ codex plugin add aidd-context@aidd-framework # per plugin

</details>

<details>
<summary><strong>Kilo Code</strong> — Flat only</summary>

1. Unzip the `kilo-flat` archive into your project root → `.kilo/` and `kilo.json`.
2. Start a new Kilo session so it loads the generated project plugin.

[Plugins documentation](https://kilo.ai/docs/automate/extending/plugins)

</details>

## 🚀 Quick start

Three ways in — pick one:
Expand Down
6 changes: 4 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
`@ai-driven-dev/cli` installs AI tool runtime configs, IDE integrations, and plugins from an AIDD marketplace into a project.
Every file it writes is hash-tracked in a manifest, so drift is detected and owned files can be restored.

Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode. Supported IDE: VS Code.
Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode, Kilo Code. Supported IDE: VS Code.
Requires Node.js >= 22.12, and `git` to fetch marketplace plugins.

## Install
Expand Down Expand Up @@ -136,8 +136,9 @@ Two output layouts, chosen by `--as`:
| `copilot` | yes | yes | `.github/` |
| `codex` | yes | yes | `.codex/` |
| `opencode` | no | yes | `.opencode/` |
| `kilo` | no | yes | `.kilo/` |

OpenCode declares no marketplace contract, so it is flat only. Every other target accepts both layouts.
OpenCode and Kilo Code declare no marketplace contract, so they are flat only. Every other target accepts both layouts.

## Environment variables

Expand Down Expand Up @@ -184,6 +185,7 @@ Per tool, the settings file the CLI writes:
| GitHub Copilot | Plugin recommendations in `.github/copilot/settings.json`, MCP servers in `.vscode/mcp.json`, plus `.vscode/settings.json` when the VS Code tool is installed too |
| Codex | `.codex/config.toml` |
| OpenCode | `opencode.json`, or `opencode.jsonc` when that is the one present |
| Kilo Code | `kilo.json`, or `kilo.jsonc` when that is the one present |
| VS Code | `.vscode/settings.json`, `.vscode/extensions.json`, `.vscode/keybindings.json` |

## More
Expand Down
1 change: 1 addition & 0 deletions cli/aidd_docs/memory/codebase-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ src/
│ │ │ ├── codex/
│ │ │ ├── copilot/
│ │ │ ├── cursor/
│ │ │ ├── kilo/
│ │ │ ├── opencode/
│ │ │ └── vscode/
│ │ └── infrastructure/
Expand Down
4 changes: 4 additions & 0 deletions cli/assets/configs/kilo/kilo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://app.kilo.ai/config.json",
"instructions": [".kilo/rules/**/*.md"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { AiToolId } from "../../../../../kernel/tool.js";
import type { MarketplaceRegistry } from "../../../../distribution/domain/ports/marketplace-registry.js";
import {
frameworkBuildModeFor,
getAiToolConfig,
resolvePluginsCapability,
} from "../../../../tools/domain/registry.js";
import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
Expand Down Expand Up @@ -158,7 +159,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const files: InstallationFile[] = [];
for (const abs of absPaths) {
const rel = posixRelative(builtDir, abs);
if (!this.belongsToPlugin(rel, name) && !hookPaths.has(rel)) continue;
if (!this.belongsToPlugin(rel, name, toolId) && !hookPaths.has(rel)) continue;
const content = await this.fs.readFile(abs);
files.push(
new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) })
Expand All @@ -167,9 +168,10 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
return files;
}

private belongsToPlugin(rel: string, name: string): boolean {
private belongsToPlugin(rel: string, name: string, toolId: AiToolId): boolean {
const segments = rel.split("/");
if (segments[0] !== ".opencode" || segments.length < 3) return false;
const toolDirectory = getAiToolConfig(toolId).directory.replace(/\/$/, "");
if (segments[0] !== toolDirectory || segments.length < 3) return false;
// `skills/` nests the whole plugin under one exactly-named segment; every other flat section
// hyphen-prefixes the leaf segment.
if (segments[1] === "skills") return segments[2] === name;
Expand All @@ -181,7 +183,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const flatHooksDir = plugins?.flatHooksDir;
if (plugins === null || flatHooksDir === null || flatHooksDir === undefined) return new Set();
const name = dist.manifest.name;
return new Set(
const paths = new Set(
dist.components.hooks
.filter((f) => f.relativePath !== "hooks/hooks.json")
.map((f) =>
Expand All @@ -193,6 +195,15 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
)
)
);
const bridge = plugins.flatHooksBridge;
const hooksJson = dist.components.hooks.find((f) => f.relativePath === "hooks/hooks.json");
const hasOwnBridge =
bridge !== null &&
dist.components.hooks.some((f) => f.relativePath.endsWith(`/${bridge.skipIfSourceHas}`));
if (bridge !== null && hooksJson !== undefined && !hasOwnBridge) {
if (bridge.generate(hooksJson.content, name) !== null) paths.add(bridge.path(name));
}
return paths;
}

private async findMarketplace(name: string, projectRoot: string) {
Expand Down
138 changes: 138 additions & 0 deletions cli/src/contexts/tools/domain/profiles/kilo/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/** Kilo's project distribution is flat. Its config and MCP format follow the OpenCode runtime,
* but Kilo has its own .kilo/ discovery paths and bundled base configuration. */

import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js";
import {
flatHooksPathWithLoaderEntry,
flatMcpKeyPrefix,
genericFlatAgentPath,
genericFlatSkillTreePath,
} from "../../../../../kernel/materialization/flat-paths.js";
import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js";
import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../../kernel/ports/file-writer.js";
import type { ToolBuildContract } from "../../build-contract.js";
import { buildOpencodeFlatConfig } from "../../formats/opencode-mcp-merge.js";
import { transformMcpToOpencode } from "../opencode/build.js";
import { generateKiloHooksBridge } from "./kilo-hooks-bridge.js";
import {
KILO_DIRECTORY,
KILO_HOOKS_DIR,
KILO_PLUGIN_DIR,
KILO_PLUGIN_ENTRY_BASENAME,
makeKiloHooksBridgePath,
} from "./kilo-paths.js";

type FsType = FileReader & FileWriter;

function kiloFlatAgentPath(plugin: string, rel: string): string {
return genericFlatAgentPath(
`${KILO_DIRECTORY}agents/`,
plugin,
rel.replace(/^agents\//, ""),
".md"
);
}

function kiloFlatSkillPath(plugin: string, rel: string): string {
return genericFlatSkillTreePath(`${KILO_DIRECTORY}skills/`, plugin, rel.replace(/^skills\//, ""));
}

function kiloFlatResolveTarget(plugin: string, rel: string): string {
if (rel.startsWith("agents/")) return kiloFlatAgentPath(plugin, rel);
if (rel.startsWith("skills/")) return kiloFlatSkillPath(plugin, rel);
return rel;
}

function kiloFlatHooksPath(plugin: string, rel: string): string {
return flatHooksPathWithLoaderEntry(
KILO_HOOKS_DIR,
{ dir: KILO_PLUGIN_DIR, baseName: KILO_PLUGIN_ENTRY_BASENAME },
plugin,
rel
);
}

function transformKiloFlatAgent(content: string, plugin: string, outName: string): string {
const { frontmatter, body } = parseFrontmatter(content);
const flatRelPath = kiloFlatAgentPath(plugin, `agents/${outName}`);
const rewrittenBody = rewriteRelativeLinks(body, {
currentFilePluginRelative: flatRelPath,
resolveTargetPath: (rel) => kiloFlatResolveTarget(plugin, rel),
});
return serializeFrontmatter(
{ ...frontmatter, name: `${plugin}-${outName.replace(/\.md$/, "")}`, mode: "subagent" },
rewrittenBody
);
}

async function resolveKiloJsonPath(outDir: string, fs: FsType): Promise<string> {
if (await fs.fileExists(`${outDir}/kilo.jsonc`)) return `${outDir}/kilo.jsonc`;
return `${outDir}/kilo.json`;
}

async function collectKiloMcp(
builtPlugins: readonly string[],
sourceDir: string,
fs: FsType
): Promise<Record<string, unknown>> {
const incoming: Record<string, unknown> = {};
for (const plugin of builtPlugins) {
const mcpSrc = `${sourceDir}/plugins/${plugin}/.mcp.json`;
if (!(await fs.fileExists(mcpSrc))) continue;
const transformed = JSON.parse(transformMcpToOpencode(await fs.readFile(mcpSrc))) as {
mcp?: Record<string, unknown>;
};
for (const [key, value] of Object.entries(transformed.mcp ?? {})) {
incoming[`${flatMcpKeyPrefix(plugin)}${key}`] = value;
}
}
return incoming;
}

export function buildKiloFlatContract(): ToolBuildContract {
return {
manifestFileRelative: null,
synthesizeManifest: null,
manifestSchemaName: null,
artifacts: {
skills: {
supported: true,
source: { kind: "fullTree", srcDir: "skills" },
path: kiloFlatSkillPath,
rewriteSkillName: true,
},
agents: {
supported: true,
source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" },
path: kiloFlatAgentPath,
transform: transformKiloFlatAgent,
},
mcp: { supported: false },
hooks: {
supported: true,
source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" },
path: kiloFlatHooksPath,
skipHooksJson: true,
hooksBridge: {
generate: generateKiloHooksBridge,
path: makeKiloHooksBridgePath,
skipIfSourceHas: KILO_PLUGIN_ENTRY_BASENAME,
},
},
rules: { supported: false },
commands: { supported: false },
},
buildMarketplaceCatalog: null,
buildMarketplaceEntry: null,
emitConfigArtifact: async (builtPlugins, outDir, sourceDir, fs, _validator, assetProvider) => {
const configPath = await resolveKiloJsonPath(outDir, fs);
const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : null;
const incoming = await collectKiloMcp(builtPlugins, sourceDir, fs);
const asset = assetProvider.loadConfigAsset("kilo", "kilo.json");
const base = typeof asset === "string" ? asset : JSON.stringify(asset);
await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming));
return 1;
},
};
}
105 changes: 105 additions & 0 deletions cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Kilo loads JavaScript modules from `.kilo/plugin/` and requires a default descriptor with
* `{ id, server }`. Declarative hooks.json has no Kilo runtime, so this generator turns its
* replayable SessionStart commands into that native module. Kilo documents `session.created` as
* the session lifecycle event; other Claude hook events deliberately remain unsupported until
* their Kilo payloads have been captured.
*/

interface ClaudeHookItem {
readonly command?: string;
}

interface ClaudeMatcherGroup {
readonly hooks?: readonly ClaudeHookItem[];
}

interface ClaudeHooksShape {
readonly hooks?: Record<string, readonly ClaudeMatcherGroup[]>;
}

interface ParsedHookCall {
readonly script: string;
readonly args: readonly string[];
}

const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/;

function parseCommand(command: string | undefined): ParsedHookCall | null {
if (typeof command !== "string") return null;
const match = COMMAND_PATTERN.exec(command.trim());
if (match === null) return null;
const [, script, rest] = match;
return { script, args: rest.trim().length === 0 ? [] : rest.trim().split(/\s+/) };
}

/** Parses only the one declarative event whose Kilo mapping is documented and intended here. */
export function parseKiloSessionStartHooks(rawHooksJson: string): readonly ParsedHookCall[] {
const parsed = JSON.parse(rawHooksJson) as ClaudeHooksShape;
return (parsed.hooks?.SessionStart ?? []).flatMap((group) =>
(group.hooks ?? []).flatMap((hook) => {
const parsedHook = parseCommand(hook.command);
return parsedHook === null ? [] : [parsedHook];
})
);
}

function toIdentifier(plugin: string): string {
return `${plugin
.split("-")
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join("")}KiloHooks`;
}

/** Generates Kilo's default plugin descriptor, or no file where hooks.json has no replayable
* SessionStart command. The generated module keeps errors non-blocking so a failed memory
* refresh cannot prevent Kilo from creating a session. */
export function generateKiloHooksBridge(rawHooksJson: string, plugin: string): string | null {
const sessionStart = parseKiloSessionStartHooks(rawHooksJson);
if (sessionStart.length === 0) return null;
const identifier = toIdentifier(plugin);
return `// Generated by aidd from plugins/${plugin}/hooks/hooks.json - do not edit by hand.
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";

const HOOKS_DIR = fileURLToPath(new URL("../hooks/${plugin}/", import.meta.url));
const SESSION_START = ${JSON.stringify(sessionStart)};

function runHook(script, args, payload, directory) {
const child = spawn("node", [HOOKS_DIR + script, ...args], {
cwd: directory,
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
child.on("error", () => {});
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(payload));
}

function sessionStartCallsFor(event, directory) {
if (event?.type !== "session.created") return [];
return SESSION_START.map((hook) => ({
script: hook.script,
args: hook.args,
payload: { hook_event_name: "SessionStart", session_id: null, cwd: directory },
}));
}

const ${identifier} = async ({ directory }) => ({
event: async ({ event }) => {
try {
for (const call of sessionStartCallsFor(event, directory)) {
runHook(call.script, call.args, call.payload, directory);
}
} catch {
// A session-start refresh is best effort and must never interrupt Kilo.
}
},
});

${identifier}.sessionStartCallsFor = sessionStartCallsFor;

export default { id: "${plugin}-hooks", server: ${identifier} };
`;
}
Loading