From bd18e63bbbd002e6b679b7271870dc3dc8167b25 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 11:21:42 -0700 Subject: [PATCH 01/15] feat: enhance scanForIntents to include global packages --- packages/intent/src/cli-support.ts | 2 +- packages/intent/src/index.ts | 1 + packages/intent/src/scanner.ts | 13 +++++++------ packages/intent/src/types.ts | 4 ++++ packages/intent/tests/scanner.test.ts | 23 ++++++++++++++++++++++- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 8bb0e3b..396170c 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -23,7 +23,7 @@ export async function scanIntentsOrFail(): Promise { const { scanForIntents } = await import('./scanner.js') try { - return scanForIntents() + return scanForIntents(undefined, { includeGlobal: true }) } catch (err) { fail(err instanceof Error ? err.message : String(err)) } 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..715ada6 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -18,6 +18,7 @@ import type { IntentConfig, IntentPackage, NodeModulesScanTarget, + ScanOptions, ScanResult, SkillEntry, VersionConflict, @@ -320,8 +321,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 = @@ -595,11 +600,7 @@ export function scanForIntents(root?: string): ScanResult { walkKnownPackages() walkProjectDeps() - if ( - explicitGlobalNodeModules || - packages.length === 0 || - !nodeModules.local.exists - ) { + if (includeGlobal) { ensureGlobalNodeModules() scanTarget(nodeModules.global) walkKnownPackages() diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index f7ef756..bc0ca2b 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 diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 11c7362..a7d2421 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -352,6 +352,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, { includeGlobal: true }) + + 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', @@ -438,7 +459,7 @@ describe('scanForIntents', () => { description: 'Query v3 skill', }) - const result = scanForIntents(root) + const result = scanForIntents(root, { includeGlobal: true }) const versionWarning = result.warnings.find((warning) => warning.includes('@tanstack/query'), ) From 4e956d3139902bc97ee60f809541541ab9bcdcf4 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 11:32:09 -0700 Subject: [PATCH 02/15] fix: update scanForIntents calls to toggle global package inclusion --- packages/intent/tests/scanner.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index a7d2421..8dd682e 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -300,7 +300,7 @@ 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) @@ -334,7 +334,7 @@ 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) @@ -366,7 +366,7 @@ describe('scanForIntents', () => { description: 'Global fetching skill', }) - const result = scanForIntents(root, { includeGlobal: true }) + const result = scanForIntents(root) expect(result.nodeModules.global.detected).toBe(true) expect(result.nodeModules.global.scanned).toBe(false) @@ -459,7 +459,7 @@ describe('scanForIntents', () => { description: 'Query v3 skill', }) - const result = scanForIntents(root, { includeGlobal: true }) + const result = scanForIntents(root) const versionWarning = result.warnings.find((warning) => warning.includes('@tanstack/query'), ) From 2913fbaa3de697b0bff49503158f11e0f4be3ec6 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 11:34:26 -0700 Subject: [PATCH 03/15] feat: modify scanIntentsOrFail to accept options for global package inclusion --- packages/intent/src/cli-support.ts | 8 +++++--- packages/intent/src/cli.ts | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 396170c..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(undefined, { includeGlobal: true }) + 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..f419141 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -29,7 +29,9 @@ function createCli(): CAC { .example('list') .example('list --json') .action(async (options: { json?: boolean }) => { - await runListCommand(options, scanIntentsOrFail) + await runListCommand(options, () => + scanIntentsOrFail({ includeGlobal: true }), + ) }) cli From 77b453a6683abadf871a7545a691a1ae0d6dfee5 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 11:50:06 -0700 Subject: [PATCH 04/15] feat: add source attribute to package registration for local and global distinction --- packages/intent/src/commands/list.ts | 3 ++- packages/intent/src/scanner.ts | 13 +++++++++++-- packages/intent/src/types.ts | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 5a12d8a..efa9cf8 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -69,11 +69,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/scanner.ts b/packages/intent/src/scanner.ts index 715ada6..5efbb99 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -408,7 +408,11 @@ export function scanForIntents( target.scanned = true for (const dirPath of listNodeModulesPackageDirs(target.path)) { - tryRegister(dirPath, 'unknown') + tryRegister( + dirPath, + 'unknown', + target === nodeModules.global ? 'global' : 'local', + ) } } @@ -417,7 +421,11 @@ export function scanForIntents( * 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 { + function tryRegister( + dirPath: string, + fallbackName: string, + source: IntentPackage['source'] = 'local', + ): boolean { const skillsDir = join(dirPath, 'skills') if (!existsSync(skillsDir)) return false @@ -466,6 +474,7 @@ export function scanForIntents( intent, skills, packageRoot: dirPath, + source, } const existingIndex = packageIndexes.get(name) if (existingIndex === undefined) { diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index bc0ca2b..45214c5 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -42,6 +42,7 @@ export interface IntentPackage { intent: IntentConfig skills: Array packageRoot: string + source: 'local' | 'global' } export interface InstalledVariant { From 649ce2bef21818c5ce9db59d463a15b5b3a365b7 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 11:52:11 -0700 Subject: [PATCH 05/15] test: verify package source distinction between local and global --- packages/intent/tests/scanner.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 8dd682e..79b3650 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -307,6 +307,7 @@ describe('scanForIntents', () => { 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', () => { @@ -339,6 +340,7 @@ describe('scanForIntents', () => { 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', From bf6018da65e5d4c7d0b2f4627ddd48b7aeea6bda Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 12:14:54 -0700 Subject: [PATCH 06/15] feat: refactor package registration logic into createPackageRegistrar --- packages/intent/src/discovery/register.ts | 135 ++++++++++++++++++++++ packages/intent/src/scanner.ts | 118 +++---------------- packages/intent/tests/cli.test.ts | 8 ++ 3 files changed, 158 insertions(+), 103 deletions(-) create mode 100644 packages/intent/src/discovery/register.ts diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts new file mode 100644 index 0000000..d327925 --- /dev/null +++ b/packages/intent/src/discovery/register.ts @@ -0,0 +1,135 @@ +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/scanner.ts b/packages/intent/src/scanner.ts index 5efbb99..b9dc104 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' import { join, relative, sep } from 'node:path' +import { createPackageRegistrar } from './discovery/register.js' import { detectGlobalNodeModules, getDeps, @@ -17,7 +18,6 @@ import type { InstalledVariant, IntentConfig, IntentPackage, - NodeModulesScanTarget, ScanOptions, ScanResult, SkillEntry, @@ -403,107 +403,19 @@ export function scanForIntents( } } - 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', - target === nodeModules.global ? 'global' : 'local', - ) - } - } - - /** - * 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, - source: IntentPackage['source'] = 'local', - ): 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, - source, - } - 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) @@ -611,7 +523,7 @@ export function scanForIntents( if (includeGlobal) { ensureGlobalNodeModules() - scanTarget(nodeModules.global) + scanTarget(nodeModules.global, 'global') walkKnownPackages() walkProjectDeps() } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 28217d0..11c4938 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() From 562b26254168e4726e9158e1daa7ba71385ad9a6 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 12:23:56 -0700 Subject: [PATCH 07/15] feat: implement dependency walker to streamline transitive dependency discovery --- packages/intent/src/discovery/walk.ts | 114 ++++++++++++++++++++++++++ packages/intent/src/scanner.ts | 112 ++----------------------- 2 files changed, 123 insertions(+), 103 deletions(-) create mode 100644 packages/intent/src/discovery/walk.ts diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts new file mode 100644 index 0000000..96e31c2 --- /dev/null +++ b/packages/intent/src/discovery/walk.ts @@ -0,0 +1,114 @@ +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 { + walkDeps, + walkKnownPackages, + walkProjectDeps, + walkWorkspacePackages, + } +} diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index b9dc104..55b8c05 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,19 +1,13 @@ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' import { join, relative, sep } from 'node:path' import { createPackageRegistrar } from './discovery/register.js' +import { createDependencyWalker } from './discovery/walk.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, @@ -420,102 +414,14 @@ export function scanForIntents( // 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() From d18d9c07e75e620871326eeb4b3dae4ea63ff05f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 12:34:25 -0700 Subject: [PATCH 08/15] feat: reorder report check and enhance global package handling in CLI tests --- packages/intent/src/commands/stale.ts | 8 +-- packages/intent/tests/cli.test.ts | 75 ++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) 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/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 11c4938..08407eb 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -276,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'), { @@ -289,6 +292,7 @@ describe('cli commands', () => { description: 'Core database concepts', }) + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot process.chdir(root) const exitCode = await main(['list', '--json']) @@ -310,6 +314,46 @@ describe('cli commands', () => { 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 + }> + } + + expect(exitCode).toBe(0) + expect(parsed.packages).toHaveLength(1) + expect(parsed.packages[0]).toMatchObject({ + name: '@tanstack/query', + version: '5.0.0', + packageRoot: globalPkgDir, + }) + }) + it('explains which package version was chosen when conflicts exist', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-')) tempDirs.push(root) @@ -530,6 +574,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) From 3124fc5982d6862d77a9a48b255b84d21e81e531 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 12:42:34 -0700 Subject: [PATCH 09/15] feat: consolidate discovery exports into a single index file --- packages/intent/src/discovery/index.ts | 2 ++ packages/intent/src/scanner.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 packages/intent/src/discovery/index.ts 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/scanner.ts b/packages/intent/src/scanner.ts index 55b8c05..5168b8e 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,7 +1,9 @@ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' import { join, relative, sep } from 'node:path' -import { createPackageRegistrar } from './discovery/register.js' -import { createDependencyWalker } from './discovery/walk.js' +import { + createDependencyWalker, + createPackageRegistrar, +} from './discovery/index.js' import { detectGlobalNodeModules, parseFrontmatter, From ac8a0f181f6302f18963e0bad0757d562bd7be07 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 3 Apr 2026 13:10:53 -0700 Subject: [PATCH 10/15] refactor: remove unused walkDeps function from walk.ts --- packages/intent/src/discovery/walk.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index 96e31c2..ff6cfa0 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -106,7 +106,6 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { } return { - walkDeps, walkKnownPackages, walkProjectDeps, walkWorkspacePackages, From 44678124d9f354d59443fe85848ca036b455a0bc Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 6 Apr 2026 13:00:10 -0700 Subject: [PATCH 11/15] update some docs --- docs/cli/intent-list.md | 9 ++++++--- docs/overview.md | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) 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/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 From 513a263bf25c9ab60a7c19afd8ce1c854286cafa Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 6 Apr 2026 13:01:36 -0700 Subject: [PATCH 12/15] update some docs --- docs/cli/intent-install.md | 16 ++++++++-------- packages/intent/src/cli.ts | 5 ++++- 2 files changed, 12 insertions(+), 9 deletions(-) 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/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index f419141..9533934 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -23,7 +23,10 @@ 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') From f92a8180d2180cebd9fa1c92ef7f7d6a7d505505 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 6 Apr 2026 13:08:36 -0700 Subject: [PATCH 13/15] update some docs --- docs/cli/intent-stale.md | 3 ++- docs/getting-started/quick-start-maintainers.md | 2 ++ packages/intent/src/cli.ts | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) 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/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 9533934..4ba695d 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -73,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') From 016ffde99eb779fab8d9f162bc0a5864dec3b8e8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:23:26 +0000 Subject: [PATCH 14/15] ci: apply automated fixes --- packages/intent/src/discovery/register.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index d327925..4024de7 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -13,20 +13,14 @@ type PackageJson = Record export interface CreatePackageRegistrarOptions { comparePackageVersions: (a: string, b: string) => number deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null - discoverSkills: ( - skillsDir: string, - baseName: string, - ) => Array + 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 + validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null warnings: Array } From 36a2803249f23d81b1650cbe333da1787aea47a2 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 6 Apr 2026 13:40:27 -0700 Subject: [PATCH 15/15] fix tests --- packages/intent/src/commands/list.ts | 5 +++-- packages/intent/tests/cli.test.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index efa9cf8..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) { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 08407eb..21582e5 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -298,7 +298,12 @@ describe('cli commands', () => { 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 } @@ -309,6 +314,7 @@ describe('cli commands', () => { name: '@tanstack/db', version: '0.5.2', packageRoot: pkgDir, + source: 'local', }) expect(parsed.conflicts).toEqual([]) expect(parsed.warnings).toEqual([]) @@ -342,6 +348,7 @@ describe('cli commands', () => { name: string version: string packageRoot: string + source: 'local' | 'global' }> } @@ -351,6 +358,7 @@ describe('cli commands', () => { name: '@tanstack/query', version: '5.0.0', packageRoot: globalPkgDir, + source: 'global', }) })