diff --git a/AGENTS.md b/AGENTS.md index ff18f5f..c5ca183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,13 @@ transcript store. intentionally stay out of the fast hook path. - `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 Codex and Claude Code adapter - registry. Preserve independent destinations, marked-block ownership, planning, - rollback, and conservative removal. +- `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; + 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 + malformed markers stay rejected with `GK004`. Preserve independent destinations, + marked-block ownership, planning, rollback, and conservative removal. - `examples/`, `tests/`, `docs/`, and `.github/` contain demonstrations, coverage, supporting documentation, and repository automation respectively. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4401251..e67ddbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ versioning; while the package is below 1.0, minor releases may change public beh ## [Unreleased] +### Added + +- A Cursor adapter registered as `--integrate cursor` with the canonical skill at + `.cursor/skills/graphkeeper/SKILL.md`, the marked reminder at + `.cursor/rules/graphkeeper.md`, and the `@graphkeeper` invocation. It participates in + `--integrate all`, `--dry-run`, and conservative `integrate remove`. +- An OpenCode adapter registered as `--integrate opencode` with the canonical skill at + `.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`. + +### Changed + +- Agent adapters are now a closed, data-driven registry. Adding an adapter is a single + entry in `src/lib/agent-adapters.ts`; the CLI `--integrate` grammar, the + `--integrate all` expansion, and removal machinery all derive from it, and `AgentId` + is derived from the registry instead of a hardcoded union. +- The `init` skill-scaffolding special case is expressed as adapter data + (`scaffoldSkillByInit`) instead of a hardcoded agent id. +- Multiple registered adapters may now share one guidance file. Codex and OpenCode both + use `AGENTS.md`; each owns one marked block, sibling blocks are allowed when properly + paired, and unknown or malformed markers are still rejected with `GK004`. Installing + adapters that share a file in one plan (for example `--integrate all`) appends each + block sequentially. + ## [0.4.1] - 2026-08-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4af5a52..4d42d4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,3 +8,33 @@ content. - Restart Claude Code if the current session began before the repository's `.claude/skills/` directory was created. + +## Development workflow: follow the SDD cycle + +Every feature or behavior change MUST proceed in this order, per the constitution +(`.specify/memory/constitution.md`) and the canonical `AGENTS.md`: + +1. **Constitution** — amend only if a principle changes (documented rationale + impact + report + version bump). Most features need no amendment. +2. **Spec** — write `specs/NNN-short-name/spec.md` (goal, prioritized user stories with + acceptance scenarios, requirements, success criteria). Get it approved before planning. +3. **Plan** — write `specs/NNN-short-name/plan.md` (summary, technical context, + constitution check, sequence, files changed, risks). +4. **Tasks** — write `specs/NNN-short-name/tasks.md` (test-first, phase-ordered, grouped + by user story). +5. **Implementation** — write or update tests FIRST, observe them fail for the intended + reason, then implement the smallest change to pass. Do not implement before the + spec, plan, and tasks exist. + +### Rules of the cycle + +- Do NOT jump straight to implementation. Stop and produce each artifact in order; the + user can approve or redirect at each step. +- Tests are mandatory and precede the code they govern (red → green). Cover both accepted + behavior and its rejection boundary. +- Keep changes small, focused, and test-backed. Preserve stable `GKnnn` diagnostics, exit + codes, schema fields, and append-only semantics. +- `specs/`, `.specify/`, `.codex/`, `history/`, and `PROGRESS.md` are git-ignored local SDD + artifacts. They are handoff material, not commits or package content. +- Run the smallest relevant focused test while developing, then `npm run typecheck` and the + complete suite before handoff. diff --git a/README.md b/README.md index 132a40a..c3b3acb 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,19 @@ npx graphkeeper@latest check Review the displayed plan and confirm it. `init` creates the JSON graph, `evidence/`, 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 all` does both. Codex uses +the Claude skill and reminder; `--integrate cursor` adds the Cursor skill and rule; +`--integrate opencode` adds the OpenCode 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`. Both skills are generated from the same -`templates/SKILL.md`. Existing guidance outside the matching marked block is -preserved. Integration plans are shown before writing; answer the prompt, or pass +`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 +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 +`AGENTS.md`) coexist, each owning one marked block. Existing guidance outside the +matching marked block is preserved. Integration plans are shown before writing; answer the prompt, or pass `--yes` in non-interactive automation. Use `--dry-run` for a complete read-only preflight. Default init and `--force` do not create or change `AGENTS.md` or `CLAUDE.md`. @@ -131,8 +138,8 @@ 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 Codex and/or Claude adapters. 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 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 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. | | `graphkeeper doctor` | Run fast validation plus file existence, containment, line-range, dangling-reference, and unused-entity checks. | diff --git a/docs/agent-integrations.md b/docs/agent-integrations.md index 1c2996d..e582c56 100644 --- a/docs/agent-integrations.md +++ b/docs/agent-integrations.md @@ -1,18 +1,51 @@ # Agent integration architecture -GraphKeeper v1 supports two explicit internal adapters: +GraphKeeper v1 supports the following explicit internal adapters: | ID | Skill | Guidance | Invocation | |---|---|---|---| | `codex` | `.agents/skills/graphkeeper/SKILL.md` | `AGENTS.md` | `$graphkeeper` | | `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` | + +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`. 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. Both skill files are rendered byte-for-byte from +not a public plugin framework. Every skill file is rendered byte-for-byte from `templates/SKILL.md`; vendor-specific text belongs only in adapter metadata and the short marked reminder. +## Adding an adapter + +Adapters are pure data. Adding a new agent is a single entry in the `adapters` array +in `src/lib/agent-adapters.ts`; the command logic, the CLI `--integrate` grammar, the +`--integrate all` expansion, and the removal machinery all derive from that registry +automatically. + +An entry provides: + +- `id` — the CLI token for `--integrate ` and `integrate remove `. +- `displayName` — human-readable label used in plans and diagnostics. +- `skillTarget` — where the canonical `SKILL.md` is written. +- `guidanceTarget` — which file receives the marked reminder block. +- `invocation` — how the agent invokes the skill, used in the reminder text. +- `startMarker` / `endMarker` — HTML-comment markers delimiting GraphKeeper's owned block. +- `scaffoldSkillByInit` — set `true` only when the skill is also scaffolded by + `graphkeeper init` (as for Codex), so the integration step skips rewriting it. +- `postInstallNote` — optional restart hint surfaced after installation. + +To keep the registry well-formed, every entry must satisfy `AgentAdapter` (enforced by +the typed `AGENT_ADAPTERS` assignment), ids must be unique, and `AgentId` is derived +from the array. Tests under `tests/unit/agent-adapters.test.ts` guard these invariants. +Before choosing destinations, confirm the target product's actual skill/rules layout; +the Cursor paths above are a reasonable default, not a substitute for product docs. + ## Command grammar `init` accepts distinct repeated `--integrate codex` and diff --git a/src/cli.ts b/src/cli.ts index 33a926c..1b9e3ad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,13 +48,14 @@ export interface CliTerminal { const VERSION = '0.4.1'; const COMMANDS = new Set(['init', 'integrate', 'check', 'query', 'doctor', 'update']); +const AGENT_GRAMMAR = AGENT_IDS.join('|'); const USAGE = [ 'GraphKeeper - grounded, auditable memory for coding agents', '', 'Usage:', - ' graphkeeper init [--force] [--integrate ]... [--yes] [--dry-run]', - ' graphkeeper integrate remove [--yes] [--dry-run]', + ` graphkeeper init [--force] [--integrate <${AGENT_GRAMMAR}|all>]... [--yes] [--dry-run]`, + ` graphkeeper integrate remove <${AGENT_GRAMMAR}> [--yes] [--dry-run]`, ' graphkeeper check', ' graphkeeper query ', ' graphkeeper doctor', @@ -248,7 +249,7 @@ export async function run( if (parsed === null) { io.stderr(diagnostic( 'GK002', - 'init accepts --force, distinct --integrate codex|claude flags or --integrate all, --yes, and --dry-run', + 'init accepts --force, distinct --integrate ' + AGENT_GRAMMAR + ' flags or --integrate all, --yes, and --dry-run', )); return EXIT_USAGE; } @@ -287,7 +288,7 @@ export async function run( if (parsed === null) { io.stderr(diagnostic( 'GK002', - 'integrate accepts remove followed by optional --yes and --dry-run', + 'integrate accepts remove <' + AGENT_GRAMMAR + '> followed by optional --yes and --dry-run', )); return EXIT_USAGE; } diff --git a/src/commands/init.ts b/src/commands/init.ts index 2d26534..f332ef4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -680,7 +680,9 @@ export async function prepareInitialization( const integrationPlan = integrations.length === 0 ? null : await prepareAgentInstall(root, integrations, options.force, { - skipSkillFor: new Set(['codex']), + skipSkillFor: new Set( + AGENT_IDS.filter((id) => getAgentAdapter(id).scaffoldSkillByInit === true), + ), }); const assets = await loadRequiredAssets(scaffoldActions); const hookPlan = isGitRepository ? await prepareHookPlan(root) : null; diff --git a/src/commands/integrate.ts b/src/commands/integrate.ts index 671d351..fa88680 100644 --- a/src/commands/integrate.ts +++ b/src/commands/integrate.ts @@ -162,14 +162,18 @@ export async function prepareAgentInstall( const operations: FileOperation[] = []; const notes: string[] = []; const fileSnapshots: FileSnapshot[] = []; + const snapshotTargets = new Set(); + const pushSnapshot = (relativeTarget: string, content: string | null): void => { + if (snapshotTargets.has(relativeTarget)) return; + snapshotTargets.add(relativeTarget); + fileSnapshots.push({ relativeTarget, content }); + }; + const plannedGuidance = new Map(); for (const id of adapterIds) { const adapter = getAgentAdapter(id); const existingSkill = await readOptionalRegularFile(root, adapter.skillTarget); - fileSnapshots.push({ - relativeTarget: adapter.skillTarget, - content: existingSkill, - }); + pushSnapshot(adapter.skillTarget, existingSkill); if (!options.skipSkillFor?.has(id)) { const skillKind = existingSkill === null ? 'create' @@ -202,11 +206,11 @@ export async function prepareAgentInstall( } } - const existingGuidance = await readOptionalRegularFile(root, adapter.guidanceTarget); - fileSnapshots.push({ - relativeTarget: adapter.guidanceTarget, - content: existingGuidance, - }); + let existingGuidance = plannedGuidance.get(adapter.guidanceTarget); + if (existingGuidance === undefined) { + existingGuidance = await readOptionalRegularFile(root, adapter.guidanceTarget); + } + pushSnapshot(adapter.guidanceTarget, existingGuidance); const guidance = planGuidanceContent(adapter, existingGuidance); actions.push({ kind: guidance.kind, @@ -223,6 +227,7 @@ export async function prepareAgentInstall( expected: guidance.expected, mode: 0o644, }); + plannedGuidance.set(adapter.guidanceTarget, guidance.content); } if (adapter.postInstallNote !== undefined) { @@ -436,11 +441,19 @@ export async function validateAgentIntegrationPlan( throw operational(snapshot.relativeTarget + ' changed after planning and was preserved'); } } + const writeCounts = new Map(); for (const operation of plan.operations) { if (operation.type === 'write') { - const current = await readOptionalRegularFile(plan.root, operation.relativeTarget); - if (current !== operation.expected) { - throw operational(operation.relativeTarget + ' changed after planning and was preserved'); + writeCounts.set(operation.relativeTarget, (writeCounts.get(operation.relativeTarget) ?? 0) + 1); + } + } + for (const operation of plan.operations) { + if (operation.type === 'write') { + if ((writeCounts.get(operation.relativeTarget) ?? 0) <= 1) { + const current = await readOptionalRegularFile(plan.root, operation.relativeTarget); + if (current !== operation.expected) { + throw operational(operation.relativeTarget + ' changed after planning and was preserved'); + } } continue; } diff --git a/src/lib/agent-adapters.ts b/src/lib/agent-adapters.ts index e2a23d9..d8abf9d 100644 --- a/src/lib/agent-adapters.ts +++ b/src/lib/agent-adapters.ts @@ -1,7 +1,5 @@ import { GraphKeeperError } from './errors.js'; -export type AgentId = 'codex' | 'claude'; - export interface AgentAdapter { readonly id: AgentId; readonly displayName: string; @@ -10,6 +8,7 @@ export interface AgentAdapter { readonly invocation: string; readonly startMarker: string; readonly endMarker: string; + readonly scaffoldSkillByInit?: boolean; readonly postInstallNote?: string; } @@ -30,6 +29,7 @@ const adapters = [ invocation: '`$graphkeeper`', startMarker: '', endMarker: '', + scaffoldSkillByInit: true, }, { id: 'claude', @@ -41,7 +41,29 @@ const adapters = [ endMarker: '', postInstallNote: 'Restart Claude Code if .claude/skills did not exist when the current session started.', }, -] as const satisfies readonly AgentAdapter[]; + { + id: 'cursor', + displayName: 'Cursor', + skillTarget: '.cursor/skills/graphkeeper/SKILL.md', + guidanceTarget: '.cursor/rules/graphkeeper.md', + invocation: '`@graphkeeper`', + startMarker: '', + endMarker: '', + postInstallNote: 'Restart Cursor if .cursor/skills did not exist when the current session began.', + }, + { + id: 'opencode', + displayName: 'OpenCode', + skillTarget: '.opencode/skills/graphkeeper/SKILL.md', + guidanceTarget: 'AGENTS.md', + invocation: '`graphkeeper`', + startMarker: '', + endMarker: '', + postInstallNote: 'Restart opencode if .opencode/skills did not exist when the current session began.', + }, +] as const; + +export type AgentId = (typeof adapters)[number]['id']; export const AGENT_ADAPTERS: readonly AgentAdapter[] = adapters; export const AGENT_IDS: readonly AgentId[] = adapters.map((adapter) => adapter.id); @@ -83,16 +105,59 @@ function guidanceBlock(adapter: AgentAdapter, newline: string): string { ].join(newline); } +interface MarkerTally { + start: number; + end: number; + firstStart?: number; + lastEnd?: number; +} + +const GRAPH_KEEPER_MARKER = /graphkeeper:([a-z0-9_-]+):(start|end)/gi; + +function foreignMarkerProblem( + adapter: AgentAdapter, + content: string, +): { id: string; detail: string } | null { + const tallies = new Map(); + for (const match of content.matchAll(GRAPH_KEEPER_MARKER)) { + const id = match[1] as string; + const kind = match[2] as 'start' | 'end'; + if (id === adapter.id) continue; + let tally = tallies.get(id); + if (tally === undefined) { + tally = { start: 0, end: 0 }; + tallies.set(id, tally); + } + if (kind === 'start') { + tally.start += 1; + if (tally.firstStart === undefined) tally.firstStart = match.index ?? 0; + } else { + tally.end += 1; + tally.lastEnd = match.index ?? 0; + } + } + for (const [id, tally] of tallies) { + if (!isAgentId(id)) { + return { id, detail: 'contains an unregistered GraphKeeper marker' }; + } + if (tally.start !== 1 || tally.end !== 1) { + return { id, detail: 'contains malformed or repeated GraphKeeper markers' }; + } + if (tally.firstStart !== undefined && tally.lastEnd !== undefined && tally.firstStart > tally.lastEnd) { + return { id, detail: 'contains reversed GraphKeeper markers' }; + } + } + return null; +} + function rejectForeignOrMalformedMarkers( adapter: AgentAdapter, content: string, ): void { - const withoutOwnedMarkers = content - .split(adapter.startMarker).join('') - .split(adapter.endMarker).join(''); - if (/graphkeeper:[a-z0-9_-]+:(?:start|end)/i.test(withoutOwnedMarkers)) { + const problem = foreignMarkerProblem(adapter, content); + if (problem !== null) { throw operational( - adapter.guidanceTarget + ' contains mixed or malformed GraphKeeper markers', + adapter.guidanceTarget + ' ' + problem.detail + ' (' + problem.id + ')', ); } } diff --git a/tests/e2e/cli-help.test.ts b/tests/e2e/cli-help.test.ts index 836e707..1242e6a 100644 --- a/tests/e2e/cli-help.test.ts +++ b/tests/e2e/cli-help.test.ts @@ -15,6 +15,7 @@ import { type CliTerminal, run, } from '../../src/cli.js'; +import { AGENT_IDS } from '../../src/lib/agent-adapters.js'; import { runCommand } from '../helpers/repository.js'; function captureIO(): { @@ -42,8 +43,9 @@ test('prints help successfully when no command is provided', async () => { assert.match(capture.stdout.join('\n'), /graphkeeper init/); assert.match(capture.stdout.join('\n'), /graphkeeper update/); assert.equal(capture.stderr.length, 0); - assert.match(capture.stdout.join('\n'), /--integrate /); - assert.match(capture.stdout.join('\n'), /integrate remove /); + const grammar = AGENT_IDS.join('|'); + assert.match(capture.stdout.join('\n'), new RegExp(`--integrate <${grammar}\\|all>`)); + assert.match(capture.stdout.join('\n'), new RegExp(`integrate remove <${grammar}>`)); }); test('parses the documented multi-adapter init option grammar deterministically', () => { @@ -75,7 +77,7 @@ test('parses the documented multi-adapter init option grammar deterministically' ); assert.deepEqual( parseInitArguments(['--integrate', 'all']), - { force: false, integrations: ['codex', 'claude'], yes: false, dryRun: false }, + { force: false, integrations: [...AGENT_IDS], yes: false, dryRun: false }, ); for (const invalid of [ diff --git a/tests/e2e/init.test.ts b/tests/e2e/init.test.ts index 8e2ab4d..6b5d9df 100644 --- a/tests/e2e/init.test.ts +++ b/tests/e2e/init.test.ts @@ -84,6 +84,47 @@ test('explicit Codex integration creates the managed AGENTS.md block through the } }); +test('explicit Cursor integration creates the managed rules block through the CLI', async () => { + const fixture = await createRepositoryFixture(); + try { + const result = await runInit(fixture.root, ['--integrate', 'cursor', '--yes']); + assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr); + assert.match(result.stdout, /CREATE \.cursor\/rules\/graphkeeper\.md/); + const rules = await readFile( + join(fixture.root, '.cursor', 'rules', 'graphkeeper.md'), + 'utf8', + ); + assert.match(rules, //); + assert.match(rules, /invoke `@graphkeeper`/); + assert.equal((rules.match(/graphkeeper:cursor:start/g) ?? []).length, 1); + assert.match( + await readFile(join(fixture.root, '.cursor', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + /^---\nname: graphkeeper\n/, + ); + } finally { + await fixture.cleanup(); + } +}); + +test('explicit OpenCode integration creates the managed AGENTS.md block through the CLI', async () => { + const fixture = await createRepositoryFixture(); + try { + const result = await runInit(fixture.root, ['--integrate', 'opencode', '--yes']); + assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr); + assert.match(result.stdout, /CREATE AGENTS\.md/); + const agents = await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'); + assert.match(agents, //); + assert.match(agents, /invoke `graphkeeper`/); + assert.equal((agents.match(/graphkeeper:opencode:start/g) ?? []).length, 1); + assert.match( + await readFile(join(fixture.root, '.opencode', '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 { @@ -109,7 +150,9 @@ test('--dry-run preflights all adapters without prompting or writing', async () assert.equal(result.exitCode, EXIT_SUCCESS, result.stderr); 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, /\.claude\/skills\/graphkeeper\/SKILL\.md/); + assert.match(result.stdout, /\.opencode\/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'))); @@ -144,7 +187,12 @@ test('all adapters install and conservative removal works through the CLI', asyn const installed = await runInit(fixture.root, ['--integrate', 'all', '--yes']); assert.equal(installed.exitCode, EXIT_SUCCESS, installed.stderr); assert.match(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), /graphkeeper:codex/); + assert.match(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), /graphkeeper:opencode/); assert.match(await readFile(join(fixture.root, 'CLAUDE.md'), 'utf8'), /graphkeeper:claude/); + assert.match( + await readFile(join(fixture.root, '.cursor', 'rules', 'graphkeeper.md'), 'utf8'), + /graphkeeper:cursor/, + ); const refused = await runCli(fixture.root, ['integrate', 'remove', 'claude']); assert.equal(refused.exitCode, EXIT_USAGE); diff --git a/tests/e2e/package-contents.test.ts b/tests/e2e/package-contents.test.ts index cebf31f..2ab40b3 100644 --- a/tests/e2e/package-contents.test.ts +++ b/tests/e2e/package-contents.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; +import { AGENT_IDS } from '../../src/lib/agent-adapters.js'; import { runProcess } from '../../src/lib/process.js'; import { parsePackManifest } from '../helpers/npm-pack.js'; @@ -97,7 +98,7 @@ test('release tarball contains every runtime asset and excludes development-only assert.match(help.stdout, /GraphKeeper - grounded, auditable memory/); assert.match(help.stdout, /graphkeeper doctor/); assert.match(help.stdout, /graphkeeper update/); - assert.match(help.stdout, /integrate remove /); + assert.match(help.stdout, new RegExp(`integrate remove <${AGENT_IDS.join('|')}>`)); assert.match(await readFile(join(packageRoot, 'scripts', 'validate.sh'), 'utf8'), /GraphKeeper: validation passed/); assert.match(await readFile(join(packageRoot, 'scripts', 'validate.mjs'), 'utf8'), /GraphKeeper: validation passed/); assert.match( diff --git a/tests/integration/agent-integrations.test.ts b/tests/integration/agent-integrations.test.ts index 8ebc4ab..89c4617 100644 --- a/tests/integration/agent-integrations.test.ts +++ b/tests/integration/agent-integrations.test.ts @@ -57,6 +57,94 @@ test('Claude integration installs the canonical skill and one independent guidan } }); +test('Cursor 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: ['cursor'], + environment: supportedInitEnvironment(), + }); + assert.equal( + await readFile(join(fixture.root, '.cursor', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + const rules = await readFile( + join(fixture.root, '.cursor', 'rules', 'graphkeeper.md'), + 'utf8', + ); + assert.match(rules, //); + assert.match(rules, /invoke `@graphkeeper`/); + assert.equal((rules.match(/graphkeeper:cursor:start/g) ?? []).length, 1); + assert.ok(report.notes.some((note) => /Restart Cursor/.test(note))); + } finally { + await fixture.cleanup(); + } +}); + +test('Cursor removal deletes only canonical Cursor-owned material and leaves others intact', async () => { + const fixture = await createRepositoryFixture(); + try { + await initialize({ + cwd: fixture.root, + force: false, + integrations: ['codex', 'claude', 'cursor'], + environment: supportedInitEnvironment(), + }); + const plan = await prepareAgentRemoval(fixture.root, 'cursor'); + assert.ok(plan.actions.some((action) => + action.kind === 'remove' && action.target === '.cursor/rules/graphkeeper.md')); + await applyAgentIntegrationPlan(plan); + + assert.doesNotMatch( + await readFile(join(fixture.root, '.cursor', 'rules', 'graphkeeper.md'), 'utf8'), + /graphkeeper:cursor/, + ); + await assert.rejects(stat(join(fixture.root, '.cursor', 'skills', 'graphkeeper'))); + assert.match(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), /graphkeeper:codex:start/); + assert.match(await readFile(join(fixture.root, 'CLAUDE.md'), 'utf8'), /graphkeeper:claude:start/); + + const repeated = await prepareAgentRemoval(fixture.root, 'cursor'); + assert.ok(repeated.actions.every((action) => action.kind === 'skip')); + } finally { + await fixture.cleanup(); + } +}); + +test('OpenCode shares AGENTS.md with Codex and removal preserves the sibling block', async () => { + const fixture = await createRepositoryFixture(); + try { + await initialize({ + cwd: fixture.root, + force: false, + integrations: ['codex', 'opencode'], + environment: supportedInitEnvironment(), + }); + assert.equal( + await readFile(join(fixture.root, '.opencode', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + const agents = await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'); + assert.match(agents, /graphkeeper:codex:start/); + assert.match(agents, /graphkeeper:opencode:start/); + assert.equal((agents.match(/graphkeeper:codex:start/g) ?? []).length, 1); + assert.equal((agents.match(/graphkeeper:opencode:start/g) ?? []).length, 1); + + await applyAgentIntegrationPlan(await prepareAgentRemoval(fixture.root, 'opencode')); + const after = await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'); + assert.doesNotMatch(after, /graphkeeper:opencode/); + assert.match(after, /graphkeeper:codex:start/); + await assert.rejects(stat(join(fixture.root, '.opencode', 'skills', 'graphkeeper'))); + assert.equal( + await readFile(join(fixture.root, '.agents', 'skills', 'graphkeeper', 'SKILL.md'), 'utf8'), + await template(), + ); + } 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 2193712..f7f3137 100644 --- a/tests/unit/agent-adapters.test.ts +++ b/tests/unit/agent-adapters.test.ts @@ -1,28 +1,47 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import test from 'node:test'; import { AGENT_ADAPTERS, + AGENT_IDS, getAgentAdapter, + isAgentId, planGuidanceContent, planGuidanceRemovalContent, + type AgentAdapter, } from '../../src/lib/agent-adapters.js'; import { GraphKeeperError } from '../../src/lib/errors.js'; -test('registers explicit Codex and Claude adapters with independent destinations', () => { - assert.deepEqual(AGENT_ADAPTERS.map((adapter) => adapter.id), ['codex', 'claude']); +const projectRoot = fileURLToPath(new URL('../../../', import.meta.url)); + +function sourceFile(relativePath: string): Promise { + return readFile(join(projectRoot, relativePath), 'utf8'); +} + +test('registers explicit adapters with independent destinations', () => { + assert.deepEqual( + AGENT_ADAPTERS.map((adapter) => adapter.id), + ['codex', 'claude', 'cursor', 'opencode'], + ); assert.deepEqual( AGENT_ADAPTERS.map((adapter) => adapter.skillTarget), [ '.agents/skills/graphkeeper/SKILL.md', '.claude/skills/graphkeeper/SKILL.md', + '.cursor/skills/graphkeeper/SKILL.md', + '.opencode/skills/graphkeeper/SKILL.md', ], ); assert.deepEqual( AGENT_ADAPTERS.map((adapter) => adapter.guidanceTarget), - ['AGENTS.md', 'CLAUDE.md'], + ['AGENTS.md', 'CLAUDE.md', '.cursor/rules/graphkeeper.md', 'AGENTS.md'], ); assert.notEqual(AGENT_ADAPTERS[0]?.startMarker, AGENT_ADAPTERS[1]?.startMarker); + assert.notEqual(AGENT_ADAPTERS[1]?.startMarker, AGENT_ADAPTERS[2]?.startMarker); + assert.notEqual(AGENT_ADAPTERS[2]?.startMarker, AGENT_ADAPTERS[3]?.startMarker); }); test('plans Claude create, append, refresh, and skip without changing outside bytes', () => { @@ -84,3 +103,78 @@ test('removal deletes only the exact owned marker span', () => { expected: '# No block', }); }); + +test('planning allows a properly-paired registered sibling block in a shared guidance file', () => { + const codex = getAgentAdapter('codex'); + const claude = getAgentAdapter('claude'); + const codexBlock = '\n## GraphKeeper memory\n\ntext\n' + + '\n'; + const plan = planGuidanceContent(claude, codexBlock); + assert.equal(plan.kind, 'append'); + assert.match(plan.content, /graphkeeper:codex:start/); + assert.match(plan.content, /graphkeeper:claude:start/); +}); + +test('removal in a shared guidance file preserves a properly-paired sibling block', () => { + const codex = getAgentAdapter('codex'); + const claude = getAgentAdapter('claude'); + const codexBlock = '\nManaged\n' + + '\n'; + const claudeBlock = '\nManaged\n' + + '\n'; + const shared = codexBlock + '\n' + claudeBlock; + const removed = planGuidanceRemovalContent(claude, shared); + if (removed.content === null) throw new Error('expected removal content'); + assert.equal(removed.kind, 'remove'); + assert.ok(removed.content.includes('graphkeeper:codex:start')); + assert.ok(!removed.content.includes('graphkeeper:claude:start')); +}); + +test('every registered adapter satisfies the contract with unique, ordered ids', () => { + const required: readonly (keyof AgentAdapter)[] = [ + 'id', + 'displayName', + 'skillTarget', + 'guidanceTarget', + 'invocation', + 'startMarker', + 'endMarker', + ]; + const seen = new Set(); + for (const adapter of AGENT_ADAPTERS) { + for (const key of required) { + assert.equal(typeof adapter[key], 'string', adapter.id + ' must define ' + key); + assert.ok((adapter[key] as string).length > 0, adapter.id + ' ' + key + ' must be non-empty'); + } + assert.ok( + adapter.postInstallNote === undefined || typeof adapter.postInstallNote === 'string', + adapter.id + ' postInstallNote must be a string or absent', + ); + assert.ok(!seen.has(adapter.id), 'duplicate adapter id: ' + adapter.id); + seen.add(adapter.id); + } + assert.deepEqual(AGENT_IDS, AGENT_ADAPTERS.map((adapter) => adapter.id)); + assert.equal(new Set(AGENT_IDS).size, AGENT_IDS.length, 'AGENT_IDS must be unique'); + for (const id of AGENT_IDS) { + assert.equal(isAgentId(id), true); + assert.equal(getAgentAdapter(id).id, id); + } +}); + +test('cli derives the agent grammar from AGENT_IDS instead of a literal codex|claude string', async () => { + const cliSource = await sourceFile('src/cli.ts'); + assert.equal( + cliSource.includes('codex|claude'), + false, + 'src/cli.ts must not hardcode the agent list; derive the grammar from AGENT_IDS', + ); +}); + +test('init derives its skill-scaffolding skip set from adapter data, not a hardcoded codex literal', async () => { + const initSource = await sourceFile('src/commands/init.ts'); + assert.equal( + initSource.includes("Set(['codex'])"), + false, + 'src/commands/init.ts must not hardcode a codex id; express the skip set as adapter data', + ); +});