diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6284ff67..761b2165 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,6 +36,23 @@ jobs: main-branch-name: main - name: Run Checks run: pnpm run test:pr + delivery: + name: Delivery (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Setup Tools + uses: TanStack/config/.github/setup@b313637fa7d314532b98638f6b57b7b9c169d390 # main + - name: Run Managed Link Tests + run: pnpm --filter @tanstack/intent run test:delivery preview: name: Preview runs-on: ubuntu-latest diff --git a/benchmarks/intent/catalog.bench.ts b/benchmarks/intent/catalog.bench.ts new file mode 100644 index 00000000..f947f9ff --- /dev/null +++ b/benchmarks/intent/catalog.bench.ts @@ -0,0 +1,107 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { scanForIntents } from '../../packages/intent/src/discovery/scanner.js' +import { buildCurrentLockfileSources } from '../../packages/intent/src/core/lockfile/lockfile-state.js' +import { writeIntentLockfile } from '../../packages/intent/src/core/lockfile/lockfile.js' +import { + createCliRunner, + createConsoleSilencer, + createTempDir, + writeFile, + writeJson, + writePackage, +} from './helpers.js' + +const consoleSilencer = createConsoleSilencer() +const root = createTempDir('catalog') +const runner = createCliRunner({ cwd: root }) + +const PACKAGES = [ + { + name: '@bench/query', + skills: [ + 'queries', + 'mutations', + 'invalidation', + 'prefetching', + 'suspense', + 'pagination', + 'optimistic-updates', + 'ssr-hydration', + ], + }, + { + name: '@bench/router', + skills: [ + 'routing', + 'loaders', + 'search-params', + 'navigation', + 'code-splitting', + 'route-masking', + 'not-found', + ], + }, + { + name: '@bench/table', + skills: ['columns', 'sorting', 'filtering', 'grouping', 'virtualization'], + }, +] + +let getIntentCatalogContext: (options: { + cwd: string + refresh?: boolean +}) => Promise + +beforeAll(async () => { + consoleSilencer.silence() + writeJson(join(root, 'package.json'), { + name: 'intent-catalog-benchmark', + private: true, + intent: { skills: ['@bench/*'] }, + dependencies: Object.fromEntries( + PACKAGES.map((pkg) => [pkg.name, '1.0.0']), + ), + }) + writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: "9.0"\n') + for (const pkg of PACKAGES) { + writePackage(join(root, 'node_modules'), pkg.name, '1.0.0', { + skills: pkg.skills, + }) + } + + // Without a lockfile the catalogue skips verification entirely, so the warm + // path would measure an empty loop instead of the per-skill hashing it runs + // on every session start. + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources( + scanForIntents(root, { scope: 'local' }).packages, + ), + }) + + await runner.setup() + const catalog = await import('../../packages/intent/dist/catalog.mjs') + getIntentCatalogContext = catalog.getIntentCatalogContext +}) + +afterAll(() => { + runner.teardown() + rmSync(root, { recursive: true, force: true }) + consoleSilencer.restore() +}) + +describe('intent catalog', () => { + bench('cold catalogue generation through API', async () => { + await getIntentCatalogContext({ cwd: root, refresh: true }) + }) + + bench('warm cached catalogue retrieval through API', async () => { + await getIntentCatalogContext({ cwd: root }) + }) + + bench('warm cached catalogue retrieval through CLI', async () => { + await runner.run(['catalog', '--json']) + }) +}) diff --git a/benchmarks/intent/helpers.ts b/benchmarks/intent/helpers.ts index 011fd52b..c3918955 100644 --- a/benchmarks/intent/helpers.ts +++ b/benchmarks/intent/helpers.ts @@ -1,6 +1,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import type { IntentPackage } from '../../packages/intent/src/shared/types.js' let builtCliMainPromise: Promise< (argv?: Array) => Promise @@ -20,6 +21,7 @@ type ConsoleSnapshot = { export type SkillOptions = { description: string + name?: string bodyLines?: number type?: 'core' | 'framework' requires?: Array @@ -36,6 +38,22 @@ export type PackageOptions = { brokenIntent?: boolean } +export function createRepresentativeIntentPackages(): Array { + return Array.from({ length: 20 }, (_, packageIndex) => ({ + name: `@bench/package-${String(packageIndex).padStart(2, '0')}`, + version: '1.0.0', + kind: 'npm', + source: 'local', + packageRoot: `node_modules/@bench/package-${packageIndex}`, + intent: { version: 1, repo: 'bench/packages', docs: 'docs/' }, + skills: Array.from({ length: 5 }, (_, skillIndex) => ({ + name: `skill-${skillIndex}`, + path: `skills/skill-${skillIndex}/SKILL.md`, + description: `Skill ${skillIndex}`, + })), + })) +} + const noop = () => undefined export function createBenchOptions( @@ -142,7 +160,7 @@ async function loadBuiltCliMain(): Promise< ) } - return module.main as (argv?: Array) => Promise + return module.main }, ) @@ -168,7 +186,7 @@ export function writeSkill( options: SkillOptions, ): void { const frontmatter = [ - `name: ${JSON.stringify(skillName)}`, + `name: ${JSON.stringify(options.name ?? skillName)}`, `description: ${JSON.stringify(options.description)}`, ] diff --git a/benchmarks/intent/install-plan.bench.ts b/benchmarks/intent/install-plan.bench.ts new file mode 100644 index 00000000..234df780 --- /dev/null +++ b/benchmarks/intent/install-plan.bench.ts @@ -0,0 +1,32 @@ +import { bench, describe } from 'vitest' +import { updateIntentConsumerConfigText } from '../../packages/intent/src/commands/install/config.js' +import { buildSkillSelectionPlan } from '../../packages/intent/src/commands/install/plan.js' +import { createRepresentativeIntentPackages } from './helpers.js' + +const packages = createRepresentativeIntentPackages() + +const packageJson = `${JSON.stringify( + { + name: 'install-plan-benchmark', + private: true, + intent: { skills: [], exclude: [] }, + }, + null, + 2, +)}\n` + +const selection = buildSkillSelectionPlan(packages, { mode: 'all-found' }) + +describe('installer planning', () => { + bench('plans 100 discovered skills', () => { + buildSkillSelectionPlan(packages, { mode: 'all-found' }) + }) + + bench('updates consumer JSONC configuration', () => { + updateIntentConsumerConfigText(packageJson, { + skills: selection.skills, + exclude: selection.exclude, + install: { method: 'symlink', targets: ['agents'] }, + }) + }) +}) diff --git a/benchmarks/intent/lockfile-hash.bench.ts b/benchmarks/intent/lockfile-hash.bench.ts new file mode 100644 index 00000000..58d79949 --- /dev/null +++ b/benchmarks/intent/lockfile-hash.bench.ts @@ -0,0 +1,25 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { computeSkillContentHash } from '../../packages/intent/src/core/lockfile/hash.js' +import { createTempDir, writeFile } from './helpers.js' + +const root = createTempDir('lockfile-hash') +const skillDir = join(root, 'skills', 'representative') + +beforeAll(() => { + writeFile(join(skillDir, 'SKILL.md'), '# Guidance\n'.repeat(200)) + writeFile(join(skillDir, 'references', 'api.md'), '# API\n'.repeat(200)) + writeFile(join(skillDir, 'assets', 'example.json'), '{"enabled":true}\n') + writeFile(join(skillDir, 'scripts', 'check.mjs'), 'process.exit(0)\n') +}) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('per-skill lock hashing', () => { + bench('hashes a representative skill folder', () => { + computeSkillContentHash({ packageRoot: root, skillDir }) + }) +}) diff --git a/benchmarks/intent/sync.bench.ts b/benchmarks/intent/sync.bench.ts new file mode 100644 index 00000000..e7e25b4c --- /dev/null +++ b/benchmarks/intent/sync.bench.ts @@ -0,0 +1,62 @@ +import { bench, describe } from 'vitest' +import { buildInstallDeltaInventory } from '../../packages/intent/src/commands/install/plan.js' +import { createSyncAliases } from '../../packages/intent/src/commands/sync/targets.js' +import { createRepresentativeIntentPackages } from './helpers.js' +import type { IntentLockfileSource } from '../../packages/intent/src/core/lockfile/lockfile.js' +import type { IntentConsumerConfig } from '../../packages/intent/src/commands/install/config.js' + +const packages = createRepresentativeIntentPackages() + +const sources: Array = packages.map((pkg) => ({ + kind: pkg.kind, + id: pkg.name, + skills: pkg.skills.map((skill) => ({ + path: `skills/${skill.name}`, + contentHash: `${pkg.name}-${skill.name}`, + })), +})) + +const config: IntentConsumerConfig = { + skills: ['@bench/*'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, +} + +describe('sync planning', () => { + bench('plans unchanged representative sources and aliases', () => { + buildInstallDeltaInventory( + packages, + sources, + { status: 'found', lockfile: { lockfileVersion: 1, sources } }, + config, + ) + createSyncAliases( + packages.flatMap((pkg) => + pkg.skills.map((skill) => ({ + kind: pkg.kind, + id: pkg.name, + skill: skill.name, + })), + ), + ) + }) + + bench('plans changed and pending sources', () => { + const changed = sources.map((source, index) => + index === 0 + ? { + ...source, + skills: source.skills.map((skill, skillIndex) => + skillIndex === 0 ? { ...skill, contentHash: 'changed' } : skill, + ), + } + : source, + ) + buildInstallDeltaInventory( + packages, + changed, + { status: 'found', lockfile: { lockfileVersion: 1, sources } }, + { ...config, skills: ['@bench/package-00'] }, + ) + }) +}) diff --git a/benchmarks/intent/tsconfig.json b/benchmarks/intent/tsconfig.json index fa8caa6e..8ce9be06 100644 --- a/benchmarks/intent/tsconfig.json +++ b/benchmarks/intent/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "rootDir": ".", "noEmit": true }, "include": ["*.ts"] diff --git a/benchmarks/intent/validate.bench.ts b/benchmarks/intent/validate.bench.ts index b05e998e..6a0cdef0 100644 --- a/benchmarks/intent/validate.bench.ts +++ b/benchmarks/intent/validate.bench.ts @@ -44,7 +44,6 @@ function createFixture(): ValidateFixture { writeSkill(root, domain, { description: `${domain} overview and guardrails`, bodyLines: 20, - type: 'core', }) for (let index = 1; index <= 4; index++) { @@ -53,8 +52,8 @@ function createFixture(): ValidateFixture { writeSkill(root, skillName, { description: `${domain} workflow ${index}`, + name: `workflow-${index}`, bodyLines: 18, - type: isFrameworkSkill ? 'framework' : 'core', requires: isFrameworkSkill ? [domain] : undefined, }) } @@ -124,10 +123,8 @@ describe('intent validate', () => { 'checks a shipped skills tree', async () => { const state = getFixture() - for (let index = 0; index < 3; index++) { - await state.runner.run(['validate']) - } + await state.runner.run(['validate']) }, - createBenchOptions(setup, teardown), + { ...createBenchOptions(setup, teardown), warmupIterations: 1 }, ) }) diff --git a/docs/cli/intent-exclude.md b/docs/cli/intent-exclude.md index 1c1d4db2..38774460 100644 --- a/docs/cli/intent-exclude.md +++ b/docs/cli/intent-exclude.md @@ -1,43 +1,37 @@ ---- -title: intent exclude -id: intent-exclude ---- - -`intent exclude` manages `package.json#intent.exclude` entries. - -```bash -npx @tanstack/intent@latest exclude [list|add|remove] [pattern] [--json] -``` - -## Options - -- `--json`: print the configured exclude patterns as JSON - -## Actions - -1. `list` (default): print current excludes -2. `add `: append one exclude pattern -3. `remove `: remove one exclude pattern - -## Examples - -```bash -npx @tanstack/intent@latest exclude -npx @tanstack/intent@latest exclude list --json -npx @tanstack/intent@latest exclude add @tanstack/router#experimental-* -npx @tanstack/intent@latest exclude remove @tanstack/router#experimental-* -``` - -## Behavior - -- Reads and writes the current working directory `package.json` -- Creates `intent.exclude` when missing -- Keeps existing excludes and appends new patterns in order -- Validates pattern syntax before writing -- Refuses invalid `package.json` structures for `intent` and `intent.exclude` - -## Related - -- [Configuration](../concepts/configuration) -- [intent list](./intent-list) -- [intent load](./intent-load) +--- +title: intent exclude +id: intent-exclude +--- + +`intent exclude` manages the `intent.exclude` list in your `package.json`. Excludes remove packages or individual skills after the `intent.skills` allowlist resolves, so an excluded skill never reaches your agent even when its package is trusted. + + +@tanstack/intent@latest exclude [list|add|remove] [pattern] [--json] + + +## Actions + +- `list` (default): print the configured excludes. Add `--json` for machine-readable output. +- `add `: append one exclude pattern. +- `remove `: remove one exclude pattern. + +```bash +npx @tanstack/intent@latest exclude +npx @tanstack/intent@latest exclude list --json +npx @tanstack/intent@latest exclude add @tanstack/router#experimental-* +npx @tanstack/intent@latest exclude remove @tanstack/router#experimental-* +``` + +For the pattern grammar - whole packages, single skills, and globs - see [Configuration](../concepts/configuration). + +## Behavior + +`add` and `remove` edit the `package.json` in the current directory, creating `intent.exclude` if it is missing and keeping existing entries in order. Intent validates a pattern before writing and refuses an invalid `intent` or `intent.exclude` structure. `list` prints `Configured excludes:` with one entry per line, or `No excludes configured.` when the list is empty. + +An excluded package does not trigger the unlisted-source warning, because excluding it is an explicit decision. + +## Related + +- [Configuration](../concepts/configuration) - the exclude pattern grammar. +- [`intent list`](./intent-list) - see what remains after excludes. +- [`intent load`](./intent-load) - excluded skills refuse to load. diff --git a/docs/cli/intent-hooks.md b/docs/cli/intent-hooks.md index 44e1c9f0..69fb7e6d 100644 --- a/docs/cli/intent-hooks.md +++ b/docs/cli/intent-hooks.md @@ -3,49 +3,38 @@ title: intent hooks id: intent-hooks --- -`intent hooks install` installs lifecycle hooks that surface available Intent skills and enforce loading matching guidance before edits in supported agents. +`intent hooks run` runs the agent lifecycle hook that shows your coding agent which skills are available at the start of a session. You do not usually run it yourself: `intent install` wires it into the agent's session-start hook when you choose hook delivery. -```bash -npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all] -``` + +@tanstack/intent@latest hooks run --agent copilot|claude|codex + ## Options -- `--scope `: hook install scope, either `project` or `user`; defaults to `project` -- `--agents `: comma-separated hook agents to configure (`copilot`, `claude`, `codex`) or `all`; defaults to `all` +- `--agent `: the agent whose hook format to emit, one of `copilot`, `claude`, or `codex`. Required. -## Behavior +## What it does -- Installs hook behavior without writing an `intent-skills` guidance block. -- Adds a session-start skill catalog for supported agents so the agent sees available `skill-id: description` entries before it starts work. -- Keeps edit enforcement in place: supported edit tools are blocked until the agent runs `intent load ` for matching guidance. -- `--scope project` writes project-local hook config for agents that support it. -- `--scope user` writes user-level agent config and stores runner scripts under `~/.tanstack/intent/hooks`. -- `--agents all` is the default. In project scope, Copilot is skipped because the supported Copilot CLI hook location is user-scoped. -- Run `intent install` separately when you also want to write project guidance. -- Use `package.json#intent.skills` and `package.json#intent.exclude` to control which skills are surfaced in the session catalog. +At the start of an agent session, the hook prints a short catalogue of the skills your project trusts, each with the command to load it, so the agent knows what is available before it starts work. It reads the session event on stdin and responds only to session-start events; for anything else it prints nothing. -## Hook support +The catalogue lists only skills accepted in `intent.lock`, and it is capped to keep sessions small: at most 50 skills and about 8 KB, with long descriptions trimmed. If it cannot build the catalogue it fails open, so the session continues and the hook prints a note to run `intent catalog` outside the session to see why. -| Agent | Project scope | User scope | Hooks installed | -| --- | --- | --- | --- | -| Claude Code | `.claude/settings.json` | `~/.claude/settings.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate | -| Codex | `.codex/hooks.json` | `~/.codex/hooks.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate; Codex hook interception is not a complete security boundary | -| GitHub Copilot CLI | Guidance via `.github/copilot-instructions.md`; blocking hooks are not project-scoped | `$COPILOT_HOME/hooks/hooks.json` or `~/.copilot/hooks/hooks.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate in user scope | -| Cursor | Guidance only | Guidance only | Use `AGENTS.md` or Cursor rules; no blocking hook is installed | -| Generic `AGENTS.md` agents | Guidance only | Guidance only | Use the `intent-skills` guidance block; no blocking hook is installed | +Hooks surface skills; they do not block edits. They are a convenience for getting skills in front of your agent, not a security boundary - the trust guarantees come from the source policy and the lockfile. See the [trust model](../concepts/trust-model). -`.github/copilot-instructions.md` is a supported project guidance target for `intent install`. GitHub Copilot CLI hook enforcement uses the user-scoped Copilot hooks directory because that is the supported hook location. +## Installing hooks -Codex requires users to review and trust non-managed hooks before they run. If Codex reports hooks awaiting review, open its hook browser and trust the generated Intent hook. +Choose hook delivery when you run [`intent install`](./intent-install). Because the supported hook locations live in your home directory, these hooks are user-scoped and apply across your repositories, so `install` asks before writing them. It configures a session-start hook for the agents you target and removes any earlier Intent edit-gate hooks. -## Status messages +| Agent | Hook config | +| --- | --- | +| Claude Code | `~/.claude/settings.json` | +| Codex | `~/.codex/hooks.json` | +| GitHub Copilot CLI | `~/.copilot/hooks/hooks.json` (or `$COPILOT_HOME/hooks/hooks.json`) | -- Hook installed: `Installed Intent hooks for claude (project) in .claude/settings.json.` -- Hook skipped: `Skipped Intent hooks for copilot: project scope is not supported; use --scope user` +Codex may hold new hooks for review; open its hook browser and trust the Intent hook if prompted. ## Related -- [intent install](./intent-install) -- [intent list](./intent-list) -- [intent load](./intent-load) +- [`intent install`](./intent-install) - set up hook delivery. +- [`intent list`](./intent-list) - see which skills are available. +- [`intent load`](./intent-load) - print a skill's guidance. diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 85b70e92..57785a4f 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -3,61 +3,45 @@ title: intent install id: intent-install --- -`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. +`intent install` sets up trusted skill delivery for your project. It records which packages you trust, locks the skill content you accept, and delivers those skills to your coding agents. Run it once to set up a project, and again after dependencies change to review and accept updates. For a step-by-step walkthrough, see the [consumer quick start](../getting-started/quick-start-consumers). -```bash -npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] -``` +The default is an interactive setup where you choose how skills are delivered: symlinks, lifecycle hooks, or a static guidance block. `--map` writes the static guidance block directly, without the interactive prompts and without a terminal. + + +@tanstack/intent@latest install [--map] [--dry-run] [--global] [--global-only] [--no-notices] + ## Options -### Guidance output +- `--map`: write the static guidance block directly, without the interactive setup. +- `--dry-run`: report what install would write, and change nothing. +- `--global`: with `--map`, include global packages after the project packages. +- `--global-only`: with `--map`, use only global packages. +- `--no-notices`: suppress non-critical notices on stderr. -- `--map`: write explicit task-to-skill mappings instead of lightweight loading guidance -- `--dry-run`: print the generated block without writing files -- `--print-prompt`: print the agent setup prompt instead of writing files +## Interactive setup -### Mapping scan scope +The default `install` runs an interactive setup, so it needs a terminal. For CI or a non-interactive shell, use `--map`. -- `--global`: include global packages after project packages when `--map` is passed -- `--global-only`: install mappings from global packages only when `--map` is passed -- `--no-notices`: suppress non-critical notices on stderr +Intent asks how to deliver skills, where to deliver them, and which skills to trust, then confirms before writing. There are three delivery choices: -## Behavior +- **Symlinks** link the accepted skill folders into your agent directories. +- **Lifecycle hooks** surface accepted skills at the start of an agent session. +- **Static guidance block** writes an `intent-skills` block into a file such as `AGENTS.md`. -- Writes lightweight skill loading guidance by default. -- Creates `AGENTS.md` when no managed block exists. -- Updates an existing managed block in a supported config file. -- Preserves all content outside the managed block. -- Scans packages and writes compact `id`, `run`, and `for` mappings only when `--map` is passed. -- Surfaces packages permitted by `package.json#intent.skills` in `--map` mode. See [Configuration](../concepts/configuration). -- Skips reference, meta, maintainer, and maintainer-only skills in `--map` mode. -- Writes compact skill identities and runnable guidance commands instead of local file paths in `--map` mode. -- Verifies the managed block before reporting success. -- Prints `No intent-enabled skills found.` and does not create a config file when `--map` finds no actionable skills. +Every choice records your trusted sources in `package.json` (`intent.skills`, plus any `intent.exclude`) and the content you accepted in `intent.lock`. -Supported config files: `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`. +Symlinks and hooks are managed delivery: Intent also writes `.intent/delivery.json` and keeps the skills in place. With symlinks it runs [`intent sync`](./intent-sync) once, adds the links to `.git/info/exclude`, and adds a `prepare: intent sync` script when Intent is a dev dependency, then prints a line such as `Installed 5 skills using symlink.` The static guidance block is a snapshot rather than managed delivery, the same output as the `--map` snapshot below; Intent prints a line such as `Installed 5 skills to AGENTS.md as a static guidance block.` -## Default output +Use `--dry-run` to preview any of this without writing files. See the [trust model](../concepts/trust-model) for how trusted sources and accepted content combine. -The default block tells agents to discover skills and load matching guidance on demand: +## Portable snapshot with --map -```markdown - -## Skill Loading - -Before editing files for a substantial task: -- Run `npx @tanstack/intent@latest list` from the workspace root to see available local skills. -- If a listed skill matches the task, run `npx @tanstack/intent@latest load #` before changing files. -- Use the loaded `SKILL.md` guidance while making the change. -- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. -- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. - -``` +`install --map` writes the static guidance block without the interactive setup, and unlike the default it does not need a terminal, so it suits CI. The resulting block lists each skill with the command your agent runs to load it. It is a snapshot: it does not update when dependencies change, so re-run the command to refresh it. -## Mapping output +Supported files are `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, and `.github/copilot-instructions.md`. In a terminal, Intent asks which file to use or lets you name another project file. It includes only packages permitted by `intent.skills` and skips reference, meta, and maintainer skills. `--global` and `--global-only` add global packages. On a project with no policy yet, a terminal run also helps you pick which skills to trust and writes `intent.skills` and `intent.lock`. -`--map` writes compact skill identities and commands: +The block stores portable identities and commands, never local file paths: ```yaml @@ -69,26 +53,21 @@ tanstackIntent: ``` -- `id`: portable skill identity in `#` format -- `run`: package-manager-aware command agents should run before editing -- `for`: task-routing phrase for agents -- The block does not store `load` paths, absolute paths, or package-manager-internal paths +- `id`: portable skill identity in `#` form. +- `run`: the package-manager-aware command to load the skill. +- `for`: a task-routing phrase for the agent. -## Status messages +Intent verifies the block after writing and reports the result, such as `Created AGENTS.md with 1 mapping.` or `No changes to AGENTS.md; 2 mappings already current.` If it finds no usable skills it prints `No intent-enabled skills found.` and writes nothing. -- Created: `Created AGENTS.md with 1 mapping.` -- Updated: `Updated AGENTS.md with 2 mappings.` -- Unchanged: `No changes to AGENTS.md; 2 mappings already current.` -- Guidance created: `Created AGENTS.md with skill loading guidance.` -- Guidance unchanged: `No changes to AGENTS.md; skill loading guidance already current.` -- Placement tip: `Tip: Keep the intent-skills block near the top of AGENTS.md so agents read it before task-specific instructions.` -- No actionable skills in `--map` mode: `No intent-enabled skills found.` +## When install stops -To suppress trust and migration notices in automation, pass `--no-notices`. +- **No terminal.** The interactive setup needs a TTY. Without one, install stops and points you to `--map`. +- **Symlinks not possible.** Archive-backed and Yarn Plug'n'Play sources cannot be symlinked. Install stops and tells you to choose hook delivery or the static guidance block instead. +- **Target conflict.** If a delivery target already contains a conflicting file, install stops and lists the paths so you can move them. ## Related -- [intent list](./intent-list) -- [intent load](./intent-load) -- [intent hooks](./intent-hooks) -- [Quick Start for Consumers](../getting-started/quick-start-consumers) +- [Consumer quick start](../getting-started/quick-start-consumers) +- [Trust model](../concepts/trust-model) +- [`intent list`](./intent-list) +- [`intent hooks`](./intent-hooks) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index c1fe1e9a..e8a0bca1 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -3,42 +3,39 @@ title: intent list id: intent-list --- -`intent list` discovers skill-enabled packages and prints available skills. +`intent list` shows the skills your project can use. It scans installed dependencies and workspace packages, applies your `intent.skills` policy, and prints the packages and skills that reach your agent. -```bash -npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--no-notices] -``` + +npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--why] [--no-notices] + ## Options -- `--json`: print JSON instead of text output -- `--debug`: print discovery debug details to stderr -- `--global`: include global packages after project packages -- `--global-only`: list global packages only -- `--show-hidden`: show unlisted hidden skill sources when run outside an agent session -- `--no-notices`: suppress non-critical notices on stderr - -## What you get - -- Scans project and workspace dependencies for intent-enabled packages and skills -- Surfaces packages permitted by `package.json#intent.skills` (see [Allowlist](#allowlist)) -- Includes global packages only when `--global` or `--global-only` is passed -- Includes warnings from discovery -- Excludes packages and skills matched by package.json `intent.exclude` -- Prints debug details to stderr when `--debug` is passed -- If no packages are discovered, prints `No intent-enabled packages found.` -- Summary line with package count and skill count -- Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS` -- Skill tree grouped by package -- Optional warnings section (`⚠ ...` per warning) -- Optional notices section on stderr (`ℹ ...` per notice), suppressed by `--no-notices` - -`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`: print the machine-readable list instead of the text tables. +- `--global`: include global packages after the project packages. +- `--global-only`: list global packages only. +- `--show-hidden`: also list sources that `intent.skills` does not permit, so you can decide what to enable. Has no effect in an agent session. +- `--why`: explain why each skill is shown or hidden. Has no effect in an agent session. +- `--debug`: print discovery details to stderr. +- `--no-notices`: suppress non-critical notices on stderr for this run. + +## What it shows + +`list` prints a summary line, a package table with `PACKAGE`, `SOURCE`, `VERSION`, and `SKILLS` columns, and a skill tree grouped by package. `SOURCE` shows whether a package came from local discovery or global scanning; when the same package is found both locally and globally, the local one is used. If nothing is found, `list` prints `No intent-enabled packages found.` + +Warnings print under `Warnings:` (each prefixed `⚠`), and notices print under `Notices:` on stderr (each prefixed `ℹ`). Suppress notices for one run with `--no-notices`, or set `INTENT_NO_NOTICES=1` for CI and wrapper scripts. + +`list` scans the project's `node_modules`, or Yarn's Plug'n'Play API when there is no usable `node_modules`. It reads package files only and never runs package code; see the [trust model](../concepts/trust-model). + +## Which packages appear + +`list` shows only packages permitted by `package.json#intent.skills`, then removes anything matched by `intent.exclude`. A missing or empty `intent.skills` permits nothing, so `list` shows no packages until you add a source; the exact `"*"` entry shows every discovered package. See [Configuration](../concepts/configuration) for the entry grammar and special forms. + +A package that ships skills but is not permitted is hidden. Outside an agent session, `list` names hidden sources; `--show-hidden` lists them and `--why` explains each decision. In an agent session, hidden sources are reported by count only, so run `intent list --show-hidden` outside the session to review candidates. An entry that matches no discovered package is reported too. ## JSON output -`--json` prints an adapter-friendly skill list: +`--json` prints a stable shape for tools and agents: ```json { @@ -50,9 +47,7 @@ When both local and global packages are scanned, local packages take precedence. "packageVersion": "5.0.0", "packageSource": "local", "skillName": "fetching", - "description": "Query data fetching patterns", - "type": "skill (optional)", - "framework": "react (optional)" + "description": "Query data fetching patterns" } ], "packages": [ @@ -75,71 +70,22 @@ When both local and global packages are scanned, local packages take precedence. "conflicts": [ { "packageName": "string", - "chosen": { - "version": "string", - "packageRoot": "string" - }, - "variants": [ - { - "version": "string", - "packageRoot": "string" - } - ] + "chosen": { "version": "string", "packageRoot": "string" }, + "variants": [{ "version": "string", "packageRoot": "string" }] } ] } ``` -When the same package exists both locally and globally and global scanning is enabled, `intent list` prefers the local package. -When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without usable `node_modules`, `intent list` uses Yarn's PnP API. - -## Allowlist - -`package.json#intent.skills` is the allowlist that decides which discovered packages are surfaced. Only listed packages contribute skills. - -```json -{ - "intent": { - "skills": ["@tanstack/query", "workspace:@scope/internal"] - } -} -``` +Each skill also carries `type` and `framework` when the skill sets them. In an agent session, package paths are blanked and `conflicts` is empty. -Each entry is one source: - -- `@scope/pkg` or `pkg`: an npm package reachable through the dependency tree. -- `workspace:@scope/pkg`: a package in the current workspace. -- `@scope/*` or `workspace:@scope/*`: every discovered package of that kind whose name matches the pattern. -- `git:/#`: reserved, and not yet supported. - -The list as a whole has three special forms: - -- **Absent** (no `intent.skills` key): every discovered package is surfaced, with a deprecation notice printed to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist. -- **Empty** (`"skills": []`): no package is surfaced, with an info notice printed to stderr. -- **Wildcard** (`"skills": ["*"]`): every discovered package is surfaced, with an acknowledged-risk notice printed to stderr. This exact trust-all entry is distinct from a scoped package pattern such as `@tanstack/*`. - -A package that ships skills but is not listed or matched by a pattern is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. An exact entry or pattern that matches no discovered package is reported as well. Package patterns support `*` wildcards. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). - -## Excludes - -Package excludes are hard filters for packages that should not be used in a repo, applied after the allowlist. -Intent reads `intent.exclude` arrays from package.json files while walking from the workspace or project root to the current working directory. -Manage persistent excludes with `intent exclude add|remove|list`. - -```json -{ - "intent": { - "exclude": ["@tanstack/*devtools*", "@tanstack/router#experimental-*"] - } -} -``` - -A pattern without `#` excludes a whole package. A pattern with `#` excludes a single skill (`@scope/pkg#search-params`), and the skill segment may itself be a glob (`@scope/pkg#experimental-*`). A pattern may cross package boundaries at skill granularity (`*#experimental-*`). The `#*` shortcut (`@scope/pkg#*`) excludes the whole package. Only exact names and `*` wildcards are supported on each segment. Bare package-name patterns keep working unchanged. +## Common errors -An excluded package never triggers the unlisted-source warning, because an exclude is an explicit decision rather than an oversight. +- Scanner failures print as errors. +- Unsupported environments, such as Deno projects without `node_modules`. `list` needs a resolvable `node_modules` or a Yarn Plug'n'Play setup. -## Common errors +## Related -- Scanner failures are printed as errors -- Unsupported environments: - - Deno projects without `node_modules` +- [Configuration](../concepts/configuration) - the `intent.skills` and `intent.exclude` grammar. +- [Trust model](../concepts/trust-model) - why discovery does not grant trust. +- [`intent load`](./intent-load) - print a matching skill's `SKILL.md`. diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 28043d29..0b324112 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -3,43 +3,36 @@ title: intent load id: intent-load --- -`intent load` loads a compact skill identity from the current install and prints the matching `SKILL.md` content. +`intent load` prints the `SKILL.md` for a skill in one of your trusted packages, matched to the version installed in your project. Your coding agent runs it to pull a skill's guidance into context, and you can run it yourself to read one. -```bash -npx @tanstack/intent@latest load # [--path] [--json] [--debug] [--global] [--global-only] -``` + +@tanstack/intent@latest load # [--path] [--json] [--debug] [--global] [--global-only] + + +The package may be scoped or unscoped, and the skill may include slash-separated sub-skill names. An unambiguous short skill name works when only one package-prefixed skill matches. + + +@tanstack/intent@latest load @tanstack/query#fetching +@tanstack/intent@latest load @tanstack/query#core/fetching +@tanstack/intent@latest load some-lib#core --path + ## 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 -- `--global`: load from project packages first, then global packages -- `--global-only`: load from global packages only - -## What you get - -- Validates `#` before scanning -- Scans project-local packages by default -- Includes global packages only when `--global` or `--global-only` is passed -- Refuses before scanning when the target package is not permitted by `package.json#intent.skills` -- Refuses before scanning when the target package or skill matches `intent.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. - -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 -``` +- `--path`: print the resolved file path instead of the content. Cannot be combined with `--json`. +- `--json`: print the content plus metadata as JSON. Cannot be combined with `--path`. +- `--debug`: print resolution details to stderr. +- `--global`: load from project packages first, then global packages. +- `--global-only`: load from global packages only. + +## What it checks + +Before printing anything, `load` confirms the skill is one you are allowed to use: + +- The package must be permitted by `package.json#intent.skills`, and must not be removed by `intent.exclude`. +- If `intent.lock` exists, the skill must be recorded in it and its content must still match the accepted hash. `load` refuses a skill that was never accepted or whose content changed since you accepted it. Run `intent install` to review and accept a new baseline. + +Without a lockfile, `load` applies the source policy only and does not check content. It reads project packages by default; `--global` and `--global-only` add or switch to global packages, and a local package wins when the same one exists in both. ## JSON output @@ -60,19 +53,16 @@ npx @tanstack/intent@latest load some-lib#core --path ## Common errors -- Missing separator: `Invalid skill use "@tanstack/query": expected #.` -- Empty package: `Invalid skill use "#core": package is required.` -- 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?` -- Unlisted package: `Cannot load skill use "...": package "..." is not listed in intent.skills.` -- Excluded package: `Cannot load skill use "...": package "..." is excluded by Intent configuration.` -- Excluded skill: `Cannot load skill use "...": skill "..." is excluded by Intent configuration.` +- **Malformed use.** `Invalid skill use "@tanstack/query": expected #.`, or a similar message for an empty package or skill. +- **Not found.** `Cannot resolve skill use "...": package "..." was not found.`, or the same for a missing skill, sometimes with a `Did you mean ...?` suggestion. +- **Not trusted.** `Cannot load skill use "...": package "..." is not listed in intent.skills.` +- **Excluded.** `Cannot load skill use "...": package "..." is excluded by Intent configuration.`, or the same for a skill. +- **Not accepted.** `Cannot load skill use "...": skill is not accepted in intent.lock.` +- **Content changed.** `Cannot load skill use "...": installed content does not match intent.lock.` ## Related -- [intent list](./intent-list) -- [intent install](./intent-install) -- [Trust model](../concepts/trust-model) -- [Configuration](../concepts/configuration) +- [`intent list`](./intent-list) - find loadable skills. +- [`intent install`](./intent-install) - accept skills into the lockfile. +- [Trust model](../concepts/trust-model) - how the policy and lockfile gate loading. +- [Configuration](../concepts/configuration) - the `intent.skills` and `intent.exclude` grammar. diff --git a/docs/cli/intent-sync.md b/docs/cli/intent-sync.md new file mode 100644 index 00000000..a8715d56 --- /dev/null +++ b/docs/cli/intent-sync.md @@ -0,0 +1,48 @@ +--- +title: intent sync +id: intent-sync +--- + +`intent sync` updates the managed symlinks for symlink delivery so your agent sees the skills you have accepted. Run it after installing dependencies or pulling changes. With symlink delivery, `install` also adds a `prepare` script so `sync` runs after each `npm install`. + + +@tanstack/intent@latest sync [--dry-run] [--json] + + +## Options + +- `--dry-run`: report what sync would change without touching any links. +- `--json`: print the result as JSON. + +## Prerequisites + +`sync` works only with symlink delivery, and it needs the state that `intent install` writes: + +- `.intent/delivery.json` set to symlink delivery. Without it, sync prints a note to run `intent install` and stops without changing anything. +- `package.json` policy and `intent.lock`. Without them, sync stops and tells you to run `intent install` first. + +For hook delivery, run `intent install` instead; `sync` does not manage hooks. + +## What it does + +`sync` reconciles the symlinks in your agent directories against `intent.lock`: it adds links for accepted skills and removes links that no longer belong. It reports anything that needs your attention rather than accepting it silently: + +- **New dependencies** that ship skills you have not trusted. +- **New skills** in a package you already trust. +- **Changed skill content** that no longer matches `intent.lock`. + +New and changed skills are held for review. In a terminal, sync offers to enable or exclude new dependencies; otherwise it only reports them. To accept changed content as a new baseline, run `intent install`. When it runs from the `prepare` script, sync never prompts. + +## When sync stops + +- **No delivery configured.** sync prints a note to run `intent install` and exits without changes. +- **Missing policy or lockfile.** sync stops and points you to `intent install`. +- **Hook delivery.** sync manages symlinks only; use `intent install` to repair hooks. +- **Symlinks not possible.** Archive-backed and Yarn Plug'n'Play sources cannot be symlinked; sync stops and tells you to use hook delivery. +- **Link conflict.** If a managed target already holds an unmanaged file, sync stops and lists the paths. +- **Malformed install state.** sync stops and explains how to restore or reset the Intent-managed links and state. + +## Related + +- [`intent install`](./intent-install) - configure delivery and accept a new baseline. +- [Trust model](../concepts/trust-model) - how the lockfile gates content. diff --git a/docs/cli/intent-validate.md b/docs/cli/intent-validate.md index b54b4d2a..0a96833c 100644 --- a/docs/cli/intent-validate.md +++ b/docs/cli/intent-validate.md @@ -3,7 +3,7 @@ title: intent validate id: intent-validate --- -`intent validate` checks `SKILL.md` files and artifacts for structural problems. +`intent validate` checks `SKILL.md` files, artifacts, and the rendered session catalogue. ```bash npx @tanstack/intent@latest validate [] [--github-summary] [--fix] [--check] @@ -17,12 +17,12 @@ npx @tanstack/intent@latest validate [] [--github-summary] [--fix] [--check ## Options - `--github-summary`: write a GitHub Actions step summary when `GITHUB_STEP_SUMMARY` is set -- `--check`: fail if any `SKILL.md` has fixable frontmatter migrations pending, without writing files +- `--check`: fail on catalogue warnings or fixable frontmatter migrations, without writing files - `--fix`: rewrite fixable `SKILL.md` frontmatter migrations, then validate the result ## Frontmatter migration fixes -Use `--check` in CI to detect mechanical frontmatter migrations that have not been applied: +Use `--check` in CI to detect catalogue warnings and mechanical frontmatter migrations that have not been applied: ```bash npx @tanstack/intent@latest validate --check @@ -49,17 +49,20 @@ npx @tanstack/intent@latest validate --fix ## Validation checks -For each discovered `SKILL.md`: +For each discovered `SKILL.md`, validation checks the [Agent Skills specification](https://agentskills.io/specification) fields and Intent-specific constraints: - Frontmatter delimiter and structure are valid -- YAML frontmatter parses successfully -- Required fields exist: `name`, `description` +- YAML frontmatter parses as a mapping +- Required fields `name` and `description` are non-empty strings - `name` is a single leaf segment matching the skill's parent directory (no slashes); the namespace is carried by the directory path - `name` uses only lowercase letters, numbers, and hyphens - `name` is at most 64 characters - Only spec top-level keys are allowed (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`); Intent-specific scalars (`type`, `library`, `library_version`, `framework`) must live under `metadata` - `metadata`, when present, is a mapping of string values - `description` length is at most 1024 characters +- `license`, when present, is a non-empty string +- `compatibility`, when present, is a non-empty string of at most 500 characters +- `allowed-tools`, when present, is a non-empty space-separated string - `type: framework` requires `requires` to be an array - Total file length is at most 500 lines @@ -69,6 +72,23 @@ If `/_artifacts` exists, it also validates artifacts: - Required files must be non-empty - `.yaml` artifacts must parse successfully +## Catalogue warnings + +Validation builds minimal skill summaries from the parsed frontmatter and passes them through the same catalogue builder and formatter used for agent sessions. It reports these warnings per skill: + +- Description truncation beyond 180 characters, including the number of characters lost +- Description blanking when it contains a local filesystem path +- Unknown `metadata.type` values and whether the skill remains in the catalogue +- Known types excluded from the catalogue, because agents will not see those skills +- Duplicate descriptions after catalogue normalization and truncation +- Malformed `#` uses + +For each package, validation also reports the full rendered byte count against the 8000-byte budget and lists skills omitted by the 50-skill or byte limit. + +Catalogue findings are warnings by default. `--check` promotes them to validation errors. + +Agent Skills field warnings and packaging warnings remain informational in `--check` mode. + ## Packaging warnings Packaging warnings are always computed from `package.json` in the current working directory: diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 8b9d27ad..8efa8d1f 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -18,7 +18,7 @@ Intent merges these keys from every `package.json` between the current working d ## `intent.skills` -`intent.skills` is the allowlist. Only packages it permits contribute skills to `list`, `load`, `install`, and `stale`. See [Trust model](./trust-model) for the reasoning. +`intent.skills` is the allowlist. Only packages it permits contribute skills to commands like `list` and `load`. Interactive `install` is where you set this list. See [Trust model](./trust-model) for the reasoning. ### Source entries @@ -31,21 +31,21 @@ Each array entry names one source: | `@scope/*` or `workspace:@scope/*` | npm or workspace | Every discovered package of that kind whose name matches the pattern. | | `git:/#` | git | Reserved. Not yet supported, and rejected until a future version adds it. | -A malformed entry fails the whole command, and every bad entry is reported at once. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`. Intent matches allowlist entries against discovered package names. This matching will tighten in a future version. +A malformed entry fails the whole command, and every bad entry is reported at once. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`. Intent matches allowlist entries against discovered package names. ### Special forms The list as a whole has three special forms: -- **Absent.** No `intent.skills` key. Every discovered package is surfaced, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist. -- **Empty.** `"skills": []`. No package is surfaced. Intent prints an info notice to stderr. +- **Absent or empty.** No `intent.skills` key, or `"skills": []`. Intent permits no sources, so nothing is surfaced until you list at least one entry. It prints a notice to stderr saying no sources are permitted. +- **Explicit entries.** Intent surfaces only the packages that match a listed entry. - **Wildcard.** `"skills": ["*"]`. Every discovered package is surfaced. Unlike a package pattern such as `@tanstack/*`, this exact entry crosses package scopes and source kinds. Intent prints an acknowledged-risk notice to stderr, since unvetted skills may reach your agent. A package that ships skills but is not listed is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. A listed package that was not discovered is reported as well. ### Existing projects -A project that has not set `intent.skills` keeps working. Intent surfaces every discovered package and prints the deprecation notice described under the absent form. Nothing breaks. Add an allowlist when you are ready, before a future version requires one. Run `intent list` to confirm which packages are surfaced. +A project that has not set `intent.skills` surfaces no skills until you list at least one source. Configuration written for earlier versions still parses, but Intent no longer treats a missing `intent.skills` as permission to surface every package, so you have to opt in. Run `intent install` to choose which packages to trust, or add entries to `intent.skills` by hand, then run `intent list` to confirm what is surfaced. ### Suppressing notices temporarily diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 65ea0b4b..f26fbb4c 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -3,26 +3,45 @@ title: Trust model id: trust-model --- -Intent surfaces skills from your dependencies into your coding agent's guidance. A skill is instructions an agent follows, so the set of packages allowed to contribute skills is a trust decision. Intent makes that decision explicit through the `intent.skills` allowlist. +A skill is instructions your coding agent follows, so letting a dependency contribute one carries the same weight as running its code. Intent keeps that decision explicit and in your hands: a skill reaches your agent only when you have trusted its package and accepted its content. -## Explicit sources +## Two gates: sources and content + +Trust has two parts, and a skill has to pass both: + +- **Sources**: which packages may contribute skills at all, set by `intent.skills` in `package.json`. +- **Content**: which exact skill files you have accepted, recorded in `intent.lock`. + +The source policy decides who is allowed in. The lockfile pins what you actually agreed to, so a later change cannot slip through unnoticed. + +## Which packages you trust A package ships skills in a `skills/` directory. Discovery finds every installed package that has one, including transitive dependencies. Discovery does not grant trust. -`package.json#intent.skills` is the gate. A discovered package contributes skills only when an exact entry or `*` pattern in the allowlist matches it. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it. +`intent.skills` is the gate, and it is required. With no `intent.skills` key, or an empty list, Intent permits no sources and surfaces nothing until you list at least one. An explicit entry matches packages by name; the exact `"*"` entry allows every discovered package, which is why Intent warns when you use it. See the [special forms](./configuration#special-forms) in Configuration for each case. + +Trust does not propagate. A package you trust may depend on another package that ships skills, but that dependency stays untrusted unless a separate entry matches it. A package that ships skills but is not listed is dropped, and Intent names it so you can opt in or leave it out. -The gate is opt-in today. A project with no `intent.skills` key still surfaces every discovered package, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. A future version will require an explicit allowlist. See the [special forms](./configuration#special-forms) in Configuration. +## Which content you accepted -Trust does not propagate. A listed package may depend on another package that ships skills, but that dependency stays unlisted unless another entry matches it. Exact entries allow one source; patterns such as `@tanstack/*` explicitly allow every matching source. +`intent.lock` records every accepted skill as a path and a content hash. When a lockfile is present, `load` enforces it: it refuses a skill that is not in the lock, and refuses one whose installed content no longer matches the recorded hash. -## Static discovery +This is what stops a dependency update from quietly changing what your agent reads. When an update adds a skill or changes an accepted one, `sync` reports it for review instead of accepting it, and you take the new content by running `install` again. Without a lockfile, Intent falls back to the source policy alone and does not check content. + +## Discovery never runs package code Intent reads package data as files. It never imports, requires, or executes the code of a discovered package to find or load a skill. Adding a package to your dependency tree cannot run that package's code through Intent. -One exception is sanctioned: in Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. It loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript. An ESLint rule enforces this invariant in the discovery code. +One exception is sanctioned: in Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. It loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript, and an ESLint rule enforces that invariant in the discovery code. + +## Delivery affects the guarantee + +The lockfile check runs when Intent runs. How skills reach your agent between those runs depends on the delivery method you chose at install. + +Symlink delivery links the live package folders into your agent's directories. A package update can change linked content before Intent re-checks `intent.lock`; Intent detects the drift the next time it runs, but it cannot stop an agent from reading changed content in the meantime. Hook delivery surfaces only skills already accepted in the lockfile, so changes are held for review before they can reach the agent. Choose hooks when you want every change reviewed first. -## What the allowlist does not cover yet +## Current limits -Matching is currently by package name. A `workspace:foo` entry and a bare `foo` entry both authorize a discovered package named `foo`, because the scanner does not yet distinguish a workspace member from a published package of the same name. This errs toward permitting a same-named package, never toward denying one you listed. A future version tightens matching once the scanner carries that signal. +Matching is currently by package name. A `workspace:foo` entry and a bare `foo` entry both authorize a discovered package named `foo`, because the scanner does not yet distinguish a workspace member from a published package of the same name. This errs toward permitting a same-named package, never toward denying one you listed. -The `git:` source kind is reserved. Intent parses and validates the shape, then rejects it until a future version can pin the resolved ref and content hash. A git entry never loads silently. +The `git:` source kind is reserved. Intent validates the shape but rejects it for now, so a git entry never loads silently. diff --git a/docs/config.json b/docs/config.json index 11e09090..efbee84b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -17,6 +17,10 @@ "label": "Quick Start (Consumers)", "to": "getting-started/quick-start-consumers" }, + { + "label": "Troubleshooting", + "to": "getting-started/troubleshooting" + }, { "label": "Quick Start (Maintainers)", "to": "getting-started/quick-start-maintainers" @@ -47,6 +51,10 @@ "label": "intent install", "to": "cli/intent-install" }, + { + "label": "intent sync", + "to": "cli/intent-sync" + }, { "label": "intent hooks", "to": "cli/intent-hooks" diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 374742a8..3f0041fe 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -3,123 +3,103 @@ title: Quick Start for Consumers id: quick-start-consumers --- -Get started using Intent to help your agent discover and load package skills. +When a library you depend on ships Agent Skills, Intent puts that guidance in front of your coding agent. A skill tells your agent what to do, so you choose which dependencies to trust and how their skills reach your agent. -## 1. Run install +## Before you start -The install command guides your agent through the setup process: +You need a project with a `package.json` and at least one installed dependency that ships skills. To check what dependencies in your project offer skills before you set anything up, Intent can scan your `node_modules` and report the candidates: -```bash -npx @tanstack/intent@latest install -``` + -Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`. +@tanstack/intent@latest list --show-hidden -This creates or updates an `intent-skills` guidance block. It: + -1. Checks for existing `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) -2. Writes lightweight instructions for skill discovery and loading -3. Preserves content outside the managed block -4. Verifies the managed block before reporting success +Until you trust a package, its skills stay hidden, so `--show-hidden` is what reveals the candidates. Without it, a fresh project reports no packages even when a dependency ships skills. -If an `intent-skills` block already exists, Intent updates that file in place. -If no block exists, `AGENTS.md` is the default target. +## How to run Intent -Intent creates guidance like: +Every command in this guide works with `npx @tanstack/intent@latest` and no install. That is fine for a quick start or a one-off, but `@latest` fetches whatever version is current, so a new release can change how a command behaves. -```markdown - -## Skill Loading +For the most stable experience, add Intent as a dev dependency: -Before editing files for a substantial task: -- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills. -- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load #` before changing files. -- Use the loaded `SKILL.md` guidance while making the change. -- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. -- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. - -``` + -Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`. +@tanstack/intent -To enforce loading guidance before edits in supported agents, opt in to hooks: + -```bash -npx @tanstack/intent@latest hooks install -``` +Your lockfile then records the exact version, so everyone on your team runs the same Intent and upgrades happen when you choose. With Intent installed and symlink delivery, `install` also adds a `prepare` script that runs `intent sync` after each `npm install`, so your managed links stay current without anyone remembering to run it. -Project-scoped hooks are installed for Claude Code and Codex. `intent install` can write project guidance to `.github/copilot-instructions.md`, but GitHub Copilot CLI hook enforcement is user-scoped, so configure it explicitly: +## Install skills -```bash -npx @tanstack/intent@latest hooks install --scope user --agents copilot -``` +`install` runs an interactive setup, so run it in a terminal. For CI or a non-interactive shell, use [a portable snapshot](#portable-snapshots) instead. -Cursor and generic `AGENTS.md` agents use the guidance block only. + -Hooks add the available Intent skill catalog to supported agent sessions and keep the edit gate active until the agent loads matching full guidance. To tailor what appears in the session catalog, configure `intent.skills` and `intent.exclude` in `package.json`. +@tanstack/intent@latest install -## 2. Choose which packages' skills to use + -`package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced. +Intent asks: -```json -{ - "intent": { - "skills": ["@tanstack/*"] - } -} -``` +- **How to deliver skills.** You can symlink the skill folders into your agent's directories, install lifecycle hooks that list available skills at the start of a session, or write a static snapshot of skill mappings into an agent file such as `AGENTS.md`. +- **Where to put them.** Intent pre-selects the agent tools it can detect, such as GitHub Copilot, Cursor, Claude Code, Codex, VS Code, or a shared `.agents` directory. Symlinks support all of these; hooks at this time only support GitHub Copilot, Claude Code, and Codex (if you'd like to add hooks for other agents or platforms, we welcome contributions). You can also choose a custom folder for symlinks or hooks. +- **Which skills to trust.** Enable every skill it found, everything under a certain package name (eg. `@tanstack/*`), or pick individual skills. Only the packages you enable here can provide skills to your agent. +- **A final confirmation** before it writes anything. -List the packages or `*` package patterns you trust. Intent then surfaces skills from matching packages and leaves the rest out. See the [source entries](../concepts/configuration#source-entries) in Configuration for the forms an entry can take, and [Trust model](../concepts/trust-model) for why the allowlist exists. +> [!WARNING] +> Using symlinks can expose live package content before Intent can re-checks it. This means a skill can be updated in a dependency without Intent reviewing it first. If you want to review new or changed skills before they reach your agent, choose hook delivery instead. -## 3. Use skills in your workflow +Once finished, Intent prints a line describing how many skills were installed, e.g., `Installed 5 skills using symlink.` -When your agent works on a task that matches an available skill, it loads the matching `SKILL.md` into context. +## What install writes -Load a skill manually: +Intent records your choices in three files: -```bash -npx @tanstack/intent@latest load @tanstack/react-query#core -``` +- `package.json` holds `intent.skills`, the list of sources you trust, as well as the `intent.exclude` patterns that remove skills or packages you do not want. +- `intent.lock` holds contains the accepted skill contents, so teams can share the same baseline. It also records the package versions that shipped those skills, so Intent can detect when a dependency update changes its skills, or the contents of a skill you already accepted have changed. +- `.intent/delivery.json` holds your local delivery method and targets. -This prints the skill content for the installed package version. +> [!NOTE] +> If you chose symlinks, Intent adds the managed links to `.git/info/exclude` so they do not get committed. -If you want explicit task-to-skill mappings in your agent config, opt in: +Commit `package.json` and `intent.lock` if you're looking for the project to share the same trusted sources and accepted skills. `.intent/` stays local to your checkout. -```bash -npx @tanstack/intent@latest install --map -``` +## Check that it worked -## 4. Keep skills up-to-date +List the skills your project now trusts: -Skills version with library releases. When you update a library: + -```bash -npm update @tanstack/react-query -``` +@tanstack/intent@latest list -The new version brings updated skills automatically. The skills are shipped with the library, so you get the version that matches your installed code. If a package is installed both locally and globally and global scanning is enabled, Intent prefers the local version. + -If you need to see what skills have changed, run: +Intent prints a summary such as `5 intent-enabled packages, 12 skills`, then the packages you trusted and their skills. Load one to read its guidance: -```bash -npx @tanstack/intent@latest list -``` + -Use `--json` for machine-readable output: +@tanstack/intent@latest load @tanstack/query#fetching -```bash -npx @tanstack/intent@latest list --json -``` + -Global package scanning is opt-in: +Replace `@tanstack/query#fetching` with a package and skill from your own list. `load` prints the `SKILL.md` shipped with the version installed in your project, and your agent can run the same command when it needs that guidance. -```bash -npx @tanstack/intent@latest list --global -``` +If a command does not behave as described, see [Troubleshooting](./troubleshooting). -You can also check if any skills reference outdated source documentation: +## Keep skills current -```bash -npx @tanstack/intent@latest stale -``` +Updating a dependency can add, remove, or change its skills. With symlink delivery, run [`intent sync`](../cli/intent-sync) to update the links; it flags new or changed skills for review before they reach your agent. Run `install` again when you are ready to accept a new baseline. See the [trust model](../concepts/trust-model) for how that review works. + +## Portable snapshots + +`install --map` writes a static list of skill mappings into an agent file such as `AGENTS.md` instead of setting up managed delivery: + + + +@tanstack/intent@latest install --map + + + +The snapshot does not update when dependencies change, so re-run the command to refresh it. Hooks and symlinks keep skills current automatically, so they are the more reliable choice for everyday use; reach for `--map` when you want committed guidance or cannot use managed delivery. An MCP server is planned for a future release. diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md new file mode 100644 index 00000000..e5ff50bc --- /dev/null +++ b/docs/getting-started/troubleshooting.md @@ -0,0 +1,30 @@ +--- +title: Troubleshooting +id: troubleshooting +--- + +Fixes for common problems when you consume skills with Intent. New to Intent? Start with the [consumer quick start](./quick-start-consumers). + +## `list` reports no packages + +Before you install, nothing is trusted, so `intent list` reports no packages even when a dependency ships skills. Run `intent list --show-hidden` to see the candidates, then enable the ones you want during `install`. + +## A skill you expected is missing + +Add `--why` to see how Intent classified each source: + + + +@tanstack/intent@latest list --show-hidden --why + + + +A skill can be missing because you did not enable its package during install, or because an `intent.exclude` pattern removed it. + +## Install exits without prompting + +Interactive install needs a terminal. In CI or a non-interactive shell, use [a portable snapshot](./quick-start-consumers#portable-snapshots) instead. + +## Symlinks are not available + +Some setups, such as Yarn Plug'n'Play, cannot expose package skills as real folders. Choose another method of delivery instead when `install` asks how to deliver skills. diff --git a/docs/overview.md b/docs/overview.md index 237fffeb..9886ee8c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,88 +1,22 @@ ---- -title: Overview -id: overview ---- - -`@tanstack/intent` is a CLI for shipping and consuming Agent Skills as package artifacts. - -Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases and ships them inside npm packages. It discovers skills from your project and workspace dependencies, then helps agents load them when working on matching tasks. - -## What Intent does - -Intent provides tooling for two workflows: - -**For consumers:** - -- Discover skills from your project and workspace dependencies -- Control which packages' skills are surfaced with an allowlist -- Add lightweight skill loading guidance to your agent config -- Add hook enforcement for agents that support blocking lifecycle hooks -- Keep skills synchronized with library versions - -**For maintainers (library teams):** - -- Scaffold skills through AI-assisted domain discovery -- Validate SKILL.md format and packaging -- Ship skills in the same release pipeline as code -- Track staleness when source docs change - -## How it works - -### Discovery and installation - -Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: - -| Tool | Pattern | -| ---- | -------------------------------------------- | -| npm | `npx @tanstack/intent@latest ` | -| pnpm | `pnpm dlx @tanstack/intent@latest ` | -| Yarn | `yarn dlx @tanstack/intent@latest ` | -| Bun | `bunx @tanstack/intent@latest ` | - -```bash -npx @tanstack/intent@latest list -``` - -Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`. You can narrow which packages are surfaced with `package.json#intent.skills`. See the [Trust model](./concepts/trust-model) and [Configuration](./concepts/configuration) for how the allowlist works. -Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages. -When both local and global packages are scanned, local packages take precedence. - -```bash -npx @tanstack/intent@latest install -``` - -Creates or updates lightweight `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing guidance is updated in place; otherwise `AGENTS.md` is the default target. Pass `--map` to opt in to explicit task-to-skill mappings. - -```bash -npx @tanstack/intent@latest hooks install -``` - -Installs hook enforcement for supported agents. Project-scoped hooks are available for Claude Code and Codex. GitHub Copilot CLI project guidance can live in `.github/copilot-instructions.md`, while blocking hooks are user-scoped. Cursor and generic `AGENTS.md` agents use guidance only. - -```bash -npx @tanstack/intent@latest load @tanstack/query#fetching -``` - -Loads the matching `SKILL.md` content for the installed package version. Pass `--path` when you need the resolved skill file path for debugging. - -### Scaffolding and validation - -```bash -npx @tanstack/intent@latest scaffold -``` - -Guides your agent through domain discovery, tree generation, and skill authoring with interactive maintainer interviews. - -```bash -npx @tanstack/intent@latest validate -``` - -Enforces SKILL.md format rules and packaging requirements before publish. - -### Staleness tracking - -```bash -npx @tanstack/intent@latest stale -``` - -Detects when skills reference outdated source documentation or library versions. +--- +title: Overview +id: overview +--- + +An Agent Skill is a set of instructions that helps a coding agent work with a library or handle a particular task. `@tanstack/intent` lets libraries include these skills in their packages, so each package version can carry matching guidance. + +Skills come from your dependencies, and a skill tells your agent what to do, so Intent only uses skills from the packages you choose to trust. You decide which packages may provide skills and how your agents receive them. + +## Use skills from a dependency + +If a dependency already includes skills, start with the [consumer quick start](./getting-started/quick-start-consumers). During installation, you approve the packages you trust to provide skills and choose how to deliver those skills to your agents. + +Intent records the sources you approved in `package.json`, the accepted skill contents in `intent.lock`, and your local delivery choice in `.intent/delivery.json`. A package that ships skills contributes nothing until you list it among the sources you trust. + +Symlink installs use `sync` to keep agent links current, and it flags new or changed skills for review before they reach your agent. Hooks are another delivery option, and a static guidance block can write the skills into a file such as `AGENTS.md`. Read the [trust model](./concepts/trust-model) for how packages and skill changes are approved, or [configuration](./concepts/configuration) for the available settings. + +## Publish skills with a library + +If you maintain a library, keep its skills in the package alongside the code they describe, so each release ships the guidance written for it. Start with the [maintainer quick start](./getting-started/quick-start-maintainers). + +Intent scaffolds skills with your agent, validates their format and packaging before you publish, and reports when a skill looks stale as the library changes. The [registry](./registry) explains how to make a published package discoverable. diff --git a/knip.json b/knip.json index f46e762c..27dbb6a9 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,13 @@ ] }, "packages/intent": { - "entry": ["src/index.ts", "src/cli.ts", "src/core.ts", "src/setup.ts"], + "entry": [ + "src/index.ts", + "src/cli.ts", + "src/core.ts", + "src/setup.ts", + "src/catalog.ts" + ], "ignoreDependencies": ["verdaccio"] } } diff --git a/packages/intent/package.json b/packages/intent/package.json index 53f94c7e..3a2af697 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -4,6 +4,9 @@ "description": "Ship compositional knowledge for AI coding agents alongside your npm packages", "license": "MIT", "type": "module", + "engines": { + "node": ">=20.12.0" + }, "repository": { "type": "git", "url": "https://github.com/TanStack/intent" @@ -16,6 +19,10 @@ "./core": { "import": "./dist/core.mjs", "types": "./dist/core.d.mts" + }, + "./catalog": { + "import": "./dist/catalog.mjs", + "types": "./dist/catalog.d.mts" } }, "bin": { @@ -26,6 +33,7 @@ "meta" ], "dependencies": { + "@clack/prompts": "1.7.0", "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", @@ -39,9 +47,10 @@ }, "scripts": { "prepack": "npm run build", - "build": "tsdown src/index.ts src/cli.ts src/setup.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", + "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/core.ts src/catalog.ts --format esm --dts", + "test:smoke": "pnpm run build && node dist/cli.mjs --help && node dist/cli.mjs load --help && node --input-type=module -e \"const api = await import('@tanstack/intent/catalog'); if (typeof api.getIntentCatalogContext !== 'function' || typeof api.runSessionCatalogueHook !== 'function') process.exit(1)\"", "test:lib": "vitest run --exclude 'tests/integration/**'", + "test:delivery": "vitest run tests/sync.test.ts", "test:integration": "vitest run tests/integration/", "test:types": "tsc --noEmit", "test:eslint": "eslint ." diff --git a/packages/intent/src/catalog-lock.ts b/packages/intent/src/catalog-lock.ts new file mode 100644 index 00000000..b8025735 --- /dev/null +++ b/packages/intent/src/catalog-lock.ts @@ -0,0 +1,136 @@ +import { join } from 'node:path' +import { createIntentFsCache } from './discovery/fs-cache.js' +import { + getProjectReadFs, + scanIntentPackageAtRoot, +} from './discovery/scanner.js' +import { buildCurrentLockfileSkill } from './core/lockfile/lockfile-state.js' +import { + classifyLockfileHash, + readIntentLockfile, +} from './core/lockfile/lockfile.js' +import { sourceIdentityKey } from './core/types.js' +import { formatSkillUse } from './skills/use.js' +import type { CatalogueVerificationEntry } from './session-catalog.js' +import type { IntentSkillList } from './core/index.js' +import type { ReadFs } from './shared/utils.js' + +export interface LockCheckedCatalogueDiscovery { + result: IntentSkillList + verification: Array | null +} + +function formatWithheldWarning(count: number, reason: string): string { + return `${count} ${count === 1 ? 'skill was' : 'skills were'} withheld because ${reason}.` +} + +export function applyCatalogueLock( + result: IntentSkillList, + workspaceRoot: string, + readFs: ReadFs = getProjectReadFs(workspaceRoot), +): LockCheckedCatalogueDiscovery { + const locked = readIntentLockfile(join(workspaceRoot, 'intent.lock')) + if (locked.status === 'missing') return { result, verification: null } + + const fsCache = createIntentFsCache() + fsCache.useFs(readFs) + const surfacedUses = new Set(result.skills.map((skill) => skill.use)) + const allowedUses = new Set() + const verification: Array = [] + let pendingCount = 0 + let changedCount = 0 + let unverifiableCount = 0 + + for (const summary of result.packages) { + const scanned = scanIntentPackageAtRoot(summary.packageRoot, { + fallbackName: summary.name, + fsCache, + projectRoot: workspaceRoot, + source: summary.source, + }).package + if (!scanned) continue + + const currentSourceKey = sourceIdentityKey({ + kind: scanned.kind, + id: scanned.name, + }) + const lockedSource = locked.lockfile.sources.find( + (source) => sourceIdentityKey(source) === currentSourceKey, + ) + const lockedSkills = new Map( + lockedSource?.skills.map((skill) => [skill.path, skill]) ?? [], + ) + for (const scannedSkill of scanned.skills) { + const use = formatSkillUse(scanned.name, scannedSkill.name) + if (!surfacedUses.has(use)) continue + + let skill: ReturnType + try { + skill = buildCurrentLockfileSkill( + scanned, + scannedSkill, + fsCache.getReadFs(), + ) + } catch { + unverifiableCount += 1 + continue + } + verification.push({ + packageRoot: summary.packageRoot, + skillPath: skill.path, + contentHash: skill.contentHash, + }) + const status = classifyLockfileHash( + skill.contentHash, + lockedSkills.get(skill.path)?.contentHash, + ) + if (status === 'accepted') { + allowedUses.add(use) + } else { + if (status === 'new') pendingCount += 1 + else changedCount += 1 + } + } + } + + const skills = result.skills.filter((skill) => allowedUses.has(skill.use)) + const skillCountByPackage = new Map() + for (const skill of skills) { + skillCountByPackage.set( + skill.packageRoot, + (skillCountByPackage.get(skill.packageRoot) ?? 0) + 1, + ) + } + const packages = result.packages.flatMap((pkg) => { + const skillCount = skillCountByPackage.get(pkg.packageRoot) ?? 0 + return skillCount > 0 ? [{ ...pkg, skillCount }] : [] + }) + const warnings = [...result.warnings] + for (const [count, reason] of [ + [pendingCount, 'no matching intent.lock entry exists'], + [changedCount, 'installed content does not match intent.lock'], + [unverifiableCount, 'installed content could not be verified'], + ] as const) { + if (count > 0) warnings.push(formatWithheldWarning(count, reason)) + } + + return { + result: { + ...result, + skills, + packages, + warnings, + ...(result.debug + ? { + debug: { + ...result.debug, + packageCount: packages.length, + skillCount: skills.length, + warningCount: warnings.length, + }, + } + : {}), + }, + verification: unverifiableCount > 0 ? null : verification, + } +} diff --git a/packages/intent/src/catalog.ts b/packages/intent/src/catalog.ts new file mode 100644 index 00000000..bee2511d --- /dev/null +++ b/packages/intent/src/catalog.ts @@ -0,0 +1,179 @@ +import { applyCatalogueLock } from './catalog-lock.js' +import { getProjectReadFs } from './discovery/scanner.js' +import { + formatSessionCatalogue, + getSessionCatalogue, + resolveCatalogueWorkspaceRoot, +} from './session-catalog.js' +import type { HookAgent } from './hooks/types.js' + +export type { HookAgent } from './hooks/types.js' + +export interface IntentCatalogContext { + cacheStatus: 'hit' | 'miss' | 'refresh' + context: string +} + +export async function getIntentCatalogContext({ + cwd, + refresh = false, +}: { + cwd: string + refresh?: boolean +}): Promise { + const workspaceRoot = resolveCatalogueWorkspaceRoot(cwd) + const readFs = getProjectReadFs(workspaceRoot) + const result = await getSessionCatalogue({ + root: workspaceRoot, + policyRoot: cwd, + readFs, + refresh, + discover: async () => { + const { listIntentSkills } = await import('./core/index.js') + const discovered = listIntentSkills({ + audience: 'agent', + cwd, + }) + return applyCatalogueLock(discovered, workspaceRoot, readFs) + }, + }) + const context = formatSessionCatalogue(result.catalogue) + + return { + cacheStatus: result.cacheStatus, + context, + } +} + +export async function runSessionCatalogueHook({ + agent, + event, +}: { + agent: HookAgent + event?: Record +}): Promise { + const resolvedEvent = event ?? (await readEventFromStdin()) + const eventName = getLifecycleEventName(agent, resolvedEvent) + if (!eventName) return + + let context: string + try { + const result = await getIntentCatalogContext({ + cwd: getEventCwd(resolvedEvent), + }) + context = result.context + } catch (error) { + logHookFailure(error) + context = + 'Intent skills are unavailable because the catalogue could not be built. Run `intent catalog` outside the agent session for details.' + } + + try { + process.stdout.write( + JSON.stringify(formatHookOutput(agent, eventName, context)), + ) + } catch (error) { + logHookFailure(error) + } +} + +function logHookFailure(error: unknown): void { + console.error( + `[intent catalog] hook failed open: ${error instanceof Error ? error.message : String(error)}`, + ) +} + +function readEventFromStdin(): Promise> { + if (process.stdin.readableEnded || process.stdin.isTTY === true) { + return Promise.resolve({}) + } + + return new Promise((resolve) => { + const chunks: Array = [] + let byteLength = 0 + let settled = false + const timeout = setTimeout(() => finish(true), 1_000) + + const onData = (chunk: Buffer | string): void => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + byteLength += buffer.byteLength + if (byteLength > 64 * 1024) { + finish(false) + return + } + chunks.push(buffer) + } + const onEnd = (): void => finish(true) + const onError = (): void => finish(false) + + function finish(parse: boolean): void { + if (settled) return + settled = true + clearTimeout(timeout) + process.stdin.off('data', onData) + process.stdin.off('end', onEnd) + process.stdin.off('error', onError) + process.stdin.pause() + + if (parse) { + try { + const value = JSON.parse( + Buffer.concat(chunks, byteLength).toString('utf8'), + ) as unknown + if (value && typeof value === 'object' && !Array.isArray(value)) { + resolve(value as Record) + return + } + } catch {} + } + resolve({}) + } + + process.stdin.on('data', onData) + process.stdin.on('end', onEnd) + process.stdin.on('error', onError) + process.stdin.resume() + }) +} + +function getLifecycleEventName( + agent: HookAgent, + event: Record, +): 'SessionStart' | 'SubagentStart' | null { + const explicit = event.hook_event_name ?? event.hookEventName + if (explicit === 'SessionStart' || explicit === 'sessionStart') { + return 'SessionStart' + } + if (explicit === 'SubagentStart' || explicit === 'subagentStart') { + return 'SubagentStart' + } + if (agent === 'copilot') { + if (typeof event.agentName === 'string') return 'SubagentStart' + if ( + event.source === 'startup' || + event.source === 'resume' || + event.source === 'new' + ) { + return 'SessionStart' + } + } + return null +} + +function getEventCwd(event: Record): string { + return typeof event.cwd === 'string' ? event.cwd : process.cwd() +} + +function formatHookOutput( + agent: HookAgent, + eventName: 'SessionStart' | 'SubagentStart', + additionalContext: string, +): Record { + if (agent === 'copilot') return { additionalContext } + return { + hookSpecificOutput: { + hookEventName: eventName, + additionalContext, + }, + } +} diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index d317b9d7..079879c7 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url' import { cac } from 'cac' import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' +import type { HookAgent } from './catalog.js' import type { ExcludeCommandOptions } from './commands/exclude.js' -import type { HooksInstallCommandOptions } from './commands/hooks/command.js' +import type { CatalogCommandOptions } from './commands/catalog.js' import type { InstallCommandOptions } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' +import type { SyncCommandOptions } from './commands/sync/command.js' import type { ValidateCommandOptions } from './commands/validate.js' function createCli(): CAC { @@ -23,7 +25,7 @@ function createCli(): CAC { 'Discover intent-enabled packages from the project or workspace', ) .usage( - 'list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--no-notices]', + 'list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--why] [--no-notices]', ) .option('--json', 'Output JSON') .option('--debug', 'Print discovery debug details to stderr') @@ -33,15 +35,32 @@ function createCli(): CAC { '--show-hidden', 'Show hidden skill sources not listed in intent.skills', ) + .option('--why', 'Explain why each shown skill is available or hidden') .option('--no-notices', 'Suppress non-critical notices on stderr') .example('list') .example('list --json') .example('list --global') + .example('list --why') .action(async (options: ListCommandOptions) => { const { runListCommand } = await import('./commands/list.js') await runListCommand(options) }) + cli + .command( + 'catalog', + 'Build compact cached skill context for agent lifecycle hooks', + ) + .usage('catalog [--json] [--refresh]') + .option('--json', 'Output JSON with context and cache metrics') + .option('--refresh', 'Ignore a valid cached catalogue') + .example('catalog') + .example('catalog --json') + .action(async (options: CatalogCommandOptions) => { + const { runCatalogCommand } = await import('./commands/catalog.js') + await runCatalogCommand(options) + }) + cli .command( 'exclude [action] [pattern]', @@ -118,27 +137,19 @@ function createCli(): CAC { ) cli - .command( - 'install', - 'Create or update skill loading guidance in an agent config file', - ) + .command('install', 'Configure trusted skill sources and delivery targets') .usage( - 'install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', + 'install [--map] [--dry-run] [--global] [--global-only] [--no-notices]', ) .option('--map', 'Write explicit skill-to-task mappings') - .option('--dry-run', 'Print the generated block without writing') - .option( - '--print-prompt', - 'Print the legacy agent setup prompt instead of writing', - ) - .option('--global', 'Include global packages after project packages') - .option('--global-only', 'Install mappings from global packages only') + .option('--dry-run', 'Preview installation without writing files') + .option('--global', 'With --map, include global packages') + .option('--global-only', 'With --map, use only global packages') .option('--no-notices', 'Suppress non-critical notices on stderr') .example('install') .example('install --map') .example('install --dry-run') - .example('install --print-prompt') - .example('install --global') + .example('install --map --global') .action(async (options: InstallCommandOptions) => { const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ import('./commands/support.js'), @@ -148,31 +159,39 @@ function createCli(): CAC { }) cli - .command( - 'hooks [action]', - 'Manage agent hooks that surface and enforce skill loading', - ) - .usage( - 'hooks install [--scope project|user] [--agents copilot,claude,codex|all]', - ) - .option('--scope ', 'Hook install scope: project or user') - .option('--agents ', 'Hook agents: copilot,claude,codex, or all') - .example('hooks install') - .example('hooks install --scope user --agents copilot') - .action( - async ( - action: string | undefined, - options: HooksInstallCommandOptions, - ) => { - if (action !== 'install') { - fail('Unknown hooks action: expected install.') + .command('sync', 'Synchronize verified skill links into configured targets') + .usage('sync [--dry-run] [--json]') + .option('--dry-run', 'Report changes without writing files') + .option('--json', 'Output JSON') + .example('sync') + .example('sync --dry-run') + .action(async (options: SyncCommandOptions) => { + const { runSyncCommand } = await import('./commands/sync/command.js') + await runSyncCommand(options) + }) + + cli + .command('hooks [action]', 'Run agent hooks that surface available skills') + .usage('hooks run --agent copilot|claude|codex') + .option('--agent ', 'Hook agent: copilot, claude, or codex') + .example('hooks run --agent copilot') + .action(async (action: string | undefined, options: { agent?: string }) => { + if (action === 'run') { + if (!options.agent) { + fail('Missing hook agent. Expected copilot, claude, or codex.') + } + if (!['copilot', 'claude', 'codex'].includes(options.agent)) { + fail( + `Unknown hook agent: ${options.agent}. Expected copilot, claude, or codex.`, + ) } + const { runSessionCatalogueHook } = await import('./catalog.js') + await runSessionCatalogueHook({ agent: options.agent as HookAgent }) + return + } - const { runHooksInstallCommand } = - await import('./commands/hooks/command.js') - runHooksInstallCommand(options) - }, - ) + fail('Unknown hooks action: expected run.') + }) cli .command('scaffold', 'Print maintainer scaffold prompt') diff --git a/packages/intent/src/commands/catalog.ts b/packages/intent/src/commands/catalog.ts new file mode 100644 index 00000000..ab349970 --- /dev/null +++ b/packages/intent/src/commands/catalog.ts @@ -0,0 +1,26 @@ +import { getIntentCatalogContext } from '../catalog.js' + +export interface CatalogCommandOptions { + json?: boolean + refresh?: boolean +} + +export async function runCatalogCommand( + options: CatalogCommandOptions = {}, +): Promise { + const result = await getIntentCatalogContext({ + cwd: process.cwd(), + refresh: options.refresh, + }) + if (!options.json) { + console.log(result.context) + return + } + + console.log( + JSON.stringify({ + cacheStatus: result.cacheStatus, + context: result.context, + }), + ) +} diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index 503d573b..d41d5b25 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -1,7 +1,13 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { fail } from '../shared/cli-error.js' import { compileExcludePatterns } from '../core/excludes.js' +import { writeTextFileAtomic } from '../shared/atomic-write.js' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from './install/config.js' +import type { IntentConsumerConfig } from './install/config.js' export interface ExcludeCommandOptions { json?: boolean @@ -20,65 +26,34 @@ function getPackageJsonPath(cwd: string): string { return join(cwd, 'package.json') } -function readPackageJson(cwd: string): Record { +function readPackageJsonText(cwd: string): string { const packageJsonPath = getPackageJsonPath(cwd) if (!existsSync(packageJsonPath)) { fail(`No package.json found in ${cwd}`) } + return readFileSync(packageJsonPath, 'utf8') +} +function readConfig(text: string): IntentConsumerConfig { try { - return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as Record< - string, - unknown - > + return readIntentConsumerConfig(text) } catch (err) { fail( - `Failed to parse ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`, + `Invalid package.json intent configuration: ${err instanceof Error ? err.message : String(err)}`, ) } } -function readConfiguredExcludes(pkg: Record): Array { - const intent = pkg.intent - if (intent === undefined) return [] - if (!intent || typeof intent !== 'object') { - fail('Invalid package.json: intent must be an object when present.') - } - - const raw = (intent as Record).exclude - if (raw === undefined) return [] - if (!Array.isArray(raw)) { - fail('Invalid package.json: intent.exclude must be an array of strings.') - } - - const excludes: Array = [] - for (const entry of raw) { - if (typeof entry !== 'string') { - fail('Invalid package.json: intent.exclude must contain only strings.') - } - const trimmed = entry.trim() - if (trimmed.length === 0) continue - excludes.push(trimmed) - } - return excludes -} - -function setConfiguredExcludes( - pkg: Record, +function writeExcludes( + cwd: string, + text: string, + config: IntentConsumerConfig, excludes: Array, ): void { - const intent = - pkg.intent && typeof pkg.intent === 'object' - ? (pkg.intent as Record) - : {} - - intent.exclude = excludes - pkg.intent = intent -} - -function writePackageJson(cwd: string, pkg: Record): void { - const packageJsonPath = getPackageJsonPath(cwd) - writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8') + writeTextFileAtomic( + getPackageJsonPath(cwd), + updateIntentConsumerConfigText(text, { ...config, exclude: excludes }), + ) } function normalizePattern( @@ -133,17 +108,19 @@ export function runExcludeCommand( ): void { const action = normalizeAction(actionArg) const cwd = process.cwd() - const pkg = readPackageJson(cwd) - const currentExcludes = readConfiguredExcludes(pkg) + const text = readPackageJsonText(cwd) + const config = readConfig(text) if (action === 'list') { if (patternArg) { fail('Unexpected pattern for list. Use: intent exclude list [--json]') } - printExcludes(currentExcludes, options.json) + printExcludes(config.exclude, options.json) return } + const currentExcludes = config.exclude + const pattern = normalizePattern(patternArg, action) validatePattern(pattern) @@ -158,8 +135,7 @@ export function runExcludeCommand( } const updated = [...currentExcludes, pattern] - setConfiguredExcludes(pkg, updated) - writePackageJson(cwd, pkg) + writeExcludes(cwd, text, config, updated) if (options.json) { printExcludes(updated, true) return @@ -180,8 +156,7 @@ export function runExcludeCommand( return } - setConfiguredExcludes(pkg, updated) - writePackageJson(cwd, pkg) + writeExcludes(cwd, text, config, updated) if (options.json) { printExcludes(updated, true) return diff --git a/packages/intent/src/commands/hooks/command.ts b/packages/intent/src/commands/hooks/command.ts deleted file mode 100644 index aca1eee4..00000000 --- a/packages/intent/src/commands/hooks/command.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - formatHookInstallResult, - runInstallHooks, - validateHookInstallOptions, -} from '../../hooks/install.js' - -export interface HooksInstallCommandOptions { - agents?: string - scope?: string -} - -export function runHooksInstallCommand( - options: HooksInstallCommandOptions, -): void { - validateHookInstallOptions(options) - - const results = runInstallHooks({ - agents: options.agents, - root: process.cwd(), - scope: options.scope, - }) - - for (const result of results) { - console.log(formatHookInstallResult(result)) - } -} diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 2721081b..285d9402 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,6 +1,14 @@ -import { relative } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { compileExcludePatterns } from '../../core/excludes.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { writeIntentLockfile } from '../../core/lockfile/lockfile.js' +import { resolveProjectContext } from '../../core/project-context.js' +import { applySourcePolicy } from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' import { fail } from '../../shared/cli-error.js' -import { detectIntentCommandPackageManager } from '../../shared/command-runner.js' +import { detectIntentAudience } from '../../shared/environment.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, @@ -8,123 +16,68 @@ import { printWarnings, } from '../support.js' import { - buildIntentSkillGuidanceBlock, + SUPPORTED_MAP_TARGETS, buildIntentSkillsBlock, - resolveIntentSkillsBlockTargetPath, - verifyIntentSkillsBlockFile, - writeIntentSkillsBlock, + findExistingIntentSkillsBlockTargetPath, + resolveMapTargetPath, + writeVerifiedIntentSkillsBlock, } from './guidance.js' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from './config.js' +import { buildSkillSelectionPlan } from './plan.js' import type { GlobalScanFlags } from '../support.js' +import type { InstallerPrompter } from './consumer.js' +import type { IntentLockfile } from '../../core/lockfile/lockfile.js' import type { IntentCoreOptions } from '../../core/index.js' import type { ScanResult } from '../../shared/types.js' -export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. - -Goal: create or update one agent config file with an intent-skills mapping block. - -Hard rules: -- Do not report success until a file was created or updated, or an existing mapping block was confirmed. -- If skills are discovered and no mapping block exists, create AGENTS.md unless the user asks for another supported config file. -- If a mapping block already exists in a supported config file, update that file. -- Preserve all content outside the managed block unchanged. -- Store compact \`id\` values and runnable \`run\` commands in the managed block; do not write local paths. -- Never write absolute local file paths, node_modules paths, or package-manager-internal paths in the managed block. -- Verify the target file before your final response. - -Follow these steps in order: - -1. CHECK FOR EXISTING MAPPINGS - Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings, keep that file as the source of truth, - and ask "What would you like to update?" Then skip to step 4 with their requested changes. - - If not found: continue to step 2. - -2. DISCOVER AVAILABLE SKILLS - Run: \`npx @tanstack/intent@latest list\` - This scans project-local node_modules by default and outputs each package and skill's name, - description, and source. - If the user explicitly wants globally installed skills included, run: - \`npx @tanstack/intent@latest list --global\` - This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop - with node_modules enabled). - If no skills are found, do not create a config file. Report: "No intent-enabled skills found." - -3. SCAN THE REPOSITORY - Build a picture of the project's structure and patterns: - - Read package.json for library dependencies - - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) - - Note recurring patterns (routing, data fetching, auth, UI components, etc.) - - Mapping coverage rule: - - Create mappings for all discovered actionable skills. - - Do not omit an actionable skill only because the repo does not currently appear to use it. - - Do not map reference, meta, maintainer, or maintainer-only skills by default. - - Include slash-named sub-skills when no parent mapping exists, or when they describe distinct user tasks. - - If the proposed block would exceed 12 mappings, show the full discovered list and ask which packages - or skill groups to include before writing. - - Add one fallback note telling the agent to run \`npx @tanstack/intent@latest list\` for less common local skills. - - Based on the repository scan and the coverage rule, propose the skill-to-task mappings. - For each one explain: - - The task or code area (in plain language the user would recognise) - - Which skill applies and why - - Then ask: "What other tasks do you commonly use AI coding agents for? - I'll create mappings for those too." - Also ask: "I'll default to AGENTS.md unless you want another supported config file. - Do you have a preference?" - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file. - - If you found an existing intent-skills block, update that file in place. - - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. - - Do not stop after discovery. If skills were found, the task is incomplete until this file exists - and contains the managed block. - - Use this exact block: - - -# TanStack Intent - before editing files, run the matching guidance command. -tanstackIntent: - - id: "@scope/package#skill-name" - run: "npx @tanstack/intent@latest load @scope/package#skill-name" - for: "describe the task or code area here" - - - Rules: - - Use the user's own words for \`for\` descriptions - - Use compact \`id\` values in \`#\` format - - Include a \`run\` command that loads the matching \`id\` - - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, - drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. - - Agents should run the \`run\` command before editing matching files - - Keep entries concise - this block is read on every agent task - - Preserve all content outside the block tags unchanged - - If the user is on Deno, note that this setup is best-effort today and relies on npm interop - -5. VERIFY AND REPORT - Before reporting completion: - - Confirm the target file exists - - Confirm it contains both managed block markers - - Confirm every mapping has \`id\`, \`run\`, and \`for\` - - Confirm every \`id\` parses as \`#\` - - Confirm every \`run\` command loads the matching \`id\` - - Confirm no path-like machine-specific values are stored in the managed block - - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice - - Final response must include: - - The target file path - - Whether it was created, updated, or already contained a valid block - - The number of mappings - - The verification result` +async function runInstallWithPrompts({ + dryRun, + prompts, + root, +}: { + dryRun?: boolean + prompts: InstallerPrompter + root: string +}): Promise { + const [{ runConsumerInstall }, { createIntentFsCache }, { scanForIntents }] = + await Promise.all([ + import('./consumer.js'), + import('../../discovery/fs-cache.js'), + import('../../discovery/scanner.js'), + ]) + const fsCache = createIntentFsCache() + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(root, scanOptions) + await runConsumerInstall({ + discovered: scan.packages, + dryRun, + packageManager: scan.packageManager, + prompts, + readFs: fsCache.getReadFs(), + root, + }) +} export interface InstallCommandOptions extends GlobalScanFlags { dryRun?: boolean map?: boolean - printPrompt?: boolean +} + +export async function runInteractiveInstall({ + cwd, + dryRun, + prompts, +}: { + cwd: string + dryRun?: boolean + prompts: InstallerPrompter +}): Promise { + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + await runInstallWithPrompts({ dryRun, prompts, root }) } function formatTargetPath(targetPath: string): string { @@ -198,76 +151,129 @@ export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, ): Promise { - if (options.printPrompt) { - console.log(INSTALL_PROMPT) - return - } - const coreOptions = coreOptionsFromGlobalFlags(options) const noticeOptions = noticeOptionsFromGlobalFlags(options) if (!options.map) { - const generated = buildIntentSkillGuidanceBlock( - detectIntentCommandPackageManager(), - ) - - if (options.dryRun) { - const targetPath = resolveIntentSkillsBlockTargetPath(process.cwd(), 1) - console.log( - `Generated skill loading guidance for ${formatTargetPath(targetPath!)}.`, + if (!process.stdin.isTTY || !process.stdout.isTTY) { + fail( + 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map`.', ) - console.log(generated.block) - return - } - - const result = writeIntentSkillsBlock({ - ...generated, - root: process.cwd(), - skipWhenEmpty: false, - }) - - if (!result.targetPath) { - fail('Install guidance target was not created.') } - - const verification = verifyIntentSkillsBlockFile({ - expectedBlock: generated.block, - targetPath: result.targetPath, + const { createClackInstallerPrompter } = await import('./prompts.js') + await runInteractiveInstall({ + cwd: process.cwd(), + dryRun: options.dryRun, + prompts: createClackInstallerPrompter(), }) + return + } - const target = formatTargetPath(result.targetPath) - if (!verification.ok) { - fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), - ) + let root = process.cwd() + let projectRoot = root + let scanResult: ScanResult | null = null + let bootstrapWrites: { + lockfile: IntentLockfile + packageJsonPath: string + updatedPackageJson: string + } | null = null + + if (process.stdin.isTTY && process.stdout.isTTY) { + const context = resolveProjectContext({ cwd: process.cwd() }) + projectRoot = context.workspaceRoot ?? context.packageRoot ?? context.cwd + const lockfilePath = join(projectRoot, 'intent.lock') + const packageJsonPath = join(projectRoot, 'package.json') + if (!existsSync(lockfilePath) && existsSync(packageJsonPath)) { + const packageJson = readFileSync(packageJsonPath, 'utf8') + const config = readIntentConsumerConfig(packageJson) + if ( + config.skills.length === 0 && + config.exclude.length === 0 && + !options.global && + !options.globalOnly + ) { + root = projectRoot + const [{ createIntentFsCache }, { scanForIntents }] = await Promise.all( + [ + import('../../discovery/fs-cache.js'), + import('../../discovery/scanner.js'), + ], + ) + const fsCache = createIntentFsCache() + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(root, scanOptions) + if (buildIntentSkillsBlock(scan).mappingCount === 0) { + printNoActionableSkills(scan.warnings, scan.notices, noticeOptions) + return + } + + const { selectClackSkills } = await import('./prompts.js') + const selection = await selectClackSkills(scan.packages) + if (!selection) return + + const plan = buildSkillSelectionPlan(scan.packages, selection) + const policy = applySourcePolicy( + { packages: scan.packages }, + { + config: parseSkillSources(plan.skills), + excludeMatchers: compileExcludePatterns(plan.exclude), + }, + ) + scanResult = { ...scan, packages: policy.packages } + bootstrapWrites = { + lockfile: { + lockfileVersion: 1, + sources: buildCurrentLockfileSources( + policy.packages, + fsCache.getReadFs(), + ), + }, + packageJsonPath, + updatedPackageJson: updateIntentConsumerConfigText(packageJson, { + skills: plan.skills, + exclude: plan.exclude, + }), + } + } } - - printWriteResult(result) - printPlacementTip(result.targetPath) - return } - const scanResult = await scanIntentsOrFail(coreOptions) + scanResult ??= await scanIntentsOrFail(coreOptions) const generated = buildIntentSkillsBlock(scanResult) - if (options.dryRun) { - const targetPath = resolveIntentSkillsBlockTargetPath( - process.cwd(), - generated.mappingCount, + if (generated.mappingCount === 0) { + printNoActionableSkills( + scanResult.warnings, + scanResult.notices, + noticeOptions, ) + return + } - if (!targetPath) { - printNoActionableSkills( - scanResult.warnings, - scanResult.notices, - noticeOptions, - ) - return + let existingTargetPath = + root !== projectRoot + ? findExistingIntentSkillsBlockTargetPath(projectRoot) + : null + if (existingTargetPath) { + root = projectRoot + } else { + existingTargetPath = findExistingIntentSkillsBlockTargetPath(root) + } + let targetPath: string + if (existingTargetPath) { + targetPath = resolveMapTargetPath(root, relative(root, existingTargetPath)) + } else { + let selectedTarget: string = SUPPORTED_MAP_TARGETS[0] + if (process.stdin.isTTY && process.stdout.isTTY) { + const { selectClackMapTarget } = await import('./prompts.js') + const selection = await selectClackMapTarget(root) + if (!selection) return + selectedTarget = selection } + targetPath = resolveMapTargetPath(root, selectedTarget) + } + if (options.dryRun) { console.log( `Generated ${formatMappingCount(generated.mappingCount)} for ${formatTargetPath(targetPath)}.`, ) @@ -277,33 +283,20 @@ export async function runInstallCommand( return } - const result = writeIntentSkillsBlock({ - ...generated, - root: process.cwd(), + const result = writeVerifiedIntentSkillsBlock({ + generated, + root, + targetPath, + formatTargetLabel: formatTargetPath, }) - if (!result.targetPath) { - printNoActionableSkills( - scanResult.warnings, - scanResult.notices, - noticeOptions, - ) - return - } - - const target = formatTargetPath(result.targetPath) - const verification = verifyIntentSkillsBlockFile({ - expectedBlock: generated.block, - expectedMappingCount: generated.mappingCount, - targetPath: result.targetPath, - }) + if (!result.targetPath) return - if (!verification.ok) { - fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), + if (bootstrapWrites) { + writeIntentLockfile(join(root, 'intent.lock'), bootstrapWrites.lockfile) + writeTextFileAtomic( + bootstrapWrites.packageJsonPath, + bootstrapWrites.updatedPackageJson, ) } @@ -311,5 +304,13 @@ export async function runInstallCommand( printPlacementTip(result.targetPath) printWarnings(scanResult.warnings) - printNotices(scanResult.notices, noticeOptions) + const snapshotNotices = + result.status !== 'unchanged' && + generated.mappingCount > 0 && + detectIntentAudience() === 'human' + ? [ + 'The intent-skills block is a snapshot and does not update when dependencies change. Re-run `intent install --map` to regenerate it.', + ] + : [] + printNotices([...snapshotNotices, ...scanResult.notices], noticeOptions) } diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts new file mode 100644 index 00000000..a6e52e3d --- /dev/null +++ b/packages/intent/src/commands/install/config.ts @@ -0,0 +1,159 @@ +import { applyEdits, modify, parse } from 'jsonc-parser' +import { compileExcludePatterns } from '../../core/excludes.js' +import { parseSkillSources } from '../../core/skill-sources.js' + +export interface IntentConsumerConfig { + skills: Array + exclude: Array +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function requireStringArray(value: unknown, label: string): Array { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== 'string') + ) { + throw new Error(`${label} must be an array of strings.`) + } + return value +} + +function validateExcludes(excludes: Array): void { + if (excludes.some((entry) => entry.trim() === '')) { + throw new Error('intent.exclude must not contain blank entries.') + } + compileExcludePatterns(excludes) +} + +function parsePackageJson(text: string): Record { + const errors: Array<{ error: number; offset: number; length: number }> = [] + const value = parse(text.replace(/^\ufeff/, ''), errors, { + allowTrailingComma: true, + disallowComments: false, + }) + if (errors.length > 0 || !isRecord(value)) { + throw new Error('Invalid package.json JSONC.') + } + return value +} + +export function hasIntentDevDependency(text: string): boolean { + const devDependencies = parsePackageJson(text).devDependencies + return ( + isRecord(devDependencies) && + typeof devDependencies['@tanstack/intent'] === 'string' + ) +} + +export function readIntentConsumerConfig(text: string): IntentConsumerConfig { + const packageJson = parsePackageJson(text) + const intent = packageJson.intent + if (intent === undefined) return { skills: [], exclude: [] } + if (!isRecord(intent)) throw new Error('intent must be an object.') + const skills = + intent.skills === undefined + ? [] + : requireStringArray(intent.skills, 'intent.skills') + const exclude = + intent.exclude === undefined + ? [] + : requireStringArray(intent.exclude, 'intent.exclude') + parseSkillSources(skills) + validateExcludes(exclude) + return { skills, exclude } +} + +function equalsConfig( + left: IntentConsumerConfig, + right: IntentConsumerConfig, +): boolean { + return ( + equalsArray(left.skills, right.skills) && + equalsArray(left.exclude, right.exclude) + ) +} + +function equalsArray( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => entry === right[index]) + ) +} + +function formattingOptions(text: string): { + eol: string + insertSpaces: boolean + tabSize: number +} { + const indentation = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ' + return { + eol: text.includes('\r\n') ? '\r\n' : '\n', + insertSpaces: !indentation.includes('\t'), + tabSize: indentation.includes('\t') ? 1 : indentation.length, + } +} + +function applyModification( + text: string, + path: Array, + value: unknown, + options: ReturnType, +): string { + return applyEdits( + text, + modify(text, path, value, { formattingOptions: options }), + ) +} + +export function updateIntentConsumerConfigText( + text: string, + requested: IntentConsumerConfig, +): string { + const existing = readIntentConsumerConfig(text) + const intent = parsePackageJson(text).intent + const hasLegacyInstall = isRecord(intent) && intent.install !== undefined + const normalized = { + skills: requireStringArray(requested.skills, 'intent.skills'), + exclude: requireStringArray(requested.exclude, 'intent.exclude'), + } + parseSkillSources(normalized.skills) + validateExcludes(normalized.exclude) + if (equalsConfig(existing, normalized) && !hasLegacyInstall) { + return text + } + + const bom = text.startsWith('\ufeff') ? '\ufeff' : '' + const options = formattingOptions(text) + let updated = bom === '' ? text : text.slice(1) + if (!equalsArray(existing.skills, normalized.skills)) { + updated = applyModification( + updated, + ['intent', 'skills'], + normalized.skills, + options, + ) + } + if (!equalsArray(existing.exclude, normalized.exclude)) { + updated = applyModification( + updated, + ['intent', 'exclude'], + normalized.exclude, + options, + ) + } + if (hasLegacyInstall) { + updated = applyModification( + updated, + ['intent', 'install'], + undefined, + options, + ) + } + return `${bom}${updated}` +} diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts new file mode 100644 index 00000000..07fed862 --- /dev/null +++ b/packages/intent/src/commands/install/consumer.ts @@ -0,0 +1,396 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { compileExcludePatterns } from '../../core/excludes.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../../core/lockfile/lockfile.js' +import { applySourcePolicy } from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { runInstallHooks } from '../../hooks/install.js' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { detectIntentAudience } from '../../shared/environment.js' +import { nodeReadFs } from '../../shared/utils.js' +import { runSyncCommand } from '../sync/command.js' +import { hasNonNativeLinkSource, reconcileManagedLinks } from '../sync/links.js' +import { buildSyncLinkPlan } from '../sync/plan.js' +import { wireIntentSyncPrepare } from '../sync/prepare.js' +import { readInstallStateForLinks } from '../sync/state.js' +import { toProjectRelativePath } from '../sync/targets.js' +import { + hasIntentDevDependency, + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from './config.js' +import { + INSTALL_TARGETS, + detectInstallTargets, + readIntentDeliveryConfig, + writeIntentDeliveryConfig, +} from './delivery.js' +import { + buildInstallDeltaInventory, + buildSkillSelectionPlan, + summarizeInstallDeltaInventory, +} from './plan.js' +import { + buildIntentSkillsBlockFromPackages, + findExistingIntentSkillsBlockTargetPath, + resolveMapTargetPath, + writeVerifiedIntentSkillsBlock, +} from './guidance.js' +import type { IntentConsumerConfig } from './config.js' +import type { + DeliveryMethod, + InstallMethod, + InstallTarget, + IntentDeliveryConfig, +} from './delivery.js' +import type { SkillSelection } from './plan.js' +import type { HookAgent } from '../../hooks/types.js' +import type { ReadFs } from '../../shared/utils.js' +import type { IntentPackage, ScanResult } from '../../shared/types.js' + +export type InstallConfirmation = 'install' | 'back' | null + +export interface InstallerPrompter { + complete: (message: string) => void + selectMethod: () => Promise + selectMapTarget: (root: string) => Promise + selectTargets: ( + method: DeliveryMethod, + detected: ReadonlyArray, + ) => Promise | null> + confirmSymlink: () => Promise + confirmUserScopeHooks: () => Promise + selectSkills: ( + discovered: ReadonlyArray, + ) => Promise + confirmInstall: (confirmation: { + config: IntentConsumerConfig + delivery: IntentDeliveryConfig + skillCount: number + }) => Promise +} + +export interface RunConsumerInstallOptions { + copilotHome?: string + discovered: ReadonlyArray + dryRun?: boolean + homeDir?: string + packageManager: ScanResult['packageManager'] + prompts: InstallerPrompter + readFs?: ReadFs + root: string +} + +function hookAgentForTarget(target: InstallTarget): HookAgent { + switch (target) { + case 'github': + return 'copilot' + case 'claude': + case 'codex': + return target + default: + throw new Error( + `Install method "hooks" is not supported for "${target}".`, + ) + } +} + +function countSkills(entries: ReadonlyArray<{ skillCount: number }>): number { + return entries.reduce((count, entry) => count + entry.skillCount, 0) +} + +function installConfiguredHooks( + root: string, + targets: ReadonlyArray, + homeDir: string | undefined, + copilotHome: string | undefined, +): Array { + const agents = targets.map(hookAgentForTarget).join(',') + return runInstallHooks({ agents, copilotHome, homeDir, root, scope: 'user' }) + .filter((result) => result.status !== 'skipped') + .map((result) => result.agent) +} + +export async function runConsumerInstall({ + copilotHome, + discovered, + dryRun = false, + homeDir, + packageManager, + prompts, + readFs = nodeReadFs, + root, +}: RunConsumerInstallOptions): Promise { + const packageJsonPath = join(root, 'package.json') + const packageJson = readFileSync(packageJsonPath, 'utf8') + const intentDevDependency = hasIntentDevDependency(packageJson) + const existingConfig = readIntentConsumerConfig(packageJson) + const existingLock = readIntentLockfile(join(root, 'intent.lock')) + const delivery = dryRun ? null : readIntentDeliveryConfig(root) + async function resolveSkillSelection(): Promise<{ + plan: ReturnType | null + config: IntentConsumerConfig + skillCount: number + } | null> { + const hasCommittedTrust = + existingLock.status === 'found' && + (existingConfig.skills.length > 0 || existingConfig.exclude.length > 0) + const reusingCommittedTrust = !delivery && hasCommittedTrust + const selection = reusingCommittedTrust + ? null + : await prompts.selectSkills(discovered) + if (!reusingCommittedTrust && !selection) return null + const plan = selection + ? buildSkillSelectionPlan(discovered, selection) + : null + const config = plan + ? { skills: plan.skills, exclude: plan.exclude } + : existingConfig + const skillCount = (plan?.packages ?? discovered).reduce( + (count, pkg) => + count + + pkg.skills.filter( + (skill) => !('status' in skill) || skill.status === 'enabled', + ).length, + 0, + ) + return { plan, config, skillCount } + } + if (delivery) { + const inventory = buildInstallDeltaInventory( + discovered, + buildCurrentLockfileSources(discovered, readFs), + existingLock, + existingConfig, + ) + const summary = summarizeInstallDeltaInventory(inventory) + const newDependencies = countSkills(summary.newDependencies) + const newSkills = countSkills(summary.newSkills) + const changed = countSkills(summary.changed) + const hasChanges = + newDependencies > 0 || newSkills > 0 || changed > 0 || summary.removed > 0 + if (!hasChanges && existingLock.status === 'found') { + if (delivery.method === 'symlink') { + prompts.complete('Project is up to date.') + return + } + const userScopeHooksAccepted = await prompts.confirmUserScopeHooks() + if (userScopeHooksAccepted === null) return + const installedAgents = userScopeHooksAccepted + ? installConfiguredHooks(root, delivery.targets, homeDir, copilotHome) + : [] + const repairedHooks = + installedAgents.length > 0 ? ' Repaired configured hooks.' : '' + const skippedHooks = userScopeHooksAccepted + ? '' + : ' Hooks were skipped because home-directory access was declined.' + prompts.complete(`Project is up to date.${repairedHooks}${skippedHooks}`) + return + } + console.log( + `Install changes: ${newDependencies} new ${newDependencies === 1 ? 'dependency' : 'dependencies'}, ${newSkills} new ${newSkills === 1 ? 'skill' : 'skills'}, ${changed} changed, ${summary.removed} removed.`, + ) + } + for (;;) { + const method = delivery ? delivery.method : await prompts.selectMethod() + if (!method) return + if (method === 'map') { + if (discovered.every((pkg) => pkg.skills.length === 0)) { + prompts.complete('No intent-enabled skills found.') + return + } + const resolved = await resolveSkillSelection() + if (!resolved) return + const { config, skillCount } = resolved + const policy = applySourcePolicy( + { packages: [...discovered] }, + { + config: parseSkillSources(config.skills), + excludeMatchers: compileExcludePatterns(config.exclude), + }, + ) + const generated = buildIntentSkillsBlockFromPackages( + policy.packages, + packageManager, + ) + if (generated.mappingCount === 0) { + prompts.complete('No intent-enabled skills found.') + return + } + + const existingTargetPath = findExistingIntentSkillsBlockTargetPath(root) + let targetPath: string + if (existingTargetPath) { + targetPath = existingTargetPath + } else { + const selectedTarget = await prompts.selectMapTarget(root) + if (!selectedTarget) return + targetPath = resolveMapTargetPath(root, selectedTarget) + } + const relativeTarget = toProjectRelativePath(root, targetPath) + + if (dryRun) { + console.log( + `Generated ${generated.mappingCount} ${generated.mappingCount === 1 ? 'mapping' : 'mappings'} for ${relativeTarget}.`, + ) + console.log(generated.block) + prompts.complete('Dry run complete.') + return + } + + const result = writeVerifiedIntentSkillsBlock({ + generated, + root, + targetPath, + formatTargetLabel: () => relativeTarget, + }) + if (!result.targetPath) return + + const updatedPackageJson = updateIntentConsumerConfigText( + packageJson, + config, + ) + if (updatedPackageJson !== packageJson) { + writeTextFileAtomic(packageJsonPath, updatedPackageJson) + } + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources(policy.packages, readFs), + }) + if (result.status !== 'unchanged' && detectIntentAudience() === 'human') { + console.log( + 'The intent-skills block is a snapshot and does not update when dependencies change. Re-run `intent install --map` to regenerate it.', + ) + } + prompts.complete( + `Installed ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} to ${relativeTarget} as a static guidance block.`, + ) + return + } + const targets = delivery + ? delivery.targets + : await prompts.selectTargets(method, detectInstallTargets(root)) + if (!targets || targets.length === 0) return + if (!delivery && method === 'symlink') { + const symlinkAccepted = await prompts.confirmSymlink() + if (!symlinkAccepted) return + } + if (discovered.every((pkg) => pkg.skills.length === 0)) { + prompts.complete('No intent-enabled skills found.') + return + } + const resolved = await resolveSkillSelection() + if (!resolved) return + const { plan, config, skillCount } = resolved + const deliveryConfig = { method, targets } + const installation = { + config, + delivery: deliveryConfig, + skillCount, + } + const confirmation = await prompts.confirmInstall(installation) + if (confirmation === null) return + if (confirmation === 'back') continue + + const updatedConsumerConfig = updateIntentConsumerConfigText( + packageJson, + installation.config, + ) + const updatedPackageJson = + method === 'symlink' && intentDevDependency + ? wireIntentSyncPrepare(updatedConsumerConfig) + : updatedConsumerConfig + const policy = applySourcePolicy( + { packages: [...discovered] }, + { + config: parseSkillSources(installation.config.skills), + excludeMatchers: compileExcludePatterns(installation.config.exclude), + }, + ) + const lockfile = { + lockfileVersion: 1 as const, + sources: buildCurrentLockfileSources(policy.packages, readFs), + } + if (method === 'symlink') { + const linkPlan = buildSyncLinkPlan({ + config: installation.config, + currentSources: lockfile.sources, + discovered, + lock: { status: 'found', lockfile }, + packages: policy.packages, + root, + targets, + }) + if (hasNonNativeLinkSource(linkPlan.expected, readFs)) { + throw new Error( + 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', + ) + } + const preflight = reconcileManagedLinks({ + root, + dryRun: true, + expected: linkPlan.expected, + stateResult: readInstallStateForLinks(root), + }) + if (preflight.conflicts.length > 0) { + throw new Error( + `Install target conflicts: ${preflight.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + } + + if (dryRun) { + const labels = new Map( + INSTALL_TARGETS.map((target) => [target.id, target.label]), + ) + const targetLabels = targets.map((target) => labels.get(target) ?? target) + console.log( + `Would install ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} to ${targetLabels.join(', ')} using ${method}.`, + ) + console.log( + `Would update package.json intent configuration:\n${JSON.stringify(installation.config, null, 2)}`, + ) + console.log( + `Would write intent.lock with ${lockfile.sources.length} ${lockfile.sources.length === 1 ? 'source' : 'sources'}.`, + ) + prompts.complete('Dry run complete.') + return + } + + const userScopeHooksAccepted = + method === 'hooks' ? await prompts.confirmUserScopeHooks() : false + if (userScopeHooksAccepted === null) return + + if (updatedPackageJson !== packageJson) { + writeTextFileAtomic(packageJsonPath, updatedPackageJson) + } + if (plan) { + writeIntentLockfile(join(root, 'intent.lock'), lockfile) + } + writeIntentDeliveryConfig(root, deliveryConfig) + if (method === 'symlink') { + await runSyncCommand({ cwd: root }, { review: 'reminder' }) + prompts.complete( + `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using ${method}.`, + ) + return + } + + const installedAgents = userScopeHooksAccepted + ? installConfiguredHooks(root, targets, homeDir, copilotHome) + : [] + const skippedHooks = userScopeHooksAccepted + ? '' + : ' Hooks were skipped because home-directory access was declined.' + prompts.complete( + `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using hooks. Installed hook agents: ${installedAgents.length > 0 ? installedAgents.join(', ') : 'none'}.${skippedHooks}`, + ) + return + } +} diff --git a/packages/intent/src/commands/install/delivery.ts b/packages/intent/src/commands/install/delivery.ts new file mode 100644 index 00000000..7dc491f1 --- /dev/null +++ b/packages/intent/src/commands/install/delivery.ts @@ -0,0 +1,164 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { writeIntentGitExclude } from '../sync/gitignore.js' + +export const DELIVERY_CONFIG_PATH = '.intent/delivery.json' + +export type DeliveryMethod = 'symlink' | 'hooks' +export type InstallMethod = DeliveryMethod | 'map' +export type InstallTarget = + | 'agents' + | 'github' + | 'vscode' + | 'cursor' + | 'codex' + | 'claude' + +export interface IntentDeliveryConfig { + method: DeliveryMethod + targets: Array +} + +export const INSTALL_TARGETS: ReadonlyArray<{ + id: InstallTarget + label: string +}> = [ + { id: 'agents', label: 'Shared .agents directory' }, + { id: 'github', label: 'GitHub Copilot' }, + { id: 'vscode', label: 'VS Code' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'codex', label: 'Codex' }, + { id: 'claude', label: 'Claude Code' }, +] + +const INSTALL_METHODS: Readonly< + Record> +> = { + symlink: new Set(INSTALL_TARGETS.map((target) => target.id)), + hooks: new Set(['github', 'codex', 'claude']), +} + +export function installTargetsForMethod( + method: DeliveryMethod, +): typeof INSTALL_TARGETS { + return INSTALL_TARGETS.filter((target) => + INSTALL_METHODS[method].has(target.id), + ) +} + +function isDirectory(root: string, path: string): boolean { + const target = join(root, path) + return existsSync(target) && statSync(target).isDirectory() +} + +export function detectInstallTargets(root: string): Array { + return INSTALL_TARGETS.flatMap((target) => { + switch (target.id) { + case 'agents': + return isDirectory(root, '.agents') || + existsSync(join(root, 'AGENTS.md')) + ? [target.id] + : [] + case 'github': + return existsSync(join(root, '.github/copilot-instructions.md')) + ? [target.id] + : [] + case 'vscode': + return isDirectory(root, '.vscode') ? [target.id] : [] + case 'cursor': + return isDirectory(root, '.cursor') || + existsSync(join(root, '.cursorrules')) + ? [target.id] + : [] + case 'codex': + return isDirectory(root, '.codex') ? [target.id] : [] + case 'claude': + return isDirectory(root, '.claude') || + existsSync(join(root, 'CLAUDE.md')) + ? [target.id] + : [] + } + }) +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function validateIntentDeliveryConfig(value: unknown): IntentDeliveryConfig { + if (!isRecord(value)) throw new Error('Local delivery must be an object.') + if (Object.keys(value).sort().join(',') !== 'method,targets') { + throw new Error('Local delivery must contain exactly method and targets.') + } + if (typeof value.method !== 'string' || !(value.method in INSTALL_METHODS)) { + throw new Error(`Unknown install method "${String(value.method)}".`) + } + if (!Array.isArray(value.targets) || value.targets.length === 0) { + throw new Error('Local delivery targets must be a non-empty array.') + } + const method = value.method as DeliveryMethod + const targets: Array = [] + const seen = new Set() + for (const target of value.targets) { + if ( + typeof target !== 'string' || + !INSTALL_TARGETS.some((candidate) => candidate.id === target) + ) { + throw new Error(`Unknown install target "${String(target)}".`) + } + if (seen.has(target)) { + throw new Error(`Duplicate install target "${target}".`) + } + if (!INSTALL_METHODS[method].has(target as InstallTarget)) { + throw new Error( + `Install method "${method}" is not supported for "${target}".`, + ) + } + seen.add(target) + targets.push(target as InstallTarget) + } + return { method, targets } +} + +function parseIntentDeliveryConfig(text: string): IntentDeliveryConfig { + try { + return validateIntentDeliveryConfig(JSON.parse(text)) + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid local delivery JSON: ${error.message}`) + } + throw error + } +} + +function serializeIntentDeliveryConfig(config: IntentDeliveryConfig): string { + return `${JSON.stringify(validateIntentDeliveryConfig(config), null, 2)}\n` +} + +export function readIntentDeliveryConfig( + root: string, +): IntentDeliveryConfig | null { + const path = join(root, DELIVERY_CONFIG_PATH) + if (!existsSync(path)) return null + try { + return parseIntentDeliveryConfig(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Invalid local delivery at ${DELIVERY_CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + } +} + +export function writeIntentDeliveryConfig( + root: string, + config: IntentDeliveryConfig, +): boolean { + const path = join(root, DELIVERY_CONFIG_PATH) + const content = serializeIntentDeliveryConfig(config) + if (existsSync(path) && readFileSync(path, 'utf8') === content) return false + writeIntentGitExclude(root, [DELIVERY_CONFIG_PATH]) + writeTextFileAtomic(path, content) + return true +} diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index 0a7e4f19..a3d3500c 100644 --- a/packages/intent/src/commands/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -1,22 +1,36 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path' import { parse as parseYaml } from 'yaml' +import { fail } from '../../shared/cli-error.js' import { formatIntentCommand } from '../../shared/command-runner.js' +import { isPathWithin } from '../../shared/utils.js' import { isGeneratedMappingSkill } from '../../skills/categories.js' import { formatSkillUse, parseSkillUse } from '../../skills/use.js' -import type { ScanResult, SkillEntry } from '../../shared/types.js' +import type { + IntentPackage, + ScanResult, + SkillEntry, +} from '../../shared/types.js' const INTENT_SKILLS_START = '' const INTENT_SKILLS_END = '' const LOCAL_PATH_VALUE_PATTERN = /(?:^|[\s"'])(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\/(?:Users|home|private|tmp|var\/folders)[\\/]|[^\s"']*(?:node_modules|\.pnpm|\.bun|\.yarn|\.intent)[\\/])/i -const SUPPORTED_AGENT_CONFIG_FILES = [ +export const SUPPORTED_MAP_TARGETS = [ 'AGENTS.md', 'CLAUDE.md', '.cursorrules', '.github/copilot-instructions.md', -] +] as const export interface IntentSkillsBlockResult { block: string @@ -26,6 +40,7 @@ export interface IntentSkillsBlockResult { export interface WriteIntentSkillsBlockOptions extends IntentSkillsBlockResult { root: string skipWhenEmpty?: boolean + targetPath?: string } interface WriteIntentSkillsBlockFileResult { @@ -131,7 +146,7 @@ function containsLocalPathValue(value: string): boolean { function parseLoadedSkillUse(command: string): string | null { const match = command.match( - /(?:^|&&|\|\||;|\|)\s*(?:bunx\s+@tanstack\/intent(?:@latest)?|pnpm\s+exec\s+intent|pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?|npx\s+@tanstack\/intent(?:@latest)?|yarn\s+dlx\s+@tanstack\/intent(?:@latest)?|intent)\s+load\s+([^\s|;&]+)/i, + /(?:^|&&|\|\||;|\|)\s*(?:bunx\s+@tanstack\/intent(?:@[^\s]+)?|pnpm\s+exec\s+intent|pnpm\s+dlx\s+@tanstack\/intent(?:@[^\s]+)?|npx\s+@tanstack\/intent(?:@[^\s]+)?|yarn\s+dlx\s+@tanstack\/intent(?:@[^\s]+)?|intent)\s+load\s+([^\s|;&]+)/i, ) return match?.[1] ?? null } @@ -261,15 +276,65 @@ export function verifyIntentSkillsBlockFile({ } } -export function resolveIntentSkillsBlockTargetPath( +export function findExistingIntentSkillsBlockTargetPath( root: string, - mappingCount: number, ): string | null { - if (mappingCount === 0) return null - return ( - findExistingConfigWithManagedBlock(root)?.filePath ?? - join(root, 'AGENTS.md') - ) + return findExistingConfigWithManagedBlock(root)?.filePath ?? null +} + +export function resolveMapTargetPath( + root: string, + projectRelativePath: string, +): string { + const value = projectRelativePath.trim() + if (value === '') throw new Error('Map target path is required.') + if (/[\\/]$/.test(value)) { + throw new Error('Map target must be a file, not a directory.') + } + if (isAbsolute(value) || win32.isAbsolute(value)) { + throw new Error('Map target must be relative to the project.') + } + + const segments = value.replace(/\\/g, '/').split('/') + if ( + segments.some( + (segment) => segment === '..' || segment.toLowerCase() === '.git', + ) + ) { + throw new Error('Map target cannot use `..` or `.git` path segments.') + } + + const normalizedSegments = posix.normalize(segments.join('/')).split('/') + const resolvedRoot = resolve(root) + const targetPath = resolve(resolvedRoot, ...normalizedSegments) + if (!isPathWithin(resolvedRoot, targetPath)) { + throw new Error('Map target must stay within the project.') + } + + const realRoot = realpathSync(resolvedRoot) + let existingPath = targetPath + for (;;) { + try { + lstatSync(existingPath) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const parent = dirname(existingPath) + if (parent === existingPath) { + throw new Error('Map target must have an existing project ancestor.') + } + existingPath = parent + } + + if (!isPathWithin(realRoot, realpathSync(existingPath))) { + throw new Error('Map target cannot resolve outside the project.') + } + if (existsSync(targetPath) && statSync(targetPath).isDirectory()) { + throw new Error('Map target must be a file, not a directory.') + } + + return targetPath } function compareNames(a: { name: string }, b: { name: string }): number { @@ -285,8 +350,9 @@ function formatWhen(packageName: string, skill: SkillEntry): string { return description || `Use ${packageName} ${skill.name}` } -export function buildIntentSkillsBlock( - scanResult: ScanResult, +export function buildIntentSkillsBlockFromPackages( + packages: ReadonlyArray, + packageManager: ScanResult['packageManager'], ): IntentSkillsBlockResult { const lines = [ INTENT_SKILLS_START, @@ -295,7 +361,7 @@ export function buildIntentSkillsBlock( ] let mappingCount = 0 - for (const pkg of [...scanResult.packages].sort(compareNames)) { + for (const pkg of [...packages].sort(compareNames)) { for (const skill of [...pkg.skills].sort(compareNames)) { if (!isGeneratedMappingSkill(skill)) continue @@ -306,7 +372,7 @@ export function buildIntentSkillsBlock( lines.push( ` run: ${quoteYamlString( formatIntentCommand( - scanResult.packageManager, + packageManager, `load ${formatSkillUse(pkg.name, skill.name)}`, ), )}`, @@ -326,6 +392,15 @@ export function buildIntentSkillsBlock( } } +export function buildIntentSkillsBlock( + scanResult: ScanResult, +): IntentSkillsBlockResult { + return buildIntentSkillsBlockFromPackages( + scanResult.packages, + scanResult.packageManager, + ) +} + export function buildIntentSkillGuidanceBlock( packageManager: ScanResult['packageManager'] = 'unknown', ): IntentSkillsBlockResult { @@ -365,23 +440,33 @@ function findExistingConfigWithManagedBlock(root: string): { filePath: string managedBlock: ManagedBlock } | null { - for (const file of SUPPORTED_AGENT_CONFIG_FILES) { + for (const file of SUPPORTED_MAP_TARGETS) { const filePath = join(root, file) - if (!existsSync(filePath)) continue - - const content = readFileSync(filePath, 'utf8') - const { managedBlock, errors, hasMarker } = readManagedBlock(content) - if (managedBlock) return { content, filePath, managedBlock } - if (hasMarker) { - throw new Error( - `Invalid intent-skills block in ${filePath}: ${errors.join(' ')}`, - ) - } + const existing = readConfigWithManagedBlock(filePath) + if (existing) return existing } return null } +function readConfigWithManagedBlock(filePath: string): { + content: string + filePath: string + managedBlock: ManagedBlock +} | null { + if (!existsSync(filePath)) return null + + const content = readFileSync(filePath, 'utf8') + const { managedBlock, errors, hasMarker } = readManagedBlock(content) + if (managedBlock) return { content, filePath, managedBlock } + if (hasMarker) { + throw new Error( + `Invalid intent-skills block in ${filePath}: ${errors.join(' ')}`, + ) + } + return null +} + function replaceManagedBlock( content: string, managedBlock: ManagedBlock, @@ -397,6 +482,7 @@ export function writeIntentSkillsBlock({ mappingCount, root, skipWhenEmpty = true, + targetPath: explicitTargetPath, }: WriteIntentSkillsBlockOptions): WriteIntentSkillsBlockResult { if (mappingCount === 0 && skipWhenEmpty) { return { @@ -406,8 +492,13 @@ export function writeIntentSkillsBlock({ } } - const existingTarget = findExistingConfigWithManagedBlock(root) - const targetPath = existingTarget?.filePath ?? join(root, 'AGENTS.md') + const existingTarget = explicitTargetPath + ? readConfigWithManagedBlock(explicitTargetPath) + : findExistingConfigWithManagedBlock(root) + const targetPath = + explicitTargetPath ?? + existingTarget?.filePath ?? + join(root, SUPPORTED_MAP_TARGETS[0]) if (existingTarget) { const nextContent = replaceManagedBlock( @@ -453,3 +544,32 @@ export function writeIntentSkillsBlock({ targetPath, } } + +export function writeVerifiedIntentSkillsBlock({ + generated, + root, + targetPath, + formatTargetLabel, +}: { + generated: IntentSkillsBlockResult + root: string + targetPath: string + formatTargetLabel: (resolvedTargetPath: string) => string +}): WriteIntentSkillsBlockResult { + const result = writeIntentSkillsBlock({ ...generated, root, targetPath }) + if (!result.targetPath) return result + const verification = verifyIntentSkillsBlockFile({ + expectedBlock: generated.block, + expectedMappingCount: generated.mappingCount, + targetPath: result.targetPath, + }) + if (!verification.ok) { + fail( + [ + `Install verification failed for ${formatTargetLabel(result.targetPath)}:`, + ...verification.errors.map((error) => `- ${error}`), + ].join('\n'), + ) + } + return result +} diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts new file mode 100644 index 00000000..75dc7444 --- /dev/null +++ b/packages/intent/src/commands/install/plan.ts @@ -0,0 +1,390 @@ +import { + compileExcludePatterns, + isPackageExcluded, + isSkillExcluded, +} from '../../core/excludes.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { compileSkillSourcePolicy } from '../../core/source-policy.js' +import { sourceIdentityKey } from '../../core/types.js' +import { classifyLockfileHash } from '../../core/lockfile/lockfile.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.js' +import type { ExcludeMatcher } from '../../core/excludes.js' +import type { SkillSourcesConfig } from '../../core/skill-sources.js' +import type { CompiledSkillSourcePolicy } from '../../core/source-policy.js' +import type { IntentConsumerConfig } from './config.js' +import type { IntentPackage, SkillEntry } from '../../shared/types.js' + +export type SkillSelection = + | { mode: 'all-found' } + | { mode: 'scope'; scope: string } + | { mode: 'individual'; enabled: Array } + +export interface SkillSelectionPlan { + skills: Array + exclude: Array + packages: Array<{ + name: string + kind: IntentPackage['kind'] + skills: Array<{ id: string; status: 'enabled' | 'excluded' }> + }> +} + +export type InventoryPolicyStatus = 'enabled' | 'excluded' | 'pending' +export type InventoryLockStatus = 'accepted' | 'new' | 'changed' | null + +export interface InstallDeltaInventory { + packages: Array<{ + name: string + kind: IntentPackage['kind'] + skills: Array<{ + id: string + policy: InventoryPolicyStatus + lock: InventoryLockStatus + }> + }> + removed: Array<{ + kind: IntentPackage['kind'] + id: string + path: string | null + }> +} + +export interface InstallDeltaSummary { + newDependencies: Array<{ name: string; skillCount: number }> + newSkills: Array<{ name: string; skillCount: number }> + changed: Array<{ name: string; skillCount: number }> + removed: number +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function sourceEntry(pkg: Pick): string { + return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name +} + +export function summarizeInstallDeltaInventory( + inventory: InstallDeltaInventory, +): InstallDeltaSummary { + return { + newDependencies: inventory.packages + .map((pkg) => ({ + name: sourceEntry(pkg), + skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') + .length, + })) + .filter((entry) => entry.skillCount > 0), + newSkills: inventory.packages + .map((pkg) => ({ + name: sourceEntry(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'new', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + changed: inventory.packages + .map((pkg) => ({ + name: sourceEntry(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'changed', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + removed: inventory.removed.length, + } +} + +export function skillSelectionId( + pkg: IntentPackage, + skill: SkillEntry, +): string { + return `${sourceEntry(pkg)}#${skill.name}` +} + +function classifySkillPolicy( + pkg: Pick, + skillName: string, + packageSkills: ReadonlyArray, + sources: SkillSourcesConfig, + sourcePolicy: CompiledSkillSourcePolicy, + excludes: Array, +): InventoryPolicyStatus { + if ( + isPackageExcluded(pkg.name, excludes) || + isSkillExcluded(pkg.name, skillName, excludes) || + sources.mode === 'empty' + ) { + return 'excluded' + } + return sourcePolicy.permitsSkill(pkg.name, skillName, pkg.kind, packageSkills) + ? 'enabled' + : 'pending' +} + +function sortedPackages( + packages: ReadonlyArray, +): Array { + return [...packages].sort((left, right) => { + const byName = compareStrings(left.name, right.name) + return byName === 0 ? compareStrings(left.kind, right.kind) : byName + }) +} + +function sortedSkills(pkg: IntentPackage): Array { + return [...pkg.skills].sort((left, right) => + compareStrings(left.name, right.name), + ) +} + +function assertUniqueDiscovery(packages: ReadonlyArray): void { + const sources = new Set() + for (const pkg of packages) { + const source = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) + if (sources.has(source)) { + throw new Error(`Duplicate discovered source "${sourceEntry(pkg)}".`) + } + sources.add(source) + const skills = new Set() + for (const skill of pkg.skills) { + if (skills.has(skill.name)) { + throw new Error( + `Duplicate discovered skill "${skillSelectionId(pkg, skill)}".`, + ) + } + skills.add(skill.name) + } + } +} + +function validateScope(scope: string): void { + if (!/^@[a-z0-9][a-z0-9._-]*\/\*$/.test(scope)) { + throw new Error( + 'Scope selection must be an npm scope pattern such as "@tanstack/*".', + ) + } +} + +function assertExclusionsRepresentable( + packages: ReadonlyArray, + grouped: ReadonlyArray<{ + skills: ReadonlyArray<{ status: 'enabled' | 'excluded' }> + }>, + exclude: ReadonlySet, +): void { + if (exclude.size === 0) return + const patterns = [...exclude].map((pattern) => ({ + pattern, + matchers: compileExcludePatterns([pattern.trim()]), + })) + + for (const [index, pkg] of packages.entries()) { + const packageSkills = sortedSkills(pkg) + for (const [skillIndex, entry] of grouped[index]!.skills.entries()) { + if (entry.status !== 'enabled') continue + const skillName = packageSkills[skillIndex]!.name + const offending = patterns.find( + ({ matchers }) => + isPackageExcluded(pkg.name, matchers) || + isSkillExcluded(pkg.name, skillName, matchers), + ) + if (offending) { + throw new Error( + `Cannot write intent.exclude "${offending.pattern}": it would also hide "${sourceEntry(pkg)}#${skillName}", which this selection enables.`, + ) + } + } + } +} + +export function buildSkillSelectionPlan( + discovered: ReadonlyArray, + selection: SkillSelection, +): SkillSelectionPlan { + const packages = sortedPackages(discovered) + assertUniqueDiscovery(packages) + const selected = new Set() + if (selection.mode === 'scope') validateScope(selection.scope) + if (selection.mode === 'individual') { + for (const id of selection.enabled) { + if (selected.has(id)) throw new Error(`Duplicate selected skill "${id}".`) + selected.add(id) + } + const discoveredIds = new Set( + packages.flatMap((pkg) => + sortedSkills(pkg).map((skill) => skillSelectionId(pkg, skill)), + ), + ) + for (const id of selected) { + if (!/^[^#\s]+#[^#\s]+$/.test(id) || !discoveredIds.has(id)) { + throw new Error(`Unknown selected skill "${id}".`) + } + } + } + + const skills = new Set() + const exclude = new Set() + const grouped = packages.map((pkg) => { + const packageSkills = sortedSkills(pkg) + const packageMatchesScope = + selection.mode === 'scope' && + pkg.kind === 'npm' && + new RegExp( + `^${selection.scope.slice(0, -1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ).test(pkg.name) + const packageEnabled = + selection.mode === 'all-found' || + packageMatchesScope || + (selection.mode === 'individual' && + packageSkills.some((skill) => + selected.has(skillSelectionId(pkg, skill)), + )) + if (selection.mode === 'scope') { + skills.add(selection.scope) + } else if (selection.mode === 'all-found') { + skills.add(sourceEntry(pkg)) + } + + const entries = packageSkills.map((skill) => { + const id = skillSelectionId(pkg, skill) + const enabled = selection.mode !== 'individual' || selected.has(id) + return { + id, + status: enabled ? ('enabled' as const) : ('excluded' as const), + } + }) + if (selection.mode === 'scope' && !packageMatchesScope) { + exclude.add(pkg.name) + return { + name: pkg.name, + kind: pkg.kind, + skills: entries.map((entry) => ({ + ...entry, + status: 'excluded' as const, + })), + } + } + if (selection.mode === 'individual' && !packageEnabled) { + exclude.add(pkg.name) + } else if (selection.mode === 'individual') { + const enabledEntries = entries.filter( + (entry) => entry.status === 'enabled', + ) + if (enabledEntries.length === entries.length) { + skills.add(sourceEntry(pkg)) + } else { + for (const entry of enabledEntries) skills.add(entry.id) + } + } + return { name: pkg.name, kind: pkg.kind, skills: entries } + }) + + assertExclusionsRepresentable(packages, grouped, exclude) + + return { + skills: [...skills].sort(compareStrings), + exclude: [...exclude].sort(compareStrings), + packages: grouped, + } +} + +function currentSkill( + skill: SkillEntry, + current: IntentLockfileSource | undefined, +): IntentLockfileSource['skills'][number] | undefined { + return current?.skills.find((entry) => entry.path === `skills/${skill.name}`) +} + +export function buildInstallDeltaInventory( + discovered: ReadonlyArray, + currentSources: ReadonlyArray, + lockResult: ReadIntentLockfileResult, + config: IntentConsumerConfig, +): InstallDeltaInventory { + assertUniqueDiscovery(discovered) + const sources = parseSkillSources(config.skills) + const sourcePolicy = compileSkillSourcePolicy(sources) + const excludes = compileExcludePatterns(config.exclude) + const currentByKey = new Map( + currentSources.map((source) => [sourceIdentityKey(source), source]), + ) + const lockedByKey = new Map( + lockResult.status === 'found' + ? lockResult.lockfile.sources.map((source) => [ + sourceIdentityKey(source), + source, + ]) + : [], + ) + const seen = new Set() + const packages = sortedPackages(discovered).map((pkg) => { + const key = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) + seen.add(key) + const current = currentByKey.get(key) + const locked = lockedByKey.get(key) + const packageSkills = sortedSkills(pkg) + return { + name: pkg.name, + kind: pkg.kind, + skills: packageSkills.map((skill) => { + const policy = classifySkillPolicy( + pkg, + skill.name, + packageSkills, + sources, + sourcePolicy, + excludes, + ) + if (policy !== 'enabled') + return { id: skillSelectionId(pkg, skill), policy, lock: null } + const currentEntry = currentSkill(skill, current) + const lockedEntry = currentEntry + ? locked?.skills.find((entry) => entry.path === currentEntry.path) + : undefined + const lock: InventoryLockStatus = classifyLockfileHash( + currentEntry?.contentHash, + lockedEntry?.contentHash, + ) + return { + id: skillSelectionId(pkg, skill), + policy, + lock, + } + }), + } + }) + const removed: Array<{ + kind: IntentPackage['kind'] + id: string + path: string | null + }> = [] + if (lockResult.status === 'found') { + for (const source of lockResult.lockfile.sources) { + const current = currentByKey.get(sourceIdentityKey(source)) + if (!seen.has(sourceIdentityKey(source)) || !current) { + removed.push({ kind: source.kind, id: source.id, path: null }) + continue + } + for (const skill of source.skills) { + if (!current.skills.some((entry) => entry.path === skill.path)) { + removed.push({ kind: source.kind, id: source.id, path: skill.path }) + } + } + } + } + return { + packages, + removed: removed.sort((left, right) => { + const bySource = compareStrings( + sourceIdentityKey(left), + sourceIdentityKey(right), + ) + return bySource === 0 + ? compareStrings(left.path ?? '', right.path ?? '') + : bySource + }), + } +} diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts new file mode 100644 index 00000000..d80858a0 --- /dev/null +++ b/packages/intent/src/commands/install/prompts.ts @@ -0,0 +1,209 @@ +import { + cancel, + confirm, + groupMultiselect, + intro, + isCancel, + multiselect, + note, + outro, + select, + text, +} from '@clack/prompts' +import { installTargetsForMethod } from './delivery.js' +import { SUPPORTED_MAP_TARGETS, resolveMapTargetPath } from './guidance.js' +import { skillSelectionId } from './plan.js' +import type { InstallConfirmation, InstallerPrompter } from './consumer.js' +import type { + DeliveryMethod, + InstallMethod, + InstallTarget, +} from './delivery.js' +import type { SkillSelection } from './plan.js' +import type { IntentPackage } from '../../shared/types.js' + +function cancelled(value: T | symbol): T | null { + if (!isCancel(value)) return value + cancel('Installation cancelled.') + return null +} + +function sourceLabel(pkg: IntentPackage): string { + return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name +} + +const OTHER_MAP_TARGET = 'other' + +export async function selectClackMapTarget( + root: string, +): Promise { + const selection = cancelled( + await select({ + message: 'Where should Intent write skill mappings?', + options: [ + ...SUPPORTED_MAP_TARGETS.map((target) => ({ + value: target, + label: target, + })), + { value: OTHER_MAP_TARGET, label: 'Other project file' }, + ], + }), + ) + if (!selection) return null + if (selection !== OTHER_MAP_TARGET) return selection + + const customTarget = cancelled( + await text({ + message: 'Project-relative file path', + validate(value) { + try { + resolveMapTargetPath(root, value ?? '') + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + return undefined + }, + }), + ) + return customTarget?.trim() || null +} + +export function groupSkillOptions( + discovered: ReadonlyArray, +): Record< + string, + Array<{ value: string; label: string; hint: string | undefined }> +> { + return Object.fromEntries( + discovered.map((pkg) => [ + sourceLabel(pkg), + pkg.skills.map((skill) => ({ + value: skillSelectionId(pkg, skill), + label: skill.name, + hint: skill.description || undefined, + })), + ]), + ) +} + +export async function selectClackSkills( + discovered: ReadonlyArray, + includeModes = true, +): Promise { + note( + `${discovered.reduce((count, pkg) => count + pkg.skills.length, 0)} skills from ${discovered.length} packages`, + 'Skills found', + ) + if (includeModes) { + const mode = cancelled( + await select({ + message: 'Which skills do you want to enable?', + options: [ + { value: 'all-found', label: 'Enable all skills found' }, + { value: 'scope', label: 'Enable all @tanstack/* skills' }, + { value: 'individual', label: 'Select skills' }, + ], + }), + ) + if (!mode) return null + if (mode === 'all-found') return { mode } + if (mode === 'scope') return { mode, scope: '@tanstack/*' } + } + const enabled = cancelled( + await groupMultiselect({ + message: 'Select skills to enable', + options: groupSkillOptions(discovered), + required: false, + selectableGroups: true, + groupSpacing: 1, + }), + ) + return enabled ? { mode: 'individual', enabled } : null +} + +export function createClackInstallerPrompter(): InstallerPrompter { + intro('Configure TanStack Intent') + return { + complete(message: string): void { + outro(message) + }, + async selectMethod(): Promise { + return cancelled( + await select({ + message: 'How do you want to install skills?', + options: [ + { value: 'symlink', label: 'Symlink skill folders' }, + { value: 'hooks', label: 'Install lifecycle hooks' }, + { value: 'map', label: 'Static file (guidance block)' }, + ], + }), + ) + }, + async selectMapTarget(root: string): Promise { + return selectClackMapTarget(root) + }, + async selectTargets( + method: DeliveryMethod, + detected: ReadonlyArray, + ): Promise | null> { + const targets = installTargetsForMethod(method) + const supported = new Set(targets.map((target) => target.id)) + return cancelled( + await multiselect({ + message: 'Where do you want to install skills?', + options: targets.map((target) => ({ + value: target.id, + label: target.label, + })), + initialValues: detected.filter((target) => supported.has(target)), + required: true, + }), + ) + }, + async confirmSymlink(): Promise { + note( + 'Package updates may change linked skills before Intent verifies intent.lock. Intent detects drift when it runs again, but it cannot prevent an agent from reading changed content before that check.', + 'Symlinks expose live package skill content', + ) + return cancelled( + await confirm({ + message: 'Continue with symlinks?', + initialValue: false, + vertical: true, + }), + ) + }, + async confirmUserScopeHooks(): Promise { + note( + 'Hooks are written to your home directory and affect sessions in this and other repositories.', + 'User-level hooks apply across repositories', + ) + return cancelled( + await confirm({ + message: 'Allow Intent to install user-level hooks?', + initialValue: false, + vertical: true, + }), + ) + }, + async selectSkills( + discovered: ReadonlyArray, + ): Promise { + return selectClackSkills(discovered) + }, + async confirmInstall({ + delivery, + skillCount, + }): Promise { + return cancelled( + await select>({ + message: `Install ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} using ${delivery.method}?`, + options: [ + { value: 'install', label: 'Install' }, + { value: 'back', label: 'Go back' }, + ], + }), + ) + }, + } +} diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0fc45c5e..c2298d52 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,5 +1,6 @@ import { detectIntentAudience } from '../shared/environment.js' import { formatIntentCommand } from '../shared/command-runner.js' +import { containsLocalPath } from '../shared/local-path.js' import { listIntentSkills } from '../core/index.js' import { coreOptionsFromGlobalFlags, @@ -10,6 +11,7 @@ import { } from './support.js' import type { GlobalScanFlags } from './support.js' import type { + IntentExcludedSkillSummary, IntentPackageSummary, IntentSkillList, IntentSkillSummary, @@ -19,6 +21,7 @@ import type { ScanResult } from '../shared/types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean showHidden?: boolean + why?: boolean } function printListDebug(result: IntentSkillList): void { @@ -57,6 +60,18 @@ function printVersionConflicts(result: IntentSkillList): void { } } +function visibleWarnings( + result: IntentSkillList, + audience: string, +): Array { + if (audience !== 'agent') return result.warnings + return result.warnings.filter((warning) => !containsLocalPath(warning)) +} + +function redactPackageRoot(entry: T): T { + return { ...entry, packageRoot: '' } +} + function groupSkillsByPackageRoot( skills: Array, ): Map> { @@ -81,15 +96,31 @@ function getPackageSkills( return skillsByPackageRoot.get(pkg.packageRoot) ?? [] } +function getExcludedPackageSummary( + skill: IntentExcludedSkillSummary, +): IntentPackageSummary { + return { + name: skill.packageName, + version: skill.packageVersion, + source: skill.packageSource, + packageRoot: skill.packageRoot, + skillCount: 0, + } +} + function formatLoadCommand( - skill: IntentSkillSummary, + skillUse: string, packageManager: ScanResult['packageManager'], scopeFlag: string, ): string { - return formatIntentCommand(packageManager, `load ${skill.use}${scopeFlag}`) + return formatIntentCommand(packageManager, `load ${skillUse}${scopeFlag}`) } -function printHiddenSources(result: IntentSkillList, audience: string): void { +function printHiddenSources( + result: IntentSkillList, + audience: string, + why: boolean, +): void { if (audience === 'agent') { console.log( 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', @@ -101,9 +132,15 @@ function printHiddenSources(result: IntentSkillList, audience: string): void { console.log('\nHidden skill sources:\n') for (const source of result.hiddenSources) { + const count = `${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'}` console.log( - ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})`, + source.hiddenSkills + ? ` ${source.name} (${count} not listed: ${source.hiddenSkills.join(', ')})` + : ` ${source.name} (${count})`, ) + if (why) { + console.log(' Hidden because not listed in intent.skills') + } } } @@ -111,11 +148,14 @@ export async function runListCommand( options: ListCommandOptions, ): Promise { const audience = detectIntentAudience() + const explain = audience === 'human' && options.why === true const result = listIntentSkills({ ...coreOptionsFromGlobalFlags(options), audience, + why: explain, }) const noticeOptions = noticeOptionsFromGlobalFlags(options) + const warnings = visibleWarnings(result, audience) printListDebug(result) if (options.json) { @@ -124,23 +164,39 @@ export async function runListCommand( packageManager: _packageManager, ...jsonResult } = result - console.log(JSON.stringify(jsonResult, null, 2)) + const outputResult = + audience === 'agent' + ? { + ...jsonResult, + skills: jsonResult.skills.map(redactPackageRoot), + excludedSkills: jsonResult.excludedSkills?.map(redactPackageRoot), + packages: jsonResult.packages.map(redactPackageRoot), + warnings, + conflicts: [], + } + : jsonResult + console.log(JSON.stringify(outputResult, null, 2)) return } const { computeSkillNameWidth, printSkillTree, printTable } = await import('../shared/display.js') - if (result.packages.length === 0) { + if ( + result.packages.length === 0 && + (result.excludedSkills?.length ?? 0) === 0 + ) { console.log('No intent-enabled packages found.') if (options.showHidden && result.hiddenSourceCount > 0) { - printHiddenSources(result, audience) + printHiddenSources(result, audience, explain) } - if (result.warnings.length > 0) { + if (warnings.length > 0) { console.log() - printWarnings(result.warnings) + printWarnings(warnings) + } + if (audience === 'human') { + printNotices(result.notices, noticeOptions) } - printNotices(result.notices, noticeOptions) return } @@ -148,25 +204,39 @@ export async function runListCommand( `\n${result.packages.length} intent-enabled packages, ${result.skills.length} skills\n`, ) - const rows = result.packages.map((pkg) => [ - pkg.name, - pkg.source, - pkg.version, - String(pkg.skillCount), - ]) - printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS'], rows) + if (audience === 'human') { + const rows = result.packages.map((pkg) => [ + pkg.name, + pkg.source, + pkg.version, + String(pkg.skillCount), + ]) + printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS'], rows) + } - printVersionConflicts(result) + if (audience === 'human') { + printVersionConflicts(result) + } if (options.showHidden) { - printHiddenSources(result, audience) + printHiddenSources(result, audience, explain) } - const skillsByPackageRoot = groupSkillsByPackageRoot(result.skills) - const allSkills = result.packages.map((pkg) => + const displaySkills = [...result.skills, ...(result.excludedSkills ?? [])] + const skillsByPackageRoot = groupSkillsByPackageRoot(displaySkills) + const displayPackages = [...result.packages] + for (const skill of result.excludedSkills ?? []) { + if (!displayPackages.some((pkg) => pkg.packageRoot === skill.packageRoot)) { + displayPackages.push(getExcludedPackageSummary(skill)) + } + } + const packagesWithSkills = displayPackages.filter( + (pkg) => getPackageSkills(pkg, skillsByPackageRoot).length > 0, + ) + const allSkills = packagesWithSkills.map((pkg) => getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, - description: skill.description, + description: 'excluded' in skill ? '(excluded)' : skill.description, type: skill.type, })), ) @@ -178,21 +248,33 @@ export async function runListCommand( ? ' --global' : '' + if (audience === 'agent') { + console.log( + `Load a skill with \`${formatLoadCommand('', result.packageManager, scopeFlag)}\`.`, + ) + } + console.log(`\nSkills:\n`) - for (const pkg of result.packages) { + for (const pkg of packagesWithSkills) { console.log(` ${pkg.name}`) printSkillTree( getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, - description: skill.description, - loadCommand: formatLoadCommand(skill, result.packageManager, scopeFlag), + description: 'excluded' in skill ? '(excluded)' : skill.description, + loadCommand: + audience === 'human' && !('excluded' in skill) + ? formatLoadCommand(skill.use, result.packageManager, scopeFlag) + : undefined, type: skill.type, + why: skill.why, })), { nameWidth, packageName: pkg.name, showTypes }, ) console.log() } - printWarnings(result.warnings) - printNotices(result.notices, noticeOptions) + printWarnings(warnings) + if (audience === 'human') { + printNotices(result.notices, noticeOptions) + } } diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts new file mode 100644 index 00000000..f0190c52 --- /dev/null +++ b/packages/intent/src/commands/sync/command.ts @@ -0,0 +1,644 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fail } from '../../shared/cli-error.js' +import { compileExcludePatterns } from '../../core/excludes.js' +import { createIntentFsCache } from '../../discovery/fs-cache.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { sourceIdentityKey } from '../../core/types.js' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../../core/lockfile/lockfile.js' +import { resolveProjectContext } from '../../core/project-context.js' +import { + applySourcePolicy, + compileSkillSourcePolicy, + scanForConfiguredIntents, +} from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from '../install/config.js' +import { + DELIVERY_CONFIG_PATH, + readIntentDeliveryConfig, +} from '../install/delivery.js' +import { + buildSkillSelectionPlan, + skillSelectionId, + summarizeInstallDeltaInventory, +} from '../install/plan.js' +import { writeIntentGitExclude } from './gitignore.js' +import { hasNonNativeLinkSource, reconcileManagedLinks } from './links.js' +import { buildSyncLinkPlan } from './plan.js' +import { + INSTALL_STATE_PATH, + readInstallStateForLinks, + writeInstallState, +} from './state.js' +import { toProjectRelativePath } from './targets.js' +import type { SkillSelection } from '../install/plan.js' +import type { LinkReconciliation } from './links.js' +import type { IntentPackage } from '../../shared/types.js' +import type { ReadFs } from '../../shared/utils.js' + +type SyncSkillSelection = Extract + +export interface SyncCommandOptions { + cwd?: string + dryRun?: boolean + json?: boolean +} + +export type NewDependencyDecision = 'review' | 'exclude' | 'later' + +export interface SyncReviewPrompter { + complete: (message: string) => void + reviewNewDependencies: ( + entries: ReadonlyArray, + ) => Promise + selectSkills: ( + packages: ReadonlyArray, + ) => Promise +} + +type SyncReviewMode = 'interactive' | 'reminder' | 'fail' + +export interface SyncCommandRuntime { + review?: SyncReviewMode + prompts?: SyncReviewPrompter +} + +interface SyncPackageSummary { + name: string + skillCount: number +} + +interface SyncCommandResult { + created: Array + repaired: Array + removed: Array + unchanged: Array + conflicts: Array + newDependencies: Array + newSkills: Array + changed: Array +} + +function printReminder( + title: string, + entries: Array, + action: string, +): void { + if (entries.length === 0) return + const width = Math.max(...entries.map((entry) => entry.name.length)) + const packages = entries + .map( + (entry) => + `${entry.name.padEnd(width)} ${entry.skillCount} ${entry.skillCount === 1 ? 'skill' : 'skills'}`, + ) + .join('\n') + console.log(`${title}:\n\n${packages}\n\n${action}`) +} + +function writeManagedLinkState(root: string, links: LinkReconciliation): void { + const entries = links.entries.map((entry) => ({ + ...entry, + path: toProjectRelativePath(root, entry.path), + })) + writeInstallState(root, { version: 1, entries }) + writeIntentGitExclude(root, [ + ...entries.map((entry) => entry.path), + DELIVERY_CONFIG_PATH, + INSTALL_STATE_PATH, + ]) +} + +function buildSyncCommandResult( + root: string, + links: LinkReconciliation, + summaries: Pick< + SyncCommandResult, + 'newDependencies' | 'newSkills' | 'changed' + >, +): SyncCommandResult { + return { + created: links.created.map((path) => toProjectRelativePath(root, path)), + repaired: links.repaired.map((path) => toProjectRelativePath(root, path)), + removed: links.removed.map((path) => toProjectRelativePath(root, path)), + unchanged: links.unchanged.map((path) => toProjectRelativePath(root, path)), + conflicts: links.conflicts.map((path) => toProjectRelativePath(root, path)), + ...summaries, + } +} + +function output( + result: SyncCommandResult, + json: boolean, + interactiveReview: boolean, +): void { + if (json) { + console.log(JSON.stringify(result)) + return + } + console.log( + `Intent sync: ${result.created.length} created, ${result.repaired.length} repaired, ${result.removed.length} removed.`, + ) + printReminder( + 'Pending skills by source', + result.newDependencies, + interactiveReview + ? 'Choose how to handle them below.' + : 'Run `intent install` to review and install them, or add them to `intent.exclude`.', + ) + printReminder( + 'New skills found in enabled dependencies', + result.newSkills, + 'Run `intent install` to review and install them.', + ) + printReminder( + 'Changed skill content', + result.changed, + 'Run `intent install` to review and accept the new baseline.', + ) + if (result.conflicts.length > 0) + console.log(`Conflicts: ${result.conflicts.join(', ')}.`) +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function sourceName(source: Pick): string { + return source.kind === 'workspace' ? `workspace:${source.name}` : source.name +} + +function shouldReviewInteractively( + options: SyncCommandOptions, + runtime: SyncCommandRuntime, +): boolean { + if ( + options.dryRun === true || + options.json === true || + process.env.npm_lifecycle_event === 'prepare' + ) { + return false + } + if (runtime.review !== undefined) return runtime.review === 'interactive' + return process.stdin.isTTY === true && process.stdout.isTTY === true +} + +async function reviewNewDependencies({ + config, + deliveryTargets, + discovered, + lock, + packages, + reviewedPackages, + prompts, + readFs, + root, +}: { + config: ReturnType + deliveryTargets: NonNullable< + ReturnType + >['targets'] + discovered: Array + lock: Extract, { status: 'found' }> + packages: Array + reviewedPackages: Array + prompts: SyncReviewPrompter + readFs: ReadFs + root: string +}): Promise { + const decision = await prompts.reviewNewDependencies( + packages.map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.length, + })), + ) + if (!decision || decision === 'later') { + prompts.complete('Pending skills remain pending review.') + return + } + const packageJsonPath = join(root, 'package.json') + const packageJson = readFileSync(packageJsonPath, 'utf8') + if (decision === 'exclude') { + const sourcePolicy = compileSkillSourcePolicy( + parseSkillSources(config.skills), + ) + const updatedConfig = { + ...config, + exclude: [ + ...new Set([ + ...config.exclude, + ...packages.flatMap((pkg) => + sourcePolicy.permits(pkg.name, pkg.kind) + ? pkg.skills.map((skill) => `${pkg.name}#${skill.name}`) + : [pkg.name], + ), + ]), + ].sort(compareStrings), + } + writeTextFileAtomic( + packageJsonPath, + updateIntentConsumerConfigText(packageJson, updatedConfig), + ) + prompts.complete('Excluded pending skills.') + return + } + + const selection = await prompts.selectSkills(packages) + if (!selection) { + prompts.complete('Pending skills remain pending review.') + return + } + const pendingSelectionPlan = buildSkillSelectionPlan(packages, selection) + const pendingSkillIds = new Set( + packages.flatMap((pkg) => + pkg.skills.map((skill) => skillSelectionId(pkg, skill)), + ), + ) + const selectionPlan = buildSkillSelectionPlan(reviewedPackages, { + ...selection, + enabled: [ + ...new Set([ + ...selection.enabled, + ...reviewedPackages.flatMap((pkg) => + pkg.skills + .map((skill) => skillSelectionId(pkg, skill)) + .filter((id) => !pendingSkillIds.has(id)), + ), + ]), + ], + }) + const packageExcludes = new Set(selectionPlan.exclude) + const narrowedExcludes = packages.flatMap((pkg) => + pendingSelectionPlan.exclude.includes(pkg.name) && + !packageExcludes.has(pkg.name) + ? pkg.skills.map((skill) => `${pkg.name}#${skill.name}`) + : [], + ) + const configuredSources = parseSkillSources(config.skills) + const configuredPolicy = compileSkillSourcePolicy(configuredSources) + const addedSkills = new Set(selectionPlan.skills) + for (const pkg of selectionPlan.packages) { + const hasSkillEntries = configuredPolicy.matchers.some( + (matcher) => + matcher.matchesSkill !== undefined && + matcher.matchesPackage(pkg.name, pkg.kind), + ) + if (hasSkillEntries) { + addedSkills.delete(sourceName(pkg)) + for (const skill of pkg.skills) { + if (skill.status === 'enabled') addedSkills.add(skill.id) + } + } + } + const updatedConfig = { + ...config, + skills: [...new Set([...config.skills, ...addedSkills])].sort( + compareStrings, + ), + exclude: [ + ...new Set([ + ...config.exclude, + ...selectionPlan.exclude, + ...narrowedExcludes, + ]), + ].sort(compareStrings), + } + const policy = applySourcePolicy( + { packages: discovered }, + { + config: parseSkillSources(updatedConfig.skills), + excludeMatchers: compileExcludePatterns(updatedConfig.exclude), + }, + ) + const currentSources = buildCurrentLockfileSources(policy.packages, readFs) + const selectedPendingIds = new Set(selection.enabled) + const selectedPathsBySource = new Map( + packages.flatMap((pkg) => { + const paths = pkg.skills + .filter((skill) => selectedPendingIds.has(skillSelectionId(pkg, skill))) + .map((skill) => `skills/${skill.name}`) + return paths.length > 0 + ? [ + [ + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + new Set(paths), + ] as const, + ] + : [] + }), + ) + const selectedCurrentSources = new Map( + currentSources.flatMap((source) => { + const key = sourceIdentityKey(source) + const selectedPaths = selectedPathsBySource.get(key) + if (!selectedPaths) return [] + return [ + [ + key, + { + ...source, + skills: source.skills.filter((skill) => + selectedPaths.has(skill.path), + ), + }, + ] as const, + ] + }), + ) + const lockedKeys = new Set( + lock.lockfile.sources.map((source) => sourceIdentityKey(source)), + ) + const prospectiveLock = { + lockfileVersion: 1 as const, + sources: [ + ...lock.lockfile.sources.map((source) => { + const selectedSource = selectedCurrentSources.get( + sourceIdentityKey(source), + ) + if (!selectedSource) return source + const selectedPaths = new Set( + selectedSource.skills.map((skill) => skill.path), + ) + return { + ...source, + skills: [ + ...source.skills.filter((skill) => !selectedPaths.has(skill.path)), + ...selectedSource.skills, + ], + } + }), + ...[...selectedCurrentSources.entries()] + .filter(([key]) => !lockedKeys.has(key)) + .map(([, source]) => source), + ], + } + const expected = buildSyncLinkPlan({ + config: updatedConfig, + currentSources, + discovered, + lock: { status: 'found', lockfile: prospectiveLock }, + packages: policy.packages, + root, + targets: deliveryTargets, + }).expected + if (hasNonNativeLinkSource(expected, readFs)) { + fail( + 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', + ) + } + const stateResult = readInstallStateForLinks(root) + const preflight = reconcileManagedLinks({ + root, + dryRun: true, + expected, + stateResult, + }) + if (preflight.conflicts.length > 0) { + fail( + `Intent sync found managed link conflicts: ${preflight.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + writeTextFileAtomic( + packageJsonPath, + updateIntentConsumerConfigText(packageJson, updatedConfig), + ) + writeIntentLockfile(join(root, 'intent.lock'), prospectiveLock) + const links = reconcileManagedLinks({ + root, + dryRun: false, + expected, + stateResult, + }) + writeManagedLinkState(root, links) + if (links.conflicts.length > 0) { + fail( + `Intent sync found managed link conflicts: ${links.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + prompts.complete( + 'Installed selected skills using the existing delivery settings.', + ) +} + +export async function runSyncCommand( + options: SyncCommandOptions, + runtime: SyncCommandRuntime = {}, +): Promise { + const context = resolveProjectContext({ cwd: options.cwd ?? process.cwd() }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + const delivery = readIntentDeliveryConfig(root) + if (!delivery) { + console.error( + 'Intent skill delivery is not configured for this checkout. Run `intent install` to configure it.', + ) + return + } + const packageJsonPath = join(root, 'package.json') + if (!existsSync(packageJsonPath)) { + fail( + 'Intent sync requires package policy and intent.lock. Run `intent install` first.', + ) + } + const packageJson = readFileSync(packageJsonPath, 'utf8') + const config = readIntentConsumerConfig(packageJson) + const lock = readIntentLockfile(join(root, 'intent.lock')) + if (lock.status !== 'found') { + fail( + 'Intent sync requires package policy and intent.lock. Run `intent install` first.', + ) + } + if (delivery.method !== 'symlink') { + fail( + 'Hook delivery is repaired by `intent install`; run it to repair configured hooks.', + ) + } + const stateResult = readInstallStateForLinks(root) + if (stateResult.status === 'malformed') { + fail( + `Intent install state is malformed at ${INSTALL_STATE_PATH}. Restore a valid copy, or remove the existing Intent-managed links and ${INSTALL_STATE_PATH}, then run \`intent install\` again.`, + ) + } + + let discovery: ReturnType + let plan: ReturnType + const fsCache = createIntentFsCache() + try { + discovery = scanForConfiguredIntents({ + root, + config: parseSkillSources(config.skills), + exclude: config.exclude, + fsCache, + }) + plan = buildSyncLinkPlan({ + config, + currentSources: buildCurrentLockfileSources( + discovery.policy.packages, + fsCache.getReadFs(), + ), + discovered: discovery.discovered, + lock, + packages: discovery.policy.packages, + root, + targets: delivery.targets, + }) + } catch (error) { + if (stateResult.status === 'missing') throw error + const links = reconcileManagedLinks({ + root, + dryRun: options.dryRun === true, + expected: [], + stateResult, + }) + if (!options.dryRun) { + writeManagedLinkState(root, links) + } + if (links.conflicts.length > 0) { + throw new Error( + `Intent sync could not revoke managed links after verification failed: ${links.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + { cause: error }, + ) + } + throw error + } + const { discovered, policy } = discovery + const { expected, inventory } = plan + if (hasNonNativeLinkSource(expected, fsCache.getReadFs())) { + fail( + 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', + ) + } + const { newDependencies, newSkills, changed } = + summarizeInstallDeltaInventory(inventory) + const summaries = { newDependencies, newSkills, changed } + const interactiveReview = + summaries.newDependencies.length > 0 && + shouldReviewInteractively(options, runtime) + const preflight = reconcileManagedLinks({ + root, + dryRun: true, + expected, + stateResult, + }) + if (preflight.conflicts.length > 0) { + const reportedLinks = options.dryRun + ? preflight + : { ...preflight, created: [], repaired: [], removed: [] } + output( + buildSyncCommandResult(root, reportedLinks, summaries), + options.json === true, + false, + ) + fail( + `Intent sync found managed link conflicts: ${preflight.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + const links = options.dryRun + ? preflight + : reconcileManagedLinks({ + root, + dryRun: false, + expected, + stateResult, + }) + if (!options.dryRun) { + writeManagedLinkState(root, links) + } + const result = buildSyncCommandResult(root, links, summaries) + if (links.conflicts.length > 0) { + output(result, options.json === true, false) + fail( + `Intent sync found managed link conflicts: ${links.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + output(result, options.json === true, interactiveReview) + if ( + runtime.review === 'fail' && + (summaries.newDependencies.length > 0 || + summaries.newSkills.length > 0 || + summaries.changed.length > 0) + ) { + fail('Intent sync requires review before automation can continue.') + } + if (interactiveReview) { + const pendingSkills = new Map( + inventory.packages.map((pkg) => [ + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + new Set( + pkg.skills + .filter((skill) => skill.policy === 'pending') + .map((skill) => skill.id.slice(skill.id.indexOf('#') + 1)), + ), + ]), + ) + const packages = discovered.flatMap((pkg) => { + const skills = pendingSkills.get( + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ) + if (!skills || skills.size === 0) return [] + return [ + { + ...pkg, + skills: pkg.skills.filter((skill) => skills.has(skill.name)), + }, + ] + }) + const reviewedKeys = new Set( + packages.map((pkg) => + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ), + ) + const enabledSkills = new Map( + policy.packages.map((pkg) => [ + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + new Set(pkg.skills.map((skill) => skill.name)), + ]), + ) + const reviewedPackages = discovered.flatMap((pkg) => { + const key = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) + if (!reviewedKeys.has(key)) return [] + const pending = pendingSkills.get(key) + const enabled = enabledSkills.get(key) + return [ + { + ...pkg, + skills: pkg.skills.filter( + (skill) => + pending?.has(skill.name) === true || + enabled?.has(skill.name) === true, + ), + }, + ] + }) + const prompts = + runtime.prompts ?? + (await import('./prompts.js')).createClackSyncReviewPrompter() + await reviewNewDependencies({ + config, + deliveryTargets: delivery.targets, + discovered, + lock, + packages, + reviewedPackages, + prompts, + readFs: fsCache.getReadFs(), + root, + }) + } +} diff --git a/packages/intent/src/commands/sync/gitignore.ts b/packages/intent/src/commands/sync/gitignore.ts new file mode 100644 index 00000000..3e8f2ff2 --- /dev/null +++ b/packages/intent/src/commands/sync/gitignore.ts @@ -0,0 +1,48 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' + +const START = '# intent skill links:start' +const END = '# intent skill links:end' + +export function updateIntentGitExclude( + text: string | null, + paths: ReadonlyArray, +): string { + const eol = text?.includes('\r\n') ? '\r\n' : '\n' + const prefix = text ?? '' + const entries = [...new Set(paths)].sort() + const block = [START, ...entries, END].join(eol) + const matcher = new RegExp( + `${START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ) + if (matcher.test(prefix)) return prefix.replace(matcher, block) + if (prefix === '') return `${block}${eol}` + const separator = prefix.endsWith('\n') ? '' : eol + return `${prefix}${separator}${block}${eol}` +} + +export function writeIntentGitExclude( + root: string, + paths: ReadonlyArray, +): boolean { + let gitPath: string + try { + gitPath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return false + } + if (gitPath === '') return false + + const path = resolve(root, gitPath) + const before = existsSync(path) ? readFileSync(path, 'utf8') : null + const after = updateIntentGitExclude(before, paths) + if (before === after) return false + writeTextFileAtomic(path, after) + return true +} diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts new file mode 100644 index 00000000..7a0e13ff --- /dev/null +++ b/packages/intent/src/commands/sync/links.ts @@ -0,0 +1,283 @@ +import { + lstatSync, + mkdirSync, + existsSync as nativeExistsSync, + readlinkSync, + realpathSync, + rmdirSync, + symlinkSync, + unlinkSync, +} from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { isPathWithin } from '../../shared/utils.js' +import type { InstallStateEntry, ReadInstallStateResult } from './state.js' +import type { ReadFs } from '../../shared/utils.js' + +export interface ExpectedLink { + path: string + targetDirectory: string + alias: string + source: { kind: 'npm' | 'workspace'; id: string } + skillPath: string + sourceDirectory: string + packageRoot: string +} + +export interface LinkReconciliation { + created: Array + repaired: Array + removed: Array + unchanged: Array + conflicts: Array + entries: Array +} + +export function hasNonNativeLinkSource( + expected: ReadonlyArray, + readFs: ReadFs, +): boolean { + return expected.some( + (entry) => + readFs.existsSync(entry.sourceDirectory) && + !nativeExistsSync(entry.sourceDirectory), + ) +} + +function exists(path: string): boolean { + try { + lstatSync(path) + return true + } catch { + return false + } +} + +function resolveLinkTarget(path: string): string | null { + try { + const target = readlinkSync(path) + return resolve(dirname(path), target) + } catch { + return null + } +} + +function isLink(path: string): boolean { + try { + return lstatSync(path).isSymbolicLink() + } catch { + return false + } +} + +function hasContainedParent(path: string, root: string): boolean { + try { + const resolvedRoot = resolve(root) + const resolvedParent = resolve(dirname(path)) + if (!isPathWithin(resolvedRoot, resolvedParent)) return false + const realRoot = realpathSync(resolvedRoot) + let existingParent = resolvedParent + for (;;) { + try { + lstatSync(existingParent) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false + } + const parent = dirname(existingParent) + if (parent === existingParent) return false + existingParent = parent + } + return isPathWithin(realRoot, realpathSync(existingParent)) + } catch { + return false + } +} + +function sourceTarget(expected: ExpectedLink): string | null { + try { + const packageRoot = realpathSync(expected.packageRoot) + const sourceDirectory = realpathSync(expected.sourceDirectory) + return isPathWithin(packageRoot, sourceDirectory) ? sourceDirectory : null + } catch { + return null + } +} + +function stateEntry( + expected: ExpectedLink, + linkTarget: string, +): InstallStateEntry { + return { + targetDirectory: expected.targetDirectory, + path: expected.path, + alias: expected.alias, + source: expected.source, + skillPath: expected.skillPath, + linkTarget, + } +} + +function createLink(path: string, target: string): boolean { + try { + mkdirSync(dirname(path), { recursive: true }) + if (process.platform === 'win32') { + symlinkSync(target, path, 'junction') + } else { + symlinkSync(relative(dirname(path), target), path, 'dir') + } + return true + } catch (error) { + if (typeof (error as NodeJS.ErrnoException).code === 'string') return false + throw error + } +} + +// `rmSync` with recursive+force silently leaves some directory symlinks in place. +// On Windows a directory symlink or junction needs `rmdirSync`, not `unlinkSync`. +function removeLink(path: string): boolean { + try { + unlinkSync(path) + return true + } catch (unlinkError) { + if (process.platform !== 'win32') { + if (isRemovalConflict(unlinkError)) return false + throw unlinkError + } + try { + rmdirSync(path) + return true + } catch (rmdirError) { + if (isRemovalConflict(rmdirError)) return false + throw rmdirError + } + } +} + +function isRemovalConflict(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function reconcileManagedLinks({ + root, + dryRun, + expected, + stateResult, + createLink: createManagedLink = createLink, + removeLink: removeManagedLink = removeLink, +}: { + root: string + dryRun: boolean + expected: ReadonlyArray + stateResult: ReadInstallStateResult + createLink?: (path: string, target: string) => boolean + removeLink?: (path: string) => boolean +}): LinkReconciliation { + const result: LinkReconciliation = { + created: [], + repaired: [], + removed: [], + unchanged: [], + conflicts: [], + entries: [], + } + const expectedByPath = new Map(expected.map((entry) => [entry.path, entry])) + const prior = stateResult.status === 'found' ? stateResult.state.entries : [] + const priorByPath = new Map(prior.map((entry) => [entry.path, entry])) + const preserveConflict = ( + path: string, + priorEntry?: InstallStateEntry, + ): void => { + result.conflicts.push(path) + if (priorEntry) result.entries.push(priorEntry) + } + + for (const entry of [...expected].sort((left, right) => + compareStrings(left.path, right.path), + )) { + const priorEntry = priorByPath.get(entry.path) + if (!hasContainedParent(entry.path, root)) { + preserveConflict(entry.path, priorEntry) + continue + } + const target = sourceTarget(entry) + if (!target) { + preserveConflict(entry.path, priorEntry) + continue + } + if (!exists(entry.path)) { + if (!dryRun && !createManagedLink(entry.path, target)) { + preserveConflict(entry.path, priorEntry) + continue + } + result.created.push(entry.path) + result.entries.push(stateEntry(entry, target)) + continue + } + if (!isLink(entry.path) || !priorEntry) { + preserveConflict(entry.path, priorEntry) + continue + } + const current = resolveLinkTarget(entry.path) + if (current === target) { + result.unchanged.push(entry.path) + result.entries.push(stateEntry(entry, target)) + continue + } + if (current === priorEntry.linkTarget) { + if ( + !dryRun && + (!removeManagedLink(entry.path) || + !createManagedLink(entry.path, target)) + ) { + preserveConflict(entry.path, priorEntry) + continue + } + result.repaired.push(entry.path) + result.entries.push(stateEntry(entry, target)) + continue + } + preserveConflict(entry.path, priorEntry) + } + + if (stateResult.status === 'found') { + for (const priorEntry of prior) { + if (expectedByPath.has(priorEntry.path)) continue + if (!hasContainedParent(priorEntry.path, root)) { + preserveConflict(priorEntry.path, priorEntry) + continue + } + if (!exists(priorEntry.path)) { + result.removed.push(priorEntry.path) + continue + } + if ( + isLink(priorEntry.path) && + resolveLinkTarget(priorEntry.path) === priorEntry.linkTarget + ) { + if (!dryRun && !removeManagedLink(priorEntry.path)) { + preserveConflict(priorEntry.path, priorEntry) + continue + } + result.removed.push(priorEntry.path) + continue + } + preserveConflict(priorEntry.path, priorEntry) + } + } + + return { + created: result.created.sort(compareStrings), + repaired: result.repaired.sort(compareStrings), + removed: result.removed.sort(compareStrings), + unchanged: result.unchanged.sort(compareStrings), + conflicts: [...new Set(result.conflicts)].sort(compareStrings), + entries: result.entries.sort((left, right) => + compareStrings(left.path, right.path), + ), + } +} diff --git a/packages/intent/src/commands/sync/plan.ts b/packages/intent/src/commands/sync/plan.ts new file mode 100644 index 00000000..059007e6 --- /dev/null +++ b/packages/intent/src/commands/sync/plan.ts @@ -0,0 +1,99 @@ +import { dirname, join, resolve } from 'node:path' +import { sourceIdentityKey } from '../../core/types.js' +import { buildInstallDeltaInventory } from '../install/plan.js' +import { + createSyncAliases, + resolveSyncTargetDirectories, + toProjectRelativePath, +} from './targets.js' +import type { IntentConsumerConfig } from '../install/config.js' +import type { InstallTarget } from '../install/delivery.js' +import type { InstallDeltaInventory } from '../install/plan.js' +import type { ExpectedLink } from './links.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.js' +import type { IntentPackage } from '../../shared/types.js' + +function findSkill(pkg: IntentPackage, name: string) { + return pkg.skills.find((skill) => skill.name === name) +} + +export function buildSyncLinkPlan({ + config, + currentSources, + discovered, + lock, + packages, + root, + targets, +}: { + config: IntentConsumerConfig + currentSources: ReadonlyArray + discovered: ReadonlyArray + lock: ReadIntentLockfileResult + packages: ReadonlyArray + root: string + targets: ReadonlyArray +}): { + expected: Array + inventory: InstallDeltaInventory +} { + const inventory = buildInstallDeltaInventory( + discovered, + currentSources, + lock, + config, + ) + const aliases = new Map( + createSyncAliases( + packages.flatMap((pkg) => + pkg.skills.map((skill) => ({ + kind: pkg.kind, + id: pkg.name, + skill: skill.name, + })), + ), + ).map((entry) => [ + `${sourceIdentityKey(entry)}\0${entry.skill}`, + entry.alias, + ]), + ) + const sources = new Map( + discovered.map((pkg) => [ + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + pkg, + ]), + ) + const accepted = inventory.packages.flatMap((pkg) => { + const source = sources.get( + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ) + if (!source) return [] + return pkg.skills.flatMap((skill) => { + if (skill.policy !== 'enabled' || skill.lock !== 'accepted') return [] + const sourceSkill = findSkill( + source, + skill.id.slice(skill.id.indexOf('#') + 1), + ) + return sourceSkill ? [{ pkg, skill: sourceSkill, source }] : [] + }) + }) + const targetDirectories = resolveSyncTargetDirectories(root, targets) + const expected = accepted.flatMap(({ pkg, skill, source }) => { + const identity = `${sourceIdentityKey({ kind: pkg.kind, id: pkg.name })}\0${skill.name}` + const alias = aliases.get(identity) + if (!alias) throw new Error(`Missing sync alias for ${identity}.`) + return targetDirectories.map((target) => ({ + path: join(target.path, alias), + targetDirectory: toProjectRelativePath(root, target.path), + alias, + source: { kind: pkg.kind, id: pkg.name }, + skillPath: `skills/${skill.name}`, + sourceDirectory: resolve(root, dirname(skill.path)), + packageRoot: source.packageRoot, + })) + }) + return { expected, inventory } +} diff --git a/packages/intent/src/commands/sync/prepare.ts b/packages/intent/src/commands/sync/prepare.ts new file mode 100644 index 00000000..e598f624 --- /dev/null +++ b/packages/intent/src/commands/sync/prepare.ts @@ -0,0 +1,50 @@ +import { applyEdits, modify, parse } from 'jsonc-parser' + +function formattingOptions(text: string): { + eol: string + insertSpaces: boolean + tabSize: number +} { + const indentation = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ' + return { + eol: text.includes('\r\n') ? '\r\n' : '\n', + insertSpaces: !indentation.includes('\t'), + tabSize: indentation.includes('\t') ? 1 : indentation.length, + } +} + +function containsIntentSync(value: string): boolean { + return value + .split('&&') + .some((segment) => /^\s*intent sync(?:\s|$)/.test(segment)) +} + +export function wireIntentSyncPrepare(text: string): string { + const errors: Array<{ error: number; offset: number; length: number }> = [] + const bom = text.startsWith('\ufeff') ? '\ufeff' : '' + const body = bom ? text.slice(1) : text + const value = parse(body, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as Record | null + if (errors.length > 0 || !value || typeof value !== 'object') { + throw new Error('Invalid package.json JSONC.') + } + const scripts = value.scripts + const prepare = + scripts && typeof scripts === 'object' && !Array.isArray(scripts) + ? (scripts as Record).prepare + : undefined + if (typeof prepare === 'string' && containsIntentSync(prepare)) return text + const next = + typeof prepare === 'string' && prepare.trim() + ? `${prepare} && intent sync` + : 'intent sync' + const updated = applyEdits( + body, + modify(body, ['scripts', 'prepare'], next, { + formattingOptions: formattingOptions(body), + }), + ) + return `${bom}${updated}` +} diff --git a/packages/intent/src/commands/sync/prompts.ts b/packages/intent/src/commands/sync/prompts.ts new file mode 100644 index 00000000..2d1caa5f --- /dev/null +++ b/packages/intent/src/commands/sync/prompts.ts @@ -0,0 +1,28 @@ +import { cancel, isCancel, outro, select } from '@clack/prompts' +import { selectClackSkills } from '../install/prompts.js' +import type { NewDependencyDecision, SyncReviewPrompter } from './command.js' + +export function createClackSyncReviewPrompter(): SyncReviewPrompter { + return { + complete(message: string): void { + outro(message) + }, + async reviewNewDependencies(): Promise { + const decision = await select({ + message: 'How do you want to handle these pending skills?', + options: [ + { value: 'review', label: 'Review and install' }, + { value: 'exclude', label: 'Exclude pending skills' }, + { value: 'later', label: 'Remind me later' }, + ], + }) + if (!isCancel(decision)) return decision + cancel('Sync review cancelled. Pending skills remain pending review.') + return null + }, + async selectSkills(packages) { + const selection = await selectClackSkills(packages, false) + return selection?.mode === 'individual' ? selection : null + }, + } +} diff --git a/packages/intent/src/commands/sync/state.ts b/packages/intent/src/commands/sync/state.ts new file mode 100644 index 00000000..39670501 --- /dev/null +++ b/packages/intent/src/commands/sync/state.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' + +export const INSTALL_STATE_PATH = '.intent/install-state.json' + +export interface InstallStateEntry { + targetDirectory: string + path: string + alias: string + source: { kind: 'npm' | 'workspace'; id: string } + skillPath: string + linkTarget: string +} + +export interface InstallState { + version: 1 + entries: Array +} + +export type ReadInstallStateResult = + | { status: 'missing' } + | { status: 'malformed' } + | { status: 'found'; state: InstallState } + +function compareEntry( + left: InstallStateEntry, + right: InstallStateEntry, +): number { + return left.path < right.path ? -1 : left.path > right.path ? 1 : 0 +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function parseEntry(value: unknown): InstallStateEntry | null { + if (!isRecord(value) || !isRecord(value.source)) return null + const keys = Object.keys(value).sort().join(',') + if (keys !== 'alias,linkTarget,path,skillPath,source,targetDirectory') + return null + if (Object.keys(value.source).sort().join(',') !== 'id,kind') return null + if ( + typeof value.targetDirectory !== 'string' || + typeof value.path !== 'string' || + value.path.length === 0 || + posix.isAbsolute(value.path) || + value.path.includes('\\') || + /^[A-Za-z]:/.test(value.path) || + value.path + .split('/') + .some( + (segment) => + segment === '' || + segment === '.' || + segment === '..' || + /[. ]$/.test(segment), + ) || + typeof value.alias !== 'string' || + typeof value.skillPath !== 'string' || + typeof value.linkTarget !== 'string' || + typeof value.source.id !== 'string' || + (value.source.kind !== 'npm' && value.source.kind !== 'workspace') + ) { + return null + } + return { + targetDirectory: value.targetDirectory, + path: value.path, + alias: value.alias, + source: { kind: value.source.kind, id: value.source.id }, + skillPath: value.skillPath, + linkTarget: value.linkTarget, + } +} + +export function parseInstallState(text: string): InstallState | null { + try { + const parsed: unknown = JSON.parse(text) + if ( + !isRecord(parsed) || + parsed.version !== 1 || + !Array.isArray(parsed.entries) + ) { + return null + } + if (Object.keys(parsed).sort().join(',') !== 'entries,version') return null + const entries = parsed.entries.map(parseEntry) + if (entries.some((entry) => entry === null)) return null + const typed = entries as Array + if (new Set(typed.map((entry) => entry.path)).size !== typed.length) + return null + return { version: 1, entries: [...typed].sort(compareEntry) } + } catch { + return null + } +} + +export function serializeInstallState(state: InstallState): string { + return `${JSON.stringify({ version: 1, entries: [...state.entries].sort(compareEntry) }, null, 2)}\n` +} + +export function readInstallState(root: string): ReadInstallStateResult { + const path = join(root, INSTALL_STATE_PATH) + if (!existsSync(path)) return { status: 'missing' } + const state = parseInstallState(readFileSync(path, 'utf8')) + return state ? { status: 'found', state } : { status: 'malformed' } +} + +export function readInstallStateForLinks(root: string): ReadInstallStateResult { + const result = readInstallState(root) + if (result.status !== 'found') return result + const projectRoot = resolve(root) + const entries = result.state.entries.map((entry) => ({ + ...entry, + path: resolve(projectRoot, ...entry.path.split('/')), + })) + if ( + entries.some((entry) => { + const projectRelativePath = relative(projectRoot, entry.path) + return ( + projectRelativePath === '..' || + projectRelativePath.startsWith(`..${sep}`) || + isAbsolute(projectRelativePath) + ) + }) + ) { + return { status: 'malformed' } + } + return { + status: 'found', + state: { version: 1, entries }, + } +} + +export function writeInstallState(root: string, state: InstallState): boolean { + const path = join(root, INSTALL_STATE_PATH) + const content = serializeInstallState(state) + if (existsSync(path) && readFileSync(path, 'utf8') === content) return false + writeTextFileAtomic(path, content) + return true +} diff --git a/packages/intent/src/commands/sync/targets.ts b/packages/intent/src/commands/sync/targets.ts new file mode 100644 index 00000000..7f223e5c --- /dev/null +++ b/packages/intent/src/commands/sync/targets.ts @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto' +import { join, relative, resolve, sep } from 'node:path' +import type { InstallTarget } from '../install/delivery.js' + +export interface SyncTargetDirectory { + id: InstallTarget + path: string +} + +const TARGETS: Readonly> = { + agents: '.agents/skills', + github: '.github/skills', + vscode: '.github/skills', + cursor: '.cursor/skills', + codex: '.codex/skills', + claude: '.claude/skills', +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function toProjectRelativePath(root: string, path: string): string { + return relative(resolve(root), resolve(path)).split(sep).join('/') +} + +export function resolveSyncTargetDirectories( + root: string, + targets: ReadonlyArray, +): Array { + const unique = new Map() + for (const id of targets) { + const path = join(root, TARGETS[id]) + const relativePath = toProjectRelativePath(root, path) + if (!unique.has(relativePath)) unique.set(relativePath, { id, path }) + } + return [...unique.values()].sort((left, right) => + compareStrings( + toProjectRelativePath(root, left.path), + toProjectRelativePath(root, right.path), + ), + ) +} + +function sanitize(value: string): string { + return value + .replace(/^@/, '') + .replace(/[\\/]+/g, '-') + .replace(/[^a-zA-Z0-9-]+/g, '-') + .toLowerCase() + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export interface SyncAliasInput { + kind: 'npm' | 'workspace' + id: string + skill: string +} + +export interface SyncAlias extends SyncAliasInput { + alias: string +} + +export function createSyncAliases( + inputs: ReadonlyArray, +): Array { + const preliminary = inputs.map((input) => ({ + ...input, + alias: `${input.kind}-${sanitize(input.id)}-${sanitize(input.skill)}`, + })) + const counts = new Map() + for (const entry of preliminary) { + counts.set(entry.alias, (counts.get(entry.alias) ?? 0) + 1) + } + return preliminary + .map((entry) => { + if (counts.get(entry.alias) === 1) return entry + const identity = `${entry.kind}:${entry.id}#${entry.skill}` + const suffix = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, 8) + return { ...entry, alias: `${entry.alias}-${suffix}` } + }) + .sort((left, right) => { + const leftIdentity = `${left.kind}\0${left.id}\0${left.skill}` + const rightIdentity = `${right.kind}\0${right.id}\0${right.skill}` + return compareStrings(leftIdentity, rightIdentity) + }) +} diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 961f0d99..c4a88a7f 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -8,7 +8,20 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { fail, isCliFailure } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import { + buildSessionCatalogue, + formatSessionCatalogue, +} from '../session-catalog.js' +import { containsLocalPath } from '../shared/local-path.js' +import { isKnownSkillType } from '../skills/categories.js' +import { + SESSION_CATALOGUE_MAX_BYTES, + SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH, + normalizeWhitespace, +} from '../skills/catalogue-contract.js' +import { isSkillUseParseError, parseSkillUse } from '../skills/use.js' import { printWarnings } from './support.js' +import type { IntentSkillList, IntentSkillSummary } from '../core/index.js' import type { ProjectContext } from '../core/project-context.js' interface ValidationError { @@ -32,6 +45,25 @@ interface SetVersionPlan { filePath: string } +interface CatalogueValidationSkill { + file: string + rawDescription: string + summary: IntentSkillSummary +} + +function catalogueInput(skills: Array): IntentSkillList { + return { + packageManager: 'unknown', + skills, + packages: [], + hiddenSourceCount: 0, + hiddenSources: [], + warnings: [], + notices: [], + conflicts: [], + } +} + export interface ValidateCommandOptions { check?: boolean fix?: boolean @@ -136,6 +168,11 @@ function collectPackagingWarnings(context: ProjectContext): Array { return warnings } +function displayPath(filePath: string): string { + const rel = relative(process.cwd(), filePath) + return rel.startsWith('..') ? filePath : rel +} + function formatWarning({ file, message }: ValidationWarning): string { return `${file}: ${message}` } @@ -325,22 +362,23 @@ function collectAgentSkillSpecWarnings({ message: 'Agent Skills spec warning: compatibility should be a non-empty string', }) - } else if (fm.compatibility.length > 500) { + } else if ([...fm.compatibility].length > 500) { warnings.push({ file: rel, - message: `Agent Skills spec warning: compatibility exceeds 500 characters (${fm.compatibility.length} chars)`, + message: `Agent Skills spec warning: compatibility exceeds 500 characters (${[...fm.compatibility].length} chars)`, }) } } if ( fm['allowed-tools'] !== undefined && - typeof fm['allowed-tools'] !== 'string' + (typeof fm['allowed-tools'] !== 'string' || + fm['allowed-tools'].trim().length === 0) ) { warnings.push({ file: rel, message: - 'Agent Skills spec warning: allowed-tools should be a space-separated string', + 'Agent Skills spec warning: allowed-tools should be a non-empty space-separated string', }) } @@ -408,6 +446,10 @@ async function runValidateCommandInternal( const fixPlans: Array = [] const setVersionPlans: Array = [] let validatedCount = 0 + const reportCatalogueWarning = (warning: ValidationWarning): void => { + if (options.check) errors.push(warning) + else warnings.push(formatWarning(warning)) + } if (explicitDir && findSkillFiles(skillsDirs[0]!).length === 0) { fail('No SKILL.md files found') @@ -424,9 +466,23 @@ async function runValidateCommandInternal( cwd: process.cwd(), targetPath: skillsDir, }) + const packageRoot = validateContext.packageRoot ?? dirname(skillsDir) + let packageName = basename(packageRoot) + if ( + validateContext.targetPackageJsonPath && + existsSync(validateContext.targetPackageJsonPath) + ) { + try { + const packageJson = JSON.parse( + readFileSync(validateContext.targetPackageJsonPath, 'utf8'), + ) as { name?: unknown } + if (typeof packageJson.name === 'string') packageName = packageJson.name + } catch {} + } + const catalogueSkills: Array = [] for (const filePath of skillFiles) { - const rel = relative(process.cwd(), filePath) + const rel = displayPath(filePath) const content = readFileSync(filePath, 'utf8') const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) @@ -440,9 +496,9 @@ async function runValidateCommandInternal( continue } - let fm: Record + let parsedFrontmatter: unknown try { - fm = parseYaml(match[1]) as Record + parsedFrontmatter = parseYaml(match[1]) } catch (err) { const detail = err instanceof Error ? err.message : String(err) errors.push({ @@ -451,6 +507,14 @@ async function runValidateCommandInternal( }) continue } + if (!isRecord(parsedFrontmatter)) { + errors.push({ + file: rel, + message: 'YAML frontmatter must be a mapping', + }) + continue + } + const fm = parsedFrontmatter const fixPlan = collectFrontmatterFixPlan({ filePath, fm, rel }) if (fixPlan) fixPlans.push(fixPlan) @@ -467,12 +531,25 @@ async function runValidateCommandInternal( if (!fm.name) { errors.push({ file: rel, message: 'Missing required field: name' }) + } else if (typeof fm.name !== 'string' || fm.name.trim().length === 0) { + errors.push({ + file: rel, + message: 'name must be a non-empty string', + }) } if (!fm.description) { errors.push({ file: rel, message: 'Missing required field: description', }) + } else if ( + typeof fm.description !== 'string' || + fm.description.trim().length === 0 + ) { + errors.push({ + file: rel, + message: 'description must be a non-empty string', + }) } if (typeof fm.name === 'string') { @@ -533,11 +610,14 @@ async function runValidateCommandInternal( } } - if (typeof fm.description === 'string' && fm.description.length > 1024) { - errors.push({ - file: rel, - message: `Description exceeds 1024 character limit (${fm.description.length} chars)`, - }) + if (typeof fm.description === 'string') { + const descriptionLength = [...fm.description].length + if (descriptionLength > 1024) { + errors.push({ + file: rel, + message: `Description exceeds 1024 character limit (${descriptionLength} chars)`, + }) + } } if ( @@ -554,6 +634,27 @@ async function runValidateCommandInternal( ...collectAgentSkillSpecWarnings({ fm, rel }).map(formatWarning), ) + if (typeof fm.description === 'string') { + const skillName = relative(skillsDir, dirname(filePath)).replace( + /\\/g, + '/', + ) + catalogueSkills.push({ + file: rel, + rawDescription: fm.description, + summary: { + use: `${packageName}#${skillName}`, + packageName, + packageRoot, + packageVersion: '0.0.0', + packageSource: 'local', + skillName, + description: fm.description, + type: readScalarField(fm, 'type'), + }, + }) + } + const lineCount = content.split(/\r?\n/).length if (lineCount > 500) { errors.push({ @@ -563,6 +664,117 @@ async function runValidateCommandInternal( } } + const renderableSkills: Array = [] + const renderedSkills: Array<{ + description: string + input: CatalogueValidationSkill + }> = [] + for (const skill of catalogueSkills) { + try { + parseSkillUse(skill.summary.use) + } catch (error) { + reportCatalogueWarning({ + file: skill.file, + message: `malformed catalogue use: ${isSkillUseParseError(error) ? error.message : String(error)}`, + }) + continue + } + + renderableSkills.push(skill) + const renderedSkill = buildSessionCatalogue( + catalogueInput([skill.summary]), + ).skills[0] + const type = skill.summary.type + if (type !== undefined && !isKnownSkillType(type)) { + reportCatalogueWarning({ + file: skill.file, + message: `unknown metadata.type "${type}"; skill is ${renderedSkill ? 'included in' : 'excluded from'} the catalogue`, + }) + } else if (!renderedSkill) { + reportCatalogueWarning({ + file: skill.file, + message: `metadata.type "${type}" is excluded from the catalogue; agents will not see this skill`, + }) + } + if (!renderedSkill) continue + + renderedSkills.push({ + description: renderedSkill.description, + input: skill, + }) + const normalizedLength = [...normalizeWhitespace(skill.rawDescription)] + .length + const hasLocalPath = containsLocalPath( + normalizeWhitespace(skill.rawDescription), + ) + if (hasLocalPath) { + reportCatalogueWarning({ + file: skill.file, + message: 'catalogue description contains a local path and is blanked', + }) + } + if ( + !hasLocalPath && + normalizedLength > SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH + ) { + const renderedLength = [...renderedSkill.description].length + reportCatalogueWarning({ + file: skill.file, + message: `catalogue description is truncated from ${normalizedLength} to ${renderedLength} characters (${normalizedLength - renderedLength} lost)`, + }) + } + } + + const packageCatalogueInput = catalogueInput( + renderableSkills.map((skill) => skill.summary), + ) + const fullCatalogue = buildSessionCatalogue(packageCatalogueInput, { + maxSkills: renderableSkills.length, + }) + const fullCatalogueText = formatSessionCatalogue(fullCatalogue, { + maxBytes: Number.MAX_SAFE_INTEGER, + }) + const defaultCatalogue = buildSessionCatalogue(packageCatalogueInput) + const defaultCatalogueLines = new Set( + formatSessionCatalogue(defaultCatalogue).split('\n'), + ) + const skillsOutsideLimits = fullCatalogue.skills.filter( + (skill) => + !defaultCatalogueLines.has(`- ${skill.id}: ${skill.description}`), + ) + if (skillsOutsideLimits.length > 0) { + const packageJsonPath = + validateContext.targetPackageJsonPath ?? + join(packageRoot, 'package.json') + reportCatalogueWarning({ + file: displayPath(packageJsonPath), + message: `catalogue renders ${Buffer.byteLength(fullCatalogueText)}/${SESSION_CATALOGUE_MAX_BYTES} bytes; skills outside limits: ${skillsOutsideLimits.map((skill) => skill.id).join(', ')}`, + }) + } + + const skillsByDescription = new Map< + string, + Array + >() + for (const rendered of renderedSkills) { + const matches = skillsByDescription.get(rendered.description) ?? [] + matches.push(rendered.input) + skillsByDescription.set(rendered.description, matches) + } + for (const matches of skillsByDescription.values()) { + if (matches.length < 2) continue + for (const skill of matches) { + const duplicateUses = matches + .filter((match) => match !== skill) + .map((match) => match.summary.use) + .join(', ') + reportCatalogueWarning({ + file: skill.file, + message: `catalogue description duplicates ${duplicateUses}`, + }) + } + } + // In monorepos, _artifacts lives at the workspace root, not under each package's skills/ dir. const artifactsDir = join(skillsDir, '_artifacts') if (!validateContext.isMonorepo && existsSync(artifactsDir)) { @@ -576,7 +788,7 @@ async function runValidateCommandInternal( const artifactPath = join(artifactsDir, fileName) if (!existsSync(artifactPath)) { errors.push({ - file: relative(process.cwd(), artifactPath), + file: displayPath(artifactPath), message: 'Missing required artifact', }) continue @@ -585,7 +797,7 @@ async function runValidateCommandInternal( const content = readFileSync(artifactPath, 'utf8') if (content.trim().length === 0) { errors.push({ - file: relative(process.cwd(), artifactPath), + file: displayPath(artifactPath), message: 'Artifact file is empty', }) continue @@ -597,7 +809,7 @@ async function runValidateCommandInternal( } catch (err) { const detail = err instanceof Error ? err.message : String(err) errors.push({ - file: relative(process.cwd(), artifactPath), + file: displayPath(artifactPath), message: `Invalid YAML in artifact file: ${detail}`, }) } diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 81b3e3e7..841668e3 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -1,4 +1,5 @@ -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' +import { isPathWithin } from '../shared/utils.js' import { resolveProjectContext } from './project-context.js' import { readPackageJson } from './package-json.js' import type { ProjectContext } from './project-context.js' @@ -22,11 +23,6 @@ function normalizeExcludePatterns(value: unknown): Array { .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 @@ -43,7 +39,7 @@ export function getConfigDirs( const dirs: Array = [] let dir = cwd - while (isWithinOrEqual(dir, root)) { + while (isPathWithin(root, dir)) { dirs.push(dir) if (dir === root) break @@ -142,8 +138,18 @@ export function isPackageExcluded( ) } +export function findPackageExcludeMatch( + packageName: string, + matchers: Array, +): ExcludeMatcher | undefined { + return matchers.find( + (matcher) => + matcher.matchesSkill === undefined && matcher.matchesPackage(packageName), + ) +} + // A prefixed skill is loadable by its short alias too; an exclude must match either form. -function skillNameVariants( +export function skillNameVariants( packageName: string, skillName: string, ): Array { @@ -168,6 +174,19 @@ export function isSkillExcluded( }) } +export function findSkillExcludeMatch( + packageName: string, + skillName: string, + matchers: Array, +): ExcludeMatcher | undefined { + const variants = skillNameVariants(packageName, skillName) + return matchers.find((matcher) => { + if (!matcher.matchesPackage(packageName)) return false + if (matcher.matchesSkill === undefined) return true + return variants.some((variant) => matcher.matchesSkill!(variant)) + }) +} + export function warningMentionsPackage( warning: string, packageName: string, diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a95..4812d3bc 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,5 +1,6 @@ -import { isAbsolute, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { createIntentFsCache } from '../discovery/fs-cache.js' +import { isPathWithin } from '../shared/utils.js' import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' import { formatSkillUse, parseSkillUse } from '../skills/use.js' import { @@ -7,14 +8,17 @@ import { getEffectiveExcludePatterns, } from './excludes.js' import { rewriteLoadedSkillMarkdownDestinations } from './markdown.js' +import { computeSkillContentHash } from './lockfile/hash.js' +import { readIntentLockfile } from './lockfile/lockfile.js' import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, - isSourcePermitted, + compileSkillSourcePolicy, packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, + skillNotListedRefusal, } from './source-policy.js' import type { ResolveSkillResult } from '../skills/resolver.js' import type { IntentFsCache } from '../discovery/fs-cache.js' @@ -23,6 +27,7 @@ import type { ScanOptions, ScanScope } from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, + IntentExcludedSkillSummary, IntentSkillList, IntentSkillSummary, LoadedIntentSkill, @@ -33,6 +38,7 @@ import type { export type { IntentCoreErrorCode, IntentCoreOptions, + IntentExcludedSkillSummary, IntentPackageSummary, IntentSkillListDebug, IntentSkillList, @@ -101,13 +107,20 @@ export function listIntentSkills( const scanOptions = toScanOptions(options) const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) - const { hiddenSourceCount, hiddenSources, scan, excludePatterns } = - scanForPolicedIntents({ - cwd, - scanOptions: withFsCache(scanOptions, fsCache), - coreOptions: options, - context: projectContext, - }) + const { + hiddenSourceCount, + hiddenSources, + excludedSkills, + scan, + excludePatterns, + config, + sourcePolicy, + } = scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) const packages = scan.packages const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { @@ -121,6 +134,21 @@ export function listIntentSkills( description: skill.description, type: skill.type, framework: skill.framework, + why: options.why + ? config.mode === 'allow-all' + ? 'Allowed because intent.skills allows all sources' + : (() => { + const decision = sourcePolicy.explainPermitsSkill( + pkg.name, + skill.name, + pkg.kind, + pkg.skills, + ) + return decision.source + ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` + : undefined + })() + : undefined, } }), ) @@ -142,6 +170,24 @@ export function listIntentSkills( conflicts: scan.conflicts, } + if (options.why) { + result.excludedSkills = excludedSkills.map( + ({ package: pkg, skill, pattern }): IntentExcludedSkillSummary => ({ + 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, + why: `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, + excluded: true, + }), + ) + } + if (options.debug) { result.debug = { cwd, @@ -163,22 +209,13 @@ 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, readFs: ReadFs, + kind: 'npm' | 'workspace', + lockRoot: string, debug?: LoadedIntentSkillDebug, ): { realPackageRoot: string @@ -201,13 +238,45 @@ function toResolvedIntentSkill( resolveFromCwd(cwd, resolved.packageRoot), ) - if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { + if (!isPathWithin(realPackageRoot, realResolvedPath)) { throw new IntentCoreError( 'skill-path-outside-package', `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, ) } + const lock = readIntentLockfile(join(lockRoot, 'intent.lock')) + if (lock.status === 'found') { + const skillDirectory = dirname(realResolvedPath) + const relativeSkillDirectory = relative(realPackageRoot, skillDirectory) + const skillPath = + sep === '\\' + ? relativeSkillDirectory.split(sep).join('/') + : relativeSkillDirectory + const lockedSkill = lock.lockfile.sources + .find( + (source) => source.kind === kind && source.id === resolved.packageName, + ) + ?.skills.find((skill) => skill.path === skillPath) + if (!lockedSkill) { + throw new IntentCoreError( + 'skill-not-accepted', + `Cannot load skill use "${use}": skill is not accepted in intent.lock.`, + ) + } + const contentHash = computeSkillContentHash({ + packageRoot: realPackageRoot, + skillDir: skillDirectory, + fs: readFs, + }) + if (contentHash !== lockedSkill.contentHash) { + throw new IntentCoreError( + 'skill-content-changed', + `Cannot load skill use "${use}": installed content does not match intent.lock.`, + ) + } + } + const result: ResolvedIntentSkill = { path: resolved.path, packageRoot: resolved.packageRoot, @@ -283,29 +352,50 @@ function resolveIntentSkillInCwd( const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) + const lockRoot = + projectContext.workspaceRoot ?? projectContext.packageRoot ?? cwd const excludePatterns = getEffectiveExcludePatterns(options, projectContext) const excludeMatchers = compileExcludePatterns(excludePatterns) const config = readSkillSourcesConfig(cwd, projectContext) + const sourcePolicy = compileSkillSourcePolicy(config) - const refusal = checkLoadAllowed(use, parsedUse, { config, excludeMatchers }) + const refusal = checkLoadAllowed(use, parsedUse, { + sourcePolicy, + excludeMatchers, + }) if (refusal) { throw new IntentCoreError(refusal.code, refusal.message) } const scanOptions = toScanOptions(options) const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath( - parsedUse, - options, - projectContext, - cwd, - fsCache, - ) + const skipFastPath = + config.mode === 'explicit' && + sourcePolicy.matchers.some( + (matcher) => + matcher.matchesSkill !== undefined && + matcher.matchesPackage(parsedUse.packageName), + ) + const fastPathResolved = skipFastPath + ? null + : resolveSkillUseFastPath(parsedUse, options, projectContext, cwd, fsCache) if (fastPathResolved) { + if (!sourcePolicy.permits(parsedUse.packageName, fastPathResolved.kind)) { + const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } if ( - !isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind) + !sourcePolicy.permitsSkill( + parsedUse.packageName, + fastPathResolved.skillName, + fastPathResolved.kind, + ) ) { - const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + const lateRefusal = skillNotListedRefusal( + use, + parsedUse.packageName, + parsedUse.skillName, + ) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } return toResolvedIntentSkill( @@ -313,6 +403,8 @@ function resolveIntentSkillInCwd( use, fastPathResolved, fsCache.getReadFs(), + fastPathResolved.kind, + lockRoot, options.debug ? createLoadedSkillDebug({ cwd, @@ -326,11 +418,16 @@ function resolveIntentSkillInCwd( ) } - const { scan: scanResult, droppedNames } = scanForPolicedIntents({ + const { + scan: scanResult, + discoveredPackages, + droppedNames, + } = scanForPolicedIntents({ cwd, scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, context: projectContext, + config, }) if (droppedNames.includes(parsedUse.packageName)) { const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) @@ -338,21 +435,50 @@ function resolveIntentSkillInCwd( } let resolved: ReturnType try { - resolved = resolveSkillUse(use, scanResult) + resolved = resolveSkillUse(use, { + ...scanResult, + packages: discoveredPackages, + }) } catch (err) { - if (err instanceof ResolveSkillUseError) { - throw new IntentCoreError(err.code, err.message, { - suggestedSkills: err.suggestedSkills, - }) + if (!(err instanceof ResolveSkillUseError)) throw err + try { + resolveSkillUse(use, scanResult) + } catch (filteredError) { + if (filteredError instanceof ResolveSkillUseError) { + throw new IntentCoreError(filteredError.code, filteredError.message, { + suggestedSkills: filteredError.suggestedSkills, + }) + } + throw filteredError } throw err } + const resolvedPackage = discoveredPackages.find( + (pkg) => pkg.name === resolved.packageName, + )! + if ( + !sourcePolicy.permitsSkill( + resolved.packageName, + resolved.skillName, + resolved.kind, + resolvedPackage.skills, + ) + ) { + const lateRefusal = skillNotListedRefusal( + use, + parsedUse.packageName, + parsedUse.skillName, + ) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } return toResolvedIntentSkill( cwd, use, resolved, fsCache.getReadFs(), + resolved.kind, + lockRoot, options.debug ? createLoadedSkillDebug({ cwd, diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index b69dfc63..7cf198c3 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -181,14 +181,10 @@ function getWorkspaceLoadFastPathCandidateDirs( return candidates } -export interface FastPathResolveResult extends ResolveSkillResult { - kind: 'npm' | 'workspace' -} - function resolveScannedPackageSkill( scanned: ReturnType, parsedUse: SkillUse, -): FastPathResolveResult | null { +): ResolveSkillResult | null { const pkg = scanned.package if (!pkg || pkg.name !== parsedUse.packageName) return null @@ -219,7 +215,7 @@ function resolveFromPackageRoots( parsedUse: SkillUse, cwd: string, fsCache: IntentFsCache, -): FastPathResolveResult | null { +): ResolveSkillResult | null { for (const packageRoot of packageRoots) { const scanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, @@ -253,7 +249,7 @@ export function resolveSkillUseFastPath( context = resolveProjectContext({ cwd: process.cwd() }), cwd = context.cwd, fsCache = createIntentFsCache(), -): FastPathResolveResult | null { +): ResolveSkillResult | null { if (options.globalOnly) return null if (shouldSkipFastPathForYarnPnp(context, cwd)) return null diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts new file mode 100644 index 00000000..a225dafd --- /dev/null +++ b/packages/intent/src/core/lockfile/hash.ts @@ -0,0 +1,224 @@ +import { isUtf8 } from 'node:buffer' +import { createHash } from 'node:crypto' +import { isAbsolute, join, resolve } from 'node:path' +import { isPathWithin, nodeReadFs } from '../../shared/utils.js' +import type { Dirent } from 'node:fs' +import type { ReadFs } from '../../shared/utils.js' + +const HASH_LIMITS = { + maxRecursionDepth: 32, + maxEntryCount: 1000, + maxFileCount: 1000, + maxFileBytes: 4 * 1024 * 1024, + maxTotalBytes: 16 * 1024 * 1024, +} as const + +export interface ComputeSkillContentHashOptions { + packageRoot: string + skillDir: string + fs?: ReadFs +} + +type HashEntry = { path: string; content: Buffer } +const HASH_ENTRY_SEPARATOR = Buffer.from([0]) + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function normalizeContent(content: Buffer): Buffer { + if (content.includes(0) || !isUtf8(content)) return content + return Buffer.from(content.toString('utf8').replace(/\r\n|\r/g, '\n'), 'utf8') +} + +function resolveInPackage( + fs: ReadFs, + filePath: string, + packageRoot: string, + label: string, +): string { + let resolved: string + try { + resolved = fs.realpathSync(filePath) + } catch (error) { + throw new Error( + `Failed to resolve ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + if (!isPathWithin(packageRoot, resolved)) { + throw new Error(`${label} escapes package root through a symlink.`) + } + return resolved +} + +function hashEntries(entries: ReadonlyArray): string { + const hash = createHash('sha256') + for (const entry of [...entries].sort((a, b) => + compareStrings(a.path, b.path), + )) { + const path = Buffer.from(entry.path, 'utf8') + hash.update(String(path.length), 'ascii') + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(path) + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(String(entry.content.length), 'ascii') + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(entry.content) + hash.update(HASH_ENTRY_SEPARATOR) + } + return `sha256-${hash.digest('hex')}` +} + +function readBoundedFile(fs: ReadFs, filePath: string): Buffer { + const stats = fs.lstatSync(filePath) + if (stats.size > HASH_LIMITS.maxFileBytes) { + throw new Error(`Hash file size limit exceeded by ${filePath}.`) + } + const { openSync, readSync, closeSync } = fs + if (!openSync || !readSync || !closeSync) { + const content = fs.readFileSync(filePath) + if (content.length > HASH_LIMITS.maxFileBytes) { + throw new Error(`Hash file size limit exceeded by ${filePath}.`) + } + return content + } + + const descriptor = openSync(filePath, 'r') + const chunks: Array = [] + let total = 0 + try { + for (;;) { + const remaining = HASH_LIMITS.maxFileBytes + 1 - total + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)) + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null) + if (bytesRead === 0) break + total += bytesRead + if (total > HASH_LIMITS.maxFileBytes) { + throw new Error(`Hash file size limit exceeded by ${filePath}.`) + } + chunks.push(buffer.subarray(0, bytesRead)) + } + } finally { + closeSync(descriptor) + } + return Buffer.concat(chunks, total) +} + +function readDirectoryEntries(fs: ReadFs, path: string): Array> { + if (!fs.opendirSync) { + return fs.readdirSync(path, { encoding: 'utf8', withFileTypes: true }) + } + + const directory = fs.opendirSync(path, { encoding: 'utf8' }) + const entries: Array> = [] + try { + for (;;) { + const entry = directory.readSync() + if (!entry) break + entries.push(entry) + if (entries.length > HASH_LIMITS.maxEntryCount) { + throw new Error('Hash entry count limit exceeded.') + } + } + } finally { + directory.closeSync() + } + return entries +} + +export function computeSkillContentHash({ + packageRoot, + skillDir, + fs = nodeReadFs, +}: ComputeSkillContentHashOptions): string { + const realPackageRoot = fs.realpathSync(resolve(packageRoot)) + const requestedSkillDir = isAbsolute(skillDir) + ? resolve(skillDir) + : resolve(packageRoot, skillDir) + const realSkillDir = resolveInPackage( + fs, + requestedSkillDir, + realPackageRoot, + 'skill directory', + ) + if (!fs.lstatSync(realSkillDir).isDirectory()) { + throw new Error('Skill directory is not a directory.') + } + const hashState: { + entries: Array + entryCount: number + totalBytes: number + } = { + entries: [], + entryCount: 0, + totalBytes: 0, + } + + const readFile = (physicalPath: string, logicalPath: string): void => { + const realPath = resolveInPackage( + fs, + physicalPath, + realPackageRoot, + logicalPath, + ) + if (!fs.lstatSync(realPath).isFile()) { + throw new Error(`${logicalPath} is not a regular file.`) + } + const content = readBoundedFile(fs, realPath) + hashState.totalBytes += content.length + if (hashState.totalBytes > HASH_LIMITS.maxTotalBytes) + throw new Error('Hash total size limit exceeded.') + if (hashState.entries.length + 1 > HASH_LIMITS.maxFileCount) + throw new Error('Hash file count limit exceeded.') + hashState.entries.push({ + path: logicalPath, + content: normalizeContent(content), + }) + } + + const collect = ( + physicalDir: string, + logicalDir: string, + depth: number, + ): void => { + if (depth > HASH_LIMITS.maxRecursionDepth) + throw new Error('Hash recursion depth limit exceeded.') + const dirEntries = readDirectoryEntries(fs, physicalDir) + for (const entry of [...dirEntries].sort((a, b) => + compareStrings(a.name, b.name), + )) { + hashState.entryCount += 1 + if (hashState.entryCount > HASH_LIMITS.maxEntryCount) + throw new Error('Hash entry count limit exceeded.') + const logicalPath = logicalDir + ? `${logicalDir}/${entry.name}` + : entry.name + const physicalPath = join(physicalDir, entry.name) + const realPath = resolveInPackage( + fs, + physicalPath, + realPackageRoot, + logicalPath, + ) + const stats = fs.lstatSync(realPath) + if (stats.isDirectory()) { + collect(realPath, logicalPath, depth + 1) + } else if (stats.isFile()) { + readFile(realPath, logicalPath) + } else { + throw new Error(`${logicalPath} is not a regular file or directory.`) + } + } + } + + const skillFile = resolveInPackage( + fs, + join(realSkillDir, 'SKILL.md'), + realPackageRoot, + 'SKILL.md', + ) + if (!fs.lstatSync(skillFile).isFile()) + throw new Error('SKILL.md is not a regular file.') + collect(realSkillDir, '', 0) + return hashEntries(hashState.entries) +} diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts new file mode 100644 index 00000000..2d8ebef3 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -0,0 +1,121 @@ +import { sourceIdentityKey } from '../types.js' +import { canonicalIntentLockfile, classifyLockfileHash } from './lockfile.js' +import type { + IntentLockfileSkill, + IntentLockfileSource, + ReadIntentLockfileResult, +} from './lockfile.js' + +interface ChangedLockfileSkill { + path: string + lockedContentHash: string + currentContentHash: string +} + +interface ChangedLockfileSource { + kind: IntentLockfileSource['kind'] + id: string + addedSkills: Array + removedSkills: Array + changedSkills: Array +} + +export interface LockfileDiff { + lockfile: 'missing' | 'found' + addedSources: Array + removedSources: Array + changedSources: Array + isClean: boolean +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function diffLockfileSources( + currentSources: ReadonlyArray, + locked: ReadIntentLockfileResult, +): LockfileDiff { + if (locked.status === 'missing') { + return { + lockfile: 'missing', + addedSources: [], + removedSources: [], + changedSources: [], + isClean: false, + } + } + const current = canonicalIntentLockfile({ + lockfileVersion: 1, + sources: [...currentSources], + }).sources + const currentByKey = new Map( + current.map((source) => [sourceIdentityKey(source), source]), + ) + const lockedByKey = new Map( + locked.lockfile.sources.map((source) => [ + sourceIdentityKey(source), + source, + ]), + ) + const addedSources = current.filter( + (source) => !lockedByKey.has(sourceIdentityKey(source)), + ) + const removedSources = locked.lockfile.sources.filter( + (source) => !currentByKey.has(sourceIdentityKey(source)), + ) + const changedSources: Array = [] + for (const lockedSource of locked.lockfile.sources) { + const currentSource = currentByKey.get(sourceIdentityKey(lockedSource)) + if (!currentSource) continue + const currentSkills = new Map( + currentSource.skills.map((skill) => [skill.path, skill]), + ) + const lockedSkills = new Map( + lockedSource.skills.map((skill) => [skill.path, skill]), + ) + const addedSkills = currentSource.skills.filter( + (skill) => !lockedSkills.has(skill.path), + ) + const removedSkills = lockedSource.skills.filter( + (skill) => !currentSkills.has(skill.path), + ) + const changedSkills = lockedSource.skills.flatMap((skill) => { + const currentSkill = currentSkills.get(skill.path) + if ( + !currentSkill || + classifyLockfileHash(currentSkill.contentHash, skill.contentHash) !== + 'changed' + ) { + return [] + } + return [ + { + path: skill.path, + lockedContentHash: skill.contentHash, + currentContentHash: currentSkill.contentHash, + }, + ] + }) + if (addedSkills.length || removedSkills.length || changedSkills.length) { + changedSources.push({ + kind: currentSource.kind, + id: currentSource.id, + addedSkills, + removedSkills, + changedSkills, + }) + } + } + changedSources.sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ) + return { + lockfile: 'found', + addedSources, + removedSources, + changedSources, + isClean: + !addedSources.length && !removedSources.length && !changedSources.length, + } +} diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts new file mode 100644 index 00000000..343f659a --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -0,0 +1,107 @@ +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { nodeReadFs, toPosixPath } from '../../shared/utils.js' +import { validateSkillPath } from '../skill-path.js' +import { sourceIdentityKey } from '../types.js' +import { computeSkillContentHash } from './hash.js' +import type { IntentLockfileSkill, IntentLockfileSource } from './lockfile.js' +import type { IntentPackage } from '../../shared/types.js' +import type { ReadFs } from '../../shared/utils.js' + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function packageRelativeSkillFile( + pkg: IntentPackage, + skillPath: string, +): string { + if (isAbsolute(skillPath)) return resolve(skillPath) + + const normalizedSkillPath = toPosixPath(skillPath) + const nodeModulesPrefix = `node_modules/${pkg.name}/` + if (normalizedSkillPath.startsWith(nodeModulesPrefix)) { + return resolve( + pkg.packageRoot, + normalizedSkillPath.slice(nodeModulesPrefix.length), + ) + } + + const packageSegments = toPosixPath(resolve(pkg.packageRoot)).split('/') + const skillSegments = normalizedSkillPath.split('/') + const compareSegment = + sep === '\\' + ? (left: string, right: string) => + left.toLowerCase() === right.toLowerCase() + : (left: string, right: string) => left === right + for (let start = 0; start < packageSegments.length; start++) { + const suffix = packageSegments.slice(start) + if ( + suffix.length < skillSegments.length && + suffix.every((segment, index) => + compareSegment(segment, skillSegments[index]!), + ) + ) { + return resolve(pkg.packageRoot, ...skillSegments.slice(suffix.length)) + } + } + + return resolve(pkg.packageRoot, skillPath) +} + +function skillDirectoryPath( + pkg: IntentPackage, + skillPath: string, +): { absolute: string; relative: string } { + const absoluteSkillFile = packageRelativeSkillFile(pkg, skillPath) + const absolute = dirname(absoluteSkillFile) + const relativePath = relative(resolve(pkg.packageRoot), absolute) + const packageRelativePath = + sep === '/' ? relativePath : relativePath.split(sep).join('/') + return { absolute, relative: validateSkillPath(packageRelativePath) } +} + +export function buildCurrentLockfileSkill( + pkg: IntentPackage, + skill: IntentPackage['skills'][number], + fs: ReadFs = nodeReadFs, +): IntentLockfileSkill { + const path = skillDirectoryPath(pkg, skill.path) + return { + path: path.relative, + contentHash: computeSkillContentHash({ + packageRoot: pkg.packageRoot, + skillDir: path.absolute, + fs, + }), + } +} + +export function buildCurrentLockfileSources( + packages: ReadonlyArray, + fs: ReadFs = nodeReadFs, +): Array { + const sources = packages.map((pkg) => ({ + kind: pkg.kind, + id: pkg.name, + skills: pkg.skills + .map((skill) => buildCurrentLockfileSkill(pkg, skill, fs)) + .sort((a, b) => compareStrings(a.path, b.path)), + })) + const identities = new Set() + for (const source of sources) { + const identity = sourceIdentityKey(source) + if (identities.has(identity)) + throw new Error( + `Duplicate skill source identity: ${source.kind}:${source.id}.`, + ) + identities.add(identity) + const paths = new Set(source.skills.map((skill) => skill.path)) + if (paths.size !== source.skills.length) + throw new Error( + `Duplicate skill path for source: ${source.kind}:${source.id}.`, + ) + } + return sources.sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ) +} diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts new file mode 100644 index 00000000..e05dabeb --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -0,0 +1,186 @@ +import { readFileSync } from 'node:fs' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { validateSkillPaths } from '../skill-path.js' +import { sourceIdentityKey } from '../types.js' + +export interface IntentLockfileSkill { + path: string + contentHash: string +} + +export interface IntentLockfileSource { + kind: 'npm' | 'workspace' + id: string + skills: Array +} + +export interface IntentLockfile { + lockfileVersion: 1 + sources: Array +} + +export type ReadIntentLockfileResult = + | { status: 'missing' } + | { status: 'found'; lockfile: IntentLockfile } + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function classifyLockfileHash( + currentHash: string | undefined, + lockedHash: string | undefined, +): 'new' | 'accepted' | 'changed' { + if (currentHash === undefined || lockedHash === undefined) return 'new' + return currentHash === lockedHash ? 'accepted' : 'changed' +} + +function assertRecord(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid intent.lock ${label}: expected an object.`) + } + return value as Record +} + +function assertFields( + value: Record, + allowed: ReadonlyArray, + label: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + throw new Error(`Invalid intent.lock ${label}: unknown field "${key}".`) + } + } +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string' || value === '') { + throw new Error( + `Invalid intent.lock ${label}: expected a non-empty string.`, + ) + } + return value +} + +function canonicalSource(source: IntentLockfileSource): IntentLockfileSource { + const paths = source.skills.map((skill) => skill.path) + validateSkillPaths(paths) + return { + kind: source.kind, + id: source.id, + skills: source.skills + .map((skill) => ({ + path: skill.path, + contentHash: skill.contentHash, + })) + .sort((a, b) => compareStrings(a.path, b.path)), + } +} + +export function canonicalIntentLockfile( + lockfile: IntentLockfile, +): IntentLockfile { + const sources = lockfile.sources.map(canonicalSource) + const seen = new Set() + for (const source of sources) { + const key = sourceIdentityKey(source) + if (seen.has(key)) { + throw new Error( + `Duplicate intent.lock source: ${source.kind}:${source.id}.`, + ) + } + seen.add(key) + } + return { + lockfileVersion: 1, + sources: sources.sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ), + } +} + +export function parseIntentLockfile(content: string): IntentLockfile { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch (error) { + throw new Error( + `Invalid intent.lock JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } + const root = assertRecord(parsed, 'root') + assertFields(root, ['lockfileVersion', 'sources'], 'root') + if (typeof root.lockfileVersion === 'number' && root.lockfileVersion > 1) { + throw new Error( + `intent.lock declares lockfileVersion ${root.lockfileVersion}, which this @tanstack/intent cannot read. Upgrade @tanstack/intent.`, + ) + } + if (root.lockfileVersion !== 1 || !Array.isArray(root.sources)) { + throw new Error('Invalid intent.lock root.') + } + return canonicalIntentLockfile({ + lockfileVersion: 1, + sources: root.sources.map((value, sourceIndex) => { + const source = assertRecord(value, `sources[${sourceIndex}]`) + assertFields(source, ['kind', 'id', 'skills'], `sources[${sourceIndex}]`) + if (!Array.isArray(source.skills)) { + throw new Error(`Invalid intent.lock sources[${sourceIndex}].skills.`) + } + if (source.kind !== 'npm' && source.kind !== 'workspace') { + throw new Error( + `intent.lock contains a "${String(source.kind)}" source, which this @tanstack/intent cannot read. Upgrade @tanstack/intent if a newer version wrote this lockfile.`, + ) + } + return { + kind: source.kind, + id: assertString(source.id, `sources[${sourceIndex}].id`), + skills: source.skills.map((skill, skillIndex) => { + const record = assertRecord( + skill, + `sources[${sourceIndex}].skills[${skillIndex}]`, + ) + assertFields( + record, + ['path', 'contentHash'], + `sources[${sourceIndex}].skills[${skillIndex}]`, + ) + return { + path: assertString( + record.path, + `sources[${sourceIndex}].skills[${skillIndex}].path`, + ), + contentHash: assertString( + record.contentHash, + `sources[${sourceIndex}].skills[${skillIndex}].contentHash`, + ), + } + }), + } + }), + }) +} + +export function serializeIntentLockfile(lockfile: IntentLockfile): string { + return `${JSON.stringify(canonicalIntentLockfile(lockfile), null, 2)}\n` +} + +export function readIntentLockfile(filePath: string): ReadIntentLockfileResult { + try { + return { + status: 'found', + lockfile: parseIntentLockfile(readFileSync(filePath, 'utf8')), + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { status: 'missing' } + throw error + } +} + +export function writeIntentLockfile( + filePath: string, + lockfile: IntentLockfile, +): void { + writeTextFileAtomic(filePath, serializeIntentLockfile(lockfile)) +} diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts index 2549587f..4bd653cb 100644 --- a/packages/intent/src/core/project-context.ts +++ b/packages/intent/src/core/project-context.ts @@ -1,5 +1,6 @@ import { existsSync, statSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { isPathWithin } from '../shared/utils.js' import { findWorkspaceRoot, readWorkspacePatterns, @@ -86,7 +87,7 @@ function resolveTargetSkillsDir( const packageSkillsDir = join(packageRoot, 'skills') - if (isWithinOrEqual(targetPath, packageSkillsDir)) { + if (isPathWithin(packageSkillsDir, targetPath)) { return packageSkillsDir } @@ -96,8 +97,3 @@ function resolveTargetSkillsDir( return null } - -function isWithinOrEqual(path: string, parentDir: string): boolean { - const rel = relative(parentDir, path) - return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')) -} diff --git a/packages/intent/src/core/skill-path.ts b/packages/intent/src/core/skill-path.ts new file mode 100644 index 00000000..b681a4b0 --- /dev/null +++ b/packages/intent/src/core/skill-path.ts @@ -0,0 +1,39 @@ +function assertCanonicalPackageRelativePath(path: string): void { + if (typeof path !== 'string' || path === '') { + throw new Error('Skill path must be a non-empty string.') + } + if ( + path.includes('\0') || + path.includes('\\') || + path.startsWith('/') || + /^[a-zA-Z]:/.test(path) || + path.startsWith('//') + ) { + throw new Error(`Invalid skill path: ${path}`) + } + if ( + path + .split('/') + .some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid skill path: ${path}`) + } +} + +export function validateSkillPaths( + paths: ReadonlyArray, +): Array { + const seen = new Set() + for (const path of paths) { + assertCanonicalPackageRelativePath(path) + if (seen.has(path)) { + throw new Error(`Duplicate skill path: ${path}`) + } + seen.add(path) + } + return [...paths] +} + +export function validateSkillPath(path: string): string { + return validateSkillPaths([path])[0]! +} diff --git a/packages/intent/src/core/skill-sources.ts b/packages/intent/src/core/skill-sources.ts index 303fc4b4..9604871d 100644 --- a/packages/intent/src/core/skill-sources.ts +++ b/packages/intent/src/core/skill-sources.ts @@ -1,6 +1,8 @@ // Static-discovery invariant: this module only inspects strings. It never // resolves, requires, or executes any discovered package. +import { compileWildcardPattern } from './excludes.js' + /** * Exact entries keep the `kind` + `id` identity M2's lockfile reuses. Patterns * select multiple discovered identities and remain distinct from exact entries. @@ -8,18 +10,13 @@ * parse time) but is defined here so M2 builds on this shape. */ type SkillSource = - | ({ raw: string; kind: 'npm' | 'workspace' } & ( + | ({ raw: string; kind: 'npm' | 'workspace'; skill?: string } & ( | { id: string } | { pattern: string } )) | { raw: string; id: string; kind: 'git'; ref: string } -/** - * `absent` (key missing, v0 upgrade path) and `empty` (`[]`) are deliberately - * distinct: absent is show-all, empty is deny-all. - */ export type SkillSourcesConfig = - | { mode: 'absent' } | { mode: 'empty' } | { mode: 'allow-all' } | { mode: 'explicit'; sources: Array } @@ -52,7 +49,7 @@ export function isSkillSourcesParseError( */ export function parseSkillSources(value: unknown): SkillSourcesConfig { if (value === undefined || value === null) { - return { mode: 'absent' } + return { mode: 'empty' } } if (!Array.isArray(value)) { @@ -119,12 +116,25 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { } const selector = 'pattern' in parsed ? parsed.pattern : parsed.id - const identity = `${parsed.kind}\u0000${selector}` + const skill = 'skill' in parsed ? parsed.skill : undefined + const identity = `${parsed.kind}\u0000${selector}\u0000${skill ?? ''}` if (seenIdentity.has(identity)) continue seenIdentity.add(identity) sources.push(parsed) } + if (!allowAll) { + for (const source of sources) { + const subsuming = findPackageLevelEntryCovering(source, sources) + if (subsuming) { + issues.push({ + raw: source.raw, + message: `Entry "${source.raw.trim()}" is ambiguous: "${subsuming.raw.trim()}" already allows every skill in that package. Keep one.`, + }) + } + } + } + if (issues.length > 0) { throw new SkillSourcesParseError(issues) } @@ -136,6 +146,27 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { return { mode: 'explicit', sources } } +function findPackageLevelEntryCovering( + source: SkillSource, + sources: Array, +): SkillSource | undefined { + if ( + source.kind === 'git' || + !('id' in source) || + source.skill === undefined + ) { + return undefined + } + const { id, kind } = source + return sources.find((other) => { + if (other === source || other.kind === 'git') return false + if (other.kind !== kind || other.skill !== undefined) return false + return 'pattern' in other + ? compileWildcardPattern(other.pattern)(id) + : other.id === id + }) +} + function parseEntry( raw: string, trimmed: string, @@ -144,10 +175,12 @@ function parseEntry( // npm names cannot contain ':', so a colon-free entry is unambiguously npm. if (colon === -1) { - const invalid = validateId(trimmed) + const split = splitSkillSelector(raw, trimmed, trimmed) + if ('message' in split) return split + const invalid = validateId(split.packageSegment) if (invalid) return { raw, message: `Invalid npm source "${trimmed}": ${invalid}` } - return packageSource(raw, trimmed, 'npm') + return packageSource(raw, split.packageSegment, 'npm', split.skill) } const prefix = trimmed.slice(0, colon) @@ -161,14 +194,16 @@ function parseEntry( message: `Workspace source "${trimmed}" is missing a package name.`, } } - const invalid = validateId(rest) + const split = splitSkillSelector(raw, trimmed, rest) + if ('message' in split) return split + const invalid = validateId(split.packageSegment) if (invalid) { return { raw, message: `Invalid workspace source "${trimmed}": ${invalid}`, } } - return packageSource(raw, rest, 'workspace') + return packageSource(raw, split.packageSegment, 'workspace', split.skill) } case 'git': return { @@ -183,18 +218,58 @@ function parseEntry( } } +function splitSkillSelector( + raw: string, + trimmed: string, + selector: string, +): { packageSegment: string; skill: string | null } | SkillSourceIssue { + const hash = selector.indexOf('#') + if (hash === -1) return { packageSegment: selector, skill: null } + + const packageSegment = selector.slice(0, hash) + const skillSegment = selector.slice(hash + 1) + + if (skillSegment.includes('#')) { + return { raw, message: `Entry "${trimmed}" has more than one "#".` } + } + if (packageSegment === '') { + return { + raw, + message: `Entry "${trimmed}" is missing a package name before "#".`, + } + } + if (skillSegment === '') { + return { + raw, + message: `Entry "${trimmed}" is missing a skill name after "#".`, + } + } + if (/\s/.test(skillSegment)) { + return { + raw, + message: `Invalid skill selector in "${trimmed}": skill names cannot contain whitespace.`, + } + } + + return { + packageSegment, + skill: skillSegment.replace(/\*+/g, '*') === '*' ? null : skillSegment, + } +} + function packageSource( raw: string, id: string, kind: 'npm' | 'workspace', + skill: string | null, ): SkillSource { - return id.includes('*') ? { raw, pattern: id, kind } : { raw, id, kind } + const source = id.includes('*') + ? { raw, pattern: id, kind } + : { raw, id, kind } + return skill === null ? source : { ...source, skill } } function validateId(id: string): string | null { - if (id.includes('#')) { - return 'skill-level granularity (#) is not supported in intent.skills (it is package-level); use intent.exclude for skill-level control.' - } if (/\s/.test(id)) { return 'package names cannot contain whitespace.' } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 929fd034..890355dc 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -4,16 +4,20 @@ import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' import { compileExcludePatterns, compileWildcardPattern, + findPackageExcludeMatch, + findSkillExcludeMatch, getConfigDirs, getEffectiveExcludePatterns, isPackageExcluded, isSkillExcluded, + skillNameVariants, warningMentionsPackage, } from './excludes.js' import { readPackageJson } from './package-json.js' import { parseSkillSources } from './skill-sources.js' import { resolveProjectContext } from './project-context.js' import type { SkillUse } from '../skills/use.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' import type { ExcludeMatcher } from './excludes.js' import type { ProjectContext } from './project-context.js' @@ -26,9 +30,6 @@ import type { export { ALLOW_ALL_NOTICE } -export const MIGRATION_NOTICE = - 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.' - export const EMPTY_NOTE = 'intent.skills is empty — no skill sources are permitted.' @@ -42,6 +43,7 @@ type LoadRefusalCode = | 'package-excluded' | 'package-not-listed' | 'skill-excluded' + | 'skill-not-listed' export interface LoadRefusal { code: LoadRefusalCode @@ -53,19 +55,52 @@ type ExplicitSkillSource = Extract< { mode: 'explicit' } >['sources'][number] +interface SkillSourcePolicyDecision { + permitted: boolean + source: ExplicitSkillSource | null +} + +type PackageSkills = ReadonlyArray + interface SkillSourceMatcher { source: ExplicitSkillSource matchesPackage: ( packageName: string, packageKind?: 'npm' | 'workspace', ) => boolean + matchesSkill?: ( + packageName: string, + skillName: string, + packageSkills?: PackageSkills, + ) => boolean +} + +export interface CompiledSkillSourcePolicy { + matchers: Array + permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean + permitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + packageSkills?: PackageSkills, + ) => boolean + explainPermitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + packageSkills?: PackageSkills, + ) => SkillSourcePolicyDecision } function compileSkillSourceMatcher( source: ExplicitSkillSource, ): SkillSourceMatcher { if (source.kind === 'git') { - return { source, matchesPackage: () => false } + return { + source, + matchesPackage: () => false, + matchesSkill: undefined, + } } const matchesName = @@ -73,45 +108,82 @@ function compileSkillSourceMatcher( ? compileWildcardPattern(source.pattern) : (packageName: string) => source.id === packageName + const matchesSkillName = + source.skill === undefined + ? undefined + : compileWildcardPattern(source.skill) + return { source, matchesPackage: (packageName, packageKind) => (packageKind === undefined || source.kind === packageKind) && matchesName(packageName), + matchesSkill: + matchesSkillName === undefined + ? undefined + : (packageName, skillName, packageSkills) => { + if (matchesSkillName(skillName)) return true + + return skillNameVariants(packageName, skillName).some( + (variant) => + variant !== skillName && + !packageSkills?.some((skill) => skill.name === variant) && + matchesSkillName(variant), + ) + }, } } -function compileSkillSourcePolicy(config: SkillSourcesConfig): { - matchers: Array - permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean -} { +export function compileSkillSourcePolicy( + config: SkillSourcesConfig, +): CompiledSkillSourcePolicy { switch (config.mode) { - case 'absent': case 'allow-all': - return { matchers: [], permits: () => true } + return { + matchers: [], + permits: () => true, + permitsSkill: () => true, + explainPermitsSkill: () => ({ permitted: true, source: null }), + } case 'empty': - return { matchers: [], permits: () => false } + return { + matchers: [], + permits: () => false, + permitsSkill: () => false, + explainPermitsSkill: () => ({ permitted: false, source: null }), + } case 'explicit': { const matchers = config.sources.map(compileSkillSourceMatcher) + const explainPermitsSkill = ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + packageSkills?: PackageSkills, + ): SkillSourcePolicyDecision => { + const matcher = matchers.find( + (candidate) => + candidate.matchesPackage(packageName, packageKind) && + (candidate.matchesSkill === undefined || + candidate.matchesSkill(packageName, skillName, packageSkills)), + ) + return { + permitted: matcher !== undefined, + source: matcher?.source ?? null, + } + } return { matchers, permits: (packageName, packageKind) => matchers.some((matcher) => matcher.matchesPackage(packageName, packageKind), ), + permitsSkill: (...args) => explainPermitsSkill(...args).permitted, + explainPermitsSkill, } } } } -export function isSourcePermitted( - config: SkillSourcesConfig, - packageName: string, - packageKind?: 'npm' | 'workspace', -): boolean { - return compileSkillSourcePolicy(config).permits(packageName, packageKind) -} - export function packageNotListedRefusal( use: string, packageName: string, @@ -122,15 +194,26 @@ export function packageNotListedRefusal( } } +export function skillNotListedRefusal( + use: string, + packageName: string, + skillName: string, +): LoadRefusal { + return { + code: 'skill-not-listed', + message: `Cannot load skill use "${use}": skill "${packageName}#${skillName}" is not listed in intent.skills.`, + } +} + export function checkLoadAllowed( use: string, parsed: SkillUse, params: { - config: SkillSourcesConfig + sourcePolicy: CompiledSkillSourcePolicy excludeMatchers: Array }, ): LoadRefusal | null { - const { config, excludeMatchers } = params + const { sourcePolicy, excludeMatchers } = params const { packageName, skillName } = parsed if (isPackageExcluded(packageName, excludeMatchers)) { @@ -140,13 +223,15 @@ export function checkLoadAllowed( } } - // Name-only pre-check: kind isn't known yet at this point in the load path. - // A late, kind-aware isSourcePermitted call happens once resolution reveals - // the actual kind (see intent-core.ts). - if (!isSourcePermitted(config, packageName)) { + // Name-only pre-check: kind isn't known until resolution. + if (!sourcePolicy.permits(packageName)) { return packageNotListedRefusal(use, packageName) } + if (!sourcePolicy.permitsSkill(packageName, skillName)) { + return skillNotListedRefusal(use, packageName, skillName) + } + if (isSkillExcluded(packageName, skillName, excludeMatchers)) { return { code: 'skill-excluded', @@ -177,11 +262,62 @@ function formatUnlistedNotice( return `${sourceCount} discovered ${noun} skills but ${sourceCount === 1 ? 'is' : 'are'} not listed in intent.skills: ${sorted.map((source) => source.name).join(', ')}. Add to opt in.` } +function formatUnlistedSkillNotice( + hiddenSources: Array, + audience: IntentAudience, +): string { + const uses = [...hiddenSources] + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((source) => + (source.hiddenSkills ?? []).map((skill) => `${source.name}#${skill}`), + ) + + if (audience === 'agent') { + return `${uses.length} ${pluralize(uses.length, 'skill', 'skills')} from listed packages ${pluralize(uses.length, 'is', 'are')} hidden because ${pluralize(uses.length, 'it is', 'they are')} not listed in intent.skills. Ask the user to run \`intent list --show-hidden\` outside the agent session to review candidates.` + } + + return `${uses.length} ${pluralize(uses.length, 'skill', 'skills')} from listed packages ${pluralize(uses.length, 'is', 'are')} not listed in intent.skills: ${uses.join(', ')}. Add to opt in.` +} + export interface SourcePolicyResult { hiddenSourceCount: number hiddenSources: Array + excludedSkills: Array packages: Array notices: Array + sourcePolicy: CompiledSkillSourcePolicy +} + +interface ExcludedSkill { + package: IntentPackage + skill: IntentPackage['skills'][number] + pattern: string +} + +export function scanForConfiguredIntents({ + config, + exclude, + fsCache, + root, +}: { + config: SkillSourcesConfig + exclude: Array + fsCache?: IntentFsCache + root: string +}): { + discovered: Array + policy: SourcePolicyResult +} { + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(root, scanOptions) + const discovered = scan.packages.filter((pkg) => pkg.source === 'local') + return { + discovered, + policy: applySourcePolicy( + { packages: discovered }, + { config, excludeMatchers: compileExcludePatterns(exclude) }, + ), + } } export function applySourcePolicy( @@ -202,9 +338,26 @@ export function applySourcePolicy( const packages: Array = [] const hiddenSources: Array = [] + const excludedSkills: Array = [] for (const pkg of scanResult.packages) { - if (isPackageExcluded(pkg.name, excludeMatchers)) continue + const permitsSkill = (skillName: string) => + sourcePolicy.permitsSkill(pkg.name, skillName, pkg.kind, pkg.skills) + const packageExclude = findPackageExcludeMatch(pkg.name, excludeMatchers) + if (packageExclude) { + if (sourcePolicy.permits(pkg.name, pkg.kind)) { + for (const skill of pkg.skills) { + if (permitsSkill(skill.name)) { + excludedSkills.push({ + package: pkg, + skill, + pattern: packageExclude.pattern, + }) + } + } + } + continue + } if (!sourcePolicy.permits(pkg.name, pkg.kind)) { if (config.mode === 'explicit') { @@ -213,22 +366,63 @@ export function applySourcePolicy( continue } - const skills = pkg.skills.filter( - (skill) => !isSkillExcluded(pkg.name, skill.name, excludeMatchers), - ) + const skills: Array = [] + const hiddenSkills: Array = [] + for (const skill of pkg.skills) { + if (!permitsSkill(skill.name)) { + hiddenSkills.push(skill.name) + continue + } + const skillExclude = findSkillExcludeMatch( + pkg.name, + skill.name, + excludeMatchers, + ) + if (skillExclude) { + excludedSkills.push({ + package: pkg, + skill, + pattern: skillExclude.pattern, + }) + continue + } + skills.push(skill) + } + if (config.mode === 'explicit' && hiddenSkills.length > 0) { + hiddenSources.push({ + name: pkg.name, + skillCount: hiddenSkills.length, + hiddenSkills, + }) + } packages.push( skills.length === pkg.skills.length ? pkg : { ...pkg, skills }, ) } - if (hiddenSources.length > 0) { - emit(formatUnlistedNotice(hiddenSources, audience)) + const unlistedSources = hiddenSources.filter( + (source) => source.hiddenSkills === undefined, + ) + const partiallyHidden = hiddenSources.filter( + (source) => source.hiddenSkills !== undefined, + ) + if (unlistedSources.length > 0) { + emit(formatUnlistedNotice(unlistedSources, audience)) + } + if (partiallyHidden.length > 0) { + emit(formatUnlistedSkillNotice(partiallyHidden, audience)) } if (config.mode === 'explicit') { for (const matcher of sourcePolicy.matchers) { - const notDiscovered = !scanResult.packages.some((pkg) => - matcher.matchesPackage(pkg.name, pkg.kind), + const { matchesSkill } = matcher + const notDiscovered = !scanResult.packages.some( + (pkg) => + matcher.matchesPackage(pkg.name, pkg.kind) && + (matchesSkill === undefined || + pkg.skills.some((skill) => + matchesSkill(pkg.name, skill.name, pkg.skills), + )), ) if (notDiscovered) { emit( @@ -238,20 +432,19 @@ export function applySourcePolicy( } } - if (config.mode === 'absent') emit(MIGRATION_NOTICE) - else if (config.mode === 'allow-all') emit(ALLOW_ALL_NOTICE) + if (config.mode === 'allow-all') emit(ALLOW_ALL_NOTICE) else if (config.mode === 'empty') emit(EMPTY_NOTE) return { hiddenSourceCount: hiddenSources.length, hiddenSources, + excludedSkills, packages, notices, + sourcePolicy, } } -// A null/undefined intent.skills is treated as not-declared so it cannot -// shadow a stricter parent allowlist. export function readSkillSourcesConfig( cwd: string, context: ProjectContext = resolveProjectContext({ cwd }), @@ -262,20 +455,23 @@ export function readSkillSourcesConfig( if ('skills' in intent) { const skills = (intent as Record).skills - if (skills === null || skills === undefined) continue return parseSkillSources(skills) } } - return { mode: 'absent' } + return parseSkillSources(undefined) } export interface PolicedScan { hiddenSourceCount: number hiddenSources: Array + excludedSkills: Array + discoveredPackages: Array scan: ScanResult excludePatterns: Array droppedNames: Array + config: SkillSourcesConfig + sourcePolicy: CompiledSkillSourcePolicy } export function scanForPolicedIntents(params: { @@ -283,16 +479,16 @@ export function scanForPolicedIntents(params: { scanOptions: ScanOptions coreOptions: IntentCoreOptions context?: ProjectContext + config?: SkillSourcesConfig }): PolicedScan { const { cwd, scanOptions, coreOptions } = params const context = params.context ?? resolveProjectContext({ cwd }) const audience = detectIntentAudience(coreOptions.audience) const scanResult = scanForIntents(cwd, scanOptions) - const config = readSkillSourcesConfig(cwd, context) + const config = params.config ?? readSkillSourcesConfig(cwd, context) const excludePatterns = getEffectiveExcludePatterns(coreOptions, context) const excludeMatchers = compileExcludePatterns(excludePatterns) - const policy = applySourcePolicy(scanResult, { audience, config, @@ -309,6 +505,8 @@ export function scanForPolicedIntents(params: { return { hiddenSourceCount: policy.hiddenSourceCount, hiddenSources: audience === 'agent' ? [] : policy.hiddenSources, + excludedSkills: audience === 'agent' ? [] : policy.excludedSkills, + discoveredPackages: scanResult.packages, scan: { ...scanResult, packages: policy.packages, @@ -323,5 +521,7 @@ export function scanForPolicedIntents(params: { }, excludePatterns, droppedNames, + config, + sourcePolicy: policy.sourcePolicy, } } diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index cf897035..14dd6caa 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -13,6 +13,7 @@ export interface IntentCoreOptions { global?: boolean globalOnly?: boolean exclude?: Array + why?: boolean } export type IntentAudience = 'agent' | 'human' @@ -20,6 +21,7 @@ export type IntentAudience = 'agent' | 'human' export interface IntentHiddenSourceSummary { name: string skillCount: number + hiddenSkills?: Array } export interface IntentSkillSummary { @@ -32,6 +34,11 @@ export interface IntentSkillSummary { description: string type?: string framework?: string + why?: string +} + +export interface IntentExcludedSkillSummary extends IntentSkillSummary { + excluded: true } export interface IntentPackageSummary { @@ -45,6 +52,7 @@ export interface IntentPackageSummary { export interface IntentSkillList { packageManager: PackageManager skills: Array + excludedSkills?: Array packages: Array hiddenSourceCount: number hiddenSources: Array @@ -121,6 +129,9 @@ export type IntentCoreErrorCode = | 'package-excluded' | 'package-not-listed' | 'skill-excluded' + | 'skill-not-listed' | 'skill-not-found' + | 'skill-not-accepted' + | 'skill-content-changed' | 'skill-path-outside-package' | 'skill-file-not-found' diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 622113ce..781c3a9c 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -1,13 +1,13 @@ // Static-discovery invariant: discovery reads package data as files and never -// executes discovered package code. The only sanctioned dynamic load is Yarn's -// PnP runtime (.pnp.cjs / pnpapi), used solely to map identities to readable -// roots. Enforced by the `intent/static-discovery` ESLint rule. +// executes discovered package code. The only sanctioned project-code dynamic +// load is Yarn's PnP runtime (.pnp.cjs / pnpapi), used solely to map identities +// to readable roots. Enforced by the `intent/static-discovery` ESLint rule. import { existsSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import semver from 'semver' +import { dirname, join, relative, resolve, sep } from 'node:path' import { detectGlobalNodeModules, + isPathWithin, nodeReadFs, parseFrontmatter, readScalarField, @@ -22,6 +22,7 @@ import { detectPackageManager } from './package-manager.js' import { createDependencyWalker, createPackageRegistrar } from './index.js' import type { IntentFsCache } from './fs-cache.js' import type { ReadFs } from '../shared/utils.js' +import type Semver from 'semver' import type { InstalledVariant, IntentConfig, @@ -73,6 +74,13 @@ interface NodeModuleInternals { } const requireFromHere = createRequire(import.meta.url) +let semver: typeof Semver | undefined + +function getSemver(): typeof Semver { + // Static yaml/semver imports add ~20ms of hook startup, and tsdown inlines dynamic imports, so createRequire preserves the lazy boundary. + semver ??= requireFromHere('semver') as typeof Semver + return semver +} function findPnpFile(start: string): string | null { let dir = resolve(start) @@ -126,6 +134,9 @@ function loadPnpApi(root: string): LoadedPnp | null { const readFs = requireFromHere('node:fs') as unknown as ReadFs try { + // Yarn PnP patches CommonJS resolution during setup, so cache yaml before + // loading the runtime; later createRequire calls otherwise fail. + void requireFromHere('yaml') // eslint-disable-next-line no-restricted-syntax -- sanctioned PnP runtime load const pnpModule = requireFromHere(pnpPath) as PnpApi if (typeof pnpModule.setup === 'function') { @@ -166,6 +177,10 @@ function loadPnpApi(root: string): LoadedPnp | null { } } +export function getProjectReadFs(root: string): ReadFs { + return loadPnpApi(root)?.readFs ?? nodeReadFs +} + function getPnpLocatorKey(locator: PnpPackageLocator): string { return `${locator.name ?? ''}@${locator.reference ?? ''}` } @@ -327,11 +342,6 @@ 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, @@ -350,7 +360,7 @@ function resolveSkillNameHintPath( const resolvedSkillsDir = resolve(skillsDir) const childDir = resolve(resolvedSkillsDir, ...parts) - if (!isWithinOrEqual(childDir, resolvedSkillsDir)) return null + if (!isPathWithin(resolvedSkillsDir, childDir)) return null return { childDir, @@ -401,10 +411,10 @@ function getPackageDepth(packageRoot: string, projectRoot: string): number { } function normalizeVersion(version: string): string | null { - const validVersion = semver.valid(version) + const validVersion = getSemver().valid(version) if (validVersion) return validVersion - return semver.coerce(version)?.version ?? null + return getSemver().coerce(version)?.version ?? null } function comparePackageVersions(a: string, b: string): number { @@ -417,7 +427,7 @@ function comparePackageVersions(a: string, b: string): number { return 0 } - return semver.compare(versionA, versionB) + return getSemver().compare(versionA, versionB) } function formatVariantWarning( diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts index 472b36e6..eec0aa9a 100644 --- a/packages/intent/src/hooks/adapters.ts +++ b/packages/intent/src/hooks/adapters.ts @@ -3,7 +3,6 @@ import type { HookAgent, HookInstallScope } from './types.js' type HookAdapterPaths = { configPath: string - scriptPath: string } type HookAdapterContext = { @@ -22,8 +21,6 @@ export type HookAgentAdapter = { ) => HookAdapterPaths } -const HOOK_SCRIPT_DIR = '.intent/hooks' - export const HOOK_AGENT_ADAPTERS: Record = { claude: { agent: 'claude', @@ -35,15 +32,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { configPath: project ? join(root, '.claude', 'settings.json') : join(homeDir, '.claude', 'settings.json'), - scriptPath: project - ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') - : join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-claude-gate.mjs', - ), } }, }, @@ -57,15 +45,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { configPath: project ? join(root, '.codex', 'hooks.json') : join(homeDir, '.codex', 'hooks.json'), - scriptPath: project - ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') - : join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-codex-gate.mjs', - ), } }, }, @@ -79,13 +58,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { 'hooks', 'hooks.json', ), - scriptPath: join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-copilot-gate.mjs', - ), }), }, } diff --git a/packages/intent/src/hooks/agents/claude.ts b/packages/intent/src/hooks/agents/claude.ts deleted file mode 100644 index 44bb71f1..00000000 --- a/packages/intent/src/hooks/agents/claude.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { HookDecision } from '../types.js' - -export type ClaudeHookOutput = { - hookSpecificOutput: { - hookEventName: 'PreToolUse' - permissionDecision: 'deny' - permissionDecisionReason: string - } -} - -export function formatClaudePreToolUseOutput( - decision: HookDecision, -): ClaudeHookOutput | undefined { - if (decision.decision === 'allow') { - return undefined - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: decision.reason, - }, - } -} diff --git a/packages/intent/src/hooks/agents/codex.ts b/packages/intent/src/hooks/agents/codex.ts deleted file mode 100644 index ccc13e87..00000000 --- a/packages/intent/src/hooks/agents/codex.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { HookDecision } from '../types.js' - -export type CodexHookOutput = { - hookSpecificOutput: { - hookEventName: 'PreToolUse' - permissionDecision: 'deny' - permissionDecisionReason: string - } -} - -export function formatCodexPreToolUseOutput( - decision: HookDecision, -): CodexHookOutput | undefined { - if (decision.decision === 'allow') { - return undefined - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: decision.reason, - }, - } -} diff --git a/packages/intent/src/hooks/agents/copilot.ts b/packages/intent/src/hooks/agents/copilot.ts deleted file mode 100644 index c2231075..00000000 --- a/packages/intent/src/hooks/agents/copilot.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { HookDecision } from '../types.js' - -export type CopilotHookOutput = { - permissionDecision: 'deny' - permissionDecisionReason: string -} - -export function formatCopilotPreToolUseOutput( - decision: HookDecision, -): CopilotHookOutput | undefined { - if (decision.decision === 'allow') { - return undefined - } - - return { - permissionDecision: 'deny', - permissionDecisionReason: decision.reason, - } -} diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 2e8f6a5d..3c585be5 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,20 +1,19 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, relative } from 'node:path' +import { relative } from 'node:path' import { detectPackageManager } from '../discovery/package-manager.js' +import { writeTextFileAtomic } from '../shared/atomic-write.js' import { fail } from '../shared/cli-error.js' import { formatIntentCommand } from '../shared/command-runner.js' import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js' -import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js' import type { HookAgent, HookInstallScope } from './types.js' type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' -export type HookInstallResult = { +type HookInstallResult = { agent: HookAgent configPath: string | null scope: HookInstallScope - scriptPath: string | null status: HookInstallStatus reason?: string } @@ -27,7 +26,6 @@ export type InstallHooksOptions = { scope?: string } -const GATE_STATUS_MESSAGE = 'Checking Intent guidance' const CATALOG_STATUS_MESSAGE = 'Loading Intent skill catalog' export function runInstallHooks({ @@ -51,273 +49,6 @@ export function runInstallHooks({ ) } -export function validateHookInstallOptions({ - agents, - scope, -}: Pick): void { - parseScope(scope) - parseAgents(agents) -} - -export function buildHookRunnerScript( - agent: HookAgent, - catalogCommand = formatIntentCommand( - detectPackageManager(), - 'list --json --no-notices', - ), -): string { - const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort() - - return `#!/usr/bin/env node -import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { createHash } from 'node:crypto' -import { performance } from 'node:perf_hooks' - -const AGENT = ${JSON.stringify(agent)} -const CATALOG_COMMAND = ${JSON.stringify(catalogCommand)} -const EDIT_TOOLS = new Set(${JSON.stringify(editTools)}) -const GATE_DENY_REASON = ${JSON.stringify(GATE_DENY_REASON)} -const INTENT_COMMAND_PATTERN = /(?:^|&&|\\|\\||;|\\|)\\s*((?:bunx\\s+@tanstack\\/intent(?:@latest)?)|(?:pnpm\\s+exec\\s+intent)|(?:pnpm\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:npx\\s+@tanstack\\/intent(?:@latest)?)|(?:yarn\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:intent))\\s+(list|load)(?:\\s+([^\\s|;&]+))?/i - -try { - await main() -} catch { -} - -process.exit(0) - -async function main() { - const event = readEventFromStdin() - - if (isSessionStartEvent(event)) { - const additionalContext = await createSessionCatalogContext(rootForEvent(event)) - if (additionalContext) { - process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext))) - } - return - } - - const stateFile = stateFileForEvent(event) - const observation = observationFromEvent(event) - - if (observation) { - appendObservation(stateFile, observation) - } - - const toolName = event?.tool_name ?? event?.toolName - if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) { - process.stdout.write(JSON.stringify(denyOutput())) - } -} - -function readEventFromStdin() { - try { - return JSON.parse(readFileSync(0, 'utf8')) - } catch { - return {} - } -} - -function isSessionStartEvent(event) { - return (event?.hook_event_name ?? event?.hookEventName) === 'SessionStart' -} - -function rootForEvent(event) { - return typeof event?.cwd === 'string' ? event.cwd : process.cwd() -} - -async function createSessionCatalogContext(root) { - try { - const start = performance.now() - const result = readIntentList(root) - const durationMs = performance.now() - start - console.error( - \`[intent-\${AGENT}-session-catalog] listIntentSkills found \${result.skills.length} skills from \${result.packages.length} packages in \${formatDuration(durationMs)} (packageJsonReadCount=\${result.debug?.scan.packageJsonReadCount ?? 'unknown'})\`, - ) - return formatSessionCatalog(result) - } catch { - return '' - } -} - -function readIntentList(root) { - const output = execFileSync(CATALOG_COMMAND, { - cwd: root, - encoding: 'utf8', - env: { ...process.env, INTENT_AUDIENCE: 'agent' }, - maxBuffer: 1024 * 1024, - shell: true, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 9000, - }) - return JSON.parse(output) -} - -function formatDuration(durationMs) { - return \`\${durationMs.toFixed(1)}ms\` -} - -function formatSessionCatalog(result) { - if (!Array.isArray(result.skills) || result.skills.length === 0) return '' - - return [ - 'TanStack Intent skills are available in this repository.', - '', - 'Before substantial work, check whether one listed skill clearly matches the user task. If one clearly matches, load that full skill guidance with the Intent CLI before proceeding.', - '', - 'If no skill clearly matches, continue normally. Do not load a skill just to improve phrasing or gather nonessential context.', - '', - 'Available local Intent skills:', - formatSkillCatalog(result.skills), - formatWarnings(result), - ] - .filter(Boolean) - .join('\\n') -} - -function formatSkillCatalog(skills) { - return skills - .map((skill) => \`- \${skill.use}: \${normalizeDescription(skill.description)}\`) - .join('\\n') -} - -function normalizeDescription(description) { - return typeof description === 'string' ? description.replace(/\\s+/g, ' ').trim() : '' -} - -function formatWarnings(result) { - const warnings = [ - ...(Array.isArray(result.warnings) ? result.warnings : []), - ...(Array.isArray(result.conflicts) - ? result.conflicts.map( - (conflict) => - \`Version conflict for \${conflict.packageName}; using \${conflict.chosen.version}\`, - ) - : []), - ] - - if (warnings.length === 0) return '' - return \`\\nWarnings:\\n\${warnings.map((warning) => \`- \${warning}\`).join('\\n')}\` -} - -function sessionStartOutput(additionalContext) { - if (AGENT === 'copilot') { - return { additionalContext } - } - - return { - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext, - }, - } -} - -function stateFileForEvent(event) { - const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown' - const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd() - const key = createHash('sha256').update(AGENT + '\\0' + cwd + '\\0' + sessionId).digest('hex') - return join(tmpdir(), 'tanstack-intent-hooks', key + '.jsonl') -} - -function observationFromEvent(event) { - if (!event || typeof event !== 'object') return undefined - const toolName = event.tool_name ?? event.toolName - const toolInput = event.tool_input ?? event.toolArgs - if (toolName !== 'Bash') return undefined - const command = typeof toolInput === 'string' ? safeCommandFromString(toolInput) : commandFromObject(toolInput) - const parsed = parseIntentInvocation(command) - if (!parsed || typeof command !== 'string') return undefined - return { action: parsed.action, skillUse: parsed.skillUse, raw: command } -} - -function parseIntentInvocation(command) { - if (typeof command !== 'string') return undefined - const match = command.match(INTENT_COMMAND_PATTERN) - if (!match?.[1] || !match[2]) return undefined - const action = match[2].toLowerCase() - if (action !== 'list' && action !== 'load') return undefined - const skillUse = action === 'load' ? match[3] : undefined - if (action === 'load' && !skillUse) return undefined - return action === 'load' ? { action, skillUse } : { action } -} - -function commandFromObject(value) { - return value && typeof value === 'object' ? value.command : undefined -} - -function safeCommandFromString(value) { - try { - const command = commandFromObject(JSON.parse(value)) - return typeof command === 'string' ? command : value - } catch { - return value - } -} - -function appendObservation(stateFile, observation) { - try { - mkdirSync(dirname(stateFile), { recursive: true }) - appendFileSync(stateFile, JSON.stringify({ ts: new Date().toISOString(), ...observation }) + '\\n') - } catch { - } -} - -function hasLoad(stateFile) { - if (!existsSync(stateFile)) return false - try { - return readFileSync(stateFile, 'utf8') - .split('\\n') - .filter(Boolean) - .some((line) => { - try { - return JSON.parse(line).action === 'load' - } catch { - return false - } - }) - } catch { - return false - } -} - -function denyOutput() { - if (AGENT === 'copilot') { - return { permissionDecision: 'deny', permissionDecisionReason: GATE_DENY_REASON } - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }, - } -} -` -} - -export function formatHookInstallResult(result: HookInstallResult): string { - if (result.status === 'skipped') { - return `Skipped Intent hooks for ${result.agent}: ${result.reason}` - } - - const target = result.configPath - ? formatPath(result.configPath) - : result.agent - switch (result.status) { - case 'created': - return `Installed Intent hooks for ${result.agent} (${result.scope}) in ${target}.` - case 'updated': - return `Updated Intent hooks for ${result.agent} (${result.scope}) in ${target}.` - case 'unchanged': - return `No changes to Intent hooks for ${result.agent} (${result.scope}); already current.` - } -} - function installAgentHook({ agent, copilotHome, @@ -339,97 +70,56 @@ function installAgentHook({ configPath: null, reason: 'project scope is not supported; use --scope user', scope, - scriptPath: null, status: 'skipped', } } - const { configPath, scriptPath } = adapter.paths(scope, { + const { configPath } = adapter.paths(scope, { copilotHome: copilotHome ?? process.env.COPILOT_HOME, homeDir, root, }) const catalogCommand = formatIntentCommand( detectPackageManager(root), - 'list --json --no-notices', - ) - const scriptStatus = writeIfChanged( - scriptPath, - buildHookRunnerScript(agent, catalogCommand), + `hooks run --agent ${agent}`, ) const configStatus = updateJsonConfig(configPath, (config) => upsertAdapterHooks({ + catalogCommand, config, configKind: adapter.configKind, - project: scope === 'project', - scriptPath, }), ) - return hookInstallResult({ - agent, - configPath, - scope, - scriptPath, - scriptStatus, - configStatus, - }) -} - -function hookInstallResult({ - agent, - configPath, - configStatus, - scope, - scriptPath, - scriptStatus, -}: { - agent: HookAgent - configPath: string - configStatus: HookInstallStatus - scope: HookInstallScope - scriptPath: string - scriptStatus: HookInstallStatus -}): HookInstallResult { return { agent, configPath, scope, - scriptPath, - status: - scriptStatus === 'created' || configStatus === 'created' - ? 'created' - : scriptStatus === 'updated' || configStatus === 'updated' - ? 'updated' - : 'unchanged', + status: configStatus, } } function upsertAdapterHooks({ + catalogCommand, config, configKind, - project, - scriptPath, }: { + catalogCommand: string config: Record configKind: (typeof HOOK_AGENT_ADAPTERS)[HookAgent]['configKind'] - project: boolean - scriptPath: string }): Record { switch (configKind) { case 'claude-settings': - return upsertClaudeHooks(config, project, scriptPath) case 'codex-hooks': - return upsertCodexHooks(config, project, scriptPath) + return upsertCommandHooks(config, catalogCommand) case 'copilot-hooks': - return upsertCopilotHooks(config, scriptPath) + return upsertCopilotHooks(config, catalogCommand) } } -function upsertClaudeHooks( +function upsertCommandHooks( config: Record, - project: boolean, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { @@ -437,82 +127,25 @@ function upsertClaudeHooks( hooks: [ { type: 'command', - command: 'node', - args: [ - project - ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' - : scriptPath, - ], + command: catalogCommand, timeout: 10, statusMessage: CATALOG_STATUS_MESSAGE, }, ], }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit', - hooks: [ - { - type: 'command', - command: 'node', - args: [ - project - ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' - : scriptPath, - ], - timeout: 10, - statusMessage: GATE_STATUS_MESSAGE, - }, - ], - }) - return { ...config, hooks } -} - -function upsertCodexHooks( - config: Record, - project: boolean, - scriptPath: string, -): Record { - const hooks = objectValue(config.hooks) - hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { - matcher: 'startup|resume|clear|compact', - hooks: [ - { - type: 'command', - command: project - ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' - : `node ${quoteShell(scriptPath)}`, - timeout: 10, - statusMessage: CATALOG_STATUS_MESSAGE, - }, - ], - }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - matcher: 'Bash|apply_patch|Edit|Write', - hooks: [ - { - type: 'command', - command: project - ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' - : `node ${quoteShell(scriptPath)}`, - timeout: 10, - statusMessage: GATE_STATUS_MESSAGE, - }, - ], - }) + hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } } function upsertCopilotHooks( config: Record, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { - command: `node ${quoteShell(scriptPath)}`, - }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - command: `node ${quoteShell(scriptPath)}`, + command: catalogCommand, }) + hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } } @@ -520,7 +153,11 @@ function upsertHookGroup( groups: Array, nextGroup: Record, ): Array { - return [...groups.flatMap(withoutIntentHooks), nextGroup] + return [...removeIntentHooks(groups), nextGroup] +} + +function removeIntentHooks(groups: Array): Array { + return groups.flatMap(withoutIntentHooks) } function withoutIntentHooks(value: unknown): Array { @@ -544,12 +181,17 @@ function isIntentHook(value: unknown): boolean { ? entry.args.filter((arg): arg is string => typeof arg === 'string') : [] - return [command, ...args].some(isIntentGateScriptReference) + return [command, ...args].some(isIntentHookReference) } -function isIntentGateScriptReference(value: string): boolean { - return /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test( - value, +function isIntentHookReference(value: string): boolean { + return ( + /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-(?:gate|catalog)\.mjs(?:$|[?#\s"'])/i.test( + value, + ) || + /@tanstack\/intent(?:@[^\s]+)?\s+hooks\s+run\s+--agent\s+(?:copilot|claude|codex)(?:$|\s)/i.test( + value, + ) ) } @@ -566,19 +208,7 @@ function updateJsonConfig( return 'unchanged' } - mkdirSync(dirname(filePath), { recursive: true }) - writeFileSync(filePath, next) - return existed ? 'updated' : 'created' -} - -function writeIfChanged(filePath: string, content: string): HookInstallStatus { - const existed = existsSync(filePath) - if (existed && readFileSync(filePath, 'utf8') === content) { - return 'unchanged' - } - - mkdirSync(dirname(filePath), { recursive: true }) - writeFileSync(filePath, content) + writeTextFileAtomic(filePath, next) return existed ? 'updated' : 'created' } @@ -638,10 +268,6 @@ function arrayValue(value: unknown): Array { return Array.isArray(value) ? value : [] } -function quoteShell(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - function formatPath(filePath: string): string { return relative(process.cwd(), filePath) || filePath } diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts deleted file mode 100644 index df9eb946..00000000 --- a/packages/intent/src/hooks/policy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { - HookAgent, - HookDecision, - IntentInvocation, - IntentObservation, - ToolEvent, -} from './types.js' - -const INTENT_COMMAND_PATTERN = - /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+@tanstack\/intent(?:@latest)?)|(?:pnpm\s+exec\s+intent)|(?:pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:npx\s+@tanstack\/intent(?:@latest)?)|(?:yarn\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i - -export const EDIT_TOOLS_BY_AGENT: Record> = { - claude: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), - codex: new Set(['apply_patch', 'Write', 'Edit']), - copilot: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), -} - -export const GATE_DENY_REASON = - "Blocked: load matching TanStack guidance before editing. Follow this repo's TanStack guidance setup, then retry the edit." - -export function parseIntentInvocation( - command: unknown, -): IntentInvocation | undefined { - if (typeof command !== 'string') { - return undefined - } - - const match = command.match(INTENT_COMMAND_PATTERN) - - if (!match?.[1] || !match[2]) { - return undefined - } - - const action = match[2].toLowerCase() - - if (action !== 'list' && action !== 'load') { - return undefined - } - - const skillUse = action === 'load' ? match[3] : undefined - - if (action === 'load' && !skillUse) { - return undefined - } - - return action === 'load' ? { action, skillUse } : { action } -} - -export function observationFromEvent( - event: ToolEvent | undefined, -): IntentObservation | undefined { - if (!event || typeof event !== 'object') { - return undefined - } - - const toolName = event.tool_name ?? event.toolName - const toolInput = event.tool_input ?? event.toolArgs - - if (toolName !== 'Bash') { - return undefined - } - - const command = - typeof toolInput === 'string' - ? safeCommandFromString(toolInput) - : commandFromObject(toolInput) - - const parsed = parseIntentInvocation(command) - - if (!parsed || typeof command !== 'string') { - return undefined - } - - return { action: parsed.action, skillUse: parsed.skillUse, raw: command } -} - -export function gateDecision({ - agent, - hasLoaded, - toolName, -}: { - agent: HookAgent - hasLoaded: boolean - toolName: string -}): HookDecision { - if (EDIT_TOOLS_BY_AGENT[agent].has(toolName) && !hasLoaded) { - return { decision: 'deny', reason: GATE_DENY_REASON } - } - - return { decision: 'allow' } -} - -export function hasLoadFromObservations( - observations: Array | undefined>, -): boolean { - return observations.some((entry) => entry?.action === 'load') -} - -function commandFromObject(value: unknown): unknown { - return value && typeof value === 'object' - ? (value as { command?: unknown }).command - : undefined -} - -function safeCommandFromString(value: string): string { - try { - const parsed = JSON.parse(value) as unknown - const command = commandFromObject(parsed) - return typeof command === 'string' ? command : value - } catch { - return value - } -} diff --git a/packages/intent/src/hooks/types.ts b/packages/intent/src/hooks/types.ts index db5602ca..bc5b0816 100644 --- a/packages/intent/src/hooks/types.ts +++ b/packages/intent/src/hooks/types.ts @@ -1,23 +1,3 @@ export type HookAgent = 'claude' | 'codex' | 'copilot' export type HookInstallScope = 'project' | 'user' - -export type IntentInvocation = { - action: 'list' | 'load' - skillUse?: string -} - -export type IntentObservation = IntentInvocation & { - raw: string -} - -export type HookDecision = - | { decision: 'allow' } - | { decision: 'deny'; reason: string } - -export type ToolEvent = { - tool_name?: unknown - toolName?: unknown - tool_input?: unknown - toolArgs?: unknown -} diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts new file mode 100644 index 00000000..aa53db9f --- /dev/null +++ b/packages/intent/src/session-catalog.ts @@ -0,0 +1,498 @@ +import { + chmodSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createHash } from 'node:crypto' +import { homedir, tmpdir, userInfo } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { resolveProjectContext } from './core/project-context.js' +import { computeSkillContentHash } from './core/lockfile/hash.js' +import { containsLocalPath } from './shared/local-path.js' +import { isGeneratedMappingSkill } from './skills/categories.js' +import { + SESSION_CATALOGUE_MAX_BYTES, + SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH, + SESSION_CATALOGUE_MAX_SKILLS, + normalizeWhitespace, + truncateText, +} from './skills/catalogue-contract.js' +import { parseSkillUse } from './skills/use.js' +import { findWorkspacePackages } from './setup/workspace-patterns.js' +import type * as NodePath from 'node:path' +import type { IntentSkillList } from './core/index.js' +import type { ReadFs } from './shared/utils.js' + +const CACHE_SCHEMA_VERSION = 4 +const MIN_CONTEXT_BYTES = 512 +const warnedCacheDirectories = new Set() +const FINGERPRINT_FILES = [ + 'package.json', + 'intent.lock', + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + 'pnpm-workspace.yaml', + 'deno.json', + 'deno.jsonc', + 'deno.lock', +] + +interface SessionSkillSummary { + id: string + description: string +} + +export interface CatalogueVerificationEntry { + packageRoot: string + skillPath: string + contentHash: string +} + +export interface SessionCatalogue { + skills: Array + totalSkillCount: number +} + +export interface DiscoveredSessionCatalogue { + result: IntentSkillList + verification: Array | null +} + +interface IntentSessionCatalogueCache { + schemaVersion: number + workspaceRoot: string + policyRoot: string + dependencyFingerprint: string + catalogue: SessionCatalogue + verification: Array +} + +export interface SessionCatalogueResult { + cachePath: string + cacheStatus: 'hit' | 'miss' | 'refresh' + catalogue: SessionCatalogue +} + +export function buildSessionCatalogue( + result: IntentSkillList, + options: { maxSkills?: number } = {}, +): SessionCatalogue { + const maxSkills = options.maxSkills ?? SESSION_CATALOGUE_MAX_SKILLS + const allSkills = result.skills + .filter(isGeneratedMappingSkill) + .map((skill): SessionSkillSummary => { + parseSkillUse(skill.use) + const normalizedDescription = normalizeWhitespace(skill.description) + const description = containsLocalPath(normalizedDescription) + ? '' + : truncateText( + normalizedDescription, + SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH, + ) + return { + id: skill.use, + description: description || `Use ${skill.use}`, + } + }) + .sort((left, right) => compareOrdinal(left.id, right.id)) + return { + skills: allSkills.slice(0, maxSkills), + totalSkillCount: allSkills.length, + } +} + +export function formatSessionCatalogue( + catalogue: SessionCatalogue, + options: { maxBytes?: number } = {}, +): string { + const maxBytes = options.maxBytes ?? SESSION_CATALOGUE_MAX_BYTES + if (!Number.isInteger(maxBytes) || maxBytes < MIN_CONTEXT_BYTES) { + throw new RangeError( + `Session catalogue maxBytes must be an integer of at least ${MIN_CONTEXT_BYTES}.`, + ) + } + if (catalogue.skills.length === 0) { + return 'No available Intent skills.' + } + + const baseLines = ['Available Intent skills:', ''] + const footerLines = [ + '', + 'Load a matching skill with `intent load `. If none match, continue normally.', + ] + const skillLines: Array = [] + + for (const skill of catalogue.skills) { + const nextSkillLines = [ + ...skillLines, + `- ${skill.id}: ${skill.description}`, + ] + const omitted = catalogue.totalSkillCount - nextSkillLines.length + const candidateLines = [ + ...baseLines, + ...nextSkillLines, + ...(omitted > 0 ? [formatOmittedSkills(omitted)] : []), + ...footerLines, + ] + if (!fits(candidateLines, maxBytes)) break + skillLines.push(nextSkillLines.at(-1)!) + } + + const omitted = catalogue.totalSkillCount - skillLines.length + const lines = [ + ...baseLines, + ...skillLines, + ...(omitted > 0 ? [formatOmittedSkills(omitted)] : []), + ...footerLines, + ] + if (!fits(lines, maxBytes)) { + throw new RangeError( + 'Session catalogue maxBytes must be large enough for complete guidance.', + ) + } + return lines.join('\n') +} + +export function resolveCatalogueWorkspaceRoot(cwd: string): string { + const context = resolveProjectContext({ cwd }) + return normalizeRoot(context.workspaceRoot ?? context.packageRoot ?? cwd) +} + +export async function getSessionCatalogue(options: { + cacheDir?: string + discover: () => + | DiscoveredSessionCatalogue + | Promise + refresh?: boolean + root: string + policyRoot?: string + readFs?: ReadFs +}): Promise { + const { + cacheDir: suppliedCacheDir, + discover, + refresh = false, + root, + policyRoot = root, + readFs, + } = options + const cache = prepareCacheDirectory(suppliedCacheDir) + const workspaceRoot = normalizeRoot(root) + const normalizedPolicyRoot = normalizeRoot(policyRoot) + const dependencyFingerprint = computeCatalogueFingerprint( + workspaceRoot, + normalizedPolicyRoot, + ) + const cachePath = join( + cache.path, + `${createHash('sha256').update(workspaceRoot).update('\0').update(normalizedPolicyRoot).digest('hex')}.json`, + ) + const cached = cache.enabled ? readCache(cachePath) : null + + if ( + !refresh && + cached?.workspaceRoot === workspaceRoot && + cached.policyRoot === normalizedPolicyRoot && + cached.dependencyFingerprint === dependencyFingerprint && + verifyCatalogueContent(cached.verification, readFs) + ) { + return { + cachePath, + cacheStatus: 'hit', + catalogue: cached.catalogue, + } + } + + const refreshed = await discover() + const catalogue = buildSessionCatalogue(refreshed.result) + if (cache.enabled && refreshed.verification !== null) { + const entry: IntentSessionCatalogueCache = { + schemaVersion: CACHE_SCHEMA_VERSION, + workspaceRoot, + policyRoot: normalizedPolicyRoot, + dependencyFingerprint, + catalogue, + verification: refreshed.verification, + } + writeCache(cachePath, entry) + } + + return { + cachePath, + cacheStatus: cached ? 'refresh' : 'miss', + catalogue, + } +} + +function computeCatalogueFingerprint(root: string, policyRoot: string): string { + const normalizedRoot = normalizeRoot(root) + const packageRoots = [ + normalizedRoot, + ...findWorkspacePackages(normalizedRoot), + ] + const files = [ + ...FINGERPRINT_FILES.map((file) => join(normalizedRoot, file)), + ...packageRoots.map((packageRoot) => join(packageRoot, 'package.json')), + ...policyManifestPaths(normalizedRoot, policyRoot), + ] + const hash = createHash('sha256') + hash.update(String(CACHE_SCHEMA_VERSION)) + + for (const file of [...new Set(files)].sort(compareOrdinal)) { + hash.update('\0') + hash.update(file.slice(normalizedRoot.length).replace(/\\/g, '/')) + hash.update('\0') + try { + hash.update(readFileSync(file)) + } catch { + hash.update('') + } + } + + return hash.digest('hex') +} + +function verifyCatalogueContent( + entries: ReadonlyArray, + fs?: ReadFs, +): boolean { + try { + return entries.every( + (entry) => + computeSkillContentHash({ + packageRoot: entry.packageRoot, + skillDir: entry.skillPath, + fs, + }) === entry.contentHash, + ) + } catch { + return false + } +} + +function normalizeRoot(root: string): string { + const resolved = resolve(root) + const real = existsSync(resolved) ? realpathSync.native(resolved) : resolved + const normalized = real.replace(/\\/g, '/') + return /^[A-Z]:/.test(normalized) + ? `${normalized[0]!.toLowerCase()}${normalized.slice(1)}` + : normalized +} + +export function policyManifestPaths( + workspaceRoot: string, + policyRoot: string, + pathApi: Pick< + typeof NodePath, + 'dirname' | 'isAbsolute' | 'join' | 'relative' + > = { dirname, isAbsolute, join, relative }, +): Array { + const relativePolicyRoot = pathApi.relative(workspaceRoot, policyRoot) + if ( + relativePolicyRoot.split(/[\\/]/, 1)[0] === '..' || + pathApi.isAbsolute(relativePolicyRoot) + ) { + return [pathApi.join(policyRoot, 'package.json')] + } + + const manifests: Array = [] + let directory = policyRoot + while (pathApi.relative(workspaceRoot, directory) !== '') { + manifests.push(pathApi.join(directory, 'package.json')) + const parent = pathApi.dirname(directory) + if (parent === directory) { + return [pathApi.join(policyRoot, 'package.json')] + } + directory = parent + } + manifests.push(pathApi.join(workspaceRoot, 'package.json')) + return manifests +} + +function compareOrdinal(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function formatOmittedSkills(count: number): string { + return `- ${count} additional ${count === 1 ? 'skill' : 'skills'} omitted; narrow the catalogue with package.json intent.skills or intent.exclude.` +} + +function fits(lines: Array, maxBytes: number): boolean { + return Buffer.byteLength(lines.join('\n')) <= maxBytes +} + +function defaultCacheDirectory(): string { + const tempRoot = realpathSync.native(tmpdir()) + const userKey = + typeof process.getuid === 'function' + ? String(process.getuid()) + : createHash('sha256') + .update(userInfo().username) + .update('\0') + .update(homedir()) + .digest('hex') + .slice(0, 12) + return join(tempRoot, `tanstack-intent-${userKey}-catalogues`) +} + +function prepareCacheDirectory(suppliedPath?: string): { + path: string + enabled: boolean +} { + let path = suppliedPath + try { + const isDefault = path === undefined + path = suppliedPath ?? defaultCacheDirectory() + if (typeof process.getuid === 'function' && isDefault) { + const tempRoot = lstatSync(dirname(path)) + const isPrivateOwnerDirectory = + tempRoot.uid === process.getuid() && (tempRoot.mode & 0o022) === 0 + if ( + tempRoot.isSymbolicLink() || + !tempRoot.isDirectory() || + (!isPrivateOwnerDirectory && (tempRoot.mode & 0o1000) === 0) + ) { + throw new Error('Unsafe temporary directory') + } + } + + mkdirSync(path, { recursive: true, mode: 0o700 }) + const initial = lstatSync(path) + if (initial.isSymbolicLink() || !initial.isDirectory()) { + throw new Error('Unsafe cache directory') + } + + if (typeof process.getuid === 'function') { + if (initial.uid !== process.getuid()) { + throw new Error('Unsafe cache directory owner') + } + if (isDefault && (initial.mode & 0o077) !== 0) { + chmodSync(path, 0o700) + const tightened = lstatSync(path) + if ( + tightened.dev !== initial.dev || + tightened.ino !== initial.ino || + tightened.isSymbolicLink() || + !tightened.isDirectory() || + tightened.uid !== process.getuid() || + (tightened.mode & 0o077) !== 0 + ) { + throw new Error('Cache directory changed while securing it') + } + } else if (!isDefault && (initial.mode & 0o022) !== 0) { + throw new Error('Writable cache directory') + } + } + + return { path, enabled: true } + } catch { + path ??= join(tmpdir(), 'tanstack-intent-catalogues-disabled') + if (!warnedCacheDirectories.has(path)) { + warnedCacheDirectories.add(path) + process.stderr.write( + `[intent catalog] rejected cache directory ${path}; caching is disabled.\n`, + ) + } + return { path, enabled: false } + } +} + +function readCache(path: string): IntentSessionCatalogueCache | null { + let descriptor: number | undefined + try { + const flags = + typeof process.getuid === 'function' + ? constants.O_RDONLY | constants.O_NOFOLLOW + : constants.O_RDONLY + descriptor = openSync(path, flags) + const file = fstatSync(descriptor) + if ( + !file.isFile() || + (typeof process.getuid === 'function' && + (file.uid !== process.getuid() || (file.mode & 0o022) !== 0)) + ) { + return null + } + const value = JSON.parse(readFileSync(descriptor, 'utf8')) as unknown + return isCacheEntry(value) ? value : null + } catch { + return null + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor) + } catch {} + } + } +} + +function isCacheEntry(value: unknown): value is IntentSessionCatalogueCache { + if (!value || typeof value !== 'object') return false + const entry = value as Partial + return ( + entry.schemaVersion === CACHE_SCHEMA_VERSION && + typeof entry.workspaceRoot === 'string' && + typeof entry.policyRoot === 'string' && + typeof entry.dependencyFingerprint === 'string' && + isCatalogue(entry.catalogue) && + Array.isArray(entry.verification) && + entry.verification.every(isVerificationEntry) + ) +} + +function isCatalogue(value: unknown): value is SessionCatalogue { + if (!value || typeof value !== 'object') return false + const catalogue = value as Partial + return ( + Array.isArray(catalogue.skills) && + catalogue.skills.every(isSkillSummary) && + typeof catalogue.totalSkillCount === 'number' + ) +} + +function isSkillSummary(value: unknown): value is SessionSkillSummary { + if (!value || typeof value !== 'object') return false + const skill = value as Partial + return typeof skill.id === 'string' && typeof skill.description === 'string' +} + +function isVerificationEntry( + value: unknown, +): value is CatalogueVerificationEntry { + if (!value || typeof value !== 'object') return false + const entry = value as Partial + return ( + typeof entry.packageRoot === 'string' && + typeof entry.skillPath === 'string' && + typeof entry.contentHash === 'string' + ) +} + +function writeCache(path: string, entry: IntentSessionCatalogueCache): void { + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp` + try { + writeFileSync(temporaryPath, `${JSON.stringify(entry)}\n`, { + flag: 'wx', + mode: 0o600, + }) + renameSync(temporaryPath, path) + } catch { + try { + rmSync(temporaryPath, { force: true }) + } catch {} + } +} diff --git a/packages/intent/src/setup/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts index 08005eed..995b34ab 100644 --- a/packages/intent/src/setup/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -1,8 +1,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' -import { parse as parseYaml } from 'yaml' -import { hasAnySkillFile } from '../shared/utils.js' +import { getParseYaml, hasAnySkillFile } from '../shared/utils.js' import type { ParseError } from 'jsonc-parser' function normalizeWorkspacePattern(pattern: string): string { @@ -72,7 +71,7 @@ function hasWorkspaceManifest(dir: string): boolean { } function readYamlFile(path: string): unknown { - return parseYaml(readFileSync(path, 'utf8')) + return getParseYaml()(readFileSync(path, 'utf8')) } function readJsonFile(path: string): unknown { diff --git a/packages/intent/src/shared/atomic-write.ts b/packages/intent/src/shared/atomic-write.ts new file mode 100644 index 00000000..362c7e5a --- /dev/null +++ b/packages/intent/src/shared/atomic-write.ts @@ -0,0 +1,34 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + existsSync, + mkdirSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +export function writeTextFileAtomic(path: string, content: string): void { + const directory = dirname(path) + mkdirSync(directory, { recursive: true }) + const temporaryPath = join( + directory, + `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, + ) + const mode = existsSync(path) ? statSync(path).mode & 0o777 : undefined + try { + writeFileSync(temporaryPath, content, { + encoding: 'utf8', + flag: 'wx', + ...(mode === undefined ? {} : { mode }), + }) + if (mode !== undefined) chmodSync(temporaryPath, mode) + renameSync(temporaryPath, path) + } finally { + try { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath) + } catch {} + } +} diff --git a/packages/intent/src/shared/command-runner.ts b/packages/intent/src/shared/command-runner.ts index 0bd681a9..1c0ef651 100644 --- a/packages/intent/src/shared/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -1,14 +1,50 @@ -import { detectPackageManager } from '../discovery/package-manager.js' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { PackageManager } from './types.js' -export { detectPackageManager as detectIntentCommandPackageManager } +export function packageVersionToPin(version: string): string { + if (version.includes('-')) return version + + const [major, minor] = version.split('.') + if (!major || !minor) throw new Error(`Invalid package version: ${version}`) + return `${major}.${minor}` +} + +function resolveIntentPackagePin(startDir: string): string { + let dir = startDir + + for (let limit = 0; limit < 10; limit++) { + try { + const packageJson = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as { name?: unknown; version?: unknown } + if ( + packageJson.name === '@tanstack/intent' && + typeof packageJson.version === 'string' + ) { + return packageVersionToPin(packageJson.version) + } + } catch {} + + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + + return 'latest' +} + +const intentPackagePin = resolveIntentPackagePin( + dirname(fileURLToPath(import.meta.url)), +) const runnerByPackageManager: Record = { - bun: 'bunx @tanstack/intent@latest', - npm: 'npx @tanstack/intent@latest', - pnpm: 'pnpm dlx @tanstack/intent@latest', - unknown: 'npx @tanstack/intent@latest', - yarn: 'yarn dlx @tanstack/intent@latest', + bun: `bunx @tanstack/intent@${intentPackagePin}`, + npm: `npx @tanstack/intent@${intentPackagePin}`, + pnpm: `pnpm dlx @tanstack/intent@${intentPackagePin}`, + unknown: `npx @tanstack/intent@${intentPackagePin}`, + yarn: `yarn dlx @tanstack/intent@${intentPackagePin}`, } export function formatIntentCommand( diff --git a/packages/intent/src/shared/display.ts b/packages/intent/src/shared/display.ts index 1cf7be52..08501e23 100644 --- a/packages/intent/src/shared/display.ts +++ b/packages/intent/src/shared/display.ts @@ -13,6 +13,7 @@ export interface SkillDisplay { loadCommand?: string type?: string path?: string + why?: string } function padColumn(text: string, width: number): string { @@ -49,6 +50,9 @@ function printSkillLine( ? (skill.type ? `[${skill.type}]` : '').padEnd(14) : '' console.log(`${nameStr}${padding}${typeCol}${skill.description}`) + if (skill.why) { + console.log(`${' '.repeat(indent + 2)}${skill.why}`) + } if (skill.loadCommand) { console.log(`${' '.repeat(indent + 2)}Load: ${skill.loadCommand}`) } diff --git a/packages/intent/src/shared/local-path.ts b/packages/intent/src/shared/local-path.ts new file mode 100644 index 00000000..1f6f0ad6 --- /dev/null +++ b/packages/intent/src/shared/local-path.ts @@ -0,0 +1,71 @@ +const EXPLICIT_LOCAL_PATH_PATTERN = + /(?:^|[\s"'`(]|\[)(?:file:(?:\/{1,3}|[A-Za-z]:[\\/])|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\[^\\\s]+[\\/])/i +const PACKAGE_MANAGER_PATH_PATTERN = + /(?:^|[\s"'`(]|\[)[^\s"'`]*(?:node_modules|\.pnpm|\.bun|\.yarn|\.intent)[\\/]/i +const POSIX_PATH_CANDIDATE_PATTERN = /(?:^|[\s"'`(]|\[)(\/[^\s"'`)\],;]+)/g +const SYSTEM_POSIX_ROOTS = new Set([ + 'Applications', + 'Library', + 'System', + 'bin', + 'boot', + 'dev', + 'etc', + 'lib', + 'lib64', + 'private', + 'proc', + 'root', + 'run', + 'sbin', + 'sys', + 'tmp', + 'usr', + 'var', +]) +const USER_DATA_POSIX_ROOTS = new Set([ + 'Users', + 'Volumes', + 'home', + 'media', + 'mnt', + 'opt', + 'srv', + 'workspace', +]) +const HOME_POSIX_ROOTS = new Set(['Users', 'home']) + +export function containsLocalPath(value: string): boolean { + if ( + EXPLICIT_LOCAL_PATH_PATTERN.test(value) || + PACKAGE_MANAGER_PATH_PATTERN.test(value) + ) { + return true + } + + for (const match of value.matchAll(POSIX_PATH_CANDIDATE_PATTERN)) { + if (isLikelyLocalPosixPath(match[1]!)) return true + } + + return false +} + +function isLikelyLocalPosixPath(candidate: string): boolean { + const path = candidate.replace(/[.!?:]+$/, '') + const segments = path.slice(1).split('/').filter(Boolean) + const root = segments[0] + const leaf = segments.at(-1) ?? '' + + return ( + looksLikeFileName(leaf) || + (root !== undefined && SYSTEM_POSIX_ROOTS.has(root)) || + (root !== undefined && + USER_DATA_POSIX_ROOTS.has(root) && + (segments.length >= 3 || + (HOME_POSIX_ROOTS.has(root) && segments.length >= 2))) + ) +} + +function looksLikeFileName(value: string): boolean { + return value.startsWith('.') || /\.[A-Za-z0-9][\w.-]*$/.test(value) +} diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index f3d6930e..78509163 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -4,15 +4,24 @@ import { existsSync, lstatSync, openSync, + opendirSync, readFileSync, readSync, readdirSync, realpathSync, } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, resolve, sep } from 'node:path' -import { parse as parseYaml } from 'yaml' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import type { Dirent } from 'node:fs' +import type { parse as ParseYaml } from 'yaml' + +const requireFromHere = createRequire(import.meta.url) +let parseYaml: typeof ParseYaml | undefined + +export function getParseYaml(): typeof ParseYaml { + parseYaml ??= (requireFromHere('yaml') as { parse: typeof ParseYaml }).parse + return parseYaml +} /** * The subset of `node:fs` the scanner reads through. Under Yarn PnP this is @@ -35,6 +44,7 @@ export interface ReadFs { openSync?: typeof openSync readSync?: typeof readSync closeSync?: typeof closeSync + opendirSync?: typeof opendirSync } export const nodeReadFs: ReadFs = { @@ -46,6 +56,7 @@ export const nodeReadFs: ReadFs = { openSync, readSync, closeSync, + opendirSync, } /** @@ -55,6 +66,14 @@ export function toPosixPath(p: string): string { return p.split(sep).join('/') } +export function isPathWithin(parent: string, candidate: string): boolean { + const rel = relative(parent, candidate) + return ( + rel === '' || + (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)) + ) +} + export function createFsIdentityCache( getFs: () => ReadFs = () => nodeReadFs, ): (path: string) => string { @@ -153,7 +172,7 @@ export function getDeps( for (const field of fields) { const d = pkgJson[field] if (d && typeof d === 'object') { - for (const name of Object.keys(d as Record)) { + for (const name of Object.keys(d)) { deps.add(name) } } @@ -402,7 +421,7 @@ export function parseFrontmatter( const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) if (!match?.[1]) return null try { - return parseYaml(match[1]) as Record + return getParseYaml()(match[1]) as Record } catch { return null } diff --git a/packages/intent/src/skills/catalogue-contract.ts b/packages/intent/src/skills/catalogue-contract.ts new file mode 100644 index 00000000..950acedb --- /dev/null +++ b/packages/intent/src/skills/catalogue-contract.ts @@ -0,0 +1,16 @@ +export const SESSION_CATALOGUE_MAX_BYTES = 8_000 +export const SESSION_CATALOGUE_MAX_SKILLS = 50 +export const SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH = 180 + +export function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +export function truncateText(value: string, maxLength: number): string { + const codePoints = [...value] + if (codePoints.length <= maxLength) return value + return `${codePoints + .slice(0, maxLength - 3) + .join('') + .trimEnd()}...` +} diff --git a/packages/intent/src/skills/categories.ts b/packages/intent/src/skills/categories.ts index 09581f87..e40d9b32 100644 --- a/packages/intent/src/skills/categories.ts +++ b/packages/intent/src/skills/categories.ts @@ -3,6 +3,23 @@ import type { SkillEntry } from '../shared/types.js' export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' const MAINTAINER_TYPES = new Set(['maintainer', 'maintainer-only']) +// core..security are what the authoring meta-skills emit; getSkillCategory maps them to 'task'. +const KNOWN_SKILL_TYPES = new Set([ + 'task', + 'reference', + 'meta', + 'core', + 'sub-skill', + 'framework', + 'lifecycle', + 'composition', + 'security', + ...MAINTAINER_TYPES, +]) + +export function isKnownSkillType(type: string): boolean { + return KNOWN_SKILL_TYPES.has(type.trim().toLowerCase()) +} export function getSkillCategory( skill: Pick, diff --git a/packages/intent/src/skills/resolver.ts b/packages/intent/src/skills/resolver.ts index 93f0142d..849a2ea1 100644 --- a/packages/intent/src/skills/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -8,6 +8,7 @@ import type { } from '../shared/types.js' export interface ResolveSkillResult { + kind: IntentPackage['kind'] packageName: string skillName: string path: string @@ -182,6 +183,7 @@ export function resolveSkillUse( ) ?? null return { + kind: pkg.kind, packageName, skillName: skill.name, path: skill.path, diff --git a/packages/intent/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts new file mode 100644 index 00000000..b958129d --- /dev/null +++ b/packages/intent/tests/catalog-api.test.ts @@ -0,0 +1,392 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { applyCatalogueLock } from '../src/catalog-lock.js' +import { + getIntentCatalogContext, + runSessionCatalogueHook, +} from '../src/catalog.js' +import { listIntentSkills } from '../src/core/index.js' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { getProjectReadFs } from '../src/discovery/scanner.js' +import { + formatSessionCatalogue, + getSessionCatalogue, +} from '../src/session-catalog.js' +import type { ReadFs } from '../src/shared/utils.js' + +const roots: Array = [] + +function fixture(): { root: string; packageRoot: string; skillDir: string } { + const root = mkdtempSync(join(tmpdir(), 'intent-catalog-api-')) + const packageRoot = join(root, 'node_modules', '@fixture', 'package') + const skillDir = join(packageRoot, 'skills', 'core') + const siblingDir = join(packageRoot, 'skills', 'sibling') + roots.push(root) + mkdirSync(skillDir, { recursive: true }) + mkdirSync(siblingDir, { recursive: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'catalog-consumer', + private: true, + dependencies: { '@fixture/package': '1.0.0' }, + intent: { skills: ['@fixture/package'] }, + }), + ) + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@fixture/package', + version: '1.0.0', + intent: { version: 1, repo: 'fixture/package', docs: 'docs/' }, + }), + ) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Core package guidance\n---\n\nBody.\n', + ) + writeFileSync( + join(siblingDir, 'SKILL.md'), + '---\nname: sibling\ndescription: Sibling package guidance\n---\n', + ) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: '@fixture/package', + skills: [ + { + path: 'skills/core', + contentHash: computeSkillContentHash({ packageRoot, skillDir }), + }, + { + path: 'skills/sibling', + contentHash: computeSkillContentHash({ + packageRoot, + skillDir: siblingDir, + }), + }, + ], + }, + ], + }) + return { root, packageRoot, skillDir } +} + +function getReadFsWithUnhashableDirectory( + root: string, + unhashableDirectory: string, +): ReadFs { + const nodeFs = getProjectReadFs(root) + return { + ...nodeFs, + realpathSync: ((path: Parameters[0]) => { + if (String(path).startsWith(unhashableDirectory)) { + throw new Error('Injected unhashable directory') + } + return nodeFs.realpathSync(path) + }) as ReadFs['realpathSync'], + } +} + +async function getFixtureCatalogContext( + root: string, + cacheDir: string, + readFs: ReadFs = getProjectReadFs(root), +) { + let warnings: Array = [] + const result = await getSessionCatalogue({ + cacheDir, + root, + readFs, + discover: () => { + const discovered = applyCatalogueLock( + listIntentSkills({ audience: 'agent', cwd: root }), + root, + readFs, + ) + warnings = discovered.result.warnings + return discovered + }, + }) + return { + ...result, + context: formatSessionCatalogue(result.catalogue), + warnings, + } +} + +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('getIntentCatalogContext', () => { + it('refreshes without replacing an existing cache after intent.lock is removed', async () => { + const { root, skillDir } = fixture() + const cacheDir = join(root, 'cache') + const first = await getFixtureCatalogContext(root, cacheDir) + const cachedBytes = readFileSync(first.cachePath) + rmSync(join(root, 'intent.lock')) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Changed unverified guidance\n---\n', + ) + + const changed = await getFixtureCatalogContext(root, cacheDir) + + expect(first.cacheStatus).toBe('miss') + expect(changed.cacheStatus).toBe('refresh') + expect(changed.context).toContain('Changed unverified guidance') + expect(changed.context).not.toContain('Core package guidance') + expect(readFileSync(first.cachePath)).toEqual(cachedBytes) + }) + + it('rediscovers changed context when intent.lock is missing', async () => { + const { root, skillDir } = fixture() + rmSync(join(root, 'intent.lock')) + + const first = await getIntentCatalogContext({ cwd: root }) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Changed guidance\n---\n', + ) + const changed = await getIntentCatalogContext({ cwd: root }) + + expect(first.cacheStatus).toBe('miss') + expect(first.context).toContain('Core package guidance') + expect(changed.cacheStatus).toBe('miss') + expect(changed.context).toContain('Changed guidance') + expect(changed.context).not.toContain('Core package guidance') + }) + + it('reuses accepted context and withholds drifted skill content', async () => { + const { root, skillDir } = fixture() + + const first = await getIntentCatalogContext({ cwd: root }) + const second = await getIntentCatalogContext({ cwd: root }) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Changed guidance\n---\n', + ) + const changed = await getIntentCatalogContext({ cwd: root }) + + expect(first.cacheStatus).toBe('miss') + expect(Object.keys(first).sort()).toEqual(['cacheStatus', 'context']) + expect(first.context).toContain('@fixture/package#core') + expect(second.cacheStatus).toBe('hit') + expect(changed.cacheStatus).toBe('refresh') + expect(changed.context).not.toContain('@fixture/package#core') + expect(changed.context).toContain('@fixture/package#sibling') + }) + + it('restores an accepted skill after its exact locked content returns', async () => { + const { root, skillDir } = fixture() + const cacheDir = join(root, 'cache') + const originalSkill = readFileSync(join(skillDir, 'SKILL.md')) + const first = await getFixtureCatalogContext(root, cacheDir) + const cachedBytes = readFileSync(first.cachePath) + + expect(first.cacheStatus).toBe('miss') + expect(first.context).toContain('@fixture/package#core') + expect(first.context).toContain('@fixture/package#sibling') + + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Drifted guidance\n---\n', + ) + + const drifted = await getFixtureCatalogContext(root, cacheDir) + + expect(drifted.cacheStatus).toBe('refresh') + expect(drifted.context).not.toContain('@fixture/package#core') + expect(drifted.context).toContain('@fixture/package#sibling') + expect(drifted.warnings).toContain( + '1 skill was withheld because installed content does not match intent.lock.', + ) + + const unchangedDrifted = await getFixtureCatalogContext(root, cacheDir) + + expect(unchangedDrifted.cacheStatus).toBe('hit') + expect(unchangedDrifted.context).not.toContain('@fixture/package#core') + expect(unchangedDrifted.context).toContain('@fixture/package#sibling') + expect(readFileSync(first.cachePath)).not.toEqual(cachedBytes) + + writeFileSync(join(skillDir, 'SKILL.md'), originalSkill) + const restored = await getFixtureCatalogContext(root, cacheDir) + + expect(restored.cacheStatus).toBe('refresh') + expect(restored.context).toContain('@fixture/package#core') + expect(restored.context).toContain('@fixture/package#sibling') + }) + + it('withholds a newly discovered skill without changing accepted siblings', async () => { + const { root, packageRoot } = fixture() + const cacheDir = join(root, 'cache') + const newSkillDir = join(packageRoot, 'skills', 'new-skill') + mkdirSync(newSkillDir, { recursive: true }) + writeFileSync( + join(newSkillDir, 'SKILL.md'), + '---\nname: new-skill\ndescription: New package guidance\n---\n', + ) + + const first = await getFixtureCatalogContext(root, cacheDir) + const second = await getFixtureCatalogContext(root, cacheDir) + + expect(first.cacheStatus).toBe('miss') + expect(first.context).toContain('@fixture/package#core') + expect(first.context).toContain('@fixture/package#sibling') + expect(first.context).not.toContain('@fixture/package#new-skill') + expect(first.warnings).toContain( + '1 skill was withheld because no matching intent.lock entry exists.', + ) + expect(second.cacheStatus).toBe('hit') + expect(second.context).toEqual(first.context) + }) + + it('withholds an unhashable skill without withholding a healthy sibling or caching', async () => { + const { root, skillDir } = fixture() + const cacheDir = join(root, 'cache') + const readFs = getReadFsWithUnhashableDirectory(root, skillDir) + + const first = await getFixtureCatalogContext(root, cacheDir, readFs) + const second = await getFixtureCatalogContext(root, cacheDir, readFs) + + expect(first.cacheStatus).toBe('miss') + expect(first.context).not.toContain('@fixture/package#core') + expect(first.context).toContain('@fixture/package#sibling') + expect(first.warnings).toContain( + '1 skill was withheld because installed content could not be verified.', + ) + expect(second.cacheStatus).toBe('miss') + expect(second.context).toEqual(first.context) + }) + + it('ignores an excluded unhashable skill during lock verification', () => { + const { root, packageRoot, skillDir } = fixture() + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'catalog-consumer', + private: true, + dependencies: { '@fixture/package': '1.0.0' }, + intent: { + skills: ['@fixture/package'], + exclude: ['@fixture/package#core'], + }, + }), + ) + const readFs = getReadFsWithUnhashableDirectory(root, skillDir) + + const locked = applyCatalogueLock( + listIntentSkills({ audience: 'agent', cwd: root }), + root, + readFs, + ) + + expect(locked.result.skills.map((skill) => skill.use)).toEqual([ + '@fixture/package#sibling', + ]) + expect(locked.result.warnings).not.toContain( + '1 skill was withheld because installed content could not be verified.', + ) + expect(locked.verification).toEqual([ + { + packageRoot, + skillPath: 'skills/sibling', + contentHash: computeSkillContentHash({ + packageRoot, + skillDir: join(packageRoot, 'skills', 'sibling'), + }), + }, + ]) + }) +}) + +describe('runSessionCatalogueHook', () => { + it('writes generic Copilot context when the catalogue cannot be built', async () => { + const { root } = fixture() + writeFileSync(join(root, 'intent.lock'), '{broken') + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + await runSessionCatalogueHook({ + agent: 'copilot', + event: { cwd: root, source: 'startup' }, + }) + + expect(stdout).toHaveBeenCalledTimes(1) + const output = String(stdout.mock.calls[0]![0]) + expect(JSON.parse(output)).toEqual({ + additionalContext: + 'Intent skills are unavailable because the catalogue could not be built. Run `intent catalog` outside the agent session for details.', + }) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0]![0])).toContain( + '[intent catalog] hook failed open:', + ) + expect(output).not.toContain('Invalid intent.lock JSON') + expect(output).not.toContain(root) + }) + + it('writes the documented Copilot output shape', async () => { + const { root } = fixture() + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + await runSessionCatalogueHook({ + agent: 'copilot', + event: { cwd: root, source: 'startup' }, + }) + + expect(JSON.parse(String(stdout.mock.calls[0]![0]))).toMatchObject({ + additionalContext: expect.stringContaining('@fixture/package#core'), + }) + expect(stderr).not.toHaveBeenCalled() + }) + + it('writes the documented Codex output shape', async () => { + const { root } = fixture() + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + await runSessionCatalogueHook({ + agent: 'codex', + event: { cwd: root, hook_event_name: 'SessionStart' }, + }) + + expect(JSON.parse(String(stdout.mock.calls[0]![0]))).toMatchObject({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: expect.stringContaining('@fixture/package#core'), + }, + }) + expect(stderr).not.toHaveBeenCalled() + }) + + it('ignores non-lifecycle events', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + + await runSessionCatalogueHook({ agent: 'claude', event: {} }) + + expect(stdout).not.toHaveBeenCalled() + }) +}) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe47..08b05aab 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1,22 +1,33 @@ +import { execFileSync } from 'node:child_process' import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { INSTALL_PROMPT } from '../src/commands/install/command.js' +import { writeIntentDeliveryConfig } from '../src/commands/install/delivery.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { scanForIntents } from '../src/discovery/scanner.js' +import { packageVersionToPin } from '../src/shared/command-runner.js' import { isMainModule, main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') const packageJsonPath = join(thisDir, '..', 'package.json') +const intentPackagePin = packageVersionToPin( + (JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version: string }) + .version, +) const realTmpdir = realpathSync(tmpdir()) function writeJson(filePath: string, data: unknown): void { @@ -24,6 +35,24 @@ function writeJson(filePath: string, data: unknown): void { writeFileSync(filePath, JSON.stringify(data, null, 2)) } +function writeAllowAllConsumer(root: string): void { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['*'] }, + }) +} + +function writeIntentLock( + root: string, + packages: Parameters[0] = [], +): void { + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources(packages), + }) +} + function writeSkillMd(dir: string, frontmatter: Record): void { mkdirSync(dir, { recursive: true }) const yamlLines = Object.entries(frontmatter) @@ -53,6 +82,34 @@ function writeInstalledIntentPackage( version: string }, ): void { + const consumerPackageJsonPath = join(root, 'package.json') + const consumerPackageJson = existsSync(consumerPackageJsonPath) + ? (JSON.parse(readFileSync(consumerPackageJsonPath, 'utf8')) as Record< + string, + unknown + >) + : { name: 'app', private: true } + const consumerIntent = + consumerPackageJson.intent && + typeof consumerPackageJson.intent === 'object' && + !Array.isArray(consumerPackageJson.intent) + ? (consumerPackageJson.intent as Record) + : {} + const consumerDependencies = + consumerPackageJson.dependencies && + typeof consumerPackageJson.dependencies === 'object' && + !Array.isArray(consumerPackageJson.dependencies) + ? (consumerPackageJson.dependencies as Record) + : {} + writeJson(consumerPackageJsonPath, { + ...consumerPackageJson, + dependencies: { ...consumerDependencies, [name]: version }, + intent: { + ...consumerIntent, + ...(!Object.hasOwn(consumerIntent, 'skills') ? { skills: ['*'] } : {}), + }, + }) + const pkgDir = join(root, 'node_modules', ...name.split('/')) writeJson(join(pkgDir, 'package.json'), { name, @@ -65,6 +122,57 @@ function writeInstalledIntentPackage( }) } +function writeConflictingQueryPackages(root: string): { + queryV4Dir: string + queryV5Dir: string +} { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['*'] }, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = join(root, 'node_modules', 'consumer-a') + const consumerBDir = join(root, 'node_modules', 'consumer-b') + const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query') + const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': '4.0.0' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + writeJson(join(queryV4Dir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(queryV5Dir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v4 skill', + }) + writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v5 skill', + }) + + return { queryV4Dir, queryV5Dir } +} + let originalCwd: string let logSpy: ReturnType let infoSpy: ReturnType @@ -222,20 +330,248 @@ describe('cli commands', () => { expect(output).toContain('--show-hidden') }) - it('prints the install prompt', async () => { - const exitCode = await main(['install', '--print-prompt']) - const output = String(logSpy.mock.calls[0]?.[0]) + it('omits the removed non-interactive install and standalone hook options from help', async () => { + expect(await main(['install', '--help'])).toBe(0) + expect(getHelpOutput()).not.toContain('--no-input') + + infoSpy.mockClear() + logSpy.mockClear() + + expect(await main(['hooks', '--help'])).toBe(0) + expect(getHelpOutput()).not.toContain('--scope') + expect(getHelpOutput()).not.toContain('--agents') + }) + + it('does nothing without local delivery configuration', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-unconfigured-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + process.chdir(root) + + const exitCode = await main(['sync']) expect(exitCode).toBe(0) - expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) - expect(output).toContain('tanstackIntent:') - expect(output).toContain(' - id: "@scope/package#skill-name"') - expect(output).toContain( - ' run: "npx @tanstack/intent@latest load @scope/package#skill-name"', + expect(errorSpy).toHaveBeenCalledOnce() + expect(errorSpy).toHaveBeenCalledWith( + 'Intent skill delivery is not configured for this checkout. Run `intent install` to configure it.', + ) + expect(readdirSync(root)).toEqual(['package.json']) + }) + + it('syncs verified links and reports changed, pending, removed, and dry-run work', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + execFileSync('git', ['init', '--quiet'], { cwd: root }) + writeIntentDeliveryConfig(root, { + method: 'symlink', + targets: ['github', 'vscode'], + }) + process.chdir(root) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeIntentLock(root, discovered) + + expect(await main(['sync'])).toBe(0) + const linkPath = join(root, '.github', 'skills', 'npm-verified-core') + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + const state = readFileSync( + join(root, '.intent', 'install-state.json'), + 'utf8', + ) + const excludePath = resolve( + root, + execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { + cwd: root, + encoding: 'utf8', + }).trim(), + ) + const localExclude = readFileSync(excludePath, 'utf8') + expect(localExclude).toContain('.intent/delivery.json') + expect(localExclude).toContain('.intent/install-state.json') + expect(localExclude).toContain('.github/skills/npm-verified-core') + expect(existsSync(join(root, '.gitignore'))).toBe(false) + expect(await main(['sync'])).toBe(0) + expect( + readFileSync(join(root, '.intent', 'install-state.json'), 'utf8'), + ).toBe(state) + + writeSkillMd( + join(root, 'node_modules', 'verified', 'skills', 'additional'), + { + name: 'additional', + description: 'Additional skill', + }, + ) + expect(await main(['sync'])).toBe(0) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + [ + 'New skills found in enabled dependencies:', + '', + 'verified 1 skill', + '', + 'Run `intent install` to review and install them.', + ].join('\n'), + ) + + writeFileSync( + join(root, 'node_modules', 'verified', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: changed\n---\n', + ) + expect(await main(['sync'])).toBe(0) + expect(existsSync(linkPath)).toBe(false) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + [ + 'Changed skill content:', + '', + 'verified 1 skill', + '', + 'Run `intent install` to review and accept the new baseline.', + ].join('\n'), + ) + expect( + JSON.parse( + readFileSync(join(root, '.intent', 'install-state.json'), 'utf8'), + ), + ).toEqual({ version: 1, entries: [] }) + + writeInstalledIntentPackage(root, { + name: 'pending', + version: '1.0.0', + skillName: 'new', + description: 'Pending skill', + }) + expect(await main(['sync'])).toBe(0) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + [ + 'Pending skills by source:', + '', + 'pending 1 skill', + '', + 'Run `intent install` to review and install them, or add them to `intent.exclude`.', + ].join('\n'), + ) + + rmSync(join(root, 'node_modules', 'verified'), { + recursive: true, + force: true, + }) + expect(await main(['sync'])).toBe(0) + expect(existsSync(linkPath)).toBe(false) + + const dryRoot = mkdtempSync(join(realTmpdir, 'intent-cli-sync-dry-run-')) + tempDirs.push(dryRoot) + writeJson(join(dryRoot, 'package.json'), { + name: 'dry-app', + private: true, + intent: { + skills: ['dry-package'], + }, + }) + writeInstalledIntentPackage(dryRoot, { + name: 'dry-package', + version: '1.0.0', + skillName: 'core', + description: 'Dry skill', + }) + writeIntentDeliveryConfig(dryRoot, { + method: 'symlink', + targets: ['agents'], + }) + const dryDiscovered = scanForIntents(dryRoot, { scope: 'local' }).packages + writeIntentLock(dryRoot, dryDiscovered) + process.chdir(dryRoot) + expect(await main(['sync', '--dry-run', '--json'])).toBe(0) + expect( + existsSync(join(dryRoot, '.agents', 'skills', 'npm-dry-package-core')), + ).toBe(false) + expect(existsSync(join(dryRoot, '.intent', 'install-state.json'))).toBe( + false, ) - expect(output).toContain(' for: "describe the task or code area here"') - expect(output).not.toContain('skills:\n - when:') - expect(output).not.toContain('use: "@scope/package#skill-name"') + }) + + it('reports sync preflight conflicts before failing without writing', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-conflict-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified#core'], + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + writeIntentDeliveryConfig(root, { + method: 'symlink', + targets: ['agents'], + }) + process.chdir(root) + + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeIntentLock(root, discovered) + expect(await main(['sync'])).toBe(0) + const statePath = join(root, '.intent', 'install-state.json') + const conflictPath = '.agents/skills/npm-verified-core' + const linkPath = join(root, conflictPath) + rmSync(statePath) + logSpy.mockClear() + + expect(await main(['sync', '--json'])).toBe(1) + + expect(logSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + created: [], + repaired: [], + removed: [], + conflicts: [conflictPath], + }) + expect(existsSync(statePath)).toBe(false) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + + logSpy.mockClear() + + expect(await main(['sync'])).toBe(1) + + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('Intent sync: 0 created, 0 repaired, 0 removed.') + expect(output).toContain(`Conflicts: ${conflictPath}.`) + expect(existsSync(statePath)).toBe(false) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + + it('rejects the removed install --print-prompt option', async () => { + const exitCode = await main(['install', '--print-prompt']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Unknown option `--printPrompt`') + }) + + it('rejects the removed install --no-input option before writing files', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-flags-')) + tempDirs.push(root) + const entriesBefore = readdirSync(root) + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith('Unknown option `--input`') + expect(readdirSync(root)).toEqual(entriesBefore) }) it('lists excludes when none are configured', async () => { @@ -253,6 +589,32 @@ describe('cli commands', () => { expect(logSpy).toHaveBeenCalledWith('No excludes configured.') }) + it.each([{ command: ['list'] }, { command: ['add', 'new-pkg'] }])( + 'keeps exclude mutations strict for released config ($command)', + async ({ command }) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-exclude-write-legacy-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: null, exclude: ['legacy-pkg'] }, + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) + + expect(await main(['exclude', ...command])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Invalid package.json intent configuration: intent.skills must be an array of strings.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + }, + ) + it('adds and lists an exclude pattern', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-add-')) tempDirs.push(root) @@ -349,147 +711,64 @@ describe('cli commands', () => { ) }) - it('writes skill loading guidance by default and is idempotent', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-')) - const isolatedGlobalRoot = mkdtempSync( - join(realTmpdir, 'intent-cli-install-empty-global-'), - ) - tempDirs.push(root, isolatedGlobalRoot) - writeInstalledIntentPackage(root, { - name: '@tanstack/query', - version: '5.0.0', - skillName: 'fetching', - description: 'Query data fetching patterns', - }) - - process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot - process.chdir(root) - - const exitCode = await main(['install']) - const agentsPath = join(root, 'AGENTS.md') - const content = readFileSync(agentsPath, 'utf8') - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('Created AGENTS.md with skill loading guidance.') - expect(content).toContain('## Skill Loading') - expect(content).toContain('npx @tanstack/intent@latest list') - expect(content).toContain('If a listed skill matches the task') - expect(content).toContain('before changing files') - expect(content).toContain('Monorepos:') - expect(content).toContain('Multiple matches:') - expect(content).not.toContain('--global') - expect(content).not.toContain('use: "@tanstack/query#fetching"') - expect(content).not.toContain(root) - expect(output).toContain( - 'Tip: Keep the intent-skills block near the top of AGENTS.md', - ) - - logSpy.mockClear() - - const secondExitCode = await main(['install']) - const secondOutput = logSpy.mock.calls.flat().join('\n') + it.each([{ flags: [] }, { flags: ['--dry-run'] }])( + 'fails without writing when interactive install runs outside a TTY ($flags)', + async ({ flags }) => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-nontty-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + process.chdir(root) - expect(secondExitCode).toBe(0) - expect(secondOutput).toContain( - 'No changes to AGENTS.md; skill loading guidance already current.', - ) - expect(readFileSync(agentsPath, 'utf8')).toBe(content) - }) + const exitCode = await main(['install', ...flags]) - it('prints generated skill loading guidance without writing during dry run', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-dry-run-')) - const isolatedGlobalRoot = mkdtempSync( - join(realTmpdir, 'intent-cli-install-dry-run-empty-global-'), - ) - tempDirs.push(root, isolatedGlobalRoot) - writeInstalledIntentPackage(root, { - name: '@tanstack/router', - version: '1.0.0', - skillName: 'routing', - description: 'Router patterns', - }) + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map`.', + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }, + ) - process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + it('rejects the removed hooks install action', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) + tempDirs.push(root) process.chdir(root) - const exitCode = await main(['install', '--dry-run']) - const output = logSpy.mock.calls.flat().join('\n') + const exitCode = await main(['hooks', 'install']) - expect(exitCode).toBe(0) - expect(output).toContain('Generated skill loading guidance for AGENTS.md.') - expect(output).toContain('npx @tanstack/intent@latest list') - expect(output).toContain( - 'npx @tanstack/intent@latest load #', - ) + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Unknown hooks action: expected run.') + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) - it('prints package-manager-specific install guidance', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-install-package-runner-'), - ) + it('runs the session catalogue hook for a valid agent', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-run-')) tempDirs.push(root) - writeFileSync(join(root, 'pnpm-lock.yaml'), '') - process.chdir(root) - const exitCode = await main(['install', '--dry-run']) - const output = logSpy.mock.calls.flat().join('\n') + const exitCode = await main(['hooks', 'run', '--agent', 'claude']) expect(exitCode).toBe(0) - expect(output).toContain('pnpm dlx @tanstack/intent@latest list') - expect(output).toContain( - 'pnpm dlx @tanstack/intent@latest load #', - ) }) - it('writes skill loading guidance even with no discovered skills', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-empty-')) - const isolatedGlobalRoot = mkdtempSync( - join(realTmpdir, 'intent-cli-install-empty-global-'), - ) - tempDirs.push(root, isolatedGlobalRoot) - - process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot - process.chdir(root) + it('requires an agent for hooks run', async () => { + const exitCode = await main(['hooks', 'run']) - const exitCode = await main(['install']) - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('Created AGENTS.md with skill loading guidance.') - expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( - 'npx @tanstack/intent@latest list', + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Missing hook agent. Expected copilot, claude, or codex.', ) }) - it('installs hooks with the hooks install command', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['hooks', 'install', '--agents', 'claude']) - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('Installed Intent hooks for claude (project)') - expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) - }) - - it('fails cleanly for invalid hooks install options', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-bad-options-')) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['hooks', 'install', '--scope', 'repo']) + it('fails cleanly for an invalid hooks run agent', async () => { + const exitCode = await main(['hooks', 'run', '--agent', 'cursor']) expect(exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( - 'Unknown hook scope: repo. Expected project or user.', + 'Unknown hook agent: cursor. Expected copilot, claude, or codex.', ) - expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) }) it('writes install mappings with --map and is idempotent', async () => { @@ -506,21 +785,28 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['install', '--map']) const agentsPath = join(root, 'AGENTS.md') const content = readFileSync(agentsPath, 'utf8') - const output = logSpy.mock.calls.flat().join('\n') + const output = [...logSpy.mock.calls, ...errorSpy.mock.calls] + .flat() + .join('\n') expect(exitCode).toBe(0) expect(output).toContain('Created AGENTS.md with 1 mapping.') + expect(output).toContain( + 'The intent-skills block is a snapshot and does not update when dependencies change. Re-run `intent install --map` to regenerate it.', + ) expect(content).toContain('for: "Query data fetching patterns"') expect(content).toContain('id: "@tanstack/query#fetching"') expect(content).toContain( - 'run: "npx @tanstack/intent@latest load @tanstack/query#fetching"', + `run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching"`, ) expect(content).not.toContain('load:') + expect(content).not.toContain('snapshot') expect(content).not.toContain(root) logSpy.mockClear() @@ -535,6 +821,32 @@ describe('cli commands', () => { expect(readFileSync(agentsPath, 'utf8')).toBe(content) }) + it('does not print the install mapping snapshot advisory to agents', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-map-agent-')) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-install-map-agent-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['install', '--map']) + const output = [...logSpy.mock.calls, ...errorSpy.mock.calls] + .flat() + .join('\n') + + expect(exitCode).toBe(0) + expect(output).not.toContain('snapshot') + }) + it('omits unlisted packages from the install --map block', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-allowlist-')) const isolatedGlobalRoot = mkdtempSync( @@ -576,6 +888,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-install-local-only-global-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -605,6 +918,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-install-global-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -839,11 +1153,31 @@ describe('cli commands', () => { expect(parsed.warnings).toEqual([]) }) + it('prints the empty default list message', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-empty-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [] }, + }) + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('No intent-enabled packages found.') + }) + it('prints full load commands for every skill in human list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-load-commands-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(pkgDir, 'package.json'), { name: '@tanstack/query', version: '5.0.0', @@ -858,18 +1192,324 @@ describe('cli commands', () => { description: 'Query cache skill', }) + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) const output = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('PACKAGE') + expect(output).toContain('SOURCE') + expect(output).toContain('VERSION') + expect(output).toContain('SKILLS') + expect(stderr).toContain('Notices:') + expect(output).toContain( + `Load: pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching`, + ) + expect(output).toContain( + `Load: pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#query/cache`, + ) + expect(output.match(/Load:/g)).toHaveLength(2) + }) + + it('explains why human-listed skills are available', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + const excludePattern = '@tanstack/query#mutations' + + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: [ + '@tanstack/query#fetching', + '@tanstack/query#query/cache', + excludePattern, + ], + exclude: [excludePattern], + }, + }) + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + writeSkillMd(join(pkgDir, 'skills', 'query', 'cache'), { + name: 'query/cache', + description: 'Query cache skill', + }) + writeSkillMd(join(pkgDir, 'skills', 'mutations'), { + name: 'mutations', + description: 'Query mutations skill', + }) + + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--why']) + const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching', + 'Allowed by intent.skills["@tanstack/query#fetching"]', + ) + expect(output).toContain( + 'Allowed by intent.skills["@tanstack/query#query/cache"]', + ) + expect(output.indexOf('Allowed by')).toBeLessThan(output.indexOf('Load:')) + + logSpy.mockClear() + const jsonExitCode = await main(['list', '--json', '--why']) + const jsonOutput = logSpy.mock.calls.flat().join('\n') + const parsed = JSON.parse(jsonOutput) as { + skills: Array<{ use: string; why?: string }> + excludedSkills: Array<{ + use: string + why?: string + excluded: true + }> + } + + expect(jsonExitCode).toBe(0) + expect( + Object.fromEntries(parsed.skills.map(({ use, why }) => [use, why])), + ).toMatchObject({ + '@tanstack/query#fetching': + 'Allowed by intent.skills["@tanstack/query#fetching"]', + '@tanstack/query#query/cache': + 'Allowed by intent.skills["@tanstack/query#query/cache"]', + }) + expect(parsed.excludedSkills).toContainEqual( + expect.objectContaining({ + use: excludePattern, + excluded: true, + why: `Excluded by intent.exclude[${JSON.stringify(excludePattern)}]`, + }), ) + }) + + it.each(['@tanstack/query#fetching', '@tanstack/query'])( + 'explains skills excluded by %s only under --why', + async (pattern) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-why-excluded-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: [pattern], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list'])).toBe(0) + const defaultOutput = logSpy.mock.calls.flat().join('\n') + expect(defaultOutput).not.toContain('@tanstack/query\n') + expect(defaultOutput).not.toContain('fetching') + logSpy.mockClear() + + expect(await main(['list', '--why'])).toBe(0) + const whyOutput = logSpy.mock.calls.flat().join('\n') + expect(whyOutput).toContain('@tanstack/query\n') + expect(whyOutput).toContain('fetching (excluded)') + expect(whyOutput).toContain( + `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, + ) + expect(whyOutput).not.toContain('Load:') + }, + ) + + it('names a package-level entry that allows a skill', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-package-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--why'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + + expect(output).toContain('Allowed by intent.skills["@tanstack/query"]') + }) + + it('explains explicit allow-all configuration', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-mode-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['*'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--why'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + + expect(output).toContain('Allowed because intent.skills allows all sources') + }) + + it('adds no output for --why in agent sessions', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-agent-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + expect(await main(['list'])).toBe(0) + const defaultOutput = logSpy.mock.calls + .map((call: Array) => call[0]) + .join('\n') + logSpy.mockClear() + + expect(await main(['list', '--why'])).toBe(0) + const whyOutput = logSpy.mock.calls + .map((call: Array) => call[0]) + .join('\n') + + expect(whyOutput).toBe(defaultOutput) + }) + + it.each([false, true])( + 'does not reveal policy-concealed skills under --why %#', + async (showHidden) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-why-concealed-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/router#routing'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/router', + version: '1.0.0', + skillName: 'routing', + description: 'Router navigation patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect( + await main(['list', '--why', ...(showHidden ? ['--show-hidden'] : [])]), + ).toBe(0) + const combinedOutput = [ + ...logSpy.mock.calls.flat(), + ...errorSpy.mock.calls.flat(), + ].join('\n') + + expect(combinedOutput).not.toContain('routing') + expect(combinedOutput).not.toContain( + 'intent.exclude["@tanstack/router#routing"]', + ) + }, + ) + + it('prints compact list guidance for agents', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-agent-')) + tempDirs.push(root) + writeAllowAllConsumer(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + writeSkillMd(join(pkgDir, 'skills', 'query', 'cache'), { + name: 'query/cache', + description: 'Query cache skill', + }) + + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls + .map((call: Array) => `${String(call[0] ?? '')}\n`) + .join('') + const stderr = errorSpy.mock.calls.flat().join('\n') + const loadHeader = `Load a skill with \`pnpm dlx @tanstack/intent@${intentPackagePin} load \`.` + + expect(exitCode).toBe(0) + expect(output).not.toContain('PACKAGE') + expect(output).not.toContain('SOURCE') + expect(output).not.toContain('VERSION') + expect(output).not.toContain('SKILLS') + expect(stderr).not.toContain('Notices:') + expect(stderr).not.toContain('intent.skills is not set') + expect(output.split(loadHeader)).toHaveLength(2) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#query/cache', + `1 intent-enabled packages, 2 skills\n\n${loadHeader}`, ) + expect(output).not.toContain( + `1 intent-enabled packages, 2 skills\n\n\n${loadHeader}`, + ) + expect(output).not.toContain('Load:') + expect(output).toContain('fetching') + expect(output).toContain('query/cache') }) it('reveals hidden skill sources for human list output when requested', async () => { @@ -907,6 +1547,37 @@ describe('cli commands', () => { expect(stderr).toContain('Add to opt in') }) + it('explains already-visible hidden sources without revealing more', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-why-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: 'get-tsconfig', + version: '4.0.0', + skillName: 'config', + description: 'TypeScript config lookup', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--show-hidden', '--why'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + + expect(output).toContain(' get-tsconfig (1 skill)') + expect(output).toContain(' Hidden because not listed in intent.skills') + expect(output.match(/get-tsconfig/g)).toHaveLength(1) + }) + it('does not reveal hidden skill sources to agent list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-agent-')) tempDirs.push(root) @@ -939,7 +1610,7 @@ describe('cli commands', () => { expect(combined).toContain( 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', ) - expect(combined).toContain( + expect(combined).not.toContain( '1 discovered skill source with 1 skill is hidden', ) expect(combined).not.toContain('get-tsconfig') @@ -988,9 +1659,9 @@ describe('cli commands', () => { }) it.each([ - ['pnpm-lock.yaml', 'pnpm dlx @tanstack/intent@latest'], - ['yarn.lock', 'yarn dlx @tanstack/intent@latest'], - ['bun.lock', 'bunx @tanstack/intent@latest'], + ['pnpm-lock.yaml', `pnpm dlx @tanstack/intent@${intentPackagePin}`], + ['yarn.lock', `yarn dlx @tanstack/intent@${intentPackagePin}`], + ['bun.lock', `bunx @tanstack/intent@${intentPackagePin}`], ])( 'prints %s load commands for human list output', async (lockfile, runner) => { @@ -1006,6 +1677,7 @@ describe('cli commands', () => { description: 'Query fetching skill', }) + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) @@ -1055,7 +1727,7 @@ describe('cli commands', () => { expect(output).not.toContain('Could not read') }) - it('prints the intent.skills migration notice to stderr, not stdout', async () => { + it('treats missing intent.skills as empty without a migration notice', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-migration-')) const isolatedGlobalRoot = mkdtempSync( join(realTmpdir, 'intent-cli-list-migration-empty-global-'), @@ -1067,8 +1739,10 @@ describe('cli commands', () => { skillName: 'fetching', description: 'Query data fetching patterns', }) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) @@ -1076,8 +1750,9 @@ describe('cli commands', () => { const stderr = errorSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(stdout).toContain('@tanstack/query') - expect(stderr).toContain('intent.skills is not set') + expect(stdout).toContain('No intent-enabled packages found.') + expect(stderr).toContain('intent.skills is empty') + expect(stderr).not.toContain('intent.skills is not set') expect(stdout).not.toContain('intent.skills is not set') expect(stdout).not.toContain('Notices:') }) @@ -1128,6 +1803,7 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list', '--no-notices']) @@ -1206,6 +1882,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-local-only-global-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -1237,6 +1914,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-global-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -1293,6 +1971,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-global-human-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -1306,6 +1985,7 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list', '--global']) @@ -1314,7 +1994,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Global fetching skill') expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching --global', + `Load: npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching --global`, ) expect(output).not.toContain(globalPkgDir) }) @@ -1325,6 +2005,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-mixed-global-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const localPkgDir = join(root, 'node_modules', '@tanstack', 'query') writeJson(join(localPkgDir, 'package.json'), { @@ -1457,51 +2138,21 @@ describe('cli commands', () => { ]) }) - it('rejects --global and --global-only together on list', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-list-')) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['list', '--global', '--global-only']) - - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Use either --global or --global-only, not both.', - ) - }) - - it('rejects --global and --global-only together on install', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-mutual-excl-install-'), - ) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['install', '--global', '--global-only']) - - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Use either --global or --global-only, not both.', - ) - }) - - it('rejects --global and --global-only together on load', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-load-')) - tempDirs.push(root) - process.chdir(root) + it.each([['list'], ['install'], ['load', '@tanstack/query#core']])( + 'rejects --global and --global-only together on %s', + async (...command) => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-')) + tempDirs.push(root) + process.chdir(root) - const exitCode = await main([ - 'load', - '@tanstack/query#core', - '--global', - '--global-only', - ]) + const exitCode = await main([...command, '--global', '--global-only']) - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Use either --global or --global-only, not both.', - ) - }) + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Use either --global or --global-only, not both.', + ) + }, + ) it('loads a local skill use as markdown', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-')) @@ -1525,6 +2176,7 @@ describe('cli commands', () => { it('rewrites relative markdown destinations when loading a skill', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-links-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') writeJson(join(pkgDir, 'package.json'), { @@ -1603,6 +2255,7 @@ describe('cli commands', () => { it('prints a skill path without reading skill content', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-path-only-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') writeJson(join(pkgDir, 'package.json'), { name: '@tanstack/query', @@ -1697,6 +2350,7 @@ describe('cli commands', () => { it('rewrites relative markdown destinations in json load content', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-json-links-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') writeJson(join(pkgDir, 'package.json'), { @@ -1736,6 +2390,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-load-global-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -1830,6 +2485,11 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-load-missing-package-'), ) tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) process.chdir(root) const exitCode = await main(['load', '@tanstack/query#fetching']) @@ -1883,63 +2543,68 @@ describe('cli commands', () => { ) }) - it('explains which package version was chosen when conflicts exist', async () => { + it('keeps full version conflict paths in human list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-')) tempDirs.push(root) + const { queryV4Dir, queryV5Dir } = writeConflictingQueryPackages(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - dependencies: { - 'consumer-a': '1.0.0', - 'consumer-b': '1.0.0', - }, - }) - - const consumerADir = join(root, 'node_modules', 'consumer-a') - const consumerBDir = join(root, 'node_modules', 'consumer-b') - const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query') - const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query') - - writeJson(join(consumerADir, 'package.json'), { - name: 'consumer-a', - version: '1.0.0', - dependencies: { '@tanstack/query': '4.0.0' }, - }) - writeJson(join(consumerBDir, 'package.json'), { - name: 'consumer-b', - version: '1.0.0', - dependencies: { '@tanstack/query': '5.0.0' }, - }) - writeJson(join(queryV4Dir, 'package.json'), { - name: '@tanstack/query', - version: '4.0.0', - intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, - }) - writeJson(join(queryV5Dir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, - }) - writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), { - name: 'fetching', - description: 'Query v4 skill', - }) - writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), { - name: 'fetching', - description: 'Query v5 skill', - }) - + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) - const output = logSpy.mock.calls.flat().join('\n') + const output = logSpy.mock.calls + .map((call: Array) => `${String(call[0] ?? '')}\n`) + .join('') expect(exitCode).toBe(0) expect(output).toContain('Version conflicts:') expect(output).toContain('@tanstack/query -> using 5.0.0') expect(output).toContain(`chosen: ${queryV5Dir}`) expect(output).toContain(`also found: 4.0.0 at ${queryV4Dir}`) + + logSpy.mockClear() + expect(await main(['list', '--json'])).toBe(0) + const jsonOutput = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + expect(jsonOutput).toContain(queryV4Dir) + expect(jsonOutput).toContain(queryV5Dir) + }) + + it('redacts version conflict paths from agent list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-agent-')) + tempDirs.push(root) + const { queryV4Dir, queryV5Dir } = writeConflictingQueryPackages(root) + + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + expect(await main(['list'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).not.toContain('Version conflicts:') + expect(output).not.toContain(queryV4Dir) + expect(output).not.toContain(queryV5Dir) + + logSpy.mockClear() + expect(await main(['list', '--json'])).toBe(0) + const jsonOutput = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + const parsed = JSON.parse(jsonOutput) as { conflicts: Array } + expect(parsed.conflicts).toEqual([]) + expect(jsonOutput).not.toContain(queryV4Dir) + expect(jsonOutput).not.toContain(queryV5Dir) + }) + + it('redacts scanner warning paths from agent list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-warnings-agent-')) + tempDirs.push(root) + const malformedPackageDir = join(root, 'node_modules', 'malformed') + mkdirSync(join(malformedPackageDir, 'skills'), { recursive: true }) + writeFileSync(join(malformedPackageDir, 'package.json'), '{') + + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + expect(await main(['list', '--json'])).toBe(0) + const jsonOutput = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + expect(jsonOutput).not.toContain(root) }) it('validates a well-formed skills directory', async () => { @@ -1961,6 +2626,205 @@ describe('cli commands', () => { ) }) + it('warns when a catalogue description is truncated', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-length-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'long'), { + name: 'long', + description: 'a'.repeat(200), + }) + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'skills/long/SKILL.md: catalogue description is truncated from 200 to 180 characters (20 lost)', + ) + }) + + it('reports per-skill catalogue shaping and classification warnings', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-skills-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'local'), { + name: 'local', + description: 'Read /Users/person/project/file.ts', + }) + const unknownDir = join(root, 'skills', 'unknown') + mkdirSync(unknownDir, { recursive: true }) + writeFileSync( + join(unknownDir, 'SKILL.md'), + [ + '---', + 'name: unknown', + 'description: Unknown catalogue type', + 'metadata:', + ' type: surprising', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + writeSkillMd(join(root, 'skills', 'duplicate-a'), { + name: 'duplicate-a', + description: 'Shared description', + }) + writeSkillMd(join(root, 'skills', 'duplicate-b'), { + name: 'duplicate-b', + description: 'Shared description', + }) + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'skills/local/SKILL.md: catalogue description contains a local path and is blanked', + ) + expect(output).toContain( + 'skills/unknown/SKILL.md: unknown metadata.type "surprising"; skill is included in the catalogue', + ) + expect(output).toContain( + 'skills/duplicate-a/SKILL.md: catalogue description duplicates @fixture/catalogue#duplicate-b', + ) + expect(output).toContain( + 'skills/duplicate-b/SKILL.md: catalogue description duplicates @fixture/catalogue#duplicate-a', + ) + }) + + it('accepts authoring types and reports catalogue exclusion for known non-task types', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-types-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/types', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + const writeTypedSkill = (name: string, type: string): void => { + const dir = join(root, 'skills', name) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'SKILL.md'), + [ + '---', + `name: ${name}`, + `description: Describes ${name} behaviour for agents.`, + 'metadata:', + ` type: ${type}`, + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + } + writeTypedSkill('core-skill', 'core') + writeTypedSkill('composition-skill', 'composition') + writeTypedSkill('reference-skill', 'reference') + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).not.toContain('unknown metadata.type') + expect(output).toContain( + 'skills/reference-skill/SKILL.md: metadata.type "reference" is excluded from the catalogue; agents will not see this skill', + ) + }) + + it('warns instead of crashing when a catalogue use is malformed', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-use-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'core'), { + name: 'core', + description: 'Core concepts', + }) + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'skills/core/SKILL.md: malformed catalogue use: Invalid skill use "#core": package is required.', + ) + }) + + it('reports package catalogue bytes and skills outside the limits', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-budget-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + for (let index = 0; index < 51; index++) { + const skillName = `skill-${String(index).padStart(2, '0')}` + writeSkillMd(join(root, 'skills', skillName), { + name: skillName, + description: `${skillName} ${'a'.repeat(160)}`, + }) + } + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('package.json: catalogue renders ') + expect(output).toContain('/8000 bytes; skills outside limits:') + expect(output).toContain('@fixture/catalogue#skill-50') + }) + + it('escalates catalogue warnings in check mode', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-check-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'long'), { + name: 'long', + description: 'a'.repeat(200), + }) + process.chdir(root) + + const exitCode = await main(['validate', '--check']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('Validation failed with 1 error(s)') + expect(output).toContain( + 'skills/long/SKILL.md: catalogue description is truncated from 200 to 180 characters (20 lost)', + ) + }) + it('keeps nested Intent skill names valid without Agent Skills spec warnings', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-nested-')) tempDirs.push(root) @@ -2560,6 +3424,61 @@ describe('cli commands', () => { expect(output).toContain('metadata must be a mapping') }) + it('validates Agent Skills scalar frontmatter fields', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-scalars-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: 42', + 'description: []', + 'allowed-tools: " "', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('name must be a non-empty string') + expect(output).toContain('description must be a non-empty string') + expect(output).toContain( + 'Agent Skills spec warning: allowed-tools should be a non-empty space-separated string', + ) + }) + + it('fails when SKILL.md frontmatter is not a mapping', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-validate-frontmatter-'), + ) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + ['---', 'null', '---', '', 'Skill content here.', ''].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('YAML frontmatter must be a mapping') + }) + it('does not flag array-valued top-level keys as non-spec scalars', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-array-key-')) tempDirs.push(root) diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts new file mode 100644 index 00000000..72798257 --- /dev/null +++ b/packages/intent/tests/consumer-install.test.ts @@ -0,0 +1,1879 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runInteractiveInstall } from '../src/commands/install/command.js' +import { readIntentConsumerConfig } from '../src/commands/install/config.js' +import { runConsumerInstall as runConsumerInstallImpl } from '../src/commands/install/consumer.js' +import { + detectInstallTargets, + readIntentDeliveryConfig, +} from '../src/commands/install/delivery.js' +import { + createClackInstallerPrompter, + groupSkillOptions, + selectClackMapTarget, +} from '../src/commands/install/prompts.js' +import { SUPPORTED_MAP_TARGETS } from '../src/commands/install/guidance.js' +import { runSyncCommand } from '../src/commands/sync/command.js' +import { readInstallState } from '../src/commands/sync/state.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../src/core/lockfile/lockfile.js' +import { scanForIntents } from '../src/discovery/scanner.js' +import type * as ClackPrompts from '@clack/prompts' +import type { + InstallerPrompter, + RunConsumerInstallOptions, +} from '../src/commands/install/consumer.js' + +const clackPromptMocks = vi.hoisted(() => ({ + cancel: vi.fn(), + cancelValue: Symbol('cancel'), + confirm: vi.fn<() => Promise>(), + intro: vi.fn(), + isCancel: vi.fn<(value: unknown) => boolean>(), + multiselect: vi.fn<() => Promise>(), + note: vi.fn(), + select: + vi.fn< + (options: { options: Array<{ value: string }> }) => Promise + >(), + text: vi.fn< + (options: { + validate?: (value: string) => string | Error | undefined + }) => Promise + >(), +})) + +clackPromptMocks.isCancel.mockImplementation( + (value) => value === clackPromptMocks.cancelValue, +) + +vi.mock('@clack/prompts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + cancel: clackPromptMocks.cancel, + confirm: clackPromptMocks.confirm, + intro: clackPromptMocks.intro, + isCancel: clackPromptMocks.isCancel, + multiselect: clackPromptMocks.multiselect, + note: clackPromptMocks.note, + select: clackPromptMocks.select, + text: clackPromptMocks.text, + } +}) + +function createDirectoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir') +} + +const roots: Array = [] + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2), 'utf8') +} + +function createProject(): string { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'intent-consumer-install-')), + ) + roots.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + devDependencies: { '@tanstack/intent': '0.4.0' }, + }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + writeJson(join(packageRoot, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + const skillRoot = join(packageRoot, 'skills', 'fetching') + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: fetching\ndescription: Query fetching patterns\n---\n', + 'utf8', + ) + return root +} + +function addSkillPackage( + root: string, + name: string, + skills: Array, +): void { + const packageRoot = join(root, 'node_modules', ...name.split('/')) + writeJson(join(packageRoot, 'package.json'), { + name, + version: '1.0.0', + intent: { version: 1, repo: `test/${name}`, docs: 'docs/' }, + }) + for (const skill of skills) { + const skillRoot = join(packageRoot, 'skills', skill) + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + `---\nname: ${skill}\ndescription: ${skill} guidance\n---\n`, + 'utf8', + ) + } +} + +function createPrompts( + overrides: Partial = {}, +): InstallerPrompter { + return { + complete: () => {}, + selectMethod: () => Promise.resolve('symlink'), + selectMapTarget: () => Promise.resolve('AGENTS.md'), + selectTargets: () => Promise.resolve(['agents']), + confirmSymlink: () => Promise.resolve(true), + confirmUserScopeHooks: () => Promise.resolve(true), + selectSkills: () => Promise.resolve({ mode: 'all-found' }), + confirmInstall: () => Promise.resolve('install'), + ...overrides, + } +} + +function runConsumerInstall( + options: Omit, +): Promise { + return runConsumerInstallImpl({ ...options, packageManager: 'pnpm' }) +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.clearAllMocks() +}) + +describe('consumer install', () => { + it('detects configured agent targets from project-owned signals', () => { + const root = createProject() + mkdirSync(join(root, '.claude')) + writeFileSync(join(root, '.cursorrules'), '', 'utf8') + + expect(detectInstallTargets(root)).toEqual(['cursor', 'claude']) + }) + + it('detects no agent targets in a bare project', () => { + expect(detectInstallTargets(createProject())).toEqual([]) + }) + + it('does not detect GitHub Copilot from the .github directory alone', () => { + const root = createProject() + mkdirSync(join(root, '.github')) + + expect(detectInstallTargets(root)).toEqual([]) + }) + + it('preselects detected targets while keeping every target toggleable', async () => { + const root = createProject() + mkdirSync(join(root, '.claude')) + writeFileSync(join(root, '.cursorrules'), '', 'utf8') + clackPromptMocks.multiselect.mockResolvedValueOnce([]) + + await createClackInstallerPrompter().selectTargets( + 'symlink', + detectInstallTargets(root), + ) + + expect(clackPromptMocks.multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: ['cursor', 'claude'], + options: expect.arrayContaining([ + expect.objectContaining({ value: 'agents' }), + expect.objectContaining({ value: 'github' }), + expect.objectContaining({ value: 'vscode' }), + expect.objectContaining({ value: 'cursor' }), + expect.objectContaining({ value: 'codex' }), + expect.objectContaining({ value: 'claude' }), + ]), + }), + ) + }) + + it('groups selectable skills by package', () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + + expect(groupSkillOptions(discovered)).toEqual({ + '@tanstack/query': [ + { + value: '@tanstack/query#fetching', + label: 'fetching', + hint: 'Query fetching patterns', + }, + ], + }) + }) + + it('offers implemented interactive install choices', async () => { + clackPromptMocks.select.mockResolvedValueOnce('symlink') + + await createClackInstallerPrompter().selectMethod() + + expect(clackPromptMocks.select).toHaveBeenCalledOnce() + const [{ options }] = clackPromptMocks.select.mock.calls[0]! + expect(options.map((option) => option.value)).toEqual([ + 'symlink', + 'hooks', + 'map', + ]) + }) + + it('requests permission to install user-level hooks across repositories', async () => { + clackPromptMocks.confirm.mockResolvedValueOnce(true) + + await expect( + createClackInstallerPrompter().confirmUserScopeHooks(), + ).resolves.toBe(true) + + expect(clackPromptMocks.note).toHaveBeenCalledWith( + 'Hooks are written to your home directory and affect sessions in this and other repositories.', + 'User-level hooks apply across repositories', + ) + expect(clackPromptMocks.confirm).toHaveBeenCalledWith({ + message: 'Allow Intent to install user-level hooks?', + initialValue: false, + vertical: true, + }) + }) + + it('selects a standard map target from the canonical target list', async () => { + const root = createProject() + clackPromptMocks.select.mockResolvedValueOnce( + '.github/copilot-instructions.md', + ) + + await expect(selectClackMapTarget(root)).resolves.toBe( + '.github/copilot-instructions.md', + ) + + const [{ options }] = clackPromptMocks.select.mock.calls[0]! + expect(options.map((option) => option.value)).toEqual([ + ...SUPPORTED_MAP_TARGETS, + 'other', + ]) + expect(clackPromptMocks.text).not.toHaveBeenCalled() + }) + + it('validates Other project file map targets with the safe resolver', async () => { + const root = createProject() + clackPromptMocks.select.mockResolvedValueOnce('other') + clackPromptMocks.text.mockImplementationOnce(({ validate }) => { + expect(validate?.('../outside.md')).toBe( + 'Map target cannot use `..` or `.git` path segments.', + ) + return Promise.resolve('notes/assistant.md') + }) + + await expect(selectClackMapTarget(root)).resolves.toBe('notes/assistant.md') + }) + + it.each(['selection', 'Other path'])( + 'returns null when map target %s is cancelled', + async (cancelledPrompt) => { + const root = createProject() + if (cancelledPrompt === 'selection') { + clackPromptMocks.select.mockResolvedValueOnce( + clackPromptMocks.cancelValue, + ) + } else { + clackPromptMocks.select.mockResolvedValueOnce('other') + clackPromptMocks.text.mockResolvedValueOnce( + clackPromptMocks.cancelValue, + ) + } + + await expect(selectClackMapTarget(root)).resolves.toBeNull() + }, + ) + + it('selects the method before requesting applicable targets', async () => { + const root = createProject() + const calls: Array = [] + const prompts = createPrompts({ + selectMethod: () => { + calls.push('method') + return Promise.resolve('symlink') + }, + selectTargets: (method) => { + calls.push(`targets:${method}`) + return Promise.resolve(null) + }, + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect(calls).toEqual(['method', 'targets:symlink']) + }) + + it('runs the full interview for an unconfigured project', async () => { + const root = createProject() + const selectMethod = vi.fn(() => Promise.resolve('symlink' as const)) + const selectTargets = vi.fn(() => + Promise.resolve>(['agents']), + ) + const selectSkills = vi.fn(() => + Promise.resolve({ mode: 'all-found' as const }), + ) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ selectMethod, selectTargets, selectSkills }), + root, + }) + + expect(selectMethod).toHaveBeenCalledOnce() + expect(selectTargets).toHaveBeenCalledOnce() + expect(selectSkills).toHaveBeenCalledOnce() + }) + + it('moves legacy delivery local without changing committed trust or lock', async () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.description = 'preserve me' + packageJson.intent = { + skills: ['@tanstack/query'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, + } + writeJson(packageJsonPath, packageJson) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }) + const lockBefore = readFileSync(join(root, 'intent.lock')) + const selectMethod = vi.fn(() => Promise.resolve('symlink' as const)) + const selectTargets = vi.fn(() => + Promise.resolve>(['agents']), + ) + const selectSkills = vi.fn(() => + Promise.reject(new Error('current trust must not reselect skills')), + ) + + await runConsumerInstall({ + discovered, + prompts: createPrompts({ selectMethod, selectTargets, selectSkills }), + root, + }) + + expect(selectMethod).toHaveBeenCalledOnce() + expect(selectTargets).toHaveBeenCalledOnce() + expect(selectSkills).not.toHaveBeenCalled() + expect(JSON.parse(readFileSync(packageJsonPath, 'utf8'))).toEqual({ + name: 'app', + private: true, + devDependencies: { '@tanstack/intent': '0.4.0' }, + description: 'preserve me', + intent: { skills: ['@tanstack/query'], exclude: [] }, + scripts: { prepare: 'intent sync' }, + }) + expect(readFileSync(join(root, 'intent.lock'))).toEqual(lockBefore) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['agents'], + }) + expect( + lstatSync( + join(root, '.agents', 'skills', 'npm-tanstack-query-fetching'), + ).isSymbolicLink(), + ).toBe(true) + }) + + it('wires prepare once and reports an already-configured project as up to date without interviewing', async () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + await runConsumerInstall({ discovered, prompts: createPrompts(), root }) + const complete = vi.fn() + const selectMethod = vi.fn(() => + Promise.reject(new Error('method must not run')), + ) + const selectTargets = vi.fn(() => + Promise.reject(new Error('targets must not run')), + ) + const selectSkills = vi.fn(() => + Promise.reject(new Error('skills must not run')), + ) + const prompts = createPrompts({ + complete, + selectMethod, + selectTargets, + selectSkills, + }) + + await runConsumerInstall({ discovered, prompts, root }) + + expect(complete).toHaveBeenCalledWith('Project is up to date.') + expect(selectMethod).not.toHaveBeenCalled() + expect(selectTargets).not.toHaveBeenCalled() + expect(selectSkills).not.toHaveBeenCalled() + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toEqual({ prepare: 'intent sync' }) + }) + + it('reports a new skill and enters review without re-interviewing delivery', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const skillRoot = join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'mutations', + ) + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: mutations\ndescription: Query mutation patterns\n---\n', + 'utf8', + ) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const selectSkills = vi.fn(() => + Promise.resolve({ mode: 'all-found' as const }), + ) + const confirmInstall = vi.fn(() => Promise.resolve('install' as const)) + const prompts = createPrompts({ + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => Promise.reject(new Error('targets must not run')), + selectSkills, + confirmInstall, + }) + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect(log.mock.calls.flat().join('\n')).toContain( + 'Install changes: 0 new dependencies, 1 new skill, 0 changed, 0 removed.', + ) + } finally { + log.mockRestore() + } + expect(selectSkills).toHaveBeenCalledOnce() + expect(confirmInstall).toHaveBeenCalledOnce() + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }, { path: 'skills/mutations' }], + }, + ], + }, + }) + }) + + it('installs confirmed skills with policy, lock state, and managed links', async () => { + const root = createProject() + const prompts = createPrompts() + + await runInteractiveInstall({ + cwd: root, + prompts, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['agents'], + }) + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + kind: 'npm', + id: '@tanstack/query', + skills: [ + { + path: 'skills/fetching', + contentHash: expect.stringMatching(/^sha256-[a-f0-9]{64}$/), + }, + ], + }, + ], + }, + }) + const link = join(root, '.agents', 'skills', 'npm-tanstack-query-fetching') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + expect(readInstallState(root)).toMatchObject({ + status: 'found', + state: { + entries: [{ path: '.agents/skills/npm-tanstack-query-fetching' }], + }, + }) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('installs selected skills as a static guidance block without delivery state', async () => { + const root = createProject() + const complete = vi.fn() + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.resolve('map'), + selectMapTarget: () => Promise.resolve('notes/assistant.md'), + selectTargets: () => + Promise.reject(new Error('map must not select delivery targets')), + confirmInstall: () => + Promise.reject(new Error('map must not confirm delivery')), + }), + root, + }) + + const target = readFileSync(join(root, 'notes', 'assistant.md'), 'utf8') + expect(target).toContain('id: "@tanstack/query#fetching"') + expect(target).toContain('load @tanstack/query#fetching') + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toBeUndefined() + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }], + }, + ], + }, + }) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + expect(complete).toHaveBeenCalledWith( + 'Installed 1 skill to notes/assistant.md as a static guidance block.', + ) + }) + + it('writes nothing when the static selection produces no mappings', async () => { + const root = createProject() + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [], + }) + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const complete = vi.fn() + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.resolve('map'), + selectMapTarget: () => + Promise.reject(new Error('empty map must not select a target')), + selectSkills: () => + Promise.resolve({ mode: 'individual', enabled: [] }), + }), + root, + }) + + expect(complete).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + + it('prints a static guidance block without writing during dry run', async () => { + const root = createProject() + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const complete = vi.fn() + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun: true, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.resolve('map'), + selectMapTarget: () => Promise.resolve('notes/assistant.md'), + selectTargets: () => + Promise.reject(new Error('map must not select delivery targets')), + }), + root, + }) + + const output = log.mock.calls.flat().join('\n') + expect(output).toContain('Generated 1 mapping for notes/assistant.md.') + expect(output).toContain('id: "@tanstack/query#fetching"') + } finally { + log.mockRestore() + } + + expect(complete).toHaveBeenCalledWith('Dry run complete.') + expect(existsSync(join(root, 'notes', 'assistant.md'))).toBe(false) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + + it('preserves malformed install state and managed links when sync fails', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const statePath = join(root, '.intent', 'install-state.json') + const gitignorePath = join(root, '.gitignore') + const linkPath = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + writeFileSync(gitignorePath, 'shared\n', 'utf8') + writeFileSync(statePath, '{malformed ownership state\n', 'utf8') + const stateBefore = readFileSync(statePath) + const gitignoreBefore = readFileSync(gitignorePath) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + /install state is malformed.*remove.*install-state\.json/i, + ) + + expect(readFileSync(statePath)).toEqual(stateBefore) + expect(readFileSync(gitignorePath)).toEqual(gitignoreBefore) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + + it('preserves missing install state and existing links when sync conflicts', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const statePath = join(root, '.intent', 'install-state.json') + const gitignorePath = join(root, '.gitignore') + const linkPath = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + writeFileSync(gitignorePath, 'shared\n', 'utf8') + unlinkSync(statePath) + const gitignoreBefore = readFileSync(gitignorePath) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + 'Intent sync found managed link conflicts: .agents/skills/npm-tanstack-query-fetching.', + ) + + expect(existsSync(statePath)).toBe(false) + expect(readFileSync(gitignorePath)).toEqual(gitignoreBefore) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + + it('revokes exact-owned links while preserving conflicting links after verification fails', async () => { + const root = createProject() + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const mutationRoot = join(packageRoot, 'skills', 'mutation') + mkdirSync(mutationRoot, { recursive: true }) + writeFileSync( + join(mutationRoot, 'SKILL.md'), + '---\nname: mutation\ndescription: Mutation guidance\n---\n', + 'utf8', + ) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const fetchingLink = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + const mutationLink = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-mutation', + ) + const retargeted = join(root, 'retargeted-skill') + mkdirSync(retargeted) + unlinkSync(mutationLink) + createDirectoryLink(retargeted, mutationLink) + const outside = join(root, 'outside-content') + mkdirSync(outside) + writeFileSync(join(outside, 'unsafe.md'), 'unsafe', 'utf8') + createDirectoryLink( + outside, + join(packageRoot, 'skills', 'fetching', 'references'), + ) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + 'Intent sync could not revoke managed links after verification failed: .agents/skills/npm-tanstack-query-mutation.', + ) + + expect(existsSync(fetchingLink)).toBe(false) + expect(existsSync(join(fetchingLink, 'references', 'unsafe.md'))).toBe( + false, + ) + expect(realpathSync(mutationLink)).toBe(realpathSync(retargeted)) + const state = readInstallState(root) + expect(state.status).toBe('found') + if (state.status !== 'found') throw new Error('Expected install state.') + expect(state.state.entries.map((entry) => entry.path)).toEqual([ + '.agents/skills/npm-tanstack-query-mutation', + ]) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('reports managed links that cannot be revoked after verification fails', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const link = join(root, '.agents', 'skills', 'npm-tanstack-query-fetching') + const state = readInstallState(root) + expect(state.status).toBe('found') + if (state.status !== 'found') throw new Error('Expected install state.') + unlinkSync(link) + createDirectoryLink(join(root, 'retargeted-skill'), link) + const outside = join(root, 'outside-content') + mkdirSync(outside) + createDirectoryLink( + outside, + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'references', + ), + ) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + 'Intent sync could not revoke managed links after verification failed: .agents/skills/npm-tanstack-query-fetching.', + ) + expect(readInstallState(root)).toEqual(state) + }) + + it('preserves missing ownership state after verification fails', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const statePath = join(root, '.intent', 'install-state.json') + const gitignorePath = join(root, '.gitignore') + const linkPath = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + writeFileSync(gitignorePath, 'shared\n', 'utf8') + unlinkSync(statePath) + const gitignoreBefore = readFileSync(gitignorePath) + const outside = join(root, 'outside-content') + mkdirSync(outside) + createDirectoryLink( + outside, + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'references', + ), + ) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow(/escapes package root/i) + + expect(existsSync(statePath)).toBe(false) + expect(readFileSync(gitignorePath)).toEqual(gitignoreBefore) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + + it('installs hooks with policy and lock state without links or prepare', async () => { + const root = createProject() + const homeDir = join(root, 'home') + const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) + const prompts = createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['claude', 'codex']), + confirmSymlink: () => + Promise.reject(new Error('hooks must not request symlink consent')), + confirmUserScopeHooks, + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + homeDir, + prompts, + root, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'hooks', + targets: ['claude', 'codex'], + }) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toBeUndefined() + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }], + }, + ], + }, + }) + expect(confirmUserScopeHooks).toHaveBeenCalledOnce() + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(homeDir, '.codex', 'hooks.json'))).toBe(true) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(false) + expect(existsSync(join(root, '.agents'))).toBe(false) + expect(readInstallState(root)).toEqual({ status: 'missing' }) + }) + + it('repairs missing user hooks without re-interviewing or rewriting install state', async () => { + const root = createProject() + const homeDir = join(root, 'home') + const discovered = scanForIntents(root, { scope: 'local' }).packages + const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) + await runConsumerInstall({ + discovered, + homeDir, + prompts: createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['claude', 'codex']), + confirmUserScopeHooks, + }), + root, + }) + const claudeConfig = join(homeDir, '.claude', 'settings.json') + const codexConfig = join(homeDir, '.codex', 'hooks.json') + const packageBefore = readFileSync(join(root, 'package.json')) + const lockBefore = readFileSync(join(root, 'intent.lock')) + const codexBefore = readFileSync(codexConfig) + unlinkSync(claudeConfig) + const complete = vi.fn() + + await runConsumerInstall({ + discovered, + homeDir, + prompts: createPrompts({ + complete, + confirmUserScopeHooks, + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => Promise.reject(new Error('targets must not run')), + selectSkills: () => Promise.reject(new Error('skills must not run')), + }), + root, + }) + + expect(existsSync(claudeConfig)).toBe(true) + expect(readFileSync(codexConfig)).toEqual(codexBefore) + expect(readFileSync(join(root, 'package.json'))).toEqual(packageBefore) + expect(readFileSync(join(root, 'intent.lock'))).toEqual(lockBefore) + expect(confirmUserScopeHooks).toHaveBeenCalledTimes(2) + expect(complete).toHaveBeenCalledWith( + 'Project is up to date. Repaired configured hooks.', + ) + }) + + it('installs and repairs GitHub hooks at user scope after confirmation', async () => { + const root = createProject() + const copilotHome = join(root, 'copilot-home') + const hookConfig = join(copilotHome, 'hooks', 'hooks.json') + const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) + let output = '' + const prompts = createPrompts({ + complete(message) { + output = message + }, + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + confirmUserScopeHooks, + }) + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + expect(confirmUserScopeHooks).toHaveBeenCalledOnce() + expect(existsSync(hookConfig)).toBe(true) + expect(output).toContain('Installed hook agents: copilot.') + unlinkSync(hookConfig) + + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete(message) { + output = message + }, + confirmUserScopeHooks, + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => Promise.reject(new Error('targets must not run')), + selectSkills: () => Promise.reject(new Error('skills must not run')), + }), + root, + }) + + expect(confirmUserScopeHooks).toHaveBeenCalledTimes(2) + expect(existsSync(hookConfig)).toBe(true) + expect(output).toBe('Project is up to date. Repaired configured hooks.') + }) + + it('reports declined GitHub hook repair without claiming a repair', async () => { + const root = createProject() + const copilotHome = join(root, 'copilot-home') + const hookConfig = join(copilotHome, 'hooks', 'hooks.json') + let output = '' + + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + }), + root, + }) + unlinkSync(hookConfig) + + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete(message) { + output = message + }, + confirmUserScopeHooks: () => Promise.resolve(false), + }), + root, + }) + + expect(existsSync(hookConfig)).toBe(false) + expect(output).toBe( + 'Project is up to date. Hooks were skipped because home-directory access was declined.', + ) + }) + + it('skips all user hooks when home-directory access is declined', async () => { + const root = createProject() + const homeDir = join(root, 'home') + const copilotHome = join(root, 'copilot-home') + let output = '' + const prompts = createPrompts({ + complete(message) { + output = message + }, + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github', 'claude', 'codex']), + confirmUserScopeHooks: () => Promise.resolve(false), + }) + + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + homeDir, + prompts, + root, + }) + + expect(existsSync(join(copilotHome, 'hooks', 'hooks.json'))).toBe(false) + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(homeDir, '.codex', 'hooks.json'))).toBe(false) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(false) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'hooks', + targets: ['github', 'claude', 'codex'], + }) + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock')).status).toBe('found') + expect(output).toBe( + 'Installed 1 skill using hooks. Installed hook agents: none. Hooks were skipped because home-directory access was declined.', + ) + }) + + it('writes nothing when user-scope hook confirmation is cancelled', async () => { + const root = createProject() + const homeDir = join(root, 'home') + const copilotHome = join(root, 'copilot-home') + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const prompts = createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github', 'claude', 'codex']), + confirmUserScopeHooks: () => Promise.resolve(null), + }) + + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + homeDir, + prompts, + root, + }) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(readIntentDeliveryConfig(root)).toBeNull() + expect(existsSync(join(copilotHome, 'hooks', 'hooks.json'))).toBe(false) + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(homeDir, '.codex', 'hooks.json'))).toBe(false) + expect(existsSync(join(root, '.agents'))).toBe(false) + }) + + it('locks and links selected skills without excluding unchecked siblings', async () => { + const root = createProject() + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const sibling = join(packageRoot, 'skills', 'mutations') + mkdirSync(sibling, { recursive: true }) + writeFileSync( + join(sibling, 'SKILL.md'), + '---\nname: mutations\ndescription: Query mutation patterns\n---\n', + 'utf8', + ) + const prompts = createPrompts({ + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['@tanstack/query#fetching'], + }), + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + const config = readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + expect(config.skills).toEqual(['@tanstack/query#fetching']) + expect(config.exclude).toEqual([]) + const lock = readIntentLockfile(join(root, 'intent.lock')) + expect( + lock.status === 'found' ? lock.lockfile.sources[0]?.skills : [], + ).toEqual([ + { + path: 'skills/fetching', + contentHash: expect.stringMatching(/^sha256-[a-f0-9]{64}$/), + }, + ]) + expect( + existsSync( + join(root, '.agents', 'skills', 'npm-tanstack-query-mutations'), + ), + ).toBe(false) + }) + + it('prints the complete plan without writing during dry run', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + const prompts = createPrompts() + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun: true, + prompts, + root, + }) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + } + + expect(output).toContain( + 'Would install 1 skill to Shared .agents directory using symlink.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.agents'))).toBe(false) + }) + + it('prints the hooks plan without writing or requesting home access', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + const prompts = createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + confirmUserScopeHooks: () => + Promise.reject(new Error('dry run must not request home access')), + }) + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun: true, + prompts, + root, + }) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + } + + expect(output).toContain( + 'Would install 1 skill to GitHub Copilot using hooks.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.copilot'))).toBe(false) + }) + + it('installs without Intent as a project development dependency', async () => { + const root = createProject() + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + const prompts = createPrompts() + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['agents'], + }) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toBeUndefined() + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }], + }, + ], + }, + }) + }) + + it('stops without skill selection when discovery is empty', async () => { + const root = createProject() + rmSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + force: true, + }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + const prompts = createPrompts({ + complete(message) { + output = message + }, + selectSkills: () => + Promise.reject(new Error('skill selection must not run')), + }) + + try { + await runConsumerInstall({ discovered: [], prompts, root }) + } finally { + log.mockRestore() + } + + expect(output).toContain('No intent-enabled skills found.') + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + }) + + it('does not write configuration when a delivery target conflicts', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const target = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + mkdirSync(target, { recursive: true }) + + await expect( + runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }), + ).rejects.toThrow('Install target conflicts') + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(lstatSync(target).isDirectory()).toBe(true) + }) + + it('restarts choices when final confirmation goes back', async () => { + const root = createProject() + const targets = [['agents'], ['cursor']] as const + let pass = 0 + const prompts = createPrompts({ + selectTargets: () => + Promise.resolve([...targets[pass]!] as Array<'agents' | 'cursor'>), + confirmInstall: () => { + pass += 1 + return Promise.resolve(pass === 1 ? 'back' : 'install') + }, + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['cursor'], + }) + expect( + existsSync( + join(root, '.cursor', 'skills', 'npm-tanstack-query-fetching'), + ), + ).toBe(true) + expect(existsSync(join(root, '.agents'))).toBe(false) + }) + + it('reviews and installs selected skills from new dependencies', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, '@tanstack/new-package', ['first', 'second']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['@tanstack/new-package#first'], + }), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + expect(config.skills).toEqual([ + '@tanstack/new-package#first', + '@tanstack/query', + ]) + expect(config.exclude).toEqual([]) + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { id: '@tanstack/new-package', skills: [{ path: 'skills/first' }] }, + { id: '@tanstack/query' }, + ], + }, + }) + expect( + existsSync( + join(root, '.agents', 'skills', 'npm-tanstack-new-package-first'), + ), + ).toBe(true) + expect( + existsSync( + join(root, '.agents', 'skills', 'npm-tanstack-new-package-second'), + ), + ).toBe(false) + }) + + it('adds accepted skills beside an existing skill-level entry', async () => { + const root = createProject() + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['demo-pkg#alpha'] + packageJson.intent.exclude = ['@tanstack/query'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['demo-pkg#beta'], + }), + }, + }, + ) + + expect( + readIntentConsumerConfig(readFileSync(packageJsonPath, 'utf8')).skills, + ).toEqual(['demo-pkg#alpha', 'demo-pkg#beta']) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(true) + }) + + it('accepts a pending skill without rebaselining a changed sibling', async () => { + const root = createProject() + rmSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + force: true, + }) + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['demo-pkg#alpha'] + writeJson(packageJsonPath, packageJson) + const lockBefore = readIntentLockfile(join(root, 'intent.lock')) + expect(lockBefore.status).toBe('found') + if (lockBefore.status !== 'found') return + const alphaHash = lockBefore.lockfile.sources[0]!.skills[0]!.contentHash + writeFileSync( + join(root, 'node_modules', 'demo-pkg', 'skills', 'alpha', 'SKILL.md'), + '---\nname: alpha\ndescription: changed alpha guidance\n---\n', + 'utf8', + ) + addSkillPackage(root, 'demo-pkg', ['beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['demo-pkg#beta'], + }), + }, + }, + ) + + const lockAfter = readIntentLockfile(join(root, 'intent.lock')) + expect(lockAfter.status).toBe('found') + if (lockAfter.status !== 'found') return + const demoSource = lockAfter.lockfile.sources.find( + (source) => source.id === 'demo-pkg', + ) + expect({ + alphaHash: demoSource?.skills.find( + (skill) => skill.path === 'skills/alpha', + )?.contentHash, + alphaLinked: existsSync( + join(root, '.agents', 'skills', 'npm-demo-pkg-alpha'), + ), + betaLocked: demoSource?.skills.some( + (skill) => skill.path === 'skills/beta', + ), + betaLinked: existsSync( + join(root, '.agents', 'skills', 'npm-demo-pkg-beta'), + ), + }).toEqual({ + alphaHash, + alphaLinked: false, + betaLocked: true, + betaLinked: true, + }) + }) + + it('preserves enabled siblings when no pending skills are selected', async () => { + const root = createProject() + rmSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + force: true, + }) + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['demo-pkg#alpha'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ mode: 'individual', enabled: [] }), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(packageJsonPath, 'utf8'), + ) + expect(config.skills).toEqual(['demo-pkg#alpha']) + expect(config.exclude).toEqual(['demo-pkg#beta']) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(false) + }) + + it('leaves a new skill under package-level trust pending baseline review', async () => { + const root = createProject() + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['demo-pkg'] + packageJson.intent.exclude = ['@tanstack/query'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['beta']) + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + + try { + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => + Promise.reject(new Error('package-level trust must not prompt')), + selectSkills: () => + Promise.reject(new Error('package-level trust must not select')), + }, + }, + ) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + } + + expect(output).toContain('New skills found in enabled dependencies:') + expect(output).toContain('demo-pkg 1 skill') + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(false) + }) + + it('writes a package-level entry when all skills in a new package are accepted', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['demo-pkg#alpha', 'demo-pkg#beta'], + }), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + expect(config.skills).toContain('demo-pkg') + expect(config.skills).not.toContain('demo-pkg#alpha') + expect(config.skills).not.toContain('demo-pkg#beta') + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(true) + }) + + it('excludes new dependencies without changing the lock', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'declined-package', ['declined']) + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('exclude'), + selectSkills: () => Promise.resolve(null), + }, + }, + ) + + expect( + readIntentConsumerConfig(readFileSync(join(root, 'package.json'), 'utf8')) + .exclude, + ).toEqual(['declined-package']) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('excludes only new skills from a partially allowed package', async () => { + const root = createProject() + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['@tanstack/query', 'demo-pkg#alpha'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('exclude'), + selectSkills: () => Promise.resolve(null), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(packageJsonPath, 'utf8'), + ) + expect(config.exclude).toContain('demo-pkg#beta') + expect(config.exclude).not.toContain('demo-pkg') + + await runSyncCommand({ cwd: root }, { review: 'reminder' }) + + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + }) + + it('fails non-interactive sync for a pending dependency without accepting it', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'pending-package', ['pending']) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await expect( + runSyncCommand({ cwd: root }, { review: 'fail' }), + ).rejects.toThrow( + 'Intent sync requires review before automation can continue.', + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('fails non-interactive sync for a new skill in an enabled package without accepting it', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const skillRoot = join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'mutation', + ) + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: mutation\ndescription: Mutation guidance\n---\n', + 'utf8', + ) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await expect( + runSyncCommand({ cwd: root }, { review: 'fail' }), + ).rejects.toThrow( + 'Intent sync requires review before automation can continue.', + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('fails non-interactive sync for changed content without accepting it', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + writeFileSync( + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'SKILL.md', + ), + '---\nname: fetching\ndescription: Changed guidance\n---\n', + 'utf8', + ) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await expect( + runSyncCommand({ cwd: root }, { review: 'fail' }), + ).rejects.toThrow( + 'Intent sync requires review before automation can continue.', + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('leaves new dependencies pending when review is deferred', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'later-package', ['later']) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('later'), + selectSkills: () => Promise.resolve(null), + }, + }, + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('keeps prepare sync prompt-free and reminder-only', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'prepare-package', ['prepare-skill']) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const previousLifecycle = process.env.npm_lifecycle_event + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + process.env.npm_lifecycle_event = 'prepare' + + try { + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => + Promise.reject(new Error('prepare must not prompt')), + selectSkills: () => + Promise.reject(new Error('prepare must not select skills')), + }, + }, + ) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + if (previousLifecycle === undefined) { + delete process.env.npm_lifecycle_event + } else { + process.env.npm_lifecycle_event = previousLifecycle + } + } + + expect(output).toContain('Pending skills by source:') + expect(output).toContain('prepare-package 1 skill') + expect(output).toContain('Run `intent install` to review and install them') + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) +}) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1fb..0ad39810 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -15,6 +15,9 @@ import { loadIntentSkill, resolveIntentSkill, } from '../src/core/index.js' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { EMPTY_NOTE } from '../src/core/source-policy.js' const realTmpdir = realpathSync(tmpdir()) @@ -83,6 +86,11 @@ let originalCwd: string beforeEach(() => { root = realpathSync(mkdtempSync(join(realTmpdir, 'intent-core-test-'))) + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['*'] }, + }) originalCwd = process.cwd() }) @@ -180,7 +188,10 @@ describe('listIntentSkills', () => { writeJson(join(root, 'package.json'), { name: 'test-app', private: true, - intent: { exclude: ['@tanstack/*devtools*'] }, + intent: { + skills: ['*'], + exclude: ['@tanstack/*devtools*'], + }, }) writeInstalledIntentPackage(root, { name: '@tanstack/query', @@ -334,7 +345,11 @@ describe('listIntentSkills', () => { expect(result.packages[0]?.skillCount).toBe(1) }) - it('warns about migration when intent.skills is absent', () => { + it('denies all discovered skills when intent.skills is missing', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -344,10 +359,9 @@ describe('listIntentSkills', () => { const result = listIntentSkills({ cwd: root }) - expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) - expect(result.notices).toEqual([ - 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.', - ]) + expect(result.packages).toEqual([]) + expect(result.skills).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) }) it('permits nothing and notes an empty allowlist', () => { @@ -469,6 +483,105 @@ describe('loadIntentSkill', () => { }) }) + it('loads only exact skill content accepted by intent.lock', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(packageRoot, 'skills', 'fetching') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: '@tanstack/query', + skills: [ + { + path: 'skills/fetching', + contentHash: computeSkillContentHash({ packageRoot, skillDir }), + }, + ], + }, + ], + }) + + expect( + loadIntentSkill('@tanstack/query#fetching', { cwd: root }).skillName, + ).toBe('fetching') + + writeFileSync(join(skillDir, 'SKILL.md'), 'changed content', 'utf8') + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": installed content does not match intent.lock.', + ) + expect(() => + resolveIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": installed content does not match intent.lock.', + ) + }) + + it('refuses a skill missing from an existing intent.lock', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [], + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill is not accepted in intent.lock.', + ) + expect(() => + resolveIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill is not accepted in intent.lock.', + ) + }) + + it('does not accept a lock entry for the wrong source kind', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(packageRoot, 'skills', 'fetching') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'workspace', + id: '@tanstack/query', + skills: [ + { + path: 'skills/fetching', + contentHash: computeSkillContentHash({ packageRoot, skillDir }), + }, + ], + }, + ], + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill is not accepted in intent.lock.', + ) + }) + it('does not change process cwd when loading from an explicit cwd', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', @@ -582,6 +695,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app', @@ -610,6 +724,54 @@ describe('loadIntentSkill', () => { ) }) + it('loads an exact workspace skill accepted by intent.lock', () => { + const appDir = join(root, 'packages', 'app') + const routerDir = join(root, 'packages', 'router-core') + const skillDir = join(routerDir, 'skills', 'router-core', 'auth-and-guards') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + intent: { skills: ['*'] }, + }) + 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: skillDir, + frontmatter: { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + }) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'workspace', + id: '@tanstack/router-core', + skills: [ + { + path: 'skills/router-core/auth-and-guards', + contentHash: computeSkillContentHash({ + packageRoot: routerDir, + skillDir, + }), + }, + ], + }, + ], + }) + + expect( + loadIntentSkill('@tanstack/router-core#auth-and-guards', { cwd: appDir }) + .skillName, + ).toBe('router-core/auth-and-guards') + }) + it('loads a package-prefixed workspace skill by short name', () => { const appDir = join(root, 'packages', 'app') const routerDir = join(root, 'packages', 'router-core') @@ -617,6 +779,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app', @@ -644,6 +807,75 @@ describe('loadIntentSkill', () => { ) }) + it.each([ + ['@scope/ui#ui/theme', ['ui/theme'], true], + ['@scope/ui#ui/*', ['ui/theme'], true], + ['@scope/ui#ui/theme', ['theme', 'ui/theme'], false], + ['@scope/ui#ui/*', ['theme', 'ui/theme'], false], + ] as const)( + 'loads canonical policy %s from skills %j without redirecting an exact sibling', + (policy, skillNames, shortAliasAllowed) => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: [policy] }, + }) + for (const skillName of skillNames) { + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName, + description: skillName, + }) + } + + expect( + loadIntentSkill('@scope/ui#ui/theme', { cwd: root }).skillName, + ).toBe('ui/theme') + if (shortAliasAllowed) { + expect( + loadIntentSkill('@scope/ui#theme', { cwd: root }).skillName, + ).toBe('ui/theme') + } else { + expect(() => loadIntentSkill('@scope/ui#theme', { cwd: root })).toThrow( + 'Cannot load skill use "@scope/ui#theme": skill "@scope/ui#theme" is not listed in intent.skills.', + ) + } + }, + ) + + it('does not authorize a prefixed skill through an ambiguous short alias', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@scope/ui#theme'] }, + }) + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName: 'theme', + description: 'Theme', + }) + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName: 'ui/theme', + description: 'Prefixed theme', + }) + + expect(loadIntentSkill('@scope/ui#theme', { cwd: root }).skillName).toBe( + 'theme', + ) + expect(() => loadIntentSkill('@scope/ui#ui/theme', { cwd: root })).toThrow( + 'Cannot load skill use "@scope/ui#ui/theme": skill "@scope/ui#ui/theme" is not listed in intent.skills.', + ) + + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + expect(() => loadIntentSkill('@scope/ui#ui/theme', { cwd: root })).toThrow( + 'Cannot load skill use "@scope/ui#ui/theme": skill "@scope/ui#ui/theme" is not listed in intent.skills.', + ) + }) + it('refuses a prefixed skill excluded by canonical name when loaded by short alias', () => { const appDir = join(root, 'packages', 'app') const routerDir = join(root, 'packages', 'router-core') @@ -687,6 +919,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app', @@ -815,6 +1048,59 @@ describe('loadIntentSkill', () => { ]) }) + it('redacts policy-hidden names from full-scan resolution errors', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: [ + '@scope/ui#ui/auth', + '@scope/ui#ui/auth-visible', + '@scope/missing', + ], + }, + }) + for (const skillName of ['ui/auth-visible', 'ui/auth-hidden']) { + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName, + description: skillName, + }) + } + writeInstalledIntentPackage(root, { + name: '@scope/hidden', + version: '1.0.0', + skillName: 'secret', + description: 'Hidden package skill', + }) + + let skillError: unknown + try { + loadIntentSkill('@scope/ui#auth', { cwd: root }) + } catch (error) { + skillError = error + } + + expect(skillError).toBeInstanceOf(IntentCoreError) + expect((skillError as IntentCoreError).suggestedSkills).toEqual([ + 'ui/auth-visible', + ]) + expect((skillError as Error).message).toContain('ui/auth-visible') + expect((skillError as Error).message).not.toContain('ui/auth-hidden') + + let packageError: unknown + try { + loadIntentSkill('@scope/missing#auth', { cwd: root }) + } catch (error) { + packageError = error + } + + expect(packageError).toBeInstanceOf(IntentCoreError) + expect((packageError as Error).message).toContain('@scope/ui') + expect((packageError as Error).message).not.toContain('@scope/hidden') + }) + it('fails clearly when the package is excluded', () => { writeInstalledIntentPackage(root, { name: '@tanstack/devtools', @@ -930,6 +1216,71 @@ describe('loadIntentSkill — kind-mismatch late gate', () => { ) }) + it('refuses a skill granted only to the workspace kind when the npm copy resolves', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query#alpha', 'workspace:@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'alpha', + description: 'Alpha', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + let thrown: unknown + try { + thrown = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(IntentCoreError) + expect((thrown as IntentCoreError).code).toBe('skill-not-listed') + }) + + it('refuses the same skill identically via the full-scan fallback', () => { + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query#alpha', 'workspace:@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'alpha', + description: 'Alpha', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + let thrown: unknown + try { + thrown = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(IntentCoreError) + expect((thrown as IntentCoreError).code).toBe('skill-not-listed') + }) + it('refuses an npm-installed package listed only as workspace:, via the full-scan fallback', () => { writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') writeJson(join(root, 'package.json'), { diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts new file mode 100644 index 00000000..e2550d1b --- /dev/null +++ b/packages/intent/tests/hash.test.ts @@ -0,0 +1,212 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { nodeReadFs } from '../src/shared/utils.js' +import type { ReadFs } from '../src/shared/utils.js' + +const roots: Array = [] + +function skillRoot(): { root: string; skill: string } { + const root = mkdtempSync(join(tmpdir(), 'intent-hash-')) + const skill = join(root, 'skills', 'fetching') + mkdirSync(skill, { recursive: true }) + writeFileSync(join(skill, 'SKILL.md'), 'Fetch\r\n') + roots.push(root) + return { root, skill } +} + +afterEach(() => { + roots + .splice(0) + .forEach((path) => rmSync(path, { recursive: true, force: true })) +}) + +describe('computeSkillContentHash', () => { + it('pins the lockfile content digest framing', () => { + const { root, skill } = skillRoot() + writeFileSync( + join(skill, 'SKILL.md'), + Buffer.from('---\nname: pinned\ndescription: Pinned hash fixture\n---\n'), + ) + mkdirSync(join(skill, 'references')) + writeFileSync(join(skill, 'references', 'zeta.md'), Buffer.from('Zeta\n')) + writeFileSync( + join(skill, 'references', 'alpha.md'), + Buffer.from('Alpha\r\n'), + ) + + // Changing this value invalidates every existing intent.lock, so digest framing changes must be deliberate. + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe( + 'sha256-985f0fe3329f5eb4cbf3202c9d34da0c53d404292423a15a25d914b7fadc6ce7', + ) + }) + + it('normalizes text line endings', () => { + const { root, skill } = skillRoot() + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + writeFileSync(join(skill, 'SKILL.md'), 'Fetch\n') + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe(baseline) + writeFileSync(join(skill, 'SKILL.md'), 'Fetch\r') + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe(baseline) + }) + + it('hashes with a ReadFs that omits optional low-level methods', () => { + const { root, skill } = skillRoot() + const readFs: ReadFs = { + existsSync: nodeReadFs.existsSync, + lstatSync: nodeReadFs.lstatSync, + readFileSync: nodeReadFs.readFileSync, + readdirSync: nodeReadFs.readdirSync, + realpathSync: nodeReadFs.realpathSync, + } + + expect( + computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + fs: readFs, + }), + ).toBe(computeSkillContentHash({ packageRoot: root, skillDir: skill })) + }) + + it.each(['references', 'assets', 'scripts'])( + 'includes files under %s', + (directory) => { + const { root, skill } = skillRoot() + mkdirSync(join(skill, directory)) + writeFileSync(join(skill, directory, 'resource.txt'), 'One') + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + + writeFileSync(join(skill, directory, 'resource.txt'), 'Two') + + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(baseline) + }, + ) + + it('includes arbitrary top-level files', () => { + const { root, skill } = skillRoot() + writeFileSync(join(skill, 'EXTRA.md'), 'One') + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + writeFileSync(join(skill, 'EXTRA.md'), 'Two') + + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(baseline) + }) + + it('includes files under arbitrary top-level directories', () => { + const { root, skill } = skillRoot() + mkdirSync(join(skill, 'examples')) + writeFileSync(join(skill, 'examples', 'usage.md'), 'One') + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + + writeFileSync(join(skill, 'examples', 'usage.md'), 'Two') + + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(baseline) + }) + + it('preserves binary bytes', () => { + const { root, skill } = skillRoot() + writeFileSync(join(skill, 'SKILL.md'), Buffer.from([0xff, 13, 10])) + const binary = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + writeFileSync(join(skill, 'SKILL.md'), Buffer.from([0xff, 10])) + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(binary) + }) + + it('rejects an oversized skill file', () => { + const { root, skill } = skillRoot() + writeFileSync(join(skill, 'SKILL.md'), Buffer.alloc(4 * 1024 * 1024 + 1)) + + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow('Hash file size limit exceeded') + }) + + it('follows in-bound links and rejects dangling or escaping links when links are supported', () => { + const { root, skill } = skillRoot() + const target = join(root, 'shared.md') + writeFileSync(target, 'Shared') + mkdirSync(join(skill, 'references')) + try { + symlinkSync(target, join(skill, 'references', 'linked.md')) + } catch { + return + } + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toMatch(/^sha256-/) + symlinkSync( + join(root, 'missing.md'), + join(skill, 'references', 'dangling.md'), + ) + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow() + rmSync(join(skill, 'references', 'dangling.md')) + const outside = mkdtempSync(join(tmpdir(), 'intent-hash-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'outside.md'), 'Outside') + symlinkSync( + join(outside, 'outside.md'), + join(skill, 'references', 'outside.md'), + ) + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow() + }) + + it.each([ + ['the skill root', ['outside.md']], + ['an arbitrary nested directory', ['examples', 'nested', 'outside.md']], + ])('rejects an escaping symlink under %s', (_location, linkParts) => { + const { root, skill } = skillRoot() + const outside = mkdtempSync(join(tmpdir(), 'intent-hash-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'outside.md'), 'Outside') + mkdirSync(join(skill, ...linkParts.slice(0, -1)), { recursive: true }) + try { + symlinkSync(join(outside, 'outside.md'), join(skill, ...linkParts)) + } catch { + return + } + + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow('escapes package root through a symlink') + }) +}) diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 82e89853..08e1ef8c 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -7,17 +7,21 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { HOOK_AGENT_ADAPTERS } from '../src/hooks/adapters.js' -import { - buildHookRunnerScript, - formatHookInstallResult, - runInstallHooks, -} from '../src/hooks/install.js' +import { runInstallHooks } from '../src/hooks/install.js' +import { packageVersionToPin } from '../src/shared/command-runner.js' const tempDirs: Array = [] +const packageJson = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), + 'utf8', + ), +) as { version: string } +const intentPackagePin = packageVersionToPin(packageJson.version) afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -36,6 +40,14 @@ function readJson(filePath: string): Record { } describe('hook installer', () => { + it('pins stable versions to major.minor', () => { + expect(packageVersionToPin('0.4.2')).toBe('0.4') + }) + + it('pins prerelease versions exactly', () => { + expect(packageVersionToPin('0.4.0-next.1')).toBe('0.4.0-next.1') + }) + it('declares supported scopes in the adapter registry', () => { expect(HOOK_AGENT_ADAPTERS.claude.supportedScopes.has('project')).toBe(true) expect(HOOK_AGENT_ADAPTERS.codex.supportedScopes.has('project')).toBe(true) @@ -45,8 +57,12 @@ describe('hook installer', () => { expect(HOOK_AGENT_ADAPTERS.copilot.supportedScopes.has('user')).toBe(true) }) - it('installs project-scoped Claude and Codex hooks and skips Copilot', () => { + it('installs project-scoped Claude and Codex catalogues without edit gates', () => { const root = tempRoot('intent-hooks-project-') + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@10.0.0' }), + ) const results = runInstallHooks({ root, scope: 'project' }) @@ -74,39 +90,25 @@ describe('hook installer', () => { 'startup|resume|clear|compact', ) expect(claudeConfig.hooks.SessionStart[0].hooks[0]).toMatchObject({ - command: 'node', - args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], - type: 'command', - }) - expect(claudeConfig.hooks.PreToolUse).toHaveLength(1) - expect(claudeConfig.hooks.PreToolUse[0].matcher).toBe( - 'Bash|Write|Edit|MultiEdit|NotebookEdit', - ) - expect(claudeConfig.hooks.PreToolUse[0].hooks[0]).toMatchObject({ - command: 'node', - args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], + command: `pnpm dlx @tanstack/intent@${intentPackagePin} hooks run --agent claude`, type: 'command', }) + expect(claudeConfig.hooks.PreToolUse).toEqual([]) const codexConfig = readJson(join(root, '.codex', 'hooks.json')) expect(codexConfig.hooks.SessionStart[0].matcher).toBe( 'startup|resume|clear|compact', ) - expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toContain( - '.intent/hooks/intent-codex-gate.mjs', - ) - expect(codexConfig.hooks.PreToolUse[0].matcher).toBe( - 'Bash|apply_patch|Edit|Write', - ) - expect(codexConfig.hooks.PreToolUse[0].hooks[0].command).toContain( - '.intent/hooks/intent-codex-gate.mjs', + expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toBe( + `pnpm dlx @tanstack/intent@${intentPackagePin} hooks run --agent codex`, ) + expect(codexConfig.hooks.PreToolUse).toEqual([]) expect( - existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), - ).toBe(true) + existsSync(join(root, '.intent', 'hooks', 'intent-claude-catalog.mjs')), + ).toBe(false) expect( - existsSync(join(root, '.intent', 'hooks', 'intent-codex-gate.mjs')), - ).toBe(true) + existsSync(join(root, '.intent', 'hooks', 'intent-codex-catalog.mjs')), + ).toBe(false) }) it('installs user-scoped Copilot hooks into the selected home', () => { @@ -125,12 +127,11 @@ describe('hook installer', () => { expect(result).toMatchObject({ agent: 'copilot', status: 'created' }) const config = readJson(join(copilotHome, 'hooks', 'hooks.json')) const sessionCommand = config.hooks.SessionStart[0].command as string - const command = config.hooks.PreToolUse[0].command as string - expect(sessionCommand).toContain(join(homeDir, '.tanstack')) - expect(sessionCommand).toContain('intent-copilot-gate.mjs') - expect(command).toContain(join(homeDir, '.tanstack')) - expect(command).toContain('intent-copilot-gate.mjs') + expect(sessionCommand).toBe( + `npx @tanstack/intent@${intentPackagePin} hooks run --agent copilot`, + ) + expect(config.hooks.PreToolUse).toEqual([]) expect( existsSync( join( @@ -138,16 +139,24 @@ describe('hook installer', () => { '.tanstack', 'intent', 'hooks', - 'intent-copilot-gate.mjs', + 'intent-copilot-catalog.mjs', ), ), - ).toBe(true) + ).toBe(false) }) - it('updates only the Intent hook group on repeated installs', () => { + it('updates only the Intent hook group and is unchanged on repeated installs', () => { const root = tempRoot('intent-hooks-update-') const settingsPath = join(root, '.claude', 'settings.json') + const legacyScriptPath = join( + root, + '.intent', + 'hooks', + 'intent-claude-gate.mjs', + ) mkdirSync(join(root, '.claude'), { recursive: true }) + mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) + writeFileSync(legacyScriptPath, 'legacy gate') writeFileSync( settingsPath, JSON.stringify( @@ -180,8 +189,9 @@ describe('hook installer', () => { const config = readJson(settingsPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse).toHaveLength(1) expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('echo keep') + expect(existsSync(legacyScriptPath)).toBe(true) expect(second[0]).toMatchObject({ status: 'unchanged' }) }) @@ -217,13 +227,10 @@ describe('hook installer', () => { const config = readJson(settingsPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse).toHaveLength(1) expect(config.hooks.PreToolUse[0].hooks).toEqual([ { type: 'command', command: 'echo keep' }, ]) - expect(config.hooks.PreToolUse[1].hooks[0].args[0]).toContain( - 'intent-claude-gate.mjs', - ) }) it('replaces direct Copilot Intent hook entries on reinstall', () => { @@ -258,11 +265,7 @@ describe('hook installer', () => { const config = readJson(hooksPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) - expect(config.hooks.PreToolUse[0]).toEqual({ command: 'echo keep' }) - expect(config.hooks.PreToolUse[1].command).toContain( - 'intent-copilot-gate.mjs', - ) + expect(config.hooks.PreToolUse).toEqual([{ command: 'echo keep' }]) }) it('preserves hooks that only mention an Intent gate outside command fields', () => { @@ -300,260 +303,10 @@ describe('hook installer', () => { const config = readJson(hooksPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) - expect(config.hooks.PreToolUse[0]).toMatchObject({ + expect(config.hooks.PreToolUse).toHaveLength(1) + expect(config.hooks.PreToolUse[0]).toEqual({ command: 'echo keep', note: 'mentions intent-copilot-gate.mjs in documentation', }) - expect(config.hooks.PreToolUse[1].command).toContain( - 'intent-copilot-gate.mjs', - ) - }) - - it('builds a runner script with command-free denial text', () => { - const script = buildHookRunnerScript('claude') - - expect(script).toContain('const AGENT = "claude"') - expect(script).toContain('permissionDecision') - expect(script).not.toMatch(/Blocked:.*intent\s+(list|load)/i) - expect(script).not.toContain('@tanstack/intent/core') - expect(script).not.toContain('createRequire') - }) - - it('runs the generated gate script through the load then edit cycle', () => { - const root = tempRoot('intent-hooks-runner-') - const scriptPath = join(root, 'intent-claude-gate.mjs') - writeFileSync(scriptPath, buildHookRunnerScript('claude')) - - const beforeLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - const load = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Bash', - tool_input: { command: 'intent load @tanstack/router#routing' }, - }) - const afterLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(beforeLoad.status).toBe(0) - expect(JSON.parse(beforeLoad.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) - expect(load.status).toBe(0) - expect(load.stdout).toBe('') - expect(afterLoad.status).toBe(0) - expect(afterLoad.stdout).toBe('') - }) - - it.each(['claude', 'codex', 'copilot'] as const)( - 'emits session catalog context for %s', - (agent) => { - const root = tempRoot(`intent-hooks-session-catalog-${agent}-`) - const catalogCommand = writeFakeIntentListCommand(root) - const scriptPath = join( - root, - '.intent', - 'hooks', - `intent-${agent}-gate.mjs`, - ) - mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) - writeFileSync(scriptPath, buildHookRunnerScript(agent, catalogCommand)) - - const result = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'SessionStart', - session_id: 'session-a', - source: 'startup', - }) - - expect(result.status).toBe(0) - const output = JSON.parse(result.stdout) - const context = - agent === 'copilot' - ? output.additionalContext - : output.hookSpecificOutput.additionalContext - expect(context).toContain('TanStack Intent skills are available') - expect(context).toContain( - '- @tanstack/router#routing: Router routing guidance', - ) - expect(context).toContain('load that full skill guidance') - expect(context).not.toContain('intent load ') - if (agent !== 'copilot') { - expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart') - } - }, - ) - - it('does not unlock edits after session catalog context', () => { - const root = tempRoot('intent-hooks-session-catalog-gate-') - const catalogCommand = writeFakeIntentListCommand(root) - const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') - mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) - writeFileSync(scriptPath, buildHookRunnerScript('claude', catalogCommand)) - - const sessionStart = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'SessionStart', - session_id: 'session-a', - source: 'startup', - }) - const edit = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(sessionStart.status).toBe(0) - expect(JSON.parse(sessionStart.stdout)).toMatchObject({ - hookSpecificOutput: { hookEventName: 'SessionStart' }, - }) - expect(edit.status).toBe(0) - expect(JSON.parse(edit.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) - }) - - it('continues silently when session catalog loading fails', () => { - const root = tempRoot('intent-hooks-session-catalog-missing-') - const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') - mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) - writeFileSync( - scriptPath, - buildHookRunnerScript( - 'claude', - `${quoteShell(process.execPath)} ${quoteShell(join(root, 'missing.mjs'))}`, - ), - ) - - const result = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'SessionStart', - session_id: 'session-a', - source: 'startup', - }) - - expect(result.status).toBe(0) - expect(result.stdout).toBe('') - }) - - it('does not unlock edits after non-executed load text', () => { - const root = tempRoot('intent-hooks-non-executed-load-') - const scriptPath = join(root, 'intent-claude-gate.mjs') - writeFileSync(scriptPath, buildHookRunnerScript('claude')) - - const echoLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Bash', - tool_input: { command: 'echo intent load @tanstack/router#routing' }, - }) - const afterEcho = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(echoLoad.status).toBe(0) - expect(echoLoad.stdout).toBe('') - expect(afterEcho.status).toBe(0) - expect(JSON.parse(afterEcho.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) - }) - - it('unlocks edits after a load in an or-chain command', () => { - const root = tempRoot('intent-hooks-runner-or-chain-') - const scriptPath = join(root, 'intent-claude-gate.mjs') - writeFileSync(scriptPath, buildHookRunnerScript('claude')) - - const load = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Bash', - tool_input: { - command: 'npm test || intent load @tanstack/router#routing', - }, - }) - const afterLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(load.status).toBe(0) - expect(load.stdout).toBe('') - expect(afterLoad.status).toBe(0) - expect(afterLoad.stdout).toBe('') - }) - - it('formats skipped install results', () => { - expect( - formatHookInstallResult({ - agent: 'copilot', - configPath: null, - reason: 'project scope is not supported; use --scope user', - scope: 'project', - scriptPath: null, - status: 'skipped', - }), - ).toBe( - 'Skipped Intent hooks for copilot: project scope is not supported; use --scope user', - ) }) }) - -function runHookScript(scriptPath: string, event: Record) { - return spawnSync(process.execPath, [scriptPath], { - encoding: 'utf8', - input: JSON.stringify(event), - }) -} - -function writeFakeIntentListCommand(root: string): string { - const scriptPath = join(root, 'fake-intent-list.mjs') - writeFileSync( - scriptPath, - `if (process.env.INTENT_AUDIENCE !== 'agent') { - process.exit(1) -} - -console.log(JSON.stringify({ - conflicts: [], - debug: { scan: { packageJsonReadCount: 3 } }, - packages: [{ name: '@tanstack/router' }], - skills: [ - { - description: 'Router routing guidance', - use: '@tanstack/router#routing', - }, - ], - warnings: [], - })) -`, - ) - return `${quoteShell(process.execPath)} ${quoteShell(scriptPath)}` -} - -function quoteShell(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts deleted file mode 100644 index b7a9e66f..00000000 --- a/packages/intent/tests/hooks.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { formatClaudePreToolUseOutput } from '../src/hooks/agents/claude.js' -import { formatCodexPreToolUseOutput } from '../src/hooks/agents/codex.js' -import { formatCopilotPreToolUseOutput } from '../src/hooks/agents/copilot.js' -import { - EDIT_TOOLS_BY_AGENT, - GATE_DENY_REASON, - gateDecision, - hasLoadFromObservations, - observationFromEvent, - parseIntentInvocation, -} from '../src/hooks/policy.js' - -describe('intent hook policy', () => { - it('parses intent load and list invocations across runners', () => { - expect( - parseIntentInvocation( - 'npx @tanstack/intent@latest load @tanstack/router#routing', - ), - ).toEqual({ action: 'load', skillUse: '@tanstack/router#routing' }) - expect(parseIntentInvocation('intent list')).toEqual({ action: 'list' }) - expect( - parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), - ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) - expect( - parseIntentInvocation('npm test || intent load @tanstack/x#y'), - ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) - }) - - it('ignores non-intent commands and incomplete load commands', () => { - expect(parseIntentInvocation('npm run build')).toBeUndefined() - expect( - parseIntentInvocation('echo intent load @tanstack/router#routing'), - ).toBeUndefined() - expect( - parseIntentInvocation('# intent load @tanstack/router#routing'), - ).toBeUndefined() - expect(parseIntentInvocation('intent load')).toBeUndefined() - expect(parseIntentInvocation(undefined)).toBeUndefined() - }) - - it('observes intent commands only from Bash tool calls', () => { - expect( - observationFromEvent({ - tool_name: 'Bash', - tool_input: { command: 'intent load @tanstack/router#routing' }, - }), - ).toEqual({ - action: 'load', - skillUse: '@tanstack/router#routing', - raw: 'intent load @tanstack/router#routing', - }) - expect( - observationFromEvent({ - toolName: 'Bash', - toolArgs: JSON.stringify({ command: 'intent list' }), - }), - ).toEqual({ action: 'list', raw: 'intent list', skillUse: undefined }) - expect( - observationFromEvent({ - tool_name: 'Edit', - tool_input: { command: 'intent load @tanstack/router#routing' }, - }), - ).toBeUndefined() - }) - - it('denies edit tools until a load is observed', () => { - expect( - gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: false }), - ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) - expect( - gateDecision({ agent: 'claude', toolName: 'Write', hasLoaded: false }), - ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) - expect( - gateDecision({ - agent: 'codex', - toolName: 'apply_patch', - hasLoaded: false, - }), - ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) - expect( - gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: true }), - ).toEqual({ decision: 'allow' }) - expect( - gateDecision({ agent: 'codex', toolName: 'Bash', hasLoaded: false }), - ).toEqual({ decision: 'allow' }) - expect(EDIT_TOOLS_BY_AGENT.copilot.has('Write')).toBe(true) - expect(EDIT_TOOLS_BY_AGENT.claude.has('Edit')).toBe(true) - expect(EDIT_TOOLS_BY_AGENT.codex.has('apply_patch')).toBe(true) - }) - - it('detects a prior load from observation records', () => { - expect(hasLoadFromObservations([{ action: 'list' }])).toBe(false) - expect( - hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), - ).toBe(true) - }) - - it('keeps the deny reason free of parseable intent commands', () => { - expect(parseIntentInvocation(GATE_DENY_REASON)).toBeUndefined() - expect(/intent\s+(list|load)/i.test(GATE_DENY_REASON)).toBe(false) - }) -}) - -describe('intent hook agent adapters', () => { - const deny = { decision: 'deny' as const, reason: GATE_DENY_REASON } - - it('formats Copilot PreToolUse denial output', () => { - expect(formatCopilotPreToolUseOutput(deny)).toEqual({ - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }) - expect(formatCopilotPreToolUseOutput({ decision: 'allow' })).toBeUndefined() - }) - - it('formats Claude PreToolUse denial output', () => { - expect(formatClaudePreToolUseOutput(deny)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }, - }) - expect(formatClaudePreToolUseOutput({ decision: 'allow' })).toBeUndefined() - }) - - it('formats Codex PreToolUse denial output', () => { - expect(formatCodexPreToolUseOutput(deny)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }, - }) - expect(formatCodexPreToolUseOutput({ decision: 'allow' })).toBeUndefined() - }) -}) diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts new file mode 100644 index 00000000..39a69577 --- /dev/null +++ b/packages/intent/tests/install-config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from '../src/commands/install/config.js' +import type { IntentConsumerConfig } from '../src/commands/install/config.js' + +describe('installer configuration', () => { + it('updates JSONC fields without changing unrelated formatting', () => { + const source = + '\ufeff{\r\n\t// keep this comment\r\n\t"name": "app",\r\n\t"intent": {\r\n\t\t"skills": ["old"],\r\n\t},\r\n}\r\n' + const updated = updateIntentConsumerConfigText(source, { + skills: ['@tanstack/query'], + exclude: ['@other/pkg'], + }) + + expect(updated.startsWith('\ufeff')).toBe(true) + expect(updated).toContain('\t// keep this comment\r\n') + expect(updated).toContain('\t"name": "app"') + expect(updated).toContain('\r\n') + expect(updated.endsWith('\r\n')).toBe(true) + expect(readIntentConsumerConfig(updated)).toEqual({ + skills: ['@tanstack/query'], + exclude: ['@other/pkg'], + }) + }) + + it('returns byte-identical JSONC for an unchanged request', () => { + const source = + '{\n // formatting stays\n "intent": {\n "skills": ["pkg"],\n "exclude": []\n }\n}\n' + const requested: IntentConsumerConfig = { skills: ['pkg'], exclude: [] } + expect(updateIntentConsumerConfigText(source, requested)).toBe(source) + }) + + it('ignores legacy intent.install when reading policy', () => { + expect( + readIntentConsumerConfig( + '{"intent":{"skills":["pkg"],"exclude":[],"install":"invalid"}}', + ), + ).toEqual({ skills: ['pkg'], exclude: [] }) + }) + + it('removes legacy intent.install when updating policy', () => { + const updated = updateIntentConsumerConfigText( + '{"intent":{"skills":["old"],"exclude":[],"install":{"method":"hooks"}}}\n', + { skills: ['pkg'], exclude: [] }, + ) + + expect(updated).not.toContain('"install"') + expect(readIntentConsumerConfig(updated)).toEqual({ + skills: ['pkg'], + exclude: [], + }) + }) + + it('preserves unchanged array formatting when another field changes', () => { + const source = `{ + "intent": { + "skills": [ + "first", + "second" + ], + "exclude": [] + } +} +` + + const updated = updateIntentConsumerConfigText(source, { + skills: ['first', 'second'], + exclude: ['ignored'], + }) + + expect(updated).toContain( + '"skills": [\n "first",\n "second"\n ]', + ) + }) + + it('rejects blank exclude entries', () => { + expect(() => + readIntentConsumerConfig('{"intent":{"exclude":[" "]}}'), + ).toThrow('must not contain blank entries') + }) +}) diff --git a/packages/intent/tests/install-delivery.test.ts b/packages/intent/tests/install-delivery.test.ts new file mode 100644 index 00000000..d8ca1a0e --- /dev/null +++ b/packages/intent/tests/install-delivery.test.ts @@ -0,0 +1,79 @@ +import { execFileSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + DELIVERY_CONFIG_PATH, + readIntentDeliveryConfig, + writeIntentDeliveryConfig, +} from '../src/commands/install/delivery.js' +import type { IntentDeliveryConfig } from '../src/commands/install/delivery.js' + +const roots: Array = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('local delivery configuration', () => { + it('round-trips the exact schema', () => { + const root = mkdtempSync(join(tmpdir(), 'intent-delivery-')) + roots.push(root) + const config: IntentDeliveryConfig = { + method: 'symlink', + targets: ['agents', 'github'], + } + + expect(DELIVERY_CONFIG_PATH).toBe('.intent/delivery.json') + expect(writeIntentDeliveryConfig(root, config)).toBe(true) + expect(readIntentDeliveryConfig(root)).toEqual(config) + expect( + JSON.parse(readFileSync(join(root, DELIVERY_CONFIG_PATH), 'utf8')), + ).toEqual(config) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown field', '{"method":"symlink","targets":["agents"],"extra":true}'], + ['invalid method', '{"method":"copy","targets":["agents"]}'], + ['empty targets', '{"method":"symlink","targets":[]}'], + ['invalid target', '{"method":"symlink","targets":["unknown"]}'], + ['duplicate target', '{"method":"symlink","targets":["agents","agents"]}'], + ['unsupported target', '{"method":"hooks","targets":["vscode"]}'], + ])('rejects %s', (_label, source) => { + const root = mkdtempSync(join(tmpdir(), 'intent-delivery-')) + roots.push(root) + mkdirSync(join(root, '.intent')) + writeFileSync(join(root, DELIVERY_CONFIG_PATH), source, 'utf8') + + expect(() => readIntentDeliveryConfig(root)).toThrow() + }) + + it('writes idempotently and excludes delivery from the local Git repository', () => { + const root = mkdtempSync(join(tmpdir(), 'intent-delivery-')) + roots.push(root) + execFileSync('git', ['init', '--quiet'], { cwd: root }) + writeFileSync(join(root, '.gitignore'), 'shared\r\n', 'utf8') + const config: IntentDeliveryConfig = { + method: 'hooks', + targets: ['github'], + } + + expect(writeIntentDeliveryConfig(root, config)).toBe(true) + expect(writeIntentDeliveryConfig(root, config)).toBe(false) + expect(readIntentDeliveryConfig(root)).toEqual(config) + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe('shared\r\n') + expect( + readFileSync(join(root, '.git', 'info', 'exclude'), 'utf8'), + ).toContain(DELIVERY_CONFIG_PATH) + }) +}) diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts new file mode 100644 index 00000000..f6c7e5ec --- /dev/null +++ b/packages/intent/tests/install-plan.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest' +import { + buildInstallDeltaInventory, + buildSkillSelectionPlan, + summarizeInstallDeltaInventory, +} from '../src/commands/install/plan.js' +import type { + InventoryLockStatus, + InventoryPolicyStatus, +} from '../src/commands/install/plan.js' +import type { IntentLockfileSource } from '../src/core/lockfile/lockfile.js' +import type { IntentPackage } from '../src/shared/types.js' + +function pkg( + name: string, + skills: Array, + kind: IntentPackage['kind'] = 'npm', +): IntentPackage { + return { + name, + version: '1.0.0', + kind, + source: 'local', + packageRoot: name, + intent: { version: 1, repo: '', docs: '' }, + skills: skills.map((name) => ({ + name, + path: `skills/${name}/SKILL.md`, + description: '', + })), + } +} + +const discovered = [ + pkg('@other/core', ['second']), + pkg('@tanstack/query', ['zeta', 'alpha']), + pkg('workspace-query', ['local'], 'workspace'), +] + +describe('installer selection planning', () => { + it('uses exact discovered source identities for all-found', () => { + expect( + buildSkillSelectionPlan(discovered, { mode: 'all-found' }), + ).toMatchObject({ + skills: ['@other/core', '@tanstack/query', 'workspace:workspace-query'], + exclude: [], + }) + }) + + it('adds explicit exclusions for scope nonmatches', () => { + const plan = buildSkillSelectionPlan(discovered, { + mode: 'scope', + scope: '@tanstack/*', + }) + expect(plan.skills).toEqual(['@tanstack/*']) + expect(plan.exclude).toEqual(['@other/core', 'workspace-query']) + expect(plan.packages.flatMap((entry) => entry.skills)).toEqual([ + { id: '@other/core#second', status: 'excluded' }, + { id: '@tanstack/query#alpha', status: 'enabled' }, + { id: '@tanstack/query#zeta', status: 'enabled' }, + { id: 'workspace:workspace-query#local', status: 'excluded' }, + ]) + }) + + it('allows checked skills and excludes unchecked packages for individual selection', () => { + const plan = buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['@tanstack/query#alpha'], + }) + expect(plan.skills).toEqual(['@tanstack/query#alpha']) + expect(plan.exclude).toEqual(['@other/core', 'workspace-query']) + }) + + it('writes a bare package exclusion when no skills are selected', () => { + const plan = buildSkillSelectionPlan([pkg('disabled', ['one', 'two'])], { + mode: 'individual', + enabled: [], + }) + + expect(plan.skills).toEqual([]) + expect(plan.exclude).toEqual(['disabled']) + }) + + it('rejects malformed, duplicate, and unknown individual selections', () => { + expect(() => + buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['not-an-id'], + }), + ).toThrow('Unknown') + expect(() => + buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['@tanstack/query#alpha', '@tanstack/query#alpha'], + }), + ).toThrow('Duplicate') + }) + + it('rejects a selection whose exclusion would hide an enabled skill', () => { + const sameName = [ + pkg('shared', ['npm-skill']), + pkg('shared', ['workspace-skill'], 'workspace'), + ] + expect( + buildSkillSelectionPlan(sameName, { mode: 'all-found' }).skills, + ).toEqual(['shared', 'workspace:shared']) + expect(() => + buildSkillSelectionPlan(sameName, { + mode: 'individual', + enabled: ['workspace:shared#workspace-skill'], + }), + ).toThrow('would also hide "workspace:shared#workspace-skill"') + }) + + it('writes kind-aware skill allows when both kinds select a subset', () => { + const sameName = [ + pkg('shared', ['keep', 'drop']), + pkg('shared', ['keep', 'drop'], 'workspace'), + ] + const plan = buildSkillSelectionPlan(sameName, { + mode: 'individual', + enabled: ['shared#keep', 'workspace:shared#keep'], + }) + + expect(plan.skills).toEqual(['shared#keep', 'workspace:shared#keep']) + expect(plan.exclude).toEqual([]) + }) + + it('writes a shared package exclusion when both kinds are fully disabled', () => { + const sameName = [ + pkg('shared', ['one']), + pkg('shared', ['two'], 'workspace'), + pkg('other', ['keep']), + ] + const plan = buildSkillSelectionPlan(sameName, { + mode: 'individual', + enabled: ['other#keep'], + }) + + expect(plan.skills).toEqual(['other']) + expect(plan.exclude).toEqual(['shared']) + }) + + it('uses kind-aware workspace grammar for partial skill allows', () => { + const plan = buildSkillSelectionPlan( + [pkg('workspace-only', ['enabled', 'excluded'], 'workspace')], + { + mode: 'individual', + enabled: ['workspace:workspace-only#enabled'], + }, + ) + + expect(plan.skills).toEqual(['workspace:workspace-only#enabled']) + expect(plan.exclude).toEqual([]) + }) + + it('rejects duplicate discovered sources and skills', () => { + expect(() => + buildSkillSelectionPlan( + [pkg('duplicate', ['one']), pkg('duplicate', ['two'])], + { + mode: 'all-found', + }, + ), + ).toThrow('Duplicate discovered source') + expect(() => + buildSkillSelectionPlan([pkg('duplicate', ['one', 'one'])], { + mode: 'all-found', + }), + ).toThrow('Duplicate discovered skill') + }) +}) + +describe('installer delta inventory', () => { + it('summarizes pending, new, changed, and removed entries', () => { + expect( + summarizeInstallDeltaInventory({ + packages: [ + { + name: 'pkg', + kind: 'npm', + skills: [ + { id: 'pkg#pending', policy: 'pending', lock: null }, + { id: 'pkg#new', policy: 'enabled', lock: 'new' }, + { id: 'pkg#changed', policy: 'enabled', lock: 'changed' }, + { id: 'pkg#accepted', policy: 'enabled', lock: 'accepted' }, + ], + }, + ], + removed: [{ kind: 'npm', id: 'gone', path: null }], + }), + ).toEqual({ + newDependencies: [{ name: 'pkg', skillCount: 1 }], + newSkills: [{ name: 'pkg', skillCount: 1 }], + changed: [{ name: 'pkg', skillCount: 1 }], + removed: 1, + }) + }) + + it('classifies changed skills independently and reports removed lock entries', () => { + const accepted: InventoryLockStatus = 'accepted' + const enabled: InventoryPolicyStatus = 'enabled' + expect([enabled, accepted]).toEqual(['enabled', 'accepted']) + const packages = [pkg('pkg', ['alpha', 'beta'])] + const current: Array = [ + { + kind: 'npm', + id: 'pkg', + skills: [ + { path: 'skills/alpha', contentHash: 'changed' }, + { path: 'skills/beta', contentHash: 'accepted' }, + ], + }, + ] + const inventory = buildInstallDeltaInventory( + packages, + current, + { + status: 'found', + lockfile: { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: 'pkg', + skills: [ + { path: 'skills/alpha', contentHash: 'old' }, + { path: 'skills/beta', contentHash: 'accepted' }, + { path: 'skills/removed', contentHash: 'removed' }, + ], + }, + { + kind: 'workspace', + id: 'gone', + skills: [{ path: 'skills/old', contentHash: 'removed' }], + }, + ], + }, + }, + { skills: ['pkg'], exclude: [] }, + ) + expect(inventory.packages[0]!.skills).toEqual([ + { id: 'pkg#alpha', policy: 'enabled', lock: 'changed' }, + { id: 'pkg#beta', policy: 'enabled', lock: 'accepted' }, + ]) + expect(inventory.removed).toEqual([ + { kind: 'npm', id: 'pkg', path: 'skills/removed' }, + { kind: 'workspace', id: 'gone', path: null }, + ]) + }) + + it('marks enabled sources as new without a lock and leaves pending policy unaccepted', () => { + const inventory = buildInstallDeltaInventory( + [pkg('a', ['one']), pkg('b', ['two'])], + [ + { + kind: 'npm', + id: 'a', + skills: [{ path: 'skills/one', contentHash: 'a' }], + }, + { + kind: 'npm', + id: 'b', + skills: [{ path: 'skills/two', contentHash: 'b' }], + }, + ], + { status: 'missing' }, + { skills: ['a'], exclude: [] }, + ) + expect(inventory.packages.map((entry) => entry.skills[0])).toEqual([ + { id: 'a#one', policy: 'enabled', lock: 'new' }, + { id: 'b#two', policy: 'pending', lock: null }, + ]) + }) + + it('marks a skill outside a skill-level allow entry as pending', () => { + const inventory = buildInstallDeltaInventory( + [pkg('pkg', ['alpha', 'beta'])], + [], + { status: 'missing' }, + { skills: ['pkg#alpha'], exclude: [] }, + ) + + expect(inventory.packages[0]!.skills).toEqual([ + { id: 'pkg#alpha', policy: 'enabled', lock: 'new' }, + { id: 'pkg#beta', policy: 'pending', lock: null }, + ]) + }) + + it('treats an explicit empty skill policy as deny-all', () => { + const inventory = buildInstallDeltaInventory( + [pkg('pkg', ['alpha', 'beta'])], + [], + { status: 'missing' }, + { skills: [], exclude: [] }, + ) + + expect(inventory.packages[0]!.skills).toEqual([ + { id: 'pkg#alpha', policy: 'excluded', lock: null }, + { id: 'pkg#beta', policy: 'excluded', lock: null }, + ]) + }) + + it('records a declined package as excluded rather than pending', () => { + const discovered = [pkg('keep', ['one']), pkg('decline', ['two'])] + const plan = buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['keep#one'], + }) + const inventory = buildInstallDeltaInventory( + discovered, + [], + { status: 'missing' }, + { skills: plan.skills, exclude: plan.exclude }, + ) + const policies = new Map( + inventory.packages.flatMap((entry) => + entry.skills.map((skill) => [skill.id, skill.policy]), + ), + ) + + expect(policies.get('keep#one')).toBe('enabled') + expect(policies.get('decline#two')).toBe('excluded') + }) +}) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 67112f5e..4868a72f 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -1,32 +1,77 @@ import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runInstallCommand } from '../src/commands/install/command.js' +import { readIntentConsumerConfig } from '../src/commands/install/config.js' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, - resolveIntentSkillsBlockTargetPath, + buildIntentSkillsBlockFromPackages, + resolveMapTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../src/commands/install/guidance.js' +import { readIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { packageVersionToPin } from '../src/shared/command-runner.js' import type { IntentPackage, ScanResult, SkillEntry, } from '../src/shared/types.js' +const mapPromptMocks = vi.hoisted(() => ({ + selectClackMapTarget: vi.fn<(root: string) => Promise>(), + selectClackSkills: vi.fn(), +})) + +vi.mock('../src/commands/install/prompts.js', async (importOriginal) => ({ + ...(await importOriginal()), + selectClackMapTarget: mapPromptMocks.selectClackMapTarget, + selectClackSkills: mapPromptMocks.selectClackSkills, +})) + const tempDirs: Array = [] +const originalCwd = process.cwd() +const originalStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') +const originalStdoutTTY = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', +) +const packageJson = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), + 'utf8', + ), +) as { version: string } +const intentPackagePin = packageVersionToPin(packageJson.version) afterEach(() => { + process.chdir(originalCwd) + if (originalStdinTTY) { + Object.defineProperty(process.stdin, 'isTTY', originalStdinTTY) + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY + } + if (originalStdoutTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalStdoutTTY) + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } + vi.restoreAllMocks() + vi.clearAllMocks() }) function tempRoot(): string { @@ -35,6 +80,89 @@ function tempRoot(): string { return root } +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2), 'utf8') +} + +function bootstrapProject(): string { + const root = tempRoot() + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + writeJson(join(packageRoot, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + const skillRoot = join(packageRoot, 'skills', 'fetching') + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: fetching\ndescription: Query fetching patterns\n---\n', + 'utf8', + ) + return root +} + +function writeFetchingSkill( + root: string, + frontmatterLines: Array, +): void { + writeFileSync( + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'SKILL.md', + ), + `---\n${frontmatterLines.join('\n')}\n---\n`, + 'utf8', + ) +} + +function bootstrapChdir(): { + root: string + packageJsonPath: string + originalPackageJson: string +} { + const root = bootstrapProject() + const packageJsonPath = join(root, 'package.json') + const originalPackageJson = readFileSync(packageJsonPath, 'utf8') + process.chdir(root) + return { root, packageJsonPath, originalPackageJson } +} + +function mockBootstrapSelection(target: string | null): void { + mapPromptMocks.selectClackSkills.mockResolvedValueOnce({ + mode: 'all-found', + }) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce(target) +} + +function configuredMapProject(): string { + const root = tempRoot() + writeJson(join(root, 'package.json'), { + name: 'app', + intent: { skills: ['pkg'], exclude: [] }, + }) + return root +} + +function expectNoBootstrapWrites( + root: string, + originalPackageJson: string, +): void { + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) +} + function skill(overrides: Partial): SkillEntry { return { name: 'core', @@ -85,11 +213,30 @@ function scanResult(packages: Array): ScanResult { } } +function mappedScanResult(): ScanResult { + return scanResult([ + pkg({ + skills: [skill({ description: 'Core guidance' })], + }), + ]) +} + +function setTTY(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value, + }) + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }) +} + const exampleBlock = ` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -100,7 +247,9 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(0) expect(generated.block).toContain('## Skill Loading') - expect(generated.block).toContain('npx @tanstack/intent@latest list') + expect(generated.block).toContain( + `npx @tanstack/intent@${intentPackagePin} list`, + ) expect(generated.block).toContain('If a listed skill matches the task') expect(generated.block).toContain('before changing files') expect(generated.block).toContain('Monorepos:') @@ -112,9 +261,11 @@ describe('install writer block builder', () => { it('builds package-manager-specific loading guidance', () => { const generated = buildIntentSkillGuidanceBlock('pnpm') - expect(generated.block).toContain('pnpm dlx @tanstack/intent@latest list') expect(generated.block).toContain( - 'pnpm dlx @tanstack/intent@latest load #', + `pnpm dlx @tanstack/intent@${intentPackagePin} list`, + ) + expect(generated.block).toContain( + `pnpm dlx @tanstack/intent@${intentPackagePin} load #`, ) }) @@ -149,18 +300,24 @@ describe('install writer block builder', () => { const generated = buildIntentSkillsBlock(result) + expect( + buildIntentSkillsBlockFromPackages( + result.packages, + result.packageManager, + ), + ).toEqual(generated) expect(generated.mappingCount).toBe(3) expect(generated.block).toBe(` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching patterns" - id: "@tanstack/query#mutations" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#mutations" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#mutations" for: "Mutation patterns" - id: "@tanstack/router#routing" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/router#routing" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/router#routing" for: "Routing patterns" `) @@ -191,7 +348,7 @@ tanstackIntent: expect(generated.block).toContain('id: "@tanstack/query#global-fetching"') expect(generated.block).toContain('id: "@tanstack/query#pnpm-fetching"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#global-fetching"', + `run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#global-fetching"`, ) expect(generated.block).not.toContain('/home/sarah') expect(generated.block).not.toContain('node_modules/.pnpm') @@ -230,12 +387,12 @@ tanstackIntent: expect(generated.block).toContain('for: "Core skill"') expect(generated.block).toContain('id: "@tanstack/query#core"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core"', + `run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#core"`, ) expect(generated.block).toContain('for: "Sub-skill"') expect(generated.block).toContain('id: "@tanstack/query#core/fetching"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core/fetching"', + `run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#core/fetching"`, ) expect(generated.block).not.toContain('Reference material') expect(generated.block).not.toContain('Maintainer task') @@ -302,6 +459,57 @@ tanstackIntent: }) describe('install writer file updates', () => { + it('resolves nested project map targets', () => { + const root = tempRoot() + + expect(resolveMapTargetPath(root, '.github/copilot-instructions.md')).toBe( + join(root, '.github', 'copilot-instructions.md'), + ) + }) + + it.each([ + ['', 'empty'], + ['/tmp/outside.md', 'native absolute'], + ['C:\\outside.md', 'Windows absolute'], + ['../outside.md', 'parent segment'], + ['notes/../outside.md', 'nested parent segment'], + ['.git/instructions.md', 'git metadata'], + ['notes/.git/instructions.md', 'nested git metadata'], + ['.GIT/instructions.md', 'case-variant git metadata'], + ])('rejects %s as a map target (%s)', (targetPath) => { + const root = tempRoot() + + expect(() => resolveMapTargetPath(root, targetPath)).toThrow() + }) + + it('rejects directory and trailing-separator map targets', () => { + const root = tempRoot() + mkdirSync(join(root, 'notes')) + + expect(() => resolveMapTargetPath(root, 'notes')).toThrow() + expect(() => resolveMapTargetPath(root, 'notes/')).toThrow() + }) + + it('rejects map targets through a symlinked parent outside the project', () => { + const root = tempRoot() + const outside = tempRoot() + symlinkSync(outside, join(root, 'linked-notes'), 'dir') + + expect(() => + resolveMapTargetPath(root, 'linked-notes/instructions.md'), + ).toThrow() + }) + + it('rejects an existing map target symlinked outside the project', () => { + const root = tempRoot() + const outside = tempRoot() + const outsideFile = join(outside, 'instructions.md') + writeFileSync(outsideFile, 'outside\n') + symlinkSync(outsideFile, join(root, 'instructions.md'), 'file') + + expect(() => resolveMapTargetPath(root, 'instructions.md')).toThrow() + }) + it('creates AGENTS.md when no managed block exists', () => { const root = tempRoot() @@ -345,6 +553,35 @@ After `) }) + it('replaces an explicit custom target block exactly once on rerun', () => { + const root = tempRoot() + const targetPath = resolveMapTargetPath(root, 'notes/assistant.md') + const surrounding = 'Project introduction\nProject details\n' + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, surrounding) + + writeIntentSkillsBlock({ + block: exampleBlock, + mappingCount: 1, + root, + targetPath, + }) + const updatedBlock = exampleBlock.replace( + 'Query data fetching', + 'Query cache management', + ) + writeIntentSkillsBlock({ + block: updatedBlock, + mappingCount: 1, + root, + targetPath, + }) + + const content = readFileSync(targetPath, 'utf8') + expect(content.match(//g)).toHaveLength(1) + expect(content).toBe(`${updatedBlock}\n${surrounding}`) + }) + it('prepends to an existing AGENTS.md without a managed block', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') @@ -388,15 +625,6 @@ old expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) - it('resolves the existing managed config as the write target', () => { - const root = tempRoot() - const claudePath = join(root, 'CLAUDE.md') - writeFileSync(claudePath, exampleBlock) - - expect(resolveIntentSkillsBlockTargetPath(root, 1)).toBe(claudePath) - expect(resolveIntentSkillsBlockTargetPath(root, 0)).toBe(null) - }) - it('rejects malformed managed blocks before writing', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') @@ -466,6 +694,36 @@ describe('install writer verification', () => { ).toEqual({ errors: [], ok: true }) }) + it.each([ + 'intent load @tanstack/query#fetching', + 'npx @tanstack/intent@latest load @tanstack/query#fetching', + 'npx @tanstack/intent@0.4 load @tanstack/query#fetching', + 'npx @tanstack/intent@0.4.0-next.1 load @tanstack/query#fetching', + ])( + 'accepts a guidance command that extracts its skill use: %s', + (command) => { + const root = tempRoot() + const agentsPath = join(root, 'AGENTS.md') + const block = ` +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "${command}" + for: "Query data fetching" + +` + writeFileSync(agentsPath, block) + + expect( + verifyIntentSkillsBlockFile({ + expectedBlock: block, + expectedMappingCount: 1, + targetPath: agentsPath, + }), + ).toEqual({ errors: [], ok: true }) + }, + ) + it('accepts a written compact block', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') @@ -473,7 +731,7 @@ describe('install writer verification', () => { # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -542,7 +800,7 @@ tanstackIntent: const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. +# Skill mappings - load \`use\` with \`npx @tanstack/intent@${intentPackagePin} load \`. skills: - when: "Global query skill" load: "/home/sarah/.npm-global/lib/node_modules/@tanstack/query/skills/global/SKILL.md" @@ -569,7 +827,7 @@ skills: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" ` writeFileSync(agentsPath, block) @@ -592,7 +850,7 @@ tanstackIntent: const block = ` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + - run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -617,7 +875,7 @@ tanstackIntent: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -642,7 +900,7 @@ tanstackIntent: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/router#routing" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/router#routing" for: "Query data fetching" ` @@ -667,7 +925,7 @@ tanstackIntent: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Edit /Users/sarah/project/src files" ` @@ -688,3 +946,280 @@ tanstackIntent: ) }) }) + +describe('install map destination command', () => { + beforeEach(() => { + setTTY(true) + }) + + it('bootstraps trust and writes the selected map without delivery state', async () => { + const root = bootstrapProject() + const targetPath = join(root, '.github', 'copilot-instructions.md') + process.chdir(root) + mockBootstrapSelection('.github/copilot-instructions.md') + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock')).status).toBe('found') + expect(readFileSync(targetPath, 'utf8')).toContain( + 'id: "@tanstack/query#fetching"', + ) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + + it('writes trust only after the bootstrap map verifies', async () => { + const { root, packageJsonPath, originalPackageJson } = bootstrapChdir() + const targetPath = join(root, 'AGENTS.md') + writeFetchingSkill(root, [ + 'name: fetching', + 'description: Edit /Users/example/project files', + ]) + mockBootstrapSelection('AGENTS.md') + + await expect( + runInstallCommand({ map: true }, () => Promise.resolve(scanResult([]))), + ).rejects.toThrow('Install verification failed for AGENTS.md:') + + expect(readFileSync(targetPath, 'utf8')).toContain( + 'Edit /Users/example/project files', + ) + expect(readFileSync(packageJsonPath, 'utf8')).toBe(originalPackageJson) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + + it('falls through to policed map behavior when the TTY root has no package', async () => { + const root = tempRoot() + process.chdir(root) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ map: true }, scan) + + expect(scan).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('does not bootstrap without a lock outside a TTY', async () => { + const { root, originalPackageJson } = bootstrapChdir() + setTTY(false) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ map: true }, scan) + + expect(scan).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('preserves existing policy without a lock and does not bootstrap', async () => { + const root = bootstrapProject() + const packageJsonPath = join(root, 'package.json') + const originalPackageJson = `{ + "name": "app", + "intent": { + "skills": ["@tanstack/query"], + "exclude": [] + } +} +` + writeFileSync(packageJsonPath, originalPackageJson) + process.chdir(root) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + + await runInstallCommand({ map: true }, scan) + + expect(scan).toHaveBeenCalledOnce() + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('writes nothing when bootstrap skill selection is cancelled', async () => { + const { root, originalPackageJson } = bootstrapChdir() + mapPromptMocks.selectClackSkills.mockResolvedValueOnce(null) + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('writes nothing when bootstrap map destination selection is cancelled', async () => { + const { root, originalPackageJson } = bootstrapChdir() + mockBootstrapSelection(null) + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(mapPromptMocks.selectClackMapTarget).toHaveBeenCalledWith( + process.cwd(), + ) + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('does not prompt or write when bootstrap finds no actionable skills', async () => { + const { root, originalPackageJson } = bootstrapChdir() + writeFetchingSkill(root, [ + 'name: fetching', + 'description: Query reference', + 'type: reference', + ]) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ map: true }, scan) + + expect(log).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(scan).not.toHaveBeenCalled() + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('dry-runs bootstrap map output without writing trust or map files', async () => { + const { root, originalPackageJson } = bootstrapChdir() + mockBootstrapSelection('.github/copilot-instructions.md') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ dryRun: true, map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(log.mock.calls.flat().join('\n')).toContain( + 'Generated 1 mapping for .github/copilot-instructions.md.', + ) + expect(log.mock.calls.flat().join('\n')).toContain( + 'id: "@tanstack/query#fetching"', + ) + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('writes bootstrap artifacts at the workspace root from a package leaf', async () => { + const root = bootstrapProject() + const leaf = join(root, 'packages', 'app') + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(leaf, 'package.json'), { name: 'app', private: true }) + process.chdir(leaf) + mockBootstrapSelection('AGENTS.md') + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock')).status).toBe('found') + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + 'id: "@tanstack/query#fetching"', + ) + expect(existsSync(join(leaf, 'intent.lock'))).toBe(false) + expect(existsSync(join(leaf, 'AGENTS.md'))).toBe(false) + + mapPromptMocks.selectClackMapTarget.mockClear() + await runInstallCommand({ map: true }, () => + Promise.resolve(mappedScanResult()), + ) + + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + 'id: "pkg#core"', + ) + expect(existsSync(join(leaf, 'AGENTS.md'))).toBe(false) + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + }) + + it('targets the current package leaf during fallback', async () => { + const root = tempRoot() + const leaf = join(root, 'packages', 'app') + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + intent: { skills: ['pkg'], exclude: [] }, + }) + writeJson(join(leaf, 'package.json'), { name: 'app', private: true }) + process.chdir(leaf) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce('AGENTS.md') + + await runInstallCommand({ map: true }, () => + Promise.resolve(mappedScanResult()), + ) + + expect(mapPromptMocks.selectClackMapTarget).toHaveBeenCalledWith( + process.cwd(), + ) + expect(readFileSync(join(leaf, 'AGENTS.md'), 'utf8')).toContain( + 'id: "pkg#core"', + ) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it.each([{ global: true }, { globalOnly: true }])( + 'skips bootstrap and maps the scanned result with global flags (%o)', + async (flag) => { + const { root, originalPackageJson } = bootstrapChdir() + const scan = vi.fn(() => Promise.resolve(mappedScanResult())) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce('AGENTS.md') + + await runInstallCommand({ map: true, ...flag }, scan) + + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expect(scan).toHaveBeenCalledOnce() + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + 'id: "pkg#core"', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + }, + ) + + it('does not prompt or write when there are no mappings', async () => { + const root = configuredMapProject() + process.chdir(root) + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('updates an existing managed target without prompting', async () => { + const root = configuredMapProject() + const targetPath = join(root, 'CLAUDE.md') + process.chdir(root) + writeFileSync(targetPath, exampleBlock) + + await runInstallCommand({ map: true }, () => + Promise.resolve(mappedScanResult()), + ) + + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expect(readFileSync(targetPath, 'utf8')).toContain('id: "pkg#core"') + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) +}) diff --git a/packages/intent/tests/integration/catalog-bundle.test.ts b/packages/intent/tests/integration/catalog-bundle.test.ts new file mode 100644 index 00000000..c83a8236 --- /dev/null +++ b/packages/intent/tests/integration/catalog-bundle.test.ts @@ -0,0 +1,94 @@ +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const staticImportPattern = + /\b(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g + +function getStaticImports(source: string): Array { + return [...source.matchAll(staticImportPattern)].map((match) => match[1]!) +} + +describe('catalog bundle', () => { + it('does not statically import yaml or semver', () => { + const distDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../dist', + ) + const pending = [resolve(distDir, 'catalog.mjs')] + const visited = new Set() + + while (pending.length > 0) { + const modulePath = pending.pop()! + if (visited.has(modulePath)) continue + visited.add(modulePath) + + for (const specifier of getStaticImports( + readFileSync(modulePath, 'utf8'), + )) { + expect(specifier).not.toBe('yaml') + expect(specifier).not.toBe('semver') + + if (specifier.startsWith('.')) { + pending.push(resolve(dirname(modulePath), specifier)) + } + } + } + }) + + it('reads a complete lifecycle event without waiting for stdin to close', async () => { + const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + const child = spawn( + process.execPath, + [ + resolve(packageDir, 'dist/cli.mjs'), + 'hooks', + 'run', + '--agent', + 'copilot', + ], + { + cwd: packageDir, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + let stdinError: Error | undefined + child.stdin.on('error', (error) => { + stdinError = error + }) + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk + }) + child.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk + }) + child.stdin.write(JSON.stringify({ source: 'startup', cwd: packageDir })) + + const exitCode = await new Promise((resolveExit, reject) => { + const timeout = setTimeout(() => { + child.kill() + reject(new Error('catalog hook did not exit with stdin held open')) + }, 4_000) + child.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.once('exit', (code) => { + clearTimeout(timeout) + resolveExit(code) + }) + }) + + expect( + exitCode, + [stderr, stdinError?.message].filter(Boolean).join('\n'), + ).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + additionalContext: expect.any(String), + }) + }, 6_000) +}) diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 193cc64d..e213385a 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readdirSync, @@ -11,6 +12,11 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { runInteractiveInstall } from '../../src/commands/install/command.js' +import { runConsumerInstall } from '../../src/commands/install/consumer.js' +import { createIntentFsCache } from '../../src/discovery/fs-cache.js' +import { scanForIntents } from '../../src/discovery/scanner.js' +import type { InstallerPrompter } from '../../src/commands/install/consumer.js' /** * Regression guard for discussion #119: skill discovery in a real Yarn Berry @@ -106,6 +112,7 @@ function scaffoldBerryProject(): string { name: 'berry-corepack-repro', packageManager: `yarn@${YARN_VERSION}`, dependencies: { '@repro/skills-leaf': `file:./${tarball}` }, + intent: { skills: ['@repro/skills-leaf'] }, }) // CI makes Berry installs immutable by default; this fixture creates lockfile fresh. @@ -119,6 +126,20 @@ function scaffoldBerryProject(): string { return dir } +function createInstallPrompts(method: 'hooks' | 'symlink'): InstallerPrompter { + return { + complete: () => {}, + selectMethod: () => Promise.resolve(method), + selectMapTarget: () => Promise.resolve('AGENTS.md'), + selectTargets: () => + Promise.resolve(method === 'hooks' ? ['claude'] : ['agents']), + confirmSymlink: () => Promise.resolve(true), + confirmUserScopeHooks: () => Promise.resolve(true), + selectSkills: () => Promise.resolve({ mode: 'all-found' }), + confirmInstall: () => Promise.resolve('install'), + } +} + describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { it('discovers and loads skills from a zip-backed dependency', () => { const cwd = scaffoldBerryProject() @@ -149,4 +170,48 @@ describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { ) expect(load).toContain('# Core') }, 120_000) + + it('installs hooks and locks a zip-backed dependency', async () => { + const cwd = scaffoldBerryProject() + const homeDir = mkdtempSync(join(realTmpdir, 'intent-berry-home-')) + tempDirs.push(homeDir) + const fsCache = createIntentFsCache() + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(cwd, scanOptions) + + await runConsumerInstall({ + discovered: scan.packages, + homeDir, + packageManager: scan.packageManager, + prompts: createInstallPrompts('hooks'), + readFs: fsCache.getReadFs(), + root: cwd, + }) + + expect(existsSync(join(cwd, 'intent.lock'))).toBe(true) + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(false) + }, 120_000) + + it('rejects symlink delivery for a zip-backed dependency before writing', async () => { + const cwd = scaffoldBerryProject() + + const error = await runInteractiveInstall({ + cwd, + prompts: createInstallPrompts('symlink'), + }).then( + () => null, + (reason: unknown) => reason, + ) + const output = error instanceof Error ? error.message : String(error) + + expect(error).toBeInstanceOf(Error) + expect(output).toMatch(/archive-backed|PnP/i) + expect(output).toMatch(/cannot use symlink delivery.*use hooks/i) + expect(output).not.toContain('ENOTDIR') + expect(existsSync(join(cwd, 'intent.lock'))).toBe(false) + expect( + existsSync(join(cwd, '.agents', 'skills', 'npm-repro-skills-leaf-core')), + ).toBe(false) + }, 120_000) }) diff --git a/packages/intent/tests/integration/scaffold.ts b/packages/intent/tests/integration/scaffold.ts index e58232b2..4639446e 100644 --- a/packages/intent/tests/integration/scaffold.ts +++ b/packages/intent/tests/integration/scaffold.ts @@ -203,6 +203,7 @@ export function scaffoldProject(opts: { private: true, ...(opts.pnp ? { installConfig: { pnp: true } } : {}), dependencies: { [opts.dependency]: '1.0.0' }, + intent: { skills: ['@test-intent/skills-leaf'] }, }) install(root, opts.pm, opts.registryUrl, opts) return { root, cwd: root } @@ -214,6 +215,7 @@ export function scaffoldProject(opts: { private: true, ...(opts.pm !== 'pnpm' ? { workspaces: ['packages/*'] } : {}), ...(opts.pnp ? { installConfig: { pnp: true } } : {}), + intent: { skills: ['@test-intent/skills-leaf'] }, }) if (opts.pm === 'pnpm') { writeFileSync( diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 5bbede6f..202395bb 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -8,8 +8,11 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' import { main } from '../../src/cli.js' +import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' +import { buildCurrentLockfileSources } from '../../src/core/lockfile/lockfile-state.js' +import { parseSkillSources } from '../../src/core/skill-sources.js' +import { applySourcePolicy } from '../../src/core/source-policy.js' const realTmpdir = realpathSync(tmpdir()) @@ -43,19 +46,28 @@ const EXCLUDED = '@scope/excluded' describe('source policy — all four surfaces filter excluded and unlisted', () => { let root: string let originalCwd: string + let previousIntentAudience: string | undefined + let errorSpy: ReturnType let logSpy: ReturnType beforeEach(() => { originalCwd = process.cwd() + previousIntentAudience = process.env.INTENT_AUDIENCE + delete process.env.INTENT_AUDIENCE root = mkdtempSync(join(realTmpdir, 'intent-g4-')) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { process.chdir(originalCwd) vi.restoreAllMocks() delete process.env.INTENT_GLOBAL_NODE_MODULES + if (previousIntentAudience === undefined) { + delete process.env.INTENT_AUDIENCE + } else { + process.env.INTENT_AUDIENCE = previousIntentAudience + } rmSync(root, { recursive: true, force: true }) }) @@ -70,10 +82,10 @@ describe('source policy — all four surfaces filter excluded and unlisted', () writeIntentPackage(root, EXCLUDED, 'core') } - it('list surfaces only the listed package', () => { + it('list names the unlisted package for a human audience', () => { writeStandaloneFixture() - const result = listIntentSkills({ cwd: root }) + const result = listIntentSkills({ cwd: root, audience: 'human' }) expect(result.packages.map((pkg) => pkg.name)).toEqual([LISTED]) expect(result.notices.some((notice) => notice.includes(UNLISTED))).toBe( @@ -87,6 +99,153 @@ describe('source policy — all four surfaces filter excluded and unlisted', () ) }) + it('list withholds the unlisted package name from an agent audience', () => { + writeStandaloneFixture() + + const result = listIntentSkills({ cwd: root, audience: 'agent' }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual([LISTED]) + expect(result.notices.some((notice) => notice.includes(UNLISTED))).toBe( + false, + ) + expect( + result.notices.some((notice) => + notice.includes('not listed in intent.skills'), + ), + ).toBe(true) + }) + + it.each([ + ['one skill', '@scope/hidden-one', ['only'], '1 skill'], + ['multiple skills', '@scope/hidden-many', ['first', 'second'], '2 skills'], + ])( + 'list --show-hidden prints a fully unlisted package with %s', + async (_case, hiddenPackage, hiddenSkills, count) => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [LISTED] }, + }) + writeIntentPackage(root, LISTED, 'core') + for (const hiddenSkill of hiddenSkills) { + writeIntentPackage(root, hiddenPackage, hiddenSkill) + } + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain(` ${hiddenPackage} (${count})`) + }, + ) + + it('list --show-hidden names skills hidden by a skill-level entry', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [`${LISTED}#a`] }, + }) + writeIntentPackage(root, LISTED, 'a') + writeIntentPackage(root, LISTED, 'b') + writeIntentPackage(root, LISTED, 'c') + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain(` ${LISTED} (2 skills not listed: b, c)`) + }) + + it('list --show-hidden redacts hidden source details for an agent audience', async () => { + const hiddenPackage = '@scope/agent-secret-package' + const hiddenSkill = 'agent-secret-skill' + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [LISTED] }, + }) + writeIntentPackage(root, LISTED, 'core') + writeIntentPackage(root, hiddenPackage, hiddenSkill) + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = [ + ...logSpy.mock.calls.flat(), + ...errorSpy.mock.calls.flat(), + ].join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', + ) + expect(output).not.toContain(hiddenPackage) + expect(output).not.toContain(hiddenSkill) + }) + + it('documents the current empty source retained for an unmatched skill entry', () => { + const packageName = '@scope/empty-source' + const packageRoot = join(root, 'node_modules', '@scope', 'empty-source') + const skillPath = join(packageRoot, 'skills', 'b', 'SKILL.md') + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [`${packageName}#a`] }, + }) + writeIntentPackage(root, packageName, 'b') + + const listed = listIntentSkills({ cwd: root, audience: 'human' }) + const policy = applySourcePolicy( + { + packages: [ + { + name: packageName, + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: 'docs/' }, + skills: [ + { + name: 'b', + path: skillPath, + description: `${packageName} b`, + }, + ], + packageRoot, + kind: 'npm', + source: 'local', + }, + ], + }, + { + config: parseSkillSources([`${packageName}#a`]), + excludeMatchers: [], + }, + ) + const notices = [ + `1 skill from listed packages is not listed in intent.skills: ${packageName}#b. Add to opt in.`, + `"${packageName}#a" is declared in intent.skills but was not discovered.`, + ] + + expect(listed.packages).toMatchObject([ + { name: packageName, skillCount: 0 }, + ]) + expect(listed.notices).toEqual(notices) + expect(policy).toMatchObject({ + hiddenSourceCount: 1, + hiddenSources: [ + { name: packageName, skillCount: 1, hiddenSkills: ['b'] }, + ], + packages: [{ name: packageName, skills: [] }], + notices, + }) + expect(buildCurrentLockfileSources(policy.packages)).toEqual([ + { kind: 'npm', id: packageName, skills: [] }, + ]) + }) + it('list and load accept packages matched by an allowlist glob', () => { writeJson(join(root, 'package.json'), { name: 'app', diff --git a/packages/intent/tests/local-path.test.ts b/packages/intent/tests/local-path.test.ts new file mode 100644 index 00000000..6076b4af --- /dev/null +++ b/packages/intent/tests/local-path.test.ts @@ -0,0 +1,55 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { containsLocalPath } from '../src/shared/local-path.js' +import { isPathWithin } from '../src/shared/utils.js' + +describe('isPathWithin', () => { + const parent = join(tmpdir(), 'intent-path-parent') + const child = join(parent, 'child') + + it('recognizes the same path and child paths', () => { + expect(isPathWithin(parent, parent)).toBe(true) + expect(isPathWithin(parent, child)).toBe(true) + expect(isPathWithin(parent, join(parent, '..foo'))).toBe(true) + }) + + it('rejects parent and sibling paths', () => { + expect(isPathWithin(child, parent)).toBe(false) + expect(isPathWithin(parent, join(tmpdir(), 'intent-path-sibling'))).toBe( + false, + ) + }) + + it('is directional', () => { + expect(isPathWithin(parent, child)).toBe(true) + expect(isPathWithin(child, parent)).toBe(false) + }) +}) + +describe('containsLocalPath', () => { + it.each([ + 'C:\\Users\\person\\project\\SKILL.md', + '/Users/alice', + '/Users/person/project/SKILL.md', + '/home/alice', + '/home/person/project/package.json', + './packages/router/SKILL.md', + 'node_modules/@scope/package/skills/core/SKILL.md', + 'file:///workspace/project/SKILL.md', + ])('detects local path %s', (value) => { + expect(containsLocalPath(value)).toBe(true) + }) + + it.each([ + '/users/:id', + '/posts/:slug', + '/media/logo', + '/opt/pricing', + '/workspace/list', + 'Use the router/search API', + '@scope/package#skill', + ])('preserves non-filesystem value %s', (value) => { + expect(containsLocalPath(value)).toBe(false) + }) +}) diff --git a/packages/intent/tests/lockfile-diff.test.ts b/packages/intent/tests/lockfile-diff.test.ts new file mode 100644 index 00000000..018aeb56 --- /dev/null +++ b/packages/intent/tests/lockfile-diff.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { diffLockfileSources } from '../src/core/lockfile/lockfile-diff.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../src/core/lockfile/lockfile.js' + +const source = ( + skills: IntentLockfileSource['skills'], +): IntentLockfileSource => ({ kind: 'npm', id: 'example', skills }) + +describe('diffLockfileSources', () => { + it('distinguishes a missing lockfile', () => { + expect(diffLockfileSources([], { status: 'missing' })).toMatchObject({ + lockfile: 'missing', + isClean: false, + }) + }) + + it('diffs sources and individual skills independently regardless of ordering', () => { + const locked: ReadIntentLockfileResult = { + status: 'found' as const, + lockfile: { + lockfileVersion: 1 as const, + sources: [ + source([ + { path: 'skills/first', contentHash: 'one' }, + { path: 'skills/second', contentHash: 'two' }, + ]), + { kind: 'workspace', id: 'removed', skills: [] }, + ], + }, + } + const result = diffLockfileSources( + [ + source([ + { path: 'skills/third', contentHash: 'three' }, + { path: 'skills/second', contentHash: 'two' }, + { path: 'skills/first', contentHash: 'changed' }, + ]), + { kind: 'workspace', id: 'new', skills: [] }, + ], + locked, + ) + expect(result.addedSources).toEqual([ + { kind: 'workspace', id: 'new', skills: [] }, + ]) + expect(result.removedSources).toEqual([ + { kind: 'workspace', id: 'removed', skills: [] }, + ]) + expect(result.changedSources).toEqual([ + { + kind: 'npm', + id: 'example', + addedSkills: [{ path: 'skills/third', contentHash: 'three' }], + removedSkills: [], + changedSkills: [ + { + path: 'skills/first', + lockedContentHash: 'one', + currentContentHash: 'changed', + }, + ], + }, + ]) + }) + + it('reports a removed skill without treating it as changed', () => { + const locked: ReadIntentLockfileResult = { + status: 'found', + lockfile: { + lockfileVersion: 1, + sources: [ + source([ + { path: 'skills/first', contentHash: 'one' }, + { path: 'skills/removed', contentHash: 'old' }, + ]), + ], + }, + } + + expect( + diffLockfileSources( + [source([{ path: 'skills/first', contentHash: 'one' }])], + locked, + ).changedSources, + ).toEqual([ + { + kind: 'npm', + id: 'example', + addedSkills: [], + removedSkills: [{ path: 'skills/removed', contentHash: 'old' }], + changedSkills: [], + }, + ]) + }) +}) diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts new file mode 100644 index 00000000..bfc0e588 --- /dev/null +++ b/packages/intent/tests/lockfile-state.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import type { IntentPackage } from '../src/shared/types.js' + +const roots: Array = [] + +function packageFixture(kind: 'npm' | 'workspace' = 'npm'): { + pkg: IntentPackage + first: string + second: string +} { + const root = mkdtempSync(join(tmpdir(), 'intent-lock-state-')) + const first = join(root, 'skills', 'first', 'SKILL.md') + const second = join(root, 'skills', 'second', 'SKILL.md') + mkdirSync(join(root, 'skills', 'first'), { recursive: true }) + mkdirSync(join(root, 'skills', 'second'), { recursive: true }) + writeFileSync(first, 'First') + writeFileSync(second, 'Second') + roots.push(root) + return { + pkg: { + name: 'example', + version: '1.0.0', + kind, + source: 'local', + packageRoot: root, + intent: { version: 1, repo: '', docs: '' }, + skills: [ + { name: 'second', path: second, description: '' }, + { name: 'first', path: 'skills/first/SKILL.md', description: '' }, + ], + }, + first, + second, + } +} + +afterEach(() => { + roots + .splice(0) + .forEach((path) => rmSync(path, { recursive: true, force: true })) +}) + +describe('buildCurrentLockfileSources', () => { + it('builds independent hashes with package-relative skill directories', () => { + const { pkg, first } = packageFixture() + const initial = buildCurrentLockfileSources([pkg]) + writeFileSync(first, 'Changed') + const updated = buildCurrentLockfileSources([pkg]) + expect(initial[0]!.skills.map((skill) => skill.path)).toEqual([ + 'skills/first', + 'skills/second', + ]) + expect(updated[0]!.skills[0]!.contentHash).not.toBe( + initial[0]!.skills[0]!.contentHash, + ) + expect(updated[0]!.skills[1]!.contentHash).toBe( + initial[0]!.skills[1]!.contentHash, + ) + }) + + it('keeps npm and workspace sources with the same id distinct', () => { + const npm = packageFixture('npm').pkg + const workspace = { + ...packageFixture('workspace').pkg, + packageRoot: npm.packageRoot, + skills: npm.skills, + } + expect( + buildCurrentLockfileSources([workspace, npm]).map( + (source) => source.kind, + ), + ).toEqual(['npm', 'workspace']) + }) + + it('resolves npm and workspace paths rewritten for loading', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'intent-lock-project-')) + roots.push(projectRoot) + const npmRoot = join(projectRoot, 'node_modules', '@scope', 'package') + const workspaceRoot = join(projectRoot, 'packages', 'workspace') + const npmSkill = join(npmRoot, 'skills', 'npm-skill', 'SKILL.md') + const workspaceSkill = join( + workspaceRoot, + 'skills', + 'workspace-skill', + 'SKILL.md', + ) + mkdirSync(join(npmRoot, 'skills', 'npm-skill'), { recursive: true }) + mkdirSync(join(workspaceRoot, 'skills', 'workspace-skill'), { + recursive: true, + }) + writeFileSync(npmSkill, 'Npm') + writeFileSync(workspaceSkill, 'Workspace') + + const npm = packageFixture().pkg + npm.name = '@scope/package' + npm.packageRoot = npmRoot + npm.skills = [ + { + name: 'npm-skill', + path: 'node_modules/@scope/package/skills/npm-skill/SKILL.md', + description: '', + }, + ] + const workspace = packageFixture('workspace').pkg + workspace.name = 'workspace-package' + workspace.packageRoot = workspaceRoot + workspace.skills = [ + { + name: 'workspace-skill', + path: 'packages/workspace/skills/workspace-skill/SKILL.md', + description: '', + }, + ] + + expect(buildCurrentLockfileSources([workspace, npm])).toMatchObject([ + { kind: 'npm', skills: [{ path: 'skills/npm-skill' }] }, + { + kind: 'workspace', + skills: [{ path: 'skills/workspace-skill' }], + }, + ]) + }) +}) diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts new file mode 100644 index 00000000..5d371884 --- /dev/null +++ b/packages/intent/tests/lockfile.test.ts @@ -0,0 +1,153 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + parseIntentLockfile, + readIntentLockfile, + serializeIntentLockfile, + writeIntentLockfile, +} from '../src/core/lockfile/lockfile.js' + +const roots: Array = [] + +function root(): string { + const path = mkdtempSync(join(tmpdir(), 'intent-lockfile-')) + roots.push(path) + return path +} + +afterEach(() => { + roots + .splice(0) + .forEach((path) => rmSync(path, { recursive: true, force: true })) +}) + +describe('intent lockfile', () => { + it('serializes sources and skills in ordinal order', () => { + const serialized = serializeIntentLockfile({ + lockfileVersion: 1, + sources: [ + { + kind: 'workspace', + id: 'z', + skills: [{ path: 'skills/z', contentHash: 'sha256-z' }], + }, + { + kind: 'npm', + id: 'a', + skills: [ + { path: 'skills/z', contentHash: 'sha256-z' }, + { path: 'skills/a', contentHash: 'sha256-a' }, + ], + }, + ], + }) + + expect(serialized).toBe( + `${JSON.stringify( + { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: 'a', + skills: [ + { path: 'skills/a', contentHash: 'sha256-a' }, + { path: 'skills/z', contentHash: 'sha256-z' }, + ], + }, + { + kind: 'workspace', + id: 'z', + skills: [{ path: 'skills/z', contentHash: 'sha256-z' }], + }, + ], + }, + null, + 2, + )}\n`, + ) + }) + + it('rejects undeclared fields, invalid source identity, and duplicate paths', () => { + expect(() => + parseIntentLockfile('{"lockfileVersion":1,"sources":[],"extra":true}'), + ).toThrow() + expect(() => + parseIntentLockfile('{"lockfileVersion":2,"sources":[]}'), + ).toThrow() + expect(() => + parseIntentLockfile('{"lockfileVersion":1,"sources":"bad"}'), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"git","id":"a","skills":[]}]}', + ), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"npm","id":"a","skills":[]},{"kind":"npm","id":"a","skills":[]}]}', + ), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"npm","id":"a","skills":[{"path":"skills/a","contentHash":"x"},{"path":"skills/a","contentHash":"y"}]}]}', + ), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"npm","id":"a","skills":[{"path":"../skills/a","contentHash":"x"}]}]}', + ), + ).toThrow() + }) + + it('names an upgrade path for lockfiles a newer Intent wrote', () => { + expect(() => + parseIntentLockfile('{"lockfileVersion":2,"sources":[]}'), + ).toThrow(/lockfileVersion 2.*Upgrade @tanstack\/intent/s) + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"git","id":"a","skills":[]}]}', + ), + ).toThrow(/contains a "git" source.*Upgrade @tanstack\/intent/s) + }) + + it('reads missing locks and atomically writes canonical content', () => { + const path = join(root(), 'nested', 'intent.lock') + expect(readIntentLockfile(path)).toEqual({ status: 'missing' }) + writeIntentLockfile(path, { lockfileVersion: 1, sources: [] }) + writeIntentLockfile(path, { + lockfileVersion: 1, + sources: [{ kind: 'npm', id: 'example', skills: [] }], + }) + expect(readIntentLockfile(path)).toEqual({ + status: 'found', + lockfile: { + lockfileVersion: 1, + sources: [{ kind: 'npm', id: 'example', skills: [] }], + }, + }) + expect(readFileSync(path, 'utf8')).toContain('"id": "example"') + }) + + it.each([ + { label: '0o600', mode: 0o600 }, + { label: '0o664', mode: 0o664 }, + ])('preserves mode $label of an existing lockfile', ({ mode }) => { + const path = join(root(), 'intent.lock') + writeFileSync(path, '{"lockfileVersion":1,"sources":[]}\n') + chmodSync(path, mode) + + writeIntentLockfile(path, { lockfileVersion: 1, sources: [] }) + + expect(statSync(path).mode & 0o777).toBe(mode) + }) +}) diff --git a/packages/intent/tests/released-config-compat.test.ts b/packages/intent/tests/released-config-compat.test.ts new file mode 100644 index 00000000..17256791 --- /dev/null +++ b/packages/intent/tests/released-config-compat.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { compileExcludePatterns } from '../src/core/excludes.js' +import { + applySourcePolicy, + checkLoadAllowed, + compileSkillSourcePolicy, +} from '../src/core/source-policy.js' +import { parseSkillSources } from '../src/core/skill-sources.js' +import type { IntentPackage, SkillEntry } from '../src/shared/types.js' + +function skill(name: string): SkillEntry { + return { name, path: `/pkg/skills/${name}/SKILL.md`, description: name } +} + +function pkg( + name: string, + skillNames: Array, + kind: IntentPackage['kind'] = 'npm', +): IntentPackage { + return { + name, + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: '' }, + skills: skillNames.map(skill), + packageRoot: `/root/node_modules/${name}`, + kind, + source: 'local', + } +} + +function config(value: unknown) { + return parseSkillSources(value) +} + +describe('released 0.3.6 package.json config shapes remain policy-compatible', () => { + it('keeps a realistic released config combining names, workspace entries, globs, and excludes', () => { + const releasedIntent = { + skills: ['plain', '@scope/*', 'workspace:local'], + exclude: ['plain#b', '@scope/blocked', '@scope/tools#dangerous'], + } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { + packages: [ + pkg('plain', ['a', 'b']), + pkg('@scope/alpha', ['x']), + pkg('@scope/tools', ['safe', 'dangerous']), + pkg('@scope/blocked', ['hidden']), + pkg('local', ['workspace-skill'], 'workspace'), + pkg('local', ['npm-skill']), + pkg('unlisted', ['z']), + ], + }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.kind, + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([ + ['npm', 'plain', ['a']], + ['npm', '@scope/alpha', ['x']], + ['npm', '@scope/tools', ['safe']], + ['workspace', 'local', ['workspace-skill']], + ]) + expect(result.notices).toEqual([ + '2 discovered packages ship skills but are not listed in intent.skills: local, unlisted. Add to opt in.', + ]) + expect( + checkLoadAllowed( + 'plain#a', + { packageName: 'plain', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + 'plain#b', + { packageName: 'plain', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + expect( + checkLoadAllowed( + '@scope/blocked#hidden', + { packageName: '@scope/blocked', skillName: 'hidden' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-excluded') + expect( + checkLoadAllowed( + '@scope/tools#dangerous', + { packageName: '@scope/tools', skillName: 'dangerous' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + expect( + checkLoadAllowed( + 'unlisted#z', + { packageName: 'unlisted', skillName: 'z' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-not-listed') + }) +}) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 1ffb28e6..4388366f 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -88,6 +88,7 @@ describe('resolveSkillUse', () => { expect(resolveSkillUse('@tanstack/query#core', scanResult([pkg]))).toEqual({ conflict: null, + kind: 'npm', packageName: '@tanstack/query', packageRoot: 'node_modules/@tanstack/query', path: 'node_modules/@tanstack/query/skills/core/SKILL.md', diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 970016c3..f8e5ce73 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1999,3 +1999,77 @@ describe('package manager detection', () => { expect(() => scanForIntents(root)).toThrow('Deno without node_modules') }) }) + +describe('discovered sources (characterization)', () => { + function writeSkillPackage(dir: string, name: string, skill: string): void { + createDir(dir, 'skills', skill) + writeJson(join(dir, 'package.json'), { + name, + version: '1.0.0', + intent: { version: 1, repo: `example/${skill}`, docs: 'docs/' }, + }) + writeSkillMd(join(dir, 'skills', skill), { + name: skill, + description: `${skill} guidance`, + }) + } + + function discovered(): Array { + return scanForIntents(root) + .packages.map((pkg) => `${pkg.kind}:${pkg.name}`) + .sort() + } + + it('finds direct and transitive dependencies in a hoisted layout', () => { + writeFileSync(join(root, 'package-lock.json'), '{}') + writeJson(join(root, 'package.json'), { + name: 'consumer', + private: true, + dependencies: { '@scope/direct': '1.0.0' }, + }) + + const direct = createDir(root, 'node_modules', '@scope', 'direct') + writeSkillPackage(direct, '@scope/direct', 'direct-skill') + writeJson(join(direct, 'package.json'), { + name: '@scope/direct', + version: '1.0.0', + intent: { version: 1, repo: 'example/direct', docs: 'docs/' }, + dependencies: { transitive: '1.0.0' }, + }) + + writeSkillPackage( + createDir(root, 'node_modules', 'transitive'), + 'transitive', + 'transitive-skill', + ) + + expect(discovered()).toEqual(['npm:@scope/direct', 'npm:transitive']) + }) + + it('does not surface the workspace root as a skill source', () => { + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeSkillPackage(root, 'my-repo', 'root-skill') + createDir(root, 'node_modules') + + expect(discovered()).toEqual([]) + }) + + it('finds a workspace package reached through its node_modules link', () => { + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { name: 'my-repo', private: true }) + + const ui = createDir(root, 'packages', 'ui') + writeSkillPackage(ui, '@my/ui', 'ui-skill') + + createDir(root, 'node_modules', '@my') + symlinkSync(ui, join(root, 'node_modules', '@my', 'ui'), 'dir') + + expect(discovered()).toEqual(['workspace:@my/ui']) + }) +}) diff --git a/packages/intent/tests/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts new file mode 100644 index 00000000..134072bc --- /dev/null +++ b/packages/intent/tests/session-catalog.test.ts @@ -0,0 +1,670 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, win32 } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildSessionCatalogue, + formatSessionCatalogue, + getSessionCatalogue, + policyManifestPaths, +} from '../src/session-catalog.js' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { nodeReadFs } from '../src/shared/utils.js' +import type { IntentSkillList } from '../src/core/index.js' + +const roots: Array = [] + +function tempRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), name)) + roots.push(root) + return root +} + +function result( + skills: Array<{ use: string; description: string }>, + warnings: Array = [], + notices: Array = [], +): IntentSkillList { + return { + packageManager: 'pnpm', + skills: skills.map((skill) => ({ + ...skill, + packageName: skill.use.split('#')[0]!, + packageRoot: '/workspace/node_modules/package', + packageVersion: '1.0.0', + packageSource: 'local', + skillName: skill.use.split('#')[1]!, + })), + packages: [ + { + name: '@fixture/package', + version: '1.0.0', + source: 'local', + packageRoot: '/workspace/node_modules/package', + skillCount: skills.length, + }, + ], + hiddenSourceCount: 0, + hiddenSources: [], + warnings, + notices, + conflicts: [], + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('session catalogue formatting', () => { + it('sorts, bounds, and redacts agent context', () => { + const catalogue = buildSessionCatalogue( + result( + [ + { use: '@fixture/package#z', description: 'Z guidance' }, + { + use: '@fixture/package#a', + description: 'Read C:\\Users\\person\\secret.txt', + }, + ], + ['Warning from /Users/person/project/package.json'], + ), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue.skills.map((skill) => skill.id)).toEqual([ + '@fixture/package#a', + '@fixture/package#z', + ]) + expect(context).toContain('@fixture/package#a: Use @fixture/package#a') + expect(catalogue.skills[0]?.description).toBe('Use @fixture/package#a') + expect(context).not.toContain('person') + expect(Buffer.byteLength(context)).toBeLessThanOrEqual(8_000) + }) + + it('reports omitted skills within a UTF-8 byte budget', () => { + const catalogue = buildSessionCatalogue( + result( + Array.from({ length: 60 }, (_, index) => ({ + use: `@fixture/package#skill-${String(index).padStart(2, '0')}`, + description: `Guidance ${'界'.repeat(100)}`, + })), + ), + ) + const context = formatSessionCatalogue(catalogue, { maxBytes: 1_200 }) + + expect(catalogue.skills).toHaveLength(50) + expect(catalogue.totalSkillCount).toBe(60) + expect(Buffer.byteLength(context)).toBeLessThanOrEqual(1_200) + expect(context).toMatch(/additional skills omitted/) + }) + + it('preserves application route paths in descriptions', () => { + const catalogue = buildSessionCatalogue( + result([ + { + use: '@fixture/package#routes', + description: 'Use /users/:id and /posts/:slug routes', + }, + ]), + ) + + expect(formatSessionCatalogue(catalogue)).toContain( + 'Use /users/:id and /posts/:slug routes', + ) + }) + + it('excludes warnings and human-facing notices', () => { + const catalogue = buildSessionCatalogue( + result([], ['Agent warning'], ['Maintainer notice']), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Agent warning') + expect(context).not.toContain('Maintainer notice') + }) + + it('excludes warnings regardless of warning count', () => { + const catalogue = buildSessionCatalogue( + result( + [], + Array.from({ length: 12 }, (_, index) => `Warning ${index + 1}`), + ['Maintainer notice'], + ), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Warning 1') + expect(context).not.toContain('additional warnings omitted') + expect(context).not.toContain('Maintainer notice') + }) + + it('omits diagnostics when only notices are present', () => { + const catalogue = buildSessionCatalogue( + result([], [], ['Maintainer notice']), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Warnings:') + expect(context).not.toContain('Maintainer notice') + }) +}) + +describe('session catalogue cache', () => { + it('does not persist a discovery without lock verification', async () => { + const root = tempRoot('intent-catalog-no-lock-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: null } + }, + } + + const first = await getSessionCatalogue(options) + const second = await getSessionCatalogue(options) + + expect(first.cacheStatus).toBe('miss') + expect(second.cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + expect(existsSync(first.cachePath)).toBe(false) + }) + + it('fails open when the default temporary directory cannot be resolved', async () => { + const root = tempRoot('intent-catalog-missing-temp-') + const missingTempRoot = join(root, 'missing') + writeFileSync(join(root, 'package.json'), '{}') + const originalTmpdir = process.env.TMPDIR + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + + try { + process.env.TMPDIR = missingTempRoot + vi.resetModules() + const catalog = await import('../src/session-catalog.js') + const discovered = await catalog.getSessionCatalogue({ + root, + discover: () => ({ + result: result([ + { + use: '@fixture/package#core', + description: 'Safe guidance', + }, + ]), + verification: [], + }), + }) + + expect(discovered.cacheStatus).toBe('miss') + expect(catalog.formatSessionCatalogue(discovered.catalogue)).toContain( + 'Safe guidance', + ) + expect(existsSync(discovered.cachePath)).toBe(false) + expect( + stderr.mock.calls.filter(([chunk]) => + String(chunk).includes('caching is disabled'), + ), + ).toHaveLength(1) + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = originalTmpdir + vi.resetModules() + stderr.mockRestore() + } + }) + + it('uses a private immediate child of the current temporary directory by default', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-default-root-') + const importTempRoot = tempRoot('intent-catalog-import-temp-') + const defaultTempRoot = tempRoot('intent-catalog-default-temp-') + const realDefaultTempRoot = realpathSync.native(defaultTempRoot) + writeFileSync(join(root, 'package.json'), '{}') + const originalTmpdir = process.env.TMPDIR + let discoveries = 0 + + try { + process.env.TMPDIR = importTempRoot + vi.resetModules() + const { getSessionCatalogue: getFreshSessionCatalogue } = + await import('../src/session-catalog.js') + process.env.TMPDIR = defaultTempRoot + const options = { + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + + const first = await getFreshSessionCatalogue(options) + const cacheDir = dirname(first.cachePath) + + expect(dirname(cacheDir)).toBe(realDefaultTempRoot) + expect(basename(cacheDir)).toMatch(/^tanstack-intent-.*-catalogues$/) + expect(statSync(cacheDir).mode & 0o777).toBe(0o700) + expect(statSync(first.cachePath).mode & 0o777).toBe(0o600) + + chmodSync(cacheDir, 0o755) + const second = await getFreshSessionCatalogue(options) + + expect(second.cacheStatus).toBe('hit') + expect(second.cachePath).toBe(first.cachePath) + expect(statSync(cacheDir).mode & 0o777).toBe(0o700) + expect(discoveries).toBe(1) + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = originalTmpdir + vi.resetModules() + } + }) + + it('bypasses a supplied cache directory symlink', async ({ skip }) => { + const root = tempRoot('intent-catalog-directory-symlink-') + const targetDir = join(root, 'target') + const cacheDir = join(root, 'cache') + mkdirSync(targetDir) + writeFileSync(join(root, 'package.json'), '{}') + try { + symlinkSync(targetDir, cacheDir, 'dir') + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + ['EACCES', 'ENOTSUP', 'EPERM'].includes(String(error.code)) + ) { + skip() + return + } + throw error + } + + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + + try { + const first = await getSessionCatalogue(options) + const second = await getSessionCatalogue(options) + const cacheWarnings = stderr.mock.calls.filter(([chunk]) => + String(chunk).includes('caching is disabled'), + ) + + expect(first.cacheStatus).toBe('miss') + expect(second.cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + expect(existsSync(first.cachePath)).toBe(false) + expect(cacheWarnings).toHaveLength(1) + expect(String(cacheWarnings[0]?.[0])).toContain(cacheDir) + } finally { + stderr.mockRestore() + } + }) + + it('does not serve a cache file symlink', async ({ skip }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-file-symlink-') + const cacheDir = join(root, 'cache') + const externalCachePath = join(root, 'external-cache.json') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { + result: result([ + { + use: '@fixture/package#core', + description: 'Safe guidance', + }, + ]), + verification: [], + } + }, + } + const first = await getSessionCatalogue(options) + const persisted = JSON.parse(readFileSync(first.cachePath, 'utf8')) as { + catalogue: { skills: Array<{ description: string }> } + } + persisted.catalogue.skills[0]!.description = 'Modified guidance' + writeFileSync(externalCachePath, JSON.stringify(persisted)) + unlinkSync(first.cachePath) + try { + symlinkSync(externalCachePath, first.cachePath) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + ['EACCES', 'ENOTSUP', 'EPERM'].includes(String(error.code)) + ) { + skip() + return + } + throw error + } + + const second = await getSessionCatalogue(options) + + expect(second.cacheStatus).toBe('miss') + expect(second.catalogue.skills[0]?.description).toBe('Safe guidance') + expect(discoveries).toBe(2) + expect(lstatSync(first.cachePath).isSymbolicLink()).toBe(false) + expect(lstatSync(first.cachePath).isFile()).toBe(true) + }) + + it('replaces a writable cache file instead of serving it', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-writable-file-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { + result: result([ + { + use: '@fixture/package#core', + description: 'Safe guidance', + }, + ]), + verification: [], + } + }, + } + const first = await getSessionCatalogue(options) + const persisted = JSON.parse(readFileSync(first.cachePath, 'utf8')) as { + catalogue: { skills: Array<{ description: string }> } + } + persisted.catalogue.skills[0]!.description = 'Modified guidance' + writeFileSync(first.cachePath, JSON.stringify(persisted)) + chmodSync(first.cachePath, 0o666) + + const second = await getSessionCatalogue(options) + + expect(second.cacheStatus).toBe('miss') + expect(second.catalogue.skills[0]?.description).toBe('Safe guidance') + expect(second.cachePath).toBe(first.cachePath) + expect(discoveries).toBe(2) + expect(lstatSync(second.cachePath).isFile()).toBe(true) + expect(statSync(second.cachePath).mode & 0o777).toBe(0o600) + }) + + it('creates custom cache directories and files with private modes', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-private-modes-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + + const catalogue = await getSessionCatalogue({ + cacheDir, + root, + discover: () => ({ result: result([]), verification: [] }), + }) + + expect(statSync(cacheDir).mode & 0o777).toBe(0o700) + expect(statSync(catalogue.cachePath).mode & 0o777).toBe(0o600) + }) + + it('bypasses an existing writable custom cache directory without changing its mode', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-permissive-directory-') + const cacheDir = join(root, 'cache') + mkdirSync(cacheDir) + chmodSync(cacheDir, 0o777) + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + + try { + const first = await getSessionCatalogue(options) + const second = await getSessionCatalogue(options) + + expect(first.cacheStatus).toBe('miss') + expect(second.cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + expect(existsSync(first.cachePath)).toBe(false) + expect(statSync(cacheDir).mode & 0o777).toBe(0o777) + } finally { + stderr.mockRestore() + } + }) + + it('recomputes a persisted catalogue from an older schema version', async () => { + const root = tempRoot('intent-catalog-stale-schema-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { + result: result([ + { + use: '@fixture/package#core', + description: + discoveries === 1 ? 'Cached guidance' : 'Recomputed guidance', + }, + ]), + verification: [], + } + }, + } + const first = await getSessionCatalogue(options) + const persisted = JSON.parse(readFileSync(first.cachePath, 'utf8')) as { + schemaVersion: number + } + writeFileSync( + first.cachePath, + JSON.stringify({ + ...persisted, + schemaVersion: persisted.schemaVersion - 1, + }), + ) + + const recomputed = await getSessionCatalogue(options) + + expect(recomputed.cacheStatus).toBe('miss') + expect(recomputed.catalogue.skills).toEqual([ + { + id: '@fixture/package#core', + description: 'Recomputed guidance', + }, + ]) + expect(discoveries).toBe(2) + }) + + it('reuses valid content and refreshes after accepted skill drift', async () => { + const root = tempRoot('intent-catalog-cache-') + const cacheDir = join(root, 'cache') + const skillDir = join(root, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(root, 'package.json'), '{}') + writeFileSync(join(skillDir, 'SKILL.md'), 'First\n') + let discoveries = 0 + let fileOpens = 0 + const readFs = { + ...nodeReadFs, + openSync: ( + ...args: Parameters> + ) => { + fileOpens += 1 + return nodeReadFs.openSync!(...args) + }, + } + + const get = () => + getSessionCatalogue({ + cacheDir, + root, + readFs, + discover: () => { + discoveries += 1 + return { + result: result([ + { use: '@fixture/package#core', description: 'Core guidance' }, + ]), + verification: [ + { + packageRoot: root, + skillPath: 'skills/core', + contentHash: computeSkillContentHash({ + packageRoot: root, + skillDir, + }), + }, + ], + } + }, + }) + + expect((await get()).cacheStatus).toBe('miss') + const opensAfterMiss = fileOpens + expect((await get()).cacheStatus).toBe('hit') + expect(fileOpens).toBeGreaterThan(opensAfterMiss) + writeFileSync(join(skillDir, 'SKILL.md'), 'Changed\n') + expect((await get()).cacheStatus).toBe('refresh') + expect(discoveries).toBe(2) + }) + + it('treats a malformed cache entry as a miss', async () => { + const root = tempRoot('intent-catalog-malformed-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + const first = await getSessionCatalogue(options) + writeFileSync(first.cachePath, '{partial') + + expect((await getSessionCatalogue(options)).cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + }) +}) + +describe('session catalogue policy manifests', () => { + it('uses only the policy manifest when Windows roots are on different drives', () => { + expect(policyManifestPaths('C:\\workspace', 'D:\\policy', win32)).toEqual([ + 'D:\\policy\\package.json', + ]) + }) + + it('includes nested policy ancestors on the same Windows drive', () => { + expect( + policyManifestPaths( + 'C:\\workspace', + 'C:\\workspace\\packages\\app', + win32, + ), + ).toEqual([ + 'C:\\workspace\\packages\\app\\package.json', + 'C:\\workspace\\packages\\package.json', + 'C:\\workspace\\package.json', + ]) + }) + + it('recognizes equivalent Windows roots with different casing and trailing separators', () => { + expect( + policyManifestPaths( + 'C:\\WORKSPACE\\', + 'c:\\workspace\\packages\\app\\', + win32, + ), + ).toEqual([ + 'c:\\workspace\\packages\\app\\package.json', + 'c:\\workspace\\packages\\package.json', + 'C:\\WORKSPACE\\package.json', + ]) + }) + + it('includes an in-workspace child whose name starts with two dots', () => { + expect( + policyManifestPaths('C:\\workspace', 'C:\\workspace\\..cfg', win32), + ).toEqual([ + 'C:\\workspace\\..cfg\\package.json', + 'C:\\workspace\\package.json', + ]) + }) +}) diff --git a/packages/intent/tests/skill-path.test.ts b/packages/intent/tests/skill-path.test.ts new file mode 100644 index 00000000..ca971199 --- /dev/null +++ b/packages/intent/tests/skill-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { validateSkillPaths } from '../src/core/skill-path.js' + +describe('validateSkillPaths', () => { + it('accepts canonical package-relative directories and rejects unsafe forms', () => { + expect( + validateSkillPaths(['skills/fetching', 'skills/query-core']), + ).toEqual(['skills/fetching', 'skills/query-core']) + expect(() => validateSkillPaths(['../skills/fetching'])).toThrow() + expect(() => validateSkillPaths(['skills\\fetching'])).toThrow() + expect(() => validateSkillPaths(['C:/skills/fetching'])).toThrow() + expect(() => + validateSkillPaths(['//server/share/skills/fetching']), + ).toThrow() + expect(() => validateSkillPaths(['/skills/fetching'])).toThrow() + expect(() => validateSkillPaths(['skills//fetching'])).toThrow() + expect(() => validateSkillPaths(['skills/./fetching'])).toThrow() + expect(() => validateSkillPaths(['skills/\0fetching'])).toThrow() + expect(() => + validateSkillPaths(['skills/fetching', 'skills/fetching']), + ).toThrow() + }) +}) diff --git a/packages/intent/tests/skill-sources.test.ts b/packages/intent/tests/skill-sources.test.ts index d80ec2cf..3d643ecd 100644 --- a/packages/intent/tests/skill-sources.test.ts +++ b/packages/intent/tests/skill-sources.test.ts @@ -16,15 +16,15 @@ function expectParseError(value: unknown): SkillSourcesParseError { } describe('parseSkillSources — list-level modes', () => { - it('treats an absent key (undefined) as the migration show-all mode', () => { - expect(parseSkillSources(undefined)).toEqual({ mode: 'absent' }) + it('treats an undefined value as deny-all', () => { + expect(parseSkillSources(undefined)).toEqual({ mode: 'empty' }) }) - it('treats null as absent', () => { - expect(parseSkillSources(null)).toEqual({ mode: 'absent' }) + it('treats null as deny-all', () => { + expect(parseSkillSources(null)).toEqual({ mode: 'empty' }) }) - it('treats an empty array as deny-all (distinct from absent)', () => { + it('treats an empty array as deny-all', () => { expect(parseSkillSources([])).toEqual({ mode: 'empty' }) }) @@ -255,12 +255,133 @@ describe('parseSkillSources — wildcard composition', () => { }) }) -describe('parseSkillSources — id validation', () => { - it('rejects skill-level granularity (#) in an npm entry', () => { - const error = expectParseError(['@scope/pkg#skill']) - expect(error.issues[0]?.message).toContain('skill-level granularity') +describe('parseSkillSources — skill-level entries', () => { + it('parses a skill selector on an npm source', () => { + expect(parseSkillSources(['@scope/pkg#fetching'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@scope/pkg#fetching', + id: '@scope/pkg', + kind: 'npm', + skill: 'fetching', + }, + ], + }) + }) + + it('parses a skill selector on a workspace source', () => { + expect(parseSkillSources(['workspace:@scope/pkg#core'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: 'workspace:@scope/pkg#core', + id: '@scope/pkg', + kind: 'workspace', + skill: 'core', + }, + ], + }) + }) + + it('parses a skill selector alongside a package glob', () => { + expect(parseSkillSources(['@scope/*#core'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@scope/*#core', + pattern: '@scope/*', + kind: 'npm', + skill: 'core', + }, + ], + }) + }) + + it('parses a glob in a skill selector', () => { + expect(parseSkillSources(['@scope/pkg#fetch-*'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@scope/pkg#fetch-*', + id: '@scope/pkg', + kind: 'npm', + skill: 'fetch-*', + }, + ], + }) + }) + + it('collapses an all-skills selector to a package-level entry', () => { + expect(parseSkillSources(['@scope/pkg#*', 'workspace:other#**'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: '@scope/pkg#*', id: '@scope/pkg', kind: 'npm' }, + { raw: 'workspace:other#**', id: 'other', kind: 'workspace' }, + ], + }) + }) + + it('keeps two skill entries for the same package distinct', () => { + expect(parseSkillSources(['pkg#a', 'pkg#b'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: 'pkg#a', id: 'pkg', kind: 'npm', skill: 'a' }, + { raw: 'pkg#b', id: 'pkg', kind: 'npm', skill: 'b' }, + ], + }) + }) + + it('dedups the same package and skill selector', () => { + expect(parseSkillSources(['pkg#a', ' pkg#a '])).toEqual({ + mode: 'explicit', + sources: [{ raw: 'pkg#a', id: 'pkg', kind: 'npm', skill: 'a' }], + }) + }) + + it('rejects a skill entry whose package is already allowed in full', () => { + const error = expectParseError(['@scope/*', '@scope/a#x']) + expect(error.issues[0]?.raw).toBe('@scope/a#x') + expect(error.issues[0]?.message).toContain('already allows every skill') + }) + + it('rejects a skill entry beside an exact package entry', () => { + const error = expectParseError(['pkg', 'pkg#a']) + expect(error.issues[0]?.message).toContain('already allows every skill') }) + it('keeps a skill entry beside a package entry of a different kind', () => { + expect(parseSkillSources(['workspace:pkg', 'pkg#a'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: 'workspace:pkg', id: 'pkg', kind: 'workspace' }, + { raw: 'pkg#a', id: 'pkg', kind: 'npm', skill: 'a' }, + ], + }) + }) + + it('rejects a missing skill name after "#"', () => { + const error = expectParseError(['@scope/pkg#']) + expect(error.issues[0]?.message).toContain('missing a skill name') + }) + + it('rejects a missing package name before "#"', () => { + const error = expectParseError(['#skill']) + expect(error.issues[0]?.message).toContain('missing a package name') + }) + + it('rejects more than one "#"', () => { + const error = expectParseError(['pkg#a#b']) + expect(error.issues[0]?.message).toContain('more than one "#"') + }) + + it('rejects whitespace inside a skill name', () => { + const error = expectParseError(['pkg#a b']) + expect(error.issues[0]?.message).toContain('cannot contain whitespace') + }) +}) + +describe('parseSkillSources — id validation', () => { it('rejects internal whitespace in a package name', () => { const error = expectParseError(['a b']) expect(error.issues[0]?.message).toContain('cannot contain whitespace') diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 1f6e3554..13eba9ca 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -11,12 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { compileExcludePatterns, compileWildcardPattern, + findPackageExcludeMatch, + findSkillExcludeMatch, } from '../src/core/excludes.js' import { ALLOW_ALL_NOTICE, EMPTY_NOTE, - MIGRATION_NOTICE, applySourcePolicy, + checkLoadAllowed, + compileSkillSourcePolicy, readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' @@ -234,6 +237,227 @@ describe('applySourcePolicy — allowlist matrix', () => { ) expect(input.skills.map((s) => s.name)).toEqual(['keep', 'drop']) }) + + it('records only exclusions permitted by intent.skills', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/allowed', ['keep', 'drop']), + pkg('@scope/concealed', ['secret']), + ], + }, + { + config: config(['@scope/allowed']), + excludeMatchers: compileExcludePatterns([ + '@scope/allowed#drop', + '@scope/concealed#secret', + ]), + }, + ) + + expect( + result.excludedSkills.map( + ({ package: package_, skill: entry, pattern }) => + `${package_.name}#${entry.name}:${pattern}`, + ), + ).toEqual(['@scope/allowed#drop:@scope/allowed#drop']) + }) +}) + +describe('compiled policy explanations', () => { + it('returns the package-level entry that permits a skill', () => { + const policy = compileSkillSourcePolicy(config(['@scope/a'])) + + expect(policy.explainPermitsSkill('@scope/a', 'x')).toMatchObject({ + permitted: true, + source: { raw: '@scope/a' }, + }) + }) + + it('returns the skill-level entry that permits a skill', () => { + const policy = compileSkillSourcePolicy(config(['@scope/a#x'])) + + expect(policy.explainPermitsSkill('@scope/a', 'x')).toMatchObject({ + permitted: true, + source: { raw: '@scope/a#x', skill: 'x' }, + }) + expect(policy.explainPermitsSkill('@scope/a', 'y')).toEqual({ + permitted: false, + source: null, + }) + }) + + it.each([ + [undefined, false], + [['*'], true], + [[], false], + ])( + 'explains policy mode %# without inventing a responsible entry', + (value, permitted) => { + const policy = compileSkillSourcePolicy(config(value)) + + expect(policy.explainPermitsSkill('@scope/a', 'x')).toEqual({ + permitted, + source: null, + }) + }, + ) + + it('returns the exclusion pattern that matched', () => { + const matchers = compileExcludePatterns(['@scope/a', '@scope/b#fetch-*']) + + expect(findPackageExcludeMatch('@scope/a', matchers)?.pattern).toBe( + '@scope/a', + ) + expect( + findSkillExcludeMatch('@scope/b', 'fetch-one', matchers)?.pattern, + ).toBe('@scope/b#fetch-*') + expect(findSkillExcludeMatch('@scope/b', 'other', matchers)).toBeUndefined() + }) +}) + +function skillNames(packages: Array): Array> { + return packages.map((p) => p.skills.map((s) => s.name)) +} + +describe('applySourcePolicy — skill-level allowlist entries', () => { + it('surfaces only the named skill from a listed package', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y'])] }, + { config: config(['@scope/a#x']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(skillNames(result.packages)).toEqual([['x']]) + }) + + it('reports skills a listed package ships that no entry allows', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y', 'z'])] }, + { config: config(['@scope/a#x']), excludeMatchers: [] }, + ) + + expect(result.hiddenSources).toEqual([ + { name: '@scope/a', skillCount: 2, hiddenSkills: ['y', 'z'] }, + ]) + expect(result.notices).toEqual([ + '2 skills from listed packages are not listed in intent.skills: @scope/a#y, @scope/a#z. Add to opt in.', + ]) + }) + + it('does not report excluded skills as hidden', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y'])] }, + { + config: config(['@scope/a']), + excludeMatchers: compileExcludePatterns(['@scope/a#y']), + }, + ) + + expect(result.hiddenSources).toEqual([]) + expect(result.notices).toEqual([]) + }) + + it('matches a glob in the skill selector', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['fetch-one', 'fetch-two', 'other'])] }, + { config: config(['@scope/a#fetch-*']), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['fetch-one', 'fetch-two']]) + }) + + it('keeps a skill entry kind-specific', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/a', ['x'], 'workspace'), + pkg('@scope/a', ['x'], 'npm'), + ], + }, + { config: config(['workspace:@scope/a#x']), excludeMatchers: [] }, + ) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]?.kind).toBe('workspace') + }) + + it.each(['@scope/ui#theme', '@scope/ui#the*'])( + 'matches a prefixed skill by its unambiguous short alias with %s', + (source) => { + const result = applySourcePolicy( + { packages: [pkg('@scope/ui', ['ui/theme', 'ui/layout'])] }, + { config: config([source]), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['ui/theme']]) + }, + ) + + it.each(['@scope/ui#theme', '@scope/ui#the*'])( + 'prefers an exact skill name over a prefixed skill short alias with %s', + (source) => { + const result = applySourcePolicy( + { packages: [pkg('@scope/ui', ['theme', 'ui/theme'])] }, + { config: config([source]), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['theme']]) + }, + ) + + it('reports a skill entry that matched no discovered skill', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x'])] }, + { config: config(['@scope/a#nope']), excludeMatchers: [] }, + ) + expect(result.notices).toEqual([ + '1 skill from listed packages is not listed in intent.skills: @scope/a#x. Add to opt in.', + '"@scope/a#nope" is declared in intent.skills but was not discovered.', + ]) + }) + + it('still lets an exclude hide a skill that a skill entry allows', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y'])] }, + { + config: config(['@scope/a#x']), + excludeMatchers: compileExcludePatterns(['@scope/a#x']), + }, + ) + expect(skillNames(result.packages)).toEqual([[]]) + }) +}) + +describe('checkLoadAllowed — skill-level allowlist entries', () => { + const use = '@scope/a#y' + const parsed = { packageName: '@scope/a', skillName: 'y' } + + it('allows a skill named by a skill-level entry', () => { + expect( + checkLoadAllowed( + '@scope/a#x', + { packageName: '@scope/a', skillName: 'x' }, + { + sourcePolicy: compileSkillSourcePolicy(config(['@scope/a#x'])), + excludeMatchers: [], + }, + ), + ).toBeNull() + }) + + it('refuses a skill the entry does not name, without claiming the package is unlisted', () => { + const refusal = checkLoadAllowed(use, parsed, { + sourcePolicy: compileSkillSourcePolicy(config(['@scope/a#x'])), + excludeMatchers: [], + }) + expect(refusal?.code).toBe('skill-not-listed') + expect(refusal?.message).toContain('"@scope/a#y"') + expect(refusal?.message).not.toContain('package "@scope/a" is not listed') + }) + + it('still refuses a package that is not listed at all', () => { + const refusal = checkLoadAllowed(use, parsed, { + sourcePolicy: compileSkillSourcePolicy(config(['@other/b#x'])), + excludeMatchers: [], + }) + expect(refusal?.code).toBe('package-not-listed') + }) }) describe('applySourcePolicy — permit-all and empty modes', () => { @@ -259,13 +483,13 @@ describe('applySourcePolicy — permit-all and empty modes', () => { expect(result.notices).toEqual([ALLOW_ALL_NOTICE]) }) - it('permits every discovered source under absent config with a migration warning', () => { + it('denies every discovered source under undefined config with the empty note', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x'])] }, { config: config(undefined), excludeMatchers: [] }, ) - expect(names(result.packages)).toEqual(['@scope/a']) - expect(result.notices).toEqual([MIGRATION_NOTICE]) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) }) it('permits nothing under empty config with a quiet info note', () => { @@ -298,7 +522,7 @@ describe('applySourcePolicy — exclude interaction', () => { expect(names(result.packages)).toEqual(['@scope/a']) }) - it('subtracts an excluded package on top of absent (migration) mode', () => { + it('denies all packages under undefined config even when excludes are present', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x']), pkg('@scope/bad', ['y'])] }, { @@ -306,8 +530,8 @@ describe('applySourcePolicy — exclude interaction', () => { excludeMatchers: compileExcludePatterns(['@scope/bad']), }, ) - expect(names(result.packages)).toEqual(['@scope/a']) - expect(result.notices).toEqual([MIGRATION_NOTICE]) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) }) it('treats an unlisted+excluded package as excluded with no unlisted warning', () => { @@ -386,9 +610,9 @@ describe('readSkillSourcesConfig', () => { writeFileSync(filePath, JSON.stringify(data, null, 2)) } - it('returns absent when no package.json declares intent.skills', () => { + it('returns empty when no package.json declares intent.skills', () => { writeJson(join(root, 'package.json'), { name: 'app', private: true }) - expect(readSkillSourcesConfig(root)).toEqual({ mode: 'absent' }) + expect(readSkillSourcesConfig(root)).toEqual({ mode: 'empty' }) }) it('returns empty when intent.skills is an empty array', () => { @@ -439,7 +663,7 @@ describe('readSkillSourcesConfig', () => { }) }) - it('ignores a null intent.skills so it cannot shadow a stricter parent', () => { + it('inherits from the parent when the nearer package omits intent.skills', () => { const appDir = join(root, 'packages', 'app') writeFileSync( join(root, 'pnpm-workspace.yaml'), @@ -452,7 +676,7 @@ describe('readSkillSourcesConfig', () => { }) writeJson(join(appDir, 'package.json'), { name: '@scope/app', - intent: { skills: null }, + intent: { exclude: [] }, }) expect(readSkillSourcesConfig(appDir)).toEqual({ @@ -460,4 +684,23 @@ describe('readSkillSourcesConfig', () => { sources: [{ raw: '@scope/root', id: '@scope/root', kind: 'npm' }], }) }) + + it('treats null intent.skills as deny-all without inheriting from the parent', () => { + const appDir = join(root, 'packages', 'app') + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { + name: 'monorepo', + private: true, + intent: { skills: ['@scope/root'] }, + }) + writeJson(join(appDir, 'package.json'), { + name: '@scope/app', + intent: { skills: null }, + }) + + expect(readSkillSourcesConfig(appDir)).toEqual({ mode: 'empty' }) + }) }) diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index fa266570..b85954d9 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -73,11 +73,11 @@ function mockFetchVersion(version: string): void { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ version }), - } as Response) + }) } function mockFetchNotOk(): void { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }) } beforeEach(() => { diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts new file mode 100644 index 00000000..7a197049 --- /dev/null +++ b/packages/intent/tests/sync.test.ts @@ -0,0 +1,529 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { updateIntentGitExclude } from '../src/commands/sync/gitignore.js' +import { reconcileManagedLinks } from '../src/commands/sync/links.js' +import { + parseInstallState, + readInstallState, + readInstallStateForLinks, + serializeInstallState, + writeInstallState, +} from '../src/commands/sync/state.js' +import { + createSyncAliases, + resolveSyncTargetDirectories, +} from '../src/commands/sync/targets.js' + +const tempDirs: Array = [] + +function tempRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), name)) + tempDirs.push(root) + return root +} + +function createDirectoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir') +} + +afterEach(() => { + for (const root of tempDirs.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +describe('sync targets and aliases', () => { + it('deduplicates github and vscode target directories deterministically', () => { + expect( + resolveSyncTargetDirectories('/project', ['vscode', 'github', 'agents']), + ).toEqual([ + { id: 'agents', path: join('/project', '.agents/skills') }, + { id: 'vscode', path: join('/project', '.github/skills') }, + ]) + }) + + it('normalizes aliases and hashes every collision', () => { + const aliases = createSyncAliases([ + { kind: 'npm', id: '@scope/a.b', skill: 'one/two' }, + { kind: 'npm', id: 'scope/a-b', skill: 'one/two' }, + { kind: 'workspace', id: '@scope/pkg', skill: 'core' }, + ]) + expect(aliases.map((entry) => entry.alias)).toEqual([ + expect.stringMatching(/^npm-scope-a-b-one-two-[a-f0-9]{8}$/), + expect.stringMatching(/^npm-scope-a-b-one-two-[a-f0-9]{8}$/), + 'workspace-scope-pkg-core', + ]) + expect(aliases[0]!.alias).not.toBe(aliases[1]!.alias) + }) +}) + +describe('sync state', () => { + const state = { + version: 1 as const, + entries: [ + { + targetDirectory: '.github/skills', + path: '.github/skills/b', + alias: 'b', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/b', + linkTarget: '/source/b', + }, + { + targetDirectory: '.github/skills', + path: '.github/skills/a', + alias: 'a', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/a', + linkTarget: '/source/a', + }, + ], + } + + it('strictly parses and deterministically serializes entries', () => { + const serialized = serializeInstallState(state) + expect(serialized.indexOf('"path": ".github/skills/a"')).toBeLessThan( + serialized.indexOf('"path": ".github/skills/b"'), + ) + expect( + parseInstallState(serialized)?.entries.map((entry) => entry.alias), + ).toEqual(['a', 'b']) + expect( + parseInstallState('{"version":1,"entries":[],"extra":true}'), + ).toBeNull() + }) + + it.each([ + '', + '/absolute/link', + 'outside\\link', + 'C:/link', + 'C:link', + './link', + 'links/./link', + 'links//link', + '../outside/link', + 'links/../outside', + '.. /link', + 'links/.. /outside', + 'links/link.', + 'links/link ', + ])('rejects invalid persisted path %j', (path) => { + expect( + parseInstallState( + JSON.stringify({ + ...state, + entries: [{ ...state.entries[0], path }], + }), + ), + ).toBeNull() + }) + + it('writes atomically only when state changes and reports malformed state', () => { + const root = tempRoot('intent-sync-state-') + expect(writeInstallState(root, state)).toBe(true) + expect(writeInstallState(root, state)).toBe(false) + expect(readInstallState(root)).toMatchObject({ status: 'found' }) + writeFileSync(join(root, '.intent', 'install-state.json'), '{bad', 'utf8') + expect(readInstallState(root)).toEqual({ status: 'malformed' }) + }) +}) + +describe('managed sync links', () => { + function expected(root: string) { + const packageRoot = join(root, 'node_modules', 'pkg') + const source = join(packageRoot, 'skills', 'core') + const path = join(root, '.github', 'skills', 'npm-pkg-core') + mkdirSync(source, { recursive: true }) + return { + path, + targetDirectory: '.github/skills', + alias: 'npm-pkg-core', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/core', + sourceDirectory: source, + packageRoot, + } + } + + function ownedEntry( + link: ReturnType, + linkTarget = link.sourceDirectory, + ) { + const { targetDirectory, path, alias, source, skillPath } = link + return { targetDirectory, path, alias, source, skillPath, linkTarget } + } + + it('rejects outside-parent create, repair, and stale removal', () => { + const parent = tempRoot('intent-sync-outside-parent-') + const root = join(parent, 'project') + const outside = join(parent, 'outside') + const priorTarget = join(parent, 'prior-source') + mkdirSync(root) + mkdirSync(join(outside, 'skills'), { recursive: true }) + mkdirSync(priorTarget) + createDirectoryLink(outside, join(root, '.github')) + const link = expected(root) + + const created = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + }) + expect(created.conflicts).toEqual([link.path]) + expect(existsSync(link.path)).toBe(false) + + createDirectoryLink(priorTarget, link.path) + const priorEntry = ownedEntry(link, priorTarget) + const stateResult = { + status: 'found' as const, + state: { version: 1 as const, entries: [priorEntry] }, + } + const repair = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult, + }) + const stale = reconcileManagedLinks({ + root, + dryRun: false, + expected: [], + stateResult, + }) + + expect({ + repair: [repair.conflicts, repair.repaired, repair.entries], + stale: [stale.conflicts, stale.removed, stale.entries], + }).toEqual({ + repair: [[link.path], [], [priorEntry]], + stale: [[link.path], [], [priorEntry]], + }) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + }) + + it('rejects persisted link paths that traverse outside the project', () => { + const parent = tempRoot('intent-sync-state-path-') + const root = join(parent, 'project') + const outsideDirectory = join(parent, 'outside') + const source = join(parent, 'source') + const outsideLink = join(outsideDirectory, 'link') + mkdirSync(join(root, '.intent'), { recursive: true }) + mkdirSync(outsideDirectory, { recursive: true }) + mkdirSync(source, { recursive: true }) + createDirectoryLink(source, outsideLink) + writeFileSync( + join(root, '.intent', 'install-state.json'), + JSON.stringify({ + version: 1, + entries: [ + { + targetDirectory: '../outside', + path: '../outside/link', + alias: 'link', + source: { kind: 'npm', id: 'pkg' }, + skillPath: 'skills/core', + linkTarget: source, + }, + ], + }), + 'utf8', + ) + + const stateResult = readInstallStateForLinks(root) + const result = reconcileManagedLinks({ + root, + dryRun: false, + expected: [], + stateResult, + }) + + expect({ + stateStatus: stateResult.status, + removed: result.removed, + outsideLinkExists: existsSync(outsideLink), + }).toEqual({ + stateStatus: 'malformed', + removed: [], + outsideLinkExists: true, + }) + }) + + it('creates, leaves unchanged, repairs owned links, and cleans owned stale links', () => { + const root = tempRoot('intent-sync-links-') + const link = expected(root) + const first = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + }) + expect(first.created).toEqual([link.path]) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + const second = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { version: 1, entries: first.entries }, + }, + }) + expect(second.unchanged).toEqual([link.path]) + unlinkSync(link.path) + const repaired = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { version: 1, entries: first.entries }, + }, + }) + expect(repaired.created).toEqual([link.path]) + const cleanup = reconcileManagedLinks({ + root, + dryRun: false, + expected: [], + stateResult: { + status: 'found', + state: { version: 1, entries: repaired.entries }, + }, + }) + expect(cleanup.removed).toEqual([link.path]) + expect(existsSync(link.path)).toBe(false) + }) + + it('does not replace unmanaged links and makes dry runs non-writing', () => { + const root = tempRoot('intent-sync-conflict-') + const link = expected(root) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(join(root, 'somewhere-else'), link.path) + const conflict = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + }) + expect(conflict.conflicts).toEqual([link.path]) + const dryRunLink = { + ...link, + path: join(root, '.github', 'skills', 'dry-run'), + } + const dryRun = reconcileManagedLinks({ + root, + dryRun: true, + expected: [dryRunLink], + stateResult: { status: 'missing' }, + }) + expect(dryRun.created).toEqual([dryRunLink.path]) + expect(existsSync(dryRunLink.path)).toBe(false) + }) + + it('treats an unreadable owned link target as a conflict', () => { + const root = tempRoot('intent-sync-unreadable-') + const link = expected(root) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(join(root, 'missing'), link.path) + + const result = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { + version: 1, + entries: [ownedEntry(link)], + }, + }, + }) + + expect(result.conflicts).toEqual([link.path]) + expect(result.repaired).toEqual([]) + }) + + it('keeps owned state and continues when removing a stale link fails', () => { + const root = tempRoot('intent-sync-remove-failure-') + const source = join(root, 'source') + const blockedPath = join(root, '.github', 'skills', 'blocked') + const removablePath = join(root, '.github', 'skills', 'removable') + mkdirSync(source, { recursive: true }) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(source, blockedPath) + createDirectoryLink(source, removablePath) + const entries = [blockedPath, removablePath].map((path) => ({ + targetDirectory: '.github/skills', + path, + alias: path === blockedPath ? 'blocked' : 'removable', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/core', + linkTarget: source, + })) + + const result = reconcileManagedLinks({ + root, + dryRun: false, + expected: [], + stateResult: { status: 'found', state: { version: 1, entries } }, + removeLink: (path) => { + if (path === blockedPath) return false + unlinkSync(path) + return true + }, + }) + + expect(result.conflicts).toEqual([blockedPath]) + expect(result.removed).toEqual([removablePath]) + expect(result.entries).toEqual([entries[0]]) + expect(lstatSync(blockedPath).isSymbolicLink()).toBe(true) + expect(existsSync(removablePath)).toBe(false) + }) + + it('records a fresh create failure and continues creating later links', () => { + const root = tempRoot('intent-sync-create-failure-') + const link = expected(root) + const createdBefore = { + ...link, + path: join(dirname(link.path), 'a-created'), + } + const blocked = { ...link, path: join(dirname(link.path), 'm-blocked') } + const created = { ...link, path: join(dirname(link.path), 'z-created') } + mkdirSync(dirname(link.path), { recursive: true }) + + const result = reconcileManagedLinks({ + root, + dryRun: false, + expected: [blocked, created, createdBefore], + stateResult: { status: 'missing' }, + createLink: (path, target) => { + if (path === blocked.path) return false + createDirectoryLink(target, path) + return true + }, + }) + + expect(result.conflicts).toEqual([blocked.path]) + expect(result.created).toEqual([createdBefore.path, created.path]) + expect(result.entries.map((entry) => entry.path)).toEqual([ + createdBefore.path, + created.path, + ]) + expect(existsSync(blocked.path)).toBe(false) + expect(lstatSync(createdBefore.path).isSymbolicLink()).toBe(true) + expect(lstatSync(created.path).isSymbolicLink()).toBe(true) + }) + + it('does not swallow unexpected creation errors', () => { + const root = tempRoot('intent-sync-create-error-') + const link = expected(root) + + expect(() => + reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + createLink: () => { + throw new Error('unexpected creation error') + }, + }), + ).toThrow('unexpected creation error') + }) + + it.each([ + { failure: 'removal', removeLink: () => false, linkRemains: true }, + { failure: 'creation', createLink: () => false, linkRemains: false }, + ])('retains prior state when owned-link repair $failure fails', (failure) => { + const root = tempRoot('intent-sync-repair-failure-') + const link = expected(root) + const priorTarget = join(root, 'prior-source') + mkdirSync(priorTarget, { recursive: true }) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(priorTarget, link.path) + const priorEntry = ownedEntry(link, priorTarget) + + const result = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { version: 1, entries: [priorEntry] }, + }, + createLink: failure.createLink, + removeLink: failure.removeLink, + }) + + expect(result.conflicts).toEqual([link.path]) + expect(result.repaired).toEqual([]) + expect(result.entries).toEqual([priorEntry]) + expect(existsSync(link.path)).toBe(failure.linkRemains) + if (failure.linkRemains) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + }) + + it('does not swallow unexpected removal errors', () => { + const root = tempRoot('intent-sync-remove-error-') + const source = join(root, 'source') + const path = join(root, '.github', 'skills', 'owned') + mkdirSync(source, { recursive: true }) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(source, path) + + expect(() => + reconcileManagedLinks({ + root, + dryRun: false, + expected: [], + stateResult: { + status: 'found', + state: { + version: 1, + entries: [ + { + targetDirectory: '.github/skills', + path, + alias: 'owned', + source: { kind: 'npm', id: 'pkg' }, + skillPath: 'skills/core', + linkTarget: source, + }, + ], + }, + }, + removeLink: () => { + throw new Error('unexpected removal error') + }, + }), + ).toThrow('unexpected removal error') + }) +}) + +describe('sync managed text', () => { + it('updates only the exact local exclude block while preserving CRLF', () => { + const updated = updateIntentGitExclude('node_modules/\r\n', [ + '.github/skills/a', + '.intent/install-state.json', + ]) + expect(updated).toContain('node_modules/\r\n# intent skill links:start\r\n') + expect(updated).toContain('.github/skills/a\r\n') + expect( + updateIntentGitExclude(updated, [ + '.github/skills/a', + '.intent/install-state.json', + ]), + ).toBe(updated) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 546d7aeb..fe581541 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: pino: 10.3.1 + typescript-eslint: 8.65.0 undici-types: 8.4.1 packageExtensionsChecksum: sha256-RTw5AJ+OM+YIsEdtsFGhLkoKUR+JYXJDfUxe9dr2paA= @@ -22,7 +23,7 @@ importers: version: 2.31.0(@types/node@25.0.9) '@tanstack/eslint-config': specifier: 0.4.0 - version: 0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + version: 0.4.0(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@tanstack/typedoc-config': specifier: ^0.3.3 version: 0.3.3(typescript@6.0.3) @@ -34,7 +35,7 @@ importers: version: 9.39.4(jiti@2.7.0) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) knip: specifier: ^6.16.1 version: 6.16.1 @@ -77,6 +78,9 @@ importers: packages/intent: dependencies: + '@clack/prompts': + specifier: 1.7.0 + version: 1.7.0 cac: specifier: ^6.7.14 version: 6.7.14 @@ -191,6 +195,14 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@codspeed/core@5.5.0': resolution: {integrity: sha512-5FbjNlxSVOfemB85orEecikZiTz0C8aZYUfCkt5HY6QLLd1mqkrHAfekEJw0gkHcgCjNgD6DVp2TXm0V/xtt4w==} @@ -1234,43 +1246,43 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.65.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.53.0': resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} @@ -1280,21 +1292,25 @@ packages: resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -2218,9 +2234,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3394,6 +3419,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3564,8 +3592,8 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3651,12 +3679,12 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -4136,6 +4164,18 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@codspeed/core@5.5.0': dependencies: axios: 1.13.2 @@ -4839,15 +4879,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@9.39.4(jiti@2.7.0)) '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.7.0)) eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) globals: 17.4.0 - typescript-eslint: 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - '@typescript-eslint/utils' @@ -4921,60 +4961,60 @@ snapshots: '@types/node': 25.0.9 optional: true - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/parser': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.65.0 eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.1': + '@typescript-eslint/scope-manager@8.65.0': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.7.0) - ts-api-utils: 2.4.0(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4983,35 +5023,37 @@ snapshots: '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.4 tinyglobby: 0.2.17 - ts-api-utils: 2.4.0(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.1': + '@typescript-eslint/visitor-keys@8.65.0': dependencies: - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.0 '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -5849,7 +5891,7 @@ snapshots: eslint: 9.39.4(jiti@2.7.0) eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@typescript-eslint/types': 8.53.0 comment-parser: 1.4.4 @@ -5862,7 +5904,7 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -5881,11 +5923,11 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): dependencies: eslint: 9.39.4(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint-scope@8.4.0: dependencies: @@ -6043,8 +6085,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7355,6 +7407,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} smol-toml@1.6.1: {} @@ -7520,7 +7574,7 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@2.4.0(typescript@6.0.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -7598,12 +7652,12 @@ snapshots: typescript: 6.0.3 yaml: 2.8.3 - typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c9f7ae3..7bc02e80 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ trustPolicy: 'no-downgrade' overrides: pino: 10.3.1 + typescript-eslint: 8.65.0 undici-types: 8.4.1 packageExtensions: