diff --git a/.changeset/fair-tools-review.md b/.changeset/fair-tools-review.md new file mode 100644 index 00000000..02d70dca --- /dev/null +++ b/.changeset/fair-tools-review.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +Add grouped interactive first-run skill permission setup to `intent install`. Preview discovered skills and their permission scope, choose package-wide or exact-skill access, and confirm before saving. Excluded skills cannot be selected, empty discovery leaves setup available for retry, and completion reports available skills and the next command. diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 8b0abbf1..0b64d6dd 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -3,7 +3,7 @@ title: intent install id: intent-install --- -`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. +`intent install` confirms skill-source permissions on first use, then creates or updates an `intent-skills` guidance block in a project guidance file. ```bash npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] @@ -25,13 +25,39 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob ## Behavior -### Default guidance +### Default install -- 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. -- Verifies the managed block before reporting success. +If `intent.skills` is already configured, including through workspace inheritance, `install` only updates guidance. It does not prompt or change `package.json`. + +Otherwise, first-run setup requires an interactive terminal. Non-TTY execution fails before discovery or writes. Node.js 20.12.0 or newer is required. + +#### First-run flow + +1. **Review** discovered npm and workspace packages, versions, and skill descriptions. Excluded candidates appear in the overview but cannot be selected. +2. **Choose** permissions. After the overview, Intent asks about allow-all; choose No to select individual packages or skills. Press Space to toggle grouped choices and Enter to review. +3. **Confirm** the exact `intent.skills` value, destination file, and trust change. Confirmation defaults to No. +4. **Finish** with verified guidance, available skill and package counts, and a package-manager-aware `list` command. If no skills are enabled, Intent explains how to edit `intent.skills`. + +#### Permission choices + +| Choice | Effect | +| --- | --- | +| All skills in a package | Permits its current and future skills. | +| Individual skill | Permits only the named skill. | +| Allow all sources | Writes `["*"]` and skips narrower selection. | +| Select nothing | Explicitly confirms writing `[]`, disabling current and future sources until `intent.skills` is edited. | + +Existing `intent.exclude` rules always apply and remain unchanged. + +#### Files and retry behavior + +Permissions go in the nearest owning `package.json`. Inside a workspace package, this is that package's file. The update preserves formatting and uses an atomic replacement; if the file changes after preview, Intent stops and asks you to retry. + +After permissions are saved, Intent updates an existing managed guidance block in a supported config file, or creates one in `AGENTS.md`. Content outside the block is preserved, and the block is verified before success is reported. + +- **No skills found, or all excluded:** explains how to retry and writes nothing. Empty discovery does not create a deny-all policy. +- **Decline or cancel a prompt:** writes neither permissions nor guidance. +- **`--dry-run`:** performs discovery and selection, previews permissions and guidance, and writes neither file. ### Mapping mode @@ -88,6 +114,10 @@ tanstackIntent: | 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.` | +| Permissions updated | `Permissions: updated package.json.` | +| Permissions canceled | `Permissions: canceled.` | +| Guidance result after setup | `Guidance: created AGENTS.md.` | +| Guidance failure after setup | `Guidance: failed: ` | | 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.` | diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 349e98da..6d4d123c 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -3,12 +3,20 @@ title: Configuration id: configuration --- -Intent reads consumer configuration from the `intent` object in `package.json`. Two keys control which discovered skills Intent surfaces: `skills` (the allowlist) and `exclude` (the blocklist). +Configure Intent in the `intent` object in `package.json`: + +- **`skills`** permits packages or individual skills. +- **`exclude`** blocks packages or skills after permissions are evaluated. ```json { "intent": { - "skills": ["@tanstack/query", "@acme/*", "@tanstack/start#routing", "workspace:@scope/internal"], + "skills": [ + "@tanstack/query", + "@acme/*", + "@tanstack/start#routing", + "workspace:@scope/internal" + ], "exclude": ["@tanstack/router#experimental-*"] } } @@ -16,8 +24,10 @@ Intent reads consumer configuration from the `intent` object in `package.json`. ## Configuration inheritance -- **`intent.skills`:** Intent uses the nearest non-null declaration between the current working directory and the workspace or project root. A nearer declaration replaces its parent. An omitted or null value inherits the nearest parent declaration. -- **`intent.exclude`:** Intent combines arrays from the root through the current working directory, then adds excludes passed by the caller. +| Key | Inheritance rule | +| --- | --- | +| `intent.skills` | Uses the nearest non-null declaration between the current directory and the workspace or project root. A nearer declaration replaces its parent; omitted or null values inherit. | +| `intent.exclude` | Combines arrays from the root through the current directory, then adds excludes passed by the caller. | ## `intent.skills` @@ -27,9 +37,9 @@ Intent reads consumer configuration from the `intent` object in `package.json`. - Resolve through `load`. - Contribute mappings to `install --map`. -The default `install` command writes generic loading guidance without scanning packages. See [Trust model](./trust-model) for the reasoning and lifecycle boundaries. +Default `install` helps configure permissions on first use. See [Existing projects](#existing-projects) for how it handles saved or inherited configuration, and [Trust model](./trust-model) for the trust boundaries. -Package selectors permit every skill in the package. Exact selectors use `#` and permit only the named skill. If the same package matches both forms, the package selector takes precedence and permits every skill. `intent.exclude` is applied afterward and can still remove a permitted package or skill. +Package selectors permit current and future skills in the package. Exact selectors use `#` and permit only that skill. If both match, the package selector takes precedence. `intent.exclude` is applied afterward and can still block either choice. ### Source entries @@ -45,25 +55,51 @@ Each array entry names one source: | `workspace:@scope/*` | workspace | Every discovered workspace package 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. Exact selectors require one non-empty package name and one non-empty, non-wildcard skill name. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`, but cannot be combined with an exact skill selector. +#### Validation rules + +- Exact selectors require a non-empty package name and a non-empty skill name without wildcards. +- Package patterns support `*`, including scoped patterns such as `@tanstack/*`. Patterns cannot be combined with an exact skill selector. +- Source kinds must match: bare selectors permit npm sources; `workspace:` selectors permit workspace sources. +- `git:` entries are rejected, including entries containing `#` for a Git ref. -Intent matches both the package name and source kind: a bare package or exact selector permits only an npm source, and a `workspace:` selector permits only a workspace source. `git:` entries remain unsupported and are rejected, including entries that contain `#` for a Git ref. +A malformed entry fails the whole command. Intent reports every bad entry at once. ### Special forms | Form | Result | Notice | | --- | --- | --- | -| **Absent:** no `intent.skills` key | Surfaces every discovered package as an upgrade path for existing projects. A future version will require an explicit allowlist. | Deprecation notice on stderr on each run until you set `intent.skills`. | +| **Absent:** no effective `intent.skills` key | Discovery commands surface every discovered package as migration behavior. | Deprecation notice until you configure permissions. | | **Empty:** `"skills": []` | Surfaces no packages. | Info notice on stderr. | -| **Wildcard:** `"skills": ["*"]` | Surfaces every discovered package across package scopes and source kinds. This is broader than a pattern such as `@tanstack/*`. | Acknowledged-risk notice on stderr because unvetted skills may reach your agent. | +| **Wildcard:** `"skills": ["*"]` | Permits every discovered package across scopes and source kinds, broader than `@tanstack/*`. | Acknowledged-risk notice: unvetted skills may reach your agent. | + +All policy notices go to stderr. Exclusions still apply to these forms. + +#### Discovery notices -A package that ships skills but is not listed is dropped. In human output, Intent adds one policy notice naming packages dropped this way so you can opt in. Agent sessions receive only the hidden package and skill counts. A listed package that was not discovered is reported as a notice as well. +| Situation | Notice | +| --- | --- | +| Discovered package is not permitted | Human output names omitted packages in one notice. Agent sessions receive only hidden package and skill counts. | +| Configured package was not discovered | Reports that the package was not discovered. | +| Package was explicitly excluded | No unlisted-source notice. | ### Existing projects Run `intent list` to see which packages the current policy surfaces. -A project without `intent.skills` uses the absent form: Intent surfaces every discovered package and prints its deprecation notice. Add an allowlist to permit specific sources before a future version requires one. +| Current configuration | Default `intent install` behavior | +| --- | --- | +| Saved or inherited `intent.skills` | Updates guidance only. Keeps permissions unchanged and does not prompt. | +| No effective `intent.skills` | Starts interactive permission setup. Non-TTY execution fails without writes. | + +First-run setup discovers candidates before policy filtering and shows their versions and descriptions. It previews the selected allowlist and nearest owning `package.json`, then requires confirmation before saving permissions and installing guidance. + +- **Excluded candidates** stay visible in the overview but cannot be selected. Exclusions remain unchanged. +- **Package-wide choices** include current and future skills and remove redundant selected children. Exact choices permit only the named skill. +- **Allow-all** is offered separately after the overview and saves `["*"]` alone. +- **An empty selection** explicitly confirms disabling all skills with `[]`. +- **Empty or fully excluded discovery** writes nothing, so setup can be retried. + +See [Default install](../cli/intent-install#default-install) for picker controls, previews, and cancellation behavior. ### Suppressing notices temporarily @@ -98,12 +134,14 @@ npx @tanstack/intent@latest exclude list } ``` -Pattern grammar: +### Exclusion patterns -- A pattern without `#` excludes a whole package: `@scope/pkg`. -- A pattern with `#` excludes a single skill: `@scope/pkg#search-params`. -- The skill segment may be a glob: `@scope/pkg#experimental-*`. -- A pattern may cross package boundaries at skill granularity: `*#experimental-*`. -- The `#*` shortcut excludes the whole package: `@scope/pkg#*`. +| Pattern | Excludes | +| --- | --- | +| `@scope/pkg` | The whole package. | +| `@scope/pkg#search-params` | One named skill. | +| `@scope/pkg#experimental-*` | Matching skills in one package. | +| `*#experimental-*` | Matching skills across packages. | +| `@scope/pkg#*` | The whole package, using the `#*` shortcut. | -Only exact names and `*` wildcards are supported on each segment. Excludes are source-kind agnostic, so a package pattern excludes matching npm and workspace sources. An excluded package does not trigger the unlisted-source notice, because an exclude is an explicit decision. +Each segment supports exact names and `*` wildcards only. Excludes apply to both npm and workspace sources with matching names, regardless of source kind. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 38cd69c2..6d426b1c 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -3,17 +3,43 @@ title: Trust model id: trust-model --- -Intent discovers skills from your dependencies and can surface permitted skills through its CLI and agent integrations. 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. +Skills contain instructions for an agent. Choosing which packages can supply those instructions is a trust decision, controlled by the `intent.skills` allowlist. ## Explicit sources 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 its package name and source kind. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it. +When configured, `package.json#intent.skills` controls which discovered skills can surface through the CLI and agent integrations: -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. +- **Package entries** permit current and future skills from matching packages. +- **Exact skill entries** permit only the named skill. +- **Source kinds stay separate:** `foo` permits an npm source; `workspace:foo` permits a workspace source. Their wildcard patterns remain kind-specific. The exact `*` entry permits every discovered npm and workspace source. -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. A bare entry such as `foo` permits an npm source, while `workspace:foo` permits a workspace source. Their wildcard forms remain kind-specific. The exact `*` entry permits every discovered npm and workspace source. +Trust does not propagate to dependencies. A dependency that ships skills needs its own matching entry. Intent omits unlisted packages and reports them so you can opt in or ignore them. + +### Projects without an allowlist + +The gate is opt-in today. Without an effective `intent.skills` declaration, discovery commands still surface every discovered package and print a deprecation notice to stderr. A future version will require an explicit allowlist. See [Special forms](./configuration#special-forms). + +Default `intent install` handles this state through interactive permission setup. + +## First-run permission review + +When no effective policy exists, `intent install` follows this flow: + +1. **Discover:** show npm and workspace packages, versions, and skill descriptions. Excluded candidates appear in the overview but cannot be selected. +2. **Choose:** select package-wide or exact-skill permissions. An empty selection explicitly confirms disabling all skills. +3. **Review:** show the exact `intent.skills` value and destination file. +4. **Confirm:** replace `package.json` atomically only after affirmative confirmation, then install guidance. + +| Outcome | Files changed | +| --- | --- | +| No skills discovered, or all excluded | None. | +| Cancel any prompt | None. | +| Run first-time setup without a TTY | None; the command fails. | +| Save permissions, then fail to write or verify guidance | Confirmed permissions remain saved; the guidance failure is reported separately. | + +The completion summary reports skills available under the saved policy. It does not prove that an agent loaded or applied them. See [Default install](../cli/intent-install#default-install) for picker controls and permission choices. ## Static discovery diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 03280df1..cfb0123b 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -9,16 +9,23 @@ id: quick-start-consumers npx @tanstack/intent@latest install ``` -This command creates or updates skill-loading guidance for your agent. +Run this in an interactive terminal. On first use, Intent helps you choose which installed packages and skills your agent may use, then creates or updates skill-loading guidance. Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`. The command: -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 +1. Shows discovered packages, versions, skill descriptions, and exclusions +2. Lets you choose package-wide or individual skill permissions +3. Previews the exact `intent.skills` configuration and destination `package.json`, then asks for confirmation +4. Writes the confirmed permissions and creates or updates the `intent-skills` guidance block, preserving unrelated content +5. Verifies the guidance and reports the available skill count and a command to list those skills + +Choose **No** when asked about allowing all current and future skill sources to select specific packages and skills. Press Space to toggle choices and Enter to review your selection. A package's **All skills** choice includes its current and future skills; an individual choice permits only that named skill. Excluded skills appear in the discovery overview but cannot be selected. + +Selecting nothing requires explicit confirmation to disable all skills. If no skills are found, or all are excluded, Intent explains the next step and leaves permissions and guidance unchanged. Install a package that ships skills or review your exclusions, then run `install` again. + +Canceling before confirmation writes neither file. `--dry-run` previews the flow without writing. First-run setup requires a terminal; noninteractive execution fails without writes when permissions have not been configured. If an `intent-skills` block already exists, Intent updates that file in place. If no block exists, `AGENTS.md` is the default target. @@ -64,19 +71,19 @@ Hooks do not verify that: To control what appears in the session catalog, configure `intent.skills` and `intent.exclude` in `package.json`. -## 2. Choose which packages' skills to use +## 2. Review the saved permissions -`package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced. +`install` saves your choices in `package.json#intent.skills`, an allowlist of packages or individual skills. It uses the nearest `package.json` that owns the directory where you ran the command. ```json { "intent": { - "skills": ["@tanstack/*"] + "skills": ["@tanstack/react-query#core"] } } ``` -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. +When permissions already exist, including inherited workspace permissions, `install` preserves them and only updates guidance. To change your choices, edit the owning `intent.skills` declaration. You can also use `*` package patterns such as `@tanstack/*`. Existing `intent.exclude` rules still take precedence. See the [source entries](../concepts/configuration#source-entries) in Configuration and the [Trust model](../concepts/trust-model). ## 3. Use skills in your workflow diff --git a/packages/intent/package.json b/packages/intent/package.json index dd44f067..67838f28 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -3,6 +3,9 @@ "version": "0.3.8", "description": "Ship compositional knowledge for AI coding agents alongside your npm packages", "license": "MIT", + "engines": { + "node": ">=20.12.0" + }, "type": "module", "repository": { "type": "git", @@ -26,6 +29,7 @@ "meta" ], "dependencies": { + "@clack/prompts": "1.7.0", "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index afab9dd8..20630ade 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -7,13 +7,16 @@ import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' import type { ExcludeCommandOptions } from './commands/exclude.js' import type { HooksInstallCommandOptions } from './commands/hooks/command.js' -import type { InstallCommandOptions } from './commands/install/command.js' +import type { + InstallCommandOptions, + InstallCommandRuntime, +} 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 { ValidateCommandOptions } from './commands/validate.js' -function createCli(): CAC { +function createCli(runtime: InstallCommandRuntime = {}): CAC { const cli = cac('intent') cli.usage(' [options]') @@ -144,7 +147,7 @@ function createCli(): CAC { import('./commands/support.js'), import('./commands/install/command.js'), ]) - await runInstallCommand(options, scanIntentsOrFail) + await runInstallCommand(options, scanIntentsOrFail, runtime) }) cli @@ -271,9 +274,12 @@ function createCli(): CAC { return cli } -export async function main(argv: Array = process.argv.slice(2)) { +export async function main( + argv: Array = process.argv.slice(2), + runtime: InstallCommandRuntime = {}, +) { try { - const cli = createCli() + const cli = createCli(runtime) if (argv.length === 0) { cli.outputHelp() diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 2721081b..985c9814 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,6 +1,10 @@ import { relative } from 'node:path' +import { readSkillSourcesConfig } from '../../core/source-policy.js' import { fail } from '../../shared/cli-error.js' -import { detectIntentCommandPackageManager } from '../../shared/command-runner.js' +import { + detectIntentCommandPackageManager, + formatIntentCommand, +} from '../../shared/command-runner.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, @@ -14,9 +18,14 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from './guidance.js' +import { + createPermissionPrompts, + setupInitialPermissions, +} from './permissions.js' import type { GlobalScanFlags } from '../support.js' import type { IntentCoreOptions } from '../../core/index.js' import type { ScanResult } from '../../shared/types.js' +import type { PermissionPrompts } from './permissions.js' export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. @@ -127,6 +136,11 @@ export interface InstallCommandOptions extends GlobalScanFlags { printPrompt?: boolean } +export interface InstallCommandRuntime { + isTTY?: boolean + permissionPrompts?: PermissionPrompts +} + function formatTargetPath(targetPath: string): string { return relative(process.cwd(), targetPath) || targetPath } @@ -197,6 +211,7 @@ function printWriteResult({ export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, + runtime: InstallCommandRuntime = {}, ): Promise { if (options.printPrompt) { console.log(INSTALL_PROMPT) @@ -207,6 +222,46 @@ export async function runInstallCommand( const noticeOptions = noticeOptionsFromGlobalFlags(options) if (!options.map) { + const policy = readSkillSourcesConfig(process.cwd()) + let permissions: Awaited< + ReturnType + > | null = null + + if (policy.mode === 'absent') { + const isTTY = runtime.isTTY ?? process.stdin.isTTY === true + if (!isTTY) { + fail( + 'Permissions: failed: intent.skills is not configured. Run `intent install` in an interactive terminal to review and confirm permissions.', + ) + } + + try { + permissions = await setupInitialPermissions({ + dryRun: options.dryRun, + root: process.cwd(), + runtime: { + prompts: runtime.permissionPrompts ?? createPermissionPrompts(), + }, + }) + } catch (error) { + const message = + error && typeof error === 'object' && 'message' in error + ? String(error.message) + : String(error) + fail(`Permissions: failed: ${message}`) + } + if ( + permissions.status === 'canceled' || + permissions.status === 'unavailable' + ) + return + console.log( + options.dryRun + ? 'Permissions: unchanged package.json (dry run).' + : `Permissions: ${permissions.status} package.json.`, + ) + } + const generated = buildIntentSkillGuidanceBlock( detectIntentCommandPackageManager(), ) @@ -220,34 +275,70 @@ export async function runInstallCommand( 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 target = formatTargetPath(result.targetPath) - if (!verification.ok) { - fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), - ) + const available = permissions ? await scanIntentsOrFail() : null + try { + 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 target = formatTargetPath(result.targetPath) + if (!verification.ok) { + fail( + [ + `Install verification failed for ${target}:`, + ...verification.errors.map((error) => `- ${error}`), + ].join('\n'), + ) + } + + printWriteResult(result) + if (permissions) { + console.log(`Guidance: ${result.status} ${target}.`) + } + printPlacementTip(result.targetPath) + if (available && permissions) { + const packages = available.packages.filter( + (pkg) => pkg.skills.length > 0, + ) + const skillCount = packages.reduce( + (count, pkg) => count + pkg.skills.length, + 0, + ) + console.log( + `Available: ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} from ${packages.length} ${packages.length === 1 ? 'package' : 'packages'}.`, + ) + if (skillCount > 0) { + console.log( + `Next: ${formatIntentCommand(detectIntentCommandPackageManager(), 'list')}`, + ) + } else { + console.log( + `To enable skills, edit intent.skills in ${formatTargetPath(permissions.packageJsonPath)} and run intent install again.`, + ) + } + } + return + } catch (error) { + if (permissions) { + const message = + error && typeof error === 'object' && 'message' in error + ? String(error.message) + : String(error) + fail(`Guidance: failed: ${message}`) + } + throw error } - - printWriteResult(result) - printPlacementTip(result.targetPath) - return } const scanResult = await scanIntentsOrFail(coreOptions) diff --git a/packages/intent/src/commands/install/package-json.ts b/packages/intent/src/commands/install/package-json.ts new file mode 100644 index 00000000..d2ba9b65 --- /dev/null +++ b/packages/intent/src/commands/install/package-json.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + fchmodSync, + fsyncSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { applyEdits, modify, parse, printParseErrorCode } from 'jsonc-parser' +import type { ParseError } from 'jsonc-parser' + +export interface PreparedPackageSkillsUpdate { + content: string + packageJsonPath: string + skills: Array + source: string +} + +export interface PackageSkillsWriterRuntime { + beforeReplace?: () => void + rename?: (oldPath: string, newPath: string) => void +} + +function parsePackageJson( + content: string, + packageJsonPath: string, +): Record { + const errors: Array = [] + const value = parse(content, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as unknown + + if (errors.length > 0) { + throw new Error( + `Cannot update ${packageJsonPath}: invalid JSONC (${printParseErrorCode(errors[0]!.error)} at offset ${errors[0]!.offset}).`, + ) + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `Cannot update ${packageJsonPath}: package.json must contain an object.`, + ) + } + + const pkg = value as Record + if ( + pkg.intent !== undefined && + (pkg.intent === null || + typeof pkg.intent !== 'object' || + Array.isArray(pkg.intent)) + ) { + throw new Error( + `Cannot update ${packageJsonPath}: intent must contain an object.`, + ) + } + return pkg +} + +function formattingOptions(content: string): { + eol: string + insertSpaces: boolean + tabSize: number +} { + const indentation = content.match(/(?:^|\r?\n)([ \t]+)"/)?.[1] ?? ' ' + return { + eol: content.includes('\r\n') ? '\r\n' : '\n', + insertSpaces: !indentation.includes('\t'), + tabSize: indentation.includes('\t') ? 1 : indentation.length, + } +} + +export function preparePackageSkillsUpdate( + packageJsonPath: string, + skills: Array, +): PreparedPackageSkillsUpdate { + const source = readFileSync(packageJsonPath, 'utf8') + parsePackageJson(source, packageJsonPath) + const content = applyEdits( + source, + modify(source, ['intent', 'skills'], skills, { + formattingOptions: formattingOptions(source), + }), + ) + const updated = parsePackageJson(content, packageJsonPath) + const updatedIntent = updated.intent as Record + if (JSON.stringify(updatedIntent.skills) !== JSON.stringify(skills)) { + throw new Error( + `Cannot update ${packageJsonPath}: intent.skills validation failed.`, + ) + } + + return { content, packageJsonPath, skills, source } +} + +export function writePreparedPackageSkillsUpdate( + update: PreparedPackageSkillsUpdate, + runtime: PackageSkillsWriterRuntime = {}, +): 'unchanged' | 'updated' { + if (update.content === update.source) return 'unchanged' + + const targetDir = dirname(update.packageJsonPath) + const temporaryPath = join( + targetDir, + `.${basename(update.packageJsonPath)}.${process.pid}.${randomUUID()}.tmp`, + ) + const mode = statSync(update.packageJsonPath).mode + let descriptor: number | null = null + + try { + descriptor = openSync(temporaryPath, 'wx', mode) + writeFileSync(descriptor, update.content, 'utf8') + fchmodSync(descriptor, mode) + fsyncSync(descriptor) + closeSync(descriptor) + descriptor = null + + runtime.beforeReplace?.() + if (readFileSync(update.packageJsonPath, 'utf8') !== update.source) { + throw new Error( + `Cannot update ${update.packageJsonPath}: package.json changed after permission review. Run intent install again.`, + ) + } + + ;(runtime.rename ?? renameSync)(temporaryPath, update.packageJsonPath) + return 'updated' + } finally { + if (descriptor !== null) closeSync(descriptor) + try { + unlinkSync(temporaryPath) + } catch {} + } +} diff --git a/packages/intent/src/commands/install/permissions.ts b/packages/intent/src/commands/install/permissions.ts new file mode 100644 index 00000000..fc98a607 --- /dev/null +++ b/packages/intent/src/commands/install/permissions.ts @@ -0,0 +1,289 @@ +import { stdin, stdout } from 'node:process' +import { cancel, confirm, groupMultiselect, isCancel } from '@clack/prompts' +import { + compileExcludePatterns, + getEffectiveExcludePatterns, + isPackageExcluded, + isSkillExcluded, +} from '../../core/excludes.js' +import { resolveProjectContext } from '../../core/project-context.js' +// First-run permission setup must show unpoliced candidates for explicit review. +// eslint-disable-next-line no-restricted-imports +import { scanForIntents } from '../../discovery/scanner.js' +import { + ALLOW_ALL_NOTICE, + printNotices, + printWarnings, +} from '../../shared/cli-output.js' +import { + preparePackageSkillsUpdate, + writePreparedPackageSkillsUpdate, +} from './package-json.js' +import type { IntentPackage, ScanResult } from '../../shared/types.js' + +interface PermissionPromptOption { + disabled?: boolean + hint?: string + label: string + value: string +} + +export interface PermissionPromptGroup { + label: string + options: Array +} + +export interface PermissionPrompts { + confirmAllowAll: () => Promise + selectPermissions: ( + groups: Array, + ) => Promise | null> + confirmWrite: (denyAll: boolean) => Promise +} + +export interface ClackPermissionRuntime { + cancel: typeof cancel + confirm: typeof confirm + groupMultiselect: typeof groupMultiselect + isCancel: typeof isCancel +} + +export interface PermissionSetupRuntime { + scan?: (root: string) => ScanResult + prompts: PermissionPrompts +} + +export type PermissionSetupResult = + | { status: 'canceled' } + | { status: 'unavailable' } + | { packageJsonPath: string; status: 'unchanged' | 'updated' } + +function selectorForPackage(pkg: IntentPackage): string { + return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name +} + +function permissionGroups( + packages: Array, + excludes: ReturnType, +): Array { + return packages.map((pkg) => { + const packageUnavailable = isPackageExcluded(pkg.name, excludes) + const packageSelector = selectorForPackage(pkg) + return { + label: packageSelector, + options: [ + { + label: 'All skills', + value: packageSelector, + hint: 'Current and future skills; exclusions still apply', + ...(packageUnavailable + ? { disabled: true, hint: 'Excluded by intent.exclude' } + : {}), + }, + ...[...pkg.skills] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((skill) => { + const skillUnavailable = + packageUnavailable || + isSkillExcluded(pkg.name, skill.name, excludes) + return { + label: skill.name, + value: `${packageSelector}#${skill.name}`, + hint: skill.description, + ...(skillUnavailable + ? { disabled: true, hint: 'Excluded by intent.exclude' } + : {}), + } + }), + ], + } + }) +} + +function normalizePermissions(selected: Array): Array { + const values = new Set(selected) + for (const value of selected) { + if (!value.includes('#')) continue + const packageSelector = value.slice(0, value.indexOf('#')) + if (values.has(packageSelector)) values.delete(value) + } + return [...values].sort((left, right) => left.localeCompare(right)) +} + +function clackResult( + value: T | symbol, + runtime: ClackPermissionRuntime, +): T | null { + if (!runtime.isCancel(value)) return value + runtime.cancel('Permissions: canceled.', { output: stdout }) + return null +} + +export function createPermissionPrompts( + runtime: ClackPermissionRuntime = { + cancel, + confirm, + groupMultiselect, + isCancel, + }, +): PermissionPrompts { + return { + confirmAllowAll: async () => + clackResult( + await runtime.confirm({ + message: 'Allow all current and future skill sources?', + initialValue: false, + input: stdin, + output: stdout, + }), + runtime, + ), + selectPermissions: async (groups) => + clackResult( + await runtime.groupMultiselect({ + message: 'Select trusted packages and skills', + // Clack's grouped picker does not enforce disabled options. + options: Object.fromEntries( + groups + .map((group) => ({ + ...group, + options: group.options.filter((option) => !option.disabled), + })) + .filter((group) => group.options.length > 0) + .map((group) => [group.label, group.options]), + ), + selectableGroups: false, + required: false, + input: stdin, + output: stdout, + }), + runtime, + ), + confirmWrite: async (denyAll) => + clackResult( + await runtime.confirm({ + message: denyAll + ? 'Disable all skills by writing intent.skills: []?' + : 'Write this permission configuration?', + initialValue: false, + input: stdin, + output: stdout, + }), + runtime, + ), + } +} + +export async function setupInitialPermissions({ + dryRun = false, + root, + runtime, +}: { + dryRun?: boolean + root: string + runtime: PermissionSetupRuntime +}): Promise { + const context = resolveProjectContext({ cwd: root }) + if (!context.targetPackageJsonPath) { + throw new Error( + 'Cannot configure permissions: no owning package.json was found.', + ) + } + + const scan = ( + runtime.scan ?? ((cwd) => scanForIntents(cwd, { scope: 'local' })) + )(root) + const packages = [...scan.packages].sort((left, right) => + selectorForPackage(left).localeCompare(selectorForPackage(right)), + ) + const excludes = compileExcludePatterns( + getEffectiveExcludePatterns({}, context), + ) + printWarnings(scan.warnings) + console.log('Discovered skills:') + for (const pkg of packages) { + console.log(` ${selectorForPackage(pkg)}@${pkg.version}`) + for (const skill of pkg.skills) { + const excluded = + isPackageExcluded(pkg.name, excludes) || + isSkillExcluded(pkg.name, skill.name, excludes) + console.log( + ` ${skill.name}: ${skill.description}${excluded ? ' (Excluded by intent.exclude; unavailable)' : ''}`, + ) + } + } + const availableSkillCount = packages.reduce( + (count, pkg) => + count + + pkg.skills.filter( + (skill) => + !isPackageExcluded(pkg.name, excludes) && + !isSkillExcluded(pkg.name, skill.name, excludes), + ).length, + 0, + ) + if (availableSkillCount === 0) { + const discoveredSkillCount = packages.reduce( + (count, pkg) => count + pkg.skills.length, + 0, + ) + console.log( + discoveredSkillCount === 0 + ? 'No intent-enabled skills found. Install a package that ships skills, then run intent install again.' + : 'All discovered skills are excluded by intent.exclude. Review your exclusions, then run intent install again.', + ) + console.log('Permissions and guidance unchanged.') + return { status: 'unavailable' } + } + console.log( + 'All skills includes current and future skills in that package. Exact choices permit only the named skill. Exclusions always apply.', + ) + const allowAll = await runtime.prompts.confirmAllowAll() + if (allowAll === null) return { status: 'canceled' } + const selected = allowAll + ? ['*'] + : await runtime.prompts.selectPermissions( + permissionGroups(packages, excludes), + ) + if (selected === null) return { status: 'canceled' } + const skills = allowAll ? ['*'] : normalizePermissions(selected) + const update = preparePackageSkillsUpdate( + context.targetPackageJsonPath, + skills, + ) + + console.log(`Permission destination: ${context.targetPackageJsonPath}`) + console.log(`intent.skills: ${JSON.stringify(skills, null, 2)}`) + if (skills.length === 0) { + console.log( + 'No skills selected. This disables all skill sources, including future sources, until you edit intent.skills.', + ) + } else { + console.log( + skills.length === 1 && skills[0] === '*' + ? 'Trust change: all current and future npm and workspace skill sources will be permitted.' + : 'Trust change: selected packages and skills can provide instructions to AI agents.', + ) + } + if (skills.length === 1 && skills[0] === '*') { + printNotices([ALLOW_ALL_NOTICE]) + } + + if (dryRun) { + return { + packageJsonPath: context.targetPackageJsonPath, + status: 'unchanged', + } + } + + const confirmation = await runtime.prompts.confirmWrite(skills.length === 0) + if (confirmation !== true) { + console.log('Permissions: canceled.') + return { status: 'canceled' } + } + + return { + packageJsonPath: context.targetPackageJsonPath, + status: writePreparedPackageSkillsUpdate(update), + } +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe47..e515af58 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -13,6 +13,7 @@ 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 { isMainModule, main } from '../src/cli.js' +import type { PermissionPrompts } from '../src/commands/install/permissions.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') @@ -65,6 +66,22 @@ function writeInstalledIntentPackage( }) } +function permissionPrompts({ + allowAll = false, + confirmWrite = true, + selection = [], +}: { + allowAll?: boolean | null + confirmWrite?: boolean | null + selection?: Array | null +} = {}): PermissionPrompts { + return { + confirmAllowAll: vi.fn(async () => allowAll), + selectPermissions: vi.fn(async () => selection), + confirmWrite: vi.fn(async () => confirmWrite), + } +} + let originalCwd: string let logSpy: ReturnType let infoSpy: ReturnType @@ -355,6 +372,11 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-install-empty-global-'), ) tempDirs.push(root, isolatedGlobalRoot) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -397,12 +419,275 @@ describe('cli commands', () => { expect(readFileSync(agentsPath, 'utf8')).toBe(content) }) + it('fails without writes when intent.skills is absent in non-TTY use', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-non-tty-')) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + const agentsPath = join(root, 'AGENTS.md') + const packageJson = '{\n "name": "app",\n "private": true\n}\n' + const guidance = '# Existing guidance\n' + writeFileSync(packageJsonPath, packageJson) + writeFileSync(agentsPath, guidance) + process.chdir(root) + + const exitCode = await main(['install']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Permissions: failed: intent.skills is not configured. Run `intent install` in an interactive terminal to review and confirm permissions.', + ) + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) + expect(readFileSync(agentsPath, 'utf8')).toBe(guidance) + }) + + it('selects, previews, and confirms package permissions before guidance', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-confirm-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.chdir(root) + const prompts = permissionPrompts({ selection: ['@tanstack/query'] }) + + const exitCode = await main(['install'], { + isTTY: true, + permissionPrompts: prompts, + }) + const packageJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { intent?: { skills?: Array } } + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(packageJson.intent?.skills).toEqual(['@tanstack/query']) + expect(output).toContain( + `Permission destination: ${join(root, 'package.json')}`, + ) + expect(output).toContain('intent.skills: [\n "@tanstack/query"\n]') + expect(output).toContain( + 'Trust change: selected packages and skills can provide instructions to AI agents.', + ) + expect(output).toContain('Permissions: updated package.json.') + expect(output).toContain('Guidance: created AGENTS.md.') + expect(output).toContain('Available: 1 skill from 1 package.') + expect(output).toContain('Next: npx @tanstack/intent@latest list') + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + '## Skill Loading', + ) + expect(prompts.confirmWrite).toHaveBeenCalledOnce() + }) + + it('does not write policy or guidance when all candidates are excluded', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-excluded-')) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + writeJson(packageJsonPath, { + name: 'app', + private: true, + intent: { exclude: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const packageJson = readFileSync(packageJsonPath, 'utf8') + process.chdir(root) + const prompts = permissionPrompts({ selection: [] }) + + const exitCode = await main(['install'], { + isTTY: true, + permissionPrompts: prompts, + }) + expect(exitCode).toBe(0) + expect(prompts.confirmAllowAll).not.toHaveBeenCalled() + expect(prompts.selectPermissions).not.toHaveBeenCalled() + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'All discovered skills are excluded by intent.exclude.', + ) + }) + + it('can retry first-run setup after installing a package with skills', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-retry-')) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + const source = '{"name":"app"}\n' + writeFileSync(packageJsonPath, source) + process.chdir(root) + const prompts = permissionPrompts({ selection: ['pkg#core'] }) + const runtime = { isTTY: true, permissionPrompts: prompts } + + expect(await main(['install'], runtime)).toBe(0) + expect(prompts.confirmAllowAll).not.toHaveBeenCalled() + expect(readFileSync(packageJsonPath, 'utf8')).toBe(source) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'No intent-enabled skills found. Install a package that ships skills, then run intent install again.', + ) + + writeInstalledIntentPackage(root, { + name: 'pkg', + version: '1.0.0', + skillName: 'core', + description: 'Core guidance', + }) + expect(await main(['install'], runtime)).toBe(0) + expect(prompts.selectPermissions).toHaveBeenCalledOnce() + expect( + JSON.parse(readFileSync(packageJsonPath, 'utf8')).intent.skills, + ).toEqual(['pkg#core']) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(true) + }) + + it('reports intentional deny-all without claiming skills are available', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-deny-all-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app' }) + writeInstalledIntentPackage(root, { + name: 'pkg', + version: '1.0.0', + skillName: 'core', + description: 'Core guidance', + }) + process.chdir(root) + const prompts = permissionPrompts({ selection: [] }) + + expect( + await main(['install'], { isTTY: true, permissionPrompts: prompts }), + ).toBe(0) + expect(prompts.confirmWrite).toHaveBeenCalledWith(true) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('Available: 0 skills from 0 packages.') + expect(output).toContain('To enable skills, edit intent.skills in') + expect(output).not.toContain('Next:') + }) + + it('keeps confirmed permissions and reports a later guidance failure separately', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-guidance-failure-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const agentsPath = join(root, 'AGENTS.md') + const guidance = '\ninvalid\n' + writeFileSync(agentsPath, guidance) + process.chdir(root) + const prompts = permissionPrompts({ selection: ['@tanstack/query'] }) + + const exitCode = await main(['install'], { + isTTY: true, + permissionPrompts: prompts, + }) + const pkg = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { + intent?: { skills?: Array } + } + const output = logSpy.mock.calls.flat().join('\n') + const errors = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(pkg.intent?.skills).toEqual(['@tanstack/query']) + expect(output).toContain('Permissions: updated package.json.') + expect(errors).toContain('Guidance: failed:') + expect(errors).toContain('Invalid intent-skills block in') + expect(readFileSync(agentsPath, 'utf8')).toBe(guidance) + }) + + it('reports package validation failure as a permission failure without writes', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-invalid-package-'), + ) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + const packageJson = '{ "name": "app", ' + writeFileSync(packageJsonPath, packageJson) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.chdir(root) + const prompts = permissionPrompts({ selection: ['@tanstack/query'] }) + + const exitCode = await main(['install'], { + isTTY: true, + permissionPrompts: prompts, + }) + const errors = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(errors).toContain('Permissions: failed:') + expect(errors).toContain('invalid JSONC') + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it.each([ + ['decline', permissionPrompts({ confirmWrite: false })], + ['allow-all cancel', permissionPrompts({ allowAll: null })], + ['selection cancel', permissionPrompts({ selection: null })], + ['confirmation cancel', permissionPrompts({ confirmWrite: null })], + ])( + 'leaves configuration and guidance unchanged on %s', + async (_label, prompts) => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-cancel-')) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + const agentsPath = join(root, 'AGENTS.md') + const packageJson = '{\n "name": "app"\n}\n' + const guidance = '# Existing guidance\n' + writeFileSync(packageJsonPath, packageJson) + writeFileSync(agentsPath, guidance) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.chdir(root) + const exitCode = await main(['install'], { + isTTY: true, + permissionPrompts: prompts, + }) + + expect(exitCode).toBe(0) + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) + expect(readFileSync(agentsPath, 'utf8')).toBe(guidance) + }, + ) + 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) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['*'] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/router', version: '1.0.0', @@ -425,11 +710,49 @@ describe('cli commands', () => { expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) + it('previews first-run permissions without writing during dry run', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-first-run-dry-'), + ) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + const packageJson = '{\n "name": "app"\n}\n' + writeFileSync(packageJsonPath, packageJson) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.chdir(root) + const prompts = permissionPrompts({ selection: ['@tanstack/query'] }) + + const exitCode = await main(['install', '--dry-run'], { + isTTY: true, + permissionPrompts: prompts, + }) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('intent.skills: [\n "@tanstack/query"\n]') + expect(output).toContain('Permissions: unchanged package.json (dry run).') + expect(output).toContain('Generated skill loading guidance for AGENTS.md.') + expect(prompts.selectPermissions).toHaveBeenCalledOnce() + expect(prompts.confirmWrite).not.toHaveBeenCalled() + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + it('prints package-manager-specific install guidance', async () => { const root = mkdtempSync( join(realTmpdir, 'intent-cli-install-package-runner-'), ) tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/router'] }, + }) writeFileSync(join(root, 'pnpm-lock.yaml'), '') process.chdir(root) @@ -450,6 +773,11 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-install-empty-global-'), ) tempDirs.push(root, isolatedGlobalRoot) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [] }, + }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot process.chdir(root) @@ -464,6 +792,36 @@ describe('cli commands', () => { ) }) + it('keeps inherited intent.skills on the guidance-only install path', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-inherited-')) + const packageRoot = join(root, 'packages', 'app') + tempDirs.push(root) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + intent: { skills: ['*'] }, + }) + writeJson(join(packageRoot, 'package.json'), { + name: '@scope/app', + private: true, + }) + const packageJsonPath = join(packageRoot, 'package.json') + const packageJson = readFileSync(packageJsonPath, 'utf8') + process.chdir(packageRoot) + + const exitCode = await main(['install']) + + expect(exitCode).toBe(0) + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) + expect(readFileSync(join(packageRoot, 'AGENTS.md'), 'utf8')).toContain( + '## Skill Loading', + ) + }) + it('installs hooks with the hooks install command', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) tempDirs.push(root) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 67112f5e..670d5975 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from 'node:fs' @@ -15,6 +16,10 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../src/commands/install/guidance.js' +import { + preparePackageSkillsUpdate, + writePreparedPackageSkillsUpdate, +} from '../src/commands/install/package-json.js' import type { IntentPackage, ScanResult, @@ -688,3 +693,85 @@ tanstackIntent: ) }) }) + +describe('package.json permission writer', () => { + it('preserves JSONC formatting, exclusions, and unrelated fields', () => { + const root = tempRoot() + const targetPath = join(root, 'package.json') + writeFileSync( + targetPath, + '{\r\n\t// retained\r\n\t"name": "app",\r\n\t"intent": {\r\n\t\t"exclude": ["@scope/private"]\r\n\t},\r\n\t"custom": { "kept": true }\r\n}\r\n', + ) + + const update = preparePackageSkillsUpdate(targetPath, [ + '@scope/npm#core', + 'workspace:@scope/local', + ]) + const status = writePreparedPackageSkillsUpdate(update) + const content = readFileSync(targetPath, 'utf8') + + expect(status).toBe('updated') + expect(content).toContain('\r\n\t// retained\r\n') + expect(content).toContain( + '\t\t"exclude": [\r\n\t\t\t"@scope/private"\r\n\t\t]', + ) + expect(content).toContain('\t"custom": { "kept": true }') + expect(content).toContain( + '\t\t"skills": [\r\n\t\t\t"@scope/npm#core",\r\n\t\t\t"workspace:@scope/local"\r\n\t\t]', + ) + expect(content.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('rejects source-byte drift before replacement', () => { + const root = tempRoot() + const targetPath = join(root, 'package.json') + const source = '{\n "name": "app"\n}\n' + const changed = '{\n "name": "app",\n "changed": true\n}\n' + writeFileSync(targetPath, source) + const update = preparePackageSkillsUpdate(targetPath, ['@scope/npm']) + + expect(() => + writePreparedPackageSkillsUpdate(update, { + beforeReplace: () => writeFileSync(targetPath, changed), + }), + ).toThrow('package.json changed after permission review') + expect(readFileSync(targetPath, 'utf8')).toBe(changed) + expect(readdirSync(root)).toEqual(['package.json']) + }) + + it('rejects parse and validation failures without writing', () => { + const root = tempRoot() + const targetPath = join(root, 'package.json') + const invalidJson = '{ "name": "app", ' + writeFileSync(targetPath, invalidJson) + expect(() => preparePackageSkillsUpdate(targetPath, [])).toThrow( + 'invalid JSONC', + ) + expect(readFileSync(targetPath, 'utf8')).toBe(invalidJson) + + const invalidIntent = '{\n "name": "app",\n "intent": []\n}\n' + writeFileSync(targetPath, invalidIntent) + expect(() => preparePackageSkillsUpdate(targetPath, [])).toThrow( + 'intent must contain an object', + ) + expect(readFileSync(targetPath, 'utf8')).toBe(invalidIntent) + }) + + it('preserves the original file when atomic replacement fails', () => { + const root = tempRoot() + const targetPath = join(root, 'package.json') + const source = '{\n "name": "app"\n}\n' + writeFileSync(targetPath, source) + const update = preparePackageSkillsUpdate(targetPath, ['@scope/npm']) + + expect(() => + writePreparedPackageSkillsUpdate(update, { + rename: () => { + throw new Error('replacement failed') + }, + }), + ).toThrow('replacement failed') + expect(readFileSync(targetPath, 'utf8')).toBe(source) + expect(readdirSync(root)).toEqual(['package.json']) + }) +}) diff --git a/packages/intent/tests/permissions.test.ts b/packages/intent/tests/permissions.test.ts new file mode 100644 index 00000000..6b7350ae --- /dev/null +++ b/packages/intent/tests/permissions.test.ts @@ -0,0 +1,458 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { cancel, confirm, groupMultiselect, isCancel } from '@clack/prompts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createPermissionPrompts, + setupInitialPermissions, +} from '../src/commands/install/permissions.js' +import { ALLOW_ALL_NOTICE } from '../src/shared/cli-output.js' +import type { + ClackPermissionRuntime, + PermissionPromptGroup, + PermissionPrompts, +} from '../src/commands/install/permissions.js' +import type { IntentPackage, ScanResult } from '../src/shared/types.js' + +const tempDirs: Array = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function packageCandidate( + name: string, + kind: IntentPackage['kind'], + skills: Array, +): IntentPackage { + return { + name, + version: '1.0.0', + intent: { version: 1, repo: `test/${name}`, docs: 'docs/' }, + skills: skills.map((skill) => ({ + name: skill, + path: `node_modules/${name}/skills/${skill}/SKILL.md`, + description: `${skill} guidance`, + })), + packageRoot: `node_modules/${name}`, + kind, + source: 'local', + } +} + +function scan(packages: Array): ScanResult { + return { + packageManager: 'pnpm', + packages, + warnings: [], + notices: [], + conflicts: [], + nodeModules: { + local: { + path: 'node_modules', + detected: true, + exists: true, + scanned: true, + }, + global: { path: null, detected: false, exists: false, scanned: false }, + }, + stats: { packageJsonCacheHits: 0, packageJsonReadCount: 0 }, + } +} + +function prompts({ + allowAll = false, + confirmWrite = true, + selection = [], +}: { + allowAll?: boolean | null + confirmWrite?: boolean | null + selection?: Array | null +} = {}): PermissionPrompts & { groups: Array } { + const result = { + groups: [] as Array, + confirmAllowAll: vi.fn(async () => allowAll), + selectPermissions: vi.fn(async (groups: Array) => { + result.groups = groups + return selection + }), + confirmWrite: vi.fn(async () => confirmWrite), + } + return result +} + +async function configure({ + dryRun = false, + exclude = [], + packages = [ + packageCandidate('@scope/npm', 'npm', ['core', 'advanced']), + packageCandidate('@scope/workspace', 'workspace', ['routing']), + ], + permissionPrompts = prompts(), +}: { + dryRun?: boolean + exclude?: Array + packages?: Array + permissionPrompts?: PermissionPrompts & { + groups?: Array + } +} = {}): Promise<{ + packageJsonPath: string + permissionPrompts: PermissionPrompts & { + groups?: Array + } + result: Awaited> +}> { + const root = mkdtempSync(join(tmpdir(), 'intent-permissions-')) + tempDirs.push(root) + const packageJsonPath = join(root, 'package.json') + writeFileSync( + packageJsonPath, + `${JSON.stringify({ name: 'app', intent: { exclude } }, null, 2)}\n`, + ) + + const result = await setupInitialPermissions({ + dryRun, + root, + runtime: { + prompts: permissionPrompts, + scan: () => scan(packages), + }, + }) + + return { packageJsonPath, permissionPrompts, result } +} + +function configuredSkills(packageJsonPath: string): Array | undefined { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + intent?: { skills?: Array } + } + return pkg.intent?.skills +} + +describe('interactive permission selection', () => { + it('shows discovered descriptions and versions before asking about allow-all', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + const permissionPrompts = prompts({ allowAll: null }) + vi.mocked(permissionPrompts.confirmAllowAll).mockImplementation(() => { + const text = output.mock.calls.flat().join('\n') + expect(text).toContain('@scope/npm@1.0.0') + expect(text).toContain('core guidance') + expect(text).toContain('All skills includes current and future skills') + return Promise.resolve(null) + }) + try { + await configure({ permissionPrompts }) + } finally { + output.mockRestore() + } + }) + + it('explicitly confirms disabling all skills after an empty selection', async () => { + const permissionPrompts = prompts({ selection: [] }) + await configure({ permissionPrompts }) + expect(permissionPrompts.confirmWrite).toHaveBeenCalledWith(true) + }) + + it.each([ + ['no discovered skills', [], []], + [ + 'all skills excluded', + [packageCandidate('pkg', 'npm', ['blocked'])], + ['pkg#blocked'], + ], + ])( + 'leaves first-run setup available with %s', + async (_label, packages, exclude) => { + const permissionPrompts = prompts() + const { packageJsonPath, result } = await configure({ + packages, + exclude, + permissionPrompts, + }) + + expect(result).toEqual({ status: 'unavailable' }) + expect(configuredSkills(packageJsonPath)).toBeUndefined() + expect(permissionPrompts.confirmAllowAll).not.toHaveBeenCalled() + expect(permissionPrompts.selectPermissions).not.toHaveBeenCalled() + expect(permissionPrompts.confirmWrite).not.toHaveBeenCalled() + }, + ) + + it.each([ + ['deny all', [], []], + ['exact only', ['@scope/npm#core'], ['@scope/npm#core']], + [ + 'package plus child', + ['@scope/npm#advanced', '@scope/npm', '@scope/npm#core'], + ['@scope/npm'], + ], + [ + 'npm and workspace selectors', + ['workspace:@scope/workspace#routing', '@scope/npm#advanced'], + ['@scope/npm#advanced', 'workspace:@scope/workspace#routing'], + ], + ])('normalizes %s selection', async (_label, selection, expected) => { + const { packageJsonPath } = await configure({ + permissionPrompts: prompts({ selection }), + }) + + expect(configuredSkills(packageJsonPath)).toEqual(expected) + }) + + it('keeps allow-all separate from grouped selections', async () => { + const permissionPrompts = prompts({ + allowAll: true, + selection: ['@scope/npm#core'], + }) + const { packageJsonPath } = await configure({ permissionPrompts }) + + expect(configuredSkills(packageJsonPath)).toEqual(['*']) + expect(permissionPrompts.selectPermissions).not.toHaveBeenCalled() + }) + + it('prints the allow-all notice before final confirmation', async () => { + const events: Array = [] + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation((message) => { + events.push(String(message)) + }) + const permissionPrompts = prompts({ allowAll: true }) + vi.mocked(permissionPrompts.confirmWrite).mockImplementation(() => { + events.push('confirm-write') + return Promise.resolve(false) + }) + + try { + await configure({ permissionPrompts }) + } finally { + errorSpy.mockRestore() + } + + expect(events).toContain(` ℹ ${ALLOW_ALL_NOTICE}`) + expect(events.indexOf(` ℹ ${ALLOW_ALL_NOTICE}`)).toBeLessThan( + events.indexOf('confirm-write'), + ) + }) + + it('prints the allow-all notice during dry-run', async () => { + const errors: Array = [] + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation((message) => { + errors.push(String(message)) + }) + + try { + await configure({ + dryRun: true, + permissionPrompts: prompts({ allowAll: true }), + }) + } finally { + errorSpy.mockRestore() + } + + expect(errors).toContain(` ℹ ${ALLOW_ALL_NOTICE}`) + }) + + it('constructs package groups with package-wide, exact, and disabled exclusion options', async () => { + const permissionPrompts = prompts({ selection: [] }) + await configure({ + exclude: ['@scope/npm#advanced', '@scope/workspace'], + permissionPrompts, + }) + + expect(permissionPrompts.groups).toEqual([ + { + label: '@scope/npm', + options: [ + { + label: 'All skills', + value: '@scope/npm', + hint: 'Current and future skills; exclusions still apply', + }, + { + label: 'advanced', + value: '@scope/npm#advanced', + disabled: true, + hint: 'Excluded by intent.exclude', + }, + { label: 'core', value: '@scope/npm#core', hint: 'core guidance' }, + ], + }, + { + label: 'workspace:@scope/workspace', + options: [ + { + label: 'All skills', + value: 'workspace:@scope/workspace', + disabled: true, + hint: 'Excluded by intent.exclude', + }, + { + label: 'routing', + value: 'workspace:@scope/workspace#routing', + disabled: true, + hint: 'Excluded by intent.exclude', + }, + ], + }, + ]) + }) + + it.each([ + ['allow-all prompt', prompts({ allowAll: null })], + ['grouped selection', prompts({ selection: null })], + ['write confirmation', prompts({ selection: [], confirmWrite: null })], + ['declined write', prompts({ selection: [], confirmWrite: false })], + ])( + 'cancels once without writing at the %s', + async (_label, permissionPrompts) => { + const { packageJsonPath, result } = await configure({ permissionPrompts }) + + expect(result).toEqual({ status: 'canceled' }) + expect(configuredSkills(packageJsonPath)).toBeUndefined() + }, + ) + + it('does not ask for final confirmation or write during dry-run', async () => { + const permissionPrompts = prompts({ selection: ['@scope/npm#core'] }) + const { packageJsonPath, result } = await configure({ + dryRun: true, + permissionPrompts, + }) + + expect(result).toEqual({ packageJsonPath, status: 'unchanged' }) + expect(permissionPrompts.confirmWrite).not.toHaveBeenCalled() + expect(configuredSkills(packageJsonPath)).toBeUndefined() + }) +}) + +describe('Clack permission adapter', () => { + it('cannot select an excluded skill through the real grouped picker', async () => { + const input = new PassThrough() + const output = new PassThrough() + output.resume() + const permissionPrompts = createPermissionPrompts({ + cancel, + confirm, + isCancel, + groupMultiselect: (options) => { + const result = groupMultiselect({ ...options, input, output }) + process.nextTick(() => input.write(' \r')) + return result + }, + }) + + try { + await expect( + permissionPrompts.selectPermissions([ + { + label: 'excluded-package', + options: [ + { + label: 'All skills', + value: 'excluded-package', + disabled: true, + }, + ], + }, + { + label: 'pkg', + options: [ + { label: 'blocked', value: 'pkg#blocked', disabled: true }, + { label: 'allowed', value: 'pkg#allowed' }, + ], + }, + ]), + ).resolves.toEqual(['pkg#allowed']) + } finally { + input.destroy() + output.destroy() + } + }) + + it('maps grouped options and prompt defaults to Clack', async () => { + const runtime = { + cancel: vi.fn(), + confirm: vi.fn(async () => false), + groupMultiselect: vi.fn(async () => ['pkg#core']), + isCancel: vi.fn(() => false), + } as unknown as ClackPermissionRuntime + const permissionPrompts = createPermissionPrompts(runtime) + const groups: Array = [ + { + label: 'pkg', + options: [ + { label: 'All skills', value: 'pkg' }, + { + label: 'private', + value: 'pkg#private', + disabled: true, + hint: 'Excluded by intent.exclude', + }, + ], + }, + ] + + await expect(permissionPrompts.confirmAllowAll()).resolves.toBe(false) + await expect(permissionPrompts.selectPermissions(groups)).resolves.toEqual([ + 'pkg#core', + ]) + await expect(permissionPrompts.confirmWrite(false)).resolves.toBe(false) + + expect(runtime.confirm).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: 'Allow all current and future skill sources?', + initialValue: false, + }), + ) + expect(runtime.groupMultiselect).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Select trusted packages and skills', + options: { pkg: [{ label: 'All skills', value: 'pkg' }] }, + required: false, + selectableGroups: false, + }), + ) + expect(runtime.confirm).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: 'Write this permission configuration?', + initialValue: false, + }), + ) + await expect(permissionPrompts.confirmWrite(true)).resolves.toBe(false) + expect(runtime.confirm).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + message: 'Disable all skills by writing intent.skills: []?', + initialValue: false, + }), + ) + }) + + it('maps a Clack cancel symbol to one cancellation message', async () => { + const canceled = Symbol('cancel') + const runtime = { + cancel: vi.fn(), + confirm: vi.fn(async () => canceled), + groupMultiselect: vi.fn(), + isCancel: vi.fn((value) => value === canceled), + } as unknown as ClackPermissionRuntime + const permissionPrompts = createPermissionPrompts(runtime) + + await expect(permissionPrompts.confirmAllowAll()).resolves.toBeNull() + expect(runtime.cancel).toHaveBeenCalledOnce() + expect(runtime.cancel).toHaveBeenCalledWith( + 'Permissions: canceled.', + expect.objectContaining({ output: process.stdout }), + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 906a5eea..46f9503c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: packages/intent: dependencies: + '@clack/prompts': + specifier: 1.7.0 + version: 1.7.0 cac: specifier: ^6.7.14 version: 6.7.14 @@ -109,6 +112,14 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@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'} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -2110,9 +2121,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.6: resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -3314,6 +3334,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3885,6 +3908,18 @@ snapshots: '@babel/runtime@7.29.7': {} + '@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 + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -5867,8 +5902,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.6: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -7146,6 +7191,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + sisteransi@1.0.5: {} + siginfo@2.0.0: {} signal-exit@3.0.7: {}