Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -138,7 +144,7 @@ The older claim remains in history and is marked as superseded. Reviewers can fo

| Command | Role |
|---|---|
| `graphkeeper init [--force] [--integrate <adapter\|all>]... [--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 <adapter\|all>]... [--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 <adapter> [--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 <subject>` | Resolve an exact ID or unique alias and print active claims with provenance. It does not read evidence contents. |
Expand Down
3 changes: 3 additions & 0 deletions docs/agent-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/lib/agent-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ const adapters = [
endMarker: '<!-- graphkeeper:opencode:end -->',
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: '<!-- graphkeeper:kilo:start -->',
endMarker: '<!-- graphkeeper:kilo:end -->',
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: '<!-- graphkeeper:windsurf:start -->',
endMarker: '<!-- graphkeeper:windsurf:end -->',
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: '<!-- graphkeeper:geminicli:start -->',
endMarker: '<!-- graphkeeper:geminicli:end -->',
postInstallNote: 'Restart Gemini CLI if .gemini/skills did not exist when the current session began.',
},
] as const;

export type AgentId = (typeof adapters)[number]['id'];
Expand Down
78 changes: 78 additions & 0 deletions tests/e2e/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<!-- graphkeeper:kilo:start -->/);
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, /<!-- graphkeeper:windsurf:start -->/);
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, /<!-- graphkeeper:geminicli:start -->/);
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 {
Expand All @@ -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')));
Expand Down Expand Up @@ -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);
Expand Down
113 changes: 113 additions & 0 deletions tests/integration/agent-integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<!-- graphkeeper:kilo:start -->/);
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, /<!-- graphkeeper:windsurf:start -->/);
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, /<!-- graphkeeper:geminicli:start -->/);
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 {
Expand Down
Loading