diff --git a/.changeset/dull-tigers-send.md b/.changeset/dull-tigers-send.md new file mode 100644 index 0000000..992964b --- /dev/null +++ b/.changeset/dull-tigers-send.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix package discovery for Yarn PnP projects and pnpm symlinked dependency trees, and use the detected package manager when printing Intent commands. diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 09d2623..021a5ba 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -13,6 +13,8 @@ The install command guides your agent through the setup process: npx @tanstack/intent@latest install ``` +Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`. + This creates or updates an `intent-skills` guidance block. It: 1. Checks for existing `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) @@ -30,13 +32,15 @@ Intent creates guidance like: ## Skill Loading Before substantial work: -- Skill check: run `npx @tanstack/intent@latest list`, or use skills already listed in context. -- Skill guidance: if one local skill clearly matches the task, run `npx @tanstack/intent@latest load #` and follow the returned `SKILL.md`. +- Skill check: run `pnpm dlx @tanstack/intent@latest list`, or use skills already listed in context. +- Skill guidance: if one local skill clearly matches the task, run `pnpm dlx @tanstack/intent@latest load #` and follow the returned `SKILL.md`. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. - Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. ``` +Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`. + ## 2. Use skills in your workflow When your agent works on a task that matches an available skill, it loads the matching `SKILL.md` into context. diff --git a/docs/overview.md b/docs/overview.md index 3b01284..e3a2cb0 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -24,11 +24,20 @@ Intent provides tooling for two workflows: ## How it works -### Discovery and installation - -```bash -npx @tanstack/intent@latest list -``` +### Discovery and installation + +Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: + +| Tool | Pattern | +| ---- | -------------------------------------------- | +| npm | `npx @tanstack/intent@latest ` | +| pnpm | `pnpm dlx @tanstack/intent@latest ` | +| Yarn | `yarn dlx @tanstack/intent@latest ` | +| Bun | `bunx @tanstack/intent@latest ` | + +```bash +npx @tanstack/intent@latest list +``` Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`. Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages. diff --git a/packages/intent/src/command-runner.ts b/packages/intent/src/command-runner.ts new file mode 100644 index 0000000..658abef --- /dev/null +++ b/packages/intent/src/command-runner.ts @@ -0,0 +1,21 @@ +import { detectPackageManager } from './package-manager.js' +import type { PackageManager } from './types.js' + +export { detectPackageManager as detectIntentCommandPackageManager } + +const runnerByPackageManager: Record = { + bun: 'bunx @tanstack/intent@latest', + npm: 'npx @tanstack/intent@latest', + pnpm: 'pnpm dlx @tanstack/intent@latest', + unknown: 'npx @tanstack/intent@latest', + yarn: 'yarn dlx @tanstack/intent@latest', +} + +export function formatIntentCommand( + packageManager: PackageManager, + args: string, +): string { + const command = runnerByPackageManager[packageManager] + const trimmedArgs = args.trim() + return trimmedArgs ? `${command} ${trimmedArgs}` : command +} diff --git a/packages/intent/src/commands/install-writer.ts b/packages/intent/src/commands/install-writer.ts index 8eb9532..e7a9524 100644 --- a/packages/intent/src/commands/install-writer.ts +++ b/packages/intent/src/commands/install-writer.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' +import { formatIntentCommand } from '../command-runner.js' import { formatSkillUse, parseSkillUse } from '../skill-use.js' import type { ScanResult, SkillEntry } from '../types.js' @@ -240,7 +241,10 @@ export function buildIntentSkillsBlock( ): IntentSkillsBlockResult { const lines = [ INTENT_SKILLS_START, - '# Skill mappings - load `use` with `npx @tanstack/intent@latest load `.', + `# Skill mappings - load \`use\` with \`${formatIntentCommand( + scanResult.packageManager, + 'load ', + )}\`.`, 'skills:', ] let mappingCount = 0 @@ -268,15 +272,23 @@ export function buildIntentSkillsBlock( } } -export function buildIntentSkillGuidanceBlock(): IntentSkillsBlockResult { +export function buildIntentSkillGuidanceBlock( + packageManager: ScanResult['packageManager'] = 'unknown', +): IntentSkillsBlockResult { + const listCommand = formatIntentCommand(packageManager, 'list') + const loadCommand = formatIntentCommand( + packageManager, + 'load #', + ) + return { block: `${[ INTENT_SKILLS_START, '## Skill Loading', '', 'Before substantial work:', - '- Skill check: run `npx @tanstack/intent@latest list`, or use skills already listed in context.', - '- Skill guidance: if one local skill clearly matches the task, run `npx @tanstack/intent@latest load #` and follow the returned `SKILL.md`.', + `- Skill check: run \`${listCommand}\`, or use skills already listed in context.`, + `- Skill guidance: if one local skill clearly matches the task, run \`${loadCommand}\` and follow the returned \`SKILL.md\`.`, '- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.', '- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.', INTENT_SKILLS_END, diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index 5cdcaf2..03662c0 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -1,5 +1,6 @@ import { relative } from 'node:path' import { fail } from '../cli-error.js' +import { detectIntentCommandPackageManager } from '../command-runner.js' import { printWarnings, scanOptionsFromGlobalFlags } from '../cli-support.js' import { buildIntentSkillGuidanceBlock, @@ -190,10 +191,12 @@ export async function runInstallCommand( return } - scanOptionsFromGlobalFlags(options) + const scanOptions = scanOptionsFromGlobalFlags(options) if (!options.map) { - const generated = buildIntentSkillGuidanceBlock() + const generated = buildIntentSkillGuidanceBlock( + detectIntentCommandPackageManager(), + ) if (options.dryRun) { const targetPath = resolveIntentSkillsBlockTargetPath(process.cwd(), 1) @@ -234,9 +237,7 @@ export async function runInstallCommand( return } - const scanResult = await scanIntentsOrFail( - scanOptionsFromGlobalFlags(options), - ) + const scanResult = await scanIntentsOrFail(scanOptions) const generated = buildIntentSkillsBlock(scanResult) if (options.dryRun) { diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0419152..a87b9c7 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -4,6 +4,7 @@ import { printWarnings, type GlobalScanFlags, } from '../cli-support.js' +import { formatIntentCommand } from '../command-runner.js' import { listIntentSkills } from '../core.js' import type { IntentPackageSummary, @@ -75,6 +76,14 @@ function getPackageSkills( return skillsByPackageRoot.get(pkg.packageRoot) ?? [] } +function formatLoadCommand( + skill: IntentSkillSummary, + packageManager: ScanResult['packageManager'], + scopeFlag: string, +): string { + return formatIntentCommand(packageManager, `load ${skill.use}${scopeFlag}`) +} + export async function runListCommand( options: ListCommandOptions, _scanIntentsOrFail?: (options?: ScanOptions) => Promise, @@ -83,7 +92,11 @@ export async function runListCommand( printListDebug(result) if (options.json) { - const { debug: _debug, ...jsonResult } = result + const { + debug: _debug, + packageManager: _packageManager, + ...jsonResult + } = result console.log(JSON.stringify(jsonResult, null, 2)) return } @@ -124,6 +137,11 @@ export async function runListCommand( ) const nameWidth = computeSkillNameWidth(allSkills) const showTypes = result.skills.some((skill) => skill.type) + const scopeFlag = options.globalOnly + ? ' --global-only' + : options.global + ? ' --global' + : '' console.log(`\nSkills:\n`) for (const pkg of result.packages) { @@ -132,6 +150,7 @@ export async function runListCommand( getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, description: skill.description, + loadCommand: formatLoadCommand(skill, result.packageManager, scopeFlag), type: skill.type, })), { nameWidth, packageName: pkg.name, showTypes }, diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index df8a46f..0348a9a 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -125,6 +125,7 @@ export function listIntentSkills( ) const result: IntentSkillList = { + packageManager: scanResult.packageManager, skills, packages: packages.map((pkg) => ({ name: pkg.name, diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 90ae9e2..2ed0e06 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -1,5 +1,6 @@ import type { IntentPackage, + PackageManager, ScanScope, ScanStats, VersionConflict, @@ -34,6 +35,7 @@ export interface IntentPackageSummary { } export interface IntentSkillList { + packageManager: PackageManager skills: Array packages: Array warnings: Array diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index 7a0d4b1..368b4a0 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { join, resolve, sep } from 'node:path' +import { join, sep } from 'node:path' import { rewriteSkillLoadPaths } from '../skill-paths.js' import { listNodeModulesPackageDirs } from '../utils.js' import type { @@ -18,10 +18,6 @@ function isLocalToProject(dirPath: string, projectRoot: string): boolean { ) } -function getFsIdentity(path: string): string { - return resolve(path) -} - export interface CreatePackageRegistrarOptions { comparePackageVersions: (a: string, b: string) => number deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null @@ -31,6 +27,7 @@ export interface CreatePackageRegistrarOptions { packages: Array projectRoot: string readPkgJson: (dirPath: string) => PackageJson | null + getFsIdentity: (path: string) => string rememberVariant: (pkg: IntentPackage) => void validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null warnings: Array @@ -41,7 +38,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { const scannedNodeModulesDirs = new Set() function shouldAttemptPackageRoot(dirPath: string): boolean { - const key = getFsIdentity(dirPath) + const key = opts.getFsIdentity(dirPath) if (attemptedPackageRoots.has(key)) return false attemptedPackageRoots.add(key) return true @@ -53,7 +50,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { ): void { if (!existsSync(nodeModulesDir)) return - const key = getFsIdentity(nodeModulesDir) + const key = opts.getFsIdentity(nodeModulesDir) if (scannedNodeModulesDirs.has(key)) return scannedNodeModulesDirs.add(key) diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index 77f4216..d9b89d9 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -1,5 +1,9 @@ import { join } from 'node:path' -import { resolveDepDir, getDeps } from '../utils.js' +import { + getDeps, + listNestedNodeModulesPackageDirs, + resolveDepDir, +} from '../utils.js' import { findWorkspacePackages } from '../workspace-patterns.js' import type { IntentFsCache } from '../fs-cache.js' import type { IntentPackage } from '../types.js' @@ -10,6 +14,7 @@ export interface CreateDependencyWalkerOptions { fsCache: IntentFsCache projectRoot: string readPkgJson: (dirPath: string) => PackageJson | null + getFsIdentity: (path: string) => string scanNodeModulesDir: (nodeModulesDir: string) => void tryRegister: (dirPath: string, fallbackName: string) => boolean packages: Array @@ -24,10 +29,11 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { depName: string, fromDir: string, ): string | null { - let byDepName = depDirCache.get(fromDir) + const fromKey = opts.getFsIdentity(fromDir) + let byDepName = depDirCache.get(fromKey) if (!byDepName) { byDepName = new Map() - depDirCache.set(fromDir, byDepName) + depDirCache.set(fromKey, byDepName) } if (!byDepName.has(depName)) { @@ -44,7 +50,7 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { ): void { for (const depName of getDeps(pkgJson, includeDevDeps)) { const depDir = resolveDepDirCached(depName, fromDir) - if (!depDir || walkVisited.has(depDir)) continue + if (!depDir) continue opts.tryRegister(depDir, depName) walkDeps(depDir, depName) @@ -52,8 +58,9 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { } function walkDeps(pkgDir: string, pkgName: string): void { - if (walkVisited.has(pkgDir)) return - walkVisited.add(pkgDir) + const pkgKey = opts.getFsIdentity(pkgDir) + if (walkVisited.has(pkgKey)) return + walkVisited.add(pkgKey) const pkgJson = opts.readPkgJson(pkgDir) if (!pkgJson) { @@ -111,7 +118,22 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { } } + function scanNestedNodeModulesDir(nodeModulesDir: string): void { + for (const dirPath of listNestedNodeModulesPackageDirs( + nodeModulesDir, + opts.getFsIdentity, + )) { + if (!opts.tryRegister(dirPath, 'unknown')) continue + + const pkgJson = opts.readPkgJson(dirPath) + const pkgName = + typeof pkgJson?.name === 'string' ? pkgJson.name : 'unknown' + walkDeps(dirPath, pkgName) + } + } + return { + scanNestedNodeModulesDir, walkKnownPackages, walkProjectDeps, walkWorkspacePackages, diff --git a/packages/intent/src/display.ts b/packages/intent/src/display.ts index 52607c4..297f49a 100644 --- a/packages/intent/src/display.ts +++ b/packages/intent/src/display.ts @@ -10,6 +10,7 @@ import { export interface SkillDisplay { name: string description: string + loadCommand?: string type?: string path?: string } @@ -48,6 +49,9 @@ function printSkillLine( ? (skill.type ? `[${skill.type}]` : '').padEnd(14) : '' console.log(`${nameStr}${padding}${typeCol}${skill.description}`) + if (skill.loadCommand) { + console.log(`${' '.repeat(indent + 2)}Load: ${skill.loadCommand}`) + } if (skill.path) { const pathIndent = ' '.repeat(indent + 2) if (isStableLoadPath(skill.path)) { @@ -69,6 +73,7 @@ export function printSkillTree( ): void { const roots: Array = [] const children = new Map>() + const printedSkills = new Set() for (const skill of skills) { const slashIdx = skill.name.indexOf('/') @@ -92,12 +97,19 @@ export function printSkillTree( if (!rootSkill) continue printSkillLine(rootName, rootSkill, 4, opts) + printedSkills.add(rootSkill.name) for (const sub of children.get(rootName) ?? []) { const childName = sub.name.slice(sub.name.indexOf('/') + 1) printSkillLine(childName, sub, 6, opts) + printedSkills.add(sub.name) } } + + for (const skill of skills) { + if (printedSkills.has(skill.name)) continue + printSkillLine(skill.name, skill, 4, opts) + } } export function computeSkillNameWidth( diff --git a/packages/intent/src/fs-cache.ts b/packages/intent/src/fs-cache.ts index 6385f2e..eca9406 100644 --- a/packages/intent/src/fs-cache.ts +++ b/packages/intent/src/fs-cache.ts @@ -1,6 +1,9 @@ import { readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { findSkillFiles as findSkillFilesUncached } from './utils.js' +import { join } from 'node:path' +import { + createFsIdentityCache, + findSkillFiles as findSkillFilesUncached, +} from './utils.js' type PackageJsonReadResult = { packageJson: Record | null @@ -16,13 +19,10 @@ export type IntentFsCache = { readPackageJson: (dir: string) => Record | null readPackageJsonResult: (dir: string) => PackageJsonReadResult findSkillFiles: (dir: string) => Array + getFsIdentity: (path: string) => string getStats: () => IntentFsCacheStats } -function normalizeCacheKey(path: string): string { - return resolve(path) -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -30,13 +30,14 @@ function isRecord(value: unknown): value is Record { export function createIntentFsCache(): IntentFsCache { const packageJsonCache = new Map() const skillFilesCache = new Map>() + const getFsIdentity = createFsIdentityCache() const stats: IntentFsCacheStats = { packageJsonReadCount: 0, packageJsonCacheHits: 0, } function readPackageJsonResult(dir: string): PackageJsonReadResult { - const key = normalizeCacheKey(dir) + const key = getFsIdentity(dir) const cached = packageJsonCache.get(key) if (cached) { stats.packageJsonCacheHits += 1 @@ -66,7 +67,7 @@ export function createIntentFsCache(): IntentFsCache { } function findSkillFiles(dir: string): Array { - const key = normalizeCacheKey(dir) + const key = getFsIdentity(dir) const cached = skillFilesCache.get(key) if (cached) { return [...cached] @@ -81,6 +82,7 @@ export function createIntentFsCache(): IntentFsCache { readPackageJson, readPackageJsonResult, findSkillFiles, + getFsIdentity, getStats: () => ({ ...stats }), } } diff --git a/packages/intent/src/package-manager.ts b/packages/intent/src/package-manager.ts new file mode 100644 index 0000000..6d3a7e5 --- /dev/null +++ b/packages/intent/src/package-manager.ts @@ -0,0 +1,68 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import type { PackageManager } from './types.js' + +function readPackageManagerField(dir: string): PackageManager | null { + try { + const parsed = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as unknown + if (!parsed || typeof parsed !== 'object') return null + + const value = (parsed as Record).packageManager + if (typeof value !== 'string') return null + + if (value.startsWith('pnpm@')) return 'pnpm' + if (value.startsWith('yarn@')) return 'yarn' + if (value.startsWith('bun@')) return 'bun' + if (value.startsWith('npm@')) return 'npm' + } catch { + return null + } + + return null +} + +function detectPackageManagerInDir(dir: string): PackageManager | null { + const packageManager = readPackageManagerField(dir) + if (packageManager) return packageManager + + if (existsSync(join(dir, '.pnp.cjs')) || existsSync(join(dir, '.pnp.js'))) { + return 'yarn' + } + if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm' + if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) { + return 'bun' + } + if (existsSync(join(dir, 'yarn.lock'))) return 'yarn' + if (existsSync(join(dir, 'package-lock.json'))) return 'npm' + + return null +} + +export function detectPackageManager( + cwd = process.cwd(), + extraDirs: Array = [], +): PackageManager { + const seen = new Set() + const startDirs = [cwd, ...extraDirs].filter((dir): dir is string => + Boolean(dir), + ) + + for (const startDir of startDirs) { + let dir = resolve(startDir) + + while (!seen.has(dir)) { + seen.add(dir) + + const packageManager = detectPackageManagerInDir(dir) + if (packageManager) return packageManager + + const next = dirname(dir) + if (next === dir) break + dir = next + } + } + + return 'unknown' +} diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 0fd59ab..faef2bf 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -12,6 +12,7 @@ import { toPosixPath, } from './utils.js' import { createIntentFsCache, type IntentFsCache } from './fs-cache.js' +import { detectPackageManager } from './package-manager.js' import { findWorkspaceRoot } from './workspace-patterns.js' import type { InstalledVariant, @@ -24,11 +25,6 @@ import type { VersionConflict, } from './types.js' -// --------------------------------------------------------------------------- -// Package manager detection -// --------------------------------------------------------------------------- - -type PackageManager = ScanResult['packageManager'] type ScanOptionsWithFsCache = ScanOptions & { fsCache?: IntentFsCache } @@ -70,10 +66,6 @@ function findPnpFile(start: string): string | null { } } -function isYarnPnpProject(root: string): boolean { - return findPnpFile(root) !== null -} - function assertLocalNodeModulesSupported(root: string): void { if ( existsSync(join(root, 'deno.json')) && @@ -85,33 +77,11 @@ function assertLocalNodeModulesSupported(root: string): void { } } -function detectPackageManager(root: string): PackageManager { - const dirsToCheck = [root] - const wsRoot = findWorkspaceRoot(root) - if (wsRoot && wsRoot !== root) dirsToCheck.push(wsRoot) - - for (const dir of dirsToCheck) { - if (isYarnPnpProject(dir)) return 'yarn' - if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) - return 'bun' - if (existsSync(join(dir, 'yarn.lock'))) return 'yarn' - if (existsSync(join(dir, 'package-lock.json'))) return 'npm' - } - return 'unknown' -} - function loadPnpApi(root: string): PnpApi | null { const pnpPath = findPnpFile(root) if (!pnpPath) return null try { - const moduleApi = requireFromHere('node:module') as { - findPnpApi?: (lookupSource: string) => PnpApi | null - } - const foundApi = moduleApi.findPnpApi?.(root) - if (foundApi) return foundApi - const pnpModule = requireFromHere(pnpPath) as PnpApi if (typeof pnpModule.setup === 'function') { pnpModule.setup() @@ -127,6 +97,16 @@ function loadPnpApi(root: string): PnpApi | null { const projectRequire = createRequire(join(dirname(pnpPath), 'package.json')) return projectRequire('pnpapi') as PnpApi } catch (err) { + try { + const moduleApi = requireFromHere('node:module') as { + findPnpApi?: (lookupSource: string) => PnpApi | null + } + const foundApi = moduleApi.findPnpApi?.(root) + if (foundApi) return foundApi + } catch { + // Ignore and report the project PnP load error below. + } + const msg = err instanceof Error ? err.message : String(err) throw new Error( `Yarn PnP project detected, but Intent could not load Yarn's PnP API from ${pnpPath}: ${msg}`, @@ -434,7 +414,8 @@ export function scanForIntents( const scanScope = getScanScope(options) const fsCache = (options as ScanOptionsWithFsCache).fsCache ?? createIntentFsCache() - const packageManager = detectPackageManager(projectRoot) + const workspaceRoot = findWorkspaceRoot(projectRoot) + const packageManager = detectPackageManager(projectRoot, [workspaceRoot]) const nodeModulesDir = join(projectRoot, 'node_modules') const explicitGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES?.trim() || null @@ -515,6 +496,7 @@ export function scanForIntents( deriveIntentConfig, discoverSkills: (skillsDir) => discoverSkills(skillsDir, fsCache), getPackageDepth, + getFsIdentity: fsCache.getFsIdentity, packageIndexes, packages, projectRoot, @@ -524,20 +506,24 @@ export function scanForIntents( warnings, }) - const { walkKnownPackages, walkProjectDeps, walkWorkspacePackages } = - createDependencyWalker({ - fsCache, - packages, - projectRoot, - readPkgJson, - scanNodeModulesDir, - tryRegister, - warnings, - }) + const { + scanNestedNodeModulesDir, + walkKnownPackages, + walkProjectDeps, + walkWorkspacePackages, + } = createDependencyWalker({ + fsCache, + getFsIdentity: fsCache.getFsIdentity, + packages, + projectRoot, + readPkgJson, + scanNodeModulesDir, + tryRegister, + warnings, + }) function scanPnpPackages(api: PnpApi): void { const visited = new Set() - const workspaceRoot = findWorkspaceRoot(projectRoot) const projectLocator = api.findPackageLocator?.( projectRoot.endsWith(sep) ? projectRoot : `${projectRoot}${sep}`, ) @@ -582,13 +568,24 @@ export function scanForIntents( } assertLocalNodeModulesSupported(projectRoot) + const packageCountBeforeLocalDiscovery = packages.length walkWorkspacePackages() const packageCountBeforeDependencyDiscovery = packages.length scanTarget(nodeModules.local) walkKnownPackages() walkProjectDeps() + const shouldTryPnpFallback = + packages.length === packageCountBeforeDependencyDiscovery + + if ( + nodeModules.local.path && + nodeModules.local.exists && + packages.length === packageCountBeforeLocalDiscovery + ) { + scanNestedNodeModulesDir(nodeModules.local.path) + } - if (packages.length === packageCountBeforeDependencyDiscovery) { + if (shouldTryPnpFallback) { const api = getPnpApi() if (api) { scanPnpPackages(api) @@ -694,6 +691,7 @@ export function scanIntentPackageAtRoot( ) : (skillsDir) => discoverSkills(skillsDir, fsCache), getPackageDepth, + getFsIdentity: fsCache.getFsIdentity, packageIndexes, packages, projectRoot, diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index 2f788ea..6115af2 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -14,7 +14,7 @@ export interface IntentConfig { // --------------------------------------------------------------------------- export interface ScanResult { - packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' + packageManager: PackageManager packages: Array warnings: Array conflicts: Array @@ -25,6 +25,8 @@ export interface ScanResult { stats?: ScanStats } +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' + export type ScanScope = 'local' | 'local-and-global' | 'global' export interface ScanOptions { diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index aecaf21..d464e25 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -1,7 +1,14 @@ import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' +import { + existsSync, + lstatSync, + readFileSync, + readdirSync, + realpathSync, + type Dirent, +} from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, sep } from 'node:path' +import { dirname, join, resolve, sep } from 'node:path' import { parse as parseYaml } from 'yaml' /** @@ -11,6 +18,28 @@ export function toPosixPath(p: string): string { return p.split(sep).join('/') } +export function createFsIdentityCache(): (path: string) => string { + const cache = new Map() + + return (path: string): string => { + const resolved = resolve(path) + const cached = cache.get(resolved) + if (cached) return cached + + let identity: string + try { + identity = lstatSync(resolved).isSymbolicLink() + ? realpathSync(resolved) + : resolved + } catch { + identity = resolved + } + + cache.set(resolved, identity) + return identity + } +} + /** * Recursively find all SKILL.md files under a directory. */ @@ -103,6 +132,59 @@ export function listNodeModulesPackageDirs( return packageDirs } +export function listNestedNodeModulesPackageDirs( + nodeModulesDir: string, + getFsIdentity = createFsIdentityCache(), +): Array { + const packageDirs: Array = [] + const visitedNodeModulesDirs = new Set() + const visitedPackageDirs = new Set() + + function readDir(dir: string): Array> { + try { + return readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) + } catch { + return [] + } + } + + function addPackageDir(packageDir: string): void { + const key = getFsIdentity(packageDir) + if (visitedPackageDirs.has(key)) return + visitedPackageDirs.add(key) + + if (existsSync(join(packageDir, 'package.json'))) { + packageDirs.push(packageDir) + } + + scanNodeModulesDir(join(packageDir, 'node_modules')) + } + + function scanNodeModulesDir(dir: string): void { + const key = getFsIdentity(dir) + if (visitedNodeModulesDirs.has(key)) return + visitedNodeModulesDirs.add(key) + + for (const entry of readDir(dir)) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + const dirPath = join(dir, entry.name) + + if (entry.name.startsWith('@')) { + for (const scoped of readDir(dirPath)) { + if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue + addPackageDir(join(dirPath, scoped.name)) + } + continue + } + + if (!entry.name.startsWith('.')) addPackageDir(dirPath) + } + } + + scanNodeModulesDir(nodeModulesDir) + return packageDirs +} + export function detectGlobalNodeModules(packageManager: string): { path: string | null source?: string diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index ac328c3..43abedf 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -289,6 +289,25 @@ describe('cli commands', () => { expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) + it('prints package-manager-specific install guidance', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-package-runner-'), + ) + tempDirs.push(root) + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + + process.chdir(root) + + const exitCode = await main(['install', '--dry-run']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('pnpm dlx @tanstack/intent@latest list') + expect(output).toContain( + 'pnpm dlx @tanstack/intent@latest load #', + ) + }) + it('writes skill loading guidance even with no discovered skills', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-empty-')) const isolatedGlobalRoot = mkdtempSync( @@ -576,6 +595,106 @@ describe('cli commands', () => { expect(parsed.warnings).toEqual([]) }) + it('prints full load commands for every skill in human list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-load-commands-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + writeSkillMd(join(pkgDir, 'skills', 'query', 'cache'), { + name: 'query/cache', + description: 'Query cache skill', + }) + + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching', + ) + expect(output).toContain( + 'Load: npx @tanstack/intent@latest load @tanstack/query#query/cache', + ) + }) + + it.each([ + ['pnpm-lock.yaml', 'pnpm dlx @tanstack/intent@latest'], + ['yarn.lock', 'yarn dlx @tanstack/intent@latest'], + ['bun.lock', 'bunx @tanstack/intent@latest'], + ])( + 'prints %s load commands for human list output', + async (lockfile, runner) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-package-runner-'), + ) + tempDirs.push(root) + writeFileSync(join(root, lockfile), '') + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query fetching skill', + }) + + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain(`Load: ${runner} load @tanstack/query#fetching`) + }, + ) + + it('does not print warning noise for normal pnpm list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-pnpm-clean-')) + tempDirs.push(root) + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + wrapper: '1.0.0', + }, + }) + + const wrapperDir = join(root, 'node_modules', 'wrapper') + writeJson(join(wrapperDir, 'package.json'), { + name: 'wrapper', + version: '1.0.0', + dependencies: { + '@tanstack/query': '5.0.0', + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query fetching skill', + }) + + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('@tanstack/query') + expect(output).not.toContain('Warnings:') + expect(output).not.toContain('Could not read') + }) + it('prints list debug details to stderr without changing json stdout', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-debug-')) tempDirs.push(root) @@ -720,6 +839,9 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Global fetching skill') + expect(output).toContain( + 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching --global', + ) expect(output).not.toContain(globalPkgDir) }) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index d7e5d3e..a190a7f 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -105,6 +105,7 @@ describe('listIntentSkills', () => { const result = listIntentSkills({ cwd: root }) expect(result).toEqual({ + packageManager: 'unknown', skills: [ { use: '@tanstack/query#fetching', diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 731ed9b..a28316a 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -76,7 +76,7 @@ function scanResult(packages: Array): ScanResult { } const exampleBlock = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. +# Skill mappings - load \`use\` with \`pnpm dlx @tanstack/intent@latest load \`. skills: - when: "Query data fetching" use: "@tanstack/query#fetching" @@ -99,6 +99,15 @@ describe('install writer block builder', () => { expect(generated.block).not.toContain('--global') }) + it('builds package-manager-specific loading guidance', () => { + const generated = buildIntentSkillGuidanceBlock('pnpm') + + expect(generated.block).toContain('pnpm dlx @tanstack/intent@latest list') + expect(generated.block).toContain( + 'pnpm dlx @tanstack/intent@latest load #', + ) + }) + it('builds a deterministic compact block', () => { const result = scanResult([ pkg({ @@ -132,7 +141,7 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(3) expect(generated.block).toBe(` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. +# Skill mappings - load \`use\` with \`pnpm dlx @tanstack/intent@latest load \`. skills: - when: "Query data fetching patterns" use: "@tanstack/query#fetching" diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index e5ddae7..8d904fe 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -6,6 +6,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs' +import { createRequire } from 'node:module' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -38,6 +39,7 @@ function writeSkillMd(dir: string, frontmatter: Record): void { let root: string let globalRoot: string let previousGlobalNodeModules: string | undefined +const requireFromTest = createRequire(import.meta.url) beforeEach(() => { root = realpathSync(mkdtempSync(join(tmpdir(), 'intent-test-'))) @@ -998,6 +1000,131 @@ describe('scanForIntents', () => { expect(result.warnings).toEqual([]) }) + it('uses the project Yarn PnP API when another PnP API is active', () => { + const reactStartDir = createDir( + root, + '.yarn', + '__virtual__', + '@tanstack-react-start-virtual', + '0', + 'cache', + '@tanstack-react-start-npm-1.167.52.zip', + 'node_modules', + '@tanstack', + 'react-start', + ) + + writeJson(join(root, 'package.json'), { + name: 'tanstack-intent-pnp-repro', + version: '0.0.0', + private: true, + packageManager: 'yarn@4.12.0', + dependencies: { + '@tanstack/react-start': '1.167.52', + }, + }) + writeFileSync(join(root, '.yarnrc.yml'), 'nodeLinker: pnp\n') + writeJson(join(reactStartDir, 'package.json'), { + name: '@tanstack/react-start', + version: '1.167.52', + repository: { + type: 'git', + url: 'git+https://github.com/TanStack/router.git', + directory: 'packages/react-start', + }, + homepage: 'https://tanstack.com/start', + }) + writeSkillMd(createDir(reactStartDir, 'skills', 'react-start'), { + name: 'react-start', + description: 'React Start skill', + }) + writeSkillMd( + createDir(reactStartDir, 'skills', 'lifecycle', 'migrate-from-nextjs'), + { + name: 'lifecycle/migrate-from-nextjs', + description: 'Migration skill', + }, + ) + writeSkillMd( + createDir(reactStartDir, 'skills', 'react-start', 'server-components'), + { + name: 'react-start/server-components', + description: 'Server components skill', + }, + ) + + writeFileSync( + join(root, '.pnp.cjs'), + [ + `const projectRoot = ${JSON.stringify(`${root}${sep}`)}`, + `const reactStartRoot = ${JSON.stringify(`${reactStartDir}${sep}`)}`, + "const rootLocator = { name: 'tanstack-intent-pnp-repro', reference: 'workspace:.' }", + "const reactStartLocator = { name: '@tanstack/react-start', reference: 'virtual:test#npm:1.167.52' }", + 'module.exports = {', + ' setup() {},', + ' getDependencyTreeRoots() { return [rootLocator] },', + ' findPackageLocator(location) {', + ' if (location.startsWith(projectRoot)) return rootLocator', + ' if (location.startsWith(reactStartRoot)) return reactStartLocator', + ' return null', + ' },', + ' getPackageInformation(locator) {', + " if (locator.name === 'tanstack-intent-pnp-repro') {", + ' return {', + ' packageLocation: projectRoot,', + " packageDependencies: new Map([['@tanstack/react-start', 'virtual:test#npm:1.167.52']]),", + ' }', + ' }', + " if (locator.name === '@tanstack/react-start') {", + ' return {', + ' packageLocation: reactStartRoot,', + ' packageDependencies: new Map(),', + ' }', + ' }', + ' return null', + ' },', + '}', + '', + ].join('\n'), + ) + + const moduleApi = requireFromTest('node:module') as { + findPnpApi?: () => unknown + } + const previousFindPnpApi = moduleApi.findPnpApi + moduleApi.findPnpApi = () => ({ + getDependencyTreeRoots() { + return [{ name: 'wrong-project', reference: 'workspace:.' }] + }, + getPackageInformation() { + return { + packageLocation: `${root}${sep}`, + packageDependencies: new Map(), + } + }, + }) + + try { + const result = scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/react-start') + expect( + result.packages[0]!.skills.map((skill) => skill.name).sort(), + ).toEqual([ + 'lifecycle/migrate-from-nextjs', + 'react-start', + 'react-start/server-components', + ]) + } finally { + if (previousFindPnpApi) { + moduleApi.findPnpApi = previousFindPnpApi + } else { + delete moduleApi.findPnpApi + } + } + }) + it('falls back to Yarn PnP when workspace discovery finds packages first', () => { const reactStartDir = createDir( root, @@ -1145,6 +1272,151 @@ describe('scanForIntents', () => { expect(result.packages[0]!.name).toBe('@tanstack/db') }) + it('falls back to bounded nested node_modules discovery through symlinks', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + + const wrapperDir = createDir(root, 'node_modules', 'wrapper') + writeJson(join(wrapperDir, 'package.json'), { + name: 'wrapper', + version: '1.0.0', + }) + + const skillPkgDir = createDir(root, 'store', '@tanstack', 'query') + writeJson(join(skillPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + dependencies: { + '@tanstack/store': '1.0.0', + }, + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(skillPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + const transitiveSkillPkgDir = createDir( + skillPkgDir, + 'node_modules', + '@tanstack', + 'store', + ) + writeJson(join(transitiveSkillPkgDir, 'package.json'), { + name: '@tanstack/store', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/store', docs: 'docs/' }, + }) + writeSkillMd(createDir(transitiveSkillPkgDir, 'skills', 'store'), { + name: 'store', + description: 'Store skill', + }) + + createDir(wrapperDir, 'node_modules', '@tanstack') + symlinkSync( + skillPkgDir, + join(wrapperDir, 'node_modules', '@tanstack', 'query'), + 'dir', + ) + symlinkSync( + join(root, 'node_modules'), + join(wrapperDir, 'node_modules', 'loop'), + 'dir', + ) + + const result = scanForIntents(root) + + expect(result.packages.map((pkg) => pkg.name).sort()).toEqual([ + '@tanstack/query', + '@tanstack/store', + ]) + expect(result.stats!.packageJsonReadCount).toBeLessThan(10) + }) + + it('does not crawl package source trees during nested node_modules discovery', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + + const wrapperDir = createDir(root, 'node_modules', 'wrapper') + writeJson(join(wrapperDir, 'package.json'), { + name: 'wrapper', + version: '1.0.0', + }) + + const sourcePackageDir = createDir( + wrapperDir, + 'src', + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(sourcePackageDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(sourcePackageDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + + const result = scanForIntents(root) + + expect(result.packages).toEqual([]) + expect(result.stats!.packageJsonReadCount).toBeLessThan(4) + }) + + it('dedupes recursive workspace symlink paths by real package identity', () => { + writeJson(join(root, 'package.json'), { + name: 'workspace-root', + private: true, + workspaces: ['packages/*'], + dependencies: { a: 'workspace:*' }, + }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + + const aDir = createDir(root, 'packages', 'a') + const bDir = createDir(root, 'packages', 'b') + writeJson(join(aDir, 'package.json'), { + name: 'a', + version: '1.0.0', + exports: { '.': './index.js' }, + dependencies: { b: 'workspace:*' }, + }) + writeFileSync(join(aDir, 'index.js'), '') + writeJson(join(bDir, 'package.json'), { + name: 'b', + version: '1.0.0', + intent: { version: 1, repo: 'example/b', docs: 'docs/' }, + exports: { '.': './index.js' }, + dependencies: { a: 'workspace:*' }, + }) + writeFileSync(join(bDir, 'index.js'), '') + writeSkillMd(createDir(bDir, 'skills', 'core'), { + name: 'core', + description: 'Core skill', + }) + + createDir(root, 'node_modules') + symlinkSync(aDir, join(root, 'node_modules', 'a'), 'dir') + createDir(aDir, 'node_modules') + createDir(bDir, 'node_modules') + symlinkSync(bDir, join(aDir, 'node_modules', 'b'), 'dir') + symlinkSync(aDir, join(bDir, 'node_modules', 'a'), 'dir') + + const result = scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('b') + expect(result.stats!.packageJsonReadCount).toBeLessThan(10) + }) + it('prefers valid semver versions over invalid ones at the same depth', () => { writeJson(join(root, 'package.json'), { name: 'app',