From 5955e363600445cd17a368eac1f15d3dc59f3d64 Mon Sep 17 00:00:00 2001 From: anusbutt Date: Thu, 20 Aug 2026 22:46:36 +0500 Subject: [PATCH] feat: add Kilo, Windsurf, and Gemini CLI adapters Register kilo, windsurf, and geminicli in the data-driven adapter registry, each with a repo-local canonical skill and a marked guidance reminder. The AgentAdapter shape is unchanged; the CLI grammar, --integrate all, and removal machinery derive from AGENT_IDS with no command-logic changes. --- AGENTS.md | 3 +- CHANGELOG.md | 12 ++ README.md | 12 +- docs/agent-integrations.md | 3 + src/lib/agent-adapters.ts | 30 +++++ tests/e2e/init.test.ts | 78 +++++++++++++ tests/integration/agent-integrations.test.ts | 113 +++++++++++++++++++ tests/unit/agent-adapters.test.ts | 63 ++++++++++- 8 files changed, 308 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5ca183..9c9de22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ transcript store. - `templates/graph/SCHEMA.md` is the data contract; `templates/SKILL.md` is vendor-neutral agent guidance; `templates/pre-commit` is the hook wrapper. - `src/lib/agent-adapters.ts` defines the closed, data-driven adapter registry - (Codex, Claude Code, Cursor, OpenCode). Adding an adapter is a single registry entry; + (Codex, Claude Code, Cursor, OpenCode, Kilo Code, Windsurf, Gemini CLI). Adding an + adapter is a single registry entry; the CLI grammar and `--integrate all` derive from it. Adapters may share a guidance file (Codex and OpenCode both use `AGENTS.md`): each owns one marked block, sibling blocks from registered adapters are allowed when properly paired, and unknown or diff --git a/CHANGELOG.md b/CHANGELOG.md index e67ddbe..82e6c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ versioning; while the package is below 1.0, minor releases may change public beh `.opencode/skills/graphkeeper/SKILL.md`, the marked reminder in `AGENTS.md`, and the `graphkeeper` invocation. It participates in `--integrate all`, `--dry-run`, and conservative `integrate remove`. +- A Kilo Code adapter registered as `--integrate kilo` with the canonical skill at + `.kilo/skills/graphkeeper/SKILL.md`, the marked reminder at + `.kilo/rules/graphkeeper.md`, and the `@graphkeeper` invocation. It participates in + `--integrate all`, `--dry-run`, and conservative `integrate remove`. +- A Windsurf adapter registered as `--integrate windsurf` with the canonical skill at + `.windsurf/skills/graphkeeper/SKILL.md`, the marked reminder at + `.windsurf/rules/graphkeeper.md`, and the `@graphkeeper` invocation. It participates in + `--integrate all`, `--dry-run`, and conservative `integrate remove`. +- A Gemini CLI adapter registered as `--integrate geminicli` with the canonical skill at + `.gemini/skills/graphkeeper/SKILL.md`, the marked reminder in `GEMINI.md`, and the + `@graphkeeper` invocation. It participates in `--integrate all`, `--dry-run`, and + conservative `integrate remove`. ### Changed diff --git a/README.md b/README.md index c3b3acb..dbc1a18 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,19 @@ Review the displayed plan and confirm it. `init` creates the JSON graph, `eviden the canonical validator, a pre-commit hook, and the repository-scoped Codex skill. `--integrate codex` adds the Codex reminder to `AGENTS.md`; `--integrate claude` adds the Claude skill and reminder; `--integrate cursor` adds the Cursor skill and rule; -`--integrate opencode` adds the OpenCode skill and reminder; +`--integrate opencode` adds the OpenCode skill and reminder; `--integrate kilo` adds +the Kilo Code skill and rule; `--integrate windsurf` adds the Windsurf skill and rule; +`--integrate geminicli` adds the Gemini CLI skill and reminder; `--integrate all` installs every registered adapter. Codex uses `.agents/skills/graphkeeper/SKILL.md`, `AGENTS.md`, and `$graphkeeper`. Claude Code uses `.claude/skills/graphkeeper/SKILL.md`, `CLAUDE.md`, and `/graphkeeper`. Cursor uses `.cursor/skills/graphkeeper/SKILL.md`, `.cursor/rules/graphkeeper.md`, and `@graphkeeper`. OpenCode uses -`.opencode/skills/graphkeeper/SKILL.md`, `AGENTS.md`, and `graphkeeper`. All skills +`.opencode/skills/graphkeeper/SKILL.md`, `AGENTS.md`, and `graphkeeper`. Kilo Code uses +`.kilo/skills/graphkeeper/SKILL.md`, `.kilo/rules/graphkeeper.md`, and `@graphkeeper`. +Windsurf uses `.windsurf/skills/graphkeeper/SKILL.md`, `.windsurf/rules/graphkeeper.md`, +and `@graphkeeper`. Gemini CLI uses `.gemini/skills/graphkeeper/SKILL.md`, `GEMINI.md`, +and `@graphkeeper`. All skills are generated from the same `templates/SKILL.md`. The set of adapters is a closed data-driven registry in `src/lib/agent-adapters.ts`; `graphkeeper --help` lists the installed adapters. Adapters that share a guidance file (Codex and OpenCode both use @@ -138,7 +144,7 @@ The older claim remains in history and is marked as superseded. Reviewers can fo | Command | Role | |---|---| -| `graphkeeper init [--force] [--integrate ]... [--yes] [--dry-run]` | Scaffold safely and optionally install explicit agent adapters (currently `codex`, `claude`, `cursor`, `opencode`). Distinct `--integrate` flags may repeat; `all` must stand alone. `--yes --dry-run` is accepted as a harmless dry run. | +| `graphkeeper init [--force] [--integrate ]... [--yes] [--dry-run]` | Scaffold safely and optionally install explicit agent adapters (currently `codex`, `claude`, `cursor`, `opencode`, `kilo`, `windsurf`, `geminicli`). Distinct `--integrate` flags may repeat; `all` must stand alone. `--yes --dry-run` is accepted as a harmless dry run. | | `graphkeeper integrate remove [--yes] [--dry-run]` | Remove only recognizable GraphKeeper-owned material for one adapter. Modified skills and unexpected supporting files are preserved for manual review. | | `graphkeeper check` | Run the same fast schema, append-only history, and committed-evidence protection checks used by the Git hook. | | `graphkeeper query ` | Resolve an exact ID or unique alias and print active claims with provenance. It does not read evidence contents. | diff --git a/docs/agent-integrations.md b/docs/agent-integrations.md index e582c56..c2fdcd1 100644 --- a/docs/agent-integrations.md +++ b/docs/agent-integrations.md @@ -8,6 +8,9 @@ GraphKeeper v1 supports the following explicit internal adapters: | `claude` | `.claude/skills/graphkeeper/SKILL.md` | `CLAUDE.md` | `/graphkeeper` | | `cursor` | `.cursor/skills/graphkeeper/SKILL.md` | `.cursor/rules/graphkeeper.md` | `@graphkeeper` | | `opencode` | `.opencode/skills/graphkeeper/SKILL.md` | `AGENTS.md` | `graphkeeper` | +| `kilo` | `.kilo/skills/graphkeeper/SKILL.md` | `.kilo/rules/graphkeeper.md` | `@graphkeeper` | +| `windsurf` | `.windsurf/skills/graphkeeper/SKILL.md` | `.windsurf/rules/graphkeeper.md` | `@graphkeeper` | +| `geminicli` | `.gemini/skills/graphkeeper/SKILL.md` | `GEMINI.md` | `@graphkeeper` | Some adapters (for example Codex and OpenCode) share `AGENTS.md` as their guidance file. GraphKeeper supports this: each adapter owns exactly one marked block, blocks diff --git a/src/lib/agent-adapters.ts b/src/lib/agent-adapters.ts index d8abf9d..b36c3f6 100644 --- a/src/lib/agent-adapters.ts +++ b/src/lib/agent-adapters.ts @@ -61,6 +61,36 @@ const adapters = [ endMarker: '', postInstallNote: 'Restart opencode if .opencode/skills did not exist when the current session began.', }, + { + id: 'kilo', + displayName: 'Kilo Code', + skillTarget: '.kilo/skills/graphkeeper/SKILL.md', + guidanceTarget: '.kilo/rules/graphkeeper.md', + invocation: '`@graphkeeper`', + startMarker: '', + endMarker: '', + postInstallNote: 'Restart Kilo Code if .kilo/skills did not exist when the current session began.', + }, + { + id: 'windsurf', + displayName: 'Windsurf', + skillTarget: '.windsurf/skills/graphkeeper/SKILL.md', + guidanceTarget: '.windsurf/rules/graphkeeper.md', + invocation: '`@graphkeeper`', + startMarker: '', + endMarker: '', + postInstallNote: 'Restart Windsurf if .windsurf/skills did not exist when the current session began.', + }, + { + id: 'geminicli', + displayName: 'Gemini CLI', + skillTarget: '.gemini/skills/graphkeeper/SKILL.md', + guidanceTarget: 'GEMINI.md', + invocation: '`@graphkeeper`', + startMarker: '', + endMarker: '', + postInstallNote: 'Restart Gemini CLI if .gemini/skills did not exist when the current session began.', + }, ] as const; export type AgentId = (typeof adapters)[number]['id']; diff --git a/tests/e2e/init.test.ts b/tests/e2e/init.test.ts index 6b5d9df..a198d2a 100644 --- a/tests/e2e/init.test.ts +++ b/tests/e2e/init.test.ts @@ -125,6 +125,69 @@ test('explicit OpenCode integration creates the managed AGENTS.md block through } }); +test('explicit Kilo integration creates the managed rules block and skill through the CLI', async () => { + const fixture = await createRepositoryFixture(); + try { + const result = await runInit(fixture.root, ['--integrate', 'kilo', '--yes']); + assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr); + assert.match(result.stdout, /CREATE \.kilo\/rules\/graphkeeper\.md/); + const rules = await readFile( + join(fixture.root, '.kilo', 'rules', 'graphkeeper.md'), + 'utf8', + ); + assert.match(rules, //); + assert.match(rules, /invoke `@graphkeeper`/); + assert.equal((rules.match(/graphkeeper:kilo:start/g) ?? []).length, 1); + assert.match( + await readFile(join(fixture.root, '.kilo', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + /^---\nname: graphkeeper\n/, + ); + } finally { + await fixture.cleanup(); + } +}); + +test('explicit Windsurf integration creates the managed rules block and skill through the CLI', async () => { + const fixture = await createRepositoryFixture(); + try { + const result = await runInit(fixture.root, ['--integrate', 'windsurf', '--yes']); + assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr); + assert.match(result.stdout, /CREATE \.windsurf\/rules\/graphkeeper\.md/); + const rules = await readFile( + join(fixture.root, '.windsurf', 'rules', 'graphkeeper.md'), + 'utf8', + ); + assert.match(rules, //); + assert.match(rules, /invoke `@graphkeeper`/); + assert.equal((rules.match(/graphkeeper:windsurf:start/g) ?? []).length, 1); + assert.match( + await readFile(join(fixture.root, '.windsurf', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + /^---\nname: graphkeeper\n/, + ); + } finally { + await fixture.cleanup(); + } +}); + +test('explicit Gemini CLI integration creates the managed GEMINI.md block and skill through the CLI', async () => { + const fixture = await createRepositoryFixture(); + try { + const result = await runInit(fixture.root, ['--integrate', 'geminicli', '--yes']); + assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr); + assert.match(result.stdout, /CREATE GEMINI\.md/); + const gemini = await readFile(join(fixture.root, 'GEMINI.md'), 'utf8'); + assert.match(gemini, //); + assert.match(gemini, /invoke `@graphkeeper`/); + assert.equal((gemini.match(/graphkeeper:geminicli:start/g) ?? []).length, 1); + assert.match( + await readFile(join(fixture.root, '.gemini', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + /^---\nname: graphkeeper\n/, + ); + } finally { + await fixture.cleanup(); + } +}); + test('non-interactive integration requires --yes and refuses before mutation', async () => { const fixture = await createRepositoryFixture(); try { @@ -151,8 +214,14 @@ test('--dry-run preflights all adapters without prompting or writing', async () assert.match(result.stdout, /CREATE AGENTS\.md/); assert.match(result.stdout, /CREATE CLAUDE\.md/); assert.match(result.stdout, /CREATE \.cursor\/rules\/graphkeeper\.md/); + assert.match(result.stdout, /CREATE \.kilo\/rules\/graphkeeper\.md/); + assert.match(result.stdout, /CREATE \.windsurf\/rules\/graphkeeper\.md/); + assert.match(result.stdout, /CREATE GEMINI\.md/); assert.match(result.stdout, /\.claude\/skills\/graphkeeper\/SKILL\.md/); assert.match(result.stdout, /\.opencode\/skills\/graphkeeper\/SKILL\.md/); + assert.match(result.stdout, /\.kilo\/skills\/graphkeeper\/SKILL\.md/); + assert.match(result.stdout, /\.windsurf\/skills\/graphkeeper\/SKILL\.md/); + assert.match(result.stdout, /\.gemini\/skills\/graphkeeper\/SKILL\.md/); assert.match(result.stdout, /DRY RUN No changes were made/); assert.doesNotMatch(result.stdout, /Restart Claude Code/); await assert.rejects(stat(join(fixture.root, 'graph'))); @@ -193,6 +262,15 @@ test('all adapters install and conservative removal works through the CLI', asyn await readFile(join(fixture.root, '.cursor', 'rules', 'graphkeeper.md'), 'utf8'), /graphkeeper:cursor/, ); + assert.match( + await readFile(join(fixture.root, '.kilo', 'rules', 'graphkeeper.md'), 'utf8'), + /graphkeeper:kilo/, + ); + assert.match( + await readFile(join(fixture.root, '.windsurf', 'rules', 'graphkeeper.md'), 'utf8'), + /graphkeeper:windsurf/, + ); + assert.match(await readFile(join(fixture.root, 'GEMINI.md'), 'utf8'), /graphkeeper:geminicli/); const refused = await runCli(fixture.root, ['integrate', 'remove', 'claude']); assert.equal(refused.exitCode, EXIT_USAGE); diff --git a/tests/integration/agent-integrations.test.ts b/tests/integration/agent-integrations.test.ts index 89c4617..12dcf69 100644 --- a/tests/integration/agent-integrations.test.ts +++ b/tests/integration/agent-integrations.test.ts @@ -145,6 +145,119 @@ test('OpenCode shares AGENTS.md with Codex and removal preserves the sibling blo } }); +test('Kilo integration installs the canonical skill and one independent guidance block', async () => { + const fixture = await createRepositoryFixture(); + try { + const report = await initialize({ + cwd: fixture.root, + force: false, + integrations: ['kilo'], + environment: supportedInitEnvironment(), + }); + assert.equal( + await readFile(join(fixture.root, '.kilo', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + const rules = await readFile( + join(fixture.root, '.kilo', 'rules', 'graphkeeper.md'), + 'utf8', + ); + assert.match(rules, //); + assert.match(rules, /invoke `@graphkeeper`/); + assert.equal((rules.match(/graphkeeper:kilo:start/g) ?? []).length, 1); + assert.ok(report.notes.some((note) => /Restart Kilo Code/.test(note))); + } finally { + await fixture.cleanup(); + } +}); + +test('Windsurf integration installs the canonical skill and one independent guidance block', async () => { + const fixture = await createRepositoryFixture(); + try { + const report = await initialize({ + cwd: fixture.root, + force: false, + integrations: ['windsurf'], + environment: supportedInitEnvironment(), + }); + assert.equal( + await readFile(join(fixture.root, '.windsurf', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + const rules = await readFile( + join(fixture.root, '.windsurf', 'rules', 'graphkeeper.md'), + 'utf8', + ); + assert.match(rules, //); + assert.match(rules, /invoke `@graphkeeper`/); + assert.equal((rules.match(/graphkeeper:windsurf:start/g) ?? []).length, 1); + assert.ok(report.notes.some((note) => /Restart Windsurf/.test(note))); + } finally { + await fixture.cleanup(); + } +}); + +test('Gemini CLI integration installs the canonical skill and one independent GEMINI.md block', async () => { + const fixture = await createRepositoryFixture(); + try { + const report = await initialize({ + cwd: fixture.root, + force: false, + integrations: ['geminicli'], + environment: supportedInitEnvironment(), + }); + assert.equal( + await readFile(join(fixture.root, '.gemini', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + const gemini = await readFile(join(fixture.root, 'GEMINI.md'), 'utf8'); + assert.match(gemini, //); + assert.match(gemini, /invoke `@graphkeeper`/); + assert.equal((gemini.match(/graphkeeper:geminicli:start/g) ?? []).length, 1); + assert.ok(report.notes.some((note) => /Restart Gemini CLI/.test(note))); + } finally { + await fixture.cleanup(); + } +}); + +test('Kilo, Windsurf, and Gemini CLI removal deletes only canonical owned material and leaves others intact', async () => { + const fixture = await createRepositoryFixture(); + try { + await initialize({ + cwd: fixture.root, + force: false, + integrations: ['kilo', 'windsurf', 'geminicli', 'codex'], + environment: supportedInitEnvironment(), + }); + for (const adapter of ['kilo', 'windsurf', 'geminicli'] as const) { + const plan = await prepareAgentRemoval(fixture.root, adapter); + await applyAgentIntegrationPlan(plan); + } + assert.doesNotMatch( + await readFile(join(fixture.root, '.kilo', 'rules', 'graphkeeper.md'), 'utf8'), + /graphkeeper:kilo/, + ); + assert.doesNotMatch( + await readFile(join(fixture.root, '.windsurf', 'rules', 'graphkeeper.md'), 'utf8'), + /graphkeeper:windsurf/, + ); + assert.doesNotMatch(await readFile(join(fixture.root, 'GEMINI.md'), 'utf8'), /graphkeeper:geminicli/); + await assert.rejects(stat(join(fixture.root, '.kilo', 'skills', 'graphkeeper'))); + await assert.rejects(stat(join(fixture.root, '.windsurf', 'skills', 'graphkeeper'))); + await assert.rejects(stat(join(fixture.root, '.gemini', 'skills', 'graphkeeper'))); + assert.match(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), /graphkeeper:codex:start/); + assert.equal( + await readFile(join(fixture.root, '.agents', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + + const repeated = await prepareAgentRemoval(fixture.root, 'geminicli'); + assert.ok(repeated.actions.every((action) => action.kind === 'skip')); + } finally { + await fixture.cleanup(); + } +}); + test('multi-adapter installation is deterministic, idempotent, and isolated', async () => { const fixture = await createRepositoryFixture(); try { diff --git a/tests/unit/agent-adapters.test.ts b/tests/unit/agent-adapters.test.ts index f7f3137..184ed09 100644 --- a/tests/unit/agent-adapters.test.ts +++ b/tests/unit/agent-adapters.test.ts @@ -24,7 +24,7 @@ function sourceFile(relativePath: string): Promise { test('registers explicit adapters with independent destinations', () => { assert.deepEqual( AGENT_ADAPTERS.map((adapter) => adapter.id), - ['codex', 'claude', 'cursor', 'opencode'], + ['codex', 'claude', 'cursor', 'opencode', 'kilo', 'windsurf', 'geminicli'], ); assert.deepEqual( AGENT_ADAPTERS.map((adapter) => adapter.skillTarget), @@ -33,11 +33,22 @@ test('registers explicit adapters with independent destinations', () => { '.claude/skills/graphkeeper/SKILL.md', '.cursor/skills/graphkeeper/SKILL.md', '.opencode/skills/graphkeeper/SKILL.md', + '.kilo/skills/graphkeeper/SKILL.md', + '.windsurf/skills/graphkeeper/SKILL.md', + '.gemini/skills/graphkeeper/SKILL.md', ], ); assert.deepEqual( AGENT_ADAPTERS.map((adapter) => adapter.guidanceTarget), - ['AGENTS.md', 'CLAUDE.md', '.cursor/rules/graphkeeper.md', 'AGENTS.md'], + [ + 'AGENTS.md', + 'CLAUDE.md', + '.cursor/rules/graphkeeper.md', + 'AGENTS.md', + '.kilo/rules/graphkeeper.md', + '.windsurf/rules/graphkeeper.md', + 'GEMINI.md', + ], ); assert.notEqual(AGENT_ADAPTERS[0]?.startMarker, AGENT_ADAPTERS[1]?.startMarker); assert.notEqual(AGENT_ADAPTERS[1]?.startMarker, AGENT_ADAPTERS[2]?.startMarker); @@ -68,6 +79,54 @@ test('plans Claude create, append, refresh, and skip without changing outside by assert.equal(planGuidanceContent(adapter, refreshed.content).kind, 'skip'); }); +test('plans Kilo guidance create, append, refresh, and skip without changing outside bytes', () => { + const adapter = getAgentAdapter('kilo'); + const created = planGuidanceContent(adapter, null); + assert.equal(created.kind, 'create'); + assert.match(created.content, /invoke `@graphkeeper`/); + assert.match(created.content, /graphkeeper:kilo:start/); + + const existing = '# Kilo rules\n'; + const appended = planGuidanceContent(adapter, existing); + assert.equal(appended.kind, 'append'); + assert.ok(appended.content.startsWith(existing)); + assert.match(appended.content, /graphkeeper:kilo:start/); + + assert.equal(planGuidanceContent(adapter, created.content).kind, 'skip'); +}); + +test('plans Windsurf guidance create, append, refresh, and skip without changing outside bytes', () => { + const adapter = getAgentAdapter('windsurf'); + const created = planGuidanceContent(adapter, null); + assert.equal(created.kind, 'create'); + assert.match(created.content, /invoke `@graphkeeper`/); + assert.match(created.content, /graphkeeper:windsurf:start/); + + const existing = '# Windsurf rules\n'; + const appended = planGuidanceContent(adapter, existing); + assert.equal(appended.kind, 'append'); + assert.ok(appended.content.startsWith(existing)); + assert.match(appended.content, /graphkeeper:windsurf:start/); + + assert.equal(planGuidanceContent(adapter, created.content).kind, 'skip'); +}); + +test('plans Gemini CLI guidance create into GEMINI.md with its own marked block', () => { + const adapter = getAgentAdapter('geminicli'); + const created = planGuidanceContent(adapter, null); + assert.equal(created.kind, 'create'); + assert.match(created.content, /invoke `@graphkeeper`/); + assert.match(created.content, /graphkeeper:geminicli:start/); + + const existing = '# Gemini context\n'; + const appended = planGuidanceContent(adapter, existing); + assert.equal(appended.kind, 'append'); + assert.ok(appended.content.startsWith(existing)); + assert.match(appended.content, /graphkeeper:geminicli:start/); + + assert.equal(planGuidanceContent(adapter, created.content).kind, 'skip'); +}); + test('rejects missing, repeated, reversed, mixed, and malformed adapter markers', () => { const adapter = getAgentAdapter('claude'); for (const malformed of [