From f3c166b0eb55afcf63bf9765ab7353691b716e92 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 12:36:17 -0700 Subject: [PATCH 01/14] feat: enhance installation prompt and documentation for skill mappings --- docs/cli/intent-install.md | 16 ++- docs/getting-started/quick-start-consumers.md | 17 +-- docs/overview.md | 6 +- packages/intent/src/cli.ts | 64 +++------- packages/intent/src/install-prompt.ts | 52 ++++++++ packages/intent/src/intent-library.ts | 50 +------- packages/intent/src/scanner.ts | 102 +++++++++------- packages/intent/src/types.ts | 12 ++ packages/intent/src/utils.ts | 115 +++++++++++++++++- packages/intent/tests/cli.test.ts | 1 + packages/intent/tests/scanner.test.ts | 69 +++++++++++ 11 files changed, 343 insertions(+), 161 deletions(-) create mode 100644 packages/intent/src/install-prompt.ts diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index ff6d002..e9d0a01 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -24,12 +24,16 @@ skills: ``` -They also ask you to: - -1. Check for an existing block first -2. Run `intent list` to discover installed skills -3. Add task-to-skill mappings -4. Preserve all content outside the tagged block +They also ask you to: + +1. Check for an existing block first +2. Run `intent list` to discover installed skills +3. Ask whether you want a config target other than `AGENTS.md` +4. Update an existing block in place when one already exists +5. Add task-to-skill mappings +6. Preserve all content outside the tagged block + +If no existing block is found, `AGENTS.md` is the default target. ## Related diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 4b39ae9..dd2caa8 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -13,12 +13,15 @@ The install command guides your agent through the setup process: npx @tanstack/intent@latest install ``` -This prints a skill that instructs your AI agent to: -1. Check for existing `intent-skills` mappings in your config files (CLAUDE.md, .cursorrules, etc.) -2. Run `intent list` to discover available skills from installed packages -3. Scan your repository structure to understand your project -4. Propose relevant skill-to-task mappings based on your codebase patterns -5. Write or update an `intent-skills` block in your agent config +This prints a skill that instructs your AI agent to: +1. Check for existing `intent-skills` mappings in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) +2. Run `intent list` to discover available skills from installed packages +3. Scan your repository structure to understand your project +4. Propose relevant skill-to-task mappings based on your codebase patterns +5. Ask if you want a target other than `AGENTS.md` +6. Write or update an `intent-skills` block in your agent config + +If an `intent-skills` block already exists, the agent updates that file in place. If no block exists, `AGENTS.md` is the default target. Your agent will create mappings like: @@ -45,7 +48,7 @@ Skills version with library releases. When you update a library: npm update @tanstack/react-query ``` -The new version brings updated skills automatically — you don't need to do anything. The skills are shipped with the library, so you always get the version that matches your installed code. +The new version brings updated skills automatically — you don't need to do anything. The skills are shipped with the library, so you always get the version that matches your installed code. If a package is installed both locally and globally, Intent prefers the local version. If you need to see what skills have changed, run: diff --git a/docs/overview.md b/docs/overview.md index 3c765a7..faa17f4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -5,7 +5,7 @@ id: overview `@tanstack/intent` is a CLI for shipping and consuming Agent Skills as package artifacts. -Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from `node_modules`, and helps agents load them automatically when working on matching tasks. +Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from local and accessible global `node_modules`, and helps agents load them automatically when working on matching tasks. ## What Intent does @@ -30,13 +30,13 @@ Intent provides tooling for two workflows: npx @tanstack/intent@latest list ``` -Scans `node_modules` for intent-enabled packages and shows available skills with paths and descriptions. +Scans local `node_modules` and any accessible global `node_modules` for intent-enabled packages, preferring local packages when both exist. ```bash npx @tanstack/intent@latest install ``` -Prints instructions for your agent to create `intent-skills` mappings in your config files (CLAUDE.md, .cursorrules, etc.). +Prints instructions for your agent to create `intent-skills` mappings in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing mappings are updated in place; otherwise `AGENTS.md` is the default target. ### Scaffolding and validation diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 5599ad8..7a27cfa 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -5,6 +5,7 @@ import { dirname, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { parse as parseYaml } from 'yaml' import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' +import { INSTALL_PROMPT } from './install-prompt.js' import { scanForIntents } from './scanner.js' import { findSkillFiles, parseFrontmatter } from './utils.js' import type { ScanResult } from './types.js' @@ -38,8 +39,17 @@ async function cmdList(args: Array): Promise { return } + const scanCoverage: Array = [] + if (result.nodeModules.local.scanned) + scanCoverage.push('project node_modules') + if (result.nodeModules.global.scanned) + scanCoverage.push('global node_modules') + if (result.packages.length === 0) { console.log('No intent-enabled packages found.') + if (scanCoverage.length > 0) { + console.log(`Scanned: ${scanCoverage.join(', ')}`) + } if (result.warnings.length > 0) { console.log(`\nWarnings:`) for (const w of result.warnings) console.log(` ⚠ ${w}`) @@ -54,6 +64,11 @@ async function cmdList(args: Array): Promise { console.log( `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills (${result.packageManager})\n`, ) + if (scanCoverage.length > 0) { + console.log( + `Scanned: ${scanCoverage.join(', ')}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, + ) + } // Summary table const rows = result.packages.map((pkg) => [ @@ -459,54 +474,7 @@ switch (command) { cmdValidate(commandArgs) break case 'install': { - const 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 (CLAUDE.md, AGENTS.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings 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: intent list - This outputs each skill's name, description, and full path — grouped by package. - -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." - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file - (prefer CLAUDE.md; create it if none exists) with 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 \`intent 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` - - console.log(prompt) + console.log(INSTALL_PROMPT) break } case 'scaffold': { diff --git a/packages/intent/src/install-prompt.ts b/packages/intent/src/install-prompt.ts new file mode 100644 index 0000000..31fbc00 --- /dev/null +++ b/packages/intent/src/install-prompt.ts @@ -0,0 +1,52 @@ +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: intent 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. + +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 \`intent 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` diff --git a/packages/intent/src/intent-library.ts b/packages/intent/src/intent-library.ts index 1c8961d..0fddfc6 100644 --- a/packages/intent/src/intent-library.ts +++ b/packages/intent/src/intent-library.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' +import { INSTALL_PROMPT } from './install-prompt.js' import { scanLibrary } from './library-scanner.js' import type { LibraryScanResult } from './library-scanner.js' @@ -70,54 +71,7 @@ async function cmdList(): Promise { } function cmdInstall(): void { - const 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 (CLAUDE.md, AGENTS.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings 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: intent list - This outputs each skill's name, description, and full path — grouped by package. - -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." - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file - (prefer CLAUDE.md; create it if none exists) with 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 \`intent 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` - - console.log(prompt) + console.log(INSTALL_PROMPT) } // --------------------------------------------------------------------------- diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 56b172b..d0d91a4 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,9 +1,16 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join, relative, sep } from 'node:path' -import { getDeps, parseFrontmatter, resolveDepDir } from './utils.js' +import { + detectGlobalNodeModules, + getDeps, + listNodeModulesPackageDirs, + parseFrontmatter, + resolveDepDir, +} from './utils.js' import type { IntentConfig, IntentPackage, + NodeModulesScanTarget, ScanResult, SkillEntry, } from './types.js' @@ -194,53 +201,48 @@ export async function scanForIntents(root?: string): Promise { const projectRoot = root ?? process.cwd() const packageManager = detectPackageManager(projectRoot) const nodeModulesDir = join(projectRoot, 'node_modules') + const globalNodeModules = detectGlobalNodeModules(packageManager) const packages: Array = [] const warnings: Array = [] - - if (!existsSync(nodeModulesDir)) { - return { packageManager, packages, warnings } + const nodeModules: ScanResult['nodeModules'] = { + local: { + path: nodeModulesDir, + detected: true, + exists: existsSync(nodeModulesDir), + scanned: false, + }, + global: { + path: globalNodeModules.path, + detected: Boolean(globalNodeModules.path), + exists: globalNodeModules.path + ? existsSync(globalNodeModules.path) + : false, + scanned: false, + source: globalNodeModules.source, + }, } + const resolutionRoots = [nodeModulesDir] - // Collect all package directories to check - const packageDirs: Array<{ dirPath: string }> = [] - - let topEntries: Array> - try { - topEntries = readdirSync(nodeModulesDir, { - withFileTypes: true, - encoding: 'utf8', - }) - } catch { - return { packageManager, packages, warnings } + if ( + nodeModules.global.exists && + nodeModules.global.path && + nodeModules.global.path !== nodeModulesDir + ) { + resolutionRoots.push(nodeModules.global.path) } - for (const entry of topEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue - const dirPath = join(nodeModulesDir, entry.name) - - if (entry.name.startsWith('@')) { - // Scoped package — check children - let scopedEntries: Array> - try { - scopedEntries = readdirSync(dirPath, { - withFileTypes: true, - encoding: 'utf8', - }) - } catch { - continue - } - for (const scoped of scopedEntries) { - if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue - packageDirs.push({ dirPath: join(dirPath, scoped.name) }) - } - } else if (!entry.name.startsWith('.')) { - packageDirs.push({ dirPath }) - } + if (!nodeModules.local.exists && !nodeModules.global.exists) { + return { packageManager, packages, warnings, nodeModules } } + const scanTargets: Array = [ + nodeModules.local, + nodeModules.global, + ] // Track registered package names to avoid duplicates across phases const foundNames = new Set() + const packageRoots = new Map() /** * Try to register a package with a skills/ directory. Reads its @@ -278,12 +280,17 @@ export async function scanForIntents(root?: string): Promise { skills: discoverSkills(skillsDir, name), }) foundNames.add(name) + packageRoots.set(name, dirPath) return true } // Phase 1: Check each top-level package for skills/ - for (const { dirPath } of packageDirs) { - tryRegister(dirPath, 'unknown') + for (const target of scanTargets) { + if (!target.path || !target.exists) continue + target.scanned = true + for (const dirPath of listNodeModulesPackageDirs(target.path)) { + tryRegister(dirPath, 'unknown') + } } // Phase 2: Walk dependency trees to discover transitive deps with skills. @@ -308,7 +315,7 @@ export async function scanForIntents(root?: string): Promise { for (const depName of getDeps(pkgJson)) { if (foundNames.has(depName) || walkVisited.has(depName)) continue - const depDir = resolveDepDir(depName, pkgDir, pkgName, nodeModulesDir) + const depDir = resolveDepDir(depName, pkgDir, pkgName, resolutionRoots) if (!depDir) continue tryRegister(depDir, depName) @@ -318,7 +325,10 @@ export async function scanForIntents(root?: string): Promise { // Walk from packages found in Phase 1 for (const pkg of [...packages]) { - walkDeps(join(nodeModulesDir, pkg.name), pkg.name) + const pkgDir = packageRoots.get(pkg.name) + if (pkgDir) { + walkDeps(pkgDir, pkg.name) + } } // Walk from project's direct deps that weren't found in Phase 1 @@ -343,9 +353,11 @@ export async function scanForIntents(root?: string): Promise { if (projectPkg) { for (const depName of getDeps(projectPkg, true)) { if (walkVisited.has(depName)) continue - const depDir = join(nodeModulesDir, depName) - if (existsSync(join(depDir, 'package.json'))) { - walkDeps(depDir, depName) + const depDir = resolutionRoots.find((candidateRoot) => + existsSync(join(candidateRoot, depName, 'package.json')), + ) + if (depDir) { + walkDeps(join(depDir, depName), depName) } } } @@ -353,5 +365,5 @@ export async function scanForIntents(root?: string): Promise { // Sort by dependency order const sorted = topoSort(packages) - return { packageManager, packages: sorted, warnings } + return { packageManager, packages: sorted, warnings, nodeModules } } diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index b2ab9a8..e57092e 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -17,6 +17,18 @@ export interface ScanResult { packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' packages: Array warnings: Array + nodeModules: { + local: NodeModulesScanTarget + global: NodeModulesScanTarget + } +} + +export interface NodeModulesScanTarget { + path: string | null + detected: boolean + exists: boolean + scanned: boolean + source?: string } export interface IntentPackage { diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index ddd9029..74f44a3 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -1,4 +1,11 @@ -import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { + existsSync, + readFileSync, + readdirSync, + realpathSync, + type Dirent, +} from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' @@ -42,6 +49,100 @@ export function getDeps( return [...deps] } +export function listNodeModulesPackageDirs( + nodeModulesDir: string, +): Array { + if (!existsSync(nodeModulesDir)) return [] + + let topEntries: Array> + try { + topEntries = readdirSync(nodeModulesDir, { + withFileTypes: true, + encoding: 'utf8', + }) + } catch { + return [] + } + + const packageDirs: Array = [] + + for (const entry of topEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + const dirPath = join(nodeModulesDir, entry.name) + + if (entry.name.startsWith('@')) { + let scopedEntries: Array> + try { + scopedEntries = readdirSync(dirPath, { + withFileTypes: true, + encoding: 'utf8', + }) + } catch { + continue + } + + for (const scoped of scopedEntries) { + if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue + packageDirs.push(join(dirPath, scoped.name)) + } + } else if (!entry.name.startsWith('.')) { + packageDirs.push(dirPath) + } + } + + return packageDirs +} + +export function detectGlobalNodeModules(packageManager: string): { + path: string | null + source?: string +} { + const envPath = process.env.INTENT_GLOBAL_NODE_MODULES?.trim() + if (envPath) { + return { + path: envPath, + source: 'INTENT_GLOBAL_NODE_MODULES', + } + } + + const commands: Array<{ + command: string + args: Array + transform?: (output: string) => string + }> = [] + + if (packageManager === 'pnpm') { + commands.push({ command: 'pnpm', args: ['root', '-g'] }) + } + if (packageManager === 'yarn') { + commands.push({ + command: 'yarn', + args: ['global', 'dir'], + transform: (output) => join(output, 'node_modules'), + }) + } + commands.push({ command: 'npm', args: ['root', '-g'] }) + + for (const candidate of commands) { + try { + const output = execFileSync(candidate.command, candidate.args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + if (!output) continue + + return { + path: candidate.transform ? candidate.transform(output) : output, + source: `${candidate.command} ${candidate.args.join(' ')}`, + } + } catch { + continue + } + } + + return { path: null } +} + /** * Resolve the directory of a dependency by name. First checks the top-level * node_modules (hoisted layout — npm, yarn, bun), then resolves through the @@ -52,13 +153,19 @@ export function resolveDepDir( depName: string, parentDir: string, parentName: string, - nodeModulesDir: string, + nodeModulesDirs: string | Array, ): string | null { if (!parentName) return null + const roots = Array.isArray(nodeModulesDirs) + ? nodeModulesDirs + : [nodeModulesDirs] + // 1. Top-level (hoisted) - const topLevel = join(nodeModulesDir, depName) - if (existsSync(join(topLevel, 'package.json'))) return topLevel + for (const nodeModulesDir of roots) { + const topLevel = join(nodeModulesDir, depName) + if (existsSync(join(topLevel, 'package.json'))) return topLevel + } // 2. Resolve through parent's real path (pnpm virtual store) try { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 7822d35..d774e5a 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -84,6 +84,7 @@ describe('intent list --json shape', () => { expect(result).toHaveProperty('packageManager') expect(result).toHaveProperty('packages') expect(result).toHaveProperty('warnings') + expect(result).toHaveProperty('nodeModules') expect(Array.isArray(result.packages)).toBe(true) expect(Array.isArray(result.warnings)).toBe(true) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 829bf7c..0abd565 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -35,13 +35,24 @@ function writeSkillMd(dir: string, frontmatter: Record): void { // ── Setup / Teardown ── let root: string +let globalRoot: string +let previousGlobalNodeModules: string | undefined beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'intent-test-')) + globalRoot = mkdtempSync(join(tmpdir(), 'intent-global-test-')) + previousGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES + delete process.env.INTENT_GLOBAL_NODE_MODULES }) afterEach(() => { rmSync(root, { recursive: true, force: true }) + rmSync(globalRoot, { recursive: true, force: true }) + if (previousGlobalNodeModules === undefined) { + delete process.env.INTENT_GLOBAL_NODE_MODULES + } else { + process.env.INTENT_GLOBAL_NODE_MODULES = previousGlobalNodeModules + } }) // ── Tests ── @@ -51,6 +62,7 @@ describe('scanForIntents', () => { const result = await scanForIntents(root) expect(result.packages).toEqual([]) expect(result.warnings).toEqual([]) + expect(result.nodeModules.local.exists).toBe(false) }) it('returns empty packages when node_modules has no intent packages', async () => { @@ -235,6 +247,63 @@ describe('scanForIntents', () => { expect(result.packages).toHaveLength(0) expect(result.warnings).toHaveLength(0) }) + + it('discovers global-only intent packages', async () => { + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + + const pkgDir = createDir(globalRoot, '@tanstack', 'query') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + const result = await scanForIntents(root) + + expect(result.nodeModules.global.detected).toBe(true) + expect(result.nodeModules.global.exists).toBe(true) + expect(result.nodeModules.global.scanned).toBe(true) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/query') + }) + + it('prefers local packages over global packages with the same name', async () => { + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + + const localPkgDir = createDir(root, 'node_modules', '@tanstack', 'query') + writeJson(join(localPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.1.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(localPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Local fetching skill', + }) + + const globalPkgDir = createDir(globalRoot, '@tanstack', 'query') + writeJson(join(globalPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(globalPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + const result = await scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('5.1.0') + expect(result.packages[0]!.skills[0]!.description).toBe( + 'Local fetching skill', + ) + }) }) describe('package manager detection', () => { From 953064a7ade6066cbe608dd9ed5f377de5917c40 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 14:58:32 -0700 Subject: [PATCH 02/14] feat: improve CLI command handling and enhance feedback validation --- packages/intent/README.md | 64 +++-- packages/intent/package.json | 5 +- packages/intent/src/cli.ts | 196 +++++++------- packages/intent/src/feedback.ts | 68 +++-- packages/intent/src/install-prompt.ts | 5 +- packages/intent/src/scanner.ts | 278 ++++++++++++++------ packages/intent/src/staleness.ts | 42 ++- packages/intent/src/types.ts | 5 +- packages/intent/src/utils.ts | 8 +- packages/intent/tests/cli.test.ts | 219 +++++++++------ packages/intent/tests/feedback.test.ts | 18 ++ packages/intent/tests/meta-feedback.test.ts | 15 ++ packages/intent/tests/scanner.test.ts | 112 ++++++++ packages/intent/tests/staleness.test.ts | 22 ++ 14 files changed, 772 insertions(+), 285 deletions(-) diff --git a/packages/intent/README.md b/packages/intent/README.md index 26ed8ed..42c994e 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -10,9 +10,9 @@ Docs target humans who browse. Types check individual API calls but can't encode The ecosystem already moves toward agent-readable knowledge — Cursor rules, CLAUDE.md files, skills directories. But delivery is stuck in copy-paste: hunt for a community-maintained rules file, paste it into your config, repeat for every tool. No versioning, no update path, no staleness signal. -## Skills: versioned knowledge in npm +## Skills: versioned knowledge in your package manager -A skill is a short, versioned document that tells agents how to use a specific capability of your library — correct patterns, common mistakes, and when to apply them. Skills ship inside your npm package and travel with the tool via `npm update` — not the model's training cutoff, not community-maintained rules files, not prompt snippets in READMEs. Versioned knowledge the maintainer owns, updated when the package updates. +A skill is a short, versioned document that tells agents how to use a specific capability of your library — correct patterns, common mistakes, and when to apply them. Skills ship inside your package and travel with the tool via your normal package manager update flow — not the model's training cutoff, not community-maintained rules files, not prompt snippets in READMEs. Versioned knowledge the maintainer owns, updated when the package updates. Each skill declares its source docs. When those docs change, the CLI flags the skill for review. One source of truth, one derived artifact that stays in sync. @@ -20,20 +20,32 @@ The [Agent Skills spec](https://agentskills.io) is an open standard already adop ## Quick Start +### Command runners + +Use whichever command runner matches your environment: + +| Tool | Pattern | +| ---- | -------------------------------------------- | +| npm | `npm exec @tanstack/intent@latest ` | +| pnpm | `pnpm dlx @tanstack/intent@latest ` | +| bun | `bunx @tanstack/intent@latest ` | + +If you use Deno, support is best-effort today via `npm:` interop with `node_modules` enabled. First-class Deno runtime support is not implemented yet. + ### For library consumers Set up skill-to-task mappings in your project's agent config files (CLAUDE.md, .cursorrules, etc.): ```bash -npx @tanstack/intent@latest install +npm exec @tanstack/intent@latest install ``` -No per-library setup. No hunting for rules files. Install the package, run `npx @tanstack/intent@latest install`, and the agent understands the tool. Update the package, and skills update too. +No per-library setup. No hunting for rules files. Install the package, run `intent install` through your preferred command runner, and the agent understands the tool. Update the package, and skills update too. List available skills from installed packages: ```bash -npx @tanstack/intent@latest list +pnpm dlx @tanstack/intent@latest list ``` ### For library maintainers @@ -41,7 +53,7 @@ npx @tanstack/intent@latest list Generate skills for your library by telling your AI coding agent to run: ```bash -npx @tanstack/intent@latest scaffold +bunx @tanstack/intent@latest scaffold ``` This walks the agent through domain discovery, skill tree generation, and skill creation — one step at a time with your review at each stage. @@ -49,39 +61,49 @@ This walks the agent through domain discovery, skill tree generation, and skill Validate your skill files: ```bash -npx @tanstack/intent@latest validate +npm exec @tanstack/intent@latest validate ``` Check for skills that have fallen behind their sources: ```bash -npx @tanstack/intent@latest stale +pnpm dlx @tanstack/intent@latest stale ``` Copy CI workflow templates into your repo so validation and staleness checks run on every push: ```bash -npx @tanstack/intent@latest setup-github-actions +bunx @tanstack/intent@latest setup-github-actions ``` +## Compatibility + +| Environment | Status | Notes | +| -------------- | ----------- | -------------------------------------------------- | +| Node.js + npm | Supported | Use `npm exec @tanstack/intent@latest ` | +| Node.js + pnpm | Supported | Use `pnpm dlx @tanstack/intent@latest ` | +| Node.js + Bun | Supported | Use `bunx @tanstack/intent@latest ` | +| Deno | Best-effort | Requires `npm:` interop and `node_modules` support | +| Yarn PnP | Unsupported | `@tanstack/intent` scans `node_modules` | + ## Keeping skills current -The real risk with any derived artifact is staleness. `npx @tanstack/intent@latest stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. +The real risk with any derived artifact is staleness. `intent stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. -The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next `npm update`. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. +The feedback loop runs both directions. `intent feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next package update. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. ## CLI Commands -| Command | Description | -| -------------------------------------------------- | --------------------------------------------------- | -| `npx @tanstack/intent@latest install` | Set up skill-to-task mappings in agent config files | -| `npx @tanstack/intent@latest list [--json]` | Discover intent-enabled packages | -| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | -| `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | -| `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | -| `npx @tanstack/intent@latest setup-github-actions` | Copy CI templates into your repo | -| `npx @tanstack/intent@latest stale [--json]` | Check skills for version drift | -| `npx @tanstack/intent@latest feedback` | Submit skill feedback | +| Command | Description | +| ----------------------------- | --------------------------------------------------- | +| `intent install` | Set up skill-to-task mappings in agent config files | +| `intent list [--json]` | Discover intent-enabled packages | +| `intent meta` | List meta-skills for library maintainers | +| `intent scaffold` | Print the guided skill generation prompt | +| `intent validate [dir]` | Validate SKILL.md files | +| `intent setup-github-actions` | Copy CI templates into your repo | +| `intent stale [--json]` | Check skills for version drift | +| `intent feedback` | Submit skill feedback | ## License diff --git a/packages/intent/package.json b/packages/intent/package.json index 2e8887c..0a9dd9d 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -14,7 +14,8 @@ "types": "./dist/index.d.mts" }, "./intent-library": { - "import": "./dist/intent-library.mjs" + "import": "./dist/intent-library.mjs", + "types": "./dist/intent-library.d.mts" } }, "bin": { @@ -32,7 +33,7 @@ "tsdown": "^0.19.0" }, "scripts": { - "prepack": "pnpm run build", + "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:lib": "vitest run", "test:types": "tsc --noEmit" diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 7a27cfa..6bed97e 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -2,12 +2,8 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join, relative, sep } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parse as parseYaml } from 'yaml' -import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' +import { fileURLToPath, pathToFileURL } from 'node:url' import { INSTALL_PROMPT } from './install-prompt.js' -import { scanForIntents } from './scanner.js' -import { findSkillFiles, parseFrontmatter } from './utils.js' import type { ScanResult } from './types.js' // --------------------------------------------------------------------------- @@ -24,6 +20,9 @@ function getMetaDir(): string { // --------------------------------------------------------------------------- async function cmdList(args: Array): Promise { + const { computeSkillNameWidth, printSkillTree, printTable } = + await import('./display.js') + const { scanForIntents } = await import('./scanner.js') const jsonOutput = args.includes('--json') let result: ScanResult @@ -106,7 +105,8 @@ async function cmdList(args: Array): Promise { } } -function cmdMeta(args: Array): void { +async function cmdMeta(args: Array): Promise { + const { parseFrontmatter } = await import('./utils.js') const metaDir = getMetaDir() if (!existsSync(metaDir)) { @@ -123,9 +123,7 @@ function cmdMeta(args: Array): void { const skillFile = join(metaDir, name, 'SKILL.md') if (!existsSync(skillFile)) { console.error(`Meta-skill "${name}" not found.`) - console.error( - `Run \`npx @tanstack/intent meta\` to list available meta-skills.`, - ) + console.error(`Run \`intent meta\` to list available meta-skills.`) process.exit(1) } try { @@ -220,7 +218,11 @@ function collectPackagingWarnings(root: string): Array { return warnings } -function cmdValidate(args: Array): void { +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) @@ -431,7 +433,7 @@ This produces: individual SKILL.md files. ## After all skills are generated -1. Run \`npx @tanstack/intent validate\` in each package directory +1. Run \`intent validate\` in each package directory 2. Commit skills/ and artifacts 3. For each publishable package, run: \`npx @tanstack/intent add-library-bin\` 4. For each publishable package, run: \`npx @tanstack/intent edit-package-json\` @@ -447,7 +449,7 @@ This produces: individual SKILL.md files. // Main // --------------------------------------------------------------------------- -const USAGE = `TanStack Intent CLI +export const USAGE = `TanStack Intent CLI Usage: intent list [--json] Discover intent-enabled packages @@ -460,94 +462,104 @@ Usage: intent setup-github-actions Copy CI workflow templates to .github/workflows/ intent stale Check skills for staleness` -const command = process.argv[2] -const commandArgs = process.argv.slice(3) - -switch (command) { - case 'list': - await cmdList(commandArgs) - break - case 'meta': - cmdMeta(commandArgs) - break - case 'validate': - cmdValidate(commandArgs) - break - case 'install': { - console.log(INSTALL_PROMPT) - break - } - case 'scaffold': { - cmdScaffold() - break - } - case 'stale': { - const { checkStaleness } = await import('./staleness.js') - const { scanForIntents: scanStale } = await import('./scanner.js') - let staleResult - try { - staleResult = await scanStale() - } catch (err) { - console.error((err as Error).message) - process.exit(1) +export async function main(argv: Array = process.argv.slice(2)) { + const command = argv[0] + const commandArgs = argv.slice(1) + + 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 } - - if (staleResult.packages.length === 0) { - console.log('No intent-enabled packages found.') - break + case 'scaffold': { + cmdScaffold() + return 0 } + case 'stale': { + const { checkStaleness } = await import('./staleness.js') + const { scanForIntents: scanStale } = await import('./scanner.js') + let staleResult + try { + staleResult = await scanStale() + } catch (err) { + console.error((err as Error).message) + process.exit(1) + } - const jsonStale = commandArgs.includes('--json') - const reports = await Promise.all( - staleResult.packages.map((pkg) => { - const pkgDir = join(process.cwd(), 'node_modules', pkg.name) - return checkStaleness(pkgDir, pkg.name) - }), - ) + if (staleResult.packages.length === 0) { + console.log('No intent-enabled packages found.') + return 0 + } - if (jsonStale) { - console.log(JSON.stringify(reports, null, 2)) - break - } + const jsonStale = commandArgs.includes('--json') + const reports = await Promise.all( + staleResult.packages.map((pkg) => { + return checkStaleness(pkg.packageRoot, pkg.name) + }), + ) + + if (jsonStale) { + console.log(JSON.stringify(reports, null, 2)) + return 0 + } - for (const report of reports) { - const driftLabel = report.versionDrift - ? ` [${report.versionDrift} drift]` - : '' - const vLabel = - report.skillVersion && report.currentVersion - ? ` (${report.skillVersion} → ${report.currentVersion})` + for (const report of reports) { + const driftLabel = report.versionDrift + ? ` [${report.versionDrift} drift]` : '' - 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(', ')}`) + 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() } - console.log() + return 0 } - break - } - case 'add-library-bin': { - const { runAddLibraryBinAll } = await import('./setup.js') - runAddLibraryBinAll(process.cwd()) - break - } - case 'edit-package-json': { - const { runEditPackageJsonAll } = await import('./setup.js') - runEditPackageJsonAll(process.cwd()) - break - } - case 'setup-github-actions': { - const { runSetupGithubActions } = await import('./setup.js') - runSetupGithubActions(process.cwd(), getMetaDir()) - break + case 'add-library-bin': { + const { runAddLibraryBinAll } = await import('./setup.js') + runAddLibraryBinAll(process.cwd()) + 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: + console.log(USAGE) + return command ? 1 : 0 } - default: - console.log(USAGE) - process.exit(command ? 1 : 0) +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (isMain) { + const exitCode = await main() + process.exit(exitCode) } diff --git a/packages/intent/src/feedback.ts b/packages/intent/src/feedback.ts index b39c93d..d1561e3 100644 --- a/packages/intent/src/feedback.ts +++ b/packages/intent/src/feedback.ts @@ -2,6 +2,7 @@ import { execFileSync, execSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { + FeedbackFrequency, FeedbackPayload, IntentProjectConfig, MetaFeedbackPayload, @@ -51,28 +52,40 @@ function getHomeConfigDir(): string { ) } -export function resolveFrequency(root: string): string { - // 1. User override (~/.config/intent/config.json) - const userConfigPath = join(getHomeConfigDir(), 'intent', 'config.json') +function parseFrequency(value: unknown): FeedbackFrequency | null { + if (value === 'always' || value === 'never') return value + if (typeof value !== 'string') return null + + const match = /^every-(\d+)$/.exec(value) + if (!match) return null + + const count = Number(match[1]) + return Number.isInteger(count) && count > 0 + ? (`every-${count}` as FeedbackFrequency) + : null +} + +function readFrequency(filePath: string): FeedbackFrequency | null { try { - const userCfg = JSON.parse( - readFileSync(userConfigPath, 'utf8'), + const config = JSON.parse( + readFileSync(filePath, 'utf8'), ) as Partial - if (userCfg.feedback?.frequency) return userCfg.feedback.frequency + return parseFrequency(config.feedback?.frequency) } catch { - /* fallback */ + return null } +} + +export function resolveFrequency(root: string): string { + // 1. User override (~/.config/intent/config.json) + const userConfigPath = join(getHomeConfigDir(), 'intent', 'config.json') + const userFrequency = readFrequency(userConfigPath) + if (userFrequency) return userFrequency // 2. Project config const projectConfigPath = join(root, 'intent.config.json') - try { - const projCfg = JSON.parse( - readFileSync(projectConfigPath, 'utf8'), - ) as Partial - if (projCfg.feedback?.frequency) return projCfg.feedback.frequency - } catch { - /* fallback */ - } + const projectFrequency = readFrequency(projectConfigPath) + if (projectFrequency) return projectFrequency // 3. Default return 'every-5' @@ -156,6 +169,13 @@ const VALID_META_SKILLS = [ const VALID_AGENTS = ['claude-code', 'cursor', 'copilot', 'codex', 'other'] const VALID_QUALITY_RATINGS = ['good', 'mixed', 'bad'] +const VALID_INTERVIEW_QUALITY_RATINGS = ['good', 'mixed', 'bad', 'skipped'] +const VALID_FAILURE_MODE_QUALITY_RATINGS = [ + 'good', + 'mixed', + 'bad', + 'not-applicable', +] export function validateMetaPayload(payload: unknown): { valid: boolean @@ -195,6 +215,24 @@ export function validateMetaPayload(payload: unknown): { errors.push('userRating must be one of: good, mixed, bad') } + if ( + obj.interviewQuality && + !VALID_INTERVIEW_QUALITY_RATINGS.includes(obj.interviewQuality as string) + ) { + errors.push('interviewQuality must be one of: good, mixed, bad, skipped') + } + + if ( + obj.failureModeQuality && + !VALID_FAILURE_MODE_QUALITY_RATINGS.includes( + obj.failureModeQuality as string, + ) + ) { + errors.push( + 'failureModeQuality must be one of: good, mixed, bad, not-applicable', + ) + } + // Secret scan const allText = Object.values(obj) .filter((v) => typeof v === 'string') diff --git a/packages/intent/src/install-prompt.ts b/packages/intent/src/install-prompt.ts index 31fbc00..4085763 100644 --- a/packages/intent/src/install-prompt.ts +++ b/packages/intent/src/install-prompt.ts @@ -15,6 +15,8 @@ Follow these steps in order: Run: intent 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: @@ -49,4 +51,5 @@ skills: - Use the user's own words for task descriptions - Include the exact path from \`intent 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` + - 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/scanner.ts b/packages/intent/src/scanner.ts index d0d91a4..f390163 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -193,6 +193,38 @@ function topoSort(packages: Array): Array { return sorted } +function getPackageDepth(packageRoot: string, projectRoot: string): number { + return relative(projectRoot, packageRoot).split(sep).length +} + +function comparePackageVersions(a: string, b: string): number { + const aMatch = /^(\d+)\.(\d+)\.(\d+)/.exec(a) + const bMatch = /^(\d+)\.(\d+)\.(\d+)/.exec(b) + if (!aMatch || !bMatch) return 0 + + for (let i = 1; i <= 3; i++) { + const diff = Number(aMatch[i]) - Number(bMatch[i]) + if (diff !== 0) return diff + } + + return 0 +} + +function formatVariantWarning( + name: string, + variants: Array<{ version: string; packageRoot: string }>, + chosen: IntentPackage, +): string | null { + const uniqueVersions = new Set(variants.map((variant) => variant.version)) + if (uniqueVersions.size <= 1) return null + + const details = variants + .map((variant) => `${variant.version} at ${variant.packageRoot}`) + .join(', ') + + return `Found ${variants.length} installed variants of ${name} across ${uniqueVersions.size} versions (${details}). Using ${chosen.version} from ${chosen.packageRoot}.` +} + // --------------------------------------------------------------------------- // Main scanner // --------------------------------------------------------------------------- @@ -201,7 +233,8 @@ export async function scanForIntents(root?: string): Promise { const projectRoot = root ?? process.cwd() const packageManager = detectPackageManager(projectRoot) const nodeModulesDir = join(projectRoot, 'node_modules') - const globalNodeModules = detectGlobalNodeModules(packageManager) + const explicitGlobalNodeModules = + process.env.INTENT_GLOBAL_NODE_MODULES?.trim() || null const packages: Array = [] const warnings: Array = [] @@ -213,36 +246,85 @@ export async function scanForIntents(root?: string): Promise { scanned: false, }, global: { - path: globalNodeModules.path, - detected: Boolean(globalNodeModules.path), - exists: globalNodeModules.path - ? existsSync(globalNodeModules.path) + path: explicitGlobalNodeModules, + detected: Boolean(explicitGlobalNodeModules), + exists: explicitGlobalNodeModules + ? existsSync(explicitGlobalNodeModules) : false, scanned: false, - source: globalNodeModules.source, + source: explicitGlobalNodeModules + ? 'INTENT_GLOBAL_NODE_MODULES' + : undefined, }, } const resolutionRoots = [nodeModulesDir] - if ( - nodeModules.global.exists && - nodeModules.global.path && - nodeModules.global.path !== nodeModulesDir - ) { - resolutionRoots.push(nodeModules.global.path) + // Track registered package names to avoid duplicates across phases + const packageIndexes = new Map() + const packageJsonCache = new Map | null>() + const packageVariants = new Map< + string, + Map + >() + + function rememberVariant(pkg: IntentPackage): void { + let variants = packageVariants.get(pkg.name) + if (!variants) { + variants = new Map() + packageVariants.set(pkg.name, variants) + } + variants.set(pkg.packageRoot, { + version: pkg.version, + packageRoot: pkg.packageRoot, + }) } - if (!nodeModules.local.exists && !nodeModules.global.exists) { - return { packageManager, packages, warnings, nodeModules } + function ensureGlobalNodeModules(): void { + if (!nodeModules.global.path && !explicitGlobalNodeModules) { + const detected = detectGlobalNodeModules(packageManager) + nodeModules.global.path = detected.path + nodeModules.global.source = detected.source + nodeModules.global.detected = Boolean(detected.path) + nodeModules.global.exists = detected.path + ? existsSync(detected.path) + : false + } + + if ( + nodeModules.global.exists && + nodeModules.global.path && + nodeModules.global.path !== nodeModulesDir && + !resolutionRoots.includes(nodeModules.global.path) + ) { + resolutionRoots.push(nodeModules.global.path) + } } - const scanTargets: Array = [ - nodeModules.local, - nodeModules.global, - ] - // Track registered package names to avoid duplicates across phases - const foundNames = new Set() - const packageRoots = new Map() + function readPkgJson(dirPath: string): Record | null { + if (packageJsonCache.has(dirPath)) { + return packageJsonCache.get(dirPath) ?? null + } + + try { + const pkgJson = JSON.parse( + readFileSync(join(dirPath, 'package.json'), 'utf8'), + ) as Record + packageJsonCache.set(dirPath, pkgJson) + return pkgJson + } catch { + packageJsonCache.set(dirPath, null) + return null + } + } + + function scanTarget(target: NodeModulesScanTarget): void { + if (!target.path || !target.exists || target.scanned) return + target.scanned = true + + for (const dirPath of listNodeModulesPackageDirs(target.path)) { + tryRegister(dirPath, 'unknown') + } + } /** * Try to register a package with a skills/ directory. Reads its @@ -253,17 +335,15 @@ export async function scanForIntents(root?: string): Promise { const skillsDir = join(dirPath, 'skills') if (!existsSync(skillsDir)) return false - let pkgJson: Record - try { - pkgJson = JSON.parse(readFileSync(join(dirPath, 'package.json'), 'utf8')) - } catch { + const pkgJson = readPkgJson(dirPath) + if (!pkgJson) { warnings.push(`Could not read package.json for ${dirPath}`) return false } const name = typeof pkgJson.name === 'string' ? pkgJson.name : fallbackName - if (foundNames.has(name)) return false - + const version = + typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0' const intent = validateIntentField(name, pkgJson.intent) ?? deriveIntentConfig(pkgJson) if (!intent) { @@ -273,39 +353,56 @@ export async function scanForIntents(root?: string): Promise { return false } - packages.push({ + const candidate: IntentPackage = { name, - version: typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0', + version, intent, skills: discoverSkills(skillsDir, name), - }) - foundNames.add(name) - packageRoots.set(name, dirPath) - return true - } + packageRoot: dirPath, + } + const existingIndex = packageIndexes.get(name) + if (existingIndex === undefined) { + rememberVariant(candidate) + packageIndexes.set(name, packages.push(candidate) - 1) + return true + } - // Phase 1: Check each top-level package for skills/ - for (const target of scanTargets) { - if (!target.path || !target.exists) continue - target.scanned = true - for (const dirPath of listNodeModulesPackageDirs(target.path)) { - tryRegister(dirPath, 'unknown') + const existing = packages[existingIndex]! + if (existing.packageRoot === candidate.packageRoot) { + return false + } + + rememberVariant(existing) + rememberVariant(candidate) + + const existingDepth = getPackageDepth(existing.packageRoot, projectRoot) + const candidateDepth = getPackageDepth(candidate.packageRoot, projectRoot) + const shouldReplace = + candidateDepth < existingDepth || + (candidateDepth === existingDepth && + comparePackageVersions(candidate.version, existing.version) > 0) + + if (shouldReplace) { + packages[existingIndex] = candidate } + + return true } + // Phase 1: Check local top-level packages for skills/ + scanTarget(nodeModules.local) + // Phase 2: Walk dependency trees to discover transitive deps with skills. // This handles pnpm and other non-hoisted layouts where transitive deps // are not visible at the top level of node_modules. const walkVisited = new Set() function walkDeps(pkgDir: string, pkgName: string): void { - if (walkVisited.has(pkgName)) return - walkVisited.add(pkgName) + if (walkVisited.has(pkgDir)) return + walkVisited.add(pkgDir) - let pkgJson: Record - try { - pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) - } catch { + const pkgJson = readPkgJson(pkgDir) + if (!pkgJson) { warnings.push( `Could not read package.json for ${pkgName} (skipping dependency walk)`, ) @@ -313,55 +410,82 @@ export async function scanForIntents(root?: string): Promise { } for (const depName of getDeps(pkgJson)) { - if (foundNames.has(depName) || walkVisited.has(depName)) continue - const depDir = resolveDepDir(depName, pkgDir, pkgName, resolutionRoots) - if (!depDir) continue + if (!depDir || walkVisited.has(depDir)) continue tryRegister(depDir, depName) walkDeps(depDir, depName) } } - // Walk from packages found in Phase 1 - for (const pkg of [...packages]) { - const pkgDir = packageRoots.get(pkg.name) - if (pkgDir) { - walkDeps(pkgDir, pkg.name) + function walkKnownPackages(): void { + for (const pkg of [...packages]) { + walkDeps(pkg.packageRoot, pkg.name) } } - // Walk from project's direct deps that weren't found in Phase 1 - let projectPkg: Record | null = null - try { - projectPkg = JSON.parse( - readFileSync(join(projectRoot, 'package.json'), 'utf8'), - ) - } catch (err: unknown) { - const isNotFound = - err && - typeof err === 'object' && - 'code' in err && - (err as NodeJS.ErrnoException).code === 'ENOENT' - if (!isNotFound) { - warnings.push( - `Could not read project package.json: ${err instanceof Error ? err.message : String(err)}`, - ) + function walkProjectDeps(): void { + let projectPkg: Record | null = null + try { + projectPkg = JSON.parse( + readFileSync(join(projectRoot, 'package.json'), 'utf8'), + ) as Record + } catch (err: unknown) { + const isNotFound = + err && + typeof err === 'object' && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + warnings.push( + `Could not read project package.json: ${err instanceof Error ? err.message : String(err)}`, + ) + } } - } - if (projectPkg) { + if (!projectPkg) return + for (const depName of getDeps(projectPkg, true)) { - if (walkVisited.has(depName)) continue - const depDir = resolutionRoots.find((candidateRoot) => - existsSync(join(candidateRoot, depName, 'package.json')), + const depDir = resolveDepDir( + depName, + projectRoot, + depName, + resolutionRoots, ) - if (depDir) { - walkDeps(join(depDir, depName), depName) + if (depDir && !walkVisited.has(depDir)) { + walkDeps(depDir, depName) } } } + walkKnownPackages() + walkProjectDeps() + + if ( + explicitGlobalNodeModules || + packages.length === 0 || + !nodeModules.local.exists + ) { + ensureGlobalNodeModules() + scanTarget(nodeModules.global) + walkKnownPackages() + walkProjectDeps() + } + + if (!nodeModules.local.exists && !nodeModules.global.exists) { + return { packageManager, packages, warnings, nodeModules } + } + + for (const pkg of packages) { + const variants = packageVariants.get(pkg.name) + if (!variants) continue + + const warning = formatVariantWarning(pkg.name, [...variants.values()], pkg) + if (warning) { + warnings.push(warning) + } + } + // Sort by dependency order const sorted = topoSort(packages) diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 60209f4..679c2ec 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -59,10 +59,50 @@ interface SyncState { skills?: Record }> } +function isStringRecord(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ) +} + +function parseSyncState(value: unknown): SyncState | null { + if (!value || typeof value !== 'object') return null + + const raw = value as Record + const parsed: SyncState = {} + + if (typeof raw.library_version === 'string') { + parsed.library_version = raw.library_version + } + + if (raw.skills && typeof raw.skills === 'object') { + const skills: Record }> = {} + + for (const [skillName, skillValue] of Object.entries(raw.skills)) { + if (!skillValue || typeof skillValue !== 'object') continue + + const sourcesSha = (skillValue as Record).sources_sha + if (sourcesSha !== undefined && !isStringRecord(sourcesSha)) continue + + skills[skillName] = {} + if (sourcesSha) { + skills[skillName]!.sources_sha = sourcesSha + } + } + + parsed.skills = skills + } + + return parsed +} + function readSyncState(packageDir: string): SyncState | null { const statePath = join(packageDir, 'skills', 'sync-state.json') try { - return JSON.parse(readFileSync(statePath, 'utf8')) as SyncState + return parseSyncState(JSON.parse(readFileSync(statePath, 'utf8'))) } catch { return null } diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index e57092e..f424732 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -36,6 +36,7 @@ export interface IntentPackage { version: string intent: IntentConfig skills: Array + packageRoot: string } export interface SkillEntry { @@ -106,12 +107,14 @@ export interface MetaFeedbackPayload { userRating: 'good' | 'mixed' | 'bad' } +export type FeedbackFrequency = 'always' | 'never' | `every-${number}` + // --------------------------------------------------------------------------- // Config types // --------------------------------------------------------------------------- export interface IntentProjectConfig { feedback: { - frequency: string // "always" | "every-N" | "never" + frequency: FeedbackFrequency } } diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index 74f44a3..d727ddd 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -167,7 +167,13 @@ export function resolveDepDir( if (existsSync(join(topLevel, 'package.json'))) return topLevel } - // 2. Resolve through parent's real path (pnpm virtual store) + // 2. Resolve nested installs under the parent package (npm/pnpm/bun) + const nestedNodeModules = join(parentDir, 'node_modules', depName) + if (existsSync(join(nestedNodeModules, 'package.json'))) { + return nestedNodeModules + } + + // 3. Resolve through parent's real path (pnpm virtual store) try { const realParent = realpathSync(parentDir) const segments = parentName.split('/').length diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index d774e5a..5cb93d0 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1,95 +1,166 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { parse as parseYaml } from 'yaml' - -// ── Meta-skills tests (intent meta) ── +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { INSTALL_PROMPT } from '../src/install-prompt.js' +import { main, USAGE } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') +const packageJsonPath = join(thisDir, '..', 'package.json') + +function writeJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +function writeSkillMd(dir: string, frontmatter: Record): void { + mkdirSync(dir, { recursive: true }) + const yamlLines = Object.entries(frontmatter) + .map( + ([key, value]) => + `${key}: ${typeof value === 'string' ? `"${value}"` : value}`, + ) + .join('\n') + + writeFileSync( + join(dir, 'SKILL.md'), + `---\n${yamlLines}\n---\n\nSkill content here.\n`, + ) +} + +let originalCwd: string +let logSpy: ReturnType +let errorSpy: ReturnType +let tempDirs: Array + +beforeEach(() => { + originalCwd = process.cwd() + tempDirs = [] + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + process.chdir(originalCwd) + logSpy.mockRestore() + errorSpy.mockRestore() + for (const dir of tempDirs) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) describe('intent meta', () => { - it('meta directory exists', () => { - expect(existsSync(metaDir)).toBe(true) + it('lists the shipped public meta-skills', async () => { + const exitCode = await main(['meta']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Meta-skills') + expect(output).toContain('domain-discovery') + expect(output).toContain('tree-generator') + expect(output).toContain('generate-skill') + expect(output).toContain('skill-staleness-check') }) - it('contains expected meta-skills', () => { - const entries = readdirSync(metaDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .filter((e) => existsSync(join(metaDir, e.name, 'SKILL.md'))) - .map((e) => e.name) + it('prints the requested meta-skill content', async () => { + const expected = readFileSync( + join(metaDir, 'domain-discovery', 'SKILL.md'), + 'utf8', + ) - expect(entries).toContain('domain-discovery') - expect(entries).toContain('tree-generator') - expect(entries).toContain('generate-skill') - expect(entries).toContain('skill-staleness-check') - }) + const exitCode = await main(['meta', 'domain-discovery']) - it('each meta-skill has a description in frontmatter', () => { - const entries = readdirSync(metaDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .filter((e) => existsSync(join(metaDir, e.name, 'SKILL.md'))) - - for (const entry of entries) { - const content = readFileSync( - join(metaDir, entry.name, 'SKILL.md'), - 'utf8', - ) - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - expect(match, `${entry.name} should have frontmatter`).not.toBeNull() - - const fm = parseYaml(match![1]!) as Record - expect( - fm.description, - `${entry.name} should have a description`, - ).toBeTruthy() - } + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith(expected) }) }) -// ── Validate command logic ── - -describe('intent validate', () => { - it('finds SKILL.md files in meta directory', () => { - function findSkillFiles(dir: string): Array { - const files: Array = [] - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - files.push(...findSkillFiles(fullPath)) - } else if (entry.name === 'SKILL.md') { - files.push(fullPath) - } - } - return files +describe('cli commands', () => { + it('prints usage when no command is provided', async () => { + const exitCode = await main([]) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith(USAGE) + }) + + it('prints the install prompt', async () => { + const exitCode = await main(['install']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) + }) + + it('lists installed intent packages as json', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-list-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'db') + + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/db', + version: '0.5.2', + intent: { version: 1, repo: 'TanStack/db', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['list', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const parsed = JSON.parse(String(output)) as { + packages: Array<{ name: string; version: string; packageRoot: string }> + warnings: Array } - const files = findSkillFiles(metaDir) - expect(files.length).toBeGreaterThan(0) + expect(exitCode).toBe(0) + expect(parsed.packages).toHaveLength(1) + expect(parsed.packages[0]).toMatchObject({ + name: '@tanstack/db', + version: '0.5.2', + packageRoot: pkgDir, + }) + expect(parsed.warnings).toEqual([]) + }) + + it('validates a well-formed skills directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-validate-')) + tempDirs.push(root) + + writeSkillMd(join(root, 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['validate']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + '✅ Validated 1 skill files — all passed', + ) }) }) -// ── Scanner JSON output shape ── - -describe('intent list --json shape', () => { - it('scanForIntents returns correct shape', async () => { - const { scanForIntents } = await import('../src/scanner.js') - // Run against a dir with no node_modules — should return valid shape - const { mkdtempSync } = await import('node:fs') - const { tmpdir } = await import('node:os') - const root = mkdtempSync(join(tmpdir(), 'cli-test-')) - - const result = await scanForIntents(root) - expect(result).toHaveProperty('packageManager') - expect(result).toHaveProperty('packages') - expect(result).toHaveProperty('warnings') - expect(result).toHaveProperty('nodeModules') - expect(Array.isArray(result.packages)).toBe(true) - expect(Array.isArray(result.warnings)).toBe(true) - - // Cleanup - const { rmSync } = await import('node:fs') - rmSync(root, { recursive: true, force: true }) +describe('package metadata', () => { + it('uses a package-manager-neutral prepack script', () => { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + scripts?: Record + } + + expect(packageJson.scripts?.prepack).toBe('npm run build') }) }) diff --git a/packages/intent/tests/feedback.test.ts b/packages/intent/tests/feedback.test.ts index 650b0a9..cbeb231 100644 --- a/packages/intent/tests/feedback.test.ts +++ b/packages/intent/tests/feedback.test.ts @@ -224,6 +224,24 @@ describe('resolveFrequency', () => { expect(resolveFrequency(tmpDir)).toBe('every-5') }) + it('ignores invalid project config values and falls back to default', () => { + writeFileSync( + join(tmpDir, 'intent.config.json'), + JSON.stringify({ feedback: { frequency: 'sometimes' } }), + ) + + expect(resolveFrequency(tmpDir)).toBe('every-5') + }) + + it('accepts validated every-N frequencies', () => { + writeFileSync( + join(tmpDir, 'intent.config.json'), + JSON.stringify({ feedback: { frequency: 'every-12' } }), + ) + + expect(resolveFrequency(tmpDir)).toBe('every-12') + }) + it('reads user override via XDG_CONFIG_HOME', () => { const configDir = join(tmpDir, 'xdg') mkdirSync(join(configDir, 'intent'), { recursive: true }) diff --git a/packages/intent/tests/meta-feedback.test.ts b/packages/intent/tests/meta-feedback.test.ts index 1e7d682..2217c87 100644 --- a/packages/intent/tests/meta-feedback.test.ts +++ b/packages/intent/tests/meta-feedback.test.ts @@ -128,6 +128,21 @@ describe('validateMetaPayload', () => { ) expect(result.valid).toBe(true) }) + + it('rejects invalid optional quality fields', () => { + const result = validateMetaPayload( + validMetaPayload({ + interviewQuality: 'excellent' as any, + failureModeQuality: 'unknown' as any, + }), + ) + + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('interviewQuality'))).toBe(true) + expect(result.errors.some((e) => e.includes('failureModeQuality'))).toBe( + true, + ) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 0abd565..9819fa2 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -97,6 +97,7 @@ describe('scanForIntents', () => { expect(result.packages).toHaveLength(1) expect(result.packages[0]!.name).toBe('@tanstack/db') expect(result.packages[0]!.version).toBe('0.5.2') + expect(result.packages[0]!.packageRoot).toBe(pkgDir) expect(result.packages[0]!.skills).toHaveLength(1) expect(result.packages[0]!.skills[0]!.name).toBe('db-core') expect(result.packages[0]!.skills[0]!.description).toBe( @@ -298,11 +299,122 @@ describe('scanForIntents', () => { const result = await scanForIntents(root) + expect(result.nodeModules.global.detected).toBe(true) + expect(result.nodeModules.global.scanned).toBe(true) expect(result.packages).toHaveLength(1) expect(result.packages[0]!.version).toBe('5.1.0') expect(result.packages[0]!.skills[0]!.description).toBe( 'Local fetching skill', ) + expect( + result.warnings.some( + (warning) => + warning.includes('Found 2 installed variants of @tanstack/query') && + warning.includes('Using 5.1.0'), + ), + ).toBe(true) + }) + + it('chooses the highest version when duplicate package names exist at the same depth', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + 'consumer-c': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { + '@tanstack/query': '4.0.0', + }, + }) + + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { + '@tanstack/query': '5.0.0', + }, + }) + + const consumerCDir = createDir(root, 'node_modules', 'consumer-c') + writeJson(join(consumerCDir, 'package.json'), { + name: 'consumer-c', + version: '1.0.0', + dependencies: { + '@tanstack/query': '3.0.0', + }, + }) + + const queryV4Dir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(queryV4Dir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(queryV4Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v4 skill', + }) + + const queryV5Dir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(queryV5Dir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(queryV5Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v5 skill', + }) + + const queryV3Dir = createDir( + consumerCDir, + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(queryV3Dir, 'package.json'), { + name: '@tanstack/query', + version: '3.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(queryV3Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v3 skill', + }) + + const result = await scanForIntents(root) + const versionWarning = result.warnings.find((warning) => + warning.includes('@tanstack/query'), + ) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/query') + expect(result.packages[0]!.version).toBe('5.0.0') + expect(result.packages[0]!.packageRoot).toBe(queryV5Dir) + expect(versionWarning).toContain( + 'Found 3 installed variants of @tanstack/query', + ) + expect(versionWarning).toContain('across 3 versions') + expect(versionWarning).toContain('Using 5.0.0') }) }) diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index 64586cb..d6c2333 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -225,6 +225,28 @@ describe('checkStaleness', () => { expect(report.skills[0]!.needsReview).toBe(false) }) + it('ignores malformed sync-state entries instead of flagging false positives', async () => { + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + sources: ['docs/api.md'], + }) + + writeSyncState(tmpDir, { + skills: { + core: { + sources_sha: ['not', 'a', 'record'], + }, + }, + }) + + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) + + const report = await checkStaleness(tmpDir, '@example/lib') + expect(report.skills[0]!.needsReview).toBe(false) + expect(report.skills[0]!.reasons).toEqual([]) + }) + it('handles nested skill directories', async () => { writeSkill(tmpDir, 'react/hooks', { name: 'react/hooks', From 0d49077d694d15720dc326c01dd9918ca49e2ce4 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 15:10:27 -0700 Subject: [PATCH 03/14] feat: add version conflict detection and reporting in CLI output --- packages/intent/src/cli.ts | 21 +++++++++++ packages/intent/src/scanner.ts | 32 ++++++++++++++-- packages/intent/src/types.ts | 12 ++++++ packages/intent/tests/cli.test.ts | 61 +++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 6bed97e..61027da 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -78,6 +78,27 @@ async function cmdList(args: Array): Promise { ]) printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) + if (result.conflicts.length > 0) { + console.log(` +Version conflicts: +`) + for (const conflict of result.conflicts) { + const otherVariants = conflict.variants.filter( + (variant) => variant.packageRoot !== conflict.chosen.packageRoot, + ) + console.log( + ` ${conflict.packageName} -> using ${conflict.chosen.version}`, + ) + console.log(` chosen: ${conflict.chosen.packageRoot}`) + for (const variant of otherVariants) { + console.log( + ` also found: ${variant.version} at ${variant.packageRoot}`, + ) + } + console.log() + } + } + // Skills detail const allSkills = result.packages.map((p) => p.skills) const nameWidth = computeSkillNameWidth(allSkills) diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index f390163..5164f70 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -8,11 +8,13 @@ import { resolveDepDir, } from './utils.js' import type { + InstalledVariant, IntentConfig, IntentPackage, NodeModulesScanTarget, ScanResult, SkillEntry, + VersionConflict, } from './types.js' import type { Dirent } from 'node:fs' @@ -212,7 +214,7 @@ function comparePackageVersions(a: string, b: string): number { function formatVariantWarning( name: string, - variants: Array<{ version: string; packageRoot: string }>, + variants: Array, chosen: IntentPackage, ): string | null { const uniqueVersions = new Set(variants.map((variant) => variant.version)) @@ -225,6 +227,24 @@ function formatVariantWarning( return `Found ${variants.length} installed variants of ${name} across ${uniqueVersions.size} versions (${details}). Using ${chosen.version} from ${chosen.packageRoot}.` } +function toVersionConflict( + packageName: string, + variants: Array, + chosen: IntentPackage, +): VersionConflict | null { + const uniqueVersions = new Set(variants.map((variant) => variant.version)) + if (uniqueVersions.size <= 1) return null + + return { + packageName, + chosen: { + version: chosen.version, + packageRoot: chosen.packageRoot, + }, + variants, + } +} + // --------------------------------------------------------------------------- // Main scanner // --------------------------------------------------------------------------- @@ -238,6 +258,7 @@ export async function scanForIntents(root?: string): Promise { const packages: Array = [] const warnings: Array = [] + const conflicts: Array = [] const nodeModules: ScanResult['nodeModules'] = { local: { path: nodeModulesDir, @@ -473,13 +494,18 @@ export async function scanForIntents(root?: string): Promise { } if (!nodeModules.local.exists && !nodeModules.global.exists) { - return { packageManager, packages, warnings, nodeModules } + return { packageManager, packages, warnings, conflicts, nodeModules } } for (const pkg of packages) { const variants = packageVariants.get(pkg.name) if (!variants) continue + const conflict = toVersionConflict(pkg.name, [...variants.values()], pkg) + if (conflict) { + conflicts.push(conflict) + } + const warning = formatVariantWarning(pkg.name, [...variants.values()], pkg) if (warning) { warnings.push(warning) @@ -489,5 +515,5 @@ export async function scanForIntents(root?: string): Promise { // Sort by dependency order const sorted = topoSort(packages) - return { packageManager, packages: sorted, warnings, nodeModules } + return { packageManager, packages: sorted, warnings, conflicts, nodeModules } } diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index f424732..f7ef756 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -17,6 +17,7 @@ export interface ScanResult { packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' packages: Array warnings: Array + conflicts: Array nodeModules: { local: NodeModulesScanTarget global: NodeModulesScanTarget @@ -39,6 +40,17 @@ export interface IntentPackage { packageRoot: string } +export interface InstalledVariant { + version: string + packageRoot: string +} + +export interface VersionConflict { + packageName: string + chosen: InstalledVariant + variants: Array +} + export interface SkillEntry { name: string path: string diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 5cb93d0..e9e2ff7 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -122,6 +122,7 @@ describe('cli commands', () => { const output = logSpy.mock.calls.at(-1)?.[0] const parsed = JSON.parse(String(output)) as { packages: Array<{ name: string; version: string; packageRoot: string }> + conflicts: Array<{ packageName: string }> warnings: Array } @@ -132,9 +133,69 @@ describe('cli commands', () => { version: '0.5.2', packageRoot: pkgDir, }) + expect(parsed.conflicts).toEqual([]) expect(parsed.warnings).toEqual([]) }) + it('explains which package version was chosen when conflicts exist', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-conflicts-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = join(root, 'node_modules', 'consumer-a') + const consumerBDir = join(root, 'node_modules', 'consumer-b') + const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query') + const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': '4.0.0' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + writeJson(join(queryV4Dir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(queryV5Dir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v4 skill', + }) + writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v5 skill', + }) + + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Version conflicts:') + expect(output).toContain('@tanstack/query -> using 5.0.0') + expect(output).toContain(`chosen: ${queryV5Dir}`) + expect(output).toContain(`also found: 4.0.0 at ${queryV4Dir}`) + }) + it('validates a well-formed skills directory', async () => { const root = mkdtempSync(join(tmpdir(), 'intent-cli-validate-')) tempDirs.push(root) From 82dc46fb36a1fc4f91ebfecb7916b487c51ade24 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 15:52:44 -0700 Subject: [PATCH 04/14] feat: enhance CLI error handling and output formatting for better user feedback --- packages/intent/src/cli.ts | 402 +++++++++++++++++++----------- packages/intent/tests/cli.test.ts | 89 ++++++- 2 files changed, 343 insertions(+), 148 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 61027da..99f4dcb 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -6,52 +6,117 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { INSTALL_PROMPT } from './install-prompt.js' import type { ScanResult } from './types.js' -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) return join(thisDir, '..', 'meta') } -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- +type CliFailure = { + message: string + exitCode: number +} -async function cmdList(args: Array): Promise { - const { computeSkillNameWidth, printSkillTree, printTable } = - await import('./display.js') +function fail(message: string, exitCode = 1): never { + throw { message, exitCode } satisfies CliFailure +} + +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' + ) +} + +async function scanIntentsOrFail(): Promise { const { scanForIntents } = await import('./scanner.js') - const jsonOutput = args.includes('--json') - let result: ScanResult try { - result = await scanForIntents() + return await scanForIntents() } catch (err) { - console.error((err as Error).message) - process.exit(1) + fail((err as Error).message) + } +} + +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() + } +} + +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 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: Array = [] - if (result.nodeModules.local.scanned) - scanCoverage.push('project node_modules') - if (result.nodeModules.global.scanned) - scanCoverage.push('global node_modules') + const scanCoverage = formatScanCoverage(result) if (result.packages.length === 0) { console.log('No intent-enabled packages found.') - if (scanCoverage.length > 0) { - console.log(`Scanned: ${scanCoverage.join(', ')}`) - } + if (scanCoverage) console.log(`Scanned: ${scanCoverage}`) if (result.warnings.length > 0) { - console.log(`\nWarnings:`) - for (const w of result.warnings) console.log(` ⚠ ${w}`) + console.log() + printWarnings(result.warnings) } return } @@ -63,9 +128,9 @@ async function cmdList(args: Array): Promise { console.log( `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills (${result.packageManager})\n`, ) - if (scanCoverage.length > 0) { + if (scanCoverage) { console.log( - `Scanned: ${scanCoverage.join(', ')}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, + `Scanned: ${scanCoverage}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, ) } @@ -78,26 +143,7 @@ async function cmdList(args: Array): Promise { ]) printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) - if (result.conflicts.length > 0) { - console.log(` -Version conflicts: -`) - for (const conflict of result.conflicts) { - const otherVariants = conflict.variants.filter( - (variant) => variant.packageRoot !== conflict.chosen.packageRoot, - ) - console.log( - ` ${conflict.packageName} -> using ${conflict.chosen.version}`, - ) - console.log(` chosen: ${conflict.chosen.packageRoot}`) - for (const variant of otherVariants) { - console.log( - ` also found: ${variant.version} at ${variant.packageRoot}`, - ) - } - console.log() - } - } + printVersionConflicts(result) // Skills detail const allSkills = result.packages.map((p) => p.skills) @@ -120,10 +166,7 @@ Version conflicts: ) console.log() - if (result.warnings.length > 0) { - console.log(`Warnings:`) - for (const w of result.warnings) console.log(` ⚠ ${w}`) - } + printWarnings(result.warnings) } async function cmdMeta(args: Array): Promise { @@ -131,28 +174,25 @@ async function cmdMeta(args: Array): Promise { const metaDir = getMetaDir() if (!existsSync(metaDir)) { - console.error('Meta-skills directory not found.') - process.exit(1) + fail('Meta-skills directory not found.') } if (args.length > 0) { const name = args[0]! if (name.includes('..') || name.includes('/') || name.includes('\\')) { - console.error(`Invalid meta-skill name: "${name}"`) - process.exit(1) + fail(`Invalid meta-skill name: "${name}"`) } const skillFile = join(metaDir, name, 'SKILL.md') if (!existsSync(skillFile)) { - console.error(`Meta-skill "${name}" not found.`) - console.error(`Run \`intent meta\` to list available meta-skills.`) - process.exit(1) + 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) - console.error(`Failed to read meta-skill "${name}": ${msg}`) - process.exit(1) + fail(`Failed to read meta-skill "${name}": ${msg}`) } return } @@ -248,8 +288,7 @@ async function cmdValidate(args: Array): Promise { const skillsDir = join(process.cwd(), targetDir) if (!existsSync(skillsDir)) { - console.error(`Skills directory not found: ${skillsDir}`) - process.exit(1) + fail(`Skills directory not found: ${skillsDir}`) } interface ValidationError { @@ -261,8 +300,7 @@ async function cmdValidate(args: Array): Promise { const skillFiles = findSkillFiles(skillsDir) if (skillFiles.length === 0) { - console.error('No SKILL.md files found') - process.exit(1) + fail('No SKILL.md files found') } for (const filePath of skillFiles) { @@ -375,23 +413,13 @@ async function cmdValidate(args: Array): Promise { const warnings = collectPackagingWarnings(process.cwd()) - const printWarnings = (log: (...args: Array) => void): void => { - if (warnings.length === 0) return - log(`\n⚠ Packaging warnings:`) - for (const w of warnings) log(` ${w}`) - } - if (errors.length > 0) { - console.error(`\n❌ Validation failed with ${errors.length} error(s):\n`) - for (const { file, message } of errors) { - console.error(` ${file}: ${message}`) - } - printWarnings(console.error) - process.exit(1) + fail(buildValidationFailure(errors, warnings)) } console.log(`✅ Validated ${skillFiles.length} skill files — all passed`) - printWarnings(console.log) + if (warnings.length > 0) console.log() + printWarnings(warnings) } function cmdScaffold(): void { @@ -483,96 +511,178 @@ Usage: intent setup-github-actions Copy CI workflow templates to .github/workflows/ intent stale 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 [--json] + +Check installed skills for version and source drift. + +Examples: + intent stale + intent stale --json`, + 'add-library-bin': `intent add-library-bin + +Generate bin/intent.{js,mjs} bridge files for publishable packages.`, + 'edit-package-json': `intent edit-package-json + +Update package.json files so skills and shims 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) +} + export async function main(argv: Array = process.argv.slice(2)) { const command = argv[0] const commandArgs = argv.slice(1) - switch (command) { - case 'list': - await cmdList(commandArgs) - return 0 - case 'meta': - await cmdMeta(commandArgs) - return 0 - case 'validate': - await cmdValidate(commandArgs) + try { + if (!command || isHelpFlag(command)) { + printHelp() return 0 - case 'install': { - console.log(INSTALL_PROMPT) + } + + if (command === 'help') { + printHelp(commandArgs[0]) return 0 } - case 'scaffold': { - cmdScaffold() + + if (isHelpFlag(commandArgs[0])) { + printHelp(command) return 0 } - case 'stale': { - const { checkStaleness } = await import('./staleness.js') - const { scanForIntents: scanStale } = await import('./scanner.js') - let staleResult - try { - staleResult = await scanStale() - } catch (err) { - console.error((err as Error).message) - process.exit(1) - } - if (staleResult.packages.length === 0) { - console.log('No intent-enabled packages found.') + 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 } - - const jsonStale = commandArgs.includes('--json') - const reports = await Promise.all( - staleResult.packages.map((pkg) => { - return checkStaleness(pkg.packageRoot, pkg.name) - }), - ) - - if (jsonStale) { - console.log(JSON.stringify(reports, null, 2)) + case 'scaffold': { + cmdScaffold() return 0 } + case 'stale': { + const { checkStaleness } = await import('./staleness.js') + const { scanForIntents: scanStale } = await import('./scanner.js') + let staleResult + try { + staleResult = await scanStale() + } catch (err) { + fail((err as Error).message) + } + + if (staleResult.packages.length === 0) { + console.log('No intent-enabled packages found.') + return 0 + } + + const jsonStale = commandArgs.includes('--json') + const reports = await Promise.all( + staleResult.packages.map((pkg) => { + return checkStaleness(pkg.packageRoot, pkg.name) + }), + ) + + if (jsonStale) { + console.log(JSON.stringify(reports, null, 2)) + return 0 + } - for (const report of reports) { - const driftLabel = report.versionDrift - ? ` [${report.versionDrift} drift]` - : '' - const vLabel = - report.skillVersion && report.currentVersion - ? ` (${report.skillVersion} → ${report.currentVersion})` + for (const report of reports) { + const driftLabel = report.versionDrift + ? ` [${report.versionDrift} drift]` : '' - 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(', ')}`) + 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() } - console.log() + return 0 } - return 0 - } - case 'add-library-bin': { - const { runAddLibraryBinAll } = await import('./setup.js') - runAddLibraryBinAll(process.cwd()) - return 0 - } - case 'edit-package-json': { - const { runEditPackageJsonAll } = await import('./setup.js') - runEditPackageJsonAll(process.cwd()) - return 0 + case 'add-library-bin': { + const { runAddLibraryBinAll } = await import('./setup.js') + runAddLibraryBinAll(process.cwd()) + 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 } - case 'setup-github-actions': { - const { runSetupGithubActions } = await import('./setup.js') - runSetupGithubActions(process.cwd(), getMetaDir()) - return 0 + } catch (err) { + if (isCliFailure(err)) { + console.error(err.message) + return err.exitCode } - default: - console.log(USAGE) - return command ? 1 : 0 + + throw err } } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index e9e2ff7..045aff1 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -84,14 +84,56 @@ describe('intent meta', () => { expect(exitCode).toBe(0) expect(logSpy).toHaveBeenCalledWith(expected) }) + + it('fails cleanly for invalid meta-skill names', async () => { + const exitCode = await main(['meta', '../bad']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Invalid meta-skill name: "../bad"') + }) + + it('fails cleanly when a meta-skill does not exist', async () => { + const exitCode = await main(['meta', 'missing-skill']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Meta-skill "missing-skill" not found. Run `intent meta` to list available meta-skills.', + ) + }) }) describe('cli commands', () => { - it('prints usage when no command is provided', async () => { + it('prints top-level help when no command is provided', async () => { const exitCode = await main([]) expect(exitCode).toBe(0) - expect(logSpy).toHaveBeenCalledWith(USAGE) + expect(logSpy.mock.calls[0]?.[0]).toContain(USAGE) + expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') + }) + + it('prints top-level help for --help', async () => { + const exitCode = await main(['--help']) + + expect(exitCode).toBe(0) + expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') + }) + + it('prints command help for help subcommands', async () => { + const exitCode = await main(['help', 'validate']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('intent validate [dir]'), + ) + }) + + it('prints command help when --help is passed after a subcommand', async () => { + const exitCode = await main(['list', '--help']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('intent list --json'), + ) }) it('prints the install prompt', async () => { @@ -214,6 +256,49 @@ describe('cli commands', () => { '✅ Validated 1 skill files — all passed', ) }) + + it('fails cleanly when validate is run without a skills directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-missing-skills-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main(['validate']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + `Skills directory not found: ${join(root, 'skills')}`, + ) + }) + + it('fails cleanly for unsupported yarn pnp projects', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-pnp-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + process.chdir(root) + + const exitCode = await main(['list']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Yarn PnP is not yet supported. Add `nodeLinker: node-modules` to your .yarnrc.yml to use intent.', + ) + }) + + it('fails cleanly for deno projects without node_modules', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-deno-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + writeFileSync(join(root, 'deno.json'), '{"nodeModulesDir":"none"}\n') + process.chdir(root) + + const exitCode = await main(['list']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Deno without node_modules is not yet supported. Add `"nodeModulesDir": "auto"` to your deno.json to use intent.', + ) + }) }) describe('package metadata', () => { From 349687efaa564e68f0c50f3619d26a481ff2613c Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 15:56:39 -0700 Subject: [PATCH 05/14] feat: implement semver parsing and comparison for improved version handling --- packages/intent/src/scanner.ts | 69 +++++++++++++- packages/intent/tests/scanner.test.ts | 126 ++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 5164f70..64070c3 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -199,13 +199,72 @@ function getPackageDepth(packageRoot: string, projectRoot: string): number { return relative(projectRoot, packageRoot).split(sep).length } +interface ParsedSemver { + major: number + minor: number + patch: number + prerelease: Array +} + +function parseSemver(version: string): ParsedSemver | null { + const match = + /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version, + ) + if (!match) return null + + const prerelease = match[4] + ? match[4].split('.').map((identifier) => { + return /^\d+$/.test(identifier) ? Number(identifier) : identifier + }) + : [] + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease, + } +} + +function comparePrereleaseIdentifiers( + a: string | number | undefined, + b: string | number | undefined, +): number { + if (a === undefined) return b === undefined ? 0 : 1 + if (b === undefined) return -1 + + if (typeof a === 'number' && typeof b === 'number') { + return a - b + } + + if (typeof a === 'number') return -1 + if (typeof b === 'number') return 1 + + return a.localeCompare(b) +} + function comparePackageVersions(a: string, b: string): number { - const aMatch = /^(\d+)\.(\d+)\.(\d+)/.exec(a) - const bMatch = /^(\d+)\.(\d+)\.(\d+)/.exec(b) - if (!aMatch || !bMatch) return 0 + const parsedA = parseSemver(a) + const parsedB = parseSemver(b) - for (let i = 1; i <= 3; i++) { - const diff = Number(aMatch[i]) - Number(bMatch[i]) + if (!parsedA || !parsedB) { + if (parsedA) return 1 + if (parsedB) return -1 + return 0 + } + + for (const key of ['major', 'minor', 'patch'] as const) { + const diff = parsedA[key] - parsedB[key] + if (diff !== 0) return diff + } + + const length = Math.max(parsedA.prerelease.length, parsedB.prerelease.length) + for (let i = 0; i < length; i++) { + const diff = comparePrereleaseIdentifiers( + parsedA.prerelease[i], + parsedB.prerelease[i], + ) if (diff !== 0) return diff } diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 9819fa2..1be71c3 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -416,6 +416,132 @@ describe('scanForIntents', () => { expect(versionWarning).toContain('across 3 versions') expect(versionWarning).toContain('Using 5.0.0') }) + + it('prefers stable releases over prereleases at the same depth', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0-beta.1' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + + const prereleaseDir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + const stableDir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + + writeJson(join(prereleaseDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0-beta.1', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(stableDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(prereleaseDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Prerelease query skill', + }) + writeSkillMd(createDir(stableDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Stable query skill', + }) + + const result = await scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('5.0.0') + expect(result.packages[0]!.packageRoot).toBe(stableDir) + }) + + it('prefers valid semver versions over invalid ones at the same depth', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': 'workspace-dev' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + + const invalidDir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + const validDir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + + writeJson(join(invalidDir, 'package.json'), { + name: '@tanstack/query', + version: 'workspace-dev', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(validDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(invalidDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Invalid version query skill', + }) + writeSkillMd(createDir(validDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Valid version query skill', + }) + + const result = await scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('5.0.0') + expect(result.packages[0]!.packageRoot).toBe(validDir) + }) }) describe('package manager detection', () => { From f19c0c9e81357f2928f5277ee8e6f53584f5be51 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 16:18:23 -0700 Subject: [PATCH 06/14] feat: enhance monorepo support with improved package handling and workflow templates --- packages/intent/README.md | 17 ++- .../meta/templates/workflows/check-skills.yml | 8 +- .../templates/workflows/notify-intent.yml | 8 +- packages/intent/src/cli.ts | 100 +++++++++--- packages/intent/src/setup.ts | 143 +++++++++++++++--- packages/intent/tests/cli.test.ts | 66 ++++++++ packages/intent/tests/setup.test.ts | 80 +++++++++- 7 files changed, 371 insertions(+), 51 deletions(-) diff --git a/packages/intent/README.md b/packages/intent/README.md index 42c994e..6f23942 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -64,12 +64,20 @@ Validate your skill files: npm exec @tanstack/intent@latest validate ``` +In a monorepo, you can validate a package from the repo root: + +```bash +pnpm dlx @tanstack/intent@latest validate packages/router/skills +``` + Check for skills that have fallen behind their sources: ```bash pnpm dlx @tanstack/intent@latest stale ``` +From a monorepo root, `intent stale` checks every workspace package that ships skills. To scope it to one package, pass a directory like `intent stale packages/router`. + Copy CI workflow templates into your repo so validation and staleness checks run on every push: ```bash @@ -86,6 +94,13 @@ bunx @tanstack/intent@latest setup-github-actions | Deno | Best-effort | Requires `npm:` interop and `node_modules` support | | Yarn PnP | Unsupported | `@tanstack/intent` scans `node_modules` | +## Monorepos + +- Run `intent setup-github-actions` from either the repo root or a package directory. Intent detects the workspace root and writes workflows to the repo-level `.github/workflows/` directory. +- Generated workflows are monorepo-aware: validation loops over workspace packages with skills, staleness checks run from the workspace root, and notify workflows watch package `src/` and docs paths. +- Run `intent validate packages//skills` from the repo root to validate one package without root-level packaging warnings. +- Run `intent stale` from the repo root to check all workspace packages with skills, or `intent stale packages/` to check one package. + ## Keeping skills current The real risk with any derived artifact is staleness. `intent stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. @@ -102,7 +117,7 @@ The feedback loop runs both directions. `intent feedback` lets users submit stru | `intent scaffold` | Print the guided skill generation prompt | | `intent validate [dir]` | Validate SKILL.md files | | `intent setup-github-actions` | Copy CI templates into your repo | -| `intent stale [--json]` | Check skills for version drift | +| `intent stale [dir] [--json]` | Check skills for version drift | | `intent feedback` | Submit skill feedback | ## License diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index 602d01e..5e46ca1 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -7,7 +7,7 @@ # Triggers: new release published, or manual workflow_dispatch. # # Template variables (replaced by `intent setup`): -# {{PACKAGE_NAME}} — e.g. @tanstack/query +# {{PACKAGE_LABEL}} — e.g. @tanstack/query or my-workspace workspace name: Check Skills @@ -36,12 +36,12 @@ jobs: node-version: 20 - name: Install intent - run: npm install {{PACKAGE_NAME}} + run: npm install -g @tanstack/intent - name: Check staleness id: stale run: | - OUTPUT=$(npx @tanstack/intent stale --json 2>&1) || true + OUTPUT=$(intent stale --json 2>&1) || true echo "$OUTPUT" # Check if any skills need review @@ -81,7 +81,7 @@ jobs: const summary = lines.join('\n'); const prompt = [ - 'Review and update the following stale intent skills for {{PACKAGE_NAME}}:', + 'Review and update the following stale intent skills for {{PACKAGE_LABEL}}:', '', ...stale.map(s => '- ' + s.skill + ': ' + s.reasons.join(', ')), '', diff --git a/packages/intent/meta/templates/workflows/notify-intent.yml b/packages/intent/meta/templates/workflows/notify-intent.yml index cc18685..db9ebfe 100644 --- a/packages/intent/meta/templates/workflows/notify-intent.yml +++ b/packages/intent/meta/templates/workflows/notify-intent.yml @@ -9,9 +9,9 @@ # as the INTENT_NOTIFY_TOKEN repository secret. # # Template variables (replaced by `intent setup`): -# {{PACKAGE_NAME}} — e.g. @tanstack/query -# {{DOCS_PATH}} — e.g. docs/** -# {{SRC_PATH}} — e.g. packages/query-core/src/** +# {{PAYLOAD_PACKAGE}} — e.g. @tanstack/query or my-workspace workspace +# {{DOCS_PATH}} — e.g. docs/** +# {{SRC_PATH}} — e.g. packages/query-core/src/** name: Notify Intent @@ -46,7 +46,7 @@ jobs: event-type: skill-check client-payload: | { - "package": "{{PACKAGE_NAME}}", + "package": "{{PAYLOAD_PACKAGE}}", "sha": "${{ github.sha }}", "changed_files": ${{ steps.changes.outputs.files }} } diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 99f4dcb..58892b9 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -279,6 +279,78 @@ function collectPackagingWarnings(root: string): Array { 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( + 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), + ), + ), + } +} + async function cmdValidate(args: Array): Promise { const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([ import('yaml'), @@ -286,6 +358,7 @@ async function cmdValidate(args: Array): Promise { ]) const targetDir = args[0] ?? 'skills' const skillsDir = join(process.cwd(), targetDir) + const packageRoot = resolvePackageRoot(skillsDir) if (!existsSync(skillsDir)) { fail(`Skills directory not found: ${skillsDir}`) @@ -411,7 +484,7 @@ async function cmdValidate(args: Array): Promise { } } - const warnings = collectPackagingWarnings(process.cwd()) + const warnings = collectPackagingWarnings(packageRoot) if (errors.length > 0) { fail(buildValidationFailure(errors, warnings)) @@ -509,7 +582,7 @@ Usage: intent add-library-bin Generate bin/intent.{js,mjs} bridge file intent edit-package-json Wire package.json (files, bin) for skill publishing intent setup-github-actions Copy CI workflow templates to .github/workflows/ - intent stale Check skills for staleness` + intent stale [dir] [--json] Check skills for staleness` const HELP_BY_COMMAND: Record = { list: `${USAGE} @@ -537,12 +610,13 @@ 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 [--json] + stale: `intent stale [dir] [--json] Check installed skills for version and source drift. Examples: intent stale + intent stale packages/query intent stale --json`, 'add-library-bin': `intent add-library-bin @@ -609,27 +683,15 @@ export async function main(argv: Array = process.argv.slice(2)) { return 0 } case 'stale': { - const { checkStaleness } = await import('./staleness.js') - const { scanForIntents: scanStale } = await import('./scanner.js') - let staleResult - try { - staleResult = await scanStale() - } catch (err) { - fail((err as Error).message) - } + const jsonStale = commandArgs.includes('--json') + const targetDir = commandArgs.find((arg) => !arg.startsWith('-')) + const { reports } = await resolveStaleTargets(targetDir) - if (staleResult.packages.length === 0) { + if (reports.length === 0) { console.log('No intent-enabled packages found.') return 0 } - const jsonStale = commandArgs.includes('--json') - const reports = await Promise.all( - staleResult.packages.map((pkg) => { - return checkStaleness(pkg.packageRoot, pkg.name) - }), - ) - if (jsonStale) { console.log(JSON.stringify(reports, null, 2)) return 0 diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 7bbac1b..dd99431 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -5,7 +5,7 @@ import { readdirSync, writeFileSync, } from 'node:fs' -import { join, relative } from 'node:path' +import { basename, join, relative } from 'node:path' import { parse as parseYaml } from 'yaml' import { findSkillFiles } from './utils.js' @@ -35,20 +35,22 @@ export interface MonorepoResult { interface TemplateVars { PACKAGE_NAME: string + PACKAGE_LABEL: string + PAYLOAD_PACKAGE: string REPO: string DOCS_PATH: string SRC_PATH: string + WATCH_PATHS: string } // --------------------------------------------------------------------------- // Variable detection from package.json // --------------------------------------------------------------------------- -function detectVars(root: string): TemplateVars { +function readPackageJson(root: string): Record { const pkgPath = join(root, 'package.json') - let pkgJson: Record = {} try { - pkgJson = JSON.parse(readFileSync(pkgPath, 'utf8')) + return JSON.parse(readFileSync(pkgPath, 'utf8')) as Record } catch (err: unknown) { const isNotFound = err && @@ -60,17 +62,88 @@ function detectVars(root: string): TemplateVars { `Warning: could not read ${pkgPath}: ${err instanceof Error ? err.message : err}`, ) } + return {} } +} - const name = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' +function detectRepo( + pkgJson: Record, + fallback: string, +): string { const intent = pkgJson.intent as Record | undefined + if (typeof intent?.repo === 'string') { + return intent.repo + } - const repo = - typeof intent?.repo === 'string' - ? intent.repo - : name.replace(/^@/, '').replace(/\//, '/') + if (typeof pkgJson.repository === 'string') { + return pkgJson.repository + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') + } - const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' + if ( + pkgJson.repository && + typeof pkgJson.repository === 'object' && + typeof (pkgJson.repository as Record).url === 'string' + ) { + return ((pkgJson.repository as Record).url as string) + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') + } + + return fallback +} + +function normalizePattern(pattern: string): string { + return pattern.endsWith('**') ? pattern : pattern.replace(/\/$/, '') + '/**' +} + +function buildWatchPaths(root: string, packageDirs: Array): string { + const paths = new Set() + + if (existsSync(join(root, 'docs'))) { + paths.add('docs/**') + } + + for (const packageDir of packageDirs) { + const relDir = relative(root, packageDir).split('\\').join('/') + if (existsSync(join(packageDir, 'src'))) { + paths.add(`${relDir}/src/**`) + } + + const pkgJson = readPackageJson(packageDir) + const intent = pkgJson.intent as Record | undefined + const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' + if (!docs.startsWith('http://') && !docs.startsWith('https://')) { + paths.add(normalizePattern(join(relDir, docs).split('\\').join('/'))) + } + } + + if (paths.size === 0) { + paths.add('packages/*/src/**') + paths.add('packages/*/docs/**') + } + + return [...paths] + .sort() + .map((path) => ` - '${path}'`) + .join('\n') +} + +function detectVars(root: string, packageDirs?: Array): TemplateVars { + const pkgJson = readPackageJson(root) + const name = 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 // Best-guess src path from common monorepo patterns const shortName = name.replace(/^@[^/]+\//, '') @@ -81,9 +154,14 @@ function detectVars(root: string): TemplateVars { return { PACKAGE_NAME: name, + PACKAGE_LABEL: packageLabel, + PAYLOAD_PACKAGE: packageLabel, REPO: repo, DOCS_PATH: docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**', SRC_PATH: srcPath, + WATCH_PATHS: isMonorepo + ? buildWatchPaths(root, packageDirs) + : ` - '${docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**'}'\n - '${srcPath}'`, } } @@ -94,9 +172,12 @@ function detectVars(root: string): TemplateVars { function applyVars(content: string, vars: TemplateVars): string { return content .replace(/\{\{PACKAGE_NAME\}\}/g, vars.PACKAGE_NAME) + .replace(/\{\{PACKAGE_LABEL\}\}/g, vars.PACKAGE_LABEL) + .replace(/\{\{PAYLOAD_PACKAGE\}\}/g, vars.PAYLOAD_PACKAGE) .replace(/\{\{REPO\}\}/g, vars.REPO) .replace(/\{\{DOCS_PATH\}\}/g, vars.DOCS_PATH) .replace(/\{\{SRC_PATH\}\}/g, vars.SRC_PATH) + .replace(/\{\{WATCH_PATHS\}\}/g, vars.WATCH_PATHS) } // --------------------------------------------------------------------------- @@ -124,7 +205,13 @@ function copyTemplates( continue } - const content = readFileSync(srcPath, 'utf8') + let content = readFileSync(srcPath, 'utf8') + if (vars.WATCH_PATHS.includes('\n')) { + content = content.replace( + /\s+- '?\{\{DOCS_PATH\}\}'?\n\s+- '?\{\{SRC_PATH\}\}'?/, + vars.WATCH_PATHS, + ) + } const substituted = applyVars(content, vars) writeFileSync(destPath, substituted) copied.push(destPath) @@ -348,7 +435,7 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { // Monorepo workspace resolution // --------------------------------------------------------------------------- -function readWorkspacePatterns(root: string): Array | null { +export function readWorkspacePatterns(root: string): Array | null { // pnpm-workspace.yaml const pnpmWs = join(root, 'pnpm-workspace.yaml') if (existsSync(pnpmWs)) { @@ -453,10 +540,24 @@ function collectPackageDirs(dir: string, result: Array): void { } } +export function findWorkspaceRoot(start: string): string | null { + let dir = start + + while (true) { + if (readWorkspacePatterns(dir)) { + return dir + } + + const next = join(dir, '..') + if (next === dir) return null + dir = next + } +} + /** * Find workspace packages that contain at least one SKILL.md file. */ -function findPackagesWithSkills(root: string): Array { +export function findPackagesWithSkills(root: string): Array { const patterns = readWorkspacePatterns(root) if (!patterns) return [] @@ -519,11 +620,16 @@ export function runSetupGithubActions( root: string, metaDir: string, ): SetupGithubActionsResult { - const vars = detectVars(root) + const workspaceRoot = findWorkspaceRoot(root) ?? root + const packageDirs = findPackagesWithSkills(workspaceRoot) + const vars = detectVars( + workspaceRoot, + packageDirs.length > 0 ? packageDirs : undefined, + ) const result: SetupGithubActionsResult = { workflows: [], skipped: [] } const srcDir = join(metaDir, 'templates', 'workflows') - const destDir = join(root, '.github', 'workflows') + const destDir = join(workspaceRoot, '.github', 'workflows') const { copied, skipped } = copyTemplates(srcDir, destDir, vars) result.workflows = copied result.skipped = skipped @@ -535,10 +641,11 @@ export function runSetupGithubActions( console.log('No templates directory found. Is @tanstack/intent installed?') } else if (result.workflows.length > 0) { console.log(`\nTemplate variables applied:`) - console.log(` Package: ${vars.PACKAGE_NAME}`) + console.log(` Package: ${vars.PACKAGE_LABEL}`) console.log(` Repo: ${vars.REPO}`) - console.log(` Docs: ${vars.DOCS_PATH}`) - console.log(` Src: ${vars.SRC_PATH}`) + console.log( + ` Mode: ${packageDirs.length > 0 ? `monorepo (${packageDirs.length} packages with skills)` : 'single package'}`, + ) } return result diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 045aff1..5205c1d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -257,6 +257,37 @@ describe('cli commands', () => { ) }) + it('validates package skills from repo root without root packaging warnings', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-validate-mono-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + bin: { intent: './bin/intent.js' }, + files: ['skills', 'bin', '!skills/_artifacts'], + }) + mkdirSync(join(root, 'packages', 'router', 'bin'), { recursive: true }) + writeFileSync(join(root, 'packages', 'router', 'bin', 'intent.js'), '') + writeSkillMd(join(root, 'packages', 'router', 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['validate', 'packages/router/skills']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(output).not.toContain('@tanstack/intent is not in devDependencies') + }) + it('fails cleanly when validate is run without a skills directory', async () => { const root = mkdtempSync(join(tmpdir(), 'intent-cli-missing-skills-')) tempDirs.push(root) @@ -299,6 +330,41 @@ describe('cli commands', () => { 'Deno without node_modules is not yet supported. Add `"nodeModulesDir": "auto"` to your deno.json to use intent.', ) }) + + it('checks workspace packages for staleness from the monorepo root', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-stale-mono-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ library: string }> + + expect(exitCode).toBe(0) + expect(reports).toHaveLength(1) + expect(reports[0]!.library).toBe('@tanstack/router') + + fetchSpy.mockRestore() + }) }) describe('package metadata', () => { diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 2e9c95f..1975828 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -42,7 +42,11 @@ beforeEach(() => { writeFileSync( join(metaDir, 'templates', 'workflows', 'notify-intent.yml'), - 'package: {{PACKAGE_NAME}}\nrepo: {{REPO}}\ndocs: {{DOCS_PATH}}\nsrc: {{SRC_PATH}}', + 'package: {{PAYLOAD_PACKAGE}}\nrepo: {{REPO}}\npaths:\n - {{DOCS_PATH}}\n - {{SRC_PATH}}', + ) + writeFileSync( + join(metaDir, 'templates', 'workflows', 'check-skills.yml'), + 'label: {{PACKAGE_LABEL}}\ninstall: npm install -g @tanstack/intent', ) }) @@ -259,7 +263,7 @@ describe('runSetupGithubActions', () => { }) const result = runSetupGithubActions(root, metaDir) - expect(result.workflows).toHaveLength(1) + expect(result.workflows).toHaveLength(2) expect(result.skipped).toHaveLength(0) const wfContent = readFileSync( @@ -268,12 +272,13 @@ describe('runSetupGithubActions', () => { ) expect(wfContent).toContain('package: @tanstack/query') expect(wfContent).toContain('repo: TanStack/query') - expect(wfContent).toContain('docs: docs/**') + expect(wfContent).toContain('paths:') + expect(wfContent).toContain("'docs/**'") }) it('copies templates with defaults when no package.json', () => { const result = runSetupGithubActions(root, metaDir) - expect(result.workflows).toHaveLength(1) + expect(result.workflows).toHaveLength(2) const wfPath = join(root, '.github', 'workflows', 'notify-intent.yml') expect(existsSync(wfPath)).toBe(true) @@ -285,7 +290,7 @@ describe('runSetupGithubActions', () => { runSetupGithubActions(root, metaDir) const result = runSetupGithubActions(root, metaDir) expect(result.workflows).toHaveLength(0) - expect(result.skipped).toHaveLength(1) + expect(result.skipped).toHaveLength(2) }) it('handles missing templates directory gracefully', () => { @@ -294,6 +299,71 @@ describe('runSetupGithubActions', () => { const result = runSetupGithubActions(root, emptyMeta) expect(result.workflows).toHaveLength(0) }) + + it('writes workflows to the workspace root with monorepo-aware substitutions', () => { + const monoRoot = createMonorepo({ + packages: [ + { name: 'router', hasSkills: true }, + { name: 'start', hasSkills: true }, + ], + }) + + writeFileSync( + join(monoRoot, 'package.json'), + JSON.stringify( + { name: '@tanstack/router', private: true, workspaces: ['packages/*'] }, + null, + 2, + ), + ) + writeFileSync( + join(monoRoot, 'packages', 'router', 'package.json'), + JSON.stringify( + { + name: '@tanstack/react-router', + intent: { repo: 'TanStack/router', docs: 'docs/' }, + }, + null, + 2, + ), + ) + mkdirSync(join(monoRoot, 'packages', 'router', 'src'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'router', 'docs'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'start', 'src'), { recursive: true }) + + const result = runSetupGithubActions( + join(monoRoot, 'packages', 'router'), + metaDir, + ) + + expect(result.workflows).toEqual( + expect.arrayContaining([ + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + ]), + ) + expect( + existsSync(join(monoRoot, 'packages', 'router', '.github', 'workflows')), + ).toBe(false) + + const notifyContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + 'utf8', + ) + expect(notifyContent).toContain('package: @tanstack/router') + expect(notifyContent).toContain("- 'packages/router/docs/**'") + expect(notifyContent).toContain("- 'packages/router/src/**'") + expect(notifyContent).toContain("- 'packages/start/src/**'") + + const checkContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + 'utf8', + ) + expect(checkContent).toContain('label: @tanstack/router') + expect(checkContent).toContain('npm install -g @tanstack/intent') + + rmSync(monoRoot, { recursive: true, force: true }) + }) }) // --------------------------------------------------------------------------- From d949040c8ca2809228fa23d282c8795b622a056c Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 16:30:13 -0700 Subject: [PATCH 07/14] feat: update release workflow for improved versioning and GitHub release management --- .github/workflows/release.yml | 61 ++++++-- scripts/create-github-release.mjs | 235 ++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 scripts/create-github-release.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfedf7e..31b74ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,11 @@ name: Release on: push: - # branches: [main, alpha, beta, rc] + branches: [main, '*-pre', '*-maint'] concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} @@ -19,7 +19,7 @@ permissions: jobs: release: name: Release - if: github.repository_owner == 'TanStack' + if: "github.repository_owner == 'TanStack' && !contains(github.event.head_commit.message, 'ci: changeset release')" runs-on: ubuntu-latest steps: - name: Checkout @@ -29,13 +29,50 @@ jobs: - name: Setup Tools uses: tanstack/config/.github/setup@main - name: Run Tests - run: pnpm run test:ci - - name: Run Changesets (version or publish) - uses: changesets/action@v1.5.3 - with: - version: pnpm run changeset:version - publish: pnpm run changeset:publish - commit: 'ci: Version Packages' - title: 'ci: Version Packages' + run: pnpm run test:ci --parallel=3 + - name: Version Packages + run: pnpm run changeset:version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Commit Release + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add . + if git commit -m "ci: changeset release"; then + git push + echo "committed=true" >> "$GITHUB_OUTPUT" + fi + - name: Set Dist Tag + id: dist-tag + if: steps.commit.outputs.committed == 'true' + run: | + branch="${GITHUB_REF_NAME}" + if [[ "$branch" == *-pre ]]; then + echo "tag=next" >> "$GITHUB_OUTPUT" + elif [[ "$branch" == *-maint ]]; then + echo "tag=maint" >> "$GITHUB_OUTPUT" + fi + - name: Publish Packages + if: steps.commit.outputs.committed == 'true' + run: | + if [[ -n "${{ steps.dist-tag.outputs.tag }}" ]]; then + pnpm run changeset:publish --tag "${{ steps.dist-tag.outputs.tag }}" + else + pnpm run changeset:publish + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create GitHub Release + if: steps.commit.outputs.committed == 'true' + run: | + if [[ "${{ steps.dist-tag.outputs.tag }}" == 'next' ]]; then + node scripts/create-github-release.mjs --prerelease + else + node scripts/create-github-release.mjs + fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs new file mode 100644 index 0000000..584f8c8 --- /dev/null +++ b/scripts/create-github-release.mjs @@ -0,0 +1,235 @@ +// @ts-nocheck + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execSync } from 'node:child_process' + +const rootDir = path.resolve(import.meta.dirname, '..') +const packagesDir = path.join(rootDir, 'packages') +const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN +const isPrerelease = process.argv.includes('--prerelease') + +function run(command, options = {}) { + return execSync(command, { + cwd: rootDir, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + ...options, + }).trim() +} + +function maybeRun(command) { + try { + return run(command) + } catch { + return null + } +} + +function getReleaseCommits() { + const output = maybeRun( + 'git log --grep="ci: changeset release" --format=%H --no-merges', + ) + + if (!output) { + return [] + } + + return output.split('\n').filter(Boolean) +} + +function getPackages() { + return fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const dir = entry.name + const packageJsonPath = path.join(packagesDir, dir, 'package.json') + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + + return { + dir, + packageJsonPath, + packageJson, + } + }) + .filter(({ packageJson }) => !packageJson.private) +} + +function getPreviousPackageJson(releaseCommit, packageJsonPath) { + if (!releaseCommit) { + return null + } + + const relativePath = path.relative(rootDir, packageJsonPath) + const content = maybeRun(`git show ${releaseCommit}:'${relativePath}'`) + + if (!content) { + return null + } + + return JSON.parse(content) +} + +function getChangedPackages(previousReleaseCommit) { + return getPackages() + .map(({ dir, packageJsonPath, packageJson }) => { + const previousPackageJson = getPreviousPackageJson( + previousReleaseCommit, + packageJsonPath, + ) + + if ( + !previousPackageJson || + previousPackageJson.version !== packageJson.version + ) { + return { + dir, + name: packageJson.name, + version: packageJson.version, + previousVersion: previousPackageJson?.version ?? null, + } + } + + return null + }) + .filter(Boolean) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +function getChangelogSection(changelogPath, version) { + if (!fs.existsSync(changelogPath)) { + return null + } + + const changelog = fs.readFileSync(changelogPath, 'utf8') + const marker = `## ${version}` + const start = changelog.indexOf(marker) + + if (start === -1) { + return null + } + + const bodyStart = changelog.indexOf('\n', start) + const nextSection = changelog.indexOf('\n## ', bodyStart + 1) + + return changelog + .slice(bodyStart + 1, nextSection === -1 ? undefined : nextSection) + .trim() +} + +function buildReleaseNotes(changedPackages) { + const sections = changedPackages.map((pkg) => { + const changelogPath = path.join(packagesDir, pkg.dir, 'CHANGELOG.md') + const content = + getChangelogSection(changelogPath, pkg.version) || + '- No changelog entries' + + return `#### ${pkg.name}\n\n${content}` + }) + + return sections.join('\n\n') +} + +function createReleaseTag() { + const now = new Date().toISOString() + const tag = `release-${now.slice(0, 10)}-${now.slice(11, 13)}${now.slice(14, 16)}` + const title = `Release ${now.slice(0, 10)} ${now.slice(11, 16)}` + + return { tag, title } +} + +function createReleaseBody(title, changedPackages, notes) { + const packages = changedPackages + .map((pkg) => `- ${pkg.name}@${pkg.version}`) + .join('\n') + + return `${title}\n\n## Changes\n\n${notes}\n\n## Packages\n\n${packages}` +} + +function pushTag(tag) { + const exists = maybeRun(`git rev-parse ${tag}`) + + if (exists) { + return false + } + + run(`git tag -a ${tag} -m "${tag}"`) + run(`git push origin ${tag}`) + return true +} + +function createGitHubRelease(tag, title, body) { + if (!token) { + throw new Error('Missing GH_TOKEN or GITHUB_TOKEN') + } + + const notesFile = path.join(os.tmpdir(), `${tag}.md`) + fs.writeFileSync(notesFile, body) + + const args = [ + 'gh', + 'release', + 'create', + tag, + '--title', + JSON.stringify(title), + '--notes-file', + JSON.stringify(notesFile), + ] + + if (isPrerelease) { + args.push('--prerelease') + } else { + args.push('--latest') + } + + try { + execSync(args.join(' '), { + cwd: rootDir, + stdio: 'inherit', + env: { + ...process.env, + GH_TOKEN: token, + GITHUB_TOKEN: token, + }, + }) + } finally { + fs.rmSync(notesFile, { force: true }) + } +} + +function rollbackTag(tag) { + maybeRun(`git push --delete origin ${tag}`) + maybeRun(`git tag -d ${tag}`) +} + +function main() { + const [, previousReleaseCommit] = getReleaseCommits() + const changedPackages = getChangedPackages(previousReleaseCommit) + + if (changedPackages.length === 0) { + console.log('No changed packages found for GitHub release.') + return + } + + const notes = buildReleaseNotes(changedPackages) + const { tag, title } = createReleaseTag() + const body = createReleaseBody(title, changedPackages, notes) + + let createdTag = false + + try { + createdTag = pushTag(tag) + createGitHubRelease(tag, title, body) + } catch (error) { + if (createdTag) { + rollbackTag(tag) + } + + throw error + } +} + +main() From f9c6a724bfb65d705d0e368dc031684c0befee04 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 16:33:10 -0700 Subject: [PATCH 08/14] changeset --- .changeset/young-bats-shop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/young-bats-shop.md diff --git a/.changeset/young-bats-shop.md b/.changeset/young-bats-shop.md new file mode 100644 index 0000000..c294397 --- /dev/null +++ b/.changeset/young-bats-shop.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Improves the intent CLI with better setup validation, clearer feedback, version conflict detection, and improved monorepo support. From 64f063bb9446b2a91d4f0f3576acc0cdd9dd36fb Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 16:39:02 -0700 Subject: [PATCH 09/14] fix broken link --- docs/cli/intent-install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index e9d0a01..b703d11 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -38,4 +38,4 @@ If no existing block is found, `AGENTS.md` is the default target. ## Related - [intent list](./intent-list) -- [Setting Up Agent Config](../guides/consumers/agent-config-setup) +- [Quick Start for Consumers](../getting-started/quick-start-consumers) From 60e7bb10928c07d0806458e9db569e69c816a260 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 18:11:26 -0700 Subject: [PATCH 10/14] fix: update command for skill discovery in documentation --- packages/intent/meta/tree-generator/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/intent/meta/tree-generator/SKILL.md b/packages/intent/meta/tree-generator/SKILL.md index d84841e..dc74b93 100644 --- a/packages/intent/meta/tree-generator/SKILL.md +++ b/packages/intent/meta/tree-generator/SKILL.md @@ -40,7 +40,7 @@ Every skill has a `type` field in its frontmatter. Valid types: | `composition` | Integration between two or more libraries | `electric-drizzle` | | `security` | Audit checklist or security validation | `electric-security-check` | -Agents discover skills via `tanstack intent list` and read them directly +Agents discover skills via `npx @tanstack/intent list` and read them directly from `node_modules`. Framework skills declare a `requires` dependency on their core skill so agents load them in the right order. From dd53aefa24b7cdbff0daf349175db0b635e06250 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Wed, 11 Mar 2026 18:41:34 -0700 Subject: [PATCH 11/14] fix: update command usage from npm exec to npx in documentation --- packages/intent/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/intent/README.md b/packages/intent/README.md index 6f23942..31dbe21 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -25,8 +25,8 @@ The [Agent Skills spec](https://agentskills.io) is an open standard already adop Use whichever command runner matches your environment: | Tool | Pattern | -| ---- | -------------------------------------------- | -| npm | `npm exec @tanstack/intent@latest ` | +| ---- |----------------------------------------------| +| npm | `npx @tanstack/intent@latest ` | | pnpm | `pnpm dlx @tanstack/intent@latest ` | | bun | `bunx @tanstack/intent@latest ` | @@ -37,15 +37,15 @@ If you use Deno, support is best-effort today via `npm:` interop with `node_modu Set up skill-to-task mappings in your project's agent config files (CLAUDE.md, .cursorrules, etc.): ```bash -npm exec @tanstack/intent@latest install +npx @tanstack/intent@latest install ``` -No per-library setup. No hunting for rules files. Install the package, run `intent install` through your preferred command runner, and the agent understands the tool. Update the package, and skills update too. +No per-library setup. No hunting for rules files. Install the package, run `npx @tanstack/intent@latest install` through your preferred command runner, and the agent understands the tool. Update the package, and skills update too. List available skills from installed packages: ```bash -pnpm dlx @tanstack/intent@latest list +npx @tanstack/intent@latest list ``` ### For library maintainers @@ -53,7 +53,7 @@ pnpm dlx @tanstack/intent@latest list Generate skills for your library by telling your AI coding agent to run: ```bash -bunx @tanstack/intent@latest scaffold +npx @tanstack/intent@latest scaffold ``` This walks the agent through domain discovery, skill tree generation, and skill creation — one step at a time with your review at each stage. @@ -61,19 +61,19 @@ This walks the agent through domain discovery, skill tree generation, and skill Validate your skill files: ```bash -npm exec @tanstack/intent@latest validate +npx @tanstack/intent@latest validate ``` In a monorepo, you can validate a package from the repo root: ```bash -pnpm dlx @tanstack/intent@latest validate packages/router/skills +npx @tanstack/intent@latest validate packages/router/skills ``` Check for skills that have fallen behind their sources: ```bash -pnpm dlx @tanstack/intent@latest stale +npx @tanstack/intent@latest stale ``` From a monorepo root, `intent stale` checks every workspace package that ships skills. To scope it to one package, pass a directory like `intent stale packages/router`. @@ -81,14 +81,14 @@ From a monorepo root, `intent stale` checks every workspace package that ships s Copy CI workflow templates into your repo so validation and staleness checks run on every push: ```bash -bunx @tanstack/intent@latest setup-github-actions +npx @tanstack/intent@latest setup-github-actions ``` ## Compatibility | Environment | Status | Notes | | -------------- | ----------- | -------------------------------------------------- | -| Node.js + npm | Supported | Use `npm exec @tanstack/intent@latest ` | +| Node.js + npm | Supported | Use `npx @tanstack/intent@latest ` | | Node.js + pnpm | Supported | Use `pnpm dlx @tanstack/intent@latest ` | | Node.js + Bun | Supported | Use `bunx @tanstack/intent@latest ` | | Deno | Best-effort | Requires `npm:` interop and `node_modules` support | From 6b0799b42a4f906b0c9d9dce9005fc9f94075e6b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:42:22 +0000 Subject: [PATCH 12/14] ci: apply automated fixes --- packages/intent/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/intent/README.md b/packages/intent/README.md index 31dbe21..97d4014 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -25,7 +25,7 @@ The [Agent Skills spec](https://agentskills.io) is an open standard already adop Use whichever command runner matches your environment: | Tool | Pattern | -| ---- |----------------------------------------------| +| ---- | -------------------------------------------- | | npm | `npx @tanstack/intent@latest ` | | pnpm | `pnpm dlx @tanstack/intent@latest ` | | bun | `bunx @tanstack/intent@latest ` | @@ -88,7 +88,7 @@ npx @tanstack/intent@latest setup-github-actions | Environment | Status | Notes | | -------------- | ----------- | -------------------------------------------------- | -| Node.js + npm | Supported | Use `npx @tanstack/intent@latest ` | +| Node.js + npm | Supported | Use `npx @tanstack/intent@latest ` | | Node.js + pnpm | Supported | Use `pnpm dlx @tanstack/intent@latest ` | | Node.js + Bun | Supported | Use `bunx @tanstack/intent@latest ` | | Deno | Best-effort | Requires `npm:` interop and `node_modules` support | From 276cc223dd06db1120caaf695a5f81b3b5bdfbc9 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Thu, 12 Mar 2026 11:25:17 -0700 Subject: [PATCH 13/14] fix: update command usage to npx for skill discovery in documentation --- docs/cli/intent-install.md | 22 ++++++++-------- docs/getting-started/quick-start-consumers.md | 20 +++++++------- packages/intent/README.md | 26 +++++++++---------- .../intent/meta/feedback-collection/SKILL.md | 2 +- packages/intent/meta/tree-generator/SKILL.md | 2 +- packages/intent/src/install-prompt.ts | 4 +-- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index b703d11..7680243 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -24,18 +24,18 @@ skills: ``` -They also ask you to: - -1. Check for an existing block first -2. Run `intent list` to discover installed skills -3. Ask whether you want a config target other than `AGENTS.md` -4. Update an existing block in place when one already exists -5. Add task-to-skill mappings -6. Preserve all content outside the tagged block - -If no existing block is found, `AGENTS.md` is the default target. +They also ask you to: + +1. Check for an existing block first +2. Run `intent list` to discover installed skills +3. Ask whether you want a config target other than `AGENTS.md` +4. Update an existing block in place when one already exists +5. Add task-to-skill mappings +6. Preserve all content outside the tagged block + +If no existing block is found, `AGENTS.md` is the default target. ## Related - [intent list](./intent-list) -- [Quick Start for Consumers](../getting-started/quick-start-consumers) +- [Quick Start for Consumers](../getting-started/quick-start-consumers) diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index dd2caa8..904117c 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -13,15 +13,15 @@ The install command guides your agent through the setup process: npx @tanstack/intent@latest install ``` -This prints a skill that instructs your AI agent to: -1. Check for existing `intent-skills` mappings in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) -2. Run `intent list` to discover available skills from installed packages -3. Scan your repository structure to understand your project -4. Propose relevant skill-to-task mappings based on your codebase patterns -5. Ask if you want a target other than `AGENTS.md` -6. Write or update an `intent-skills` block in your agent config - -If an `intent-skills` block already exists, the agent updates that file in place. If no block exists, `AGENTS.md` is the default target. +This prints a skill that instructs your AI agent to: +1. Check for existing `intent-skills` mappings in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) +2. Run `npx @tanstack/intent@latest list` to discover available skills from installed packages +3. Scan your repository structure to understand your project +4. Propose relevant skill-to-task mappings based on your codebase patterns +5. Ask if you want a target other than `AGENTS.md` +6. Write or update an `intent-skills` block in your agent config + +If an `intent-skills` block already exists, the agent updates that file in place. If no block exists, `AGENTS.md` is the default target. Your agent will create mappings like: @@ -48,7 +48,7 @@ Skills version with library releases. When you update a library: npm update @tanstack/react-query ``` -The new version brings updated skills automatically — you don't need to do anything. The skills are shipped with the library, so you always get the version that matches your installed code. If a package is installed both locally and globally, Intent prefers the local version. +The new version brings updated skills automatically — you don't need to do anything. The skills are shipped with the library, so you always get the version that matches your installed code. If a package is installed both locally and globally, Intent prefers the local version. If you need to see what skills have changed, run: diff --git a/packages/intent/README.md b/packages/intent/README.md index 97d4014..7cf6117 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -96,29 +96,29 @@ npx @tanstack/intent@latest setup-github-actions ## Monorepos -- Run `intent setup-github-actions` from either the repo root or a package directory. Intent detects the workspace root and writes workflows to the repo-level `.github/workflows/` directory. +- Run `npx @tanstack/intent@latest setup-github-actions` from either the repo root or a package directory. Intent detects the workspace root and writes workflows to the repo-level `.github/workflows/` directory. - Generated workflows are monorepo-aware: validation loops over workspace packages with skills, staleness checks run from the workspace root, and notify workflows watch package `src/` and docs paths. -- Run `intent validate packages//skills` from the repo root to validate one package without root-level packaging warnings. -- Run `intent stale` from the repo root to check all workspace packages with skills, or `intent stale packages/` to check one package. +- Run `npx @tanstack/intent@latest validate packages//skills` from the repo root to validate one package without root-level packaging warnings. +- Run `npx @tanstack/intent@latest stale` from the repo root to check all workspace packages with skills, or `intent stale packages/` to check one package. ## Keeping skills current -The real risk with any derived artifact is staleness. `intent stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. +The real risk with any derived artifact is staleness. `npx @tanstack/intent@latest stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. -The feedback loop runs both directions. `intent feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next package update. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. +The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next package update. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. ## CLI Commands | Command | Description | | ----------------------------- | --------------------------------------------------- | -| `intent install` | Set up skill-to-task mappings in agent config files | -| `intent list [--json]` | Discover intent-enabled packages | -| `intent meta` | List meta-skills for library maintainers | -| `intent scaffold` | Print the guided skill generation prompt | -| `intent validate [dir]` | Validate SKILL.md files | -| `intent setup-github-actions` | Copy CI templates into your repo | -| `intent stale [dir] [--json]` | Check skills for version drift | -| `intent feedback` | Submit skill feedback | +| `npx @tanstack/intent@latest install` | Set up skill-to-task mappings in agent config files | +| `npx @tanstack/intent@latest list [--json]` | Discover intent-enabled packages | +| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | +| `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | +| `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | +| `npx @tanstack/intent@latest setup-github-actions` | Copy CI templates into your repo | +| `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift | +| `npx @tanstack/intent@latest feedback` | Submit skill feedback | ## License diff --git a/packages/intent/meta/feedback-collection/SKILL.md b/packages/intent/meta/feedback-collection/SKILL.md index 1684da8..080b84e 100644 --- a/packages/intent/meta/feedback-collection/SKILL.md +++ b/packages/intent/meta/feedback-collection/SKILL.md @@ -36,7 +36,7 @@ during the session: - **Loaded and used:** Skills you read and actively followed. - **Available but not loaded:** Skills that were installed (discoverable via - `intent list`) but you never read. This is important — many issues stem from + `npx @tanstack/intent@latest list`) but you never read. This is important — many issues stem from the agent not loading the right skill, not from the skill itself being wrong. ### 1b: Gap detection diff --git a/packages/intent/meta/tree-generator/SKILL.md b/packages/intent/meta/tree-generator/SKILL.md index dc74b93..26b83a2 100644 --- a/packages/intent/meta/tree-generator/SKILL.md +++ b/packages/intent/meta/tree-generator/SKILL.md @@ -274,7 +274,7 @@ packages/ │ └── package.json # Add "skills" to files array ``` -Run `intent edit-package-json` to wire each package's `package.json` +Run `npx @tanstack/intent@latest edit-package-json` to wire each package's `package.json` automatically (adds `"skills"`, `"bin"`, and `"!skills/_artifacts"` to the `files` array, and adds the `bin` entry if missing). diff --git a/packages/intent/src/install-prompt.ts b/packages/intent/src/install-prompt.ts index 4085763..6803823 100644 --- a/packages/intent/src/install-prompt.ts +++ b/packages/intent/src/install-prompt.ts @@ -12,7 +12,7 @@ Follow these steps in order: - If not found: continue to step 2. 2. DISCOVER AVAILABLE SKILLS - Run: intent list + 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 @@ -49,7 +49,7 @@ skills: Rules: - Use the user's own words for task descriptions - - Include the exact path from \`intent list\` output so agents can load it directly + - 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` From 09d310204deb4ae6eb6d0afe985e25aec20d8e57 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:26:03 +0000 Subject: [PATCH 14/14] ci: apply automated fixes --- packages/intent/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/intent/README.md b/packages/intent/README.md index 7cf6117..901204d 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -109,8 +109,8 @@ The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` l ## CLI Commands -| Command | Description | -| ----------------------------- | --------------------------------------------------- | +| Command | Description | +| -------------------------------------------------- | --------------------------------------------------- | | `npx @tanstack/intent@latest install` | Set up skill-to-task mappings in agent config files | | `npx @tanstack/intent@latest list [--json]` | Discover intent-enabled packages | | `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers |