diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 7680243..c0b3834 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -24,14 +24,14 @@ 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 +They also ask you to: + +1. Check for an existing block first +2. Run `intent list` to discover installed skills, including any packages surfaced by the command's explicit global scan +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. diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 3aa6606..9b596a1 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -15,15 +15,17 @@ npx @tanstack/intent@latest list [--json] ## What you get -- Scans installed dependencies for intent-enabled packages and skills +- Scans project and workspace dependencies for intent-enabled packages and skills +- Intentionally includes accessible global packages when listing installed skills - Includes warnings from discovery - If no packages are discovered, prints `No intent-enabled packages found.` - Summary line with package count, skill count, and detected package manager -- Package table columns: `PACKAGE`, `VERSION`, `SKILLS`, `REQUIRES` +- Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS`, `REQUIRES` - Skill tree grouped by package - Optional warnings section (`⚠ ...` per warning) `REQUIRES` uses `intent.requires` values joined by `, `; empty values render as `–`. +`SOURCE` is a lightweight indicator showing whether the selected package came from local discovery or explicit global scanning. ## JSON output @@ -36,6 +38,7 @@ npx @tanstack/intent@latest list [--json] { "name": "string", "version": "string", + "source": "local | global", "intent": { "version": 1, "repo": "string", @@ -57,7 +60,7 @@ npx @tanstack/intent@latest list [--json] } ``` -`packages` are ordered using `intent.requires` when possible. +`packages` are ordered using `intent.requires` when possible. When the same package exists both locally and globally, `intent list` prefers the local package. ## Common errors diff --git a/docs/cli/intent-stale.md b/docs/cli/intent-stale.md index b532c4d..10b1512 100644 --- a/docs/cli/intent-stale.md +++ b/docs/cli/intent-stale.md @@ -15,7 +15,8 @@ npx @tanstack/intent@latest stale [--json] ## Behavior -- Scans installed intent-enabled packages +- Checks the current package by default, or all skill-bearing packages in the current workspace when run from a monorepo root +- When `dir` is provided, scopes the check to the targeted package or skills directory - Computes one staleness report per package - Prints text output by default or JSON with `--json` - If no packages are found, prints `No intent-enabled packages found.` diff --git a/docs/getting-started/quick-start-maintainers.md b/docs/getting-started/quick-start-maintainers.md index b24cf46..393d35d 100644 --- a/docs/getting-started/quick-start-maintainers.md +++ b/docs/getting-started/quick-start-maintainers.md @@ -162,6 +162,8 @@ Manually check which skills need updates with: npx @tanstack/intent@latest stale ``` +When run from a package, this checks that package's shipped skills. When run from a monorepo root, it checks the workspace packages that ship skills. + This detects: - **Version drift** — skill targets an older library version than currently installed - **New sources** — sources declared in frontmatter that weren't tracked before diff --git a/docs/overview.md b/docs/overview.md index faa17f4..de253a4 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 local and accessible global `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 your project and workspace by default, and helps agents load them automatically when working on matching tasks. ## What Intent does @@ -30,7 +30,7 @@ Intent provides tooling for two workflows: npx @tanstack/intent@latest list ``` -Scans local `node_modules` and any accessible global `node_modules` for intent-enabled packages, preferring local packages when both exist. +Scans the current project's `node_modules` and workspace dependencies for intent-enabled packages. The CLI intentionally includes accessible global packages for this command and still prefers local packages when both exist. ```bash npx @tanstack/intent@latest install diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 8bb0e3b..eb079d5 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { fail } from './cli-error.js' import { resolveProjectContext } from './core/project-context.js' -import type { ScanResult, StalenessReport } from './types.js' +import type { ScanOptions, ScanResult, StalenessReport } from './types.js' export function printWarnings(warnings: Array): void { if (warnings.length === 0) return @@ -19,11 +19,13 @@ export function getMetaDir(): string { return join(thisDir, '..', 'meta') } -export async function scanIntentsOrFail(): Promise { +export async function scanIntentsOrFail( + options?: ScanOptions, +): Promise { const { scanForIntents } = await import('./scanner.js') try { - return scanForIntents() + return scanForIntents(undefined, options) } catch (err) { fail(err instanceof Error ? err.message : String(err)) } diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 3af0449..4ba695d 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -23,13 +23,18 @@ function createCli(): CAC { cli.usage(' [options]') cli - .command('list', 'Discover intent-enabled packages') + .command( + 'list', + 'Discover intent-enabled packages from the project, workspace, and explicit global scan', + ) .usage('list [--json]') .option('--json', 'Output JSON') .example('list') .example('list --json') .action(async (options: { json?: boolean }) => { - await runListCommand(options, scanIntentsOrFail) + await runListCommand(options, () => + scanIntentsOrFail({ includeGlobal: true }), + ) }) cli @@ -68,7 +73,10 @@ function createCli(): CAC { }) cli - .command('stale [dir]', 'Check skills for staleness') + .command( + 'stale [dir]', + 'Check skills for staleness in the current package or workspace', + ) .usage('stale [dir] [--json]') .option('--json', 'Output JSON') .example('stale') diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 5a12d8a..dbc7a1b 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -33,8 +33,6 @@ export async function runListCommand( options: { json?: boolean }, scanIntentsOrFail: () => Promise, ): Promise { - const { computeSkillNameWidth, printSkillTree, printTable } = - await import('../display.js') const result = await scanIntentsOrFail() if (options.json) { @@ -42,6 +40,9 @@ export async function runListCommand( return } + const { computeSkillNameWidth, printSkillTree, printTable } = + await import('../display.js') + const scanCoverage = formatScanCoverage(result) if (result.packages.length === 0) { @@ -69,11 +70,12 @@ export async function runListCommand( const rows = result.packages.map((pkg) => [ pkg.name, + pkg.source, pkg.version, String(pkg.skills.length), pkg.intent.requires?.join(', ') || '–', ]) - printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) + printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) printVersionConflicts(result) diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index f216ec0..e0d9e10 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -9,13 +9,13 @@ export async function runStaleCommand( ): Promise { const { reports } = await resolveStaleTargets(targetDir) - if (reports.length === 0) { - console.log('No intent-enabled packages found.') + if (options.json) { + console.log(JSON.stringify(reports, null, 2)) return } - if (options.json) { - console.log(JSON.stringify(reports, null, 2)) + if (reports.length === 0) { + console.log('No intent-enabled packages found.') return } diff --git a/packages/intent/src/discovery/index.ts b/packages/intent/src/discovery/index.ts new file mode 100644 index 0000000..0e0e22d --- /dev/null +++ b/packages/intent/src/discovery/index.ts @@ -0,0 +1,2 @@ +export { createPackageRegistrar } from './register.js' +export { createDependencyWalker } from './walk.js' diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts new file mode 100644 index 0000000..4024de7 --- /dev/null +++ b/packages/intent/src/discovery/register.ts @@ -0,0 +1,129 @@ +import { existsSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { listNodeModulesPackageDirs, toPosixPath } from '../utils.js' +import type { + IntentConfig, + IntentPackage, + NodeModulesScanTarget, + SkillEntry, +} from '../types.js' + +type PackageJson = Record + +export interface CreatePackageRegistrarOptions { + comparePackageVersions: (a: string, b: string) => number + deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null + discoverSkills: (skillsDir: string, baseName: string) => Array + getPackageDepth: (packageRoot: string, projectRoot: string) => number + packageIndexes: Map + packages: Array + projectRoot: string + readPkgJson: (dirPath: string) => PackageJson | null + rememberVariant: (pkg: IntentPackage) => void + validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null + warnings: Array +} + +export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { + function scanTarget( + target: NodeModulesScanTarget, + source: IntentPackage['source'] = 'local', + ): void { + if (!target.path || !target.exists || target.scanned) return + target.scanned = true + + for (const dirPath of listNodeModulesPackageDirs(target.path)) { + tryRegister(dirPath, 'unknown', source) + } + } + + function tryRegister( + dirPath: string, + fallbackName: string, + source: IntentPackage['source'] = 'local', + ): boolean { + const skillsDir = join(dirPath, 'skills') + if (!existsSync(skillsDir)) return false + + const pkgJson = opts.readPkgJson(dirPath) + if (!pkgJson) { + opts.warnings.push(`Could not read package.json for ${dirPath}`) + return false + } + + const name = typeof pkgJson.name === 'string' ? pkgJson.name : fallbackName + const version = + typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0' + const intent = + opts.validateIntentField(name, pkgJson.intent) ?? + opts.deriveIntentConfig(pkgJson) + if (!intent) { + opts.warnings.push( + `${name} has a skills/ directory but could not determine repo/docs from package.json (add a "repository" field or explicit "intent" config)`, + ) + return false + } + + const skills = opts.discoverSkills(skillsDir, name) + + const isLocal = + dirPath.startsWith(opts.projectRoot + sep) || + dirPath.startsWith(opts.projectRoot + '/') + if (isLocal) { + const hasStableSymlink = + name !== '' && existsSync(join(opts.projectRoot, 'node_modules', name)) + for (const skill of skills) { + if (hasStableSymlink) { + const relFromPkg = toPosixPath(relative(dirPath, skill.path)) + skill.path = `node_modules/${name}/${relFromPkg}` + } else { + skill.path = toPosixPath(relative(opts.projectRoot, skill.path)) + } + } + } + + const candidate: IntentPackage = { + name, + version, + intent, + skills, + packageRoot: dirPath, + source, + } + const existingIndex = opts.packageIndexes.get(name) + if (existingIndex === undefined) { + opts.rememberVariant(candidate) + opts.packageIndexes.set(name, opts.packages.push(candidate) - 1) + return true + } + + const existing = opts.packages[existingIndex]! + if (existing.packageRoot === candidate.packageRoot) { + return false + } + + opts.rememberVariant(existing) + opts.rememberVariant(candidate) + + const existingDepth = opts.getPackageDepth( + existing.packageRoot, + opts.projectRoot, + ) + const candidateDepth = opts.getPackageDepth( + candidate.packageRoot, + opts.projectRoot, + ) + const shouldReplace = + candidateDepth < existingDepth || + (candidateDepth === existingDepth && + opts.comparePackageVersions(candidate.version, existing.version) > 0) + + if (shouldReplace) { + opts.packages[existingIndex] = candidate + } + + return true + } + + return { scanTarget, tryRegister } +} diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts new file mode 100644 index 0000000..ff6cfa0 --- /dev/null +++ b/packages/intent/src/discovery/walk.ts @@ -0,0 +1,113 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { listNodeModulesPackageDirs, resolveDepDir, getDeps } from '../utils.js' +import { + readWorkspacePatterns, + resolveWorkspacePackages, +} from '../workspace-patterns.js' + +type PackageJson = Record + +export interface CreateDependencyWalkerOptions { + projectRoot: string + readPkgJson: (dirPath: string) => PackageJson | null + tryRegister: (dirPath: string, fallbackName: string) => boolean + packages: Array<{ packageRoot: string; name: string }> + warnings: Array +} + +export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { + const walkVisited = new Set() + + function walkDeps(pkgDir: string, pkgName: string): void { + if (walkVisited.has(pkgDir)) return + walkVisited.add(pkgDir) + + const pkgJson = opts.readPkgJson(pkgDir) + if (!pkgJson) { + opts.warnings.push( + `Could not read package.json for ${pkgName} (skipping dependency walk)`, + ) + return + } + + for (const depName of getDeps(pkgJson)) { + const depDir = resolveDepDir(depName, pkgDir) + if (!depDir || walkVisited.has(depDir)) continue + + opts.tryRegister(depDir, depName) + walkDeps(depDir, depName) + } + } + + function walkKnownPackages(): void { + for (const pkg of [...opts.packages]) { + walkDeps(pkg.packageRoot, pkg.name) + } + } + + function walkProjectDeps(): void { + let projectPkg: PackageJson | null = null + try { + projectPkg = JSON.parse( + readFileSync(join(opts.projectRoot, 'package.json'), 'utf8'), + ) as PackageJson + } catch (err: unknown) { + const isNotFound = + err && + typeof err === 'object' && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + opts.warnings.push( + `Could not read project package.json: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + if (!projectPkg) return + walkDepsFromPkgJson(projectPkg, opts.projectRoot, true) + } + + function walkDepsFromPkgJson( + pkgJson: PackageJson, + fromDir: string, + includeDevDeps = false, + ): void { + for (const depName of getDeps(pkgJson, includeDevDeps)) { + const depDir = resolveDepDir(depName, fromDir) + if (depDir && !walkVisited.has(depDir)) { + opts.tryRegister(depDir, depName) + walkDeps(depDir, depName) + } + } + } + + function walkWorkspacePackages(): void { + const workspacePatterns = readWorkspacePatterns(opts.projectRoot) + if (!workspacePatterns) return + + for (const wsDir of resolveWorkspacePackages( + opts.projectRoot, + workspacePatterns, + )) { + const wsNodeModules = join(wsDir, 'node_modules') + if (existsSync(wsNodeModules)) { + for (const dirPath of listNodeModulesPackageDirs(wsNodeModules)) { + opts.tryRegister(dirPath, 'unknown') + } + } + + const wsPkg = opts.readPkgJson(wsDir) + if (wsPkg) { + walkDepsFromPkgJson(wsPkg, wsDir) + } + } + } + + return { + walkKnownPackages, + walkProjectDeps, + walkWorkspacePackages, + } +} diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index 3c14fcd..f14a9fa 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -30,6 +30,7 @@ export type { IntentProjectConfig, MetaFeedbackPayload, MetaSkillName, + ScanOptions, ScanResult, SkillEntry, StalenessReport, diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 4ed3623..5168b8e 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,23 +1,20 @@ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' import { join, relative, sep } from 'node:path' +import { + createDependencyWalker, + createPackageRegistrar, +} from './discovery/index.js' import { detectGlobalNodeModules, - getDeps, - listNodeModulesPackageDirs, parseFrontmatter, - resolveDepDir, toPosixPath, } from './utils.js' -import { - findWorkspaceRoot, - readWorkspacePatterns, - resolveWorkspacePackages, -} from './workspace-patterns.js' +import { findWorkspaceRoot } from './workspace-patterns.js' import type { InstalledVariant, IntentConfig, IntentPackage, - NodeModulesScanTarget, + ScanOptions, ScanResult, SkillEntry, VersionConflict, @@ -320,8 +317,12 @@ function toVersionConflict( // Main scanner // --------------------------------------------------------------------------- -export function scanForIntents(root?: string): ScanResult { +export function scanForIntents( + root?: string, + options: ScanOptions = {}, +): ScanResult { const projectRoot = root ?? process.cwd() + const { includeGlobal = false } = options const packageManager = detectPackageManager(projectRoot) const nodeModulesDir = join(projectRoot, 'node_modules') const explicitGlobalNodeModules = @@ -398,210 +399,39 @@ export function scanForIntents(root?: string): ScanResult { } } - 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 - * package.json, validates intent config, discovers skills, and pushes - * to `packages`. Returns true if the package was registered. - */ - function tryRegister(dirPath: string, fallbackName: string): boolean { - const skillsDir = join(dirPath, 'skills') - if (!existsSync(skillsDir)) return false - - 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 - const version = - typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0' - const intent = - validateIntentField(name, pkgJson.intent) ?? deriveIntentConfig(pkgJson) - if (!intent) { - warnings.push( - `${name} has a skills/ directory but could not determine repo/docs from package.json (add a "repository" field or explicit "intent" config)`, - ) - return false - } - - const skills = discoverSkills(skillsDir, name) - - // Convert absolute skill paths to stable relative paths, preferring - // node_modules//... when a top-level symlink exists, otherwise - // falling back to a path relative to the project root. - const isLocal = - dirPath.startsWith(projectRoot + sep) || - dirPath.startsWith(projectRoot + '/') - if (isLocal) { - const hasStableSymlink = - name !== '' && existsSync(join(projectRoot, 'node_modules', name)) - for (const skill of skills) { - if (hasStableSymlink) { - const relFromPkg = toPosixPath(relative(dirPath, skill.path)) - skill.path = `node_modules/${name}/${relFromPkg}` - } else { - skill.path = toPosixPath(relative(projectRoot, skill.path)) - } - } - } - - const candidate: IntentPackage = { - name, - version, - intent, - skills, - packageRoot: dirPath, - } - const existingIndex = packageIndexes.get(name) - if (existingIndex === undefined) { - rememberVariant(candidate) - packageIndexes.set(name, packages.push(candidate) - 1) - return true - } - - 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 - } + const { scanTarget, tryRegister } = createPackageRegistrar({ + comparePackageVersions, + deriveIntentConfig, + discoverSkills, + getPackageDepth, + packageIndexes, + packages, + projectRoot, + readPkgJson, + rememberVariant, + validateIntentField, + warnings, + }) // 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(pkgDir)) return - walkVisited.add(pkgDir) - - const pkgJson = readPkgJson(pkgDir) - if (!pkgJson) { - warnings.push( - `Could not read package.json for ${pkgName} (skipping dependency walk)`, - ) - return - } - - for (const depName of getDeps(pkgJson)) { - const depDir = resolveDepDir(depName, pkgDir) - if (!depDir || walkVisited.has(depDir)) continue - - tryRegister(depDir, depName) - walkDeps(depDir, depName) - } - } - - function walkKnownPackages(): void { - for (const pkg of [...packages]) { - walkDeps(pkg.packageRoot, pkg.name) - } - } - - 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) return - walkDepsFromPkgJson(projectPkg, projectRoot, true) - } - - /** Resolve and walk deps listed in a package.json. */ - function walkDepsFromPkgJson( - pkgJson: Record, - fromDir: string, - includeDevDeps = false, - ): void { - for (const depName of getDeps(pkgJson, includeDevDeps)) { - const depDir = resolveDepDir(depName, fromDir) - if (depDir && !walkVisited.has(depDir)) { - tryRegister(depDir, depName) - walkDeps(depDir, depName) - } - } - } - - /** - * In monorepos, discover workspace packages and walk their deps. - * Handles pnpm monorepos (workspace-specific node_modules) and ensures - * transitive skills packages are found through workspace package dependencies. - */ - function walkWorkspacePackages(): void { - const workspacePatterns = readWorkspacePatterns(projectRoot) - if (!workspacePatterns) return - - for (const wsDir of resolveWorkspacePackages( + const { walkKnownPackages, walkProjectDeps, walkWorkspacePackages } = + createDependencyWalker({ + packages, projectRoot, - workspacePatterns, - )) { - const wsNodeModules = join(wsDir, 'node_modules') - if (existsSync(wsNodeModules)) { - for (const dirPath of listNodeModulesPackageDirs(wsNodeModules)) { - tryRegister(dirPath, 'unknown') - } - } - - const wsPkg = readPkgJson(wsDir) - if (wsPkg) { - walkDepsFromPkgJson(wsPkg, wsDir) - } - } - } + readPkgJson, + tryRegister, + warnings, + }) walkWorkspacePackages() walkKnownPackages() walkProjectDeps() - if ( - explicitGlobalNodeModules || - packages.length === 0 || - !nodeModules.local.exists - ) { + if (includeGlobal) { ensureGlobalNodeModules() - scanTarget(nodeModules.global) + scanTarget(nodeModules.global, 'global') walkKnownPackages() walkProjectDeps() } diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index f7ef756..45214c5 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -24,6 +24,10 @@ export interface ScanResult { } } +export interface ScanOptions { + includeGlobal?: boolean +} + export interface NodeModulesScanTarget { path: string | null detected: boolean @@ -38,6 +42,7 @@ export interface IntentPackage { intent: IntentConfig skills: Array packageRoot: string + source: 'local' | 'global' } export interface InstalledVariant { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 28217d0..21582e5 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -44,6 +44,7 @@ let logSpy: ReturnType let infoSpy: ReturnType let errorSpy: ReturnType let tempDirs: Array +let previousGlobalNodeModules: string | undefined function getHelpOutput(): string { return [...infoSpy.mock.calls, ...logSpy.mock.calls] @@ -54,6 +55,8 @@ function getHelpOutput(): string { beforeEach(() => { originalCwd = process.cwd() tempDirs = [] + previousGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES + delete process.env.INTENT_GLOBAL_NODE_MODULES logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -61,6 +64,11 @@ beforeEach(() => { afterEach(() => { process.chdir(originalCwd) + if (previousGlobalNodeModules === undefined) { + delete process.env.INTENT_GLOBAL_NODE_MODULES + } else { + process.env.INTENT_GLOBAL_NODE_MODULES = previousGlobalNodeModules + } logSpy.mockRestore() infoSpy.mockRestore() errorSpy.mockRestore() @@ -268,7 +276,10 @@ describe('cli commands', () => { it('lists installed intent packages as json', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-')) - tempDirs.push(root) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-list-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) const pkgDir = join(root, 'node_modules', '@tanstack', 'db') writeJson(join(pkgDir, 'package.json'), { @@ -281,12 +292,18 @@ describe('cli commands', () => { description: 'Core database concepts', }) + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot 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 }> + packages: Array<{ + name: string + version: string + packageRoot: string + source: 'local' | 'global' + }> conflicts: Array<{ packageName: string }> warnings: Array } @@ -297,11 +314,54 @@ describe('cli commands', () => { name: '@tanstack/db', version: '0.5.2', packageRoot: pkgDir, + source: 'local', }) expect(parsed.conflicts).toEqual([]) expect(parsed.warnings).toEqual([]) }) + it('includes configured global intent packages in list json output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-global-')) + const globalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-list-global-node-modules-'), + ) + tempDirs.push(root, globalRoot) + + const globalPkgDir = join(globalRoot, '@tanstack', 'query') + writeJson(join(globalPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(globalPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + 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 + source: 'local' | 'global' + }> + } + + expect(exitCode).toBe(0) + expect(parsed.packages).toHaveLength(1) + expect(parsed.packages[0]).toMatchObject({ + name: '@tanstack/query', + version: '5.0.0', + packageRoot: globalPkgDir, + source: 'global', + }) + }) + it('explains which package version was chosen when conflicts exist', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-')) tempDirs.push(root) @@ -522,6 +582,35 @@ describe('cli commands', () => { fetchSpy.mockRestore() }) + it('ignores configured global intent packages when checking staleness', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-global-')) + const globalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-stale-global-node-modules-'), + ) + tempDirs.push(root, globalRoot) + + const globalPkgDir = join(globalRoot, '@tanstack', 'query') + writeJson(join(globalPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(globalPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + library_version: '5.0.0', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + + expect(exitCode).toBe(0) + expect(output).toBe('[]') + }) + it('checks only the targeted workspace package for staleness', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-target-')) tempDirs.push(root) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 11c7362..79b3650 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -300,13 +300,14 @@ describe('scanForIntents', () => { description: 'Global fetching skill', }) - const result = scanForIntents(root) + const result = scanForIntents(root, { includeGlobal: true }) 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') + expect(result.packages[0]!.source).toBe('global') }) it('prefers local packages over global packages with the same name', () => { @@ -334,11 +335,12 @@ describe('scanForIntents', () => { description: 'Global fetching skill', }) - const result = scanForIntents(root) + const result = scanForIntents(root, { includeGlobal: true }) expect(result.nodeModules.global.detected).toBe(true) expect(result.nodeModules.global.scanned).toBe(true) expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.source).toBe('local') expect(result.packages[0]!.version).toBe('5.1.0') expect(result.packages[0]!.skills[0]!.description).toBe( 'Local fetching skill', @@ -352,6 +354,27 @@ describe('scanForIntents', () => { ).toBe(true) }) + it('ignores global packages by default even when configured', () => { + 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 = scanForIntents(root) + + expect(result.nodeModules.global.detected).toBe(true) + expect(result.nodeModules.global.scanned).toBe(false) + expect(result.packages).toEqual([]) + }) + it('chooses the highest version when duplicate package names exist at the same depth', () => { writeJson(join(root, 'package.json'), { name: 'app',