From 82a37c27f254e2b844804f23333f9e6502400f07 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 11 Sep 2026 14:38:27 -0700 Subject: [PATCH 1/2] feat: make maintainer command output and help self-explanatory Print an ordered overview for maintainer --help and per-action options for maintainer --help. Name every file add and sync write, label the consumer install commands, show changed files and missing reviews in status and check, validate each skills root once, report all missing repository distribution inputs together, and print unsupported options in kebab-case. --- .changeset/maintainer-ergonomics.md | 5 + packages/intent/src/cli.ts | 19 ++ packages/intent/src/commands/maintainer.ts | 199 ++++++++++++++++-- packages/intent/src/maintainer/add.ts | 11 +- .../intent/src/maintainer/distribution.ts | 13 +- packages/intent/tests/cli.test.ts | 18 ++ packages/intent/tests/maintainer.test.ts | 74 +++++++ 7 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 .changeset/maintainer-ergonomics.md diff --git a/.changeset/maintainer-ergonomics.md b/.changeset/maintainer-ergonomics.md new file mode 100644 index 00000000..f436d8d7 --- /dev/null +++ b/.changeset/maintainer-ergonomics.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Print an ordered overview for `intent maintainer --help` and per-action options for `intent maintainer --help`. Name every file `maintainer add` and `maintainer sync` write, label consumer install commands, show changed files and missing reviews in `maintainer status` and `check`, validate each skills root once, report all missing repository-distribution inputs together, and print unsupported options in their kebab-case form. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index b5f85aed..a799d8df 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -398,6 +398,25 @@ export async function main( return 0 } + if (argv[0] === 'maintainer') { + const { maintainerActions, maintainerHelp } = + await import('./commands/maintainer.js') + const action = argv[1] + const wantsHelp = argv + .slice(1) + .some((arg) => arg === '--help' || arg === '-h') + if (action === undefined) { + console.log(maintainerHelp()) + return 1 + } + if (wantsHelp) { + console.log( + maintainerHelp(action in maintainerActions ? action : undefined), + ) + return 0 + } + } + // cac expects process.argv format: first two entries (binary + script) are ignored cli.parse(['intent', 'intent', ...argv], { run: false }) diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 6f03ccba..da365bcc 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -36,6 +36,159 @@ export interface MaintainerCommandRuntime { reviewPrompts?: ReviewPrompts } +interface MaintainerAction { + usage: string + summary: string + writes: string + options: Array<[flag: string, description: string]> +} + +const optionHelp: Record = { + artifacts: [ + '--artifacts ', + 'Planning record directory, relative to the repository root', + ], + package: [ + '--package ', + 'Owning package directory, relative to the repository root (default: the package that owns the current directory)', + ], + path: ['--path ', 'Skill path relative to the owning package'], + adoptPath: [ + '--path ', + 'Repository-relative custom skill directory to scan', + ], + apply: ['--apply ', 'Apply reviewed adoption choices from a JSON plan'], + domain: ['--domain ', 'Task domain for the skill'], + distribution: [ + '--distribution ', + 'repo to distribute selected skills from the repository, none to opt out', + ], + repository: [ + '--repository ', + 'GitHub repository for distribution', + ], + pluginName: ['--plugin-name ', 'Name for the generated skill plugin'], + skill: ['--skill ', 'Skill to distribute; repeat to select more'], + description: [ + '--description ', + 'Activation description for a new skill', + ], + source: ['--source ', 'Source evidence path; repeat for more'], + requires: ['--requires ', 'Prerequisite skill; repeat for more'], + base: ['--base ', 'Git revision to review against'], + interactive: ['--interactive', 'Inspect and record outcomes in a terminal'], + json: ['--json', 'Print JSON instead of text'], + record: ['--record ', 'Record outcomes from an annotated JSON report'], +} + +// Ordered as a maintainer runs them. `maintainer --help` prints this table and +// `maintainer --help` prints one entry. +export const maintainerActions: Record = { + setup: { + usage: 'maintainer setup [--distribution repo|none] [options]', + summary: 'Initialize planning records and agent instructions.', + writes: + 'skill_tree.yaml, domain_map.yaml, skill_spec.md, and the intent-maintainer block in AGENTS.md (or the existing agent instruction file).', + options: [ + 'artifacts', + 'distribution', + 'repository', + 'pluginName', + 'skill', + ].map((key) => optionHelp[key]!), + }, + adopt: { + usage: + 'maintainer adopt [--json | --apply ] [--path ]', + summary: 'Register existing skills from a reviewed plan.', + writes: + 'The three planning records and the agent instruction block. Skill contents stay as authored.', + options: ['artifacts', 'json', 'adoptPath', 'apply'].map( + (key) => optionHelp[key]!, + ), + }, + add: { + usage: + 'maintainer add --domain [--description --source ...] [options]', + summary: 'Create a skill skeleton or register an existing SKILL.md.', + writes: + 'skills//SKILL.md beside the owning package, plus its entries in skill_tree.yaml, domain_map.yaml, and skill_spec.md.', + options: [ + 'artifacts', + 'package', + 'path', + 'domain', + 'description', + 'source', + 'requires', + ].map((key) => optionHelp[key]!), + }, + status: { + usage: 'maintainer status [--json] [--base ]', + summary: 'Report authoring gaps, files to sync, and pending reviews.', + writes: 'Nothing.', + options: ['artifacts', 'base', 'json'].map((key) => optionHelp[key]!), + }, + sync: { + usage: 'maintainer sync', + summary: + 'Align the skill tree and package metadata with SKILL.md frontmatter.', + writes: + 'skill_tree.yaml, package.json keywords and files, and generated distribution files when repository distribution is selected.', + options: ['artifacts'].map((key) => optionHelp[key]!), + }, + review: { + usage: + 'maintainer review [--json | --interactive | --record ] [--base ]', + summary: 'Find guidance affected by Git changes and record outcomes.', + writes: '.intent/review-state.json when recording; nothing otherwise.', + options: ['base', 'json', 'record', 'interactive'].map( + (key) => optionHelp[key]!, + ), + }, + check: { + usage: 'maintainer check [--base ]', + summary: + 'Fail when authoring issues, stale generated files, or pending reviews remain.', + writes: 'Nothing. Use it as the CI gate.', + options: ['artifacts', 'base'].map((key) => optionHelp[key]!), + }, +} + +export function maintainerHelp(action?: string): string { + const lines: Array = [] + const entries = action + ? [[action, maintainerActions[action]!] as const] + : Object.entries(maintainerActions) + if (!action) { + lines.push( + 'Usage: intent maintainer [options]', + '', + 'Run the actions in this order. Each one is safe to rerun.', + '', + ) + } + for (const [name, entry] of entries) { + lines.push(`${action ? 'Usage: intent ' : `${name}: `}${entry.usage}`) + lines.push(` ${entry.summary}`) + lines.push(` Writes: ${entry.writes}`) + if (action) { + lines.push('', 'Options:') + for (const [flag, description] of entry.options) + lines.push(` ${flag.padEnd(28)} ${description}`) + } else lines.push('') + } + if (!action) + lines.push( + 'Run intent maintainer --help for the options of one action.', + ) + return lines.join('\n') +} + +function kebab(key: string): string { + return key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) +} + export interface MaintainerCommandOptions extends DistributionOptions { artifacts?: string package?: string @@ -97,7 +250,9 @@ export async function runMaintainerCommand( fail(`maintainer ${action} does not take a skill name.`) for (const key of Object.keys(options)) { if (key !== '--' && !allowed[action].includes(key)) - fail(`--${key} is not supported by maintainer ${action}.`) + fail( + `--${kebab(key)} is not supported by maintainer ${action}. Run intent maintainer ${action} --help for its options.`, + ) } if (action === 'review') { if (options.interactive) { @@ -212,19 +367,26 @@ export async function runMaintainerCommand( ) } else if (action === 'add') { const owner = inferOwningPackage(project.root, options.package) + const added = addSkill(project, name, { ...options, package: owner }) + console.log(`Registered ${added.path}.`) + console.log(`Updated: ${added.files.join(', ')}`) console.log( - `Registered ${addSkill(project, name, { ...options, package: owner })}.`, - ) - console.log( - 'Next: author the skill and its task coverage, then run intent maintainer sync, maintainer review, and maintainer check.', + `Next: author the guidance with intent meta generate-skill, record its developer tasks in ${project.artifacts}/domain_map.yaml, then run intent maintainer sync, intent maintainer review, and intent maintainer check.`, ) } else { const plan = planMaintainerSync(project) writeChanges(project.root, plan.changes) - console.log(`Synchronized ${plan.changes.length} file(s).`) + if (plan.changes.length === 0) console.log('Nothing to synchronize.') + for (const change of plan.changes) + console.log( + `Synchronized ${relative(project.root, change.path).replaceAll('\\', '/')}`, + ) for (const problem of plan.problems) console.log(`Remaining: ${problem}`) - for (const command of plan.distribution.commands) console.log(command) + if (plan.distribution.commands.length) + console.log('Consumers install the selected repository skills with:') + for (const command of plan.distribution.commands) + console.log(` ${command}`) } }) return @@ -254,13 +416,26 @@ export async function runMaintainerCommand( for (const problem of status.problems) console.log(` ${problem}`) for (const path of status.staleFiles) console.log(` Run intent maintainer sync: ${path}`) - for (const item of review.items) - console.log( - ` Review ${item.path}${item.problems.length ? `: ${item.problems.join('; ')}` : ''}`, - ) + for (const item of review.items) { + const label = + item.kind === 'skill' + ? 'Review skill' + : item.kind === 'planning' + ? 'Review planning records' + : 'Review unmapped change' + const detail = item.problems.length + ? item.problems.join('; ') + : item.changedFiles.length + ? `changed ${item.changedFiles.join(', ')}` + : 'no recorded review' + console.log(` ${label} ${item.path}: ${detail}`) + } } if (action === 'check') { - for (const dir of new Set(plan.skills.map(dirname))) + // Validate each skills root once instead of once per skill directory. + for (const dir of new Set( + plan.skills.map((path) => dirname(dirname(path))), + )) await runValidateCommand(dir) if (plan.problems.length || plan.changes.length || review.items.length) fail( diff --git a/packages/intent/src/maintainer/add.ts b/packages/intent/src/maintainer/add.ts index ff98659a..236ef41d 100644 --- a/packages/intent/src/maintainer/add.ts +++ b/packages/intent/src/maintainer/add.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, relative } from 'node:path' import { stringify } from 'yaml' import { resolveProjectContext } from '../core/project-context.js' import { parseFrontmatter } from '../shared/utils.js' @@ -38,10 +38,15 @@ export function addSkill( project: MaintainerProject, name: string | undefined, options: AddSkillOptions, -): string { +): { path: string; files: Array } { const plan = planAddSkills(project, [{ name, options }]) writeChanges(project.root, plan.changes) - return plan.paths[0]! + return { + path: plan.paths[0]!, + files: plan.changes.map((change) => + relative(project.root, change.path).replaceAll('\\', '/'), + ), + } } export function planAddSkills( diff --git a/packages/intent/src/maintainer/distribution.ts b/packages/intent/src/maintainer/distribution.ts index 53fe0e39..318b173d 100644 --- a/packages/intent/src/maintainer/distribution.ts +++ b/packages/intent/src/maintainer/distribution.ts @@ -117,10 +117,9 @@ export function planDistributionChoice( options.repository ?? previous?.repository ?? inferDistributionRepository(project) + const missing: Array = [] if (!repositoryPattern.test(repository)) - throw new Error( - 'Choose a GitHub repository with --repository .', - ) + missing.push('a GitHub repository with --repository ') const existingManifest = projectPath( project.root, '.claude-plugin/plugin.json', @@ -138,7 +137,7 @@ export function planDistributionChoice( .replace(/[^a-z0-9]+/g, '-') .replace(/-$/, '')) if (!namePattern.test(name)) - throw new Error('Choose a kebab-case name with --plugin-name .') + missing.push('a kebab-case name with --plugin-name ') if (previous?.name && previous.name !== name) throw new Error( `Keep the existing plugin name ${previous.name}; renaming a published plugin requires a separate migration.`, @@ -147,9 +146,11 @@ export function planDistributionChoice( ? stringList([options.skill].flat(), '--skill') : (previous?.skills ?? []) if (!skills.length) - throw new Error( - 'Select public skills explicitly with --skill (repeat for multiple skills).', + missing.push( + 'the public skills with --skill (repeat for multiple skills)', ) + if (missing.length) + throw new Error(`Repository distribution needs: ${missing.join('; ')}.`) const entries = skillEntries(project, tree) for (const selected of skills) { const entry = entries.find( diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index ddb6dc3d..a57bcc61 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -281,6 +281,24 @@ describe('cli commands', () => { expect(errorSpy).toHaveBeenCalledWith('Unknown command: wat') }) + it('prints an ordered maintainer overview and per-action options', async () => { + expect(await main(['maintainer', '--help'])).toBe(0) + const overview = getHelpOutput() + expect(overview).toContain('Run the actions in this order.') + expect(overview.indexOf('setup:')).toBeLessThan(overview.indexOf('check:')) + expect(overview).toContain('Writes: Nothing. Use it as the CI gate.') + + logSpy.mockClear() + expect(await main(['maintainer', 'add', '--help'])).toBe(0) + const add = getHelpOutput() + expect(add).toContain('--domain ') + expect(add).not.toContain('--record') + + logSpy.mockClear() + expect(await main(['maintainer'])).toBe(1) + expect(getHelpOutput()).toContain('Usage: intent maintainer ') + }) + it('prints command help when --help is passed after a subcommand', async () => { const exitCode = await main(['list', '--help']) const output = getHelpOutput() diff --git a/packages/intent/tests/maintainer.test.ts b/packages/intent/tests/maintainer.test.ts index 9244b8d7..b9fb6faa 100644 --- a/packages/intent/tests/maintainer.test.ts +++ b/packages/intent/tests/maintainer.test.ts @@ -694,3 +694,77 @@ it('does not ask for a review of the files setup and sync write', async () => { .sort(), ).toEqual(['planning:skills/_artifacts', 'skill:skills/query/SKILL.md']) }) + +it('names written files, labels install commands, and explains unsupported options', async () => { + const logs = () => vi.mocked(console.log).mock.calls.flat().map(String) + const errors = () => vi.mocked(console.error).mock.calls.flat().map(String) + write('src/query.ts', 'export const query = () => 1\n') + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) + vi.mocked(console.log).mockClear() + expect( + await main([ + 'maintainer', + 'add', + 'query', + '--domain', + 'queries', + '--description', + 'Use when querying with Library.', + '--source', + 'src/query.ts', + ]), + ).toBe(0) + expect(logs()).toEqual([ + 'Registered skills/query/SKILL.md.', + 'Updated: skills/query/SKILL.md, skills/_artifacts/skill_tree.yaml, skills/_artifacts/domain_map.yaml, skills/_artifacts/skill_spec.md', + expect.stringContaining('skills/_artifacts/domain_map.yaml'), + ]) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(logs()[0]).toBe('Synchronized package.json') + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(logs()[0]).toBe('Nothing to synchronize.') + expect(await main(['maintainer', 'sync', '--plugin-name', 'x'])).toBe(1) + expect(errors().at(-1)).toBe( + '--plugin-name is not supported by maintainer sync. Run intent maintainer sync --help for its options.', + ) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'status'])).toBe(0) + expect(logs()).toContain( + ' Review skill skills/query/SKILL.md: changed skills/query/SKILL.md, src/query.ts', + ) + expect(logs()).toContain( + ' Review planning records skills/_artifacts: changed skills/_artifacts/domain_map.yaml, skills/_artifacts/skill_spec.md, skills/_artifacts/skill_tree.yaml, skills/query/SKILL.md, src/query.ts', + ) +}) + +it('reports every missing repository distribution input at once', async () => { + expect(await main(['maintainer', 'setup', '--distribution', 'repo'])).toBe(1) + expect(vi.mocked(console.error).mock.calls.flat().map(String).at(-1)).toBe( + 'Repository distribution needs: a GitHub repository with --repository ; a kebab-case name with --plugin-name ; the public skills with --skill (repeat for multiple skills).', + ) +}) + +it('validates each skills root once during check', async () => { + write('src/query.ts', 'export const query = () => 1\n') + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) + for (const name of ['one', 'two']) { + write( + `skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: Use ${name}.\nsources: [src/query.ts]\n---\nGuidance.\n`, + ) + expect(await main(['maintainer', 'add', name, '--domain', 'queries'])).toBe( + 0, + ) + } + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'check'])).toBe(1) + expect( + vi + .mocked(console.log) + .mock.calls.flat() + .map(String) + .filter((line) => line.includes('Validated 2 skill files')), + ).toHaveLength(1) +}) From cef37f91d064ab3f880be1caf7c850fd6109cd86 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 11 Sep 2026 14:38:55 -0700 Subject: [PATCH 2/2] test: match the maintainer help overview in the packed release check --- .../tests/integration/packed-release.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index 82d4fe87..0441ba2a 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -192,9 +192,17 @@ describe('packed release', () => { } } expect(run(['scaffold']).status).toBe(1) - expect(run(['maintainer', '--help']).stdout).toContain( - 'setup|adopt|add|status|sync|review|check', - ) + const overview = run(['maintainer', '--help']).stdout + for (const action of [ + 'setup', + 'adopt', + 'add', + 'status', + 'sync', + 'review', + 'check', + ]) + expect(overview).toContain(`\n${action}: maintainer ${action}`) }) it('keeps nested authoring references usable within the extracted package', () => {