From d41ef13ed9d830fc7c61da8be8e347b2c681e5e0 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 21:53:03 -0700 Subject: [PATCH 01/20] fall back to Yarn PnP for stale node_modules --- packages/intent/src/scanner.ts | 8 ++ packages/intent/tests/scanner.test.ts | 105 ++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index f187be8..629b741 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -570,10 +570,18 @@ export function scanForIntents( } assertLocalNodeModulesSupported(projectRoot) + const packageCountBeforeNodeModules = packages.length scanTarget(nodeModules.local) walkWorkspacePackages() walkKnownPackages() walkProjectDeps() + + if (packages.length === packageCountBeforeNodeModules) { + const api = getPnpApi() + if (api) { + scanPnpPackages(api) + } + } } function scanGlobalPackages(): void { diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 84843ec..bc2f29a 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -752,6 +752,111 @@ describe('scanForIntents', () => { expect(result.warnings).toEqual([]) }) + it('falls back to Yarn PnP when stale node_modules has no intent packages', () => { + 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}/`)}`, + `const reactStartRoot = ${JSON.stringify(`${reactStartDir}/`)}`, + "const rootLocator = { name: 'tanstack-intent-pnp-repro', reference: 'workspace:.' }", + "const reactStartLocator = { name: '@tanstack/react-start', reference: 'virtual:test#npm:1.167.52' }", + 'module.exports = {', + ' 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'), + ) + createDir(root, 'node_modules') + + const result = scanForIntents(root) + + expect(result.packageManager).toBe('yarn') + expect(result.nodeModules.local.exists).toBe(true) + expect(result.nodeModules.local.scanned).toBe(true) + 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', + ], + ) + expect(result.warnings).toEqual([]) + }) + it('discovers skills using package.json workspaces', () => { writeJson(join(root, 'package.json'), { name: 'monorepo', From 2f62aa7cfe585a232db8afa472996bc7d399805b Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 22:02:40 -0700 Subject: [PATCH 02/20] add shared core skill API --- packages/intent/package.json | 6 +- packages/intent/src/core.ts | 517 +++++++++++++++++++++++++++++ packages/intent/tests/core.test.ts | 217 ++++++++++++ 3 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 packages/intent/src/core.ts create mode 100644 packages/intent/tests/core.test.ts diff --git a/packages/intent/package.json b/packages/intent/package.json index 50e6378..889f013 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -16,6 +16,10 @@ "./intent-library": { "import": "./dist/intent-library.mjs", "types": "./dist/intent-library.d.mts" + }, + "./core": { + "import": "./dist/core.mjs", + "types": "./dist/core.d.mts" } }, "bin": { @@ -37,7 +41,7 @@ }, "scripts": { "prepack": "npm run build", - "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts --format esm --dts", + "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts src/core.ts --format esm --dts", "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null", "test:lib": "vitest run --exclude 'tests/integration/**'", "test:integration": "vitest run tests/integration/", diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts new file mode 100644 index 0000000..6e2da2b --- /dev/null +++ b/packages/intent/src/core.ts @@ -0,0 +1,517 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' +import { formatSkillUse, parseSkillUse } from './skill-use.js' +import { scanForIntents } from './scanner.js' +import { toPosixPath } from './utils.js' +import type { IntentPackage, ScanOptions, VersionConflict } from './types.js' + +export interface IntentCoreOptions { + cwd?: string + global?: boolean + globalOnly?: boolean +} + +export interface IntentSkillSummary { + use: string + packageName: string + packageVersion: string + packageSource: IntentPackage['source'] + skillName: string + description: string + type?: string + framework?: string +} + +export interface IntentPackageSummary { + name: string + version: string + source: IntentPackage['source'] + skillCount: number +} + +export interface IntentSkillList { + skills: Array + packages: Array + warnings: Array + conflicts: Array +} + +export interface LoadedIntentSkill { + content: string + path: string + packageRoot: string + packageName: string + skillName: string + version: string + source: IntentPackage['source'] + warnings: Array + conflict: VersionConflict | null +} + +export class IntentCoreError extends Error { + readonly code: + | 'invalid-options' + | 'invalid-skill-use' + | 'package-not-found' + | 'skill-not-found' + | 'skill-path-outside-package' + | 'skill-file-not-found' + + constructor(code: IntentCoreError['code'], message: string) { + super(message) + this.name = 'IntentCoreError' + this.code = code + } +} + +function toScanOptions(options: IntentCoreOptions): ScanOptions { + if (options.global && options.globalOnly) { + throw new IntentCoreError( + 'invalid-options', + 'Use either global or globalOnly, not both.', + ) + } + + if (options.globalOnly) { + return { scope: 'global' } + } + + if (options.global) { + return { scope: 'local-and-global' } + } + + return { scope: 'local' } +} + +function withCwd(cwd: string | undefined, callback: () => T): T { + if (!cwd) return callback() + + const originalCwd = process.cwd() + process.chdir(cwd) + try { + return callback() + } finally { + process.chdir(originalCwd) + } +} + +export function listIntentSkills( + options: IntentCoreOptions = {}, +): IntentSkillList { + return withCwd(options.cwd, () => { + const scanResult = scanForIntents(undefined, toScanOptions(options)) + const skills = scanResult.packages.flatMap((pkg) => + pkg.skills.map((skill): IntentSkillSummary => { + return { + use: formatSkillUse(pkg.name, skill.name), + packageName: pkg.name, + packageVersion: pkg.version, + packageSource: pkg.source, + skillName: skill.name, + description: skill.description, + type: skill.type, + framework: skill.framework, + } + }), + ) + + return { + skills, + packages: scanResult.packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + source: pkg.source, + skillCount: pkg.skills.length, + })), + warnings: scanResult.warnings, + conflicts: scanResult.conflicts, + } + }) +} + +function resolveFromCwd(path: string): string { + return resolve(process.cwd(), path) +} + +function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { + const relativePath = relative( + resolveFromCwd(packageRoot), + resolveFromCwd(path), + ) + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !isAbsolute(relativePath)) + ) +} + +function splitDestinationSuffix(destination: string): { + pathPart: string + suffix: string +} { + const hashIndex = destination.indexOf('#') + const queryIndex = destination.indexOf('?') + const suffixIndex = + hashIndex === -1 + ? queryIndex + : queryIndex === -1 + ? hashIndex + : Math.min(hashIndex, queryIndex) + + if (suffixIndex === -1) { + return { pathPart: destination, suffix: '' } + } + + return { + pathPart: destination.slice(0, suffixIndex), + suffix: destination.slice(suffixIndex), + } +} + +function isExternalOrAbsoluteDestination(destination: string): boolean { + return ( + destination === '' || + destination.startsWith('#') || + destination.startsWith('?') || + destination.startsWith('//') || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) || + isAbsolute(destination) + ) +} + +interface MarkdownDestinationRewriteContext { + cwd: string + resolvedPackageRoot: string + skillDir: string +} + +function findClosingBracket(line: string, start: number): number { + let depth = 0 + + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === '[') { + depth++ + continue + } + if (char === ']') { + depth-- + if (depth === 0) return index + } + } + + return -1 +} + +function findClosingParen(line: string, start: number): number { + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === ')') return index + } + + return -1 +} + +function readBareDestination( + line: string, + start: number, +): { destinationEnd: number; endParen: number } | null { + let depth = 0 + + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === '(') { + depth++ + continue + } + if (char === ')') { + if (depth === 0) { + return { destinationEnd: index, endParen: index } + } + depth-- + continue + } + if (/\s/.test(char) && depth === 0) { + const endParen = findClosingParen(line, index) + if (endParen === -1) return null + return { destinationEnd: index, endParen } + } + } + + return null +} + +function readMarkdownDestination( + line: string, + start: number, +): { + destination: string + destinationStart: number + destinationEnd: number + endParen: number +} | null { + let cursor = start + while (cursor < line.length && /\s/.test(line[cursor]!)) cursor++ + + if (line[cursor] === '<') { + const destinationStart = cursor + 1 + const destinationEnd = line.indexOf('>', destinationStart) + if (destinationEnd === -1) return null + const endParen = findClosingParen(line, destinationEnd + 1) + if (endParen === -1) return null + return { + destination: line.slice(destinationStart, destinationEnd), + destinationStart, + destinationEnd, + endParen, + } + } + + const read = readBareDestination(line, cursor) + if (!read) return null + + return { + destination: line.slice(cursor, read.destinationEnd), + destinationStart: cursor, + destinationEnd: read.destinationEnd, + endParen: read.endParen, + } +} + +function getCodeFenceMarker(line: string): '`' | '~' | null { + const match = line.match(/^\s*(`{3,}|~{3,})/) + const marker = match?.[1]?.[0] + return marker === '`' || marker === '~' ? marker : null +} + +function rewriteMarkdownDestination({ + context, + destination, +}: { + context: MarkdownDestinationRewriteContext + destination: string +}): string { + if (isExternalOrAbsoluteDestination(destination)) return destination + + const { pathPart, suffix } = splitDestinationSuffix(destination) + if (isExternalOrAbsoluteDestination(pathPart)) return destination + + const resolvedDestinationPath = resolve(context.skillDir, pathPart) + const relativeToPackageRoot = relative( + context.resolvedPackageRoot, + resolvedDestinationPath, + ) + if ( + relativeToPackageRoot.startsWith('..') || + isAbsolute(relativeToPackageRoot) + ) { + return destination + } + + const relativeToCwd = relative(context.cwd, resolvedDestinationPath) + const rewrittenPath = + relativeToCwd && + !relativeToCwd.startsWith('..') && + !isAbsolute(relativeToCwd) + ? relativeToCwd + : resolvedDestinationPath + + return `${toPosixPath(rewrittenPath)}${suffix}` +} + +function rewriteMarkdownLineDestinations({ + context, + line, +}: { + context: MarkdownDestinationRewriteContext + line: string +}): string { + if (!line.includes('[')) return line + + let output = '' + let cursor = 0 + + while (cursor < line.length) { + const nextCodeStart = line.indexOf('`', cursor) + const nextLinkStart = line.indexOf('[', cursor) + + if (nextLinkStart === -1) { + output += line.slice(cursor) + break + } + + if (nextCodeStart !== -1 && nextCodeStart < nextLinkStart) { + output += line.slice(cursor, nextCodeStart) + cursor = nextCodeStart + const codeStart = cursor + while (cursor < line.length && line[cursor] === '`') cursor++ + const marker = line.slice(codeStart, cursor) + const codeEnd = line.indexOf(marker, cursor) + if (codeEnd === -1) { + output += line.slice(codeStart) + break + } + output += line.slice(codeStart, codeEnd + marker.length) + cursor = codeEnd + marker.length + continue + } + + const linkStart = + nextLinkStart > 0 && line[nextLinkStart - 1] === '!' + ? nextLinkStart - 1 + : nextLinkStart + output += line.slice(cursor, linkStart) + + const labelStart = nextLinkStart + const labelEnd = findClosingBracket(line, labelStart) + if (labelEnd === -1) { + output += line.slice(linkStart) + break + } + + if (line[labelEnd + 1] !== '(') { + output += line.slice(linkStart, nextLinkStart + 1) + cursor = nextLinkStart + 1 + continue + } + + const destination = readMarkdownDestination(line, labelEnd + 2) + if (!destination) { + output += line.slice(linkStart, nextLinkStart + 1) + cursor = nextLinkStart + 1 + continue + } + + const rewritten = rewriteMarkdownDestination({ + context, + destination: destination.destination, + }) + output += + line.slice(linkStart, destination.destinationStart) + + rewritten + + line.slice(destination.destinationEnd, destination.endParen + 1) + cursor = destination.endParen + 1 + } + + return output +} + +function rewriteLoadedSkillMarkdownDestinations({ + content, + cwd, + packageRoot, + skillFilePath, +}: { + content: string + cwd: string + packageRoot: string + skillFilePath: string +}): string { + const context: MarkdownDestinationRewriteContext = { + cwd, + resolvedPackageRoot: resolveFromCwd(packageRoot), + skillDir: dirname(skillFilePath), + } + let inFence: '`' | '~' | null = null + const parts = content.split(/(\r?\n)/) + let output = '' + + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index] ?? '' + const newline = parts[index + 1] ?? '' + const marker = getCodeFenceMarker(line) + + if (inFence) { + output += line + newline + if (marker === inFence) inFence = null + continue + } + + if (marker) { + inFence = marker + output += line + newline + continue + } + + output += + rewriteMarkdownLineDestinations({ + context, + line, + }) + newline + } + + return output +} + +export function loadIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): LoadedIntentSkill { + return withCwd(options.cwd, () => { + try { + parseSkillUse(use) + } catch (err) { + throw new IntentCoreError( + 'invalid-skill-use', + err instanceof Error ? err.message : String(err), + ) + } + + const scanResult = scanForIntents(undefined, toScanOptions(options)) + let resolved: ReturnType + try { + resolved = resolveSkillUse(use, scanResult) + } catch (err) { + if (err instanceof ResolveSkillUseError) { + throw new IntentCoreError(err.code, err.message) + } + throw err + } + const resolvedPath = resolveFromCwd(resolved.path) + + if (!isPathInsidePackageRoot(resolved.path, resolved.packageRoot)) { + throw new IntentCoreError( + 'skill-path-outside-package', + `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, + ) + } + + if (!existsSync(resolvedPath)) { + throw new IntentCoreError( + 'skill-file-not-found', + `Resolved skill file was not found: ${resolved.path}`, + ) + } + + const content = rewriteLoadedSkillMarkdownDestinations({ + content: readFileSync(resolvedPath, 'utf8'), + cwd: process.cwd(), + packageRoot: resolved.packageRoot, + skillFilePath: resolvedPath, + }) + + return { + content, + path: resolved.path, + packageRoot: resolved.packageRoot, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + warnings: resolved.warnings, + conflict: resolved.conflict, + } + }) +} diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts new file mode 100644 index 0000000..37c1494 --- /dev/null +++ b/packages/intent/tests/core.test.ts @@ -0,0 +1,217 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + IntentCoreError, + listIntentSkills, + loadIntentSkill, +} from '../src/core.js' + +const realTmpdir = realpathSync(tmpdir()) + +function writeJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +function writeSkillMd({ + content = 'Skill content here.', + dir, + frontmatter, +}: { + content?: string + dir: string + frontmatter: Record +}): void { + mkdirSync(dir, { recursive: true }) + const yamlLines = Object.entries(frontmatter) + .map( + ([key, value]) => + `${key}: ${typeof value === 'string' ? `"${value}"` : value}`, + ) + .join('\n') + + writeFileSync(join(dir, 'SKILL.md'), `---\n${yamlLines}\n---\n\n${content}\n`) +} + +function writeInstalledIntentPackage( + root: string, + { + description, + framework, + name, + skillName, + type, + version, + }: { + description: string + framework?: string + name: string + skillName: string + type?: string + version: string + }, +): void { + const pkgDir = join(root, 'node_modules', ...name.split('/')) + writeJson(join(pkgDir, 'package.json'), { + name, + version, + intent: { version: 1, repo: 'TanStack/test', docs: 'docs/' }, + }) + writeSkillMd({ + dir: join(pkgDir, 'skills', skillName), + frontmatter: { + name: skillName, + description, + ...(type ? { type } : {}), + ...(framework ? { framework } : {}), + }, + }) +} + +let root: string +let originalCwd: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(realTmpdir, 'intent-core-test-'))) + originalCwd = process.cwd() +}) + +afterEach(() => { + process.chdir(originalCwd) + rmSync(root, { recursive: true, force: true }) +}) + +describe('listIntentSkills', () => { + it('returns a flat skill list and package summaries', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + type: 'skill', + framework: 'react', + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result).toEqual({ + skills: [ + { + use: '@tanstack/query#fetching', + packageName: '@tanstack/query', + packageVersion: '5.0.0', + packageSource: 'local', + skillName: 'fetching', + description: 'Query data fetching patterns', + type: 'skill', + framework: 'react', + }, + ], + packages: [ + { + name: '@tanstack/query', + version: '5.0.0', + source: 'local', + skillCount: 1, + }, + ], + warnings: [], + conflicts: [], + }) + }) +}) + +describe('loadIntentSkill', () => { + it('loads skill content with package metadata', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + + expect(result).toEqual({ + content: expect.stringContaining('Skill content here.'), + path: 'node_modules/@tanstack/query/skills/fetching/SKILL.md', + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), + packageName: '@tanstack/query', + skillName: 'fetching', + version: '5.0.0', + source: 'local', + warnings: [], + conflict: null, + }) + }) + + it('rewrites relative markdown destinations in loaded content', () => { + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(pkgDir, 'skills', 'fetching') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd({ + dir: skillDir, + frontmatter: { + name: 'fetching', + description: 'Query data fetching patterns', + }, + content: [ + '- [Reference](references/topic.md)', + '- ![Diagram](assets/diagram.png)', + '- [Parent](../shared.md#setup)', + '- [External](https://example.com/reference.md)', + '- `inline [Code](references/code.md)`', + '```md', + '[Fenced](references/fenced.md)', + '```', + ].join('\n'), + }) + + const result = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + + expect(result.content).toContain( + '[Reference](node_modules/@tanstack/query/skills/fetching/references/topic.md)', + ) + expect(result.content).toContain( + '![Diagram](node_modules/@tanstack/query/skills/fetching/assets/diagram.png)', + ) + expect(result.content).toContain( + '[Parent](node_modules/@tanstack/query/skills/shared.md#setup)', + ) + expect(result.content).toContain( + '[External](https://example.com/reference.md)', + ) + expect(result.content).toContain('`inline [Code](references/code.md)`') + expect(result.content).toContain('[Fenced](references/fenced.md)') + }) + + it('fails clearly when the requested skill is missing', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + expect(() => + loadIntentSkill('@tanstack/query#mutations', { cwd: root }), + ).toThrow(IntentCoreError) + expect(() => + loadIntentSkill('@tanstack/query#mutations', { cwd: root }), + ).toThrow( + 'Cannot resolve skill use "@tanstack/query#mutations": skill "mutations" was not found in package "@tanstack/query".', + ) + }) +}) From df398890b91b653a412bdd1ee87a35e59a169077 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 22:17:12 -0700 Subject: [PATCH 03/20] route list and load through core --- docs/cli/intent-list.md | 63 ++--- packages/intent/package.json | 2 +- packages/intent/src/cli-support.ts | 14 + packages/intent/src/commands/list.ts | 75 +++--- packages/intent/src/commands/load.ts | 385 ++------------------------- packages/intent/tests/cli.test.ts | 47 ++-- 6 files changed, 128 insertions(+), 458 deletions(-) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 551ce76..f8b425d 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -21,43 +21,38 @@ npx @tanstack/intent@latest list [--json] [--global] [--global-only] - Includes global packages only when `--global` or `--global-only` is passed - 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`, `SOURCE`, `VERSION`, `SKILLS`, `REQUIRES` +- Summary line with package count and skill count +- Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS` - Skill tree grouped by package - Optional warnings section (`⚠ ...` per warning) -`REQUIRES` uses `intent.requires` values joined by a comma and space; empty values render as `–`. `SOURCE` is a lightweight indicator showing whether the selected package came from local discovery or explicit global scanning. When both local and global packages are scanned, local packages take precedence. ## JSON output -`--json` prints the `ScanResult` object: +`--json` prints an adapter-friendly skill list: ```json { - "packageManager": "npm | pnpm | yarn | bun | unknown", + "skills": [ + { + "use": "@tanstack/query#fetching", + "packageName": "@tanstack/query", + "packageVersion": "5.0.0", + "packageSource": "local", + "skillName": "fetching", + "description": "Query data fetching patterns", + "type": "skill (optional)", + "framework": "react (optional)" + } + ], "packages": [ { - "name": "string", - "version": "string", - "source": "local | global", - "packageRoot": "string", - "intent": { - "version": 1, - "repo": "string", - "docs": "string", - "requires": ["string"] - }, - "skills": [ - { - "name": "string", - "path": "string", - "description": "string", - "type": "string (optional)", - "framework": "string (optional)" - } - ] + "name": "@tanstack/query", + "version": "5.0.0", + "source": "local", + "skillCount": 1 } ], "warnings": ["string"], @@ -75,28 +70,12 @@ When both local and global packages are scanned, local packages take precedence. } ] } - ], - "nodeModules": { - "local": { - "path": "string | null", - "detected": true, - "exists": true, - "scanned": true - }, - "global": { - "path": "string | null", - "detected": true, - "exists": true, - "scanned": false, - "source": "string (optional)" - } - } + ] } ``` -`packages` are ordered using `intent.requires` when possible. When the same package exists both locally and globally and global scanning is enabled, `intent list` prefers the local package. -When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without `node_modules`, `intent list` uses Yarn's PnP API. +When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without usable `node_modules`, `intent list` uses Yarn's PnP API. ## Common errors diff --git a/packages/intent/package.json b/packages/intent/package.json index 889f013..521d59d 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -42,7 +42,7 @@ "scripts": { "prepack": "npm run build", "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts src/core.ts --format esm --dts", - "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null", + "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null && node dist/cli.mjs load --help > /dev/null", "test:lib": "vitest run --exclude 'tests/integration/**'", "test:integration": "vitest run tests/integration/", "test:types": "tsc --noEmit" diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 7b5e962..fcae4eb 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -3,6 +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 { IntentCoreOptions } from './core.js' import type { ScanOptions, ScanResult, StalenessReport } from './types.js' export { printWarnings } from './cli-output.js' @@ -74,6 +75,19 @@ export function scanOptionsFromGlobalFlags( return { scope: 'local' } } +export function coreOptionsFromGlobalFlags( + options: GlobalScanFlags, +): IntentCoreOptions { + if (options.global && options.globalOnly) { + fail('Use either --global or --global-only, not both.') + } + + return { + global: options.global, + globalOnly: options.globalOnly, + } +} + export async function resolveStaleTargets( targetDir?: string, ): Promise { diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index dff67ce..03a73fd 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,24 +1,21 @@ import { + coreOptionsFromGlobalFlags, printWarnings, - scanOptionsFromGlobalFlags, type GlobalScanFlags, } from '../cli-support.js' +import { listIntentSkills } from '../core.js' +import type { + IntentPackageSummary, + IntentSkillList, + IntentSkillSummary, +} from '../core.js' import type { ScanOptions, ScanResult } from '../types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean } -function formatScanCoverage(result: ScanResult): string { - const coverage: Array = [] - - if (result.nodeModules.local.scanned) coverage.push('project node_modules') - if (result.nodeModules.global.scanned) coverage.push('global node_modules') - - return coverage.join(', ') -} - -function printVersionConflicts(result: ScanResult): void { +function printVersionConflicts(result: IntentSkillList): void { if (result.conflicts.length === 0) return console.log('\nVersion conflicts:\n') @@ -37,11 +34,23 @@ function printVersionConflicts(result: ScanResult): void { } } +function getPackageSkills( + pkg: IntentPackageSummary, + result: IntentSkillList, +): Array { + return result.skills.filter( + (skill) => + skill.packageName === pkg.name && + skill.packageVersion === pkg.version && + skill.packageSource === pkg.source, + ) +} + export async function runListCommand( options: ListCommandOptions, - scanIntentsOrFail: (options?: ScanOptions) => Promise, + _scanIntentsOrFail?: (options?: ScanOptions) => Promise, ): Promise { - const result = await scanIntentsOrFail(scanOptionsFromGlobalFlags(options)) + const result = listIntentSkills(coreOptionsFromGlobalFlags(options)) if (options.json) { console.log(JSON.stringify(result, null, 2)) @@ -51,11 +60,8 @@ export async function runListCommand( const { computeSkillNameWidth, printSkillTree, printTable } = await import('../display.js') - const scanCoverage = formatScanCoverage(result) - if (result.packages.length === 0) { console.log('No intent-enabled packages found.') - if (scanCoverage) console.log(`Scanned: ${scanCoverage}`) if (result.warnings.length > 0) { console.log() printWarnings(result.warnings) @@ -63,40 +69,41 @@ export async function runListCommand( return } - const totalSkills = result.packages.reduce( - (sum, pkg) => sum + pkg.skills.length, - 0, - ) console.log( - `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills (${result.packageManager})\n`, + `\n${result.packages.length} intent-enabled packages, ${result.skills.length} skills\n`, ) - if (scanCoverage) { - console.log( - `Scanned: ${scanCoverage}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, - ) - } const rows = result.packages.map((pkg) => [ pkg.name, pkg.source, pkg.version, - String(pkg.skills.length), - pkg.intent.requires?.join(', ') || '–', + String(pkg.skillCount), ]) - printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) + printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS'], rows) printVersionConflicts(result) - const allSkills = result.packages.map((pkg) => pkg.skills) - const nameWidth = computeSkillNameWidth(allSkills) - const showTypes = result.packages.some((pkg) => - pkg.skills.some((skill) => skill.type), + const allSkills = result.packages.map((pkg) => + getPackageSkills(pkg, result).map((skill) => ({ + name: skill.skillName, + description: skill.description, + type: skill.type, + })), ) + const nameWidth = computeSkillNameWidth(allSkills) + const showTypes = result.skills.some((skill) => skill.type) console.log(`\nSkills:\n`) for (const pkg of result.packages) { console.log(` ${pkg.name}`) - printSkillTree(pkg.skills, { nameWidth, packageName: pkg.name, showTypes }) + printSkillTree( + getPackageSkills(pkg, result).map((skill) => ({ + name: skill.skillName, + description: skill.description, + type: skill.type, + })), + { nameWidth, packageName: pkg.name, showTypes }, + ) console.log() } diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index f76001d..0e2787f 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,10 +1,6 @@ -import { existsSync, readFileSync } from 'node:fs' -import { dirname, isAbsolute, relative, resolve } from 'node:path' import { fail } from '../cli-error.js' -import { scanOptionsFromGlobalFlags } from '../cli-support.js' -import { resolveSkillUse } from '../resolver.js' -import { parseSkillUse } from '../skill-use.js' -import { toPosixPath } from '../utils.js' +import { coreOptionsFromGlobalFlags } from '../cli-support.js' +import { IntentCoreError, loadIntentSkill } from '../core.js' import type { GlobalScanFlags } from '../cli-support.js' import type { ScanOptions, ScanResult } from '../types.js' @@ -13,333 +9,10 @@ export interface LoadCommandOptions extends GlobalScanFlags { path?: boolean } -function resolveFromCwd(path: string): string { - return resolve(process.cwd(), path) -} - -function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { - const relativePath = relative( - resolveFromCwd(packageRoot), - resolveFromCwd(path), - ) - return ( - relativePath === '' || - (!relativePath.startsWith('..') && !isAbsolute(relativePath)) - ) -} - -function splitDestinationSuffix(destination: string): { - pathPart: string - suffix: string -} { - const hashIndex = destination.indexOf('#') - const queryIndex = destination.indexOf('?') - const suffixIndex = - hashIndex === -1 - ? queryIndex - : queryIndex === -1 - ? hashIndex - : Math.min(hashIndex, queryIndex) - - if (suffixIndex === -1) { - return { pathPart: destination, suffix: '' } - } - - return { - pathPart: destination.slice(0, suffixIndex), - suffix: destination.slice(suffixIndex), - } -} - -function isExternalOrAbsoluteDestination(destination: string): boolean { - return ( - destination === '' || - destination.startsWith('#') || - destination.startsWith('?') || - destination.startsWith('//') || - /^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) || - isAbsolute(destination) - ) -} - -interface MarkdownDestinationRewriteContext { - cwd: string - resolvedPackageRoot: string - skillDir: string -} - -function findClosingBracket(line: string, start: number): number { - let depth = 0 - - for (let index = start; index < line.length; index++) { - const char = line[index]! - if (char === '\\') { - index++ - continue - } - if (char === '[') { - depth++ - continue - } - if (char === ']') { - depth-- - if (depth === 0) return index - } - } - - return -1 -} - -function findClosingParen(line: string, start: number): number { - for (let index = start; index < line.length; index++) { - const char = line[index]! - if (char === '\\') { - index++ - continue - } - if (char === ')') return index - } - - return -1 -} - -function readBareDestination( - line: string, - start: number, -): { destinationEnd: number; endParen: number } | null { - let depth = 0 - - for (let index = start; index < line.length; index++) { - const char = line[index]! - if (char === '\\') { - index++ - continue - } - if (char === '(') { - depth++ - continue - } - if (char === ')') { - if (depth === 0) { - return { destinationEnd: index, endParen: index } - } - depth-- - continue - } - if (/\s/.test(char) && depth === 0) { - const endParen = findClosingParen(line, index) - if (endParen === -1) return null - return { destinationEnd: index, endParen } - } - } - - return null -} - -function readMarkdownDestination( - line: string, - start: number, -): { - destination: string - destinationStart: number - destinationEnd: number - endParen: number -} | null { - let cursor = start - while (cursor < line.length && /\s/.test(line[cursor]!)) cursor++ - - if (line[cursor] === '<') { - const destinationStart = cursor + 1 - const destinationEnd = line.indexOf('>', destinationStart) - if (destinationEnd === -1) return null - const endParen = findClosingParen(line, destinationEnd + 1) - if (endParen === -1) return null - return { - destination: line.slice(destinationStart, destinationEnd), - destinationStart, - destinationEnd, - endParen, - } - } - - const read = readBareDestination(line, cursor) - if (!read) return null - - return { - destination: line.slice(cursor, read.destinationEnd), - destinationStart: cursor, - destinationEnd: read.destinationEnd, - endParen: read.endParen, - } -} - -function getCodeFenceMarker(line: string): '`' | '~' | null { - const match = line.match(/^\s*(`{3,}|~{3,})/) - const marker = match?.[1]?.[0] - return marker === '`' || marker === '~' ? marker : null -} - -function rewriteMarkdownDestination({ - context, - destination, -}: { - context: MarkdownDestinationRewriteContext - destination: string -}): string { - if (isExternalOrAbsoluteDestination(destination)) return destination - - const { pathPart, suffix } = splitDestinationSuffix(destination) - if (isExternalOrAbsoluteDestination(pathPart)) return destination - - const resolvedDestinationPath = resolve(context.skillDir, pathPart) - const relativeToPackageRoot = relative( - context.resolvedPackageRoot, - resolvedDestinationPath, - ) - if ( - relativeToPackageRoot.startsWith('..') || - isAbsolute(relativeToPackageRoot) - ) { - return destination - } - - const relativeToCwd = relative(context.cwd, resolvedDestinationPath) - const rewrittenPath = - relativeToCwd && - !relativeToCwd.startsWith('..') && - !isAbsolute(relativeToCwd) - ? relativeToCwd - : resolvedDestinationPath - - return `${toPosixPath(rewrittenPath)}${suffix}` -} - -function rewriteMarkdownLineDestinations({ - context, - line, -}: { - context: MarkdownDestinationRewriteContext - line: string -}): string { - if (!line.includes('[')) return line - - let output = '' - let cursor = 0 - - while (cursor < line.length) { - const nextCodeStart = line.indexOf('`', cursor) - const nextLinkStart = line.indexOf('[', cursor) - - if (nextLinkStart === -1) { - output += line.slice(cursor) - break - } - - if (nextCodeStart !== -1 && nextCodeStart < nextLinkStart) { - output += line.slice(cursor, nextCodeStart) - cursor = nextCodeStart - const codeStart = cursor - while (cursor < line.length && line[cursor] === '`') cursor++ - const marker = line.slice(codeStart, cursor) - const codeEnd = line.indexOf(marker, cursor) - if (codeEnd === -1) { - output += line.slice(codeStart) - break - } - output += line.slice(codeStart, codeEnd + marker.length) - cursor = codeEnd + marker.length - continue - } - - const linkStart = - nextLinkStart > 0 && line[nextLinkStart - 1] === '!' - ? nextLinkStart - 1 - : nextLinkStart - output += line.slice(cursor, linkStart) - - const labelStart = nextLinkStart - const labelEnd = findClosingBracket(line, labelStart) - if (labelEnd === -1) { - output += line.slice(linkStart) - break - } - - if (line[labelEnd + 1] !== '(') { - output += line.slice(linkStart, nextLinkStart + 1) - cursor = nextLinkStart + 1 - continue - } - - const destination = readMarkdownDestination(line, labelEnd + 2) - if (!destination) { - output += line.slice(linkStart, nextLinkStart + 1) - cursor = nextLinkStart + 1 - continue - } - - const rewritten = rewriteMarkdownDestination({ - context, - destination: destination.destination, - }) - output += - line.slice(linkStart, destination.destinationStart) + - rewritten + - line.slice(destination.destinationEnd, destination.endParen + 1) - cursor = destination.endParen + 1 - } - - return output -} - -function rewriteLoadedSkillMarkdownDestinations({ - content, - packageRoot, - skillFilePath, -}: { - content: string - packageRoot: string - skillFilePath: string -}): string { - const context: MarkdownDestinationRewriteContext = { - cwd: process.cwd(), - resolvedPackageRoot: resolveFromCwd(packageRoot), - skillDir: dirname(skillFilePath), - } - let inFence: '`' | '~' | null = null - const parts = content.split(/(\r?\n)/) - let output = '' - - for (let index = 0; index < parts.length; index += 2) { - const line = parts[index] ?? '' - const newline = parts[index + 1] ?? '' - const marker = getCodeFenceMarker(line) - - if (inFence) { - output += line + newline - if (marker === inFence) inFence = null - continue - } - - if (marker) { - inFence = marker - output += line + newline - continue - } - - output += - rewriteMarkdownLineDestinations({ - context, - line, - }) + newline - } - - return output -} - export async function runLoadCommand( use: string | undefined, options: LoadCommandOptions, - scanIntentsOrFail: (options?: ScanOptions) => Promise, + _scanIntentsOrFail?: (options?: ScanOptions) => Promise, ): Promise { if (!use) { fail('Missing skill use. Expected: intent load #') @@ -349,48 +22,36 @@ export async function runLoadCommand( fail('Use either --json or --path, not both.') } - parseSkillUse(use) - - const result = await scanIntentsOrFail(scanOptionsFromGlobalFlags(options)) - const resolved = resolveSkillUse(use, result) - const resolvedPath = resolveFromCwd(resolved.path) - - if (!isPathInsidePackageRoot(resolved.path, resolved.packageRoot)) { - fail( - `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, - ) - } - - if (!existsSync(resolvedPath)) { - fail(`Resolved skill file was not found: ${resolved.path}`) + let loaded: ReturnType + try { + loaded = loadIntentSkill(use, coreOptionsFromGlobalFlags(options)) + } catch (err) { + if (err instanceof IntentCoreError) { + fail(err.message) + } + throw err } if (options.path) { - console.log(resolved.path) - for (const warning of resolved.warnings) { + console.log(loaded.path) + for (const warning of loaded.warnings) { console.error(`Warning: ${warning}`) } return } - const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolvedPath, 'utf8'), - packageRoot: resolved.packageRoot, - skillFilePath: resolvedPath, - }) - if (options.json) { console.log( JSON.stringify( { - package: resolved.packageName, - skill: resolved.skillName, - path: resolved.path, - packageRoot: resolved.packageRoot, - source: resolved.source, - version: resolved.version, - content, - warnings: resolved.warnings, + package: loaded.packageName, + skill: loaded.skillName, + path: loaded.path, + packageRoot: loaded.packageRoot, + source: loaded.source, + version: loaded.version, + content: loaded.content, + warnings: loaded.warnings, }, null, 2, @@ -399,9 +60,9 @@ export async function runLoadCommand( return } - process.stdout.write(content) + process.stdout.write(loaded.content) - for (const warning of resolved.warnings) { + for (const warning of loaded.warnings) { console.error(`Warning: ${warning}`) } } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 425859c..9121121 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -549,9 +549,10 @@ describe('cli commands', () => { packages: Array<{ name: string version: string - packageRoot: string source: 'local' | 'global' + skillCount: number }> + skills: Array<{ use: string; packageName: string; skillName: string }> conflicts: Array<{ packageName: string }> warnings: Array } @@ -561,9 +562,16 @@ describe('cli commands', () => { expect(parsed.packages[0]).toMatchObject({ name: '@tanstack/db', version: '0.5.2', - packageRoot: pkgDir, source: 'local', + skillCount: 1, }) + expect(parsed.skills).toEqual([ + expect.objectContaining({ + use: '@tanstack/db#db-core', + packageName: '@tanstack/db', + skillName: 'db-core', + }), + ]) expect(parsed.conflicts).toEqual([]) expect(parsed.warnings).toEqual([]) }) @@ -592,12 +600,10 @@ describe('cli commands', () => { const exitCode = await main(['list', '--json']) const output = logSpy.mock.calls.at(-1)?.[0] const parsed = JSON.parse(String(output)) as { - nodeModules: { global: { scanned: boolean } } packages: Array<{ name: string }> } expect(exitCode).toBe(0) - expect(parsed.nodeModules.global.scanned).toBe(false) expect(parsed.packages).toEqual([]) }) @@ -628,9 +634,15 @@ describe('cli commands', () => { packages: Array<{ name: string version: string - packageRoot: string source: 'local' | 'global' - skills: Array<{ path: string }> + skillCount: number + }> + skills: Array<{ + packageName: string + packageSource: 'local' | 'global' + packageVersion: string + skillName: string + use: string }> } @@ -639,12 +651,16 @@ describe('cli commands', () => { expect(parsed.packages[0]).toMatchObject({ name: '@tanstack/query', version: '5.0.0', - packageRoot: globalPkgDir, source: 'global', + skillCount: 1, + }) + expect(parsed.skills[0]).toMatchObject({ + packageName: '@tanstack/query', + packageSource: 'global', + packageVersion: '5.0.0', + skillName: 'fetching', + use: '@tanstack/query#fetching', }) - expect(parsed.packages[0]!.skills[0]!.path).toBe( - join(globalPkgDir, 'skills', 'fetching', 'SKILL.md'), - ) }) it('does not print absolute global skill paths in global list output', async () => { @@ -673,9 +689,6 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Global fetching skill') - expect(output).toContain( - 'Lookup: Runtime lookup only: run `npx @tanstack/intent@latest load @tanstack/query#fetching --path`, and load its reported path for this session. Do not copy the resolved path into this file.', - ) expect(output).not.toContain(globalPkgDir) }) @@ -762,25 +775,21 @@ describe('cli commands', () => { const exitCode = await main(['list', '--global-only', '--json']) const output = logSpy.mock.calls.at(-1)?.[0] const parsed = JSON.parse(String(output)) as { - nodeModules: { - global: { scanned: boolean } - local: { scanned: boolean } - } packages: Array<{ name: string source: 'local' | 'global' version: string + skillCount: number }> } expect(exitCode).toBe(0) - expect(parsed.nodeModules.local.scanned).toBe(false) - expect(parsed.nodeModules.global.scanned).toBe(true) expect(parsed.packages).toHaveLength(1) expect(parsed.packages[0]).toMatchObject({ name: '@tanstack/query', source: 'global', version: '4.0.0', + skillCount: 1, }) }) From 8b6195348eec35253a9026b5bec70c061d948608 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 22:27:07 -0700 Subject: [PATCH 04/20] add hard package excludes --- docs/cli/intent-list.md | 19 ++++- docs/cli/intent-load.md | 5 +- packages/intent/src/cli-support.ts | 6 ++ packages/intent/src/cli.ts | 8 +- packages/intent/src/core.ts | 131 +++++++++++++++++++++++++++-- packages/intent/tests/cli.test.ts | 63 ++++++++++++++ packages/intent/tests/core.test.ts | 85 +++++++++++++++++++ 7 files changed, 307 insertions(+), 10 deletions(-) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index f8b425d..ba1cce0 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -6,12 +6,13 @@ id: intent-list `intent list` discovers skill-enabled packages and prints available skills. ```bash -npx @tanstack/intent@latest list [--json] [--global] [--global-only] +npx @tanstack/intent@latest list [--json] [--exclude ] [--global] [--global-only] ``` ## Options - `--json`: print JSON instead of text output +- `--exclude `: exclude package names matching a simple glob; can be passed more than once - `--global`: include global packages after project packages - `--global-only`: list global packages only @@ -20,6 +21,7 @@ npx @tanstack/intent@latest list [--json] [--global] [--global-only] - Scans project and workspace dependencies for intent-enabled packages and skills - Includes global packages only when `--global` or `--global-only` is passed - Includes warnings from discovery +- Excludes packages matched by package.json `intent.exclude` or `--exclude` - If no packages are discovered, prints `No intent-enabled packages found.` - Summary line with package count and skill count - Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS` @@ -77,6 +79,21 @@ When both local and global packages are scanned, local packages take precedence. When the same package exists both locally and globally and global scanning is enabled, `intent list` prefers the local package. When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without usable `node_modules`, `intent list` uses Yarn's PnP API. +## Excludes + +Package excludes are hard filters for packages that should not be used in a repo. +Intent reads `intent.exclude` arrays from package.json files while walking from the workspace or project root to the current working directory, then appends any `--exclude` flags. + +```json +{ + "intent": { + "exclude": ["@tanstack/*devtools*"] + } +} +``` + +Exclude patterns match full package names. In v1, only exact names and `*` wildcards are supported. + ## Common errors - Scanner failures are printed as errors diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 8119d1c..1ff5504 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -6,13 +6,14 @@ id: intent-load `intent load` loads a compact skill identity from the current install and prints the matching `SKILL.md` content. ```bash -npx @tanstack/intent@latest load # [--path] [--json] [--global] [--global-only] +npx @tanstack/intent@latest load # [--path] [--json] [--exclude ] [--global] [--global-only] ``` ## Options - `--path`: print the resolved skill path instead of the file content - `--json`: print structured JSON with metadata and content +- `--exclude `: exclude package names matching a simple glob; can be passed more than once - `--global`: load from project packages first, then global packages - `--global-only`: load from global packages only @@ -21,6 +22,7 @@ npx @tanstack/intent@latest load # [--path] [--json] [--global] - Validates `#` before scanning - Scans project-local packages by default - Includes global packages only when `--global` or `--global-only` is passed +- Fails before scanning when the target package matches package.json `intent.exclude` or `--exclude` - Prefers local packages when `--global` is used and the same package exists locally and globally - Prints raw `SKILL.md` content by default - Prints the scanner-reported path when `--path` is passed @@ -59,6 +61,7 @@ npx @tanstack/intent@latest load some-lib#core --path - Empty skill: `Invalid skill use "@tanstack/query#": skill is required.` - Missing package: `Cannot resolve skill use "...": package "..." was not found.` - Missing skill: `Cannot resolve skill use "...": skill "..." was not found in package "...".` +- Excluded package: `Cannot load skill use "...": package "..." is excluded by Intent configuration.` ## Related diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index fcae4eb..a55f42c 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -9,6 +9,7 @@ import type { ScanOptions, ScanResult, StalenessReport } from './types.js' export { printWarnings } from './cli-output.js' export interface GlobalScanFlags { + exclude?: string | Array global?: boolean globalOnly?: boolean } @@ -83,6 +84,11 @@ export function coreOptionsFromGlobalFlags( } return { + exclude: Array.isArray(options.exclude) + ? options.exclude + : options.exclude + ? [options.exclude] + : undefined, global: options.global, globalOnly: options.globalOnly, } diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 98f8ada..a2105e1 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -34,8 +34,9 @@ function createCli(): CAC { 'list', 'Discover intent-enabled packages from the project or workspace', ) - .usage('list [--json] [--global] [--global-only]') + .usage('list [--json] [--exclude ] [--global] [--global-only]') .option('--json', 'Output JSON') + .option('--exclude ', 'Exclude package name glob') .option('--global', 'Include global packages after project packages') .option('--global-only', 'List global packages only') .example('list') @@ -47,9 +48,12 @@ function createCli(): CAC { cli .command('load [use]', 'Load a compact skill use and print its SKILL.md') - .usage('load [--path] [--json] [--global] [--global-only]') + .usage( + 'load [--path] [--json] [--exclude ] [--global] [--global-only]', + ) .option('--path', 'Print the resolved skill path instead of file content') .option('--json', 'Output JSON') + .option('--exclude ', 'Exclude package name glob') .option('--global', 'Load from project packages, then global packages') .option('--global-only', 'Load from global packages only') .example('load @tanstack/query#core') diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 6e2da2b..2da497b 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { resolveProjectContext } from './core/project-context.js' import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' import { formatSkillUse, parseSkillUse } from './skill-use.js' import { scanForIntents } from './scanner.js' @@ -10,6 +11,7 @@ export interface IntentCoreOptions { cwd?: string global?: boolean globalOnly?: boolean + exclude?: Array } export interface IntentSkillSummary { @@ -54,6 +56,7 @@ export class IntentCoreError extends Error { | 'invalid-options' | 'invalid-skill-use' | 'package-not-found' + | 'package-excluded' | 'skill-not-found' | 'skill-path-outside-package' | 'skill-file-not-found' @@ -65,6 +68,15 @@ export class IntentCoreError extends Error { } } +function normalizeExcludePatterns(value: unknown): Array { + if (!Array.isArray(value)) return [] + + return value + .filter((pattern): pattern is string => typeof pattern === 'string') + .map((pattern) => pattern.trim()) + .filter(Boolean) +} + function toScanOptions(options: IntentCoreOptions): ScanOptions { if (options.global && options.globalOnly) { throw new IntentCoreError( @@ -96,12 +108,99 @@ function withCwd(cwd: string | undefined, callback: () => T): T { } } +function isWithinOrEqual(path: string, parentDir: string): boolean { + const rel = relative(parentDir, path) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +function readPackageJson(dir: string): Record | null { + try { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record< + string, + unknown + > + } catch { + return null + } +} + +function readPackageExcludes(dir: string): Array { + const pkg = readPackageJson(dir) + const intent = pkg?.intent + if (!intent || typeof intent !== 'object') return [] + + return normalizeExcludePatterns((intent as Record).exclude) +} + +function getConfigExcludePatterns(cwd: string): Array { + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? cwd + const dirs: Array = [] + let dir = cwd + + while (isWithinOrEqual(dir, root)) { + dirs.push(dir) + if (dir === root) break + + const next = dirname(dir) + if (next === dir) break + dir = next + } + + return dirs.reverse().flatMap(readPackageExcludes) +} + +function getEffectiveExcludePatterns( + options: IntentCoreOptions, +): Array { + return [ + ...getConfigExcludePatterns(process.cwd()), + ...normalizeExcludePatterns(options.exclude), + ] +} + +function globToRegExp(pattern: string): RegExp { + const source = pattern + .split('*') + .map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')) + .join('.*') + return new RegExp(`^${source}$`) +} + +function matchesPackageGlob(packageName: string, pattern: string): boolean { + return pattern.includes('*') + ? globToRegExp(pattern).test(packageName) + : packageName === pattern +} + +function isPackageExcluded( + packageName: string, + patterns: Array, +): boolean { + return patterns.some((pattern) => matchesPackageGlob(packageName, pattern)) +} + +function warningMentionsPackage(warning: string, packageName: string): boolean { + const idx = warning.indexOf(packageName) + if (idx === -1) return false + + const after = warning[idx + packageName.length] + return after === undefined || /[^a-zA-Z0-9_-]/.test(after) +} + export function listIntentSkills( options: IntentCoreOptions = {}, ): IntentSkillList { return withCwd(options.cwd, () => { const scanResult = scanForIntents(undefined, toScanOptions(options)) - const skills = scanResult.packages.flatMap((pkg) => + const excludePatterns = getEffectiveExcludePatterns(options) + const excludedPackages = scanResult.packages + .filter((pkg) => isPackageExcluded(pkg.name, excludePatterns)) + .map((pkg) => pkg.name) + const packages = scanResult.packages.filter( + (pkg) => !isPackageExcluded(pkg.name, excludePatterns), + ) + const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { return { use: formatSkillUse(pkg.name, skill.name), @@ -118,14 +217,21 @@ export function listIntentSkills( return { skills, - packages: scanResult.packages.map((pkg) => ({ + packages: packages.map((pkg) => ({ name: pkg.name, version: pkg.version, source: pkg.source, skillCount: pkg.skills.length, })), - warnings: scanResult.warnings, - conflicts: scanResult.conflicts, + warnings: scanResult.warnings.filter( + (warning) => + !excludedPackages.some((packageName) => + warningMentionsPackage(warning, packageName), + ), + ), + conflicts: scanResult.conflicts.filter( + (conflict) => !isPackageExcluded(conflict.packageName, excludePatterns), + ), } }) } @@ -460,8 +566,9 @@ export function loadIntentSkill( options: IntentCoreOptions = {}, ): LoadedIntentSkill { return withCwd(options.cwd, () => { + let parsedUse: ReturnType try { - parseSkillUse(use) + parsedUse = parseSkillUse(use) } catch (err) { throw new IntentCoreError( 'invalid-skill-use', @@ -469,6 +576,18 @@ export function loadIntentSkill( ) } + if ( + isPackageExcluded( + parsedUse.packageName, + getEffectiveExcludePatterns(options), + ) + ) { + throw new IntentCoreError( + 'package-excluded', + `Cannot load skill use "${use}": package "${parsedUse.packageName}" is excluded by Intent configuration.`, + ) + } + const scanResult = scanForIntents(undefined, toScanOptions(options)) let resolved: ReturnType try { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 9121121..d323020 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -793,6 +793,45 @@ describe('cli commands', () => { }) }) + it('excludes packages from list output with --exclude', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-exclude-')) + tempDirs.push(root) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/devtools', + version: '1.0.0', + skillName: 'panel', + description: 'Devtools panel skill', + }) + + process.chdir(root) + + const exitCode = await main([ + 'list', + '--json', + '--exclude', + '@tanstack/*devtools*', + ]) + const output = logSpy.mock.calls.at(-1)?.[0] + const parsed = JSON.parse(String(output)) as { + packages: Array<{ name: string }> + skills: Array<{ use: string }> + } + + expect(exitCode).toBe(0) + expect(parsed.packages.map((pkg) => pkg.name)).toEqual([ + '@tanstack/query', + ]) + expect(parsed.skills.map((skill) => skill.use)).toEqual([ + '@tanstack/query#fetching', + ]) + }) + it('rejects --global and --global-only together on list', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-list-')) tempDirs.push(root) @@ -1154,6 +1193,30 @@ describe('cli commands', () => { ) }) + it('fails clearly when loading an excluded package', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-exclude-')) + tempDirs.push(root) + writeInstalledIntentPackage(root, { + name: '@tanstack/devtools', + version: '1.0.0', + skillName: 'panel', + description: 'Devtools panel skill', + }) + process.chdir(root) + + const exitCode = await main([ + 'load', + '@tanstack/devtools#panel', + '--exclude', + '@tanstack/*devtools*', + ]) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Cannot load skill use "@tanstack/devtools#panel": package "@tanstack/devtools" is excluded by Intent configuration.', + ) + }) + it('explains which package version was chosen when conflicts exist', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-')) tempDirs.push(root) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 37c1494..4acb0eb 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -127,6 +127,67 @@ describe('listIntentSkills', () => { conflicts: [], }) }) + + it('hides packages matched by configured exclude globs', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { exclude: ['@tanstack/*devtools*'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/devtools', + version: '1.0.0', + skillName: 'panel', + description: 'Devtools panel skill', + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual([ + '@tanstack/query', + ]) + expect(result.skills.map((skill) => skill.use)).toEqual([ + '@tanstack/query#fetching', + ]) + }) + + it('merges root, package, and option excludes', () => { + const appDir = join(root, 'packages', 'app') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + intent: { exclude: ['@scope/root-only'] }, + }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(appDir, 'package.json'), { + name: '@scope/app', + intent: { exclude: ['@scope/app-only'] }, + }) + + for (const packageName of [ + '@scope/root-only', + '@scope/app-only', + '@scope/option-only', + ]) { + expect(() => + loadIntentSkill(`${packageName}#core`, { + cwd: appDir, + exclude: ['@scope/option-only'], + }), + ).toThrow( + `Cannot load skill use "${packageName}#core": package "${packageName}" is excluded by Intent configuration.`, + ) + } + }) }) describe('loadIntentSkill', () => { @@ -214,4 +275,28 @@ describe('loadIntentSkill', () => { 'Cannot resolve skill use "@tanstack/query#mutations": skill "mutations" was not found in package "@tanstack/query".', ) }) + + it('fails clearly when the package is excluded', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/devtools', + version: '1.0.0', + skillName: 'panel', + description: 'Devtools panel skill', + }) + + expect(() => + loadIntentSkill('@tanstack/devtools#panel', { + cwd: root, + exclude: ['@tanstack/*devtools*'], + }), + ).toThrow(IntentCoreError) + expect(() => + loadIntentSkill('@tanstack/devtools#panel', { + cwd: root, + exclude: ['@tanstack/*devtools*'], + }), + ).toThrow( + 'Cannot load skill use "@tanstack/devtools#panel": package "@tanstack/devtools" is excluded by Intent configuration.', + ) + }) }) From 467e5e11ba486db0d9298a931f685f9fbbe46da6 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 22:49:16 -0700 Subject: [PATCH 05/20] fast-path load resolution --- packages/intent/src/core.ts | 264 +++++++++++++++++++++++++---- packages/intent/src/scanner.ts | 64 +++++++ packages/intent/tests/core.test.ts | 91 ++++++++++ 3 files changed, 382 insertions(+), 37 deletions(-) diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 2da497b..3dfb1c8 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,10 +1,15 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { resolveProjectContext } from './core/project-context.js' -import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' +import { + ResolveSkillUseError, + resolveSkillUse, + type ResolveSkillResult, +} from './resolver.js' import { formatSkillUse, parseSkillUse } from './skill-use.js' -import { scanForIntents } from './scanner.js' -import { toPosixPath } from './utils.js' +import { scanForIntents, scanIntentPackageAtRoot } from './scanner.js' +import { resolveWorkspacePackages } from './workspace-patterns.js' +import { getDeps, resolveDepDir, toPosixPath } from './utils.js' import type { IntentPackage, ScanOptions, VersionConflict } from './types.js' export interface IntentCoreOptions { @@ -188,6 +193,177 @@ function warningMentionsPackage(warning: string, packageName: string): boolean { return after === undefined || /[^a-zA-Z0-9_-]/.test(after) } +interface WorkspacePackageInfo { + dir: string + name: string | null + packageJson: Record +} + +function readWorkspacePackageInfos(cwd: string): Array { + const context = resolveProjectContext({ cwd }) + const dirs = new Set() + + if (context.packageRoot) { + dirs.add(context.packageRoot) + } + + if (context.workspaceRoot) { + dirs.add(context.workspaceRoot) + + for (const dir of resolveWorkspacePackages( + context.workspaceRoot, + context.workspacePatterns, + )) { + dirs.add(dir) + } + } + + return [...dirs].flatMap((dir) => { + const packageJson = readPackageJson(dir) + if (!packageJson) return [] + + return [ + { + dir, + name: typeof packageJson.name === 'string' ? packageJson.name : null, + packageJson, + }, + ] + }) +} + +function addCandidateDir( + candidates: Array, + seen: Set, + dir: string | null, +): void { + if (!dir) return + + const key = resolve(dir) + if (seen.has(key)) return + + seen.add(key) + candidates.push(dir) +} + +function findVisibleDependencyDir( + packageName: string, + fromDir: string, +): string | null { + let dir = fromDir + + while (true) { + const candidate = join(dir, 'node_modules', packageName) + if (existsSync(join(candidate, 'package.json'))) return candidate + + const next = dirname(dir) + if (next === dir) return null + dir = next + } +} + +function resolveDependencyPackageDir( + packageName: string, + fromDir: string, +): string | null { + return ( + findVisibleDependencyDir(packageName, fromDir) ?? + resolveDepDir(packageName, fromDir) + ) +} + +function workspacePackageDeclaresDependency( + packageJson: Record, + packageName: string, +): boolean { + return getDeps(packageJson).includes(packageName) +} + +function getLoadFastPathCandidateDirs( + packageName: string, +): Array { + const cwd = process.cwd() + const context = resolveProjectContext({ cwd }) + const workspacePackages = readWorkspacePackageInfos(cwd) + const candidates: Array = [] + const seen = new Set() + + for (const pkg of workspacePackages) { + if (pkg.name === packageName) { + addCandidateDir(candidates, seen, pkg.dir) + } + } + + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir( + packageName, + context.packageRoot ?? context.workspaceRoot ?? cwd, + ), + ) + + if (context.workspaceRoot && context.workspaceRoot !== context.packageRoot) { + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, context.workspaceRoot), + ) + } + + for (const pkg of workspacePackages) { + if (!workspacePackageDeclaresDependency(pkg.packageJson, packageName)) { + continue + } + + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, pkg.dir), + ) + } + + return candidates +} + +function resolveSkillUseFastPath( + parsedUse: ReturnType, + options: IntentCoreOptions, +): ResolveSkillResult | null { + if (options.globalOnly) return null + + for (const packageRoot of getLoadFastPathCandidateDirs( + parsedUse.packageName, + )) { + const scanned = scanIntentPackageAtRoot(packageRoot, { + fallbackName: parsedUse.packageName, + projectRoot: process.cwd(), + }) + const pkg = scanned.package + if (!pkg || pkg.name !== parsedUse.packageName) continue + + const skill = pkg.skills.find( + (candidate) => candidate.name === parsedUse.skillName, + ) + if (!skill) continue + + return { + packageName: pkg.name, + skillName: skill.name, + path: skill.path, + source: pkg.source, + version: pkg.version, + packageRoot: pkg.packageRoot, + warnings: scanned.warnings.filter((warning) => + warningMentionsPackage(warning, pkg.name), + ), + conflict: null, + } + } + + return null +} + export function listIntentSkills( options: IntentCoreOptions = {}, ): IntentSkillList { @@ -561,6 +737,46 @@ function rewriteLoadedSkillMarkdownDestinations({ return output } +function loadResolvedIntentSkill( + use: string, + resolved: ResolveSkillResult, +): LoadedIntentSkill { + const resolvedPath = resolveFromCwd(resolved.path) + + if (!isPathInsidePackageRoot(resolved.path, resolved.packageRoot)) { + throw new IntentCoreError( + 'skill-path-outside-package', + `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, + ) + } + + if (!existsSync(resolvedPath)) { + throw new IntentCoreError( + 'skill-file-not-found', + `Resolved skill file was not found: ${resolved.path}`, + ) + } + + const content = rewriteLoadedSkillMarkdownDestinations({ + content: readFileSync(resolvedPath, 'utf8'), + cwd: process.cwd(), + packageRoot: resolved.packageRoot, + skillFilePath: resolvedPath, + }) + + return { + content, + path: resolved.path, + packageRoot: resolved.packageRoot, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + warnings: resolved.warnings, + conflict: resolved.conflict, + } +} + export function loadIntentSkill( use: string, options: IntentCoreOptions = {}, @@ -588,7 +804,13 @@ export function loadIntentSkill( ) } - const scanResult = scanForIntents(undefined, toScanOptions(options)) + const scanOptions = toScanOptions(options) + const fastPathResolved = resolveSkillUseFastPath(parsedUse, options) + if (fastPathResolved) { + return loadResolvedIntentSkill(use, fastPathResolved) + } + + const scanResult = scanForIntents(undefined, scanOptions) let resolved: ReturnType try { resolved = resolveSkillUse(use, scanResult) @@ -598,39 +820,7 @@ export function loadIntentSkill( } throw err } - const resolvedPath = resolveFromCwd(resolved.path) - if (!isPathInsidePackageRoot(resolved.path, resolved.packageRoot)) { - throw new IntentCoreError( - 'skill-path-outside-package', - `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, - ) - } - - if (!existsSync(resolvedPath)) { - throw new IntentCoreError( - 'skill-file-not-found', - `Resolved skill file was not found: ${resolved.path}`, - ) - } - - const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolvedPath, 'utf8'), - cwd: process.cwd(), - packageRoot: resolved.packageRoot, - skillFilePath: resolvedPath, - }) - - return { - content, - path: resolved.path, - packageRoot: resolved.packageRoot, - packageName: resolved.packageName, - skillName: resolved.skillName, - version: resolved.version, - source: resolved.source, - warnings: resolved.warnings, - conflict: resolved.conflict, - } + return loadResolvedIntentSkill(use, resolved) }) } diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 629b741..2ded282 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -628,3 +628,67 @@ export function scanForIntents( return { packageManager, packages: sorted, warnings, conflicts, nodeModules } } + +export interface ScanIntentPackageAtRootOptions { + fallbackName?: string + projectRoot?: string + source?: IntentPackage['source'] +} + +export interface ScanIntentPackageAtRootResult { + package: IntentPackage | null + warnings: Array +} + +export function scanIntentPackageAtRoot( + packageRoot: string, + options: ScanIntentPackageAtRootOptions = {}, +): ScanIntentPackageAtRootResult { + const projectRoot = options.projectRoot ?? packageRoot + const packages: Array = [] + const warnings: Array = [] + const packageIndexes = new Map() + const packageJsonCache = new Map | null>() + + function readPkgJson(dirPath: string): Record | null { + if (packageJsonCache.has(dirPath)) { + return packageJsonCache.get(dirPath) ?? null + } + + try { + const pkgJson = JSON.parse( + readFileSync(join(dirPath, 'package.json'), 'utf8'), + ) as Record + packageJsonCache.set(dirPath, pkgJson) + return pkgJson + } catch { + packageJsonCache.set(dirPath, null) + return null + } + } + + const { tryRegister } = createPackageRegistrar({ + comparePackageVersions, + deriveIntentConfig, + discoverSkills, + getPackageDepth, + packageIndexes, + packages, + projectRoot, + readPkgJson, + rememberVariant() {}, + validateIntentField, + warnings, + }) + + tryRegister( + packageRoot, + options.fallbackName ?? 'unknown', + options.source ?? 'local', + ) + + return { + package: packages[0] ?? null, + warnings, + } +} diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 4acb0eb..da8ee9d 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -214,6 +215,96 @@ describe('loadIntentSkill', () => { }) }) + it('rejects conflicting scan options before the fast path', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { + cwd: root, + global: true, + globalOnly: true, + }), + ).toThrow('Use either global or globalOnly, not both.') + }) + + it('loads a matching workspace package without node_modules', () => { + const appDir = join(root, 'packages', 'app') + const routerDir = join(root, 'packages', 'router-core') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(appDir, 'package.json'), { + name: '@test/app', + }) + writeJson(join(routerDir, 'package.json'), { + name: '@tanstack/router-core', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd({ + dir: join(routerDir, 'skills', 'router-core', 'auth-and-guards'), + frontmatter: { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + }) + + const result = loadIntentSkill( + '@tanstack/router-core#router-core/auth-and-guards', + { cwd: appDir }, + ) + + expect(result.packageRoot).toBe(routerDir) + expect(result.path).toBe( + join(routerDir, 'skills', 'router-core', 'auth-and-guards', 'SKILL.md'), + ) + }) + + it('loads a dependency declared by a workspace package without a root link', () => { + const appDir = join(root, 'packages', 'app') + const storeDir = join(root, '.store', '@tanstack', 'query') + const linkDir = join(appDir, 'node_modules', '@tanstack', 'query') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(appDir, 'package.json'), { + name: '@test/app', + dependencies: { + '@tanstack/query': '1.0.0', + }, + }) + writeJson(join(storeDir, 'package.json'), { + name: '@tanstack/query', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd({ + dir: join(storeDir, 'skills', 'fetching'), + frontmatter: { + name: 'fetching', + description: 'Query fetching', + }, + }) + mkdirSync(dirname(linkDir), { recursive: true }) + symlinkSync(storeDir, linkDir, 'dir') + + const result = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + + expect(result.packageRoot).toBe(linkDir) + expect(result.path).toBe( + 'packages/app/node_modules/@tanstack/query/skills/fetching/SKILL.md', + ) + }) + it('rewrites relative markdown destinations in loaded content', () => { const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') From 75e7fa77595e4b47194120debca2f292bd4bea71 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 23:03:30 -0700 Subject: [PATCH 06/20] resolve package-prefixed skills by short name --- benchmarks/intent/load.bench.ts | 79 ++++++++++++++++++ docs/cli/intent-load.md | 3 + packages/intent/src/core.ts | 9 ++- packages/intent/src/resolver.ts | 108 ++++++++++++++++++++++++- packages/intent/tests/core.test.ts | 34 ++++++++ packages/intent/tests/resolver.test.ts | 67 +++++++++++++++ 6 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 benchmarks/intent/load.bench.ts diff --git a/benchmarks/intent/load.bench.ts b/benchmarks/intent/load.bench.ts new file mode 100644 index 0000000..fa06ff2 --- /dev/null +++ b/benchmarks/intent/load.bench.ts @@ -0,0 +1,79 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { + createBenchOptions, + createCliRunner, + createConsoleSilencer, + createTempDir, + writeJson, + writePackage, +} from './helpers.js' + +type LoadFixture = { + root: string + runner: ReturnType +} + +const consoleSilencer = createConsoleSilencer() +let fixture: LoadFixture | null = null + +function createFixture(): LoadFixture { + const root = createTempDir('load') + + writeJson(join(root, 'package.json'), { + name: 'intent-load-benchmark', + private: true, + dependencies: { + '@bench/query': '1.0.0', + }, + }) + + writePackage(join(root, 'node_modules'), '@bench/query', '1.0.0', { + skills: ['query/core', 'query/cache', 'query/testing'], + }) + + return { + root, + runner: createCliRunner({ cwd: root }), + } +} + +function getFixture(): LoadFixture { + if (!fixture) { + consoleSilencer.silence() + fixture = createFixture() + } + + return fixture +} + +async function setup(): Promise { + await getFixture().runner.setup() +} + +function teardown(): void { + if (fixture) { + fixture.runner.teardown() + rmSync(fixture.root, { recursive: true, force: true }) + fixture = null + } + + consoleSilencer.restore() +} + +describe('intent load', () => { + beforeAll(setup) + afterAll(teardown) + + bench( + 'loads a direct dependency skill', + async () => { + const state = getFixture() + for (let index = 0; index < 10; index++) { + await state.runner.run(['load', '@bench/query#query/cache', '--path']) + } + }, + createBenchOptions(setup, teardown), + ) +}) diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 1ff5504..e027829 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -24,6 +24,7 @@ npx @tanstack/intent@latest load # [--path] [--json] [--exclude - Includes global packages only when `--global` or `--global-only` is passed - Fails before scanning when the target package matches package.json `intent.exclude` or `--exclude` - Prefers local packages when `--global` is used and the same package exists locally and globally +- Accepts an unambiguous short skill name when a package-prefixed skill exists - Prints raw `SKILL.md` content by default - Prints the scanner-reported path when `--path` is passed @@ -34,6 +35,7 @@ Examples: ```bash npx @tanstack/intent@latest load @tanstack/query#fetching npx @tanstack/intent@latest load @tanstack/query#core/fetching +npx @tanstack/intent@latest load @tanstack/router-core#auth-and-guards npx @tanstack/intent@latest load some-lib#core --path ``` @@ -61,6 +63,7 @@ npx @tanstack/intent@latest load some-lib#core --path - Empty skill: `Invalid skill use "@tanstack/query#": skill is required.` - Missing package: `Cannot resolve skill use "...": package "..." was not found.` - Missing skill: `Cannot resolve skill use "...": skill "..." was not found in package "...".` +- Skill suggestion: `Did you mean @tanstack/router-core#router-core/auth-and-guards?` - Excluded package: `Cannot load skill use "...": package "..." is excluded by Intent configuration.` ## Related diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 3dfb1c8..44b1398 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -3,6 +3,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { resolveProjectContext } from './core/project-context.js' import { ResolveSkillUseError, + resolveSkillEntry, resolveSkillUse, type ResolveSkillResult, } from './resolver.js' @@ -342,9 +343,11 @@ function resolveSkillUseFastPath( const pkg = scanned.package if (!pkg || pkg.name !== parsedUse.packageName) continue - const skill = pkg.skills.find( - (candidate) => candidate.name === parsedUse.skillName, - ) + const skill = resolveSkillEntry( + pkg.name, + parsedUse.skillName, + pkg.skills, + ).skill if (!skill) continue return { diff --git a/packages/intent/src/resolver.ts b/packages/intent/src/resolver.ts index 1da10bb..3fe6df5 100644 --- a/packages/intent/src/resolver.ts +++ b/packages/intent/src/resolver.ts @@ -1,5 +1,10 @@ import { parseSkillUse } from './skill-use.js' -import type { IntentPackage, ScanResult, VersionConflict } from './types.js' +import type { + IntentPackage, + ScanResult, + SkillEntry, + VersionConflict, +} from './types.js' export interface ResolveSkillResult { packageName: string @@ -21,6 +26,7 @@ export class ResolveSkillUseError extends Error { readonly skillName: string readonly availablePackages: Array readonly availableSkills: Array + readonly suggestedSkills: Array constructor({ availablePackages = [], @@ -28,6 +34,7 @@ export class ResolveSkillUseError extends Error { code, packageName, skillName, + suggestedSkills = [], use, }: { availablePackages?: Array @@ -35,6 +42,7 @@ export class ResolveSkillUseError extends Error { code: ResolveSkillUseErrorCode packageName: string skillName: string + suggestedSkills?: Array use: string }) { super( @@ -44,12 +52,14 @@ export class ResolveSkillUseError extends Error { code, packageName, skillName, + suggestedSkills, use, }), ) this.name = 'ResolveSkillUseError' this.availablePackages = availablePackages this.availableSkills = availableSkills + this.suggestedSkills = suggestedSkills this.code = code this.packageName = packageName this.skillName = skillName @@ -63,6 +73,75 @@ export function isResolveSkillUseError( return error instanceof ResolveSkillUseError } +export interface ResolveSkillEntryResult { + skill: SkillEntry | null + suggestedSkills: Array +} + +function getPackageShortName(packageName: string): string { + return packageName.split('/').pop() ?? packageName +} + +function getPackagePrefixedSkillAlias( + packageName: string, + skillName: string, +): string | null { + const prefix = `${getPackageShortName(packageName)}/` + return skillName.startsWith(prefix) ? skillName.slice(prefix.length) : null +} + +function getSuggestedSkills( + packageName: string, + skillName: string, + skills: Array, +): Array { + const lowerSkillName = skillName.toLowerCase() + const suggestions: Array = [] + const seen = new Set() + + for (const skill of skills) { + const alias = getPackagePrefixedSkillAlias(packageName, skill.name) + const lowerName = skill.name.toLowerCase() + const lowerAlias = alias?.toLowerCase() + const matches = + lowerAlias === lowerSkillName || + lowerName.includes(lowerSkillName) || + lowerAlias?.includes(lowerSkillName) + + if (!matches || seen.has(skill.name)) continue + + seen.add(skill.name) + suggestions.push(skill.name) + } + + return suggestions.slice(0, 3) +} + +export function resolveSkillEntry( + packageName: string, + skillName: string, + skills: Array, +): ResolveSkillEntryResult { + const exact = skills.find((candidate) => candidate.name === skillName) + if (exact) { + return { skill: exact, suggestedSkills: [] } + } + + const aliasMatches = skills.filter( + (candidate) => + getPackagePrefixedSkillAlias(packageName, candidate.name) === skillName, + ) + + if (aliasMatches.length === 1) { + return { skill: aliasMatches[0]!, suggestedSkills: [] } + } + + return { + skill: null, + suggestedSkills: getSuggestedSkills(packageName, skillName, skills), + } +} + export function resolveSkillUse( use: string, scanResult: ScanResult, @@ -82,7 +161,8 @@ export function resolveSkillUse( }) } - const skill = pkg.skills.find((candidate) => candidate.name === skillName) + const resolvedSkill = resolveSkillEntry(packageName, skillName, pkg.skills) + const skill = resolvedSkill.skill if (!skill) { throw new ResolveSkillUseError({ @@ -90,6 +170,7 @@ export function resolveSkillUse( code: 'skill-not-found', packageName, skillName, + suggestedSkills: resolvedSkill.suggestedSkills, use, }) } @@ -101,7 +182,7 @@ export function resolveSkillUse( return { packageName, - skillName, + skillName: skill.name, path: skill.path, source: pkg.source, version: pkg.version, @@ -122,6 +203,7 @@ function formatResolveSkillUseErrorMessage({ code, packageName, skillName, + suggestedSkills, use, }: { availablePackages: Array @@ -129,6 +211,7 @@ function formatResolveSkillUseErrorMessage({ code: ResolveSkillUseErrorCode packageName: string skillName: string + suggestedSkills: Array use: string }): string { switch (code) { @@ -140,11 +223,28 @@ function formatResolveSkillUseErrorMessage({ return `Cannot resolve skill use "${use}": package "${packageName}" was not found.${available}` } case 'skill-not-found': { + const suggestions = + suggestedSkills.length > 0 + ? ` Did you mean ${formatSkillSuggestions(packageName, suggestedSkills)}?` + : '' const available = availableSkills.length > 0 ? ` Available skills: ${availableSkills.join(', ')}.` : '' - return `Cannot resolve skill use "${use}": skill "${skillName}" was not found in package "${packageName}".${available}` + return `Cannot resolve skill use "${use}": skill "${skillName}" was not found in package "${packageName}".${suggestions}${available}` } } } + +function formatSkillSuggestions( + packageName: string, + skillNames: Array, +): string { + const uses = skillNames.map((skillName) => `${packageName}#${skillName}`) + + if (uses.length <= 2) { + return uses.join(' or ') + } + + return `${uses.slice(0, -1).join(', ')}, or ${uses.at(-1)}` +} diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index da8ee9d..21cdcf4 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -267,6 +267,40 @@ describe('loadIntentSkill', () => { ) }) + it('loads a package-prefixed workspace skill by short name', () => { + const appDir = join(root, 'packages', 'app') + const routerDir = join(root, 'packages', 'router-core') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(appDir, 'package.json'), { + name: '@test/app', + }) + writeJson(join(routerDir, 'package.json'), { + name: '@tanstack/router-core', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd({ + dir: join(routerDir, 'skills', 'router-core', 'auth-and-guards'), + frontmatter: { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + }) + + const result = loadIntentSkill('@tanstack/router-core#auth-and-guards', { + cwd: appDir, + }) + + expect(result.skillName).toBe('router-core/auth-and-guards') + expect(result.path).toBe( + join(routerDir, 'skills', 'router-core', 'auth-and-guards', 'SKILL.md'), + ) + }) + it('loads a dependency declared by a workspace package without a root link', () => { const appDir = join(root, 'packages', 'app') const storeDir = join(root, '.store', '@tanstack', 'query') diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 4579ae3..bcc698e 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -111,6 +111,54 @@ describe('resolveSkillUse', () => { ) }) + it('resolves an unambiguous package-prefixed skill by short name', () => { + const pkg = intentPackage({ + name: '@tanstack/router-core', + skills: [ + skill( + 'router-core/auth-and-guards', + 'node_modules/@tanstack/router-core/skills/router-core/auth-and-guards/SKILL.md', + ), + ], + }) + + const result = resolveSkillUse( + '@tanstack/router-core#auth-and-guards', + scanResult([pkg]), + ) + + expect(result.skillName).toBe('router-core/auth-and-guards') + expect(result.path).toBe( + 'node_modules/@tanstack/router-core/skills/router-core/auth-and-guards/SKILL.md', + ) + }) + + it('prefers an exact skill over a short-name alias', () => { + const pkg = intentPackage({ + name: '@tanstack/router-core', + skills: [ + skill( + 'auth-and-guards', + 'node_modules/@tanstack/router-core/skills/auth-and-guards/SKILL.md', + ), + skill( + 'router-core/auth-and-guards', + 'node_modules/@tanstack/router-core/skills/router-core/auth-and-guards/SKILL.md', + ), + ], + }) + + const result = resolveSkillUse( + '@tanstack/router-core#auth-and-guards', + scanResult([pkg]), + ) + + expect(result.skillName).toBe('auth-and-guards') + expect(result.path).toBe( + 'node_modules/@tanstack/router-core/skills/auth-and-guards/SKILL.md', + ) + }) + it('returns pnpm-internal paths reported by the scanner', () => { const pnpmPath = 'node_modules/.pnpm/@tanstack+query@5.0.0/node_modules/@tanstack/query/skills/core/SKILL.md' @@ -260,4 +308,23 @@ describe('resolveSkillUse', () => { ) }).toThrow('skill "mutations" was not found') }) + + it('suggests canonical skill uses when a short name misses', () => { + const pkg = intentPackage({ + name: '@tanstack/router-core', + skills: [ + skill('router-core/auth-and-guards'), + skill('router-core/setup-guards'), + ], + }) + + expect(() => { + resolveSkillUse( + '@tanstack/router-core#guards', + scanResult([pkg]), + ) + }).toThrow( + 'Did you mean @tanstack/router-core#router-core/auth-and-guards or @tanstack/router-core#router-core/setup-guards?', + ) + }) }) From ed8d94c6beacdf05a4f7c3aa5bba09d76f7f1fbc Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 23:17:41 -0700 Subject: [PATCH 07/20] split core implementation helpers --- packages/intent/src/core.ts | 660 +------------------- packages/intent/src/core/excludes.ts | 85 +++ packages/intent/src/core/load-resolution.ts | 184 ++++++ packages/intent/src/core/markdown.ts | 316 ++++++++++ packages/intent/src/core/package-json.ts | 13 + packages/intent/src/core/types.ts | 54 ++ 6 files changed, 680 insertions(+), 632 deletions(-) create mode 100644 packages/intent/src/core/excludes.ts create mode 100644 packages/intent/src/core/load-resolution.ts create mode 100644 packages/intent/src/core/markdown.ts create mode 100644 packages/intent/src/core/package-json.ts create mode 100644 packages/intent/src/core/types.ts diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 44b1398..50e1232 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,88 +1,47 @@ import { existsSync, readFileSync } from 'node:fs' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { resolveProjectContext } from './core/project-context.js' +import { isAbsolute, relative, resolve } from 'node:path' +import { + getEffectiveExcludePatterns, + isPackageExcluded, + warningMentionsPackage, +} from './core/excludes.js' +import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' +import { resolveSkillUseFastPath } from './core/load-resolution.js' import { ResolveSkillUseError, - resolveSkillEntry, resolveSkillUse, type ResolveSkillResult, } from './resolver.js' import { formatSkillUse, parseSkillUse } from './skill-use.js' -import { scanForIntents, scanIntentPackageAtRoot } from './scanner.js' -import { resolveWorkspacePackages } from './workspace-patterns.js' -import { getDeps, resolveDepDir, toPosixPath } from './utils.js' -import type { IntentPackage, ScanOptions, VersionConflict } from './types.js' - -export interface IntentCoreOptions { - cwd?: string - global?: boolean - globalOnly?: boolean - exclude?: Array -} - -export interface IntentSkillSummary { - use: string - packageName: string - packageVersion: string - packageSource: IntentPackage['source'] - skillName: string - description: string - type?: string - framework?: string -} - -export interface IntentPackageSummary { - name: string - version: string - source: IntentPackage['source'] - skillCount: number -} - -export interface IntentSkillList { - skills: Array - packages: Array - warnings: Array - conflicts: Array -} - -export interface LoadedIntentSkill { - content: string - path: string - packageRoot: string - packageName: string - skillName: string - version: string - source: IntentPackage['source'] - warnings: Array - conflict: VersionConflict | null -} +import { scanForIntents } from './scanner.js' +import type { ScanOptions } from './types.js' +import type { + IntentCoreErrorCode, + IntentCoreOptions, + IntentSkillList, + IntentSkillSummary, + LoadedIntentSkill, +} from './core/types.js' + +export type { + IntentCoreErrorCode, + IntentCoreOptions, + IntentPackageSummary, + IntentSkillList, + IntentSkillSummary, + LoadedIntentSkill, +} from './core/types.js' export class IntentCoreError extends Error { - readonly code: - | 'invalid-options' - | 'invalid-skill-use' - | 'package-not-found' - | 'package-excluded' - | 'skill-not-found' - | 'skill-path-outside-package' - | 'skill-file-not-found' + readonly code: IntentCoreErrorCode - constructor(code: IntentCoreError['code'], message: string) { + constructor(code: IntentCoreErrorCode, message: string) { super(message) this.name = 'IntentCoreError' this.code = code } } -function normalizeExcludePatterns(value: unknown): Array { - if (!Array.isArray(value)) return [] - - return value - .filter((pattern): pattern is string => typeof pattern === 'string') - .map((pattern) => pattern.trim()) - .filter(Boolean) -} - function toScanOptions(options: IntentCoreOptions): ScanOptions { if (options.global && options.globalOnly) { throw new IntentCoreError( @@ -114,259 +73,6 @@ function withCwd(cwd: string | undefined, callback: () => T): T { } } -function isWithinOrEqual(path: string, parentDir: string): boolean { - const rel = relative(parentDir, path) - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) -} - -function readPackageJson(dir: string): Record | null { - try { - return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record< - string, - unknown - > - } catch { - return null - } -} - -function readPackageExcludes(dir: string): Array { - const pkg = readPackageJson(dir) - const intent = pkg?.intent - if (!intent || typeof intent !== 'object') return [] - - return normalizeExcludePatterns((intent as Record).exclude) -} - -function getConfigExcludePatterns(cwd: string): Array { - const context = resolveProjectContext({ cwd }) - const root = context.workspaceRoot ?? context.packageRoot ?? cwd - const dirs: Array = [] - let dir = cwd - - while (isWithinOrEqual(dir, root)) { - dirs.push(dir) - if (dir === root) break - - const next = dirname(dir) - if (next === dir) break - dir = next - } - - return dirs.reverse().flatMap(readPackageExcludes) -} - -function getEffectiveExcludePatterns( - options: IntentCoreOptions, -): Array { - return [ - ...getConfigExcludePatterns(process.cwd()), - ...normalizeExcludePatterns(options.exclude), - ] -} - -function globToRegExp(pattern: string): RegExp { - const source = pattern - .split('*') - .map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')) - .join('.*') - return new RegExp(`^${source}$`) -} - -function matchesPackageGlob(packageName: string, pattern: string): boolean { - return pattern.includes('*') - ? globToRegExp(pattern).test(packageName) - : packageName === pattern -} - -function isPackageExcluded( - packageName: string, - patterns: Array, -): boolean { - return patterns.some((pattern) => matchesPackageGlob(packageName, pattern)) -} - -function warningMentionsPackage(warning: string, packageName: string): boolean { - const idx = warning.indexOf(packageName) - if (idx === -1) return false - - const after = warning[idx + packageName.length] - return after === undefined || /[^a-zA-Z0-9_-]/.test(after) -} - -interface WorkspacePackageInfo { - dir: string - name: string | null - packageJson: Record -} - -function readWorkspacePackageInfos(cwd: string): Array { - const context = resolveProjectContext({ cwd }) - const dirs = new Set() - - if (context.packageRoot) { - dirs.add(context.packageRoot) - } - - if (context.workspaceRoot) { - dirs.add(context.workspaceRoot) - - for (const dir of resolveWorkspacePackages( - context.workspaceRoot, - context.workspacePatterns, - )) { - dirs.add(dir) - } - } - - return [...dirs].flatMap((dir) => { - const packageJson = readPackageJson(dir) - if (!packageJson) return [] - - return [ - { - dir, - name: typeof packageJson.name === 'string' ? packageJson.name : null, - packageJson, - }, - ] - }) -} - -function addCandidateDir( - candidates: Array, - seen: Set, - dir: string | null, -): void { - if (!dir) return - - const key = resolve(dir) - if (seen.has(key)) return - - seen.add(key) - candidates.push(dir) -} - -function findVisibleDependencyDir( - packageName: string, - fromDir: string, -): string | null { - let dir = fromDir - - while (true) { - const candidate = join(dir, 'node_modules', packageName) - if (existsSync(join(candidate, 'package.json'))) return candidate - - const next = dirname(dir) - if (next === dir) return null - dir = next - } -} - -function resolveDependencyPackageDir( - packageName: string, - fromDir: string, -): string | null { - return ( - findVisibleDependencyDir(packageName, fromDir) ?? - resolveDepDir(packageName, fromDir) - ) -} - -function workspacePackageDeclaresDependency( - packageJson: Record, - packageName: string, -): boolean { - return getDeps(packageJson).includes(packageName) -} - -function getLoadFastPathCandidateDirs( - packageName: string, -): Array { - const cwd = process.cwd() - const context = resolveProjectContext({ cwd }) - const workspacePackages = readWorkspacePackageInfos(cwd) - const candidates: Array = [] - const seen = new Set() - - for (const pkg of workspacePackages) { - if (pkg.name === packageName) { - addCandidateDir(candidates, seen, pkg.dir) - } - } - - addCandidateDir( - candidates, - seen, - resolveDependencyPackageDir( - packageName, - context.packageRoot ?? context.workspaceRoot ?? cwd, - ), - ) - - if (context.workspaceRoot && context.workspaceRoot !== context.packageRoot) { - addCandidateDir( - candidates, - seen, - resolveDependencyPackageDir(packageName, context.workspaceRoot), - ) - } - - for (const pkg of workspacePackages) { - if (!workspacePackageDeclaresDependency(pkg.packageJson, packageName)) { - continue - } - - addCandidateDir( - candidates, - seen, - resolveDependencyPackageDir(packageName, pkg.dir), - ) - } - - return candidates -} - -function resolveSkillUseFastPath( - parsedUse: ReturnType, - options: IntentCoreOptions, -): ResolveSkillResult | null { - if (options.globalOnly) return null - - for (const packageRoot of getLoadFastPathCandidateDirs( - parsedUse.packageName, - )) { - const scanned = scanIntentPackageAtRoot(packageRoot, { - fallbackName: parsedUse.packageName, - projectRoot: process.cwd(), - }) - const pkg = scanned.package - if (!pkg || pkg.name !== parsedUse.packageName) continue - - const skill = resolveSkillEntry( - pkg.name, - parsedUse.skillName, - pkg.skills, - ).skill - if (!skill) continue - - return { - packageName: pkg.name, - skillName: skill.name, - path: skill.path, - source: pkg.source, - version: pkg.version, - packageRoot: pkg.packageRoot, - warnings: scanned.warnings.filter((warning) => - warningMentionsPackage(warning, pkg.name), - ), - conflict: null, - } - } - - return null -} - export function listIntentSkills( options: IntentCoreOptions = {}, ): IntentSkillList { @@ -430,316 +136,6 @@ function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { ) } -function splitDestinationSuffix(destination: string): { - pathPart: string - suffix: string -} { - const hashIndex = destination.indexOf('#') - const queryIndex = destination.indexOf('?') - const suffixIndex = - hashIndex === -1 - ? queryIndex - : queryIndex === -1 - ? hashIndex - : Math.min(hashIndex, queryIndex) - - if (suffixIndex === -1) { - return { pathPart: destination, suffix: '' } - } - - return { - pathPart: destination.slice(0, suffixIndex), - suffix: destination.slice(suffixIndex), - } -} - -function isExternalOrAbsoluteDestination(destination: string): boolean { - return ( - destination === '' || - destination.startsWith('#') || - destination.startsWith('?') || - destination.startsWith('//') || - /^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) || - isAbsolute(destination) - ) -} - -interface MarkdownDestinationRewriteContext { - cwd: string - resolvedPackageRoot: string - skillDir: string -} - -function findClosingBracket(line: string, start: number): number { - let depth = 0 - - for (let index = start; index < line.length; index++) { - const char = line[index]! - if (char === '\\') { - index++ - continue - } - if (char === '[') { - depth++ - continue - } - if (char === ']') { - depth-- - if (depth === 0) return index - } - } - - return -1 -} - -function findClosingParen(line: string, start: number): number { - for (let index = start; index < line.length; index++) { - const char = line[index]! - if (char === '\\') { - index++ - continue - } - if (char === ')') return index - } - - return -1 -} - -function readBareDestination( - line: string, - start: number, -): { destinationEnd: number; endParen: number } | null { - let depth = 0 - - for (let index = start; index < line.length; index++) { - const char = line[index]! - if (char === '\\') { - index++ - continue - } - if (char === '(') { - depth++ - continue - } - if (char === ')') { - if (depth === 0) { - return { destinationEnd: index, endParen: index } - } - depth-- - continue - } - if (/\s/.test(char) && depth === 0) { - const endParen = findClosingParen(line, index) - if (endParen === -1) return null - return { destinationEnd: index, endParen } - } - } - - return null -} - -function readMarkdownDestination( - line: string, - start: number, -): { - destination: string - destinationStart: number - destinationEnd: number - endParen: number -} | null { - let cursor = start - while (cursor < line.length && /\s/.test(line[cursor]!)) cursor++ - - if (line[cursor] === '<') { - const destinationStart = cursor + 1 - const destinationEnd = line.indexOf('>', destinationStart) - if (destinationEnd === -1) return null - const endParen = findClosingParen(line, destinationEnd + 1) - if (endParen === -1) return null - return { - destination: line.slice(destinationStart, destinationEnd), - destinationStart, - destinationEnd, - endParen, - } - } - - const read = readBareDestination(line, cursor) - if (!read) return null - - return { - destination: line.slice(cursor, read.destinationEnd), - destinationStart: cursor, - destinationEnd: read.destinationEnd, - endParen: read.endParen, - } -} - -function getCodeFenceMarker(line: string): '`' | '~' | null { - const match = line.match(/^\s*(`{3,}|~{3,})/) - const marker = match?.[1]?.[0] - return marker === '`' || marker === '~' ? marker : null -} - -function rewriteMarkdownDestination({ - context, - destination, -}: { - context: MarkdownDestinationRewriteContext - destination: string -}): string { - if (isExternalOrAbsoluteDestination(destination)) return destination - - const { pathPart, suffix } = splitDestinationSuffix(destination) - if (isExternalOrAbsoluteDestination(pathPart)) return destination - - const resolvedDestinationPath = resolve(context.skillDir, pathPart) - const relativeToPackageRoot = relative( - context.resolvedPackageRoot, - resolvedDestinationPath, - ) - if ( - relativeToPackageRoot.startsWith('..') || - isAbsolute(relativeToPackageRoot) - ) { - return destination - } - - const relativeToCwd = relative(context.cwd, resolvedDestinationPath) - const rewrittenPath = - relativeToCwd && - !relativeToCwd.startsWith('..') && - !isAbsolute(relativeToCwd) - ? relativeToCwd - : resolvedDestinationPath - - return `${toPosixPath(rewrittenPath)}${suffix}` -} - -function rewriteMarkdownLineDestinations({ - context, - line, -}: { - context: MarkdownDestinationRewriteContext - line: string -}): string { - if (!line.includes('[')) return line - - let output = '' - let cursor = 0 - - while (cursor < line.length) { - const nextCodeStart = line.indexOf('`', cursor) - const nextLinkStart = line.indexOf('[', cursor) - - if (nextLinkStart === -1) { - output += line.slice(cursor) - break - } - - if (nextCodeStart !== -1 && nextCodeStart < nextLinkStart) { - output += line.slice(cursor, nextCodeStart) - cursor = nextCodeStart - const codeStart = cursor - while (cursor < line.length && line[cursor] === '`') cursor++ - const marker = line.slice(codeStart, cursor) - const codeEnd = line.indexOf(marker, cursor) - if (codeEnd === -1) { - output += line.slice(codeStart) - break - } - output += line.slice(codeStart, codeEnd + marker.length) - cursor = codeEnd + marker.length - continue - } - - const linkStart = - nextLinkStart > 0 && line[nextLinkStart - 1] === '!' - ? nextLinkStart - 1 - : nextLinkStart - output += line.slice(cursor, linkStart) - - const labelStart = nextLinkStart - const labelEnd = findClosingBracket(line, labelStart) - if (labelEnd === -1) { - output += line.slice(linkStart) - break - } - - if (line[labelEnd + 1] !== '(') { - output += line.slice(linkStart, nextLinkStart + 1) - cursor = nextLinkStart + 1 - continue - } - - const destination = readMarkdownDestination(line, labelEnd + 2) - if (!destination) { - output += line.slice(linkStart, nextLinkStart + 1) - cursor = nextLinkStart + 1 - continue - } - - const rewritten = rewriteMarkdownDestination({ - context, - destination: destination.destination, - }) - output += - line.slice(linkStart, destination.destinationStart) + - rewritten + - line.slice(destination.destinationEnd, destination.endParen + 1) - cursor = destination.endParen + 1 - } - - return output -} - -function rewriteLoadedSkillMarkdownDestinations({ - content, - cwd, - packageRoot, - skillFilePath, -}: { - content: string - cwd: string - packageRoot: string - skillFilePath: string -}): string { - const context: MarkdownDestinationRewriteContext = { - cwd, - resolvedPackageRoot: resolveFromCwd(packageRoot), - skillDir: dirname(skillFilePath), - } - let inFence: '`' | '~' | null = null - const parts = content.split(/(\r?\n)/) - let output = '' - - for (let index = 0; index < parts.length; index += 2) { - const line = parts[index] ?? '' - const newline = parts[index + 1] ?? '' - const marker = getCodeFenceMarker(line) - - if (inFence) { - output += line + newline - if (marker === inFence) inFence = null - continue - } - - if (marker) { - inFence = marker - output += line + newline - continue - } - - output += - rewriteMarkdownLineDestinations({ - context, - line, - }) + newline - } - - return output -} - function loadResolvedIntentSkill( use: string, resolved: ResolveSkillResult, diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts new file mode 100644 index 0000000..d271099 --- /dev/null +++ b/packages/intent/src/core/excludes.ts @@ -0,0 +1,85 @@ +import { dirname, isAbsolute, relative } from 'node:path' +import { resolveProjectContext } from './project-context.js' +import { readPackageJson } from './package-json.js' +import type { IntentCoreOptions } from './types.js' + +export function normalizeExcludePatterns(value: unknown): Array { + if (!Array.isArray(value)) return [] + + return value + .filter((pattern): pattern is string => typeof pattern === 'string') + .map((pattern) => pattern.trim()) + .filter(Boolean) +} + +function isWithinOrEqual(path: string, parentDir: string): boolean { + const rel = relative(parentDir, path) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +function readPackageExcludes(dir: string): Array { + const pkg = readPackageJson(dir) + const intent = pkg?.intent + if (!intent || typeof intent !== 'object') return [] + + return normalizeExcludePatterns((intent as Record).exclude) +} + +function getConfigExcludePatterns(cwd: string): Array { + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? cwd + const dirs: Array = [] + let dir = cwd + + while (isWithinOrEqual(dir, root)) { + dirs.push(dir) + if (dir === root) break + + const next = dirname(dir) + if (next === dir) break + dir = next + } + + return dirs.reverse().flatMap(readPackageExcludes) +} + +export function getEffectiveExcludePatterns( + options: IntentCoreOptions, +): Array { + return [ + ...getConfigExcludePatterns(process.cwd()), + ...normalizeExcludePatterns(options.exclude), + ] +} + +function globToRegExp(pattern: string): RegExp { + const source = pattern + .split('*') + .map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')) + .join('.*') + return new RegExp(`^${source}$`) +} + +function matchesPackageGlob(packageName: string, pattern: string): boolean { + return pattern.includes('*') + ? globToRegExp(pattern).test(packageName) + : packageName === pattern +} + +export function isPackageExcluded( + packageName: string, + patterns: Array, +): boolean { + return patterns.some((pattern) => matchesPackageGlob(packageName, pattern)) +} + +export function warningMentionsPackage( + warning: string, + packageName: string, +): boolean { + const idx = warning.indexOf(packageName) + if (idx === -1) return false + + const after = warning[idx + packageName.length] + return after === undefined || /[^a-zA-Z0-9_-]/.test(after) +} diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts new file mode 100644 index 0000000..bf6be1c --- /dev/null +++ b/packages/intent/src/core/load-resolution.ts @@ -0,0 +1,184 @@ +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { resolveSkillEntry, type ResolveSkillResult } from '../resolver.js' +import { scanIntentPackageAtRoot } from '../scanner.js' +import { resolveWorkspacePackages } from '../workspace-patterns.js' +import { getDeps, resolveDepDir } from '../utils.js' +import { warningMentionsPackage } from './excludes.js' +import { readPackageJson } from './package-json.js' +import { resolveProjectContext } from './project-context.js' +import type { SkillUse } from '../skill-use.js' +import type { IntentCoreOptions } from './types.js' + +interface WorkspacePackageInfo { + dir: string + name: string | null + packageJson: Record +} + +function readWorkspacePackageInfos(cwd: string): Array { + const context = resolveProjectContext({ cwd }) + const dirs = new Set() + + if (context.packageRoot) { + dirs.add(context.packageRoot) + } + + if (context.workspaceRoot) { + dirs.add(context.workspaceRoot) + + for (const dir of resolveWorkspacePackages( + context.workspaceRoot, + context.workspacePatterns, + )) { + dirs.add(dir) + } + } + + return [...dirs].flatMap((dir) => { + const packageJson = readPackageJson(dir) + if (!packageJson) return [] + + return [ + { + dir, + name: typeof packageJson.name === 'string' ? packageJson.name : null, + packageJson, + }, + ] + }) +} + +function addCandidateDir( + candidates: Array, + seen: Set, + dir: string | null, +): void { + if (!dir) return + + const key = resolve(dir) + if (seen.has(key)) return + + seen.add(key) + candidates.push(dir) +} + +function findVisibleDependencyDir( + packageName: string, + fromDir: string, +): string | null { + let dir = fromDir + + while (true) { + const candidate = join(dir, 'node_modules', packageName) + if (existsSync(join(candidate, 'package.json'))) return candidate + + const next = dirname(dir) + if (next === dir) return null + dir = next + } +} + +function resolveDependencyPackageDir( + packageName: string, + fromDir: string, +): string | null { + return ( + findVisibleDependencyDir(packageName, fromDir) ?? + resolveDepDir(packageName, fromDir) + ) +} + +function workspacePackageDeclaresDependency( + packageJson: Record, + packageName: string, +): boolean { + return getDeps(packageJson).includes(packageName) +} + +function getLoadFastPathCandidateDirs( + packageName: string, +): Array { + const cwd = process.cwd() + const context = resolveProjectContext({ cwd }) + const workspacePackages = readWorkspacePackageInfos(cwd) + const candidates: Array = [] + const seen = new Set() + + for (const pkg of workspacePackages) { + if (pkg.name === packageName) { + addCandidateDir(candidates, seen, pkg.dir) + } + } + + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir( + packageName, + context.packageRoot ?? context.workspaceRoot ?? cwd, + ), + ) + + if (context.workspaceRoot && context.workspaceRoot !== context.packageRoot) { + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, context.workspaceRoot), + ) + } + + for (const pkg of workspacePackages) { + if (!workspacePackageDeclaresDependency(pkg.packageJson, packageName)) { + continue + } + + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, pkg.dir), + ) + } + + return candidates +} + +export function resolveSkillUseFastPath( + parsedUse: SkillUse, + options: IntentCoreOptions, +): ResolveSkillResult | null { + if (options.globalOnly) return null + + for (const packageRoot of getLoadFastPathCandidateDirs( + parsedUse.packageName, + )) { + const scanned = scanIntentPackageAtRoot(packageRoot, { + fallbackName: parsedUse.packageName, + projectRoot: process.cwd(), + }) + const pkg = scanned.package + if (!pkg || pkg.name !== parsedUse.packageName) continue + + const skill = resolveSkillEntry( + pkg.name, + parsedUse.skillName, + pkg.skills, + ).skill + if (!skill) continue + + return { + packageName: pkg.name, + skillName: skill.name, + path: skill.path, + source: pkg.source, + version: pkg.version, + packageRoot: pkg.packageRoot, + warnings: scanned.warnings.filter((warning) => + warningMentionsPackage(warning, pkg.name), + ), + conflict: null, + } + } + + return null +} diff --git a/packages/intent/src/core/markdown.ts b/packages/intent/src/core/markdown.ts new file mode 100644 index 0000000..5ebabb8 --- /dev/null +++ b/packages/intent/src/core/markdown.ts @@ -0,0 +1,316 @@ +import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { toPosixPath } from '../utils.js' + +function resolveFromCwd(path: string): string { + return resolve(process.cwd(), path) +} + +function splitDestinationSuffix(destination: string): { + pathPart: string + suffix: string +} { + const hashIndex = destination.indexOf('#') + const queryIndex = destination.indexOf('?') + const suffixIndex = + hashIndex === -1 + ? queryIndex + : queryIndex === -1 + ? hashIndex + : Math.min(hashIndex, queryIndex) + + if (suffixIndex === -1) { + return { pathPart: destination, suffix: '' } + } + + return { + pathPart: destination.slice(0, suffixIndex), + suffix: destination.slice(suffixIndex), + } +} + +function isExternalOrAbsoluteDestination(destination: string): boolean { + return ( + destination === '' || + destination.startsWith('#') || + destination.startsWith('?') || + destination.startsWith('//') || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) || + isAbsolute(destination) + ) +} + +interface MarkdownDestinationRewriteContext { + cwd: string + resolvedPackageRoot: string + skillDir: string +} + +function findClosingBracket(line: string, start: number): number { + let depth = 0 + + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === '[') { + depth++ + continue + } + if (char === ']') { + depth-- + if (depth === 0) return index + } + } + + return -1 +} + +function findClosingParen(line: string, start: number): number { + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === ')') return index + } + + return -1 +} + +function readBareDestination( + line: string, + start: number, +): { destinationEnd: number; endParen: number } | null { + let depth = 0 + + for (let index = start; index < line.length; index++) { + const char = line[index]! + if (char === '\\') { + index++ + continue + } + if (char === '(') { + depth++ + continue + } + if (char === ')') { + if (depth === 0) { + return { destinationEnd: index, endParen: index } + } + depth-- + continue + } + if (/\s/.test(char) && depth === 0) { + const endParen = findClosingParen(line, index) + if (endParen === -1) return null + return { destinationEnd: index, endParen } + } + } + + return null +} + +function readMarkdownDestination( + line: string, + start: number, +): { + destination: string + destinationStart: number + destinationEnd: number + endParen: number +} | null { + let cursor = start + while (cursor < line.length && /\s/.test(line[cursor]!)) cursor++ + + if (line[cursor] === '<') { + const destinationStart = cursor + 1 + const destinationEnd = line.indexOf('>', destinationStart) + if (destinationEnd === -1) return null + const endParen = findClosingParen(line, destinationEnd + 1) + if (endParen === -1) return null + return { + destination: line.slice(destinationStart, destinationEnd), + destinationStart, + destinationEnd, + endParen, + } + } + + const read = readBareDestination(line, cursor) + if (!read) return null + + return { + destination: line.slice(cursor, read.destinationEnd), + destinationStart: cursor, + destinationEnd: read.destinationEnd, + endParen: read.endParen, + } +} + +function getCodeFenceMarker(line: string): '`' | '~' | null { + const match = line.match(/^\s*(`{3,}|~{3,})/) + const marker = match?.[1]?.[0] + return marker === '`' || marker === '~' ? marker : null +} + +function rewriteMarkdownDestination({ + context, + destination, +}: { + context: MarkdownDestinationRewriteContext + destination: string +}): string { + if (isExternalOrAbsoluteDestination(destination)) return destination + + const { pathPart, suffix } = splitDestinationSuffix(destination) + if (isExternalOrAbsoluteDestination(pathPart)) return destination + + const resolvedDestinationPath = resolve(context.skillDir, pathPart) + const relativeToPackageRoot = relative( + context.resolvedPackageRoot, + resolvedDestinationPath, + ) + if ( + relativeToPackageRoot.startsWith('..') || + isAbsolute(relativeToPackageRoot) + ) { + return destination + } + + const relativeToCwd = relative(context.cwd, resolvedDestinationPath) + const rewrittenPath = + relativeToCwd && + !relativeToCwd.startsWith('..') && + !isAbsolute(relativeToCwd) + ? relativeToCwd + : resolvedDestinationPath + + return `${toPosixPath(rewrittenPath)}${suffix}` +} + +function rewriteMarkdownLineDestinations({ + context, + line, +}: { + context: MarkdownDestinationRewriteContext + line: string +}): string { + if (!line.includes('[')) return line + + let output = '' + let cursor = 0 + + while (cursor < line.length) { + const nextCodeStart = line.indexOf('`', cursor) + const nextLinkStart = line.indexOf('[', cursor) + + if (nextLinkStart === -1) { + output += line.slice(cursor) + break + } + + if (nextCodeStart !== -1 && nextCodeStart < nextLinkStart) { + output += line.slice(cursor, nextCodeStart) + cursor = nextCodeStart + const codeStart = cursor + while (cursor < line.length && line[cursor] === '`') cursor++ + const marker = line.slice(codeStart, cursor) + const codeEnd = line.indexOf(marker, cursor) + if (codeEnd === -1) { + output += line.slice(codeStart) + break + } + output += line.slice(codeStart, codeEnd + marker.length) + cursor = codeEnd + marker.length + continue + } + + const linkStart = + nextLinkStart > 0 && line[nextLinkStart - 1] === '!' + ? nextLinkStart - 1 + : nextLinkStart + output += line.slice(cursor, linkStart) + + const labelStart = nextLinkStart + const labelEnd = findClosingBracket(line, labelStart) + if (labelEnd === -1) { + output += line.slice(linkStart) + break + } + + if (line[labelEnd + 1] !== '(') { + output += line.slice(linkStart, nextLinkStart + 1) + cursor = nextLinkStart + 1 + continue + } + + const destination = readMarkdownDestination(line, labelEnd + 2) + if (!destination) { + output += line.slice(linkStart, nextLinkStart + 1) + cursor = nextLinkStart + 1 + continue + } + + const rewritten = rewriteMarkdownDestination({ + context, + destination: destination.destination, + }) + output += + line.slice(linkStart, destination.destinationStart) + + rewritten + + line.slice(destination.destinationEnd, destination.endParen + 1) + cursor = destination.endParen + 1 + } + + return output +} + +export function rewriteLoadedSkillMarkdownDestinations({ + content, + cwd, + packageRoot, + skillFilePath, +}: { + content: string + cwd: string + packageRoot: string + skillFilePath: string +}): string { + const context: MarkdownDestinationRewriteContext = { + cwd, + resolvedPackageRoot: resolveFromCwd(packageRoot), + skillDir: dirname(skillFilePath), + } + let inFence: '`' | '~' | null = null + const parts = content.split(/(\r?\n)/) + let output = '' + + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index] ?? '' + const newline = parts[index + 1] ?? '' + const marker = getCodeFenceMarker(line) + + if (inFence) { + output += line + newline + if (marker === inFence) inFence = null + continue + } + + if (marker) { + inFence = marker + output += line + newline + continue + } + + output += + rewriteMarkdownLineDestinations({ + context, + line, + }) + newline + } + + return output +} diff --git a/packages/intent/src/core/package-json.ts b/packages/intent/src/core/package-json.ts new file mode 100644 index 0000000..e6493d2 --- /dev/null +++ b/packages/intent/src/core/package-json.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export function readPackageJson(dir: string): Record | null { + try { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record< + string, + unknown + > + } catch { + return null + } +} diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts new file mode 100644 index 0000000..576faec --- /dev/null +++ b/packages/intent/src/core/types.ts @@ -0,0 +1,54 @@ +import type { IntentPackage, VersionConflict } from '../types.js' + +export interface IntentCoreOptions { + cwd?: string + global?: boolean + globalOnly?: boolean + exclude?: Array +} + +export interface IntentSkillSummary { + use: string + packageName: string + packageVersion: string + packageSource: IntentPackage['source'] + skillName: string + description: string + type?: string + framework?: string +} + +export interface IntentPackageSummary { + name: string + version: string + source: IntentPackage['source'] + skillCount: number +} + +export interface IntentSkillList { + skills: Array + packages: Array + warnings: Array + conflicts: Array +} + +export interface LoadedIntentSkill { + content: string + path: string + packageRoot: string + packageName: string + skillName: string + version: string + source: IntentPackage['source'] + warnings: Array + conflict: VersionConflict | null +} + +export type IntentCoreErrorCode = + | 'invalid-options' + | 'invalid-skill-use' + | 'package-not-found' + | 'package-excluded' + | 'skill-not-found' + | 'skill-path-outside-package' + | 'skill-file-not-found' From 3a7b31550b2ece010c942824c167079ac3d0489c Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 23:33:38 -0700 Subject: [PATCH 08/20] add debug output for list and load --- docs/cli/intent-list.md | 4 +- docs/cli/intent-load.md | 4 +- packages/intent/src/cli-support.ts | 20 ++++++ packages/intent/src/cli.ts | 8 ++- packages/intent/src/commands/list.ts | 19 +++++- packages/intent/src/commands/load.ts | 20 +++++- packages/intent/src/core.ts | 98 ++++++++++++++++++++++++---- packages/intent/src/core/types.ts | 28 +++++++- packages/intent/tests/cli.test.ts | 62 ++++++++++++++++++ packages/intent/tests/core.test.ts | 52 +++++++++++++++ 10 files changed, 296 insertions(+), 19 deletions(-) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index ba1cce0..728d6cf 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -6,12 +6,13 @@ id: intent-list `intent list` discovers skill-enabled packages and prints available skills. ```bash -npx @tanstack/intent@latest list [--json] [--exclude ] [--global] [--global-only] +npx @tanstack/intent@latest list [--json] [--debug] [--exclude ] [--global] [--global-only] ``` ## Options - `--json`: print JSON instead of text output +- `--debug`: print discovery debug details to stderr - `--exclude `: exclude package names matching a simple glob; can be passed more than once - `--global`: include global packages after project packages - `--global-only`: list global packages only @@ -22,6 +23,7 @@ npx @tanstack/intent@latest list [--json] [--exclude ] [--global] [--gl - Includes global packages only when `--global` or `--global-only` is passed - Includes warnings from discovery - Excludes packages matched by package.json `intent.exclude` or `--exclude` +- Prints debug details to stderr when `--debug` is passed - If no packages are discovered, prints `No intent-enabled packages found.` - Summary line with package count and skill count - Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS` diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index e027829..8f515cf 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -6,13 +6,14 @@ id: intent-load `intent load` loads a compact skill identity from the current install and prints the matching `SKILL.md` content. ```bash -npx @tanstack/intent@latest load # [--path] [--json] [--exclude ] [--global] [--global-only] +npx @tanstack/intent@latest load # [--path] [--json] [--debug] [--exclude ] [--global] [--global-only] ``` ## Options - `--path`: print the resolved skill path instead of the file content - `--json`: print structured JSON with metadata and content +- `--debug`: print resolution debug details to stderr - `--exclude `: exclude package names matching a simple glob; can be passed more than once - `--global`: load from project packages first, then global packages - `--global-only`: load from global packages only @@ -27,6 +28,7 @@ npx @tanstack/intent@latest load # [--path] [--json] [--exclude - Accepts an unambiguous short skill name when a package-prefixed skill exists - Prints raw `SKILL.md` content by default - Prints the scanner-reported path when `--path` is passed +- Prints debug details to stderr when `--debug` is passed The package can be scoped or unscoped. The skill can include slash-separated sub-skill names. diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index a55f42c..cf15b5e 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -9,6 +9,7 @@ import type { ScanOptions, ScanResult, StalenessReport } from './types.js' export { printWarnings } from './cli-output.js' export interface GlobalScanFlags { + debug?: boolean exclude?: string | Array global?: boolean globalOnly?: boolean @@ -84,6 +85,7 @@ export function coreOptionsFromGlobalFlags( } return { + debug: options.debug, exclude: Array.isArray(options.exclude) ? options.exclude : options.exclude @@ -94,6 +96,24 @@ export function coreOptionsFromGlobalFlags( } } +function formatDebugValue(value: string | number | Array): string { + if (Array.isArray(value)) { + return value.length > 0 ? value.join(', ') : '(none)' + } + + return String(value) +} + +export function printDebugInfo( + title: string, + fields: Array<[label: string, value: string | number | Array]>, +): void { + console.error(`Debug: ${title}`) + for (const [label, value] of fields) { + console.error(` ${label}: ${formatDebugValue(value)}`) + } +} + export async function resolveStaleTargets( targetDir?: string, ): Promise { diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index a2105e1..54cb9af 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -34,8 +34,11 @@ function createCli(): CAC { 'list', 'Discover intent-enabled packages from the project or workspace', ) - .usage('list [--json] [--exclude ] [--global] [--global-only]') + .usage( + 'list [--json] [--debug] [--exclude ] [--global] [--global-only]', + ) .option('--json', 'Output JSON') + .option('--debug', 'Print discovery debug details to stderr') .option('--exclude ', 'Exclude package name glob') .option('--global', 'Include global packages after project packages') .option('--global-only', 'List global packages only') @@ -49,10 +52,11 @@ function createCli(): CAC { cli .command('load [use]', 'Load a compact skill use and print its SKILL.md') .usage( - 'load [--path] [--json] [--exclude ] [--global] [--global-only]', + 'load [--path] [--json] [--debug] [--exclude ] [--global] [--global-only]', ) .option('--path', 'Print the resolved skill path instead of file content') .option('--json', 'Output JSON') + .option('--debug', 'Print resolution debug details to stderr') .option('--exclude ', 'Exclude package name glob') .option('--global', 'Load from project packages, then global packages') .option('--global-only', 'Load from global packages only') diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 03a73fd..42d9b75 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,5 +1,6 @@ import { coreOptionsFromGlobalFlags, + printDebugInfo, printWarnings, type GlobalScanFlags, } from '../cli-support.js' @@ -15,6 +16,20 @@ export interface ListCommandOptions extends GlobalScanFlags { json?: boolean } +function printListDebug(result: IntentSkillList): void { + if (!result.debug) return + + printDebugInfo('intent list', [ + ['cwd', result.debug.cwd], + ['scope', result.debug.scope], + ['excludes', result.debug.excludes], + ['packages', result.debug.packageCount], + ['skills', result.debug.skillCount], + ['warnings', result.debug.warningCount], + ['conflicts', result.debug.conflictCount], + ]) +} + function printVersionConflicts(result: IntentSkillList): void { if (result.conflicts.length === 0) return @@ -51,9 +66,11 @@ export async function runListCommand( _scanIntentsOrFail?: (options?: ScanOptions) => Promise, ): Promise { const result = listIntentSkills(coreOptionsFromGlobalFlags(options)) + printListDebug(result) if (options.json) { - console.log(JSON.stringify(result, null, 2)) + const { debug: _debug, ...jsonResult } = result + console.log(JSON.stringify(jsonResult, null, 2)) return } diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 0e2787f..7e2c272 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,5 +1,5 @@ import { fail } from '../cli-error.js' -import { coreOptionsFromGlobalFlags } from '../cli-support.js' +import { coreOptionsFromGlobalFlags, printDebugInfo } from '../cli-support.js' import { IntentCoreError, loadIntentSkill } from '../core.js' import type { GlobalScanFlags } from '../cli-support.js' import type { ScanOptions, ScanResult } from '../types.js' @@ -9,6 +9,23 @@ export interface LoadCommandOptions extends GlobalScanFlags { path?: boolean } +function printLoadDebug(loaded: ReturnType): void { + if (!loaded.debug) return + + printDebugInfo('intent load', [ + ['cwd', loaded.debug.cwd], + ['scope', loaded.debug.scope], + ['resolution', loaded.debug.resolution], + ['excludes', loaded.debug.excludes], + ['package', loaded.debug.packageName], + ['version', loaded.debug.version], + ['source', loaded.debug.source], + ['skill', loaded.debug.skillName], + ['path', loaded.debug.path], + ['warnings', loaded.debug.warningCount], + ]) +} + export async function runLoadCommand( use: string | undefined, options: LoadCommandOptions, @@ -31,6 +48,7 @@ export async function runLoadCommand( } throw err } + printLoadDebug(loaded) if (options.path) { console.log(loaded.path) diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 50e1232..5a122e4 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -14,12 +14,13 @@ import { } from './resolver.js' import { formatSkillUse, parseSkillUse } from './skill-use.js' import { scanForIntents } from './scanner.js' -import type { ScanOptions } from './types.js' +import type { ScanOptions, ScanScope } from './types.js' import type { IntentCoreErrorCode, IntentCoreOptions, IntentSkillList, IntentSkillSummary, + LoadedIntentSkillDebug, LoadedIntentSkill, } from './core/types.js' @@ -27,8 +28,10 @@ export type { IntentCoreErrorCode, IntentCoreOptions, IntentPackageSummary, + IntentSkillListDebug, IntentSkillList, IntentSkillSummary, + LoadedIntentSkillDebug, LoadedIntentSkill, } from './core/types.js' @@ -61,6 +64,10 @@ function toScanOptions(options: IntentCoreOptions): ScanOptions { return { scope: 'local' } } +function getScanScope(options: ScanOptions): ScanScope { + return options.scope ?? (options.includeGlobal ? 'local-and-global' : 'local') +} + function withCwd(cwd: string | undefined, callback: () => T): T { if (!cwd) return callback() @@ -77,7 +84,8 @@ export function listIntentSkills( options: IntentCoreOptions = {}, ): IntentSkillList { return withCwd(options.cwd, () => { - const scanResult = scanForIntents(undefined, toScanOptions(options)) + const scanOptions = toScanOptions(options) + const scanResult = scanForIntents(undefined, scanOptions) const excludePatterns = getEffectiveExcludePatterns(options) const excludedPackages = scanResult.packages .filter((pkg) => isPackageExcluded(pkg.name, excludePatterns)) @@ -100,7 +108,7 @@ export function listIntentSkills( }), ) - return { + const result: IntentSkillList = { skills, packages: packages.map((pkg) => ({ name: pkg.name, @@ -118,6 +126,20 @@ export function listIntentSkills( (conflict) => !isPackageExcluded(conflict.packageName, excludePatterns), ), } + + if (options.debug) { + result.debug = { + cwd: process.cwd(), + scope: getScanScope(scanOptions), + excludes: excludePatterns, + packageCount: result.packages.length, + skillCount: result.skills.length, + warningCount: result.warnings.length, + conflictCount: result.conflicts.length, + } + } + + return result }) } @@ -139,6 +161,7 @@ function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { function loadResolvedIntentSkill( use: string, resolved: ResolveSkillResult, + debug?: LoadedIntentSkillDebug, ): LoadedIntentSkill { const resolvedPath = resolveFromCwd(resolved.path) @@ -163,7 +186,7 @@ function loadResolvedIntentSkill( skillFilePath: resolvedPath, }) - return { + const result: LoadedIntentSkill = { content, path: resolved.path, packageRoot: resolved.packageRoot, @@ -174,6 +197,37 @@ function loadResolvedIntentSkill( warnings: resolved.warnings, conflict: resolved.conflict, } + + if (debug) { + result.debug = debug + } + + return result +} + +function createLoadedSkillDebug({ + excludes, + resolution, + resolved, + scope, +}: { + excludes: Array + resolution: LoadedIntentSkillDebug['resolution'] + resolved: ResolveSkillResult + scope: ScanScope +}): LoadedIntentSkillDebug { + return { + cwd: process.cwd(), + scope, + resolution, + excludes, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + path: resolved.path, + warningCount: resolved.warnings.length, + } } export function loadIntentSkill( @@ -191,12 +245,9 @@ export function loadIntentSkill( ) } - if ( - isPackageExcluded( - parsedUse.packageName, - getEffectiveExcludePatterns(options), - ) - ) { + const excludePatterns = getEffectiveExcludePatterns(options) + + if (isPackageExcluded(parsedUse.packageName, excludePatterns)) { throw new IntentCoreError( 'package-excluded', `Cannot load skill use "${use}": package "${parsedUse.packageName}" is excluded by Intent configuration.`, @@ -204,9 +255,21 @@ export function loadIntentSkill( } const scanOptions = toScanOptions(options) + const scope = getScanScope(scanOptions) const fastPathResolved = resolveSkillUseFastPath(parsedUse, options) if (fastPathResolved) { - return loadResolvedIntentSkill(use, fastPathResolved) + return loadResolvedIntentSkill( + use, + fastPathResolved, + options.debug + ? createLoadedSkillDebug({ + excludes: excludePatterns, + resolution: 'fast-path', + resolved: fastPathResolved, + scope, + }) + : undefined, + ) } const scanResult = scanForIntents(undefined, scanOptions) @@ -220,6 +283,17 @@ export function loadIntentSkill( throw err } - return loadResolvedIntentSkill(use, resolved) + return loadResolvedIntentSkill( + use, + resolved, + options.debug + ? createLoadedSkillDebug({ + excludes: excludePatterns, + resolution: 'full-scan', + resolved, + scope, + }) + : undefined, + ) }) } diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 576faec..3c29b27 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -1,7 +1,8 @@ -import type { IntentPackage, VersionConflict } from '../types.js' +import type { IntentPackage, ScanScope, VersionConflict } from '../types.js' export interface IntentCoreOptions { cwd?: string + debug?: boolean global?: boolean globalOnly?: boolean exclude?: Array @@ -30,6 +31,7 @@ export interface IntentSkillList { packages: Array warnings: Array conflicts: Array + debug?: IntentSkillListDebug } export interface LoadedIntentSkill { @@ -42,6 +44,30 @@ export interface LoadedIntentSkill { source: IntentPackage['source'] warnings: Array conflict: VersionConflict | null + debug?: LoadedIntentSkillDebug +} + +export interface IntentSkillListDebug { + cwd: string + scope: ScanScope + excludes: Array + packageCount: number + skillCount: number + warningCount: number + conflictCount: number +} + +export interface LoadedIntentSkillDebug { + cwd: string + scope: ScanScope + resolution: 'fast-path' | 'full-scan' + excludes: Array + packageName: string + skillName: string + version: string + source: IntentPackage['source'] + path: string + warningCount: number } export type IntentCoreErrorCode = diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index d323020..087251f 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -576,6 +576,37 @@ describe('cli commands', () => { expect(parsed.warnings).toEqual([]) }) + it('prints list debug details to stderr without changing json stdout', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-debug-')) + tempDirs.push(root) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.chdir(root) + + const exitCode = await main(['list', '--json', '--debug']) + const output = logSpy.mock.calls.at(-1)?.[0] + const parsed = JSON.parse(String(output)) as { + debug?: unknown + packages: Array<{ name: string }> + } + const debugOutput = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(parsed.debug).toBeUndefined() + expect(parsed.packages.map((pkg) => pkg.name)).toEqual([ + '@tanstack/query', + ]) + expect(debugOutput).toContain('Debug: intent list') + expect(debugOutput).toContain(`cwd: ${root}`) + expect(debugOutput).toContain('scope: local') + expect(debugOutput).toContain('packages: 1') + expect(debugOutput).toContain('skills: 1') + }) + it('ignores configured global intent packages in list json output by default', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-local-only-')) const globalRoot = mkdtempSync( @@ -975,6 +1006,37 @@ describe('cli commands', () => { expect(output).toBe('node_modules/@tanstack/query/skills/fetching/SKILL.md') }) + it('prints load debug details to stderr without changing path stdout', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-debug-')) + tempDirs.push(root) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.chdir(root) + + const exitCode = await main([ + 'load', + '@tanstack/query#fetching', + '--path', + '--debug', + ]) + const output = logSpy.mock.calls.flat().join('\n') + const debugOutput = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toBe('node_modules/@tanstack/query/skills/fetching/SKILL.md') + expect(debugOutput).toContain('Debug: intent load') + expect(debugOutput).toContain(`cwd: ${root}`) + expect(debugOutput).toContain('scope: local') + expect(debugOutput).toContain('resolution: fast-path') + expect(debugOutput).toContain('package: @tanstack/query') + expect(debugOutput).toContain('skill: fetching') + }) + it('loads a skill use as json', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-json-')) tempDirs.push(root) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 21cdcf4..367deca 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -129,6 +129,31 @@ describe('listIntentSkills', () => { }) }) + it('includes debug metadata when requested', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = listIntentSkills({ + cwd: root, + debug: true, + exclude: ['@tanstack/devtools'], + }) + + expect(result.debug).toEqual({ + cwd: root, + scope: 'local', + excludes: ['@tanstack/devtools'], + packageCount: 1, + skillCount: 1, + warningCount: 0, + conflictCount: 0, + }) + }) + it('hides packages matched by configured exclude globs', () => { writeJson(join(root, 'package.json'), { name: 'test-app', @@ -215,6 +240,33 @@ describe('loadIntentSkill', () => { }) }) + it('includes load debug metadata when requested', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = loadIntentSkill('@tanstack/query#fetching', { + cwd: root, + debug: true, + }) + + expect(result.debug).toEqual({ + cwd: root, + scope: 'local', + resolution: 'fast-path', + excludes: [], + packageName: '@tanstack/query', + skillName: 'fetching', + version: '5.0.0', + source: 'local', + path: 'node_modules/@tanstack/query/skills/fetching/SKILL.md', + warningCount: 0, + }) + }) + it('rejects conflicting scan options before the fast path', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', From aa4d81395a0c39c270ff2225fb993e85606a36ea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 06:36:40 +0000 Subject: [PATCH 09/20] ci: apply automated fixes --- packages/intent/src/core/load-resolution.ts | 4 +--- packages/intent/src/core/package-json.ts | 7 +++---- packages/intent/tests/cli.test.ts | 8 ++------ packages/intent/tests/core.test.ts | 4 +--- packages/intent/tests/resolver.test.ts | 5 +---- packages/intent/tests/scanner.test.ts | 14 +++++++------- 6 files changed, 15 insertions(+), 27 deletions(-) diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index bf6be1c..6fba057 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -96,9 +96,7 @@ function workspacePackageDeclaresDependency( return getDeps(packageJson).includes(packageName) } -function getLoadFastPathCandidateDirs( - packageName: string, -): Array { +function getLoadFastPathCandidateDirs(packageName: string): Array { const cwd = process.cwd() const context = resolveProjectContext({ cwd }) const workspacePackages = readWorkspacePackageInfos(cwd) diff --git a/packages/intent/src/core/package-json.ts b/packages/intent/src/core/package-json.ts index e6493d2..80fa105 100644 --- a/packages/intent/src/core/package-json.ts +++ b/packages/intent/src/core/package-json.ts @@ -3,10 +3,9 @@ import { join } from 'node:path' export function readPackageJson(dir: string): Record | null { try { - return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record< - string, - unknown - > + return JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as Record } catch { return null } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 087251f..02f3fbc 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -597,9 +597,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(parsed.debug).toBeUndefined() - expect(parsed.packages.map((pkg) => pkg.name)).toEqual([ - '@tanstack/query', - ]) + expect(parsed.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) expect(debugOutput).toContain('Debug: intent list') expect(debugOutput).toContain(`cwd: ${root}`) expect(debugOutput).toContain('scope: local') @@ -855,9 +853,7 @@ describe('cli commands', () => { } expect(exitCode).toBe(0) - expect(parsed.packages.map((pkg) => pkg.name)).toEqual([ - '@tanstack/query', - ]) + expect(parsed.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) expect(parsed.skills.map((skill) => skill.use)).toEqual([ '@tanstack/query#fetching', ]) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 367deca..7106be4 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -175,9 +175,7 @@ describe('listIntentSkills', () => { const result = listIntentSkills({ cwd: root }) - expect(result.packages.map((pkg) => pkg.name)).toEqual([ - '@tanstack/query', - ]) + expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) expect(result.skills.map((skill) => skill.use)).toEqual([ '@tanstack/query#fetching', ]) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index bcc698e..0912140 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -319,10 +319,7 @@ describe('resolveSkillUse', () => { }) expect(() => { - resolveSkillUse( - '@tanstack/router-core#guards', - scanResult([pkg]), - ) + resolveSkillUse('@tanstack/router-core#guards', scanResult([pkg])) }).toThrow( 'Did you mean @tanstack/router-core#router-core/auth-and-guards or @tanstack/router-core#router-core/setup-guards?', ) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index bc2f29a..065a65a 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -847,13 +847,13 @@ describe('scanForIntents', () => { expect(result.nodeModules.local.scanned).toBe(true) 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', - ], - ) + expect( + result.packages[0]!.skills.map((skill) => skill.name).sort(), + ).toEqual([ + 'lifecycle/migrate-from-nextjs', + 'react-start', + 'react-start/server-components', + ]) expect(result.warnings).toEqual([]) }) From 7d98ac719bfb0a5f54a0fc9d5194efee0ec00cf0 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 1 May 2026 23:54:12 -0700 Subject: [PATCH 10/20] skip content reads for path loads --- packages/intent/src/commands/load.ts | 42 +++++++++++++------ packages/intent/src/core.ts | 46 ++++++++++++++------- packages/intent/src/core/load-resolution.ts | 26 ++++++++---- packages/intent/src/core/types.ts | 7 +++- packages/intent/tests/cli.test.ts | 22 ++++++++++ packages/intent/tests/core.test.ts | 39 +++++++++++++++++ 6 files changed, 146 insertions(+), 36 deletions(-) diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 7e2c272..93d42fb 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,7 +1,12 @@ import { fail } from '../cli-error.js' import { coreOptionsFromGlobalFlags, printDebugInfo } from '../cli-support.js' -import { IntentCoreError, loadIntentSkill } from '../core.js' +import { + IntentCoreError, + loadIntentSkill, + resolveIntentSkill, +} from '../core.js' import type { GlobalScanFlags } from '../cli-support.js' +import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core.js' import type { ScanOptions, ScanResult } from '../types.js' export interface LoadCommandOptions extends GlobalScanFlags { @@ -9,7 +14,7 @@ export interface LoadCommandOptions extends GlobalScanFlags { path?: boolean } -function printLoadDebug(loaded: ReturnType): void { +function printLoadDebug(loaded: LoadedIntentSkill | ResolvedIntentSkill): void { if (!loaded.debug) return printDebugInfo('intent load', [ @@ -39,9 +44,30 @@ export async function runLoadCommand( fail('Use either --json or --path, not both.') } - let loaded: ReturnType + const coreOptions = coreOptionsFromGlobalFlags(options) + + if (options.path) { + let resolved: ResolvedIntentSkill + try { + resolved = resolveIntentSkill(use, coreOptions) + } catch (err) { + if (err instanceof IntentCoreError) { + fail(err.message) + } + throw err + } + printLoadDebug(resolved) + + console.log(resolved.path) + for (const warning of resolved.warnings) { + console.error(`Warning: ${warning}`) + } + return + } + + let loaded: LoadedIntentSkill try { - loaded = loadIntentSkill(use, coreOptionsFromGlobalFlags(options)) + loaded = loadIntentSkill(use, coreOptions) } catch (err) { if (err instanceof IntentCoreError) { fail(err.message) @@ -50,14 +76,6 @@ export async function runLoadCommand( } printLoadDebug(loaded) - if (options.path) { - console.log(loaded.path) - for (const warning of loaded.warnings) { - console.error(`Warning: ${warning}`) - } - return - } - if (options.json) { console.log( JSON.stringify( diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 5a122e4..4e772e7 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -22,6 +22,7 @@ import type { IntentSkillSummary, LoadedIntentSkillDebug, LoadedIntentSkill, + ResolvedIntentSkill, } from './core/types.js' export type { @@ -33,6 +34,7 @@ export type { IntentSkillSummary, LoadedIntentSkillDebug, LoadedIntentSkill, + ResolvedIntentSkill, } from './core/types.js' export class IntentCoreError extends Error { @@ -158,11 +160,11 @@ function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { ) } -function loadResolvedIntentSkill( +function toResolvedIntentSkill( use: string, resolved: ResolveSkillResult, debug?: LoadedIntentSkillDebug, -): LoadedIntentSkill { +): ResolvedIntentSkill { const resolvedPath = resolveFromCwd(resolved.path) if (!isPathInsidePackageRoot(resolved.path, resolved.packageRoot)) { @@ -179,15 +181,7 @@ function loadResolvedIntentSkill( ) } - const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolvedPath, 'utf8'), - cwd: process.cwd(), - packageRoot: resolved.packageRoot, - skillFilePath: resolvedPath, - }) - - const result: LoadedIntentSkill = { - content, + const result: ResolvedIntentSkill = { path: resolved.path, packageRoot: resolved.packageRoot, packageName: resolved.packageName, @@ -230,10 +224,10 @@ function createLoadedSkillDebug({ } } -export function loadIntentSkill( +export function resolveIntentSkill( use: string, options: IntentCoreOptions = {}, -): LoadedIntentSkill { +): ResolvedIntentSkill { return withCwd(options.cwd, () => { let parsedUse: ReturnType try { @@ -258,7 +252,7 @@ export function loadIntentSkill( const scope = getScanScope(scanOptions) const fastPathResolved = resolveSkillUseFastPath(parsedUse, options) if (fastPathResolved) { - return loadResolvedIntentSkill( + return toResolvedIntentSkill( use, fastPathResolved, options.debug @@ -283,7 +277,7 @@ export function loadIntentSkill( throw err } - return loadResolvedIntentSkill( + return toResolvedIntentSkill( use, resolved, options.debug @@ -297,3 +291,25 @@ export function loadIntentSkill( ) }) } + +export function loadIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): LoadedIntentSkill { + const resolved = resolveIntentSkill(use, options) + + return withCwd(options.cwd, () => { + const resolvedPath = resolveFromCwd(resolved.path) + const content = rewriteLoadedSkillMarkdownDestinations({ + content: readFileSync(resolvedPath, 'utf8'), + cwd: process.cwd(), + packageRoot: resolved.packageRoot, + skillFilePath: resolvedPath, + }) + + return { + ...resolved, + content, + } + }) +} diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index bf6be1c..2b15c59 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -6,7 +6,10 @@ import { resolveWorkspacePackages } from '../workspace-patterns.js' import { getDeps, resolveDepDir } from '../utils.js' import { warningMentionsPackage } from './excludes.js' import { readPackageJson } from './package-json.js' -import { resolveProjectContext } from './project-context.js' +import { + resolveProjectContext, + type ProjectContext, +} from './project-context.js' import type { SkillUse } from '../skill-use.js' import type { IntentCoreOptions } from './types.js' @@ -16,8 +19,9 @@ interface WorkspacePackageInfo { packageJson: Record } -function readWorkspacePackageInfos(cwd: string): Array { - const context = resolveProjectContext({ cwd }) +function readWorkspacePackageInfos( + context: ProjectContext, +): Array { const dirs = new Set() if (context.packageRoot) { @@ -96,15 +100,23 @@ function workspacePackageDeclaresDependency( return getDeps(packageJson).includes(packageName) } -function getLoadFastPathCandidateDirs( - packageName: string, -): Array { +function getLoadFastPathCandidateDirs(packageName: string): Array { const cwd = process.cwd() const context = resolveProjectContext({ cwd }) - const workspacePackages = readWorkspacePackageInfos(cwd) const candidates: Array = [] const seen = new Set() + if (!context.workspaceRoot) { + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, context.packageRoot ?? cwd), + ) + return candidates + } + + const workspacePackages = readWorkspacePackageInfos(context) + for (const pkg of workspacePackages) { if (pkg.name === packageName) { addCandidateDir(candidates, seen, pkg.dir) diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 3c29b27..27a47fe 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -34,8 +34,7 @@ export interface IntentSkillList { debug?: IntentSkillListDebug } -export interface LoadedIntentSkill { - content: string +export interface ResolvedIntentSkill { path: string packageRoot: string packageName: string @@ -47,6 +46,10 @@ export interface LoadedIntentSkill { debug?: LoadedIntentSkillDebug } +export interface LoadedIntentSkill extends ResolvedIntentSkill { + content: string +} + export interface IntentSkillListDebug { cwd: string scope: ScanScope diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 087251f..6a6239d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1006,6 +1006,28 @@ describe('cli commands', () => { expect(output).toBe('node_modules/@tanstack/query/skills/fetching/SKILL.md') }) + it('prints a skill path without reading skill content', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-path-only-')) + 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/' }, + }) + mkdirSync(join(pkgDir, 'skills', 'fetching', 'SKILL.md'), { + recursive: true, + }) + + process.chdir(root) + + const exitCode = await main(['load', '@tanstack/query#fetching', '--path']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toBe('node_modules/@tanstack/query/skills/fetching/SKILL.md') + }) + it('prints load debug details to stderr without changing path stdout', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-debug-')) tempDirs.push(root) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 367deca..ae6d694 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -13,6 +13,7 @@ import { IntentCoreError, listIntentSkills, loadIntentSkill, + resolveIntentSkill, } from '../src/core.js' const realTmpdir = realpathSync(tmpdir()) @@ -217,6 +218,44 @@ describe('listIntentSkills', () => { }) describe('loadIntentSkill', () => { + it('resolves skill metadata without loading content', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = resolveIntentSkill('@tanstack/query#fetching', { + cwd: root, + debug: true, + }) + + expect(result).toEqual({ + path: 'node_modules/@tanstack/query/skills/fetching/SKILL.md', + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), + packageName: '@tanstack/query', + skillName: 'fetching', + version: '5.0.0', + source: 'local', + warnings: [], + conflict: null, + debug: { + cwd: root, + scope: 'local', + resolution: 'fast-path', + excludes: [], + packageName: '@tanstack/query', + skillName: 'fetching', + version: '5.0.0', + source: 'local', + path: 'node_modules/@tanstack/query/skills/fetching/SKILL.md', + warningCount: 0, + }, + }) + expect('content' in result).toBe(false) + }) + it('loads skill content with package metadata', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', From 2146c51651f0aa291881582779ac4fe4bde64718 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 00:18:42 -0700 Subject: [PATCH 11/20] harden load resolution and list identity --- knip.json | 1 + packages/intent/src/commands/list.ts | 7 +- packages/intent/src/core.ts | 24 +++-- packages/intent/src/core/excludes.ts | 2 +- packages/intent/src/core/load-resolution.ts | 14 +++ packages/intent/src/core/types.ts | 2 + packages/intent/src/scanner.ts | 6 +- packages/intent/tests/core.test.ts | 45 +++++++++ packages/intent/tests/scanner.test.ts | 105 ++++++++++++++++++++ 9 files changed, 190 insertions(+), 16 deletions(-) diff --git a/knip.json b/knip.json index b3a9b9f..885afaf 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "entry": [ "src/index.ts", "src/cli.ts", + "src/core.ts", "src/setup.ts", "src/intent-library.ts", "src/library-scanner.ts" diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 42d9b75..9cd8af5 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -53,12 +53,7 @@ function getPackageSkills( pkg: IntentPackageSummary, result: IntentSkillList, ): Array { - return result.skills.filter( - (skill) => - skill.packageName === pkg.name && - skill.packageVersion === pkg.version && - skill.packageSource === pkg.source, - ) + return result.skills.filter((skill) => skill.packageRoot === pkg.packageRoot) } export async function runListCommand( diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 4e772e7..247e524 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { isAbsolute, relative, resolve } from 'node:path' import { getEffectiveExcludePatterns, @@ -100,6 +100,7 @@ export function listIntentSkills( return { use: formatSkillUse(pkg.name, skill.name), packageName: pkg.name, + packageRoot: pkg.packageRoot, packageVersion: pkg.version, packageSource: pkg.source, skillName: skill.name, @@ -116,6 +117,7 @@ export function listIntentSkills( name: pkg.name, version: pkg.version, source: pkg.source, + packageRoot: pkg.packageRoot, skillCount: pkg.skills.length, })), warnings: scanResult.warnings.filter( @@ -165,16 +167,25 @@ function toResolvedIntentSkill( resolved: ResolveSkillResult, debug?: LoadedIntentSkillDebug, ): ResolvedIntentSkill { - const resolvedPath = resolveFromCwd(resolved.path) + let realResolvedPath: string + try { + realResolvedPath = realpathSync(resolveFromCwd(resolved.path)) + } catch { + throw new IntentCoreError( + 'skill-file-not-found', + `Resolved skill file was not found: ${resolved.path}`, + ) + } + const realPackageRoot = realpathSync(resolveFromCwd(resolved.packageRoot)) - if (!isPathInsidePackageRoot(resolved.path, resolved.packageRoot)) { + if (!isPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { throw new IntentCoreError( 'skill-path-outside-package', `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, ) } - if (!existsSync(resolvedPath)) { + if (!existsSync(realResolvedPath)) { throw new IntentCoreError( 'skill-file-not-found', `Resolved skill file was not found: ${resolved.path}`, @@ -299,11 +310,12 @@ export function loadIntentSkill( const resolved = resolveIntentSkill(use, options) return withCwd(options.cwd, () => { - const resolvedPath = resolveFromCwd(resolved.path) + const resolvedPath = realpathSync(resolveFromCwd(resolved.path)) + const packageRoot = realpathSync(resolveFromCwd(resolved.packageRoot)) const content = rewriteLoadedSkillMarkdownDestinations({ content: readFileSync(resolvedPath, 'utf8'), cwd: process.cwd(), - packageRoot: resolved.packageRoot, + packageRoot, skillFilePath: resolvedPath, }) diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index d271099..4adfed4 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -3,7 +3,7 @@ import { resolveProjectContext } from './project-context.js' import { readPackageJson } from './package-json.js' import type { IntentCoreOptions } from './types.js' -export function normalizeExcludePatterns(value: unknown): Array { +function normalizeExcludePatterns(value: unknown): Array { if (!Array.isArray(value)) return [] return value diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index 2b15c59..d558ee2 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -100,6 +100,19 @@ function workspacePackageDeclaresDependency( return getDeps(packageJson).includes(packageName) } +function hasYarnPnpFile(dir: string | null): boolean { + return ( + dir !== null && + (existsSync(join(dir, '.pnp.cjs')) || existsSync(join(dir, '.pnp.js'))) + ) +} + +function shouldSkipFastPathForYarnPnp(): boolean { + const cwd = process.cwd() + const context = resolveProjectContext({ cwd }) + return hasYarnPnpFile(cwd) || hasYarnPnpFile(context.workspaceRoot) +} + function getLoadFastPathCandidateDirs(packageName: string): Array { const cwd = process.cwd() const context = resolveProjectContext({ cwd }) @@ -160,6 +173,7 @@ export function resolveSkillUseFastPath( options: IntentCoreOptions, ): ResolveSkillResult | null { if (options.globalOnly) return null + if (shouldSkipFastPathForYarnPnp()) return null for (const packageRoot of getLoadFastPathCandidateDirs( parsedUse.packageName, diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 27a47fe..9d5e03e 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -11,6 +11,7 @@ export interface IntentCoreOptions { export interface IntentSkillSummary { use: string packageName: string + packageRoot: string packageVersion: string packageSource: IntentPackage['source'] skillName: string @@ -23,6 +24,7 @@ export interface IntentPackageSummary { name: string version: string source: IntentPackage['source'] + packageRoot: string skillCount: number } diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 2ded282..4289d1a 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -570,13 +570,13 @@ export function scanForIntents( } assertLocalNodeModulesSupported(projectRoot) - const packageCountBeforeNodeModules = packages.length - scanTarget(nodeModules.local) walkWorkspacePackages() + const packageCountBeforeDependencyDiscovery = packages.length + scanTarget(nodeModules.local) walkKnownPackages() walkProjectDeps() - if (packages.length === packageCountBeforeNodeModules) { + if (packages.length === packageCountBeforeDependencyDiscovery) { const api = getPnpApi() if (api) { scanPnpPackages(api) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 7d6934d..b5de38d 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -109,6 +109,7 @@ describe('listIntentSkills', () => { { use: '@tanstack/query#fetching', packageName: '@tanstack/query', + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), packageVersion: '5.0.0', packageSource: 'local', skillName: 'fetching', @@ -122,6 +123,7 @@ describe('listIntentSkills', () => { name: '@tanstack/query', version: '5.0.0', source: 'local', + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), skillCount: 1, }, ], @@ -277,6 +279,32 @@ describe('loadIntentSkill', () => { }) }) + it('rejects a skill symlink that escapes the package root', () => { + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(pkgDir, 'skills', 'fetching') + const outsideDir = join(root, 'outside') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + mkdirSync(skillDir, { recursive: true }) + writeSkillMd({ + dir: outsideDir, + frontmatter: { + name: 'fetching', + description: 'Escaped skill', + }, + }) + symlinkSync(join(outsideDir, 'SKILL.md'), join(skillDir, 'SKILL.md')) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Resolved skill path for "@tanstack/query#fetching" is outside package root: node_modules/@tanstack/query/skills/fetching/SKILL.md', + ) + }) + it('includes load debug metadata when requested', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', @@ -304,6 +332,23 @@ describe('loadIntentSkill', () => { }) }) + it('uses the full scan in Yarn PnP projects with visible node_modules', () => { + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = loadIntentSkill('@tanstack/query#fetching', { + cwd: root, + debug: true, + }) + + expect(result.debug?.resolution).toBe('full-scan') + }) + it('rejects conflicting scan options before the fast path', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 065a65a..3917883 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -857,6 +857,111 @@ describe('scanForIntents', () => { expect(result.warnings).toEqual([]) }) + it('falls back to Yarn PnP when workspace discovery finds packages first', () => { + 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', + ) + const appDir = createDir(root, 'packages', 'app') + const workspaceSkillDir = createDir( + appDir, + 'node_modules', + 'workspace-skill-pkg', + ) + + writeJson(join(root, 'package.json'), { + name: 'tanstack-intent-pnp-monorepo', + version: '0.0.0', + private: true, + packageManager: 'yarn@4.12.0', + workspaces: ['packages/*'], + dependencies: { + '@tanstack/react-start': '1.167.52', + }, + }) + writeJson(join(appDir, 'package.json'), { + name: '@test/app', + version: '0.0.0', + dependencies: { + 'workspace-skill-pkg': '1.0.0', + }, + }) + writeJson(join(workspaceSkillDir, 'package.json'), { + name: 'workspace-skill-pkg', + version: '1.0.0', + intent: { version: 1, repo: 'test/workspace', docs: 'docs/' }, + }) + writeSkillMd(createDir(workspaceSkillDir, 'skills', 'core'), { + name: 'core', + description: 'Workspace skill', + }) + 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', + }) + + writeFileSync( + join(root, '.pnp.cjs'), + [ + `const projectRoot = ${JSON.stringify(`${root}/`)}`, + `const reactStartRoot = ${JSON.stringify(`${reactStartDir}/`)}`, + "const rootLocator = { name: 'tanstack-intent-pnp-monorepo', reference: 'workspace:.' }", + "const reactStartLocator = { name: '@tanstack/react-start', reference: 'virtual:test#npm:1.167.52' }", + 'module.exports = {', + ' 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-monorepo') {", + ' 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'), + ) + createDir(root, 'node_modules') + + const result = scanForIntents(root) + + expect(result.packages.map((pkg) => pkg.name).sort()).toEqual([ + '@tanstack/react-start', + 'workspace-skill-pkg', + ]) + }) + it('discovers skills using package.json workspaces', () => { writeJson(join(root, 'package.json'), { name: 'monorepo', From a9d766873aa728f52a52b612a2f693b4b69c2efe Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 00:47:10 -0700 Subject: [PATCH 12/20] reduce direct load path filesystem work --- packages/intent/src/commands/list.ts | 26 +++++-- packages/intent/src/core.ts | 37 +++++----- packages/intent/src/core/excludes.ts | 14 ++-- packages/intent/src/core/load-resolution.ts | 76 ++++++++++++++------- packages/intent/src/scanner.ts | 54 +++++++++++---- packages/intent/tests/scanner.test.ts | 66 +++++++++++++++++- 6 files changed, 205 insertions(+), 68 deletions(-) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 9cd8af5..f354487 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -49,11 +49,28 @@ function printVersionConflicts(result: IntentSkillList): void { } } +function groupSkillsByPackageRoot( + skills: Array, +): Map> { + const grouped = new Map>() + + for (const skill of skills) { + const packageSkills = grouped.get(skill.packageRoot) + if (packageSkills) { + packageSkills.push(skill) + } else { + grouped.set(skill.packageRoot, [skill]) + } + } + + return grouped +} + function getPackageSkills( pkg: IntentPackageSummary, - result: IntentSkillList, + skillsByPackageRoot: Map>, ): Array { - return result.skills.filter((skill) => skill.packageRoot === pkg.packageRoot) + return skillsByPackageRoot.get(pkg.packageRoot) ?? [] } export async function runListCommand( @@ -95,8 +112,9 @@ export async function runListCommand( printVersionConflicts(result) + const skillsByPackageRoot = groupSkillsByPackageRoot(result.skills) const allSkills = result.packages.map((pkg) => - getPackageSkills(pkg, result).map((skill) => ({ + getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, description: skill.description, type: skill.type, @@ -109,7 +127,7 @@ export async function runListCommand( for (const pkg of result.packages) { console.log(` ${pkg.name}`) printSkillTree( - getPackageSkills(pkg, result).map((skill) => ({ + getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, description: skill.description, type: skill.type, diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 247e524..825750f 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' import { isAbsolute, relative, resolve } from 'node:path' import { getEffectiveExcludePatterns, @@ -7,6 +7,7 @@ import { } from './core/excludes.js' import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' import { resolveSkillUseFastPath } from './core/load-resolution.js' +import { resolveProjectContext } from './core/project-context.js' import { ResolveSkillUseError, resolveSkillUse, @@ -151,11 +152,11 @@ function resolveFromCwd(path: string): string { return resolve(process.cwd(), path) } -function isPathInsidePackageRoot(path: string, packageRoot: string): boolean { - const relativePath = relative( - resolveFromCwd(packageRoot), - resolveFromCwd(path), - ) +function isResolvedPathInsidePackageRoot( + path: string, + packageRoot: string, +): boolean { + const relativePath = relative(packageRoot, path) return ( relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) @@ -169,29 +170,24 @@ function toResolvedIntentSkill( ): ResolvedIntentSkill { let realResolvedPath: string try { - realResolvedPath = realpathSync(resolveFromCwd(resolved.path)) + realResolvedPath = realpathSync.native(resolveFromCwd(resolved.path)) } catch { throw new IntentCoreError( 'skill-file-not-found', `Resolved skill file was not found: ${resolved.path}`, ) } - const realPackageRoot = realpathSync(resolveFromCwd(resolved.packageRoot)) + const realPackageRoot = realpathSync.native( + resolveFromCwd(resolved.packageRoot), + ) - if (!isPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { + if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { throw new IntentCoreError( 'skill-path-outside-package', `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, ) } - if (!existsSync(realResolvedPath)) { - throw new IntentCoreError( - 'skill-file-not-found', - `Resolved skill file was not found: ${resolved.path}`, - ) - } - const result: ResolvedIntentSkill = { path: resolved.path, packageRoot: resolved.packageRoot, @@ -250,7 +246,8 @@ export function resolveIntentSkill( ) } - const excludePatterns = getEffectiveExcludePatterns(options) + const projectContext = resolveProjectContext({ cwd: process.cwd() }) + const excludePatterns = getEffectiveExcludePatterns(options, projectContext) if (isPackageExcluded(parsedUse.packageName, excludePatterns)) { throw new IntentCoreError( @@ -261,7 +258,11 @@ export function resolveIntentSkill( const scanOptions = toScanOptions(options) const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath(parsedUse, options) + const fastPathResolved = resolveSkillUseFastPath( + parsedUse, + options, + projectContext, + ) if (fastPathResolved) { return toResolvedIntentSkill( use, diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 4adfed4..f00cb7c 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -1,5 +1,8 @@ import { dirname, isAbsolute, relative } from 'node:path' -import { resolveProjectContext } from './project-context.js' +import { + resolveProjectContext, + type ProjectContext, +} from './project-context.js' import { readPackageJson } from './package-json.js' import type { IntentCoreOptions } from './types.js' @@ -25,8 +28,10 @@ function readPackageExcludes(dir: string): Array { return normalizeExcludePatterns((intent as Record).exclude) } -function getConfigExcludePatterns(cwd: string): Array { - const context = resolveProjectContext({ cwd }) +function getConfigExcludePatterns( + cwd: string, + context = resolveProjectContext({ cwd }), +): Array { const root = context.workspaceRoot ?? context.packageRoot ?? cwd const dirs: Array = [] let dir = cwd @@ -45,9 +50,10 @@ function getConfigExcludePatterns(cwd: string): Array { export function getEffectiveExcludePatterns( options: IntentCoreOptions, + context?: ProjectContext, ): Array { return [ - ...getConfigExcludePatterns(process.cwd()), + ...getConfigExcludePatterns(process.cwd(), context), ...normalizeExcludePatterns(options.exclude), ] } diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index d558ee2..b32754b 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -107,15 +107,16 @@ function hasYarnPnpFile(dir: string | null): boolean { ) } -function shouldSkipFastPathForYarnPnp(): boolean { +function shouldSkipFastPathForYarnPnp(context: ProjectContext): boolean { const cwd = process.cwd() - const context = resolveProjectContext({ cwd }) return hasYarnPnpFile(cwd) || hasYarnPnpFile(context.workspaceRoot) } -function getLoadFastPathCandidateDirs(packageName: string): Array { +function getLoadFastPathCandidateDirs( + packageName: string, + context: ProjectContext, +): Array { const cwd = process.cwd() - const context = resolveProjectContext({ cwd }) const candidates: Array = [] const seen = new Set() @@ -168,41 +169,64 @@ function getLoadFastPathCandidateDirs(packageName: string): Array { return candidates } +function resolveScannedPackageSkill( + scanned: ReturnType, + parsedUse: SkillUse, +): ResolveSkillResult | null { + const pkg = scanned.package + if (!pkg || pkg.name !== parsedUse.packageName) return null + + const skill = resolveSkillEntry( + pkg.name, + parsedUse.skillName, + pkg.skills, + ).skill + if (!skill) return null + + return { + packageName: pkg.name, + skillName: skill.name, + path: skill.path, + source: pkg.source, + version: pkg.version, + packageRoot: pkg.packageRoot, + warnings: scanned.warnings.filter((warning) => + warningMentionsPackage(warning, pkg.name), + ), + conflict: null, + } +} + export function resolveSkillUseFastPath( parsedUse: SkillUse, options: IntentCoreOptions, + context = resolveProjectContext({ cwd: process.cwd() }), ): ResolveSkillResult | null { if (options.globalOnly) return null - if (shouldSkipFastPathForYarnPnp()) return null + if (shouldSkipFastPathForYarnPnp(context)) return null for (const packageRoot of getLoadFastPathCandidateDirs( parsedUse.packageName, + context, )) { const scanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, projectRoot: process.cwd(), + skillNameHint: parsedUse.skillName, }) - const pkg = scanned.package - if (!pkg || pkg.name !== parsedUse.packageName) continue - - const skill = resolveSkillEntry( - pkg.name, - parsedUse.skillName, - pkg.skills, - ).skill - if (!skill) continue - - return { - packageName: pkg.name, - skillName: skill.name, - path: skill.path, - source: pkg.source, - version: pkg.version, - packageRoot: pkg.packageRoot, - warnings: scanned.warnings.filter((warning) => - warningMentionsPackage(warning, pkg.name), - ), - conflict: null, + const directResolved = resolveScannedPackageSkill(scanned, parsedUse) + if (directResolved) return directResolved + + if (scanned.package?.name === parsedUse.packageName) { + const fallbackScanned = scanIntentPackageAtRoot(packageRoot, { + fallbackName: parsedUse.packageName, + projectRoot: process.cwd(), + }) + const fallbackResolved = resolveScannedPackageSkill( + fallbackScanned, + parsedUse, + ) + if (fallbackResolved) return fallbackResolved } } diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 4289d1a..560f35f 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -223,6 +223,39 @@ function deriveIntentConfig( // Skill discovery within a package // --------------------------------------------------------------------------- +function readSkillEntry( + skillsDir: string, + childDir: string, + skillFile: string, +): SkillEntry { + const fm = parseFrontmatter(skillFile) + const relName = toPosixPath(relative(skillsDir, childDir)) + const desc = + typeof fm?.description === 'string' + ? fm.description.replace(/\s+/g, ' ').trim() + : '' + + return { + name: typeof fm?.name === 'string' ? fm.name : relName, + path: skillFile, + description: desc, + type: typeof fm?.type === 'string' ? fm.type : undefined, + framework: typeof fm?.framework === 'string' ? fm.framework : undefined, + } +} + +function discoverSkillByNameHint( + skillsDir: string, + skillNameHint: string, +): Array { + const childDir = join(skillsDir, ...skillNameHint.split('/')) + const skillFile = join(childDir, 'SKILL.md') + if (!existsSync(skillFile)) return [] + + const skill = readSkillEntry(skillsDir, childDir, skillFile) + return skill.name === skillNameHint ? [skill] : [] +} + function discoverSkills( skillsDir: string, _baseName: string, @@ -241,20 +274,7 @@ function discoverSkills( const childDir = join(dir, entry.name) const skillFile = join(childDir, 'SKILL.md') if (existsSync(skillFile)) { - const fm = parseFrontmatter(skillFile) - const relName = toPosixPath(relative(skillsDir, childDir)) - const desc = - typeof fm?.description === 'string' - ? fm.description.replace(/\s+/g, ' ').trim() - : '' - skills.push({ - name: typeof fm?.name === 'string' ? fm.name : relName, - path: skillFile, - description: desc, - type: typeof fm?.type === 'string' ? fm.type : undefined, - framework: - typeof fm?.framework === 'string' ? fm.framework : undefined, - }) + skills.push(readSkillEntry(skillsDir, childDir, skillFile)) } // Always recurse into subdirectories so skills nested under // intermediate grouping directories (dirs without SKILL.md) are found. @@ -633,6 +653,7 @@ export interface ScanIntentPackageAtRootOptions { fallbackName?: string projectRoot?: string source?: IntentPackage['source'] + skillNameHint?: string } export interface ScanIntentPackageAtRootResult { @@ -670,7 +691,10 @@ export function scanIntentPackageAtRoot( const { tryRegister } = createPackageRegistrar({ comparePackageVersions, deriveIntentConfig, - discoverSkills, + discoverSkills: options.skillNameHint + ? (skillsDir) => + discoverSkillByNameHint(skillsDir, options.skillNameHint!) + : discoverSkills, getPackageDepth, packageIndexes, packages, diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 3917883..bf74dea 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -9,7 +9,7 @@ import { import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { scanForIntents } from '../src/scanner.js' +import { scanForIntents, scanIntentPackageAtRoot } from '../src/scanner.js' // ── Helpers ── @@ -1063,6 +1063,70 @@ describe('scanForIntents', () => { }) }) +describe('scanIntentPackageAtRoot', () => { + it('can scan only the hinted skill path', () => { + const pkgDir = createDir(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(createDir(pkgDir, 'skills', 'query', 'core'), { + name: 'query/core', + description: 'Core query skill', + }) + writeSkillMd(createDir(pkgDir, 'skills', 'query', 'cache'), { + name: 'query/cache', + description: 'Cache query skill', + }) + + const result = scanIntentPackageAtRoot(pkgDir, { + fallbackName: '@tanstack/query', + projectRoot: root, + skillNameHint: 'query/cache', + }) + + expect(result.package?.skills).toEqual([ + { + name: 'query/cache', + path: 'node_modules/@tanstack/query/skills/query/cache/SKILL.md', + description: 'Cache query skill', + type: undefined, + framework: undefined, + }, + ]) + }) + + it('falls back when the hinted path has a different canonical skill name', () => { + const pkgDir = createDir(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(createDir(pkgDir, 'skills', 'cache'), { + name: 'query/cache', + description: 'Cache query skill', + }) + + const result = scanIntentPackageAtRoot(pkgDir, { + fallbackName: '@tanstack/query', + projectRoot: root, + skillNameHint: 'cache', + }) + + expect(result.package?.skills).toEqual([]) + }) +}) + describe('package manager detection', () => { it('detects npm from package-lock.json', () => { writeFileSync(join(root, 'package-lock.json'), '{}') From fff866a117b343f38e2244ad840693bb2922a69f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 18:14:10 -0700 Subject: [PATCH 13/20] reduce CLI and load path overhead --- packages/intent/src/cli.ts | 52 ++++--- packages/intent/src/core.ts | 158 ++++++++++++---------- packages/intent/src/core/excludes.ts | 74 ++++++++-- packages/intent/src/discovery/register.ts | 2 +- packages/intent/src/resolver.ts | 10 +- packages/intent/src/scanner.ts | 51 +++++-- packages/intent/tests/cli.test.ts | 5 +- packages/intent/tests/core.test.ts | 9 ++ packages/intent/tests/resolver.test.ts | 16 +++ packages/intent/tests/scanner.test.ts | 46 +++++++ 10 files changed, 303 insertions(+), 120 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 54cb9af..8ff3bb6 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -4,20 +4,6 @@ import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { cac } from 'cac' import { fail, isCliFailure } from './cli-error.js' -import { - getMetaDir, - resolveStaleTargets, - scanIntentsOrFail, -} from './cli-support.js' -import { runEditPackageJsonCommand } from './commands/edit-package-json.js' -import { runInstallCommand } from './commands/install.js' -import { runListCommand } from './commands/list.js' -import { runLoadCommand } from './commands/load.js' -import { runMetaCommand } from './commands/meta.js' -import { runScaffoldCommand } from './commands/scaffold.js' -import { runSetupGithubActionsCommand } from './commands/setup-github-actions.js' -import { runStaleCommand } from './commands/stale.js' -import { runValidateCommand } from './commands/validate.js' import type { CAC } from 'cac' import type { InstallCommandOptions } from './commands/install.js' import type { ListCommandOptions } from './commands/list.js' @@ -46,7 +32,8 @@ function createCli(): CAC { .example('list --json') .example('list --global') .action(async (options: ListCommandOptions) => { - await runListCommand(options, scanIntentsOrFail) + const { runListCommand } = await import('./commands/list.js') + await runListCommand(options) }) cli @@ -63,7 +50,8 @@ function createCli(): CAC { .example('load @tanstack/query#core') .example('load @tanstack/query#core --path') .action(async (use: string | undefined, options: LoadCommandOptions) => { - await runLoadCommand(use, options, scanIntentsOrFail) + const { runLoadCommand } = await import('./commands/load.js') + await runLoadCommand(use, options) }) cli @@ -72,6 +60,10 @@ function createCli(): CAC { .example('meta') .example('meta domain-discovery') .action(async (name?: string) => { + const [{ getMetaDir }, { runMetaCommand }] = await Promise.all([ + import('./cli-support.js'), + import('./commands/meta.js'), + ]) await runMetaCommand(name, getMetaDir()) }) @@ -83,6 +75,7 @@ function createCli(): CAC { .example('validate packages/query/skills') .action( async (dir: string | undefined, options: ValidateCommandOptions) => { + const { runValidateCommand } = await import('./commands/validate.js') await runValidateCommand(dir, options) }, ) @@ -109,13 +102,21 @@ function createCli(): CAC { .example('install --print-prompt') .example('install --global') .action(async (options: InstallCommandOptions) => { + const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ + import('./cli-support.js'), + import('./commands/install.js'), + ]) await runInstallCommand(options, scanIntentsOrFail) }) cli .command('scaffold', 'Print maintainer scaffold prompt') .usage('scaffold') - .action(() => { + .action(async () => { + const [{ getMetaDir }, { runScaffoldCommand }] = await Promise.all([ + import('./cli-support.js'), + import('./commands/scaffold.js'), + ]) runScaffoldCommand(getMetaDir()) }) @@ -133,6 +134,11 @@ function createCli(): CAC { .example('stale --json') .action( async (targetDir: string | undefined, options: StaleCommandOptions) => { + const [{ resolveStaleTargets }, { runStaleCommand }] = + await Promise.all([ + import('./cli-support.js'), + import('./commands/stale.js'), + ]) await runStaleCommand(targetDir, options, resolveStaleTargets) }, ) @@ -144,6 +150,8 @@ function createCli(): CAC { ) .usage('edit-package-json') .action(async () => { + const { runEditPackageJsonCommand } = + await import('./commands/edit-package-json.js') await runEditPackageJsonCommand(process.cwd()) }) @@ -154,6 +162,11 @@ function createCli(): CAC { ) .usage('setup') .action(async () => { + const [{ getMetaDir }, { runSetupGithubActionsCommand }] = + await Promise.all([ + import('./cli-support.js'), + import('./commands/setup-github-actions.js'), + ]) await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) @@ -164,6 +177,11 @@ function createCli(): CAC { ) .usage('setup-github-actions') .action(async () => { + const [{ getMetaDir }, { runSetupGithubActionsCommand }] = + await Promise.all([ + import('./cli-support.js'), + import('./commands/setup-github-actions.js'), + ]) await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 825750f..b5c52bd 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,6 +1,7 @@ import { readFileSync, realpathSync } from 'node:fs' import { isAbsolute, relative, resolve } from 'node:path' import { + compileExcludePatterns, getEffectiveExcludePatterns, isPackageExcluded, warningMentionsPackage, @@ -88,13 +89,15 @@ export function listIntentSkills( ): IntentSkillList { return withCwd(options.cwd, () => { const scanOptions = toScanOptions(options) + const projectContext = resolveProjectContext({ cwd: process.cwd() }) const scanResult = scanForIntents(undefined, scanOptions) - const excludePatterns = getEffectiveExcludePatterns(options) + const excludePatterns = getEffectiveExcludePatterns(options, projectContext) + const excludeMatchers = compileExcludePatterns(excludePatterns) const excludedPackages = scanResult.packages - .filter((pkg) => isPackageExcluded(pkg.name, excludePatterns)) + .filter((pkg) => isPackageExcluded(pkg.name, excludeMatchers)) .map((pkg) => pkg.name) const packages = scanResult.packages.filter( - (pkg) => !isPackageExcluded(pkg.name, excludePatterns), + (pkg) => !isPackageExcluded(pkg.name, excludeMatchers), ) const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { @@ -128,7 +131,7 @@ export function listIntentSkills( ), ), conflicts: scanResult.conflicts.filter( - (conflict) => !isPackageExcluded(conflict.packageName, excludePatterns), + (conflict) => !isPackageExcluded(conflict.packageName, excludeMatchers), ), } @@ -167,7 +170,11 @@ function toResolvedIntentSkill( use: string, resolved: ResolveSkillResult, debug?: LoadedIntentSkillDebug, -): ResolvedIntentSkill { +): { + realPackageRoot: string + realResolvedPath: string + result: ResolvedIntentSkill +} { let realResolvedPath: string try { realResolvedPath = realpathSync.native(resolveFromCwd(resolved.path)) @@ -203,7 +210,11 @@ function toResolvedIntentSkill( result.debug = debug } - return result + return { + realPackageRoot, + realResolvedPath, + result, + } } function createLoadedSkillDebug({ @@ -231,76 +242,88 @@ function createLoadedSkillDebug({ } } -export function resolveIntentSkill( +function resolveIntentSkillInCurrentCwd( use: string, options: IntentCoreOptions = {}, -): ResolvedIntentSkill { - return withCwd(options.cwd, () => { - let parsedUse: ReturnType - try { - parsedUse = parseSkillUse(use) - } catch (err) { - throw new IntentCoreError( - 'invalid-skill-use', - err instanceof Error ? err.message : String(err), - ) - } - - const projectContext = resolveProjectContext({ cwd: process.cwd() }) - const excludePatterns = getEffectiveExcludePatterns(options, projectContext) +): { + realPackageRoot: string + realResolvedPath: string + result: ResolvedIntentSkill +} { + let parsedUse: ReturnType + try { + parsedUse = parseSkillUse(use) + } catch (err) { + throw new IntentCoreError( + 'invalid-skill-use', + err instanceof Error ? err.message : String(err), + ) + } - if (isPackageExcluded(parsedUse.packageName, excludePatterns)) { - throw new IntentCoreError( - 'package-excluded', - `Cannot load skill use "${use}": package "${parsedUse.packageName}" is excluded by Intent configuration.`, - ) - } + const projectContext = resolveProjectContext({ cwd: process.cwd() }) + const excludePatterns = getEffectiveExcludePatterns(options, projectContext) + const excludeMatchers = compileExcludePatterns(excludePatterns) - const scanOptions = toScanOptions(options) - const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath( - parsedUse, - options, - projectContext, + if (isPackageExcluded(parsedUse.packageName, excludeMatchers)) { + throw new IntentCoreError( + 'package-excluded', + `Cannot load skill use "${use}": package "${parsedUse.packageName}" is excluded by Intent configuration.`, ) - if (fastPathResolved) { - return toResolvedIntentSkill( - use, - fastPathResolved, - options.debug - ? createLoadedSkillDebug({ - excludes: excludePatterns, - resolution: 'fast-path', - resolved: fastPathResolved, - scope, - }) - : undefined, - ) - } - - const scanResult = scanForIntents(undefined, scanOptions) - let resolved: ReturnType - try { - resolved = resolveSkillUse(use, scanResult) - } catch (err) { - if (err instanceof ResolveSkillUseError) { - throw new IntentCoreError(err.code, err.message) - } - throw err - } + } + const scanOptions = toScanOptions(options) + const scope = getScanScope(scanOptions) + const fastPathResolved = resolveSkillUseFastPath( + parsedUse, + options, + projectContext, + ) + if (fastPathResolved) { return toResolvedIntentSkill( use, - resolved, + fastPathResolved, options.debug ? createLoadedSkillDebug({ excludes: excludePatterns, - resolution: 'full-scan', - resolved, + resolution: 'fast-path', + resolved: fastPathResolved, scope, }) : undefined, ) + } + + const scanResult = scanForIntents(undefined, scanOptions) + let resolved: ReturnType + try { + resolved = resolveSkillUse(use, scanResult) + } catch (err) { + if (err instanceof ResolveSkillUseError) { + throw new IntentCoreError(err.code, err.message) + } + throw err + } + + return toResolvedIntentSkill( + use, + resolved, + options.debug + ? createLoadedSkillDebug({ + excludes: excludePatterns, + resolution: 'full-scan', + resolved, + scope, + }) + : undefined, + ) +} + +export function resolveIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): ResolvedIntentSkill { + return withCwd(options.cwd, () => { + return resolveIntentSkillInCurrentCwd(use, options).result }) } @@ -308,20 +331,17 @@ export function loadIntentSkill( use: string, options: IntentCoreOptions = {}, ): LoadedIntentSkill { - const resolved = resolveIntentSkill(use, options) - return withCwd(options.cwd, () => { - const resolvedPath = realpathSync(resolveFromCwd(resolved.path)) - const packageRoot = realpathSync(resolveFromCwd(resolved.packageRoot)) + const resolved = resolveIntentSkillInCurrentCwd(use, options) const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolvedPath, 'utf8'), + content: readFileSync(resolved.realResolvedPath, 'utf8'), cwd: process.cwd(), - packageRoot, - skillFilePath: resolvedPath, + packageRoot: resolved.realPackageRoot, + skillFilePath: resolved.realResolvedPath, }) return { - ...resolved, + ...resolved.result, content, } }) diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index f00cb7c..7640d38 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -1,4 +1,4 @@ -import { dirname, isAbsolute, relative } from 'node:path' +import { dirname, isAbsolute, relative, resolve } from 'node:path' import { resolveProjectContext, type ProjectContext, @@ -6,6 +6,14 @@ import { import { readPackageJson } from './package-json.js' import type { IntentCoreOptions } from './types.js' +const MAX_EXCLUDE_PATTERN_LENGTH = 200 +const PACKAGE_NAME_BOUNDARY = /[^a-zA-Z0-9_-]/ + +export interface ExcludeMatcher { + pattern: string + matches: (packageName: string) => boolean +} + function normalizeExcludePatterns(value: unknown): Array { if (!Array.isArray(value)) return [] @@ -49,43 +57,81 @@ function getConfigExcludePatterns( } export function getEffectiveExcludePatterns( - options: IntentCoreOptions, + options: IntentCoreOptions = {}, context?: ProjectContext, ): Array { + const cwd = + context?.cwd ?? resolve(process.cwd(), options.cwd ?? process.cwd()) return [ - ...getConfigExcludePatterns(process.cwd(), context), + ...getConfigExcludePatterns(cwd, context), ...normalizeExcludePatterns(options.exclude), ] } +function normalizeGlobPattern(pattern: string): string { + if (pattern.length > MAX_EXCLUDE_PATTERN_LENGTH) { + throw new Error( + `Intent exclude pattern is too long: ${pattern.length} characters. Maximum is ${MAX_EXCLUDE_PATTERN_LENGTH}.`, + ) + } + + return pattern.replace(/\*+/g, '*') +} + function globToRegExp(pattern: string): RegExp { - const source = pattern + const source = normalizeGlobPattern(pattern) .split('*') .map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')) .join('.*') return new RegExp(`^${source}$`) } -function matchesPackageGlob(packageName: string, pattern: string): boolean { - return pattern.includes('*') - ? globToRegExp(pattern).test(packageName) - : packageName === pattern +export function compileExcludePatterns( + patterns: Array, +): Array { + return patterns.map((pattern) => { + if (!pattern.includes('*')) { + normalizeGlobPattern(pattern) + return { + pattern, + matches: (packageName) => packageName === pattern, + } + } + + const regex = globToRegExp(pattern) + return { + pattern, + matches: (packageName) => regex.test(packageName), + } + }) } export function isPackageExcluded( packageName: string, - patterns: Array, + matchers: Array, ): boolean { - return patterns.some((pattern) => matchesPackageGlob(packageName, pattern)) + return matchers.some((matcher) => matcher.matches(packageName)) } export function warningMentionsPackage( warning: string, packageName: string, ): boolean { - const idx = warning.indexOf(packageName) - if (idx === -1) return false + let fromIndex = 0 - const after = warning[idx + packageName.length] - return after === undefined || /[^a-zA-Z0-9_-]/.test(after) + while (true) { + const idx = warning.indexOf(packageName, fromIndex) + if (idx === -1) return false + + const before = warning[idx - 1] + const after = warning[idx + packageName.length] + if ( + (before === undefined || PACKAGE_NAME_BOUNDARY.test(before)) && + (after === undefined || PACKAGE_NAME_BOUNDARY.test(after)) + ) { + return true + } + + fromIndex = idx + packageName.length + } } diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index b5e311e..d6e31b3 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -21,7 +21,7 @@ function isLocalToProject(dirPath: string, projectRoot: string): boolean { export interface CreatePackageRegistrarOptions { comparePackageVersions: (a: string, b: string) => number deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null - discoverSkills: (skillsDir: string, baseName: string) => Array + discoverSkills: (skillsDir: string, packageName: string) => Array getPackageDepth: (packageRoot: string, projectRoot: string) => number packageIndexes: Map packages: Array diff --git a/packages/intent/src/resolver.ts b/packages/intent/src/resolver.ts index 3fe6df5..121c542 100644 --- a/packages/intent/src/resolver.ts +++ b/packages/intent/src/resolver.ts @@ -1,3 +1,4 @@ +import { warningMentionsPackage } from './core/excludes.js' import { parseSkillUse } from './skill-use.js' import type { IntentPackage, @@ -187,12 +188,9 @@ export function resolveSkillUse( source: pkg.source, version: pkg.version, packageRoot: pkg.packageRoot, - warnings: scanResult.warnings.filter((warning) => { - const idx = warning.indexOf(packageName) - if (idx === -1) return false - const after = warning[idx + packageName.length] - return after === undefined || /[^a-zA-Z0-9_-]/.test(after) - }), + warnings: scanResult.warnings.filter((warning) => + warningMentionsPackage(warning, packageName), + ), conflict, } } diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 560f35f..8a187a4 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -246,20 +246,29 @@ function readSkillEntry( function discoverSkillByNameHint( skillsDir: string, + packageName: string, skillNameHint: string, ): Array { - const childDir = join(skillsDir, ...skillNameHint.split('/')) - const skillFile = join(childDir, 'SKILL.md') - if (!existsSync(skillFile)) return [] + const skills: Array = [] + const seen = new Set() + const skillNameHints = getSkillNameHints(packageName, skillNameHint) + + for (const hint of skillNameHints) { + const childDir = join(skillsDir, ...hint.split('/')) + const skillFile = join(childDir, 'SKILL.md') + if (!existsSync(skillFile)) continue - const skill = readSkillEntry(skillsDir, childDir, skillFile) - return skill.name === skillNameHint ? [skill] : [] + const skill = readSkillEntry(skillsDir, childDir, skillFile) + if (skill.name !== hint || seen.has(skill.name)) continue + + seen.add(skill.name) + skills.push(skill) + } + + return skills } -function discoverSkills( - skillsDir: string, - _baseName: string, -): Array { +function discoverSkills(skillsDir: string): Array { const skills: Array = [] function walk(dir: string): void { @@ -286,6 +295,22 @@ function discoverSkills( return skills } +function getPackageShortName(packageName: string): string { + return packageName.split('/').pop() ?? packageName +} + +function getSkillNameHints( + packageName: string, + skillNameHint: string, +): Array { + const packageShortName = getPackageShortName(packageName) + if (skillNameHint.startsWith(`${packageShortName}/`)) { + return [skillNameHint] + } + + return [skillNameHint, `${packageShortName}/${skillNameHint}`] +} + // --------------------------------------------------------------------------- // Topological sort on requires // --------------------------------------------------------------------------- @@ -692,8 +717,12 @@ export function scanIntentPackageAtRoot( comparePackageVersions, deriveIntentConfig, discoverSkills: options.skillNameHint - ? (skillsDir) => - discoverSkillByNameHint(skillsDir, options.skillNameHint!) + ? (skillsDir, packageName) => + discoverSkillByNameHint( + skillsDir, + packageName, + options.skillNameHint!, + ) : discoverSkills, getPackageDepth, packageIndexes, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index ebb3b72..f5fc53d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1011,8 +1011,9 @@ describe('cli commands', () => { version: '5.0.0', intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, }) - mkdirSync(join(pkgDir, 'skills', 'fetching', 'SKILL.md'), { - recursive: true, + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query data fetching patterns', }) process.chdir(root) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index b5de38d..9aefda9 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -184,6 +184,15 @@ describe('listIntentSkills', () => { ]) }) + it('rejects overly long exclude patterns', () => { + expect(() => + listIntentSkills({ + cwd: root, + exclude: ['@tanstack/'.padEnd(201, 'x')], + }), + ).toThrow('Intent exclude pattern is too long') + }) + it('merges root, package, and option excludes', () => { const appDir = join(root, 'packages', 'app') writeJson(join(root, 'package.json'), { diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 0912140..1815372 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -279,6 +279,22 @@ describe('resolveSkillUse', () => { expect(result.warnings).toEqual([]) }) + it('does not include warnings when the package name is only a suffix', () => { + const warning = + 'Found 2 installed variants of prefix@tanstack/query across 2 versions.' + const validSecondWarning = + 'Found 2 installed variants of @tanstack/query across 2 versions.' + + const result = resolveSkillUse( + '@tanstack/query#core', + scanResult([intentPackage({ name: '@tanstack/query' })], { + warnings: [warning, validSecondWarning], + }), + ) + + expect(result.warnings).toEqual([validSecondWarning]) + }) + it('fails clearly when the package is missing', () => { expect(() => { resolveSkillUse( diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index bf74dea..bedd03e 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -779,6 +779,11 @@ describe('scanForIntents', () => { writeJson(join(reactStartDir, 'package.json'), { name: '@tanstack/react-start', version: '1.167.52', + intent: { + version: 1, + repo: 'TanStack/router', + docs: 'https://tanstack.com/start', + }, repository: { type: 'git', url: 'git+https://github.com/TanStack/router.git', @@ -907,6 +912,11 @@ describe('scanForIntents', () => { writeJson(join(reactStartDir, 'package.json'), { name: '@tanstack/react-start', version: '1.167.52', + intent: { + version: 1, + repo: 'TanStack/router', + docs: 'https://tanstack.com/start', + }, repository: { type: 'git', url: 'git+https://github.com/TanStack/router.git', @@ -1101,6 +1111,42 @@ describe('scanIntentPackageAtRoot', () => { ]) }) + it('can scan a package-prefixed hinted skill path from a short name', () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router-core') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router-core', + version: '1.0.0', + intent: { + version: 1, + repo: 'TanStack/router', + docs: 'docs/', + }, + }) + writeSkillMd( + createDir(pkgDir, 'skills', 'router-core', 'auth-and-guards'), + { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + ) + + const result = scanIntentPackageAtRoot(pkgDir, { + fallbackName: '@tanstack/router-core', + projectRoot: root, + skillNameHint: 'auth-and-guards', + }) + + expect(result.package?.skills).toEqual([ + { + name: 'router-core/auth-and-guards', + path: 'node_modules/@tanstack/router-core/skills/router-core/auth-and-guards/SKILL.md', + description: 'Router auth and guards', + type: undefined, + framework: undefined, + }, + ]) + }) + it('falls back when the hinted path has a different canonical skill name', () => { const pkgDir = createDir(root, 'node_modules', '@tanstack', 'query') writeJson(join(pkgDir, 'package.json'), { From a21e1a0f01c1b337834c9c662f1bf44dd2b2f8a2 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 18:20:58 -0700 Subject: [PATCH 14/20] cover load content and large workspace paths --- benchmarks/intent/load.bench.ts | 134 +++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 3 deletions(-) diff --git a/benchmarks/intent/load.bench.ts b/benchmarks/intent/load.bench.ts index fa06ff2..3930311 100644 --- a/benchmarks/intent/load.bench.ts +++ b/benchmarks/intent/load.bench.ts @@ -6,6 +6,7 @@ import { createCliRunner, createConsoleSilencer, createTempDir, + writeFile, writeJson, writePackage, } from './helpers.js' @@ -13,6 +14,7 @@ import { type LoadFixture = { root: string runner: ReturnType + workspaceRoot: string } const consoleSilencer = createConsoleSilencer() @@ -20,7 +22,19 @@ let fixture: LoadFixture | null = null function createFixture(): LoadFixture { const root = createTempDir('load') + const workspaceRoot = createTempDir('load-workspace') + writeLoadProject(root) + writeLargeWorkspaceProject(workspaceRoot) + + return { + root, + runner: createCliRunner({ cwd: root }), + workspaceRoot, + } +} + +function writeLoadProject(root: string): void { writeJson(join(root, 'package.json'), { name: 'intent-load-benchmark', private: true, @@ -33,10 +47,82 @@ function createFixture(): LoadFixture { skills: ['query/core', 'query/cache', 'query/testing'], }) - return { - root, - runner: createCliRunner({ cwd: root }), + writeQueryCacheContent(join(root, 'node_modules', '@bench', 'query')) +} + +function writeLargeWorkspaceProject(root: string): void { + writeJson(join(root, 'package.json'), { + name: 'intent-large-workspace-load-benchmark', + private: true, + workspaces: ['packages/*'], + dependencies: { + '@bench/query': '1.0.0', + }, + }) + writeFile(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n") + + for (let index = 0; index < 120; index++) { + writeJson(join(root, 'packages', `pkg-${index}`, 'package.json'), { + name: `@bench/workspace-pkg-${index}`, + version: '1.0.0', + dependencies: + index % 10 === 0 + ? { + '@bench/query': '1.0.0', + } + : undefined, + }) } + + writePackage(join(root, 'node_modules'), '@bench/query', '1.0.0', { + skills: ['query/core', 'query/cache', 'query/testing'], + }) + + writeQueryCacheContent(join(root, 'node_modules', '@bench', 'query')) +} + +function writeQueryCacheContent(packageRoot: string): void { + writeFile( + join(packageRoot, 'docs', 'cache-guide.md'), + '# Cache guide\n\nUse the cache workflow for repeated queries.\n', + ) + writeFile( + join(packageRoot, 'assets', 'cache.txt'), + 'cache diagram placeholder\n', + ) + writeFile( + join(packageRoot, 'skills', 'query', 'cache', 'setup.md'), + '# Cache setup\n\nConfigure query cache defaults.\n', + ) + writeFile( + join(packageRoot, 'skills', 'query', 'cache', 'SKILL.md'), + [ + '---', + 'name: "query/cache"', + 'description: "query/cache benchmark guidance"', + 'type: "framework"', + 'requires:', + ' - "query"', + '---', + '', + '# Query Cache', + '', + 'See [cache guide](../../../docs/cache-guide.md).', + 'Use [local setup](setup.md#configure).', + '![Cache diagram](../../../assets/cache.txt)', + '', + '```md', + '[ignored code link](setup.md)', + '```', + '', + ...Array.from( + { length: 20 }, + (_, index) => + `${index + 1}. Keep cache guidance aligned with [setup](setup.md) and [guide](../../../docs/cache-guide.md#cache).`, + ), + '', + ].join('\n'), + ) } function getFixture(): LoadFixture { @@ -56,12 +142,26 @@ function teardown(): void { if (fixture) { fixture.runner.teardown() rmSync(fixture.root, { recursive: true, force: true }) + rmSync(fixture.workspaceRoot, { recursive: true, force: true }) fixture = null } consoleSilencer.restore() } +async function runInCwd( + cwd: string, + callback: () => Promise, +): Promise { + const previousCwd = process.cwd() + process.chdir(cwd) + try { + await callback() + } finally { + process.chdir(previousCwd) + } +} + describe('intent load', () => { beforeAll(setup) afterAll(teardown) @@ -76,4 +176,32 @@ describe('intent load', () => { }, createBenchOptions(setup, teardown), ) + + bench( + 'loads direct dependency content as json', + async () => { + const state = getFixture() + for (let index = 0; index < 10; index++) { + await state.runner.run(['load', '@bench/query#query/cache', '--json']) + } + }, + createBenchOptions(setup, teardown), + ) + + bench( + 'loads a direct dependency from a large workspace', + async () => { + const state = getFixture() + await runInCwd(state.workspaceRoot, async () => { + for (let index = 0; index < 10; index++) { + await state.runner.run([ + 'load', + '@bench/query#query/cache', + '--path', + ]) + } + }) + }, + createBenchOptions(setup, teardown), + ) }) From c9432558fe8fb71b0c93572d560767889938fd49 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 01:21:46 +0000 Subject: [PATCH 15/20] ci: apply automated fixes --- benchmarks/intent/load.bench.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/benchmarks/intent/load.bench.ts b/benchmarks/intent/load.bench.ts index 3930311..7dd3d87 100644 --- a/benchmarks/intent/load.bench.ts +++ b/benchmarks/intent/load.bench.ts @@ -194,11 +194,7 @@ describe('intent load', () => { const state = getFixture() await runInCwd(state.workspaceRoot, async () => { for (let index = 0; index < 10; index++) { - await state.runner.run([ - 'load', - '@bench/query#query/cache', - '--path', - ]) + await state.runner.run(['load', '@bench/query#query/cache', '--path']) } }) }, From 312942f1f63430d97f3d19c3bea9721f3b879c88 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 18:50:54 -0700 Subject: [PATCH 16/20] fix intent core cwd handling and review feedback --- benchmarks/intent/load.bench.ts | 7 +- packages/intent/src/core.ts | 195 ++++++++++---------- packages/intent/src/core/excludes.ts | 2 +- packages/intent/src/core/load-resolution.ts | 80 +++++--- packages/intent/src/scanner.ts | 39 +++- packages/intent/tests/core.test.ts | 51 +++++ packages/intent/tests/resolver.test.ts | 16 ++ packages/intent/tests/scanner.test.ts | 36 +++- 8 files changed, 298 insertions(+), 128 deletions(-) diff --git a/benchmarks/intent/load.bench.ts b/benchmarks/intent/load.bench.ts index 7dd3d87..f31aa1a 100644 --- a/benchmarks/intent/load.bench.ts +++ b/benchmarks/intent/load.bench.ts @@ -128,7 +128,12 @@ function writeQueryCacheContent(packageRoot: string): void { function getFixture(): LoadFixture { if (!fixture) { consoleSilencer.silence() - fixture = createFixture() + try { + fixture = createFixture() + } catch (err) { + consoleSilencer.restore() + throw err + } } return fixture diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index b5c52bd..6a23908 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -41,11 +41,19 @@ export type { export class IntentCoreError extends Error { readonly code: IntentCoreErrorCode + readonly suggestedSkills?: Array - constructor(code: IntentCoreErrorCode, message: string) { + constructor( + code: IntentCoreErrorCode, + message: string, + options: { suggestedSkills?: Array } = {}, + ) { super(message) this.name = 'IntentCoreError' this.code = code + if (options.suggestedSkills) { + this.suggestedSkills = options.suggestedSkills + } } } @@ -72,87 +80,78 @@ function getScanScope(options: ScanOptions): ScanScope { return options.scope ?? (options.includeGlobal ? 'local-and-global' : 'local') } -function withCwd(cwd: string | undefined, callback: () => T): T { - if (!cwd) return callback() - - const originalCwd = process.cwd() - process.chdir(cwd) - try { - return callback() - } finally { - process.chdir(originalCwd) - } +function resolveCoreCwd(options: IntentCoreOptions): string { + return resolve(process.cwd(), options.cwd ?? process.cwd()) } export function listIntentSkills( options: IntentCoreOptions = {}, ): IntentSkillList { - return withCwd(options.cwd, () => { - const scanOptions = toScanOptions(options) - const projectContext = resolveProjectContext({ cwd: process.cwd() }) - const scanResult = scanForIntents(undefined, scanOptions) - const excludePatterns = getEffectiveExcludePatterns(options, projectContext) - const excludeMatchers = compileExcludePatterns(excludePatterns) - const excludedPackages = scanResult.packages - .filter((pkg) => isPackageExcluded(pkg.name, excludeMatchers)) - .map((pkg) => pkg.name) - const packages = scanResult.packages.filter( - (pkg) => !isPackageExcluded(pkg.name, excludeMatchers), - ) - const skills = packages.flatMap((pkg) => - pkg.skills.map((skill): IntentSkillSummary => { - return { - use: formatSkillUse(pkg.name, skill.name), - packageName: pkg.name, - packageRoot: pkg.packageRoot, - packageVersion: pkg.version, - packageSource: pkg.source, - skillName: skill.name, - description: skill.description, - type: skill.type, - framework: skill.framework, - } - }), - ) - - const result: IntentSkillList = { - skills, - packages: packages.map((pkg) => ({ - name: pkg.name, - version: pkg.version, - source: pkg.source, + const cwd = resolveCoreCwd(options) + const scanOptions = toScanOptions(options) + const projectContext = resolveProjectContext({ cwd }) + const scanResult = scanForIntents(cwd, scanOptions) + const excludePatterns = getEffectiveExcludePatterns(options, projectContext) + const excludeMatchers = compileExcludePatterns(excludePatterns) + const excludedPackages = scanResult.packages + .filter((pkg) => isPackageExcluded(pkg.name, excludeMatchers)) + .map((pkg) => pkg.name) + const packages = scanResult.packages.filter( + (pkg) => !isPackageExcluded(pkg.name, excludeMatchers), + ) + const skills = packages.flatMap((pkg) => + pkg.skills.map((skill): IntentSkillSummary => { + return { + use: formatSkillUse(pkg.name, skill.name), + packageName: pkg.name, packageRoot: pkg.packageRoot, - skillCount: pkg.skills.length, - })), - warnings: scanResult.warnings.filter( - (warning) => - !excludedPackages.some((packageName) => - warningMentionsPackage(warning, packageName), - ), - ), - conflicts: scanResult.conflicts.filter( - (conflict) => !isPackageExcluded(conflict.packageName, excludeMatchers), - ), - } - - if (options.debug) { - result.debug = { - cwd: process.cwd(), - scope: getScanScope(scanOptions), - excludes: excludePatterns, - packageCount: result.packages.length, - skillCount: result.skills.length, - warningCount: result.warnings.length, - conflictCount: result.conflicts.length, + packageVersion: pkg.version, + packageSource: pkg.source, + skillName: skill.name, + description: skill.description, + type: skill.type, + framework: skill.framework, } + }), + ) + + const result: IntentSkillList = { + skills, + packages: packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + source: pkg.source, + packageRoot: pkg.packageRoot, + skillCount: pkg.skills.length, + })), + warnings: scanResult.warnings.filter( + (warning) => + !excludedPackages.some((packageName) => + warningMentionsPackage(warning, packageName), + ), + ), + conflicts: scanResult.conflicts.filter( + (conflict) => !isPackageExcluded(conflict.packageName, excludeMatchers), + ), + } + + if (options.debug) { + result.debug = { + cwd, + scope: getScanScope(scanOptions), + excludes: excludePatterns, + packageCount: result.packages.length, + skillCount: result.skills.length, + warningCount: result.warnings.length, + conflictCount: result.conflicts.length, } + } - return result - }) + return result } -function resolveFromCwd(path: string): string { - return resolve(process.cwd(), path) +function resolveFromCwd(cwd: string, path: string): string { + return resolve(cwd, path) } function isResolvedPathInsidePackageRoot( @@ -167,6 +166,7 @@ function isResolvedPathInsidePackageRoot( } function toResolvedIntentSkill( + cwd: string, use: string, resolved: ResolveSkillResult, debug?: LoadedIntentSkillDebug, @@ -177,7 +177,7 @@ function toResolvedIntentSkill( } { let realResolvedPath: string try { - realResolvedPath = realpathSync.native(resolveFromCwd(resolved.path)) + realResolvedPath = realpathSync.native(resolveFromCwd(cwd, resolved.path)) } catch { throw new IntentCoreError( 'skill-file-not-found', @@ -185,7 +185,7 @@ function toResolvedIntentSkill( ) } const realPackageRoot = realpathSync.native( - resolveFromCwd(resolved.packageRoot), + resolveFromCwd(cwd, resolved.packageRoot), ) if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { @@ -218,18 +218,20 @@ function toResolvedIntentSkill( } function createLoadedSkillDebug({ + cwd, excludes, resolution, resolved, scope, }: { + cwd: string excludes: Array resolution: LoadedIntentSkillDebug['resolution'] resolved: ResolveSkillResult scope: ScanScope }): LoadedIntentSkillDebug { return { - cwd: process.cwd(), + cwd, scope, resolution, excludes, @@ -242,7 +244,8 @@ function createLoadedSkillDebug({ } } -function resolveIntentSkillInCurrentCwd( +function resolveIntentSkillInCwd( + cwd: string, use: string, options: IntentCoreOptions = {}, ): { @@ -260,7 +263,7 @@ function resolveIntentSkillInCurrentCwd( ) } - const projectContext = resolveProjectContext({ cwd: process.cwd() }) + const projectContext = resolveProjectContext({ cwd }) const excludePatterns = getEffectiveExcludePatterns(options, projectContext) const excludeMatchers = compileExcludePatterns(excludePatterns) @@ -277,13 +280,16 @@ function resolveIntentSkillInCurrentCwd( parsedUse, options, projectContext, + cwd, ) if (fastPathResolved) { return toResolvedIntentSkill( + cwd, use, fastPathResolved, options.debug ? createLoadedSkillDebug({ + cwd, excludes: excludePatterns, resolution: 'fast-path', resolved: fastPathResolved, @@ -293,22 +299,26 @@ function resolveIntentSkillInCurrentCwd( ) } - const scanResult = scanForIntents(undefined, scanOptions) + const scanResult = scanForIntents(cwd, scanOptions) let resolved: ReturnType try { resolved = resolveSkillUse(use, scanResult) } catch (err) { if (err instanceof ResolveSkillUseError) { - throw new IntentCoreError(err.code, err.message) + throw new IntentCoreError(err.code, err.message, { + suggestedSkills: err.suggestedSkills, + }) } throw err } return toResolvedIntentSkill( + cwd, use, resolved, options.debug ? createLoadedSkillDebug({ + cwd, excludes: excludePatterns, resolution: 'full-scan', resolved, @@ -322,27 +332,24 @@ export function resolveIntentSkill( use: string, options: IntentCoreOptions = {}, ): ResolvedIntentSkill { - return withCwd(options.cwd, () => { - return resolveIntentSkillInCurrentCwd(use, options).result - }) + return resolveIntentSkillInCwd(resolveCoreCwd(options), use, options).result } export function loadIntentSkill( use: string, options: IntentCoreOptions = {}, ): LoadedIntentSkill { - return withCwd(options.cwd, () => { - const resolved = resolveIntentSkillInCurrentCwd(use, options) - const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolved.realResolvedPath, 'utf8'), - cwd: process.cwd(), - packageRoot: resolved.realPackageRoot, - skillFilePath: resolved.realResolvedPath, - }) - - return { - ...resolved.result, - content, - } + const cwd = resolveCoreCwd(options) + const resolved = resolveIntentSkillInCwd(cwd, use, options) + const content = rewriteLoadedSkillMarkdownDestinations({ + content: readFileSync(resolved.realResolvedPath, 'utf8'), + cwd, + packageRoot: resolved.realPackageRoot, + skillFilePath: resolved.realResolvedPath, }) + + return { + ...resolved.result, + content, + } } diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 7640d38..1770a7b 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -7,7 +7,7 @@ import { readPackageJson } from './package-json.js' import type { IntentCoreOptions } from './types.js' const MAX_EXCLUDE_PATTERN_LENGTH = 200 -const PACKAGE_NAME_BOUNDARY = /[^a-zA-Z0-9_-]/ +const PACKAGE_NAME_BOUNDARY = /[^a-zA-Z0-9_.-]/ export interface ExcludeMatcher { pattern: string diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index b32754b..851f732 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -107,16 +107,18 @@ function hasYarnPnpFile(dir: string | null): boolean { ) } -function shouldSkipFastPathForYarnPnp(context: ProjectContext): boolean { - const cwd = process.cwd() +function shouldSkipFastPathForYarnPnp( + context: ProjectContext, + cwd: string, +): boolean { return hasYarnPnpFile(cwd) || hasYarnPnpFile(context.workspaceRoot) } -function getLoadFastPathCandidateDirs( +function getDirectLoadFastPathCandidateDirs( packageName: string, context: ProjectContext, + cwd: string, ): Array { - const cwd = process.cwd() const candidates: Array = [] const seen = new Set() @@ -129,14 +131,6 @@ function getLoadFastPathCandidateDirs( return candidates } - const workspacePackages = readWorkspacePackageInfos(context) - - for (const pkg of workspacePackages) { - if (pkg.name === packageName) { - addCandidateDir(candidates, seen, pkg.dir) - } - } - addCandidateDir( candidates, seen, @@ -154,6 +148,23 @@ function getLoadFastPathCandidateDirs( ) } + return candidates +} + +function getWorkspaceLoadFastPathCandidateDirs( + packageName: string, + context: ProjectContext, +): Array { + const candidates: Array = [] + const seen = new Set() + const workspacePackages = readWorkspacePackageInfos(context) + + for (const pkg of workspacePackages) { + if (pkg.name === packageName) { + addCandidateDir(candidates, seen, pkg.dir) + } + } + for (const pkg of workspacePackages) { if (!workspacePackageDeclaresDependency(pkg.packageJson, packageName)) { continue @@ -197,21 +208,15 @@ function resolveScannedPackageSkill( } } -export function resolveSkillUseFastPath( +function resolveFromPackageRoots( + packageRoots: Array, parsedUse: SkillUse, - options: IntentCoreOptions, - context = resolveProjectContext({ cwd: process.cwd() }), + cwd: string, ): ResolveSkillResult | null { - if (options.globalOnly) return null - if (shouldSkipFastPathForYarnPnp(context)) return null - - for (const packageRoot of getLoadFastPathCandidateDirs( - parsedUse.packageName, - context, - )) { + for (const packageRoot of packageRoots) { const scanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, - projectRoot: process.cwd(), + projectRoot: cwd, skillNameHint: parsedUse.skillName, }) const directResolved = resolveScannedPackageSkill(scanned, parsedUse) @@ -220,7 +225,7 @@ export function resolveSkillUseFastPath( if (scanned.package?.name === parsedUse.packageName) { const fallbackScanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, - projectRoot: process.cwd(), + projectRoot: cwd, }) const fallbackResolved = resolveScannedPackageSkill( fallbackScanned, @@ -232,3 +237,30 @@ export function resolveSkillUseFastPath( return null } + +export function resolveSkillUseFastPath( + parsedUse: SkillUse, + options: IntentCoreOptions, + context = resolveProjectContext({ cwd: process.cwd() }), + cwd = context.cwd, +): ResolveSkillResult | null { + if (options.globalOnly) return null + if (shouldSkipFastPathForYarnPnp(context, cwd)) return null + + const directResolved = resolveFromPackageRoots( + getDirectLoadFastPathCandidateDirs(parsedUse.packageName, context, cwd), + parsedUse, + cwd, + ) + if (directResolved) return directResolved + + if (!context.workspaceRoot) { + return null + } + + return resolveFromPackageRoots( + getWorkspaceLoadFastPathCandidateDirs(parsedUse.packageName, context), + parsedUse, + cwd, + ) +} diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 8a187a4..1d3da77 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, relative, resolve, sep } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { createDependencyWalker, createPackageRegistrar, @@ -254,8 +254,10 @@ function discoverSkillByNameHint( const skillNameHints = getSkillNameHints(packageName, skillNameHint) for (const hint of skillNameHints) { - const childDir = join(skillsDir, ...hint.split('/')) - const skillFile = join(childDir, 'SKILL.md') + const resolvedHint = resolveSkillNameHintPath(skillsDir, hint) + if (!resolvedHint) continue + + const { childDir, skillFile } = resolvedHint if (!existsSync(skillFile)) continue const skill = readSkillEntry(skillsDir, childDir, skillFile) @@ -299,6 +301,37 @@ function getPackageShortName(packageName: string): string { return packageName.split('/').pop() ?? packageName } +function isWithinOrEqual(path: string, parentDir: string): boolean { + const rel = relative(parentDir, path) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +function resolveSkillNameHintPath( + skillsDir: string, + hint: string, +): { childDir: string; skillFile: string } | null { + if (hint.startsWith('/') || hint.startsWith('\\')) return null + + const parts = hint.split('/') + if ( + parts.some( + (part) => + part === '' || part === '.' || part === '..' || part.includes('\\'), + ) + ) { + return null + } + + const resolvedSkillsDir = resolve(skillsDir) + const childDir = resolve(resolvedSkillsDir, ...parts) + if (!isWithinOrEqual(childDir, resolvedSkillsDir)) return null + + return { + childDir, + skillFile: join(childDir, 'SKILL.md'), + } +} + function getSkillNameHints( packageName: string, skillNameHint: string, diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 9aefda9..640f16c 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -288,6 +288,21 @@ describe('loadIntentSkill', () => { }) }) + it('does not change process cwd when loading from an explicit cwd', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const cwdBeforeLoad = process.cwd() + + loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + + expect(process.cwd()).toBe(cwdBeforeLoad) + }) + it('rejects a skill symlink that escapes the package root', () => { const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') @@ -544,6 +559,42 @@ describe('loadIntentSkill', () => { ) }) + it('preserves structured suggested skills on core resolution errors', () => { + const pkgDir = join(root, 'node_modules', '@tanstack', 'router-core') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router-core', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd({ + dir: join(pkgDir, 'skills', 'router-core', 'auth-and-guards'), + frontmatter: { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + }) + writeSkillMd({ + dir: join(pkgDir, 'skills', 'router-core', 'setup-guards'), + frontmatter: { + name: 'router-core/setup-guards', + description: 'Router setup guards', + }, + }) + + let error: unknown + try { + loadIntentSkill('@tanstack/router-core#guards', { cwd: root }) + } catch (err) { + error = err + } + + expect(error).toBeInstanceOf(IntentCoreError) + expect((error as IntentCoreError).suggestedSkills).toEqual([ + 'router-core/auth-and-guards', + 'router-core/setup-guards', + ]) + }) + it('fails clearly when the package is excluded', () => { writeInstalledIntentPackage(root, { name: '@tanstack/devtools', diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 1815372..605dadd 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -295,6 +295,22 @@ describe('resolveSkillUse', () => { expect(result.warnings).toEqual([validSecondWarning]) }) + it('does not treat dots as package name boundaries in warnings', () => { + const warning = + 'Found 2 installed variants of foo.bar.baz across 2 versions.' + const validSecondWarning = + 'Found 2 installed variants of foo.bar across 2 versions.' + + const result = resolveSkillUse( + 'foo.bar#core', + scanResult([intentPackage({ name: 'foo.bar' })], { + warnings: [warning, validSecondWarning], + }), + ) + + expect(result.warnings).toEqual([validSecondWarning]) + }) + it('fails clearly when the package is missing', () => { expect(() => { resolveSkillUse( diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index bedd03e..0545515 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -6,7 +6,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { scanForIntents, scanIntentPackageAtRoot } from '../src/scanner.js' @@ -813,8 +813,8 @@ describe('scanForIntents', () => { writeFileSync( join(root, '.pnp.cjs'), [ - `const projectRoot = ${JSON.stringify(`${root}/`)}`, - `const reactStartRoot = ${JSON.stringify(`${reactStartDir}/`)}`, + `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 = {', @@ -932,8 +932,8 @@ describe('scanForIntents', () => { writeFileSync( join(root, '.pnp.cjs'), [ - `const projectRoot = ${JSON.stringify(`${root}/`)}`, - `const reactStartRoot = ${JSON.stringify(`${reactStartDir}/`)}`, + `const projectRoot = ${JSON.stringify(`${root}${sep}`)}`, + `const reactStartRoot = ${JSON.stringify(`${reactStartDir}${sep}`)}`, "const rootLocator = { name: 'tanstack-intent-pnp-monorepo', reference: 'workspace:.' }", "const reactStartLocator = { name: '@tanstack/react-start', reference: 'virtual:test#npm:1.167.52' }", 'module.exports = {', @@ -1171,6 +1171,32 @@ describe('scanIntentPackageAtRoot', () => { expect(result.package?.skills).toEqual([]) }) + + it('does not follow hinted skill paths outside the skills directory', () => { + const pkgDir = createDir(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/', + }, + }) + createDir(pkgDir, 'skills') + writeSkillMd(createDir(pkgDir, 'outside'), { + name: '../outside', + description: 'Escaped skill', + }) + + const result = scanIntentPackageAtRoot(pkgDir, { + fallbackName: '@tanstack/query', + projectRoot: root, + skillNameHint: '../outside', + }) + + expect(result.package?.skills).toEqual([]) + }) }) describe('package manager detection', () => { From 36cdc865956cf6edb3f88b054b008e16d30283c9 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 18:59:57 -0700 Subject: [PATCH 17/20] changeset --- .changeset/five-lizards-peel.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/five-lizards-peel.md diff --git a/.changeset/five-lizards-peel.md b/.changeset/five-lizards-peel.md new file mode 100644 index 0000000..95104bb --- /dev/null +++ b/.changeset/five-lizards-peel.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': patch +--- + +Add the `@tanstack/intent/core` entrypoint for programmatic skill discovery and loading. + +`intent load` now uses the core APIs and a direct dependency fast path, avoiding broad workspace scans when a requested skill can be resolved from the target package. This significantly improves load performance, especially in large workspaces, while preserving markdown link rewriting, warnings, debug output, and existing CLI behavior. From 0685eb54b23ee6b92f4fe898ab66f068c4b94349 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 19:00:10 -0700 Subject: [PATCH 18/20] changset --- .changeset/five-lizards-peel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/five-lizards-peel.md b/.changeset/five-lizards-peel.md index 95104bb..116394a 100644 --- a/.changeset/five-lizards-peel.md +++ b/.changeset/five-lizards-peel.md @@ -4,4 +4,4 @@ Add the `@tanstack/intent/core` entrypoint for programmatic skill discovery and loading. -`intent load` now uses the core APIs and a direct dependency fast path, avoiding broad workspace scans when a requested skill can be resolved from the target package. This significantly improves load performance, especially in large workspaces, while preserving markdown link rewriting, warnings, debug output, and existing CLI behavior. +`intent load` now uses the core APIs and a direct dependency fast path, avoiding broad workspace scans when a requested skill can be resolved from the target package. This significantly improves load performance, especially in large workspaces, while preserving markdown link rewriting, warnings, debug output, and existing CLI behavior. \ No newline at end of file From c93ff00c1f93bc8144767f66ea8b699c3decb584 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 02:00:54 +0000 Subject: [PATCH 19/20] ci: apply automated fixes --- .changeset/five-lizards-peel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/five-lizards-peel.md b/.changeset/five-lizards-peel.md index 116394a..95104bb 100644 --- a/.changeset/five-lizards-peel.md +++ b/.changeset/five-lizards-peel.md @@ -4,4 +4,4 @@ Add the `@tanstack/intent/core` entrypoint for programmatic skill discovery and loading. -`intent load` now uses the core APIs and a direct dependency fast path, avoiding broad workspace scans when a requested skill can be resolved from the target package. This significantly improves load performance, especially in large workspaces, while preserving markdown link rewriting, warnings, debug output, and existing CLI behavior. \ No newline at end of file +`intent load` now uses the core APIs and a direct dependency fast path, avoiding broad workspace scans when a requested skill can be resolved from the target package. This significantly improves load performance, especially in large workspaces, while preserving markdown link rewriting, warnings, debug output, and existing CLI behavior. From 5153bf4afab15bc6b2fe407d928e11bdf00d8a5c Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 2 May 2026 19:36:37 -0700 Subject: [PATCH 20/20] update docs --- docs/cli/intent-list.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 728d6cf..3fee71c 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -43,6 +43,7 @@ When both local and global packages are scanned, local packages take precedence. { "use": "@tanstack/query#fetching", "packageName": "@tanstack/query", + "packageRoot": "/path/to/project/node_modules/@tanstack/query", "packageVersion": "5.0.0", "packageSource": "local", "skillName": "fetching", @@ -56,6 +57,7 @@ When both local and global packages are scanned, local packages take precedence. "name": "@tanstack/query", "version": "5.0.0", "source": "local", + "packageRoot": "/path/to/project/node_modules/@tanstack/query", "skillCount": 1 } ],