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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ versioning; while the package is below 1.0, minor releases may change public beh
`.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`.
- A Kiro adapter registered as `--integrate kiro` with the canonical skill at
`.kiro/skills/graphkeeper/SKILL.md`, the marked reminder at `.kiro/steering/graphkeeper.md`,
and the `/graphkeeper` invocation. It participates in `--integrate all`, `--dry-run`, and
conservative `integrate remove`.
- An Antigravity adapter registered as `--integrate antigravity` with the canonical skill at
`.agents/skills/graphkeeper/SKILL.md` (shared with Codex), the marked reminder at
`.agents/rules/graphkeeper.md`, and the `graphkeeper` invocation. It participates in
`--integrate all`, `--dry-run`, and conservative `integrate remove`; because the skill path
is shared with Codex, removal is owner-scoped so removing Antigravity preserves the
Codex-owned skill directory.

### Changed

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ of them, GraphKeeper works with it.
| Kilo Code | `--integrate kilo` | `.kilo/skills/graphkeeper/SKILL.md` | `.kilo/rules/graphkeeper.md` | `@graphkeeper` |
| Windsurf | `--integrate windsurf` | `.windsurf/skills/graphkeeper/SKILL.md` | `.windsurf/rules/graphkeeper.md` | `@graphkeeper` |
| Gemini CLI | `--integrate geminicli` | `.gemini/skills/graphkeeper/SKILL.md` | `GEMINI.md` | `@graphkeeper` |
| Kiro | `--integrate kiro` | `.kiro/skills/graphkeeper/SKILL.md` | `.kiro/steering/graphkeeper.md` | `/graphkeeper` |
| Antigravity | `--integrate antigravity` | `.agents/skills/graphkeeper/SKILL.md` | `.agents/rules/graphkeeper.md` | `graphkeeper` |

Install any subset, or all of them at once:

Expand Down Expand Up @@ -206,7 +208,7 @@ plain JSON and text — no database, no server, no vector index.
```text
CODING AGENTS
┌────────┬────────┬────────┬────────┐
│ Codex │ Claude │ Cursor │ OpenCode│ ... Kilo · Windsurf · Gemini
│ Codex │ Claude │ Cursor │ OpenCode│ ... Kilo · Windsurf · Gemini · Kiro · Antigravity
└────────┴───┬────┴────────┴────────┘
│ skill + guidance
Expand Down
11 changes: 11 additions & 0 deletions docs/agent-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,24 @@ GraphKeeper v1 supports the following explicit internal adapters:
| `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` |
| `kiro` | `.kiro/skills/graphkeeper/SKILL.md` | `.kiro/steering/graphkeeper.md` | `/graphkeeper` |
| `antigravity` | `.agents/skills/graphkeeper/SKILL.md` | `.agents/rules/graphkeeper.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
from other registered adapters are allowed when properly paired, and planning, append,
and remove always touch only the owning adapter's span. Unknown or malformed markers
are still rejected with `GK004`.

### Shared skill paths

Antigravity and Codex share the same skill file, `.agents/skills/graphkeeper/SKILL.md`.
Both are `scaffoldSkillByInit`, so `init` scaffolds that canonical file exactly once and
neither adapter rewrites it during `--integrate`. Removal is owner-scoped by the **primary
owner**: the first registered adapter on a shared skill path (Codex) is the only one that
removes the skill directory. Removing a non-owner sharer (Antigravity) preserves the shared
skill directory and removes only its guidance block, reporting a `preserve` action.

The closed registry in `src/lib/agent-adapters.ts` defines these destinations,
invocations, unique markers, and post-install notes. It is an implementation detail,
not a public plugin framework. Every skill file is rendered byte-for-byte from
Expand Down
16 changes: 16 additions & 0 deletions src/commands/integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import {
AGENT_ADAPTERS,
getAgentAdapter,
planGuidanceContent,
planGuidanceRemovalContent,
Expand Down Expand Up @@ -302,13 +303,28 @@ export async function prepareAgentRemoval(

const skillDirectory = adapter.skillTarget.split('/').slice(0, -1).join('/');
const entries = await directoryEntries(root, skillDirectory);
const sharedSkillOwners = AGENT_ADAPTERS.filter(
(other) => other.skillTarget === adapter.skillTarget && other.id !== adapter.id,
);
const primaryOwner = AGENT_ADAPTERS.find(
(other) => other.skillTarget === adapter.skillTarget,
);
const isPrimarySkillOwner = primaryOwner?.id === adapter.id;
if (entries === null) {
actions.push({
kind: 'skip',
target: adapter.skillTarget,
adapter: adapterId,
reason: 'generated GraphKeeper skill is already absent',
});
} else if (sharedSkillOwners.length > 0 && !isPrimarySkillOwner) {
actions.push({
kind: 'preserve',
target: adapter.skillTarget,
adapter: adapterId,
reason: 'skill path is shared with ' + sharedSkillOwners.map((other) => other.displayName).join(', ')
+ '; preserved for review',
});
} else if (entries.some((entry) => entry !== 'SKILL.md')) {
actions.push({
kind: 'preserve',
Expand Down
21 changes: 21 additions & 0 deletions src/lib/agent-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ const adapters = [
endMarker: '<!-- graphkeeper:geminicli:end -->',
postInstallNote: 'Restart Gemini CLI if .gemini/skills did not exist when the current session began.',
},
{
id: 'kiro',
displayName: 'Kiro',
skillTarget: '.kiro/skills/graphkeeper/SKILL.md',
guidanceTarget: '.kiro/steering/graphkeeper.md',
invocation: '`/graphkeeper`',
startMarker: '<!-- graphkeeper:kiro:start -->',
endMarker: '<!-- graphkeeper:kiro:end -->',
postInstallNote: 'Restart Kiro if .kiro/skills did not exist when the current session began.',
},
{
id: 'antigravity',
displayName: 'Antigravity',
skillTarget: '.agents/skills/graphkeeper/SKILL.md',
guidanceTarget: '.agents/rules/graphkeeper.md',
invocation: '`graphkeeper`',
startMarker: '<!-- graphkeeper:antigravity:start -->',
endMarker: '<!-- graphkeeper:antigravity:end -->',
scaffoldSkillByInit: true,
postInstallNote: 'Restart Antigravity if .agents/skills did not exist when the current session began.',
},
] as const;

export type AgentId = (typeof adapters)[number]['id'];
Expand Down
68 changes: 68 additions & 0 deletions tests/e2e/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,50 @@ test('explicit Gemini CLI integration creates the managed GEMINI.md block and sk
}
});

test('explicit Kiro integration creates the managed steering block and skill through the CLI', async () => {
const fixture = await createRepositoryFixture();
try {
const result = await runInit(fixture.root, ['--integrate', 'kiro', '--yes']);
assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr);
assert.match(result.stdout, /CREATE \.kiro\/steering\/graphkeeper\.md/);
const steering = await readFile(
join(fixture.root, '.kiro', 'steering', 'graphkeeper.md'),
'utf8',
);
assert.match(steering, /<!-- graphkeeper:kiro:start -->/);
assert.match(steering, /invoke `\/graphkeeper`/);
assert.equal((steering.match(/graphkeeper:kiro:start/g) ?? []).length, 1);
assert.match(
await readFile(join(fixture.root, '.kiro', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'),
/^---\nname: graphkeeper\n/,
);
} finally {
await fixture.cleanup();
}
});

test('explicit Antigravity integration creates the managed rules block and reuses the shared skill', async () => {
const fixture = await createRepositoryFixture();
try {
const result = await runInit(fixture.root, ['--integrate', 'antigravity', '--yes']);
assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr);
assert.match(result.stdout, /CREATE \.agents\/rules\/graphkeeper\.md/);
const rules = await readFile(
join(fixture.root, '.agents', 'rules', 'graphkeeper.md'),
'utf8',
);
assert.match(rules, /<!-- graphkeeper:antigravity:start -->/);
assert.match(rules, /invoke `graphkeeper`/);
assert.equal((rules.match(/graphkeeper:antigravity:start/g) ?? []).length, 1);
assert.match(
await readFile(join(fixture.root, '.agents', '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 Down Expand Up @@ -217,6 +261,8 @@ test('--dry-run preflights all adapters without prompting or writing', async ()
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, /CREATE \.kiro\/steering\/graphkeeper\.md/);
assert.match(result.stdout, /CREATE \.agents\/rules\/graphkeeper\.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/);
Expand Down Expand Up @@ -271,6 +317,14 @@ test('all adapters install and conservative removal works through the CLI', asyn
/graphkeeper:windsurf/,
);
assert.match(await readFile(join(fixture.root, 'GEMINI.md'), 'utf8'), /graphkeeper:geminicli/);
assert.match(
await readFile(join(fixture.root, '.kiro', 'steering', 'graphkeeper.md'), 'utf8'),
/graphkeeper:kiro/,
);
assert.match(
await readFile(join(fixture.root, '.agents', 'rules', 'graphkeeper.md'), 'utf8'),
/graphkeeper:antigravity/,
);

const refused = await runCli(fixture.root, ['integrate', 'remove', 'claude']);
assert.equal(refused.exitCode, EXIT_USAGE);
Expand Down Expand Up @@ -300,6 +354,20 @@ test('all adapters install and conservative removal works through the CLI', asyn
);
await assert.rejects(stat(join(fixture.root, '.claude', 'skills', 'graphkeeper')));
assert.match(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), /graphkeeper:codex/);

const antigravityRemoved = await runCli(
fixture.root,
['integrate', 'remove', 'antigravity', '--yes'],
);
assert.equal(antigravityRemoved.exitCode, EXIT_SUCCESS, antigravityRemoved.stderr);
assert.doesNotMatch(
await readFile(join(fixture.root, '.agents', 'rules', 'graphkeeper.md'), 'utf8'),
/graphkeeper:antigravity/,
);
assert.match(
await readFile(join(fixture.root, '.agents', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'),
/^---\nname: graphkeeper\n/,
);
} finally {
await fixture.cleanup();
}
Expand Down
106 changes: 106 additions & 0 deletions tests/integration/agent-integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,109 @@ test('wrong-type integration guidance is rejected before any writes', async () =
await fixture.cleanup();
}
});

test('Kiro integration installs the canonical skill and one independent steering block', async () => {
const fixture = await createRepositoryFixture();
try {
const report = await initialize({
cwd: fixture.root,
force: false,
integrations: ['kiro'],
environment: supportedInitEnvironment(),
});
assert.equal(
await readFile(join(fixture.root, '.kiro', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'),
await template(),
);
const steering = await readFile(
join(fixture.root, '.kiro', 'steering', 'graphkeeper.md'),
'utf8',
);
assert.match(steering, /<!-- graphkeeper:kiro:start -->/);
assert.match(steering, /invoke `\/graphkeeper`/);
assert.equal((steering.match(/graphkeeper:kiro:start/g) ?? []).length, 1);
assert.ok(report.notes.some((note) => /Restart Kiro/.test(note)));
} finally {
await fixture.cleanup();
}
});

test('Antigravity integration installs its rules block and reuses the shared Codex skill', async () => {
const fixture = await createRepositoryFixture();
try {
const report = await initialize({
cwd: fixture.root,
force: false,
integrations: ['codex', 'antigravity'],
environment: supportedInitEnvironment(),
});
const rules = await readFile(
join(fixture.root, '.agents', 'rules', 'graphkeeper.md'),
'utf8',
);
assert.match(rules, /<!-- graphkeeper:antigravity:start -->/);
assert.match(rules, /invoke `graphkeeper`/);
assert.equal((rules.match(/graphkeeper:antigravity:start/g) ?? []).length, 1);
assert.ok(report.actions.some((action) =>
action.target === '.agents/skills/graphkeeper/SKILL.md' && action.kind === 'create'));
assert.equal(
await readFile(join(fixture.root, '.agents', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'),
await template(),
);
assert.ok(report.notes.some((note) => /Restart Antigravity/.test(note)));
} finally {
await fixture.cleanup();
}
});

test('Codex removal still removes the shared skill when Antigravity is installed', async () => {
const fixture = await createRepositoryFixture();
try {
await initialize({
cwd: fixture.root,
force: false,
integrations: ['codex', 'antigravity'],
environment: supportedInitEnvironment(),
});
await applyAgentIntegrationPlan(await prepareAgentRemoval(fixture.root, 'codex'));
await assert.rejects(stat(join(fixture.root, '.agents', 'skills', 'graphkeeper')));
assert.doesNotMatch(
await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'),
/graphkeeper:codex/,
);
assert.match(
await readFile(join(fixture.root, '.agents', 'rules', 'graphkeeper.md'), 'utf8'),
/graphkeeper:antigravity:start/,
);
} finally {
await fixture.cleanup();
}
});

test('removing Antigravity preserves the Codex-owned shared skill while removing its rules block', async () => {
const fixture = await createRepositoryFixture();
try {
await initialize({
cwd: fixture.root,
force: false,
integrations: ['codex', 'antigravity'],
environment: supportedInitEnvironment(),
});
const plan = await prepareAgentRemoval(fixture.root, 'antigravity');
assert.ok(plan.actions.some((action) =>
action.kind === 'preserve' && /shared/.test(action.reason)));
await applyAgentIntegrationPlan(plan);

assert.doesNotMatch(
await readFile(join(fixture.root, '.agents', 'rules', 'graphkeeper.md'), 'utf8'),
/graphkeeper:antigravity/,
);
assert.equal(
await readFile(join(fixture.root, '.agents', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'),
await template(),
);
assert.match(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), /graphkeeper:codex:start/);
} finally {
await fixture.cleanup();
}
});
38 changes: 37 additions & 1 deletion tests/unit/agent-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function sourceFile(relativePath: string): Promise<string> {
test('registers explicit adapters with independent destinations', () => {
assert.deepEqual(
AGENT_ADAPTERS.map((adapter) => adapter.id),
['codex', 'claude', 'cursor', 'opencode', 'kilo', 'windsurf', 'geminicli'],
['codex', 'claude', 'cursor', 'opencode', 'kilo', 'windsurf', 'geminicli', 'kiro', 'antigravity'],
);
assert.deepEqual(
AGENT_ADAPTERS.map((adapter) => adapter.skillTarget),
Expand All @@ -36,6 +36,8 @@ test('registers explicit adapters with independent destinations', () => {
'.kilo/skills/graphkeeper/SKILL.md',
'.windsurf/skills/graphkeeper/SKILL.md',
'.gemini/skills/graphkeeper/SKILL.md',
'.kiro/skills/graphkeeper/SKILL.md',
'.agents/skills/graphkeeper/SKILL.md',
],
);
assert.deepEqual(
Expand All @@ -48,6 +50,8 @@ test('registers explicit adapters with independent destinations', () => {
'.kilo/rules/graphkeeper.md',
'.windsurf/rules/graphkeeper.md',
'GEMINI.md',
'.kiro/steering/graphkeeper.md',
'.agents/rules/graphkeeper.md',
],
);
assert.notEqual(AGENT_ADAPTERS[0]?.startMarker, AGENT_ADAPTERS[1]?.startMarker);
Expand Down Expand Up @@ -127,6 +131,38 @@ test('plans Gemini CLI guidance create into GEMINI.md with its own marked block'
assert.equal(planGuidanceContent(adapter, created.content).kind, 'skip');
});

test('plans Kiro guidance create, append, refresh, and skip without changing outside bytes', () => {
const adapter = getAgentAdapter('kiro');
const created = planGuidanceContent(adapter, null);
assert.equal(created.kind, 'create');
assert.match(created.content, /invoke `\/graphkeeper`/);
assert.match(created.content, /graphkeeper:kiro:start/);

const existing = '# Kiro steering\n';
const appended = planGuidanceContent(adapter, existing);
assert.equal(appended.kind, 'append');
assert.ok(appended.content.startsWith(existing));
assert.match(appended.content, /graphkeeper:kiro:start/);

assert.equal(planGuidanceContent(adapter, created.content).kind, 'skip');
});

test('plans Antigravity guidance create, append, refresh, and skip without changing outside bytes', () => {
const adapter = getAgentAdapter('antigravity');
const created = planGuidanceContent(adapter, null);
assert.equal(created.kind, 'create');
assert.match(created.content, /invoke `graphkeeper`/);
assert.match(created.content, /graphkeeper:antigravity:start/);

const existing = '# Antigravity rules\n';
const appended = planGuidanceContent(adapter, existing);
assert.equal(appended.kind, 'append');
assert.ok(appended.content.startsWith(existing));
assert.match(appended.content, /graphkeeper:antigravity: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 [
Expand Down