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. diff --git a/benchmarks/intent/load.bench.ts b/benchmarks/intent/load.bench.ts new file mode 100644 index 0000000..f31aa1a --- /dev/null +++ b/benchmarks/intent/load.bench.ts @@ -0,0 +1,208 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { + createBenchOptions, + createCliRunner, + createConsoleSilencer, + createTempDir, + writeFile, + writeJson, + writePackage, +} from './helpers.js' + +type LoadFixture = { + root: string + runner: ReturnType + workspaceRoot: string +} + +const consoleSilencer = createConsoleSilencer() +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, + dependencies: { + '@bench/query': '1.0.0', + }, + }) + + 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 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 { + if (!fixture) { + consoleSilencer.silence() + try { + fixture = createFixture() + } catch (err) { + consoleSilencer.restore() + throw err + } + } + + 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 }) + 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) + + 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), + ) + + 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), + ) +}) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 551ce76..3fee71c 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -6,12 +6,14 @@ 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] [--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 @@ -20,44 +22,43 @@ 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` +- 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, 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", + "packageRoot": "/path/to/project/node_modules/@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", + "packageRoot": "/path/to/project/node_modules/@tanstack/query", + "skillCount": 1 } ], "warnings": ["string"], @@ -75,28 +76,27 @@ 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. + +## 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 diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 8119d1c..8f515cf 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -6,13 +6,15 @@ 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] [--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 @@ -21,9 +23,12 @@ 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 +- 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. @@ -32,6 +37,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 ``` @@ -59,6 +65,8 @@ 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/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/package.json b/packages/intent/package.json index 50e6378..521d59d 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,8 +41,8 @@ }, "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", - "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null", + "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 && 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..cf15b5e 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -3,11 +3,14 @@ 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' export interface GlobalScanFlags { + debug?: boolean + exclude?: string | Array global?: boolean globalOnly?: boolean } @@ -74,6 +77,43 @@ 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 { + debug: options.debug, + exclude: Array.isArray(options.exclude) + ? options.exclude + : options.exclude + ? [options.exclude] + : undefined, + global: options.global, + globalOnly: options.globalOnly, + } +} + +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 98f8ada..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' @@ -34,28 +20,38 @@ function createCli(): CAC { 'list', 'Discover intent-enabled packages from the project or workspace', ) - .usage('list [--json] [--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') .example('list') .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 .command('load [use]', 'Load a compact skill use and print its SKILL.md') - .usage('load [--path] [--json] [--global] [--global-only]') + .usage( + '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') .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 @@ -64,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()) }) @@ -75,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) }, ) @@ -101,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()) }) @@ -125,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) }, ) @@ -136,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()) }) @@ -146,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()) }) @@ -156,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/commands/list.ts b/packages/intent/src/commands/list.ts index dff67ce..f354487 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,24 +1,36 @@ import { + coreOptionsFromGlobalFlags, + printDebugInfo, 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 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: ScanResult): void { +function printVersionConflicts(result: IntentSkillList): void { if (result.conflicts.length === 0) return console.log('\nVersion conflicts:\n') @@ -37,25 +49,48 @@ function printVersionConflicts(result: ScanResult): 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, + skillsByPackageRoot: Map>, +): Array { + return skillsByPackageRoot.get(pkg.packageRoot) ?? [] +} + 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)) + 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 } 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 +98,42 @@ 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 skillsByPackageRoot = groupSkillsByPackageRoot(result.skills) + const allSkills = result.packages.map((pkg) => + getPackageSkills(pkg, skillsByPackageRoot).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, skillsByPackageRoot).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..93d42fb 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,11 +1,12 @@ -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, printDebugInfo } from '../cli-support.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 { @@ -13,333 +14,27 @@ 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 +function printLoadDebug(loaded: LoadedIntentSkill | ResolvedIntentSkill): 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, - scanIntentsOrFail: (options?: ScanOptions) => Promise, + _scanIntentsOrFail?: (options?: ScanOptions) => Promise, ): Promise { if (!use) { fail('Missing skill use. Expected: intent load #') @@ -349,23 +44,20 @@ 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}`) - } + 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}`) @@ -373,24 +65,29 @@ export async function runLoadCommand( return } - const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolvedPath, 'utf8'), - packageRoot: resolved.packageRoot, - skillFilePath: resolvedPath, - }) + let loaded: LoadedIntentSkill + try { + loaded = loadIntentSkill(use, coreOptions) + } catch (err) { + if (err instanceof IntentCoreError) { + fail(err.message) + } + throw err + } + printLoadDebug(loaded) 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 +96,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/src/core.ts b/packages/intent/src/core.ts new file mode 100644 index 0000000..6a23908 --- /dev/null +++ b/packages/intent/src/core.ts @@ -0,0 +1,355 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { isAbsolute, relative, resolve } from 'node:path' +import { + compileExcludePatterns, + getEffectiveExcludePatterns, + isPackageExcluded, + warningMentionsPackage, +} 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, + type ResolveSkillResult, +} from './resolver.js' +import { formatSkillUse, parseSkillUse } from './skill-use.js' +import { scanForIntents } from './scanner.js' +import type { ScanOptions, ScanScope } from './types.js' +import type { + IntentCoreErrorCode, + IntentCoreOptions, + IntentSkillList, + IntentSkillSummary, + LoadedIntentSkillDebug, + LoadedIntentSkill, + ResolvedIntentSkill, +} from './core/types.js' + +export type { + IntentCoreErrorCode, + IntentCoreOptions, + IntentPackageSummary, + IntentSkillListDebug, + IntentSkillList, + IntentSkillSummary, + LoadedIntentSkillDebug, + LoadedIntentSkill, + ResolvedIntentSkill, +} from './core/types.js' + +export class IntentCoreError extends Error { + readonly code: IntentCoreErrorCode + readonly suggestedSkills?: Array + + constructor( + code: IntentCoreErrorCode, + message: string, + options: { suggestedSkills?: Array } = {}, + ) { + super(message) + this.name = 'IntentCoreError' + this.code = code + if (options.suggestedSkills) { + this.suggestedSkills = options.suggestedSkills + } + } +} + +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 getScanScope(options: ScanOptions): ScanScope { + return options.scope ?? (options.includeGlobal ? 'local-and-global' : 'local') +} + +function resolveCoreCwd(options: IntentCoreOptions): string { + return resolve(process.cwd(), options.cwd ?? process.cwd()) +} + +export function listIntentSkills( + options: IntentCoreOptions = {}, +): IntentSkillList { + 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, + 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 +} + +function resolveFromCwd(cwd: string, path: string): string { + return resolve(cwd, path) +} + +function isResolvedPathInsidePackageRoot( + path: string, + packageRoot: string, +): boolean { + const relativePath = relative(packageRoot, path) + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !isAbsolute(relativePath)) + ) +} + +function toResolvedIntentSkill( + cwd: string, + use: string, + resolved: ResolveSkillResult, + debug?: LoadedIntentSkillDebug, +): { + realPackageRoot: string + realResolvedPath: string + result: ResolvedIntentSkill +} { + let realResolvedPath: string + try { + realResolvedPath = realpathSync.native(resolveFromCwd(cwd, resolved.path)) + } catch { + throw new IntentCoreError( + 'skill-file-not-found', + `Resolved skill file was not found: ${resolved.path}`, + ) + } + const realPackageRoot = realpathSync.native( + resolveFromCwd(cwd, resolved.packageRoot), + ) + + if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { + throw new IntentCoreError( + 'skill-path-outside-package', + `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, + ) + } + + const result: ResolvedIntentSkill = { + path: resolved.path, + packageRoot: resolved.packageRoot, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + warnings: resolved.warnings, + conflict: resolved.conflict, + } + + if (debug) { + result.debug = debug + } + + return { + realPackageRoot, + realResolvedPath, + result, + } +} + +function createLoadedSkillDebug({ + cwd, + excludes, + resolution, + resolved, + scope, +}: { + cwd: string + excludes: Array + resolution: LoadedIntentSkillDebug['resolution'] + resolved: ResolveSkillResult + scope: ScanScope +}): LoadedIntentSkillDebug { + return { + cwd, + scope, + resolution, + excludes, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + path: resolved.path, + warningCount: resolved.warnings.length, + } +} + +function resolveIntentSkillInCwd( + cwd: string, + use: string, + options: IntentCoreOptions = {}, +): { + 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), + ) + } + + const projectContext = resolveProjectContext({ cwd }) + const excludePatterns = getEffectiveExcludePatterns(options, projectContext) + const excludeMatchers = compileExcludePatterns(excludePatterns) + + if (isPackageExcluded(parsedUse.packageName, excludeMatchers)) { + throw new IntentCoreError( + 'package-excluded', + `Cannot load skill use "${use}": package "${parsedUse.packageName}" is excluded by Intent configuration.`, + ) + } + + const scanOptions = toScanOptions(options) + const scope = getScanScope(scanOptions) + const fastPathResolved = resolveSkillUseFastPath( + parsedUse, + options, + projectContext, + cwd, + ) + if (fastPathResolved) { + return toResolvedIntentSkill( + cwd, + use, + fastPathResolved, + options.debug + ? createLoadedSkillDebug({ + cwd, + excludes: excludePatterns, + resolution: 'fast-path', + resolved: fastPathResolved, + scope, + }) + : undefined, + ) + } + + 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, { + suggestedSkills: err.suggestedSkills, + }) + } + throw err + } + + return toResolvedIntentSkill( + cwd, + use, + resolved, + options.debug + ? createLoadedSkillDebug({ + cwd, + excludes: excludePatterns, + resolution: 'full-scan', + resolved, + scope, + }) + : undefined, + ) +} + +export function resolveIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): ResolvedIntentSkill { + return resolveIntentSkillInCwd(resolveCoreCwd(options), use, options).result +} + +export function loadIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): LoadedIntentSkill { + 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 new file mode 100644 index 0000000..1770a7b --- /dev/null +++ b/packages/intent/src/core/excludes.ts @@ -0,0 +1,137 @@ +import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { + resolveProjectContext, + type ProjectContext, +} from './project-context.js' +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 [] + + 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, + context = resolveProjectContext({ cwd }), +): Array { + 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 = {}, + context?: ProjectContext, +): Array { + const cwd = + context?.cwd ?? resolve(process.cwd(), options.cwd ?? process.cwd()) + return [ + ...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 = normalizeGlobPattern(pattern) + .split('*') + .map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')) + .join('.*') + return new RegExp(`^${source}$`) +} + +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, + matchers: Array, +): boolean { + return matchers.some((matcher) => matcher.matches(packageName)) +} + +export function warningMentionsPackage( + warning: string, + packageName: string, +): boolean { + let fromIndex = 0 + + 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/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts new file mode 100644 index 0000000..851f732 --- /dev/null +++ b/packages/intent/src/core/load-resolution.ts @@ -0,0 +1,266 @@ +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, + type ProjectContext, +} 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( + context: ProjectContext, +): Array { + 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 hasYarnPnpFile(dir: string | null): boolean { + return ( + dir !== null && + (existsSync(join(dir, '.pnp.cjs')) || existsSync(join(dir, '.pnp.js'))) + ) +} + +function shouldSkipFastPathForYarnPnp( + context: ProjectContext, + cwd: string, +): boolean { + return hasYarnPnpFile(cwd) || hasYarnPnpFile(context.workspaceRoot) +} + +function getDirectLoadFastPathCandidateDirs( + packageName: string, + context: ProjectContext, + cwd: string, +): Array { + const candidates: Array = [] + const seen = new Set() + + if (!context.workspaceRoot) { + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, context.packageRoot ?? cwd), + ) + return candidates + } + + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir( + packageName, + context.packageRoot ?? context.workspaceRoot ?? cwd, + ), + ) + + if (context.workspaceRoot && context.workspaceRoot !== context.packageRoot) { + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, context.workspaceRoot), + ) + } + + 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 + } + + addCandidateDir( + candidates, + seen, + resolveDependencyPackageDir(packageName, pkg.dir), + ) + } + + 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, + } +} + +function resolveFromPackageRoots( + packageRoots: Array, + parsedUse: SkillUse, + cwd: string, +): ResolveSkillResult | null { + for (const packageRoot of packageRoots) { + const scanned = scanIntentPackageAtRoot(packageRoot, { + fallbackName: parsedUse.packageName, + projectRoot: cwd, + skillNameHint: parsedUse.skillName, + }) + const directResolved = resolveScannedPackageSkill(scanned, parsedUse) + if (directResolved) return directResolved + + if (scanned.package?.name === parsedUse.packageName) { + const fallbackScanned = scanIntentPackageAtRoot(packageRoot, { + fallbackName: parsedUse.packageName, + projectRoot: cwd, + }) + const fallbackResolved = resolveScannedPackageSkill( + fallbackScanned, + parsedUse, + ) + if (fallbackResolved) return fallbackResolved + } + } + + 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/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..80fa105 --- /dev/null +++ b/packages/intent/src/core/package-json.ts @@ -0,0 +1,12 @@ +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 + } 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..9d5e03e --- /dev/null +++ b/packages/intent/src/core/types.ts @@ -0,0 +1,85 @@ +import type { IntentPackage, ScanScope, VersionConflict } from '../types.js' + +export interface IntentCoreOptions { + cwd?: string + debug?: boolean + global?: boolean + globalOnly?: boolean + exclude?: Array +} + +export interface IntentSkillSummary { + use: string + packageName: string + packageRoot: string + packageVersion: string + packageSource: IntentPackage['source'] + skillName: string + description: string + type?: string + framework?: string +} + +export interface IntentPackageSummary { + name: string + version: string + source: IntentPackage['source'] + packageRoot: string + skillCount: number +} + +export interface IntentSkillList { + skills: Array + packages: Array + warnings: Array + conflicts: Array + debug?: IntentSkillListDebug +} + +export interface ResolvedIntentSkill { + path: string + packageRoot: string + packageName: string + skillName: string + version: string + source: IntentPackage['source'] + warnings: Array + conflict: VersionConflict | null + debug?: LoadedIntentSkillDebug +} + +export interface LoadedIntentSkill extends ResolvedIntentSkill { + content: string +} + +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 = + | 'invalid-options' + | 'invalid-skill-use' + | 'package-not-found' + | 'package-excluded' + | 'skill-not-found' + | 'skill-path-outside-package' + | 'skill-file-not-found' 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 1da10bb..121c542 100644 --- a/packages/intent/src/resolver.ts +++ b/packages/intent/src/resolver.ts @@ -1,5 +1,11 @@ +import { warningMentionsPackage } from './core/excludes.js' 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 +27,7 @@ export class ResolveSkillUseError extends Error { readonly skillName: string readonly availablePackages: Array readonly availableSkills: Array + readonly suggestedSkills: Array constructor({ availablePackages = [], @@ -28,6 +35,7 @@ export class ResolveSkillUseError extends Error { code, packageName, skillName, + suggestedSkills = [], use, }: { availablePackages?: Array @@ -35,6 +43,7 @@ export class ResolveSkillUseError extends Error { code: ResolveSkillUseErrorCode packageName: string skillName: string + suggestedSkills?: Array use: string }) { super( @@ -44,12 +53,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 +74,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 +162,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 +171,7 @@ export function resolveSkillUse( code: 'skill-not-found', packageName, skillName, + suggestedSkills: resolvedSkill.suggestedSkills, use, }) } @@ -101,17 +183,14 @@ export function resolveSkillUse( return { packageName, - skillName, + skillName: skill.name, path: skill.path, 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, } } @@ -122,6 +201,7 @@ function formatResolveSkillUseErrorMessage({ code, packageName, skillName, + suggestedSkills, use, }: { availablePackages: Array @@ -129,6 +209,7 @@ function formatResolveSkillUseErrorMessage({ code: ResolveSkillUseErrorCode packageName: string skillName: string + suggestedSkills: Array use: string }): string { switch (code) { @@ -140,11 +221,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/src/scanner.ts b/packages/intent/src/scanner.ts index f187be8..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, @@ -223,11 +223,55 @@ function deriveIntentConfig( // Skill discovery within a package // --------------------------------------------------------------------------- -function discoverSkills( +function readSkillEntry( skillsDir: string, - _baseName: 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, + packageName: string, + skillNameHint: string, ): Array { const skills: Array = [] + const seen = new Set() + const skillNameHints = getSkillNameHints(packageName, skillNameHint) + + for (const hint of skillNameHints) { + const resolvedHint = resolveSkillNameHintPath(skillsDir, hint) + if (!resolvedHint) continue + + const { childDir, skillFile } = resolvedHint + if (!existsSync(skillFile)) continue + + 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): Array { + const skills: Array = [] function walk(dir: string): void { let entries: Array> @@ -241,20 +285,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. @@ -266,6 +297,53 @@ function discoverSkills( return skills } +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, +): Array { + const packageShortName = getPackageShortName(packageName) + if (skillNameHint.startsWith(`${packageShortName}/`)) { + return [skillNameHint] + } + + return [skillNameHint, `${packageShortName}/${skillNameHint}`] +} + // --------------------------------------------------------------------------- // Topological sort on requires // --------------------------------------------------------------------------- @@ -570,10 +648,18 @@ export function scanForIntents( } assertLocalNodeModulesSupported(projectRoot) - scanTarget(nodeModules.local) walkWorkspacePackages() + const packageCountBeforeDependencyDiscovery = packages.length + scanTarget(nodeModules.local) walkKnownPackages() walkProjectDeps() + + if (packages.length === packageCountBeforeDependencyDiscovery) { + const api = getPnpApi() + if (api) { + scanPnpPackages(api) + } + } } function scanGlobalPackages(): void { @@ -620,3 +706,75 @@ export function scanForIntents( return { packageManager, packages: sorted, warnings, conflicts, nodeModules } } + +export interface ScanIntentPackageAtRootOptions { + fallbackName?: string + projectRoot?: string + source?: IntentPackage['source'] + skillNameHint?: string +} + +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: options.skillNameHint + ? (skillsDir, packageName) => + discoverSkillByNameHint( + skillsDir, + packageName, + options.skillNameHint!, + ) + : 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/cli.test.ts b/packages/intent/tests/cli.test.ts index 425859c..f5fc53d 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,13 +562,49 @@ 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([]) }) + 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( @@ -592,12 +629,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 +663,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 +680,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 +718,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,28 +804,61 @@ 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, }) }) + 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) @@ -927,6 +1002,60 @@ 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/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query data fetching patterns', + }) + + 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) + 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) @@ -1145,6 +1274,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 new file mode 100644 index 0000000..640f16c --- /dev/null +++ b/packages/intent/tests/core.test.ts @@ -0,0 +1,621 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + 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, + resolveIntentSkill, +} 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', + packageRoot: join(root, 'node_modules', '@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', + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), + skillCount: 1, + }, + ], + warnings: [], + conflicts: [], + }) + }) + + 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', + 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('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'), { + 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', () => { + 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', + 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('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') + 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', + 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('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', + 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 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') + 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') + 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".', + ) + }) + + 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', + 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.', + ) + }) +}) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 4579ae3..605dadd 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' @@ -231,6 +279,38 @@ 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('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( @@ -260,4 +340,20 @@ 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?', + ) + }) }) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 84843ec..0545515 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -6,10 +6,10 @@ 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 } from '../src/scanner.js' +import { scanForIntents, scanIntentPackageAtRoot } from '../src/scanner.js' // ── Helpers ── @@ -752,6 +752,226 @@ 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', + intent: { + version: 1, + repo: 'TanStack/router', + docs: 'https://tanstack.com/start', + }, + repository: { + type: 'git', + url: 'git+https://github.com/TanStack/router.git', + directory: 'packages/react-start', + }, + homepage: 'https://tanstack.com/start', + }) + writeSkillMd(createDir(reactStartDir, 'skills', 'react-start'), { + name: 'react-start', + description: 'React Start skill', + }) + writeSkillMd( + createDir(reactStartDir, 'skills', 'lifecycle', 'migrate-from-nextjs'), + { + name: 'lifecycle/migrate-from-nextjs', + description: 'Migration skill', + }, + ) + writeSkillMd( + createDir(reactStartDir, 'skills', 'react-start', 'server-components'), + { + name: 'react-start/server-components', + description: 'Server components skill', + }, + ) + + writeFileSync( + join(root, '.pnp.cjs'), + [ + `const projectRoot = ${JSON.stringify(`${root}${sep}`)}`, + `const reactStartRoot = ${JSON.stringify(`${reactStartDir}${sep}`)}`, + "const rootLocator = { name: 'tanstack-intent-pnp-repro', reference: 'workspace:.' }", + "const reactStartLocator = { name: '@tanstack/react-start', reference: 'virtual:test#npm:1.167.52' }", + 'module.exports = {', + ' 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('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', + intent: { + version: 1, + repo: 'TanStack/router', + docs: 'https://tanstack.com/start', + }, + 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}${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 = {', + ' 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', @@ -853,6 +1073,132 @@ 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('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'), { + 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([]) + }) + + 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', () => { it('detects npm from package-lock.json', () => { writeFileSync(join(root, 'package-lock.json'), '{}')