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/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', () => { 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) +})