From c103569c7a21b6c08b998ebbd1cc0a874cafb4f8 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 13:05:00 -0700 Subject: [PATCH 01/15] feat: refactor CLI to use cac for command handling and improve command structure --- packages/intent/package.json | 1 + packages/intent/src/cli.ts | 159 +++++++++++++++++++----------- packages/intent/tests/cli.test.ts | 7 ++ pnpm-lock.yaml | 3 + 4 files changed, 111 insertions(+), 59 deletions(-) diff --git a/packages/intent/package.json b/packages/intent/package.json index 4f18e25..2a287f7 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -27,6 +27,7 @@ "meta" ], "dependencies": { + "cac": "^6.7.14", "yaml": "^2.7.0" }, "devDependencies": { diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 0c74230..fefb375 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { cac } from 'cac' import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' import { dirname, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' @@ -651,57 +652,56 @@ Run \`intent help \` for details on a specific command.`) console.log(HELP_BY_COMMAND[command] ?? USAGE) } -export async function main(argv: Array = process.argv.slice(2)) { - const command = argv[0] - const commandArgs = argv.slice(1) - - try { - if (!command || isHelpFlag(command)) { - printHelp() - return 0 - } - - if (command === 'help') { - printHelp(commandArgs[0]) - return 0 - } - - if (isHelpFlag(commandArgs[0])) { - printHelp(command) - return 0 - } - - switch (command) { - case 'list': - await cmdList(commandArgs) - return 0 - case 'meta': - await cmdMeta(commandArgs) - return 0 - case 'validate': - await cmdValidate(commandArgs) - return 0 - case 'install': { - console.log(INSTALL_PROMPT) - return 0 - } - case 'scaffold': { - cmdScaffold() - return 0 - } - case 'stale': { - const jsonStale = commandArgs.includes('--json') - const targetDir = commandArgs.find((arg) => !arg.startsWith('-')) +function createCli() { + const cli = cac('intent') + + cli + .command('list', 'Discover intent-enabled packages') + .option('--json', 'Output JSON') + .action(async (options: { json?: boolean }) => { + await cmdList(options.json ? ['--json'] : []) + }) + + cli + .command('meta [name]', 'List meta-skills, or print one by name') + .action(async (name?: string) => { + await cmdMeta(name ? [name] : []) + }) + + cli + .command('validate [dir]', 'Validate skill files') + .action(async (dir?: string) => { + await cmdValidate(dir ? [dir] : []) + }) + + cli + .command( + 'install', + 'Print a skill that guides your coding agent to set up skill-to-task mappings', + ) + .action(() => { + console.log(INSTALL_PROMPT) + }) + + cli.command('scaffold', 'Print maintainer scaffold prompt').action(() => { + cmdScaffold() + }) + + cli + .command('stale [dir]', 'Check skills for staleness') + .option('--json', 'Output JSON') + .action( + async (targetDir: string | undefined, options: { json?: boolean }) => { const { reports } = await resolveStaleTargets(targetDir) if (reports.length === 0) { console.log('No intent-enabled packages found.') - return 0 + return } - if (jsonStale) { + if (options.json) { console.log(JSON.stringify(reports, null, 2)) - return 0 + return } for (const report of reports) { @@ -724,22 +724,63 @@ export async function main(argv: Array = process.argv.slice(2)) { } console.log() } - return 0 - } - case 'edit-package-json': { - const { runEditPackageJsonAll } = await import('./setup.js') - runEditPackageJsonAll(process.cwd()) - return 0 - } - case 'setup-github-actions': { - const { runSetupGithubActions } = await import('./setup.js') - runSetupGithubActions(process.cwd(), getMetaDir()) - return 0 - } - default: - printHelp() - return command ? 1 : 0 + }, + ) + + cli + .command( + 'edit-package-json', + 'Update package.json files so skills are published', + ) + .action(async () => { + const { runEditPackageJsonAll } = await import('./setup.js') + runEditPackageJsonAll(process.cwd()) + }) + + cli + .command( + 'setup-github-actions', + 'Copy Intent CI workflow templates into .github/workflows/', + ) + .action(async () => { + const { runSetupGithubActions } = await import('./setup.js') + runSetupGithubActions(process.cwd(), getMetaDir()) + }) + + return cli +} + +export async function main(argv: Array = process.argv.slice(2)) { + const command = argv[0] + const commandArgs = argv.slice(1) + + try { + if (!command || isHelpFlag(command)) { + printHelp() + return 0 } + + if (command === 'help') { + printHelp(commandArgs[0]) + return 0 + } + + if (isHelpFlag(commandArgs[0])) { + printHelp(command) + return 0 + } + + if (!(command in HELP_BY_COMMAND)) { + printHelp() + return command ? 1 : 0 + } + + const cli = createCli() + cli.help() + cli.version(false) + cli.parse(['intent', 'intent', ...argv], { run: false }) + await cli.runMatchedCommand() + return 0 } catch (err) { if (isCliFailure(err)) { console.error(err.message) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 756f590..8d887cb 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -120,6 +120,13 @@ describe('cli commands', () => { expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') }) + it('prints top-level help for unknown commands', async () => { + const exitCode = await main(['wat']) + + expect(exitCode).toBe(1) + expect(logSpy.mock.calls[0]?.[0]).toContain(USAGE) + }) + it('prints command help for help subcommands', async () => { const exitCode = await main(['help', 'validate']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e95ad1b..4e2f7dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: packages/intent: dependencies: + cac: + specifier: ^6.7.14 + version: 6.7.14 yaml: specifier: ^2.7.0 version: 2.8.2 From 35e424a59943f4a267e5967025df16889f85ad5e Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 13:23:11 -0700 Subject: [PATCH 02/15] feat: refactor CLI commands and improve error handling structure --- packages/intent/src/cli-error.ts | 19 ++ packages/intent/src/cli.ts | 338 +++++------------------ packages/intent/src/commands/install.ts | 59 ++++ packages/intent/src/commands/list.ts | 111 ++++++++ packages/intent/src/commands/scaffold.ts | 71 +++++ packages/intent/tests/cli.test.ts | 11 +- 6 files changed, 343 insertions(+), 266 deletions(-) create mode 100644 packages/intent/src/cli-error.ts create mode 100644 packages/intent/src/commands/install.ts create mode 100644 packages/intent/src/commands/list.ts create mode 100644 packages/intent/src/commands/scaffold.ts diff --git a/packages/intent/src/cli-error.ts b/packages/intent/src/cli-error.ts new file mode 100644 index 0000000..b9c4eb1 --- /dev/null +++ b/packages/intent/src/cli-error.ts @@ -0,0 +1,19 @@ +export type CliFailure = { + message: string + exitCode: number +} + +export function fail(message: string, exitCode = 1): never { + throw { message, exitCode } satisfies CliFailure +} + +export function isCliFailure(value: unknown): value is CliFailure { + return ( + !!value && + typeof value === 'object' && + 'message' in value && + typeof value.message === 'string' && + 'exitCode' in value && + typeof value.exitCode === 'number' + ) +} diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index fefb375..c3eac40 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -4,32 +4,84 @@ import { cac } from 'cac' import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' import { dirname, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { INSTALL_PROMPT } from './install-prompt.js' +import { fail, isCliFailure } from './cli-error.js' +import { runInstallCommand } from './commands/install.js' +import { runListCommand } from './commands/list.js' +import { runScaffoldCommand } from './commands/scaffold.js' import type { ScanResult } from './types.js' -function getMetaDir(): string { - const thisDir = dirname(fileURLToPath(import.meta.url)) - return join(thisDir, '..', 'meta') +export const USAGE = `TanStack Intent CLI + +Usage: + intent list [--json] Discover intent-enabled packages + intent meta [name] List meta-skills, or print one by name + intent validate [] Validate skill files (default: skills/) + intent install Print a skill that guides your coding agent to set up skill-to-task mappings + intent scaffold Print maintainer scaffold prompt + intent edit-package-json Wire package.json (files, keywords) for skill publishing + intent setup-github-actions Copy CI workflow templates to .github/workflows/ + intent stale [dir] [--json] Check skills for staleness` + +const HELP_BY_COMMAND: Record = { + list: `${USAGE} + +Examples: + intent list + intent list --json`, + meta: `intent meta [name] + +List shipped meta-skills, or print a single meta-skill by name. + +Examples: + intent meta + intent meta domain-discovery`, + validate: `intent validate [dir] + +Validate SKILL.md files in the target directory. + +Examples: + intent validate + intent validate packages/query/skills`, + install: `intent install + +Print the install prompt used to set up skill-to-task mappings.`, + scaffold: `intent scaffold + +Print the guided maintainer prompt for generating skills.`, + stale: `intent stale [dir] [--json] + +Check installed skills for version and source drift. + +Examples: + intent stale + intent stale packages/query + intent stale --json`, + 'edit-package-json': `intent edit-package-json + +Update package.json files so skills are published.`, + 'setup-github-actions': `intent setup-github-actions + +Copy Intent CI workflow templates into .github/workflows/.`, } -type CliFailure = { - message: string - exitCode: number +function isHelpFlag(arg: string | undefined): boolean { + return arg === '-h' || arg === '--help' } -function fail(message: string, exitCode = 1): never { - throw { message, exitCode } satisfies CliFailure +function printHelp(command?: string): void { + if (!command) { + console.log(`${USAGE} + +Run \`intent help \` for details on a specific command.`) + return + } + + console.log(HELP_BY_COMMAND[command] ?? USAGE) } -function isCliFailure(value: unknown): value is CliFailure { - return ( - !!value && - typeof value === 'object' && - 'message' in value && - typeof value.message === 'string' && - 'exitCode' in value && - typeof value.exitCode === 'number' - ) +function getMetaDir(): string { + const thisDir = dirname(fileURLToPath(import.meta.url)) + return join(thisDir, '..', 'meta') } async function scanIntentsOrFail(): Promise { @@ -51,34 +103,6 @@ function printWarnings(warnings: Array): void { } } -function formatScanCoverage(result: ScanResult): string { - const coverage: Array = [] - - if (result.nodeModules.local.scanned) coverage.push('project node_modules') - if (result.nodeModules.global.scanned) coverage.push('global node_modules') - - return coverage.join(', ') -} - -function printVersionConflicts(result: ScanResult): void { - if (result.conflicts.length === 0) return - - console.log('\nVersion conflicts:\n') - for (const conflict of result.conflicts) { - console.log(` ${conflict.packageName} -> using ${conflict.chosen.version}`) - console.log(` chosen: ${conflict.chosen.packageRoot}`) - - for (const variant of conflict.variants) { - if (variant.packageRoot === conflict.chosen.packageRoot) continue - console.log( - ` also found: ${variant.version} at ${variant.packageRoot}`, - ) - } - - console.log() - } -} - function buildValidationFailure( errors: Array<{ file: string; message: string }>, warnings: Array, @@ -99,77 +123,6 @@ function buildValidationFailure( return lines.join('\n') } -async function cmdList(args: Array): Promise { - const { computeSkillNameWidth, printSkillTree, printTable } = - await import('./display.js') - const jsonOutput = args.includes('--json') - const result = await scanIntentsOrFail() - - if (jsonOutput) { - console.log(JSON.stringify(result, null, 2)) - return - } - - const scanCoverage = formatScanCoverage(result) - - if (result.packages.length === 0) { - console.log('No intent-enabled packages found.') - if (scanCoverage) console.log(`Scanned: ${scanCoverage}`) - if (result.warnings.length > 0) { - console.log() - printWarnings(result.warnings) - } - return - } - - const totalSkills = result.packages.reduce( - (sum, p) => sum + p.skills.length, - 0, - ) - console.log( - `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills (${result.packageManager})\n`, - ) - if (scanCoverage) { - console.log( - `Scanned: ${scanCoverage}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, - ) - } - - // Summary table - const rows = result.packages.map((pkg) => [ - pkg.name, - pkg.version, - String(pkg.skills.length), - pkg.intent.requires?.join(', ') || '–', - ]) - printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) - - printVersionConflicts(result) - - // Skills detail - const allSkills = result.packages.map((p) => p.skills) - const nameWidth = computeSkillNameWidth(allSkills) - const showTypes = result.packages.some((p) => p.skills.some((s) => s.type)) - - console.log(`\nSkills:\n`) - for (const pkg of result.packages) { - console.log(` ${pkg.name}`) - printSkillTree(pkg.skills, { nameWidth, showTypes }) - console.log() - } - - console.log(`Feedback:`) - console.log( - ` Submit feedback on skill usage to help maintainers improve the skills.`, - ) - console.log( - ` Load: node_modules/@tanstack/intent/meta/feedback-collection/SKILL.md`, - ) - console.log() - - printWarnings(result.warnings) -} - async function cmdMeta(args: Array): Promise { const { parseFrontmatter } = await import('./utils.js') const metaDir = getMetaDir() @@ -508,150 +461,6 @@ async function cmdValidate(args: Array): Promise { printWarnings(warnings) } -function cmdScaffold(): void { - const metaDir = getMetaDir() - const metaSkillPath = (name: string) => join(metaDir, name, 'SKILL.md') - - const prompt = `You are helping a library maintainer scaffold Intent skills. - -Run the three meta skills below **one at a time, in order**. For each step: -1. Load the SKILL.md file specified -2. Follow its instructions completely -3. Present outputs to the maintainer for review -4. Do NOT proceed to the next step until the maintainer confirms - -## Before you start - -Gather this context yourself (do not ask the maintainer — agents should never -ask for information they can discover): -1. Read package.json for library name, repository URL, and homepage/docs URL -2. Detect if this is a monorepo (look for workspaces field, packages/ directory, lerna.json) -3. Use skills/ as the default skills root -4. For monorepos: - - Domain map artifacts go at the REPO ROOT: _artifacts/ - - Skills go INSIDE EACH PACKAGE: packages//skills/ - - Identify which packages are client-facing (usually client SDKs and primary framework adapters) - ---- - -## Step 1 — Domain Discovery - -Load and follow: ${metaSkillPath('domain-discovery')} - -This produces: domain_map.yaml and skill_spec.md in the artifacts directory. -Domain discovery covers the WHOLE library (one domain map even for monorepos). - -**STOP. Review outputs with the maintainer before continuing.** - ---- - -## Step 2 — Tree Generator - -Load and follow: ${metaSkillPath('tree-generator')} - -This produces: skill_tree.yaml in the artifacts directory. -For monorepos, each skill entry should include a \`package\` field. - -**STOP. Review outputs with the maintainer before continuing.** - ---- - -## Step 3 — Generate Skills - -Load and follow: ${metaSkillPath('generate-skill')} - -This produces: individual SKILL.md files. -- Single-repo: skills///SKILL.md -- Monorepo: packages//skills///SKILL.md - ---- - -## After all skills are generated - -1. Run \`intent validate\` in each package directory -2. Commit skills/ and artifacts -3. For each publishable package, run: \`npx @tanstack/intent edit-package-json\` -4. Ensure each package has \`@tanstack/intent\` as a devDependency -5. Create a \`skill:\` label on the GitHub repo for each skill (use \`gh label create\`) -6. Add a README note: "If you use an AI agent, run \`npx @tanstack/intent@latest install\`" -` - - console.log(prompt) -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -export const USAGE = `TanStack Intent CLI - -Usage: - intent list [--json] Discover intent-enabled packages - intent meta [name] List meta-skills, or print one by name - intent validate [] Validate skill files (default: skills/) - intent install Print a skill that guides your coding agent to set up skill-to-task mappings - intent scaffold Print maintainer scaffold prompt - intent edit-package-json Wire package.json (files, keywords) for skill publishing - intent setup-github-actions Copy CI workflow templates to .github/workflows/ - intent stale [dir] [--json] Check skills for staleness` - -const HELP_BY_COMMAND: Record = { - list: `${USAGE} - -Examples: - intent list - intent list --json`, - meta: `intent meta [name] - -List shipped meta-skills, or print a single meta-skill by name. - -Examples: - intent meta - intent meta domain-discovery`, - validate: `intent validate [dir] - -Validate SKILL.md files in the target directory. - -Examples: - intent validate - intent validate packages/query/skills`, - install: `intent install - -Print the install prompt used to set up skill-to-task mappings.`, - scaffold: `intent scaffold - -Print the guided maintainer prompt for generating skills.`, - stale: `intent stale [dir] [--json] - -Check installed skills for version and source drift. - -Examples: - intent stale - intent stale packages/query - intent stale --json`, - 'edit-package-json': `intent edit-package-json - -Update package.json files so skills are published.`, - 'setup-github-actions': `intent setup-github-actions - -Copy Intent CI workflow templates into .github/workflows/.`, -} - -function isHelpFlag(arg: string | undefined): boolean { - return arg === '-h' || arg === '--help' -} - -function printHelp(command?: string): void { - if (!command) { - console.log(`${USAGE} - -Run \`intent help \` for details on a specific command.`) - return - } - - console.log(HELP_BY_COMMAND[command] ?? USAGE) -} - function createCli() { const cli = cac('intent') @@ -659,7 +468,7 @@ function createCli() { .command('list', 'Discover intent-enabled packages') .option('--json', 'Output JSON') .action(async (options: { json?: boolean }) => { - await cmdList(options.json ? ['--json'] : []) + await runListCommand(options, scanIntentsOrFail) }) cli @@ -680,11 +489,11 @@ function createCli() { 'Print a skill that guides your coding agent to set up skill-to-task mappings', ) .action(() => { - console.log(INSTALL_PROMPT) + runInstallCommand() }) cli.command('scaffold', 'Print maintainer scaffold prompt').action(() => { - cmdScaffold() + runScaffoldCommand(getMetaDir()) }) cli @@ -777,7 +586,6 @@ export async function main(argv: Array = process.argv.slice(2)) { const cli = createCli() cli.help() - cli.version(false) cli.parse(['intent', 'intent', ...argv], { run: false }) await cli.runMatchedCommand() return 0 diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts new file mode 100644 index 0000000..91fa79a --- /dev/null +++ b/packages/intent/src/commands/install.ts @@ -0,0 +1,59 @@ +export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. + +Follow these steps in order: + +1. CHECK FOR EXISTING MAPPINGS + Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, + .github/copilot-instructions.md) for a block delimited by: + + + - If found: show the user the current mappings, keep that file as the source of truth, + and ask "What would you like to update?" Then skip to step 4 with their requested changes. + - If not found: continue to step 2. + +2. DISCOVER AVAILABLE SKILLS + Run: \`npx @tanstack/intent@latest list\` + This outputs each skill's name, description, full path, and whether it was found in + project-local node_modules or accessible global node_modules. + This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop + with node_modules enabled). + +3. SCAN THE REPOSITORY + Build a picture of the project's structure and patterns: + - Read package.json for library dependencies + - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) + - Note recurring patterns (routing, data fetching, auth, UI components, etc.) + + Based on this, propose 3-5 skill-to-task mappings. For each one explain: + - The task or code area (in plain language the user would recognise) + - Which skill applies and why + + Then ask: "What other tasks do you commonly use AI coding agents for? + I'll create mappings for those too." + Also ask: "I'll default to AGENTS.md unless you want another supported config file. + Do you have a preference?" + +4. WRITE THE MAPPINGS BLOCK + Once you have the full set of mappings, write or update the agent config file. + - If you found an existing intent-skills block, update that file in place. + - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. + + Use this exact block: + + +# Skill mappings - when working in these areas, load the linked skill file into context. +skills: + - task: "describe the task or code area here" + load: "node_modules/package-name/skills/skill-name/SKILL.md" + + + Rules: + - Use the user's own words for task descriptions + - Include the exact path from \`npx @tanstack/intent@latest list\` output so agents can load it directly + - Keep entries concise - this block is read on every agent task + - Preserve all content outside the block tags unchanged + - If the user is on Deno, note that this setup is best-effort today and relies on npm interop` + +export function runInstallCommand(): void { + console.log(INSTALL_PROMPT) +} diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts new file mode 100644 index 0000000..0e7b323 --- /dev/null +++ b/packages/intent/src/commands/list.ts @@ -0,0 +1,111 @@ +import type { ScanResult } from '../types.js' + +function printWarnings(warnings: Array): void { + if (warnings.length === 0) return + + console.log('Warnings:') + for (const warning of warnings) { + console.log(` ⚠ ${warning}`) + } +} + +function formatScanCoverage(result: ScanResult): string { + const coverage: Array = [] + + if (result.nodeModules.local.scanned) coverage.push('project node_modules') + if (result.nodeModules.global.scanned) coverage.push('global node_modules') + + return coverage.join(', ') +} + +function printVersionConflicts(result: ScanResult): void { + if (result.conflicts.length === 0) return + + console.log('\nVersion conflicts:\n') + for (const conflict of result.conflicts) { + console.log(` ${conflict.packageName} -> using ${conflict.chosen.version}`) + console.log(` chosen: ${conflict.chosen.packageRoot}`) + + for (const variant of conflict.variants) { + if (variant.packageRoot === conflict.chosen.packageRoot) continue + console.log( + ` also found: ${variant.version} at ${variant.packageRoot}`, + ) + } + + console.log() + } +} + +export async function runListCommand( + options: { json?: boolean }, + scanIntentsOrFail: () => Promise, +): Promise { + const { computeSkillNameWidth, printSkillTree, printTable } = + await import('../display.js') + const result = await scanIntentsOrFail() + + if (options.json) { + console.log(JSON.stringify(result, null, 2)) + return + } + + const scanCoverage = formatScanCoverage(result) + + if (result.packages.length === 0) { + console.log('No intent-enabled packages found.') + if (scanCoverage) console.log(`Scanned: ${scanCoverage}`) + if (result.warnings.length > 0) { + console.log() + printWarnings(result.warnings) + } + return + } + + const totalSkills = result.packages.reduce( + (sum, pkg) => sum + pkg.skills.length, + 0, + ) + console.log( + `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills (${result.packageManager})\n`, + ) + if (scanCoverage) { + console.log( + `Scanned: ${scanCoverage}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, + ) + } + + const rows = result.packages.map((pkg) => [ + pkg.name, + pkg.version, + String(pkg.skills.length), + pkg.intent.requires?.join(', ') || '–', + ]) + printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) + + printVersionConflicts(result) + + const allSkills = result.packages.map((pkg) => pkg.skills) + const nameWidth = computeSkillNameWidth(allSkills) + const showTypes = result.packages.some((pkg) => + pkg.skills.some((skill) => skill.type), + ) + + console.log(`\nSkills:\n`) + for (const pkg of result.packages) { + console.log(` ${pkg.name}`) + printSkillTree(pkg.skills, { nameWidth, showTypes }) + console.log() + } + + console.log('Feedback:') + console.log( + ' Submit feedback on skill usage to help maintainers improve the skills.', + ) + console.log( + ' Load: node_modules/@tanstack/intent/meta/feedback-collection/SKILL.md', + ) + console.log() + + printWarnings(result.warnings) +} diff --git a/packages/intent/src/commands/scaffold.ts b/packages/intent/src/commands/scaffold.ts new file mode 100644 index 0000000..7a1a350 --- /dev/null +++ b/packages/intent/src/commands/scaffold.ts @@ -0,0 +1,71 @@ +import { join } from 'node:path' + +export function runScaffoldCommand(metaDir: string): void { + const metaSkillPath = (name: string) => join(metaDir, name, 'SKILL.md') + + const prompt = `You are helping a library maintainer scaffold Intent skills. + +Run the three meta skills below **one at a time, in order**. For each step: +1. Load the SKILL.md file specified +2. Follow its instructions completely +3. Present outputs to the maintainer for review +4. Do NOT proceed to the next step until the maintainer confirms + +## Before you start + +Gather this context yourself (do not ask the maintainer — agents should never +ask for information they can discover): +1. Read package.json for library name, repository URL, and homepage/docs URL +2. Detect if this is a monorepo (look for workspaces field, packages/ directory, lerna.json) +3. Use skills/ as the default skills root +4. For monorepos: + - Domain map artifacts go at the REPO ROOT: _artifacts/ + - Skills go INSIDE EACH PACKAGE: packages//skills/ + - Identify which packages are client-facing (usually client SDKs and primary framework adapters) + +--- + +## Step 1 — Domain Discovery + +Load and follow: ${metaSkillPath('domain-discovery')} + +This produces: domain_map.yaml and skill_spec.md in the artifacts directory. +Domain discovery covers the WHOLE library (one domain map even for monorepos). + +**STOP. Review outputs with the maintainer before continuing.** + +--- + +## Step 2 — Tree Generator + +Load and follow: ${metaSkillPath('tree-generator')} + +This produces: skill_tree.yaml in the artifacts directory. +For monorepos, each skill entry should include a \`package\` field. + +**STOP. Review outputs with the maintainer before continuing.** + +--- + +## Step 3 — Generate Skills + +Load and follow: ${metaSkillPath('generate-skill')} + +This produces: individual SKILL.md files. +- Single-repo: skills///SKILL.md +- Monorepo: packages//skills///SKILL.md + +--- + +## After all skills are generated + +1. Run \`intent validate\` in each package directory +2. Commit skills/ and artifacts +3. For each publishable package, run: \`npx @tanstack/intent edit-package-json\` +4. Ensure each package has \`@tanstack/intent\` as a devDependency +5. Create a \`skill:\` label on the GitHub repo for each skill (use \`gh label create\`) +6. Add a README note: "If you use an AI agent, run \`npx @tanstack/intent@latest install\`" +` + + console.log(prompt) +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 8d887cb..b4b0e87 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { INSTALL_PROMPT } from '../src/install-prompt.js' +import { INSTALL_PROMPT } from '../src/commands/install.js' import { main, USAGE } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) @@ -152,6 +152,15 @@ describe('cli commands', () => { expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) }) + it('prints the scaffold prompt', async () => { + const exitCode = await main(['scaffold']) + const output = String(logSpy.mock.calls[0]?.[0]) + + expect(exitCode).toBe(0) + expect(output).toContain('## Step 1') + expect(output).toContain('meta/domain-discovery/SKILL.md') + }) + it('lists installed intent packages as json', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-')) tempDirs.push(root) From 946999574cca4c4ee231f1bdfcef8ffd4ce6a15e Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 13:29:50 -0700 Subject: [PATCH 03/15] feat: implement validation command for skill files and refactor CLI structure --- packages/intent/src/cli.ts | 261 +---------------------- packages/intent/src/commands/validate.ts | 254 ++++++++++++++++++++++ packages/intent/src/install-prompt.ts | 55 ----- packages/intent/src/intent-library.ts | 2 +- 4 files changed, 258 insertions(+), 314 deletions(-) create mode 100644 packages/intent/src/commands/validate.ts delete mode 100644 packages/intent/src/install-prompt.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index c3eac40..416ebae 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -2,12 +2,13 @@ import { cac } from 'cac' import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' -import { dirname, join, relative, sep } from 'node:path' +import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { fail, isCliFailure } from './cli-error.js' import { runInstallCommand } from './commands/install.js' import { runListCommand } from './commands/list.js' import { runScaffoldCommand } from './commands/scaffold.js' +import { runValidateCommand } from './commands/validate.js' import type { ScanResult } from './types.js' export const USAGE = `TanStack Intent CLI @@ -94,35 +95,6 @@ async function scanIntentsOrFail(): Promise { } } -function printWarnings(warnings: Array): void { - if (warnings.length === 0) return - - console.log('Warnings:') - for (const warning of warnings) { - console.log(` ⚠ ${warning}`) - } -} - -function buildValidationFailure( - errors: Array<{ file: string; message: string }>, - warnings: Array, -): string { - const lines = ['', `❌ Validation failed with ${errors.length} error(s):`, ''] - - for (const { file, message } of errors) { - lines.push(` ${file}: ${message}`) - } - - if (warnings.length > 0) { - lines.push('', '⚠ Packaging warnings:') - for (const warning of warnings) { - lines.push(` ${warning}`) - } - } - - return lines.join('\n') -} - async function cmdMeta(args: Array): Promise { const { parseFrontmatter } = await import('./utils.js') const metaDir = getMetaDir() @@ -179,89 +151,6 @@ async function cmdMeta(args: Array): Promise { console.log(`Path: node_modules/@tanstack/intent/meta//SKILL.md`) } -function collectPackagingWarnings(root: string): Array { - const pkgJsonPath = join(root, 'package.json') - if (!existsSync(pkgJsonPath)) return [] - - let pkgJson: Record - try { - pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return [`Could not parse package.json: ${msg}`] - } - - const warnings: Array = [] - - const devDeps = pkgJson.devDependencies as Record | undefined - if (!devDeps?.['@tanstack/intent']) { - warnings.push('@tanstack/intent is not in devDependencies') - } - - const keywords = pkgJson.keywords - if (!Array.isArray(keywords) || !keywords.includes('tanstack-intent')) { - warnings.push('Missing "tanstack-intent" in keywords array') - } - - const files = pkgJson.files as Array | undefined - if (Array.isArray(files)) { - if (!files.includes('skills')) { - warnings.push( - '"skills" is not in the "files" array — skills won\'t be published', - ) - } - - // Only warn about !skills/_artifacts for non-monorepo packages. - // In monorepos, artifacts live at the repo root, so the negation - // pattern is intentionally omitted by edit-package-json. - const isMonorepoPkg = (() => { - let dir = join(root, '..') - for (let i = 0; i < 5; i++) { - const parentPkg = join(dir, 'package.json') - if (existsSync(parentPkg)) { - try { - const parent = JSON.parse(readFileSync(parentPkg, 'utf8')) - return ( - Array.isArray(parent.workspaces) || parent.workspaces?.packages - ) - } catch { - return false - } - } - const next = dirname(dir) - if (next === dir) break - dir = next - } - return false - })() - - if (!isMonorepoPkg && !files.includes('!skills/_artifacts')) { - warnings.push( - '"!skills/_artifacts" is not in the "files" array — artifacts will be published unnecessarily', - ) - } - } - - return warnings -} - -function resolvePackageRoot(startDir: string): string { - let dir = startDir - - while (true) { - if (existsSync(join(dir, 'package.json'))) { - return dir - } - - const next = dirname(dir) - if (next === dir) { - return startDir - } - - dir = next - } -} - function readPackageName(root: string): string { try { const pkgJson = JSON.parse( @@ -317,150 +206,6 @@ async function resolveStaleTargets(targetDir?: string) { } } -async function cmdValidate(args: Array): Promise { - const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([ - import('yaml'), - import('./utils.js'), - ]) - const targetDir = args[0] ?? 'skills' - const skillsDir = join(process.cwd(), targetDir) - const packageRoot = resolvePackageRoot(skillsDir) - - if (!existsSync(skillsDir)) { - fail(`Skills directory not found: ${skillsDir}`) - } - - interface ValidationError { - file: string - message: string - } - - const errors: Array = [] - const skillFiles = findSkillFiles(skillsDir) - - if (skillFiles.length === 0) { - fail('No SKILL.md files found') - } - - for (const filePath of skillFiles) { - const rel = relative(process.cwd(), filePath) - const content = readFileSync(filePath, 'utf8') - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) - - if (!match) { - errors.push({ file: rel, message: 'Missing or invalid frontmatter' }) - continue - } - - if (!match[1]) { - errors.push({ file: rel, message: 'Missing YAML frontmatter' }) - continue - } - - let fm: Record - try { - fm = parseYaml(match[1]) as Record - } catch { - errors.push({ file: rel, message: 'Invalid YAML frontmatter' }) - continue - } - - if (!fm.name) - errors.push({ file: rel, message: 'Missing required field: name' }) - if (!fm.description) - errors.push({ file: rel, message: 'Missing required field: description' }) - - // Validate name matches directory path - if (typeof fm.name === 'string') { - const expectedPath = relative(skillsDir, filePath) - .replace(/[/\\]SKILL\.md$/, '') - .split(sep) - .join('/') - if (fm.name !== expectedPath) { - errors.push({ - file: rel, - message: `name "${fm.name}" does not match directory path "${expectedPath}"`, - }) - } - } - - // Description character limit - if (typeof fm.description === 'string' && fm.description.length > 1024) { - errors.push({ - file: rel, - message: `Description exceeds 1024 character limit (${fm.description.length} chars)`, - }) - } - - // Framework skills must have requires - if (fm.type === 'framework' && !Array.isArray(fm.requires)) { - errors.push({ - file: rel, - message: 'Framework skills must have a "requires" field', - }) - } - - // Line count - const lineCount = content.split(/\r?\n/).length - if (lineCount > 500) { - errors.push({ - file: rel, - message: `Exceeds 500 line limit (${lineCount} lines). Rewrite for conciseness: move API tables to references/, trim verbose examples, and remove content an agent already knows. Do not simply raise the limit.`, - }) - } - } - - const artifactsDir = join(skillsDir, '_artifacts') - if (existsSync(artifactsDir)) { - const requiredArtifacts = [ - 'domain_map.yaml', - 'skill_spec.md', - 'skill_tree.yaml', - ] - - for (const fileName of requiredArtifacts) { - const artifactPath = join(artifactsDir, fileName) - if (!existsSync(artifactPath)) { - errors.push({ - file: relative(process.cwd(), artifactPath), - message: 'Missing required artifact', - }) - continue - } - - const content = readFileSync(artifactPath, 'utf8') - if (content.trim().length === 0) { - errors.push({ - file: relative(process.cwd(), artifactPath), - message: 'Artifact file is empty', - }) - continue - } - - if (fileName.endsWith('.yaml')) { - try { - parseYaml(content) - } catch { - errors.push({ - file: relative(process.cwd(), artifactPath), - message: 'Invalid YAML in artifact file', - }) - } - } - } - } - - const warnings = collectPackagingWarnings(packageRoot) - - if (errors.length > 0) { - fail(buildValidationFailure(errors, warnings)) - } - - console.log(`✅ Validated ${skillFiles.length} skill files — all passed`) - if (warnings.length > 0) console.log() - printWarnings(warnings) -} - function createCli() { const cli = cac('intent') @@ -480,7 +225,7 @@ function createCli() { cli .command('validate [dir]', 'Validate skill files') .action(async (dir?: string) => { - await cmdValidate(dir ? [dir] : []) + await runValidateCommand(dir) }) cli diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts new file mode 100644 index 0000000..c6d44bf --- /dev/null +++ b/packages/intent/src/commands/validate.ts @@ -0,0 +1,254 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, relative, sep } from 'node:path' +import { fail } from '../cli-error.js' + +function printWarnings(warnings: Array): void { + if (warnings.length === 0) return + + console.log('Warnings:') + for (const warning of warnings) { + console.log(` ⚠ ${warning}`) + } +} + +function buildValidationFailure( + errors: Array<{ file: string; message: string }>, + warnings: Array, +): string { + const lines = ['', `❌ Validation failed with ${errors.length} error(s):`, ''] + + for (const { file, message } of errors) { + lines.push(` ${file}: ${message}`) + } + + if (warnings.length > 0) { + lines.push('', '⚠ Packaging warnings:') + for (const warning of warnings) { + lines.push(` ${warning}`) + } + } + + return lines.join('\n') +} + +function collectPackagingWarnings(root: string): Array { + const pkgJsonPath = join(root, 'package.json') + if (!existsSync(pkgJsonPath)) return [] + + let pkgJson: Record + try { + pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return [`Could not parse package.json: ${msg}`] + } + + const warnings: Array = [] + + const devDeps = pkgJson.devDependencies as Record | undefined + if (!devDeps?.['@tanstack/intent']) { + warnings.push('@tanstack/intent is not in devDependencies') + } + + const keywords = pkgJson.keywords + if (!Array.isArray(keywords) || !keywords.includes('tanstack-intent')) { + warnings.push('Missing "tanstack-intent" in keywords array') + } + + const files = pkgJson.files as Array | undefined + if (Array.isArray(files)) { + if (!files.includes('skills')) { + warnings.push( + '"skills" is not in the "files" array — skills won\'t be published', + ) + } + + const isMonorepoPkg = (() => { + let dir = join(root, '..') + for (let i = 0; i < 5; i++) { + const parentPkg = join(dir, 'package.json') + if (existsSync(parentPkg)) { + try { + const parent = JSON.parse(readFileSync(parentPkg, 'utf8')) + return ( + Array.isArray(parent.workspaces) || parent.workspaces?.packages + ) + } catch { + return false + } + } + const next = dirname(dir) + if (next === dir) break + dir = next + } + return false + })() + + if (!isMonorepoPkg && !files.includes('!skills/_artifacts')) { + warnings.push( + '"!skills/_artifacts" is not in the "files" array — artifacts will be published unnecessarily', + ) + } + } + + return warnings +} + +function resolvePackageRoot(startDir: string): string { + let dir = startDir + + while (true) { + if (existsSync(join(dir, 'package.json'))) { + return dir + } + + const next = dirname(dir) + if (next === dir) { + return startDir + } + + dir = next + } +} + +export async function runValidateCommand(dir?: string): Promise { + const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([ + import('yaml'), + import('../utils.js'), + ]) + const targetDir = dir ?? 'skills' + const skillsDir = join(process.cwd(), targetDir) + const packageRoot = resolvePackageRoot(skillsDir) + + if (!existsSync(skillsDir)) { + fail(`Skills directory not found: ${skillsDir}`) + } + + interface ValidationError { + file: string + message: string + } + + const errors: Array = [] + const skillFiles = findSkillFiles(skillsDir) + + if (skillFiles.length === 0) { + fail('No SKILL.md files found') + } + + for (const filePath of skillFiles) { + const rel = relative(process.cwd(), filePath) + const content = readFileSync(filePath, 'utf8') + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) + + if (!match) { + errors.push({ file: rel, message: 'Missing or invalid frontmatter' }) + continue + } + + if (!match[1]) { + errors.push({ file: rel, message: 'Missing YAML frontmatter' }) + continue + } + + let fm: Record + try { + fm = parseYaml(match[1]) as Record + } catch { + errors.push({ file: rel, message: 'Invalid YAML frontmatter' }) + continue + } + + if (!fm.name) { + errors.push({ file: rel, message: 'Missing required field: name' }) + } + if (!fm.description) { + errors.push({ file: rel, message: 'Missing required field: description' }) + } + + if (typeof fm.name === 'string') { + const expectedPath = relative(skillsDir, filePath) + .replace(/[/\\]SKILL\.md$/, '') + .split(sep) + .join('/') + if (fm.name !== expectedPath) { + errors.push({ + file: rel, + message: `name "${fm.name}" does not match directory path "${expectedPath}"`, + }) + } + } + + if (typeof fm.description === 'string' && fm.description.length > 1024) { + errors.push({ + file: rel, + message: `Description exceeds 1024 character limit (${fm.description.length} chars)`, + }) + } + + if (fm.type === 'framework' && !Array.isArray(fm.requires)) { + errors.push({ + file: rel, + message: 'Framework skills must have a "requires" field', + }) + } + + const lineCount = content.split(/\r?\n/).length + if (lineCount > 500) { + errors.push({ + file: rel, + message: `Exceeds 500 line limit (${lineCount} lines). Rewrite for conciseness: move API tables to references/, trim verbose examples, and remove content an agent already knows. Do not simply raise the limit.`, + }) + } + } + + const artifactsDir = join(skillsDir, '_artifacts') + if (existsSync(artifactsDir)) { + const requiredArtifacts = [ + 'domain_map.yaml', + 'skill_spec.md', + 'skill_tree.yaml', + ] + + for (const fileName of requiredArtifacts) { + const artifactPath = join(artifactsDir, fileName) + if (!existsSync(artifactPath)) { + errors.push({ + file: relative(process.cwd(), artifactPath), + message: 'Missing required artifact', + }) + continue + } + + const content = readFileSync(artifactPath, 'utf8') + if (content.trim().length === 0) { + errors.push({ + file: relative(process.cwd(), artifactPath), + message: 'Artifact file is empty', + }) + continue + } + + if (fileName.endsWith('.yaml')) { + try { + parseYaml(content) + } catch { + errors.push({ + file: relative(process.cwd(), artifactPath), + message: 'Invalid YAML in artifact file', + }) + } + } + } + } + + const warnings = collectPackagingWarnings(packageRoot) + + if (errors.length > 0) { + fail(buildValidationFailure(errors, warnings)) + } + + console.log(`✅ Validated ${skillFiles.length} skill files — all passed`) + if (warnings.length > 0) console.log() + printWarnings(warnings) +} diff --git a/packages/intent/src/install-prompt.ts b/packages/intent/src/install-prompt.ts deleted file mode 100644 index 6803823..0000000 --- a/packages/intent/src/install-prompt.ts +++ /dev/null @@ -1,55 +0,0 @@ -export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. - -Follow these steps in order: - -1. CHECK FOR EXISTING MAPPINGS - Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings, keep that file as the source of truth, - and ask "What would you like to update?" Then skip to step 4 with their requested changes. - - If not found: continue to step 2. - -2. DISCOVER AVAILABLE SKILLS - Run: \`npx @tanstack/intent@latest list\` - This outputs each skill's name, description, full path, and whether it was found in - project-local node_modules or accessible global node_modules. - This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop - with node_modules enabled). - -3. SCAN THE REPOSITORY - Build a picture of the project's structure and patterns: - - Read package.json for library dependencies - - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) - - Note recurring patterns (routing, data fetching, auth, UI components, etc.) - - Based on this, propose 3-5 skill-to-task mappings. For each one explain: - - The task or code area (in plain language the user would recognise) - - Which skill applies and why - - Then ask: "What other tasks do you commonly use AI coding agents for? - I'll create mappings for those too." - Also ask: "I'll default to AGENTS.md unless you want another supported config file. - Do you have a preference?" - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file. - - If you found an existing intent-skills block, update that file in place. - - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. - - Use this exact block: - - -# Skill mappings - when working in these areas, load the linked skill file into context. -skills: - - task: "describe the task or code area here" - load: "node_modules/package-name/skills/skill-name/SKILL.md" - - - Rules: - - Use the user's own words for task descriptions - - Include the exact path from \`npx @tanstack/intent@latest list\` output so agents can load it directly - - Keep entries concise - this block is read on every agent task - - Preserve all content outside the block tags unchanged - - If the user is on Deno, note that this setup is best-effort today and relies on npm interop` diff --git a/packages/intent/src/intent-library.ts b/packages/intent/src/intent-library.ts index c468674..3922db8 100644 --- a/packages/intent/src/intent-library.ts +++ b/packages/intent/src/intent-library.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' -import { INSTALL_PROMPT } from './install-prompt.js' +import { INSTALL_PROMPT } from './commands/install.js' import { scanLibrary } from './library-scanner.js' import type { LibraryScanResult } from './library-scanner.js' From 67cad9740556b952229878b0ee7e1daa61dc761a Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 14:04:28 -0700 Subject: [PATCH 04/15] feat: add runStaleCommand for handling stale targets in CLI --- packages/intent/src/cli.ts | 34 ++------------------- packages/intent/src/commands/stale.ts | 43 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 packages/intent/src/commands/stale.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 416ebae..e5f5dc2 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -8,6 +8,7 @@ import { fail, isCliFailure } from './cli-error.js' import { runInstallCommand } from './commands/install.js' import { runListCommand } from './commands/list.js' import { runScaffoldCommand } from './commands/scaffold.js' +import { runStaleCommand } from './commands/stale.js' import { runValidateCommand } from './commands/validate.js' import type { ScanResult } from './types.js' @@ -246,38 +247,7 @@ function createCli() { .option('--json', 'Output JSON') .action( async (targetDir: string | undefined, options: { json?: boolean }) => { - const { reports } = await resolveStaleTargets(targetDir) - - if (reports.length === 0) { - console.log('No intent-enabled packages found.') - return - } - - if (options.json) { - console.log(JSON.stringify(reports, null, 2)) - return - } - - for (const report of reports) { - const driftLabel = report.versionDrift - ? ` [${report.versionDrift} drift]` - : '' - const vLabel = - report.skillVersion && report.currentVersion - ? ` (${report.skillVersion} → ${report.currentVersion})` - : '' - console.log(`${report.library}${vLabel}${driftLabel}`) - - const stale = report.skills.filter((s) => s.needsReview) - if (stale.length === 0) { - console.log(' All skills up-to-date') - } else { - for (const skill of stale) { - console.log(` ⚠ ${skill.name}: ${skill.reasons.join(', ')}`) - } - } - console.log() - } + await runStaleCommand(targetDir, options, resolveStaleTargets) }, ) diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts new file mode 100644 index 0000000..f216ec0 --- /dev/null +++ b/packages/intent/src/commands/stale.ts @@ -0,0 +1,43 @@ +import type { StalenessReport } from '../types.js' + +export async function runStaleCommand( + targetDir: string | undefined, + options: { json?: boolean }, + resolveStaleTargets: ( + targetDir?: string, + ) => Promise<{ reports: Array }>, +): Promise { + const { reports } = await resolveStaleTargets(targetDir) + + if (reports.length === 0) { + console.log('No intent-enabled packages found.') + return + } + + if (options.json) { + console.log(JSON.stringify(reports, null, 2)) + return + } + + for (const report of reports) { + const driftLabel = report.versionDrift + ? ` [${report.versionDrift} drift]` + : '' + const vLabel = + report.skillVersion && report.currentVersion + ? ` (${report.skillVersion} → ${report.currentVersion})` + : '' + console.log(`${report.library}${vLabel}${driftLabel}`) + + const stale = report.skills.filter((skill) => skill.needsReview) + if (stale.length === 0) { + console.log(' All skills up-to-date') + } else { + for (const skill of stale) { + console.log(` ⚠ ${skill.name}: ${skill.reasons.join(', ')}`) + } + } + + console.log() + } +} From 0585cbba3ac957ee8b0c03e8528833682a09c78f Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 14:07:16 -0700 Subject: [PATCH 05/15] feat: implement runMetaCommand for managing meta-skills in CLI --- packages/intent/src/cli.ts | 61 ++------------------------- packages/intent/src/commands/meta.ts | 63 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 packages/intent/src/commands/meta.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index e5f5dc2..3b3e96a 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node import { cac } from 'cac' -import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { fail, isCliFailure } from './cli-error.js' import { runInstallCommand } from './commands/install.js' import { runListCommand } from './commands/list.js' +import { runMetaCommand } from './commands/meta.js' import { runScaffoldCommand } from './commands/scaffold.js' import { runStaleCommand } from './commands/stale.js' import { runValidateCommand } from './commands/validate.js' @@ -96,62 +97,6 @@ async function scanIntentsOrFail(): Promise { } } -async function cmdMeta(args: Array): Promise { - const { parseFrontmatter } = await import('./utils.js') - const metaDir = getMetaDir() - - if (!existsSync(metaDir)) { - fail('Meta-skills directory not found.') - } - - if (args.length > 0) { - const name = args[0]! - if (name.includes('..') || name.includes('/') || name.includes('\\')) { - fail(`Invalid meta-skill name: "${name}"`) - } - const skillFile = join(metaDir, name, 'SKILL.md') - if (!existsSync(skillFile)) { - fail( - `Meta-skill "${name}" not found. Run \`intent meta\` to list available meta-skills.`, - ) - } - try { - console.log(readFileSync(skillFile, 'utf8')) - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - fail(`Failed to read meta-skill "${name}": ${msg}`) - } - return - } - - const entries = readdirSync(metaDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .filter((e) => existsSync(join(metaDir, e.name, 'SKILL.md'))) - - if (entries.length === 0) { - console.log('No meta-skills found.') - return - } - - console.log('Meta-skills (for library maintainers):\n') - - for (const entry of entries) { - const skillFile = join(metaDir, entry.name, 'SKILL.md') - const fm = parseFrontmatter(skillFile) - let description = '' - if (typeof fm?.description === 'string') { - description = fm.description.replace(/\s+/g, ' ').trim() - } - - const shortDesc = - description.length > 60 ? description.slice(0, 57) + '...' : description - console.log(` ${entry.name.padEnd(28)} ${shortDesc}`) - } - - console.log(`\nUsage: load the SKILL.md into your AI agent conversation.`) - console.log(`Path: node_modules/@tanstack/intent/meta//SKILL.md`) -} - function readPackageName(root: string): string { try { const pkgJson = JSON.parse( @@ -220,7 +165,7 @@ function createCli() { cli .command('meta [name]', 'List meta-skills, or print one by name') .action(async (name?: string) => { - await cmdMeta(name ? [name] : []) + await runMetaCommand(name, getMetaDir()) }) cli diff --git a/packages/intent/src/commands/meta.ts b/packages/intent/src/commands/meta.ts new file mode 100644 index 0000000..e0c1bb2 --- /dev/null +++ b/packages/intent/src/commands/meta.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { fail } from '../cli-error.js' + +export async function runMetaCommand( + name: string | undefined, + metaDir: string, +): Promise { + const { parseFrontmatter } = await import('../utils.js') + + if (!existsSync(metaDir)) { + fail('Meta-skills directory not found.') + } + + if (name) { + if (name.includes('..') || name.includes('/') || name.includes('\\')) { + fail(`Invalid meta-skill name: "${name}"`) + } + + const skillFile = join(metaDir, name, 'SKILL.md') + if (!existsSync(skillFile)) { + fail( + `Meta-skill "${name}" not found. Run \`intent meta\` to list available meta-skills.`, + ) + } + + try { + console.log(readFileSync(skillFile, 'utf8')) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + fail(`Failed to read meta-skill "${name}": ${msg}`) + } + + return + } + + const entries = readdirSync(metaDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => existsSync(join(metaDir, entry.name, 'SKILL.md'))) + + if (entries.length === 0) { + console.log('No meta-skills found.') + return + } + + console.log('Meta-skills (for library maintainers):\n') + + for (const entry of entries) { + const skillFile = join(metaDir, entry.name, 'SKILL.md') + const fm = parseFrontmatter(skillFile) + let description = '' + if (typeof fm?.description === 'string') { + description = fm.description.replace(/\s+/g, ' ').trim() + } + + const shortDesc = + description.length > 60 ? `${description.slice(0, 57)}...` : description + console.log(` ${entry.name.padEnd(28)} ${shortDesc}`) + } + + console.log('\nUsage: load the SKILL.md into your AI agent conversation.') + console.log('Path: node_modules/@tanstack/intent/meta//SKILL.md') +} From 0533be6f345ddc6af4beafe39e059e7d05067947 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 14:12:34 -0700 Subject: [PATCH 06/15] feat: add commands for editing package.json and setting up GitHub actions in CLI --- packages/intent/src/cli.ts | 8 ++-- .../intent/src/commands/edit-package-json.ts | 4 ++ .../src/commands/setup-github-actions.ts | 7 +++ packages/intent/tests/cli.test.ts | 47 +++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/intent/src/commands/edit-package-json.ts create mode 100644 packages/intent/src/commands/setup-github-actions.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 3b3e96a..7b0916f 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -9,6 +9,8 @@ import { runInstallCommand } from './commands/install.js' import { runListCommand } from './commands/list.js' import { runMetaCommand } from './commands/meta.js' import { runScaffoldCommand } from './commands/scaffold.js' +import { runEditPackageJsonCommand } from './commands/edit-package-json.js' +import { runSetupGithubActionsCommand } from './commands/setup-github-actions.js' import { runStaleCommand } from './commands/stale.js' import { runValidateCommand } from './commands/validate.js' import type { ScanResult } from './types.js' @@ -202,8 +204,7 @@ function createCli() { 'Update package.json files so skills are published', ) .action(async () => { - const { runEditPackageJsonAll } = await import('./setup.js') - runEditPackageJsonAll(process.cwd()) + await runEditPackageJsonCommand(process.cwd()) }) cli @@ -212,8 +213,7 @@ function createCli() { 'Copy Intent CI workflow templates into .github/workflows/', ) .action(async () => { - const { runSetupGithubActions } = await import('./setup.js') - runSetupGithubActions(process.cwd(), getMetaDir()) + await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) return cli diff --git a/packages/intent/src/commands/edit-package-json.ts b/packages/intent/src/commands/edit-package-json.ts new file mode 100644 index 0000000..39a7e07 --- /dev/null +++ b/packages/intent/src/commands/edit-package-json.ts @@ -0,0 +1,4 @@ +export async function runEditPackageJsonCommand(root: string): Promise { + const { runEditPackageJsonAll } = await import('../setup.js') + runEditPackageJsonAll(root) +} diff --git a/packages/intent/src/commands/setup-github-actions.ts b/packages/intent/src/commands/setup-github-actions.ts new file mode 100644 index 0000000..a663592 --- /dev/null +++ b/packages/intent/src/commands/setup-github-actions.ts @@ -0,0 +1,7 @@ +export async function runSetupGithubActionsCommand( + root: string, + metaDir: string, +): Promise { + const { runSetupGithubActions } = await import('../setup.js') + runSetupGithubActions(root, metaDir) +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index b4b0e87..9736d1c 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -161,6 +161,53 @@ describe('cli commands', () => { expect(output).toContain('meta/domain-discovery/SKILL.md') }) + it('updates package.json for skill publishing', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-edit-package-json-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'pkg', + version: '1.0.0', + }) + + process.chdir(root) + + const exitCode = await main(['edit-package-json']) + const pkg = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { + keywords?: Array + files?: Array + } + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(pkg.keywords).toContain('tanstack-intent') + expect(pkg.files).toContain('skills') + expect(pkg.files).toContain('!skills/_artifacts') + expect(output).toContain('Added keywords: "tanstack-intent"') + }) + + it('copies github workflow templates', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-setup-gha-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@scope/pkg', + version: '1.0.0', + intent: { version: 1, repo: 'scope/pkg', docs: 'docs/' }, + }) + + process.chdir(root) + + const exitCode = await main(['setup-github-actions']) + const workflowsDir = join(root, '.github', 'workflows') + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(existsSync(workflowsDir)).toBe(true) + expect(output).toContain('Copied workflow:') + expect(output).toContain('Template variables applied:') + }) + it('lists installed intent packages as json', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-')) tempDirs.push(root) From d256d7b98966e12d068a55d7263ef771ded4f2b1 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 14:17:54 -0700 Subject: [PATCH 07/15] feat: enhance CLI help command structure and usage examples --- packages/intent/src/cli.ts | 142 +++++++++++------------------- packages/intent/tests/cli.test.ts | 27 +++--- 2 files changed, 69 insertions(+), 100 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 7b0916f..2d7554a 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -15,75 +15,6 @@ import { runStaleCommand } from './commands/stale.js' import { runValidateCommand } from './commands/validate.js' import type { ScanResult } from './types.js' -export const USAGE = `TanStack Intent CLI - -Usage: - intent list [--json] Discover intent-enabled packages - intent meta [name] List meta-skills, or print one by name - intent validate [] Validate skill files (default: skills/) - intent install Print a skill that guides your coding agent to set up skill-to-task mappings - intent scaffold Print maintainer scaffold prompt - intent edit-package-json Wire package.json (files, keywords) for skill publishing - intent setup-github-actions Copy CI workflow templates to .github/workflows/ - intent stale [dir] [--json] Check skills for staleness` - -const HELP_BY_COMMAND: Record = { - list: `${USAGE} - -Examples: - intent list - intent list --json`, - meta: `intent meta [name] - -List shipped meta-skills, or print a single meta-skill by name. - -Examples: - intent meta - intent meta domain-discovery`, - validate: `intent validate [dir] - -Validate SKILL.md files in the target directory. - -Examples: - intent validate - intent validate packages/query/skills`, - install: `intent install - -Print the install prompt used to set up skill-to-task mappings.`, - scaffold: `intent scaffold - -Print the guided maintainer prompt for generating skills.`, - stale: `intent stale [dir] [--json] - -Check installed skills for version and source drift. - -Examples: - intent stale - intent stale packages/query - intent stale --json`, - 'edit-package-json': `intent edit-package-json - -Update package.json files so skills are published.`, - 'setup-github-actions': `intent setup-github-actions - -Copy Intent CI workflow templates into .github/workflows/.`, -} - -function isHelpFlag(arg: string | undefined): boolean { - return arg === '-h' || arg === '--help' -} - -function printHelp(command?: string): void { - if (!command) { - console.log(`${USAGE} - -Run \`intent help \` for details on a specific command.`) - return - } - - console.log(HELP_BY_COMMAND[command] ?? USAGE) -} - function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) return join(thisDir, '..', 'meta') @@ -156,22 +87,32 @@ async function resolveStaleTargets(targetDir?: string) { function createCli() { const cli = cac('intent') + cli.usage(' [options]') cli .command('list', 'Discover intent-enabled packages') + .usage('list [--json]') .option('--json', 'Output JSON') + .example('list') + .example('list --json') .action(async (options: { json?: boolean }) => { await runListCommand(options, scanIntentsOrFail) }) cli .command('meta [name]', 'List meta-skills, or print one by name') + .usage('meta [name]') + .example('meta') + .example('meta domain-discovery') .action(async (name?: string) => { await runMetaCommand(name, getMetaDir()) }) cli .command('validate [dir]', 'Validate skill files') + .usage('validate [dir]') + .example('validate') + .example('validate packages/query/skills') .action(async (dir?: string) => { await runValidateCommand(dir) }) @@ -181,17 +122,25 @@ function createCli() { 'install', 'Print a skill that guides your coding agent to set up skill-to-task mappings', ) + .usage('install') .action(() => { runInstallCommand() }) - cli.command('scaffold', 'Print maintainer scaffold prompt').action(() => { - runScaffoldCommand(getMetaDir()) - }) + cli + .command('scaffold', 'Print maintainer scaffold prompt') + .usage('scaffold') + .action(() => { + runScaffoldCommand(getMetaDir()) + }) cli .command('stale [dir]', 'Check skills for staleness') + .usage('stale [dir] [--json]') .option('--json', 'Output JSON') + .example('stale') + .example('stale packages/query') + .example('stale --json') .action( async (targetDir: string | undefined, options: { json?: boolean }) => { await runStaleCommand(targetDir, options, resolveStaleTargets) @@ -203,6 +152,7 @@ function createCli() { 'edit-package-json', 'Update package.json files so skills are published', ) + .usage('edit-package-json') .action(async () => { await runEditPackageJsonCommand(process.cwd()) }) @@ -212,41 +162,55 @@ function createCli() { 'setup-github-actions', 'Copy Intent CI workflow templates into .github/workflows/', ) + .usage('setup-github-actions') .action(async () => { await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) + cli + .command('help [command]', 'Display help for a command') + .action((commandName?: string) => { + if (!commandName) { + cli.outputHelp() + return + } + + const command = cli.commands.find((candidate) => + candidate.isMatched(commandName), + ) + + if (!command) { + fail(`Unknown command: ${commandName}`) + } + + command.outputHelp() + }) + + cli.help() + return cli } export async function main(argv: Array = process.argv.slice(2)) { - const command = argv[0] - const commandArgs = argv.slice(1) - try { - if (!command || isHelpFlag(command)) { - printHelp() - return 0 - } + const cli = createCli() - if (command === 'help') { - printHelp(commandArgs[0]) + if (argv.length === 0) { + cli.outputHelp() return 0 } - if (isHelpFlag(commandArgs[0])) { - printHelp(command) + cli.parse(['intent', 'intent', ...argv], { run: false }) + + if (cli.options.help) { return 0 } - if (!(command in HELP_BY_COMMAND)) { - printHelp() - return command ? 1 : 0 + if (!cli.matchedCommand) { + cli.outputHelp() + return 1 } - const cli = createCli() - cli.help() - cli.parse(['intent', 'intent', ...argv], { run: false }) await cli.runMatchedCommand() return 0 } catch (err) { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 9736d1c..05bce4f 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -12,7 +12,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { INSTALL_PROMPT } from '../src/commands/install.js' -import { main, USAGE } from '../src/cli.js' +import { main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') @@ -107,42 +107,47 @@ describe('intent meta', () => { describe('cli commands', () => { it('prints top-level help when no command is provided', async () => { const exitCode = await main([]) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(0) - expect(logSpy.mock.calls[0]?.[0]).toContain(USAGE) - expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') + expect(output).toContain('Usage:') + expect(output).toContain('$ intent [options]') + expect(output).toContain('Commands:') }) it('prints top-level help for --help', async () => { const exitCode = await main(['--help']) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(0) - expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') + expect(output).toContain('Usage:') + expect(output).toContain('$ intent [options]') }) it('prints top-level help for unknown commands', async () => { const exitCode = await main(['wat']) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(1) - expect(logSpy.mock.calls[0]?.[0]).toContain(USAGE) + expect(output).toContain('Usage:') + expect(output).toContain('Commands:') }) it('prints command help for help subcommands', async () => { const exitCode = await main(['help', 'validate']) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(0) - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('intent validate [dir]'), - ) + expect(output).toContain('$ intent validate [dir]') }) it('prints command help when --help is passed after a subcommand', async () => { const exitCode = await main(['list', '--help']) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(0) - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('intent list --json'), - ) + expect(output).toContain('$ intent list [--json]') + expect(output).toContain('--json') }) it('prints the install prompt', async () => { From be8c262fd00d404487ade9f346bd08dc49a49cfd Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 14:50:11 -0700 Subject: [PATCH 08/15] feat: enhance setup for monorepos with workspace detection and workflow generation --- docs/cli/intent-setup.md | 15 ++++--- packages/intent/package.json | 1 + packages/intent/src/setup.ts | 68 ++++++++++++++++++++++++----- packages/intent/tests/cli.test.ts | 7 +++ packages/intent/tests/setup.test.ts | 4 +- 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/docs/cli/intent-setup.md b/docs/cli/intent-setup.md index 2effee9..c109d82 100644 --- a/docs/cli/intent-setup.md +++ b/docs/cli/intent-setup.md @@ -13,7 +13,7 @@ npx @tanstack/intent@latest setup-github-actions ## Commands - `edit-package-json`: add or normalize `package.json` entries needed to publish skills -- `setup-github-actions`: copy workflow templates to `.github/workflows` +- `setup-github-actions`: copy workflow templates to `.github/workflows` ## What each command changes @@ -22,10 +22,12 @@ npx @tanstack/intent@latest setup-github-actions - Ensures `keywords` includes `tanstack-intent` - Ensures `files` includes required publish entries - Preserves existing indentation -- `setup-github-actions` - - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` - - Applies variable substitution for `PACKAGE_NAME`, `REPO`, `DOCS_PATH`, `SRC_PATH` - - Skips files that already exist at destination +- `setup-github-actions` + - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` + - Applies variable substitution for `PACKAGE_NAME`, `REPO`, `DOCS_PATH`, `SRC_PATH` + - Detects the workspace root in monorepos and writes repo-level workflows there + - Generates monorepo-aware watch paths for package `src/` and docs directories + - Skips files that already exist at destination ## Required `files` entries @@ -41,7 +43,8 @@ npx @tanstack/intent@latest setup-github-actions ## Notes -- `setup-github-actions` skips existing files +- `setup-github-actions` skips existing files +- In monorepos, run `setup-github-actions` from either the repo root or a package directory; Intent writes workflows to the workspace root ## Related diff --git a/packages/intent/package.json b/packages/intent/package.json index 2a287f7..a04b502 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -38,6 +38,7 @@ "scripts": { "prepack": "npm run build", "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts --format esm --dts", + "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null", "test:lib": "vitest run --exclude 'tests/integration/**'", "test:integration": "vitest run tests/integration/", "test:types": "tsc --noEmit" diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 8ff32f0..ab5eb69 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -38,6 +38,37 @@ interface TemplateVars { WATCH_PATHS: string } +function isGenericWorkspaceName(name: string, root: string): boolean { + const normalized = name.trim().toLowerCase() + return ( + normalized.length === 0 || + normalized === 'unknown' || + normalized === 'root' || + normalized === 'workspace' || + normalized === 'monorepo' || + normalized === basename(root).toLowerCase() + ) +} + +function deriveWorkspacePackageName( + root: string, + repo: string, + packageDirs: Array, +): string { + const repoName = repo.split('/').filter(Boolean).pop() || basename(root) + + for (const packageDir of packageDirs) { + const pkgJson = readPackageJson(packageDir) + const pkgName = typeof pkgJson.name === 'string' ? pkgJson.name : null + if (pkgName?.startsWith('@')) { + const scope = pkgName.split('/')[0] + return `${scope}/${repoName}` + } + } + + return repoName +} + // --------------------------------------------------------------------------- // Variable detection from package.json // --------------------------------------------------------------------------- @@ -129,30 +160,47 @@ function buildWatchPaths(root: string, packageDirs: Array): string { function detectVars(root: string, packageDirs?: Array): TemplateVars { const pkgJson = readPackageJson(root) - const name = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' + const rawName = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' const docs = typeof (pkgJson.intent as Record | undefined)?.docs === 'string' ? ((pkgJson.intent as Record).docs as string) : 'docs/' - const repo = detectRepo(pkgJson, name.replace(/^@/, '').replace(/\//, '/')) const isMonorepo = packageDirs !== undefined - const packageLabel = - isMonorepo && name === 'unknown' ? `${basename(root)} workspace` : name + const monorepoFallbackPkg = packageDirs?.[0] + ? readPackageJson(packageDirs[0]) + : null + const repo = detectRepo( + pkgJson, + detectRepo(monorepoFallbackPkg ?? {}, basename(root)), + ) + + let packageName = rawName + if (isMonorepo && isGenericWorkspaceName(rawName, root)) { + packageName = deriveWorkspacePackageName(root, repo, packageDirs) + } + + const packageLabel = packageName // Best-guess src path from common monorepo patterns - const shortName = name.replace(/^@[^/]+\//, '') - let srcPath = `packages/${shortName}/src/**` - if (existsSync(join(root, 'src'))) { + const shortName = packageName.replace(/^@[^/]+\//, '') + let srcPath = isMonorepo + ? 'packages/*/src/**' + : `packages/${shortName}/src/**` + if (!isMonorepo && existsSync(join(root, 'src'))) { srcPath = 'src/**' } + const docsPath = isMonorepo ? 'packages/*/docs/**' : docs + return { - PACKAGE_NAME: name, + PACKAGE_NAME: packageName, PACKAGE_LABEL: packageLabel, - PAYLOAD_PACKAGE: packageLabel, + PAYLOAD_PACKAGE: packageName, REPO: repo, - DOCS_PATH: docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**', + DOCS_PATH: docsPath.endsWith('**') + ? docsPath + : docsPath.replace(/\/$/, '') + '/**', SRC_PATH: srcPath, WATCH_PATHS: isMonorepo ? buildWatchPaths(root, packageDirs) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 05bce4f..72a2aac 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -141,6 +141,13 @@ describe('cli commands', () => { expect(output).toContain('$ intent validate [dir]') }) + it('fails cleanly for unknown help subcommands', async () => { + const exitCode = await main(['help', 'wat']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Unknown command: wat') + }) + it('prints command help when --help is passed after a subcommand', async () => { const exitCode = await main(['list', '--help']) const output = String(logSpy.mock.calls[0]?.[0]) diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index c74940b..0e37ad2 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -255,7 +255,7 @@ describe('runSetupGithubActions', () => { writeFileSync( join(monoRoot, 'package.json'), JSON.stringify( - { name: '@tanstack/router', private: true, workspaces: ['packages/*'] }, + { name: 'root', private: true, workspaces: ['packages/*'] }, null, 2, ), @@ -295,9 +295,11 @@ describe('runSetupGithubActions', () => { 'utf8', ) expect(notifyContent).toContain('package: @tanstack/router') + expect(notifyContent).toContain('repo: TanStack/router') expect(notifyContent).toContain("- 'packages/router/docs/**'") expect(notifyContent).toContain("- 'packages/router/src/**'") expect(notifyContent).toContain("- 'packages/start/src/**'") + expect(notifyContent).not.toContain('packages/root/src/**') const checkContent = readFileSync( join(monoRoot, '.github', 'workflows', 'check-skills.yml'), From d49ff7fa70175a81f4873daf460bb47647fdfbe0 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 15:02:32 -0700 Subject: [PATCH 09/15] feat: refactor CLI structure by moving utility functions to cli-support module --- packages/intent/src/cli-support.ts | 77 +++++++++++++++++++++++++++++ packages/intent/src/cli.ts | 79 +++--------------------------- 2 files changed, 83 insertions(+), 73 deletions(-) create mode 100644 packages/intent/src/cli-support.ts diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts new file mode 100644 index 0000000..ca11254 --- /dev/null +++ b/packages/intent/src/cli-support.ts @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { fail } from './cli-error.js' +import type { ScanResult, StalenessReport } from './types.js' + +export function getMetaDir(): string { + const thisDir = dirname(fileURLToPath(import.meta.url)) + return join(thisDir, '..', 'meta') +} + +export async function scanIntentsOrFail(): Promise { + const { scanForIntents } = await import('./scanner.js') + + try { + return scanForIntents() + } catch (err) { + fail((err as Error).message) + } +} + +export function readPackageName(root: string): string { + try { + const pkgJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { + name?: unknown + } + return typeof pkgJson.name === 'string' + ? pkgJson.name + : relative(process.cwd(), root) || 'unknown' + } catch { + return relative(process.cwd(), root) || 'unknown' + } +} + +export async function resolveStaleTargets( + targetDir?: string, +): Promise<{ reports: Array }> { + const resolvedRoot = targetDir + ? join(process.cwd(), targetDir) + : process.cwd() + const { checkStaleness } = await import('./staleness.js') + + if (existsSync(join(resolvedRoot, 'skills'))) { + return { + reports: [ + await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), + ], + } + } + + const { findPackagesWithSkills, findWorkspaceRoot } = + await import('./setup.js') + const workspaceRoot = findWorkspaceRoot(resolvedRoot) + if (workspaceRoot) { + const packageDirs = findPackagesWithSkills(workspaceRoot) + if (packageDirs.length > 0) { + return { + reports: await Promise.all( + packageDirs.map((packageDir) => + checkStaleness(packageDir, readPackageName(packageDir)), + ), + ), + } + } + } + + const staleResult = await scanIntentsOrFail() + return { + reports: await Promise.all( + staleResult.packages.map((pkg) => + checkStaleness(pkg.packageRoot, pkg.name), + ), + ), + } +} diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 2d7554a..b2afcc2 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -1,10 +1,14 @@ #!/usr/bin/env node import { cac } from 'cac' -import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' +import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { fail, isCliFailure } from './cli-error.js' +import { + getMetaDir, + resolveStaleTargets, + scanIntentsOrFail, +} from './cli-support.js' import { runInstallCommand } from './commands/install.js' import { runListCommand } from './commands/list.js' import { runMetaCommand } from './commands/meta.js' @@ -13,77 +17,6 @@ import { runEditPackageJsonCommand } from './commands/edit-package-json.js' import { runSetupGithubActionsCommand } from './commands/setup-github-actions.js' import { runStaleCommand } from './commands/stale.js' import { runValidateCommand } from './commands/validate.js' -import type { ScanResult } from './types.js' - -function getMetaDir(): string { - const thisDir = dirname(fileURLToPath(import.meta.url)) - return join(thisDir, '..', 'meta') -} - -async function scanIntentsOrFail(): Promise { - const { scanForIntents } = await import('./scanner.js') - - try { - return scanForIntents() - } catch (err) { - fail((err as Error).message) - } -} - -function readPackageName(root: string): string { - try { - const pkgJson = JSON.parse( - readFileSync(join(root, 'package.json'), 'utf8'), - ) as { - name?: unknown - } - return typeof pkgJson.name === 'string' - ? pkgJson.name - : relative(process.cwd(), root) || 'unknown' - } catch { - return relative(process.cwd(), root) || 'unknown' - } -} - -async function resolveStaleTargets(targetDir?: string) { - const resolvedRoot = targetDir - ? join(process.cwd(), targetDir) - : process.cwd() - const { checkStaleness } = await import('./staleness.js') - - if (existsSync(join(resolvedRoot, 'skills'))) { - return { - reports: [ - await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), - ], - } - } - - const { findPackagesWithSkills, findWorkspaceRoot } = - await import('./setup.js') - const workspaceRoot = findWorkspaceRoot(resolvedRoot) - if (workspaceRoot) { - const packageDirs = findPackagesWithSkills(workspaceRoot) - if (packageDirs.length > 0) { - return { - reports: await Promise.all( - packageDirs.map((packageDir) => - checkStaleness(packageDir, readPackageName(packageDir)), - ), - ), - } - } - } - - const staleResult = await scanIntentsOrFail() - return { - reports: await Promise.all( - staleResult.packages.map((pkg) => - checkStaleness(pkg.packageRoot, pkg.name), - ), - ), - } -} function createCli() { const cli = cac('intent') From 1fc1122366c3be28e2db83a37767b069fab151b8 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 15:03:49 -0700 Subject: [PATCH 10/15] refactor: change readPackageName function to private scope in cli-support module --- packages/intent/src/cli-support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index ca11254..ab63fad 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -19,7 +19,7 @@ export async function scanIntentsOrFail(): Promise { } } -export function readPackageName(root: string): string { +function readPackageName(root: string): string { try { const pkgJson = JSON.parse( readFileSync(join(root, 'package.json'), 'utf8'), From f3f0d98bb627e890e7814619c995054c422b4c83 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 15:09:27 -0700 Subject: [PATCH 11/15] changeset --- .changeset/thin-rings-behave.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/thin-rings-behave.md diff --git a/.changeset/thin-rings-behave.md b/.changeset/thin-rings-behave.md new file mode 100644 index 0000000..319cb55 --- /dev/null +++ b/.changeset/thin-rings-behave.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': patch +--- + +Refactored the CLI to use `cac`, replacing the previous hand-rolled parsing and dispatch logic with a more structured command system. + +This update also fixes monorepo workflow generation behavior related to `setup-github-actions`, improving repo/package fallback handling and ensuring generated workflow watch paths are monorepo-aware. From fce70da96dce847d51682bf5775183af67f1d543 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 16 Mar 2026 15:25:46 -0700 Subject: [PATCH 12/15] test: update `cac` --- packages/intent/package.json | 2 +- packages/intent/tests/cli.test.ts | 19 ++- pnpm-lock.yaml | 188 +++++++++++++++++------------- 3 files changed, 119 insertions(+), 90 deletions(-) diff --git a/packages/intent/package.json b/packages/intent/package.json index f0d40b6..400461b 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -27,7 +27,7 @@ "meta" ], "dependencies": { - "cac": "^6.7.14", + "cac": "^7.0.0", "yaml": "^2.7.0" }, "devDependencies": { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 72a2aac..a504ef5 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -41,19 +41,28 @@ function writeSkillMd(dir: string, frontmatter: Record): void { let originalCwd: string let logSpy: ReturnType +let infoSpy: ReturnType let errorSpy: ReturnType let tempDirs: Array +function getHelpOutput(): string { + return infoSpy.mock.calls + .map((call: Array) => String(call[0] ?? '')) + .join('') +} + beforeEach(() => { originalCwd = process.cwd() tempDirs = [] logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { process.chdir(originalCwd) logSpy.mockRestore() + infoSpy.mockRestore() errorSpy.mockRestore() for (const dir of tempDirs) { if (existsSync(dir)) { @@ -107,7 +116,7 @@ describe('intent meta', () => { describe('cli commands', () => { it('prints top-level help when no command is provided', async () => { const exitCode = await main([]) - const output = String(logSpy.mock.calls[0]?.[0]) + const output = getHelpOutput() expect(exitCode).toBe(0) expect(output).toContain('Usage:') @@ -117,7 +126,7 @@ describe('cli commands', () => { it('prints top-level help for --help', async () => { const exitCode = await main(['--help']) - const output = String(logSpy.mock.calls[0]?.[0]) + const output = getHelpOutput() expect(exitCode).toBe(0) expect(output).toContain('Usage:') @@ -126,7 +135,7 @@ describe('cli commands', () => { it('prints top-level help for unknown commands', async () => { const exitCode = await main(['wat']) - const output = String(logSpy.mock.calls[0]?.[0]) + const output = getHelpOutput() expect(exitCode).toBe(1) expect(output).toContain('Usage:') @@ -135,7 +144,7 @@ describe('cli commands', () => { it('prints command help for help subcommands', async () => { const exitCode = await main(['help', 'validate']) - const output = String(logSpy.mock.calls[0]?.[0]) + const output = getHelpOutput() expect(exitCode).toBe(0) expect(output).toContain('$ intent validate [dir]') @@ -150,7 +159,7 @@ describe('cli commands', () => { it('prints command help when --help is passed after a subcommand', async () => { const exitCode = await main(['list', '--help']) - const output = String(logSpy.mock.calls[0]?.[0]) + const output = getHelpOutput() expect(exitCode).toBe(0) expect(output).toContain('$ intent list [--json]') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2f7dd..ca6870f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,16 +23,16 @@ importers: version: 1.2.0 '@tanstack/eslint-config': specifier: 0.4.0 - version: 0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@types/node': specifier: ^25.0.7 version: 25.0.9 eslint: specifier: ^9.39.2 - version: 9.39.2(jiti@2.6.1) + version: 9.39.4(jiti@2.6.1) eslint-plugin-unused-imports: specifier: ^4.3.0 - version: 4.3.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + version: 4.3.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) happy-dom: specifier: ^20.1.0 version: 20.3.1 @@ -70,8 +70,8 @@ importers: packages/intent: dependencies: cac: - specifier: ^6.7.14 - version: 6.7.14 + specifier: ^7.0.0 + version: 7.0.0 yaml: specifier: ^2.7.0 version: 2.8.2 @@ -350,8 +350,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -362,8 +362,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@10.0.1': @@ -375,8 +375,8 @@ packages: eslint: optional: true - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -1433,6 +1433,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1453,8 +1458,8 @@ packages: ajv: optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -1645,6 +1650,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + cacheable-lookup@6.1.0: resolution: {integrity: sha512-KJ/Dmo1lDDhmW2XDPMo+9oiy/CeqosPguPCrgcVzKyZrL6pM1gU2GmPY/xo6OQPTUaA/c0kwHuywB4E6nmT9ww==} engines: {node: '>=10.6.0'} @@ -2097,8 +2106,8 @@ packages: resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2873,6 +2882,9 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@7.4.6: resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} engines: {node: '>=10'} @@ -4367,18 +4379,18 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4390,25 +4402,25 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@9.39.4(jiti@2.6.1))': optionalDependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) - '@eslint/js@9.39.2': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -4831,19 +4843,19 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stylistic/eslint-plugin@5.10.0(eslint@9.39.2(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/types': 8.56.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 picomatch: 4.0.3 - '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': + '@sveltejs/acorn-typescript@1.0.8(acorn@8.16.0)': dependencies: - acorn: 8.15.0 + acorn: 8.16.0 '@svitejs/changesets-changelog-github-compact@1.2.0': dependencies: @@ -4856,16 +4868,16 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint/js': 10.0.1(eslint@9.39.2(jiti@2.6.1)) - '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-n: 17.24.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint/js': 10.0.1(eslint@9.39.4(jiti@2.6.1)) + '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) globals: 17.4.0 - typescript-eslint: 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - vue-eslint-parser: 10.4.0(eslint@9.39.2(jiti@2.6.1)) + typescript-eslint: 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - '@typescript-eslint/utils' - eslint-import-resolver-node @@ -4928,15 +4940,15 @@ snapshots: dependencies: '@types/node': 25.0.9 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -4944,14 +4956,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4974,13 +4986,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5005,13 +5017,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5583,6 +5595,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -5597,7 +5611,7 @@ snapshots: optionalDependencies: ajv: 8.18.0 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -5811,6 +5825,8 @@ snapshots: cac@6.7.14: {} + cac@7.0.0: {} + cacheable-lookup@6.1.0: {} cacheable-request@7.0.2: @@ -6191,9 +6207,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): + eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) semver: 7.7.3 eslint-import-context@0.1.9(unrs-resolver@1.11.1): @@ -6203,19 +6219,19 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 - eslint-plugin-es-x@7.8.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.2(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.53.0 comment-parser: 1.4.4 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.1.1 @@ -6223,16 +6239,16 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - supports-color - eslint-plugin-n@17.24.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) enhanced-resolve: 5.18.4 - eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.6.1)) get-tsconfig: 4.13.0 globals: 15.15.0 globrex: 0.1.2 @@ -6242,11 +6258,11 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -6259,21 +6275,21 @@ snapshots: eslint-visitor-keys@5.0.0: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -6292,7 +6308,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7142,6 +7158,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + minimatch@7.4.6: dependencies: brace-expansion: 2.0.2 @@ -7988,9 +8008,9 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.8(acorn@8.16.0) '@types/estree': 1.0.8 - acorn: 8.15.0 + acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 @@ -8166,13 +8186,13 @@ snapshots: typescript: 5.9.3 yaml: 2.8.2 - typescript-eslint@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8391,10 +8411,10 @@ snapshots: - tsx - yaml - vue-eslint-parser@10.4.0(eslint@9.39.2(jiti@2.6.1)): + vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-scope: 8.4.0 eslint-visitor-keys: 5.0.0 espree: 11.0.0 From a8edab1c30a69aaa6767ee771b2060087ef584a9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 16 Mar 2026 16:42:42 -0600 Subject: [PATCH 13/15] Apply code review and simplification fixes - Brand CliFailure with symbol to prevent duck-typing false positives - Handle CACError in catch block for clean CLI error output - Fix unsafe (err as Error).message cast in scanIntentsOrFail - Include YAML parse error details in validation messages - Add monorepo artifact rationale comment in validate.ts - Fix misleading src path comment in setup.ts - Complete template variable list in docs - Add comment explaining cac double-argument quirk - Extract shared printWarnings to cli-support module - Move ValidationError interface to module scope - Extract isInsideMonorepo helper from IIFE Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/cli/intent-setup.md | 18 +++--- packages/intent/src/cli-error.ts | 13 +++-- packages/intent/src/cli-support.ts | 11 +++- packages/intent/src/cli.ts | 12 +++- packages/intent/src/commands/list.ts | 10 +--- packages/intent/src/commands/meta.ts | 3 +- packages/intent/src/commands/scaffold.ts | 4 +- packages/intent/src/commands/validate.ts | 70 +++++++++++------------- packages/intent/src/setup.ts | 6 +- 9 files changed, 76 insertions(+), 71 deletions(-) diff --git a/docs/cli/intent-setup.md b/docs/cli/intent-setup.md index c109d82..39ef9b0 100644 --- a/docs/cli/intent-setup.md +++ b/docs/cli/intent-setup.md @@ -13,7 +13,7 @@ npx @tanstack/intent@latest setup-github-actions ## Commands - `edit-package-json`: add or normalize `package.json` entries needed to publish skills -- `setup-github-actions`: copy workflow templates to `.github/workflows` +- `setup-github-actions`: copy workflow templates to `.github/workflows` ## What each command changes @@ -22,12 +22,12 @@ npx @tanstack/intent@latest setup-github-actions - Ensures `keywords` includes `tanstack-intent` - Ensures `files` includes required publish entries - Preserves existing indentation -- `setup-github-actions` - - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` - - Applies variable substitution for `PACKAGE_NAME`, `REPO`, `DOCS_PATH`, `SRC_PATH` - - Detects the workspace root in monorepos and writes repo-level workflows there - - Generates monorepo-aware watch paths for package `src/` and docs directories - - Skips files that already exist at destination +- `setup-github-actions` + - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` + - Applies variable substitution (`PACKAGE_NAME`, `PACKAGE_LABEL`, `PAYLOAD_PACKAGE`, `REPO`, `DOCS_PATH`, `SRC_PATH`, `WATCH_PATHS`) + - Detects the workspace root in monorepos and writes repo-level workflows there + - Generates monorepo-aware watch paths for package `src/` and docs directories + - Skips files that already exist at destination ## Required `files` entries @@ -43,8 +43,8 @@ npx @tanstack/intent@latest setup-github-actions ## Notes -- `setup-github-actions` skips existing files -- In monorepos, run `setup-github-actions` from either the repo root or a package directory; Intent writes workflows to the workspace root +- `setup-github-actions` skips existing files +- In monorepos, run `setup-github-actions` from either the repo root or a package directory; Intent writes workflows to the workspace root ## Related diff --git a/packages/intent/src/cli-error.ts b/packages/intent/src/cli-error.ts index b9c4eb1..de9b4cc 100644 --- a/packages/intent/src/cli-error.ts +++ b/packages/intent/src/cli-error.ts @@ -1,19 +1,22 @@ +const CLI_FAILURE = Symbol('CliFailure') + export type CliFailure = { + readonly [CLI_FAILURE]: true message: string exitCode: number } +// Throws a structured CliFailure (not an Error) — this represents an expected +// user-facing failure, not an internal bug. Stack traces are intentionally +// omitted since these are anticipated exit paths (bad input, missing files, etc). export function fail(message: string, exitCode = 1): never { - throw { message, exitCode } satisfies CliFailure + throw { [CLI_FAILURE]: true as const, message, exitCode } satisfies CliFailure } export function isCliFailure(value: unknown): value is CliFailure { return ( !!value && typeof value === 'object' && - 'message' in value && - typeof value.message === 'string' && - 'exitCode' in value && - typeof value.exitCode === 'number' + CLI_FAILURE in value ) } diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index ab63fad..1535d40 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -4,6 +4,15 @@ import { fileURLToPath } from 'node:url' import { fail } from './cli-error.js' import type { ScanResult, StalenessReport } from './types.js' +export function printWarnings(warnings: Array): void { + if (warnings.length === 0) return + + console.log('Warnings:') + for (const warning of warnings) { + console.log(` ⚠ ${warning}`) + } +} + export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) return join(thisDir, '..', 'meta') @@ -15,7 +24,7 @@ export async function scanIntentsOrFail(): Promise { try { return scanForIntents() } catch (err) { - fail((err as Error).message) + fail(err instanceof Error ? err.message : String(err)) } } diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index b2afcc2..3af0449 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -1,24 +1,24 @@ #!/usr/bin/env node -import { cac } from 'cac' import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { type CAC, cac } from 'cac' import { fail, isCliFailure } from './cli-error.js' import { getMetaDir, resolveStaleTargets, scanIntentsOrFail, } from './cli-support.js' +import { runEditPackageJsonCommand } from './commands/edit-package-json.js' import { runInstallCommand } from './commands/install.js' import { runListCommand } from './commands/list.js' import { runMetaCommand } from './commands/meta.js' import { runScaffoldCommand } from './commands/scaffold.js' -import { runEditPackageJsonCommand } from './commands/edit-package-json.js' import { runSetupGithubActionsCommand } from './commands/setup-github-actions.js' import { runStaleCommand } from './commands/stale.js' import { runValidateCommand } from './commands/validate.js' -function createCli() { +function createCli(): CAC { const cli = cac('intent') cli.usage(' [options]') @@ -133,6 +133,7 @@ export async function main(argv: Array = process.argv.slice(2)) { return 0 } + // cac expects process.argv format: first two entries (binary + script) are ignored cli.parse(['intent', 'intent', ...argv], { run: false }) if (cli.options.help) { @@ -152,6 +153,11 @@ export async function main(argv: Array = process.argv.slice(2)) { return err.exitCode } + if (err instanceof Error) { + console.error(err.message) + return 1 + } + throw err } } diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0e7b323..5a12d8a 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,14 +1,6 @@ +import { printWarnings } from '../cli-support.js' import type { ScanResult } from '../types.js' -function printWarnings(warnings: Array): void { - if (warnings.length === 0) return - - console.log('Warnings:') - for (const warning of warnings) { - console.log(` ⚠ ${warning}`) - } -} - function formatScanCoverage(result: ScanResult): string { const coverage: Array = [] diff --git a/packages/intent/src/commands/meta.ts b/packages/intent/src/commands/meta.ts index e0c1bb2..3be577e 100644 --- a/packages/intent/src/commands/meta.ts +++ b/packages/intent/src/commands/meta.ts @@ -6,8 +6,6 @@ export async function runMetaCommand( name: string | undefined, metaDir: string, ): Promise { - const { parseFrontmatter } = await import('../utils.js') - if (!existsSync(metaDir)) { fail('Meta-skills directory not found.') } @@ -34,6 +32,7 @@ export async function runMetaCommand( return } + const { parseFrontmatter } = await import('../utils.js') const entries = readdirSync(metaDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .filter((entry) => existsSync(join(metaDir, entry.name, 'SKILL.md'))) diff --git a/packages/intent/src/commands/scaffold.ts b/packages/intent/src/commands/scaffold.ts index 7a1a350..cd86f67 100644 --- a/packages/intent/src/commands/scaffold.ts +++ b/packages/intent/src/commands/scaffold.ts @@ -1,7 +1,9 @@ import { join } from 'node:path' export function runScaffoldCommand(metaDir: string): void { - const metaSkillPath = (name: string) => join(metaDir, name, 'SKILL.md') + function metaSkillPath(name: string): string { + return join(metaDir, name, 'SKILL.md') + } const prompt = `You are helping a library maintainer scaffold Intent skills. diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index c6d44bf..636256d 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -1,18 +1,15 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, sep } from 'node:path' import { fail } from '../cli-error.js' +import { printWarnings } from '../cli-support.js' -function printWarnings(warnings: Array): void { - if (warnings.length === 0) return - - console.log('Warnings:') - for (const warning of warnings) { - console.log(` ⚠ ${warning}`) - } +interface ValidationError { + file: string + message: string } function buildValidationFailure( - errors: Array<{ file: string; message: string }>, + errors: Array, warnings: Array, ): string { const lines = ['', `❌ Validation failed with ${errors.length} error(s):`, ''] @@ -31,6 +28,25 @@ function buildValidationFailure( return lines.join('\n') } +function isInsideMonorepo(root: string): boolean { + let dir = join(root, '..') + for (let i = 0; i < 5; i++) { + const parentPkg = join(dir, 'package.json') + if (existsSync(parentPkg)) { + try { + const parent = JSON.parse(readFileSync(parentPkg, 'utf8')) + return Array.isArray(parent.workspaces) || parent.workspaces?.packages + } catch { + return false + } + } + const next = dirname(dir) + if (next === dir) break + dir = next + } + return false +} + function collectPackagingWarnings(root: string): Array { const pkgJsonPath = join(root, 'package.json') if (!existsSync(pkgJsonPath)) return [] @@ -63,26 +79,9 @@ function collectPackagingWarnings(root: string): Array { ) } - const isMonorepoPkg = (() => { - let dir = join(root, '..') - for (let i = 0; i < 5; i++) { - const parentPkg = join(dir, 'package.json') - if (existsSync(parentPkg)) { - try { - const parent = JSON.parse(readFileSync(parentPkg, 'utf8')) - return ( - Array.isArray(parent.workspaces) || parent.workspaces?.packages - ) - } catch { - return false - } - } - const next = dirname(dir) - if (next === dir) break - dir = next - } - return false - })() + // In monorepos, _artifacts lives at repo root, not under packages — + // the negation pattern is a no-op and shouldn't be added. + const isMonorepoPkg = isInsideMonorepo(root) if (!isMonorepoPkg && !files.includes('!skills/_artifacts')) { warnings.push( @@ -124,11 +123,6 @@ export async function runValidateCommand(dir?: string): Promise { fail(`Skills directory not found: ${skillsDir}`) } - interface ValidationError { - file: string - message: string - } - const errors: Array = [] const skillFiles = findSkillFiles(skillsDir) @@ -154,8 +148,9 @@ export async function runValidateCommand(dir?: string): Promise { let fm: Record try { fm = parseYaml(match[1]) as Record - } catch { - errors.push({ file: rel, message: 'Invalid YAML frontmatter' }) + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + errors.push({ file: rel, message: `Invalid YAML frontmatter: ${detail}` }) continue } @@ -232,10 +227,11 @@ export async function runValidateCommand(dir?: string): Promise { if (fileName.endsWith('.yaml')) { try { parseYaml(content) - } catch { + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) errors.push({ file: relative(process.cwd(), artifactPath), - message: 'Invalid YAML in artifact file', + message: `Invalid YAML in artifact file: ${detail}`, }) } } diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index ab5eb69..0a2ae23 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -180,9 +180,7 @@ function detectVars(root: string, packageDirs?: Array): TemplateVars { packageName = deriveWorkspacePackageName(root, repo, packageDirs) } - const packageLabel = packageName - - // Best-guess src path from common monorepo patterns + // Derive srcPath: monorepos use a wildcard; single packages use the short name or fall back to root src/ const shortName = packageName.replace(/^@[^/]+\//, '') let srcPath = isMonorepo ? 'packages/*/src/**' @@ -195,7 +193,7 @@ function detectVars(root: string, packageDirs?: Array): TemplateVars { return { PACKAGE_NAME: packageName, - PACKAGE_LABEL: packageLabel, + PACKAGE_LABEL: packageName, PAYLOAD_PACKAGE: packageName, REPO: repo, DOCS_PATH: docsPath.endsWith('**') From 9361932820bb6372d6a0415f0fa144e51e496b87 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:49:14 +0000 Subject: [PATCH 14/15] ci: apply automated fixes --- packages/intent/src/cli-error.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/intent/src/cli-error.ts b/packages/intent/src/cli-error.ts index de9b4cc..ffe8ecd 100644 --- a/packages/intent/src/cli-error.ts +++ b/packages/intent/src/cli-error.ts @@ -14,9 +14,5 @@ export function fail(message: string, exitCode = 1): never { } export function isCliFailure(value: unknown): value is CliFailure { - return ( - !!value && - typeof value === 'object' && - CLI_FAILURE in value - ) + return !!value && typeof value === 'object' && CLI_FAILURE in value } From 185fb56c6275465325c9a7277cbef1f3a7cb5f67 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 16 Mar 2026 18:08:57 -0600 Subject: [PATCH 15/15] Downgrade cac to keep older Node support --- packages/intent/package.json | 2 +- packages/intent/tests/cli.test.ts | 4 ++-- pnpm-lock.yaml | 10 ++-------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/intent/package.json b/packages/intent/package.json index 400461b..f0d40b6 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -27,7 +27,7 @@ "meta" ], "dependencies": { - "cac": "^7.0.0", + "cac": "^6.7.14", "yaml": "^2.7.0" }, "devDependencies": { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index a504ef5..252b081 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -46,8 +46,8 @@ let errorSpy: ReturnType let tempDirs: Array function getHelpOutput(): string { - return infoSpy.mock.calls - .map((call: Array) => String(call[0] ?? '')) + return [...infoSpy.mock.calls, ...logSpy.mock.calls] + .map((call) => String(call[0] ?? '')) .join('') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca6870f..1849c7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,8 +70,8 @@ importers: packages/intent: dependencies: cac: - specifier: ^7.0.0 - version: 7.0.0 + specifier: ^6.7.14 + version: 6.7.14 yaml: specifier: ^2.7.0 version: 2.8.2 @@ -1650,10 +1650,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cac@7.0.0: - resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} - engines: {node: '>=20.19.0'} - cacheable-lookup@6.1.0: resolution: {integrity: sha512-KJ/Dmo1lDDhmW2XDPMo+9oiy/CeqosPguPCrgcVzKyZrL6pM1gU2GmPY/xo6OQPTUaA/c0kwHuywB4E6nmT9ww==} engines: {node: '>=10.6.0'} @@ -5825,8 +5821,6 @@ snapshots: cac@6.7.14: {} - cac@7.0.0: {} - cacheable-lookup@6.1.0: {} cacheable-request@7.0.2: