From 9bec33efb11dfa4c4664f39d467bd2d02b66690d Mon Sep 17 00:00:00 2001 From: anusbutt Date: Sun, 6 Sep 2026 17:17:02 +0500 Subject: [PATCH] feat(cli): add contextual append claim help --- CHANGELOG.md | 3 + README.md | 2 +- docs/append-commands.md | 13 ++ src/cli.ts | 16 ++- src/commands/append.ts | 177 ++++++++++++++++++++++++- src/lib/command-help.ts | 87 ++++++++++++ templates/SKILL.md | 3 + tests/e2e/cli-help.test.ts | 107 +++++++++++++++ tests/e2e/package-install.test.ts | 7 + tests/integration/release-docs.test.ts | 10 ++ tests/integration/skill-doc.test.ts | 7 + tests/unit/append-command.test.ts | 45 ++++++- tests/unit/command-help.test.ts | 67 ++++++++++ 13 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 src/lib/command-help.ts create mode 100644 tests/unit/command-help.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13fd5a7..a4fbd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ versioning; while the package is below 1.0, minor releases may change public beh - `graphkeeper close run`, which atomically closes one existing open run with an end timestamp and verdict while preserving its accumulated provenance. +- Contextual `graphkeeper append --help` and `graphkeeper append claim --help` output, + including complete claim flags, source-specific requirements, and copyable examples + without repository access or mutation. ### Changed diff --git a/README.md b/README.md index f9c514a..f91b90c 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ An agent writes to `graph/` and `evidence/`, and `graphkeeper check` (and the in | `graphkeeper check` | Run the same fast schema, append-only history, and committed-evidence 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. | -| `graphkeeper append claim ...` | Concurrency-serially append a validating claim and link it into its producing run. See the [append command reference](docs/append-commands.md) for flags and lifecycle constraints. | +| `graphkeeper append claim ...` | Concurrency-serially append a validating claim and link it into its producing run. Run `graphkeeper append claim --help` for installed syntax; see the [append command reference](docs/append-commands.md) for lifecycle constraints. | | `graphkeeper append run ...` | Concurrency-serially create a validating run record. It remains create-only; see the [append command reference](docs/append-commands.md). | | `graphkeeper close run --id --ended --verdict ` | Concurrency-serially close one existing open run without replacing its accumulated provenance. | | `graphkeeper update` | Check npm's stable `latest` release and globally install one exact newer version. Repository files are never changed. | diff --git a/docs/append-commands.md b/docs/append-commands.md index 9c1adab..5c503e7 100644 --- a/docs/append-commands.md +++ b/docs/append-commands.md @@ -5,6 +5,19 @@ and closing runs. Use these commands whenever an agent changes `graph/runs.json` `graph/claims.json`; they serialize writers, validate the candidate state, and avoid the lost-update race caused by two sessions reading and replacing the same JSON file. +Use the installed CLI as the authoritative source for current command syntax: + +```sh +graphkeeper append --help +graphkeeper append claim --help +``` + +The append overview identifies supported record types. Contextual claim help lists +every accepted claim flag, separates tool-output requirements from inference +requirements, and includes copyable examples. Help is read-only and works without an +initialized repository; agents do not need to inspect GraphKeeper's internal package +source to discover flags. + ## Safe recording sequence 1. Resolve or add the subject entity in `graph/entities.json`. Entity IDs are stable diff --git a/src/cli.ts b/src/cli.ts index 83187e8..bc23834 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,11 @@ import { prepareAgentRemoval, type IntegrationAction, } from './commands/integrate.js'; -import { parseAppendArguments, runAppend } from './commands/append.js'; +import { + APPEND_HELP_TOPICS, + parseAppendArguments, + runAppend, +} from './commands/append.js'; import { parseCloseArguments, runClose } from './commands/close.js'; import { query } from './commands/query.js'; import { updateGraphKeeper } from './commands/update.js'; @@ -30,6 +34,10 @@ import { GraphKeeperError, diagnostic, } from './lib/errors.js'; +import { + renderCommandHelp, + resolveContextualHelp, +} from './lib/command-help.js'; export const EXIT_SUCCESS = EXIT_CODES.success; export const EXIT_VALIDATION = EXIT_CODES.validation; @@ -242,6 +250,12 @@ export async function run( return EXIT_SUCCESS; } + const contextualHelp = resolveContextualHelp(argv, APPEND_HELP_TOPICS); + if (contextualHelp !== undefined) { + io.stdout(renderCommandHelp(contextualHelp)); + return EXIT_SUCCESS; + } + if (!COMMANDS.has(command)) { io.stderr('Unknown command: ' + command); io.stderr(USAGE); diff --git a/src/commands/append.ts b/src/commands/append.ts index 58f4f3a..3854873 100644 --- a/src/commands/append.ts +++ b/src/commands/append.ts @@ -4,6 +4,10 @@ import { dirname, join } from 'node:path'; import { EXIT_CODES, GraphKeeperError, diagnostic, type ExitCode } from '../lib/errors.js'; import { findGitRoot } from '../lib/git.js'; +import type { + CommandHelpOption, + CommandHelpTopic, +} from '../lib/command-help.js'; import { acquireLock, LockTimeoutError, @@ -271,14 +275,177 @@ export async function runAppend(options: AppendOptions, cwd: string = process.cw } } -const CLAIM_FLAGS = new Set([ - 'subject', 'predicate', 'object', 'confidence', 'kind', 'command', - 'exit-code', 'ref', 'captured', 'basis', 'produced-by', 'created', 'id', 'supersedes', -]); +type ClaimOptionGroup = + | 'common' + | 'source' + | 'tool-output' + | 'inference' + | 'optional'; + +export interface ClaimOptionDefinition extends CommandHelpOption { + readonly group: ClaimOptionGroup; +} + +export const CLAIM_OPTION_DEFINITIONS: readonly ClaimOptionDefinition[] = [ + { + name: 'subject', + value: 'entity-id', + group: 'common', + description: 'Existing canonical entity ID.', + }, + { + name: 'predicate', + value: 'value', + group: 'common', + description: 'One flat relationship or property name.', + }, + { + name: 'object', + value: 'value', + group: 'common', + description: 'The claimed value.', + }, + { + name: 'produced-by', + value: 'run-id', + group: 'common', + description: 'Existing open run that produced the claim.', + }, + { + name: 'kind', + value: 'tool_output|inference', + group: 'source', + description: 'Source kind; tool_output is the default.', + }, + { + name: 'command', + value: 'text', + group: 'tool-output', + description: 'Command recorded as inert data.', + }, + { + name: 'exit-code', + value: 'integer', + group: 'tool-output', + description: 'Captured command exit code.', + }, + { + name: 'ref', + value: 'reference', + group: 'tool-output', + description: 'Inclusive evidence/#L-L reference.', + }, + { + name: 'captured', + value: 'timestamp', + group: 'tool-output', + description: 'Whole-second UTC evidence capture time.', + }, + { + name: 'basis', + value: 'text', + group: 'inference', + description: 'Non-empty reasoning basis; not external proof.', + }, + { + name: 'confidence', + value: 'number', + group: 'optional', + description: 'Schema-valid confidence; inference cannot use 1.', + }, + { + name: 'id', + value: 'claim-id', + group: 'optional', + description: 'Unique claim ID; generated when omitted.', + }, + { + name: 'created', + value: 'timestamp', + group: 'optional', + description: 'Whole-second UTC time; current UTC when omitted.', + }, + { + name: 'supersedes', + value: 'claim-id', + group: 'optional', + description: 'Existing active claim corrected by this claim.', + }, +]; + +const CLAIM_FLAGS = new Set(CLAIM_OPTION_DEFINITIONS.map((option) => option.name)); const RUN_FLAGS = new Set([ 'id', 'started', 'tool', 'task', 'evidence', 'claims-written', 'ended', 'verdict', ]); +function helpOptions(group: ClaimOptionGroup): readonly CommandHelpOption[] { + return CLAIM_OPTION_DEFINITIONS.filter((option) => option.group === group); +} + +export const APPEND_HELP_TOPIC: CommandHelpTopic = { + path: ['append'], + summary: 'Create a run or append one grounded claim through a safe writer.', + usage: [ + 'graphkeeper append claim [options]', + 'graphkeeper append run [options]', + ], + details: [ + 'claim Append a claim. Detailed help: graphkeeper append claim --help', + 'run Create a run. See the append command reference for its complete grammar.', + ], + optionGroups: [], + examples: [], +}; + +export const APPEND_CLAIM_HELP_TOPIC: CommandHelpTopic = { + path: ['append', 'claim'], + summary: [ + 'Append one flat claim and link it to an existing open producing run.', + 'Recorded --command text is inert data; GraphKeeper never executes it.', + ].join(' '), + usage: [ + 'graphkeeper append claim --subject --predicate ' + + '--object --produced-by [source options] [optional options]', + ], + details: [], + optionGroups: [ + { heading: 'Common required', options: helpOptions('common') }, + { heading: 'Source selection', options: helpOptions('source') }, + { heading: 'Tool-output required', options: helpOptions('tool-output') }, + { heading: 'Inference required', options: helpOptions('inference') }, + { heading: 'Optional', options: helpOptions('optional') }, + ], + examples: [ + { + label: 'Tool output', + command: [ + 'graphkeeper append claim \\', + ' --subject test_payments_flaky --predicate has_status \\', + ' --object passing_with_utc_default --kind tool_output \\', + ' --command "TZ=UTC npm test -- payments" --exit-code 0 \\', + ' --ref evidence/utc-rerun.log#L1-L3 \\', + ' --captured 2026-09-05T09:04:00Z \\', + ' --produced-by run_2026-09-05-investigation_a1', + ].join('\n'), + }, + { + label: 'Inference', + command: [ + 'graphkeeper append claim \\', + ' --subject test_payments_flaky --predicate may_depend_on \\', + ' --object timezone_configuration --kind inference \\', + ' --basis "The observed result changes when TZ changes." \\', + ' --produced-by run_2026-09-05-investigation_a1', + ].join('\n'), + }, + ], +}; + +export const APPEND_HELP_TOPICS: readonly CommandHelpTopic[] = [ + APPEND_HELP_TOPIC, + APPEND_CLAIM_HELP_TOPIC, +]; + function splitFlags(args: readonly string[]): Map { const map = new Map(); for (let index = 0; index < args.length; index += 1) { @@ -393,4 +560,4 @@ export function parseAppendArguments(recordType: string, args: readonly string[] } throw error; } -} \ No newline at end of file +} diff --git a/src/lib/command-help.ts b/src/lib/command-help.ts new file mode 100644 index 0000000..d1ca10a --- /dev/null +++ b/src/lib/command-help.ts @@ -0,0 +1,87 @@ +export interface CommandHelpOption { + readonly name: string; + readonly value: string; + readonly description: string; +} + +export interface CommandHelpOptionGroup { + readonly heading: string; + readonly options: readonly CommandHelpOption[]; +} + +export interface CommandHelpExample { + readonly label: string; + readonly command: string; +} + +export interface CommandHelpTopic { + readonly path: readonly string[]; + readonly summary: string; + readonly usage: readonly string[]; + readonly details: readonly string[]; + readonly optionGroups: readonly CommandHelpOptionGroup[]; + readonly examples: readonly CommandHelpExample[]; +} + +function isHelpToken(argument: string): boolean { + return argument === '--help' || argument === '-h'; +} + +function samePath(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length + && left.every((part, index) => part === right[index]); +} + +/** + * Resolve help only for an exact registered leading command path. Once the path is + * recognized, an explicit help token takes precedence over any trailing options so + * help remains informational and never reaches command validation. + */ +export function resolveContextualHelp( + argv: readonly string[], + topics: readonly CommandHelpTopic[], +): CommandHelpTopic | undefined { + if (!argv.some(isHelpToken)) return undefined; + + const firstOption = argv.findIndex((argument) => argument.startsWith('-')); + const path = argv.slice(0, firstOption === -1 ? argv.length : firstOption); + return topics.find((topic) => samePath(topic.path, path)); +} + +function optionSyntax(option: CommandHelpOption): string { + return '--' + option.name + ' <' + option.value + '>'; +} + +/** Render stable plain text without reading process or repository state. */ +export function renderCommandHelp(topic: CommandHelpTopic): string { + const lines = [ + 'GraphKeeper ' + topic.path.join(' '), + '', + topic.summary, + '', + 'Usage:', + ...topic.usage.map((usage) => ' ' + usage), + ]; + + if (topic.details.length > 0) { + lines.push('', 'Commands:', ...topic.details.map((detail) => ' ' + detail)); + } + + for (const group of topic.optionGroups) { + const width = Math.max(...group.options.map((option) => optionSyntax(option).length)); + lines.push('', group.heading + ':'); + for (const option of group.options) { + lines.push(' ' + optionSyntax(option).padEnd(width) + ' ' + option.description); + } + } + + if (topic.examples.length > 0) { + lines.push('', 'Examples:'); + for (const example of topic.examples) { + lines.push(' ' + example.label + ':'); + lines.push(...example.command.split('\n').map((line) => ' ' + line)); + } + } + + return lines.join('\n'); +} diff --git a/templates/SKILL.md b/templates/SKILL.md index 31393de..d7913d0 100644 --- a/templates/SKILL.md +++ b/templates/SKILL.md @@ -74,6 +74,9 @@ behavioral responsibility that software cannot infer reliably. `graphkeeper close run`; do not edit graph/claims.json or graph/runs.json by hand. The commands serialize concurrent writers so separate sessions never overwrite each other's records. +- [GUIDANCE] Use `graphkeeper append claim --help` from the installed CLI for current + claim syntax; that installed syntax is the source of truth. Do not inspect internal + package source to discover command flags. - [HOOK] A tool_output source records kind, command, exit_code, captured, and ref exactly; ref identifies the supporting inclusive evidence lines. - [HOOK] An inference source records kind and a short non-empty basis. It contains no diff --git a/tests/e2e/cli-help.test.ts b/tests/e2e/cli-help.test.ts index 0cab01b..5170c45 100644 --- a/tests/e2e/cli-help.test.ts +++ b/tests/e2e/cli-help.test.ts @@ -50,6 +50,113 @@ test('prints help successfully when no command is provided', async () => { assert.match(capture.stdout.join('\n'), new RegExp(`integrate remove <${grammar}>`)); }); +test('prints contextual append overview for both help tokens without repository access', async () => { + for (const helpToken of ['--help', '-h']) { + const capture = captureIO(); + const exitCode = await run( + ['append', helpToken], + capture.io, + '/path/that/does/not/exist', + ); + + assert.equal(exitCode, EXIT_SUCCESS); + assert.equal(capture.stderr.length, 0); + const output = capture.stdout.join('\n'); + assert.match(output, /Usage:/); + assert.match(output, /graphkeeper append claim/); + assert.match(output, /graphkeeper append run/); + assert.match(output, /graphkeeper append claim --help/); + } +}); + +test('prints complete contextual claim help for tool output and inference', async () => { + const expectedFlags = [ + '--subject', '--predicate', '--object', '--produced-by', '--kind', + '--command', '--exit-code', '--ref', '--captured', '--basis', + '--confidence', '--id', '--created', '--supersedes', + ]; + + for (const helpToken of ['--help', '-h']) { + const capture = captureIO(); + const exitCode = await run( + ['append', 'claim', helpToken], + capture.io, + '/path/that/does/not/exist', + ); + + assert.equal(exitCode, EXIT_SUCCESS); + assert.equal(capture.stderr.length, 0); + const output = capture.stdout.join('\n'); + for (const flag of expectedFlags) assert.match(output, new RegExp(flag)); + assert.match(output, /Common required/i); + assert.match(output, /Tool-output required/i); + assert.match(output, /Inference required/i); + assert.match(output, /tool_output.*default/is); + assert.match(output, /GraphKeeper never executes/i); + assert.ok( + (output.match(/graphkeeper append claim/g) ?? []).length >= 2, + 'help must contain copyable examples for both source kinds', + ); + } +}); + +test('recognized claim help takes precedence over incomplete and invalid options', async () => { + for (const args of [ + ['append', 'claim', '--subject', '--help'], + ['append', 'claim', '--unknown', 'value', '--help'], + ['append', 'claim', '--kind', 'invalid', '-h'], + ]) { + const capture = captureIO(); + const exitCode = await run(args, capture.io, '/path/that/does/not/exist'); + + assert.equal(exitCode, EXIT_SUCCESS, args.join(' ')); + assert.equal(capture.stderr.length, 0); + assert.match(capture.stdout.join('\n'), /graphkeeper append claim/); + } +}); + +test('contextual claim help leaves initialized graph data byte-for-byte unchanged', async () => { + const fixture = await createValidatorFixture('graphkeeper-claim-help-'); + try { + await fixture.writeGraph([], [], []); + const paths = ['entities.json', 'claims.json', 'runs.json']; + const before = await Promise.all(paths.map((path) => ( + readFile(join(fixture.root, 'graph', path), 'utf8') + ))); + const capture = captureIO(); + + const exitCode = await run( + ['append', 'claim', '--help'], + capture.io, + fixture.root, + ); + + assert.equal(exitCode, EXIT_SUCCESS); + assert.equal(capture.stderr.length, 0); + const after = await Promise.all(paths.map((path) => ( + readFile(join(fixture.root, 'graph', path), 'utf8') + ))); + assert.deepEqual(after, before); + } finally { + await fixture.cleanup(); + } +}); + +test('unsupported help paths and normal malformed appends remain usage errors', async () => { + for (const args of [ + ['append', 'unknown', '--help'], + ['append', 'run', '--help'], + ['append', 'claim', '--unknown', 'value'], + ]) { + const capture = captureIO(); + const exitCode = await run(args, capture.io, '/path/that/does/not/exist'); + + assert.equal(exitCode, EXIT_USAGE, args.join(' ')); + assert.match(capture.stderr.join('\n'), /GK002/); + assert.equal(capture.stdout.length, 0); + } +}); + test('parses the documented multi-adapter init option grammar deterministically', () => { assert.deepEqual( parseInitArguments([]), diff --git a/tests/e2e/package-install.test.ts b/tests/e2e/package-install.test.ts index 015361e..a004c71 100644 --- a/tests/e2e/package-install.test.ts +++ b/tests/e2e/package-install.test.ts @@ -119,6 +119,13 @@ test('a tarball installs in a clean directory and runs init, check, query, and d const help = await runCli(['--help']); assert.equal(help.exitCode, 0, help.stderr); assert.match(help.stdout, /graphkeeper update/); + const claimHelp = await runCli(['append', 'claim', '--help']); + assert.equal(claimHelp.exitCode, 0, claimHelp.stderr); + assert.equal(claimHelp.stderr, ''); + assert.match(claimHelp.stdout, /Common required/i); + assert.match(claimHelp.stdout, /--produced-by/); + assert.match(claimHelp.stdout, /--supersedes/); + assert.match(claimHelp.stdout, /GraphKeeper never executes/i); const version = await runCli(['--version']); assert.equal(version.exitCode, 0, version.stderr); assert.equal(version.stdout, '0.5.0\n'); diff --git a/tests/integration/release-docs.test.ts b/tests/integration/release-docs.test.ts index 7de1b7a..661d4c3 100644 --- a/tests/integration/release-docs.test.ts +++ b/tests/integration/release-docs.test.ts @@ -47,6 +47,16 @@ test('append command reference documents the explicit create-claim-close lifecyc assert.doesNotMatch(reference, /transition directly in\s+`graph\/runs\.json`/is); }); +test('public documentation identifies installed contextual claim help as authoritative', async () => { + const readme = await readFile(join(projectRoot, 'README.md'), 'utf8'); + const reference = await readFile(join(projectRoot, 'docs', 'append-commands.md'), 'utf8'); + + assert.match(readme, /graphkeeper append claim --help/); + assert.match(reference, /graphkeeper append --help/); + assert.match(reference, /graphkeeper append claim --help/); + assert.match(reference, /installed.*authoritative.*syntax/is); +}); + test('npm metadata points to the canonical public repository and support channels', async () => { const manifest = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) as { name?: string; diff --git a/tests/integration/skill-doc.test.ts b/tests/integration/skill-doc.test.ts index b316f0e..3b46db4 100644 --- a/tests/integration/skill-doc.test.ts +++ b/tests/integration/skill-doc.test.ts @@ -31,6 +31,13 @@ test('teaches honest tool-output and inference sourcing', async () => { assert.match(skill, /never.*invent.*evidence/is); }); +test('uses installed contextual help as the claim syntax source of truth', async () => { + const skill = await readFile(skillUrl, 'utf8'); + assert.match(skill, /`graphkeeper append claim --help`/); + assert.match(skill, /installed.*syntax.*source of truth/is); + assert.match(skill, /do not.*inspect.*internal.*source/is); +}); + test('teaches atomic claims, exact grounding, and bounded certainty', async () => { const skill = await readFile(skillUrl, 'utf8'); assert.match(skill, /one independently changeable fact per claim/is); diff --git a/tests/unit/append-command.test.ts b/tests/unit/append-command.test.ts index 8ca8035..0d23b58 100644 --- a/tests/unit/append-command.test.ts +++ b/tests/unit/append-command.test.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseAppendArguments, buildClaim, buildRun, generateRunId } from '../../src/commands/append.js'; +import { + APPEND_CLAIM_HELP_TOPIC, + CLAIM_OPTION_DEFINITIONS, + buildClaim, + buildRun, + generateRunId, + parseAppendArguments, +} from '../../src/commands/append.js'; test('parseAppendArguments parses a tool_output claim', () => { const parsed = parseAppendArguments('claim', [ @@ -44,6 +51,40 @@ test('parseAppendArguments rejects unknown claim flag', () => { assert.deepEqual(parsed, { ok: false, usageError: 'unknown claim flag: --nope' }); }); +test('claim parser acceptance and contextual help derive from one complete option catalogue', () => { + const expected = new Map([ + ['subject', 'subject'], + ['predicate', 'predicate'], + ['object', 'object'], + ['confidence', '0.8'], + ['kind', 'tool_output'], + ['command', 'npm test'], + ['exit-code', '0'], + ['ref', 'evidence/test.log#L1-L1'], + ['captured', '2026-09-05T09:00:00Z'], + ['basis', 'reasoning'], + ['produced-by', 'run_2026-09-05-test'], + ['created', '2026-09-05T09:00:00Z'], + ['id', 'claim_1234abcd'], + ['supersedes', 'claim_8765dcba'], + ]); + const definedNames = CLAIM_OPTION_DEFINITIONS.map((option) => option.name); + + assert.equal(CLAIM_OPTION_DEFINITIONS.length, expected.size); + assert.equal(new Set(definedNames).size, definedNames.length); + assert.deepEqual(new Set(definedNames), new Set(expected.keys())); + + const documentedNames = APPEND_CLAIM_HELP_TOPIC.optionGroups + .flatMap((group) => group.options) + .map((option) => option.name); + assert.deepEqual(new Set(documentedNames), new Set(expected.keys())); + + for (const [name, value] of expected) { + const parsed = parseAppendArguments('claim', ['--' + name, value]); + assert.equal(parsed.ok, true, '--' + name + ' must be accepted'); + } +}); + test('parseAppendArguments rejects invalid kind and verdict', () => { const badKind = parseAppendArguments('claim', ['--kind', 'magic', '--produced-by', 'x']); assert.equal(badKind.ok, false); @@ -75,4 +116,4 @@ test('buildClaim and buildRun produce records that round-trip', () => { const run = buildRun({ started: '2026-07-22T09:00:00Z', tool: 'codex', id: 'x' }); assert.equal(run.id, 'x'); assert.match(generateRunId('2026-07-22T09:00:00Z'), /^run_2026-07-22-[0-9a-f]{4}$/); -}); \ No newline at end of file +}); diff --git a/tests/unit/command-help.test.ts b/tests/unit/command-help.test.ts new file mode 100644 index 0000000..79a341b --- /dev/null +++ b/tests/unit/command-help.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + renderCommandHelp, + resolveContextualHelp, + type CommandHelpTopic, +} from '../../src/lib/command-help.js'; + +const overview: CommandHelpTopic = { + path: ['append'], + summary: 'Append one supported record.', + usage: ['graphkeeper append [options]'], + details: ['claim Append a claim.', 'run Create a run.'], + examples: [], + optionGroups: [], +}; + +const claim: CommandHelpTopic = { + path: ['append', 'claim'], + summary: 'Append one claim.', + usage: ['graphkeeper append claim --subject [options]'], + details: [], + optionGroups: [{ + heading: 'Common required', + options: [{ + name: 'subject', + value: 'entity-id', + description: 'Existing canonical entity ID.', + }], + }], + examples: [{ + label: 'Inference', + command: 'graphkeeper append claim --subject example --kind inference', + }], +}; + +const topics = [overview, claim]; + +test('resolveContextualHelp selects only an exact registered leading command path', () => { + assert.equal(resolveContextualHelp(['append', '--help'], topics), overview); + assert.equal(resolveContextualHelp(['append', '-h'], topics), overview); + assert.equal(resolveContextualHelp(['append', 'claim', '--help'], topics), claim); + assert.equal( + resolveContextualHelp(['append', 'claim', '--subject', 'x', '--help'], topics), + claim, + ); + assert.equal(resolveContextualHelp(['append', 'unknown', '--help'], topics), undefined); + assert.equal(resolveContextualHelp(['append', 'claim'], topics), undefined); + assert.equal(resolveContextualHelp(['append', 'claim', '--unknown', 'x'], topics), undefined); +}); + +test('renderCommandHelp renders ordered usage, details, options, and examples', () => { + const output = renderCommandHelp(claim); + + assert.match(output, /^GraphKeeper append claim\n/); + assert.match(output, /Append one claim\.\n\nUsage:\n graphkeeper append claim/); + assert.match(output, /Common required:\n --subject Existing canonical entity ID\./); + assert.match(output, /Examples:\n Inference:\n graphkeeper append claim/); + assert.equal(output.endsWith('\n'), false); +}); + +test('renderCommandHelp is deterministic and does not mutate topic definitions', () => { + const before = structuredClone(claim); + assert.equal(renderCommandHelp(claim), renderCommandHelp(claim)); + assert.deepEqual(claim, before); +});