From 776c0ade28d2a96d04d776225f03432c77254885 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 4 Sep 2026 18:57:36 -0700 Subject: [PATCH 1/3] fix(intent): make first-run permission selection compact --- .changeset/fair-tools-review.md | 2 +- docs/cli/intent-install.md | 10 +- docs/concepts/configuration.md | 6 +- docs/concepts/trust-model.md | 4 +- docs/getting-started/quick-start-consumers.md | 12 +- .../intent/src/commands/install/command.ts | 9 +- .../commands/install/permission-prompts.ts | 267 +++++++++++++++ .../src/commands/install/permissions.ts | 198 +++-------- packages/intent/tests/cli.test.ts | 19 +- .../intent/tests/permission-prompts.test.ts | 312 ++++++++++++++++++ packages/intent/tests/permissions.test.ts | 256 ++++---------- 11 files changed, 723 insertions(+), 372 deletions(-) create mode 100644 packages/intent/src/commands/install/permission-prompts.ts create mode 100644 packages/intent/tests/permission-prompts.test.ts diff --git a/.changeset/fair-tools-review.md b/.changeset/fair-tools-review.md index 02d70dc..fdbe889 100644 --- a/.changeset/fair-tools-review.md +++ b/.changeset/fair-tools-review.md @@ -2,4 +2,4 @@ '@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. +Add interactive first-run skill permission setup to `intent install`. Choose packages from a compact searchable picker, review package-wide or individual skill permissions, and inspect descriptions and exclusions on demand. 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 0b64d6d..350c8a6 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -33,10 +33,10 @@ Otherwise, first-run setup requires an interactive terminal. Non-TTY execution f #### 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`. +1. **Choose packages** from a searchable list. Type to filter, use arrow keys to move, Tab to toggle, and Enter to review. Nothing is selected by default. +2. **Review** your choices, with selected packages first. Type to find a package, then press Enter to choose individual skills, remove it, or inspect descriptions and exclusions. Full descriptions appear only when requested. +3. **Confirm** the permission summary and destination file. **Show exact configuration** previews `intent.skills`; **Continue to confirmation** asks before saving and defaults to No. +4. **Finish** with verified guidance, available skill and package counts, and a command to list those skills. #### Permission choices @@ -44,7 +44,7 @@ Otherwise, first-run setup requires an interactive terminal. Non-TTY execution f | --- | --- | | 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. | +| Advanced: allow all current and future sources | Separately confirms writing `["*"]`, replacing narrower choices. | | Select nothing | Explicitly confirms writing `[]`, disabling current and future sources until `intent.skills` is edited. | Existing `intent.exclude` rules always apply and remain unchanged. diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 6d4d123..0cbcc77 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -91,11 +91,11 @@ Run `intent list` to see which packages the current policy surfaces. | 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. +First-run setup discovers candidates before policy filtering and opens a scrolling package picker. Review shows selected packages first, with search and optional skill details. It names the nearest owning `package.json` and requires confirmation before saving permissions and installing guidance. Choose **Show exact configuration** to inspect the allowlist. -- **Excluded candidates** stay visible in the overview but cannot be selected. Exclusions remain unchanged. +- **Excluded candidates** can be inspected from package review 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. +- **Allow-all** is a separate advanced choice in review and saves `["*"]` alone after confirmation. - **An empty selection** explicitly confirms disabling all skills with `[]`. - **Empty or fully excluded discovery** writes nothing, so setup can be retried. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 2eeb09c..380fa55 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -33,9 +33,9 @@ This also applies to inherited policy within a resolved workspace. Malformed JSO 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. +1. **Discover:** summarize npm and workspace skill counts, then show a scrolling package picker. Versions appear beside focused packages; descriptions and exclusions are available through inspection. 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. +3. **Review:** show selected packages first, allow permission changes, and offer the exact `intent.skills` configuration on demand. Name the destination file before saving. 4. **Confirm:** replace `package.json` atomically only after affirmative confirmation, then install guidance. | Outcome | Files changed | diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index cfb0123..a962011 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -13,15 +13,11 @@ Run this in an interactive terminal. On first use, Intent helps you choose which 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. **Choose packages.** Type to filter, use arrow keys to move, Tab to toggle, and Enter to review. The list scrolls; nothing is selected by default. +2. **Review permissions.** Type to find a package and press Enter to inspect its skills or edit its permissions. Descriptions and exclusions are available on demand. +3. **Confirm and finish.** Review the destination `package.json`, then confirm saving. Intent writes permissions and guidance, verifies the guidance, and shows a command to list available skills. -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 a package permits its current and future skills. Choose **individual skills** from package review to permit only those names. Excluded skills cannot be selected. **Show exact configuration** previews the saved value; allowing all sources is a separate advanced choice. 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. diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 985c981..cad4def 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -18,10 +18,8 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from './guidance.js' -import { - createPermissionPrompts, - setupInitialPermissions, -} from './permissions.js' +import { setupInitialPermissions } from './permissions.js' +import { createPermissionPrompts } from './permission-prompts.js' import type { GlobalScanFlags } from '../support.js' import type { IntentCoreOptions } from '../../core/index.js' import type { ScanResult } from '../../shared/types.js' @@ -302,9 +300,10 @@ export async function runInstallCommand( ) } - printWriteResult(result) if (permissions) { console.log(`Guidance: ${result.status} ${target}.`) + } else { + printWriteResult(result) } printPlacementTip(result.targetPath) if (available && permissions) { diff --git a/packages/intent/src/commands/install/permission-prompts.ts b/packages/intent/src/commands/install/permission-prompts.ts new file mode 100644 index 0000000..8bbff0a --- /dev/null +++ b/packages/intent/src/commands/install/permission-prompts.ts @@ -0,0 +1,267 @@ +import { stdin, stdout } from 'node:process' +import { stripVTControlCharacters } from 'node:util' +import { + autocomplete, + autocompleteMultiselect, + cancel, + confirm, + isCancel, + select, +} from '@clack/prompts' +import type { PermissionPackage, PermissionPrompts } from './permissions.js' + +export interface ClackPermissionRuntime { + autocomplete: typeof autocomplete + autocompleteMultiselect: typeof autocompleteMultiselect + cancel: typeof cancel + confirm: typeof confirm + isCancel: typeof isCancel + select: typeof select +} + +function descriptionHint(description: string): string { + const text = stripVTControlCharacters(description).replace(/\s+/g, ' ').trim() + return text.length > 120 ? `${text.slice(0, 117)}...` : text +} + +export function createPermissionPrompts( + runtime: ClackPermissionRuntime = { + autocomplete, + autocompleteMultiselect, + cancel, + confirm, + isCancel, + select, + }, +): PermissionPrompts { + const io = { input: stdin, output: stdout } + // Let Clack own search, scrolling, terminal sizing, and keyboard controls. + const picker = { ...io, maxItems: 6 } + const searchablePicker = { + ...picker, + validate: (value: unknown) => + value === undefined + ? 'Choose a listed option or change your search.' + : undefined, + } + const canceled = (value: unknown): value is symbol => { + if (!runtime.isCancel(value)) return false + runtime.cancel('Permissions: canceled.', { output: stdout }) + return true + } + + return { + selectPermissions: async (packages, packageJsonPath) => { + let selected: Array = [] + const available = (pkg: PermissionPackage) => + pkg.skills.filter((skill) => !skill.excluded) + const selectionLabel = (pkg: PermissionPackage): string => { + if (selected.includes(pkg.id)) return 'all current and future skills' + const count = pkg.skills.filter((skill) => + selected.includes(skill.id), + ).length + return count > 0 + ? `${count} individual skill${count === 1 ? '' : 's'} selected` + : 'none selected' + } + const replaceSelection = ( + pkg: PermissionPackage, + values: Array, + ) => { + selected = selected.filter( + (value) => value !== pkg.id && !value.startsWith(`${pkg.id}#`), + ) + selected.push(...values) + } + + const choosePackages = async (): Promise => { + const result = await runtime.autocompleteMultiselect({ + ...picker, + message: 'Choose packages — allows their current and future skills', + options: packages + .filter((pkg) => available(pkg).length > 0) + .map((pkg) => ({ + value: pkg.id, + label: `${pkg.id} (${available(pkg).length} skill${available(pkg).length === 1 ? '' : 's'})`, + hint: pkg.version, + })), + initialValues: selected.filter((value) => !value.includes('#')), + required: false, + }) + if (canceled(result)) return false + // Revisiting package choices must not discard existing exact selections. + const packageIds = result + selected = selected.filter( + (value) => + value.includes('#') && + !packageIds.some((id) => value.startsWith(`${id}#`)), + ) + selected.push(...packageIds) + return true + } + + if (!(await choosePackages())) return null + + for (;;) { + const selectedPackages = packages.filter((pkg) => + selected.includes(pkg.id), + ).length + const selectedSkills = selected.filter((value) => + value.includes('#'), + ).length + const action = await runtime.autocomplete({ + ...searchablePicker, + message: `Review permissions — packages: ${selectedPackages}, individual skills: ${selectedSkills}`, + options: [ + { + value: 'continue', + label: 'Continue to confirmation', + hint: 'Nothing is saved yet', + }, + ...[...packages] + .sort((left, right) => { + const hasSelection = (pkg: PermissionPackage) => + selected.some( + (value) => + value === pkg.id || value.startsWith(`${pkg.id}#`), + ) + return Number(hasSelection(right)) - Number(hasSelection(left)) + }) + .map((pkg) => ({ + value: pkg, + label: `${pkg.id} — ${selectionLabel(pkg)}`, + hint: `${pkg.version}; ${available(pkg).length} available, ${pkg.skills.length - available(pkg).length} excluded. Enter to inspect or edit.`, + })), + { value: 'packages', label: 'Change package selections' }, + { value: 'config', label: 'Show exact configuration' }, + { + value: 'all', + label: 'Advanced: allow all current and future sources', + }, + ], + }) + if (canceled(action)) return null + if (action === 'continue') return selected + if (action === 'packages') { + if (!(await choosePackages())) return null + continue + } + if (action === 'config') { + console.log(`Permission destination: ${packageJsonPath}`) + console.log( + `intent.skills: ${JSON.stringify([...selected].sort(), null, 2)}`, + ) + continue + } + if (action === 'all') { + const confirmed = await runtime.confirm({ + ...io, + message: + 'Allow all current and future npm and workspace skill sources?', + initialValue: false, + }) + if (canceled(confirmed)) return null + if (confirmed === true) return ['*'] + continue + } + + if (typeof action === 'string') continue + const pkg = action + let editing = true + while (editing) { + const choice = await runtime.select({ + ...picker, + message: `${pkg.id} — ${selectionLabel(pkg)}`, + options: [ + { value: 'back', label: 'Back to review' }, + { + value: 'skills', + label: 'Choose individual skills', + hint: 'Only these skills; future additions are not included', + disabled: available(pkg).length === 0, + }, + { + value: 'all', + label: 'Allow all current and future skills in this package', + disabled: available(pkg).length === 0, + }, + { + value: 'none', + label: 'Remove this package from the selection', + }, + { + value: 'details', + label: 'Inspect skill descriptions and exclusions', + hint: 'Viewing details does not change permissions', + }, + ], + }) + if (canceled(choice)) return null + if (choice === 'back') break + if (choice === 'all' || choice === 'none') { + replaceSelection(pkg, choice === 'all' ? [pkg.id] : []) + break + } + if (choice === 'skills') { + const skills = available(pkg) + const result = await runtime.autocompleteMultiselect({ + ...picker, + message: `${pkg.id} — choose individual skills`, + options: skills.map((skill) => ({ + value: skill.id, + label: skill.name, + hint: descriptionHint(skill.description), + })), + initialValues: selected.includes(pkg.id) + ? skills.map((skill) => skill.id) + : skills + .filter((skill) => selected.includes(skill.id)) + .map((skill) => skill.id), + required: false, + }) + if (canceled(result)) return null + replaceSelection(pkg, result) + editing = false + } + if (choice === 'details') { + for (;;) { + const skill = await runtime.autocomplete< + PermissionPackage['skills'][number] | 'back' + >({ + ...searchablePicker, + message: `${pkg.id} — inspect a skill`, + options: [ + { value: 'back', label: 'Back to package' }, + ...pkg.skills.map((entry) => ({ + value: entry, + label: `${entry.name}${entry.excluded ? ' (excluded)' : ''}`, + hint: entry.excluded + ? 'Unavailable because of intent.exclude' + : descriptionHint(entry.description), + })), + ], + }) + if (canceled(skill)) return null + if (skill === 'back') break + const entry = skill + console.log( + `\n${entry.id}${entry.excluded ? ' — excluded by intent.exclude' : ''}`, + ) + console.log(stripVTControlCharacters(entry.description)) + } + } + } + } + }, + confirmWrite: async (denyAll) => { + const result = await runtime.confirm({ + ...io, + message: denyAll + ? 'Disable all skills by writing intent.skills: []?' + : 'Write this permission configuration?', + initialValue: false, + }) + return canceled(result) ? null : result + }, + } +} diff --git a/packages/intent/src/commands/install/permissions.ts b/packages/intent/src/commands/install/permissions.ts index fc98a60..3e68d2b 100644 --- a/packages/intent/src/commands/install/permissions.ts +++ b/packages/intent/src/commands/install/permissions.ts @@ -1,5 +1,3 @@ -import { stdin, stdout } from 'node:process' -import { cancel, confirm, groupMultiselect, isCancel } from '@clack/prompts' import { compileExcludePatterns, getEffectiveExcludePatterns, @@ -21,33 +19,25 @@ import { } 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 PermissionPackage { + id: string + version: string + skills: Array<{ + id: string + name: string + description: string + excluded: boolean + }> } export interface PermissionPrompts { - confirmAllowAll: () => Promise selectPermissions: ( - groups: Array, + packages: Array, + packageJsonPath: string, ) => 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 @@ -62,40 +52,25 @@ function selectorForPackage(pkg: IntentPackage): string { return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name } -function permissionGroups( +function permissionPackages( packages: Array, excludes: ReturnType, -): Array { +): Array { return packages.map((pkg) => { - const packageUnavailable = isPackageExcluded(pkg.name, excludes) - const packageSelector = selectorForPackage(pkg) + const id = 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' } - : {}), - } - }), - ], + id, + version: pkg.version, + skills: [...pkg.skills] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((skill) => ({ + id: `${id}#${skill.name}`, + name: skill.name, + description: skill.description, + excluded: + isPackageExcluded(pkg.name, excludes) || + isSkillExcluded(pkg.name, skill.name, excludes), + })), } }) } @@ -110,70 +85,6 @@ function normalizePermissions(selected: Array): Array { 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, @@ -200,60 +111,41 @@ export async function setupInitialPermissions({ 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, + const candidates = permissionPackages(packages, excludes) + const discoveredSkills = candidates.flatMap((pkg) => pkg.skills) + const availableSkillCount = discoveredSkills.filter( + (skill) => !skill.excluded, + ).length + console.log( + `Found ${discoveredSkills.length} skill${discoveredSkills.length === 1 ? '' : 's'} in ${packages.length} package${packages.length === 1 ? '' : 's'}.`, ) if (availableSkillCount === 0) { - const discoveredSkillCount = packages.reduce( - (count, pkg) => count + pkg.skills.length, - 0, - ) console.log( - discoveredSkillCount === 0 + discoveredSkills.length === 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 excludedCount = discoveredSkills.length - availableSkillCount + if (excludedCount > 0) { + console.log( + `${excludedCount} excluded skills are unavailable. Inspect a package to view them.`, + ) + } + const selected = await runtime.prompts.selectPermissions( + candidates, + context.targetPackageJsonPath, ) - 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 skills = 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.', @@ -262,7 +154,7 @@ export async function setupInitialPermissions({ 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.', + : `Trust change: package-wide permissions: ${skills.filter((skill) => !skill.includes('#')).length}; individual skills: ${skills.filter((skill) => skill.includes('#')).length}. These sources can provide instructions to AI agents.`, ) } if (skills.length === 1 && skills[0] === '*') { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 2521765..1e01d22 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -67,16 +67,13 @@ 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), } @@ -470,12 +467,14 @@ describe('cli commands', () => { 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.', + 'Trust change: package-wide permissions: 1; individual skills: 0. These sources can provide instructions to AI agents.', ) expect(output).toContain('Permissions: updated package.json.') expect(output).toContain('Guidance: created AGENTS.md.') + expect(output).not.toContain( + 'Created AGENTS.md with skill loading guidance.', + ) 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( @@ -508,7 +507,6 @@ describe('cli commands', () => { 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) @@ -528,7 +526,7 @@ describe('cli commands', () => { const runtime = { isTTY: true, permissionPrompts: prompts } expect(await main(['install'], runtime)).toBe(0) - expect(prompts.confirmAllowAll).not.toHaveBeenCalled() + expect(prompts.selectPermissions).not.toHaveBeenCalled() expect(readFileSync(packageJsonPath, 'utf8')).toBe(source) expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) expect(logSpy.mock.calls.flat().join('\n')).toContain( @@ -640,7 +638,7 @@ describe('cli commands', () => { expect(errors).toContain( `Failed to parse Intent policy from ${packageJsonPath}: invalid JSON.`, ) - expect(prompts.confirmAllowAll).not.toHaveBeenCalled() + expect(prompts.selectPermissions).not.toHaveBeenCalled() expect(logSpy).not.toHaveBeenCalled() expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson) expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) @@ -648,7 +646,6 @@ describe('cli commands', () => { it.each([ ['decline', permissionPrompts({ confirmWrite: false })], - ['allow-all cancel', permissionPrompts({ allowAll: null })], ['selection cancel', permissionPrompts({ selection: null })], ['confirmation cancel', permissionPrompts({ confirmWrite: null })], ])( @@ -737,7 +734,9 @@ describe('cli commands', () => { const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(output).toContain('intent.skills: [\n "@tanstack/query"\n]') + expect(output).toContain( + 'Trust change: package-wide permissions: 1; individual skills: 0. These sources can provide instructions to AI agents.', + ) expect(output).toContain('Permissions: unchanged package.json (dry run).') expect(output).toContain('Generated skill loading guidance for AGENTS.md.') expect(prompts.selectPermissions).toHaveBeenCalledOnce() diff --git a/packages/intent/tests/permission-prompts.test.ts b/packages/intent/tests/permission-prompts.test.ts new file mode 100644 index 0000000..3b49694 --- /dev/null +++ b/packages/intent/tests/permission-prompts.test.ts @@ -0,0 +1,312 @@ +import { PassThrough } from 'node:stream' +import { stripVTControlCharacters } from 'node:util' +import * as clack from '@clack/prompts' +import { describe, expect, it, vi } from 'vitest' +import { createPermissionPrompts } from '../src/commands/install/permission-prompts.js' +import type { ClackPermissionRuntime } from '../src/commands/install/permission-prompts.js' +import type { PermissionPackage } from '../src/commands/install/permissions.js' + +const pkg: PermissionPackage = { + id: 'pkg', + version: '1.0.0', + skills: [ + { + id: 'pkg#core', + name: 'core', + description: 'Core guidance. '.repeat(50), + excluded: false, + }, + { + id: 'pkg#other', + name: 'other', + description: 'Other guidance', + excluded: false, + }, + { + id: 'pkg#private', + name: 'private', + description: 'Private guidance', + excluded: true, + }, + ], +} + +function runtime() { + return { + ...clack, + autocomplete: vi.fn().mockResolvedValue('continue'), + autocompleteMultiselect: vi.fn().mockResolvedValue([]), + select: vi.fn().mockResolvedValue('back'), + confirm: vi.fn().mockResolvedValue(false), + cancel: vi.fn(), + isCancel: (value: unknown): value is symbol => typeof value === 'symbol', + } +} + +function prompts(api: ReturnType) { + return createPermissionPrompts(api) +} + +describe('package permission picker', () => { + it('starts with no permissions selected and excludes unavailable packages', async () => { + const api = runtime() + const blocked = { + ...pkg, + id: 'blocked', + skills: pkg.skills.filter((skill) => skill.excluded), + } + await expect( + prompts(api).selectPermissions([pkg, blocked], '/package.json'), + ).resolves.toEqual([]) + const options = api.autocompleteMultiselect.mock.calls[0]?.[0] + expect(options).toMatchObject({ + initialValues: [], + required: false, + maxItems: 6, + }) + expect( + options.options.map((option: { value: string }) => option.value), + ).toEqual(['pkg']) + expect(api.confirm).not.toHaveBeenCalled() + }) + + it('narrows package-wide permissions to exact skills and never offers excluded skills', async () => { + const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg']) + .mockResolvedValueOnce(['pkg#core']) + api.autocomplete + .mockResolvedValueOnce(pkg) + .mockResolvedValueOnce('continue') + api.select.mockResolvedValueOnce('skills') + await expect( + prompts(api).selectPermissions([pkg], '/package.json'), + ).resolves.toEqual(['pkg#core']) + const options = api.autocompleteMultiselect.mock.calls[1]?.[0] + expect(options.initialValues).toEqual(['pkg#core', 'pkg#other']) + expect( + options.options.map((option: { value: string }) => option.value), + ).toEqual(['pkg#core', 'pkg#other']) + expect(options.options[0].hint.length).toBeLessThanOrEqual(120) + expect(api.autocomplete.mock.calls[2]?.[0]).toBeUndefined() + expect(api.autocomplete.mock.calls[1]?.[0].options[1].label).toContain( + '1 individual skill selected', + ) + }) + + it('preserves exact choices when revisiting the package picker', async () => { + const api = runtime() + api.autocomplete + .mockResolvedValueOnce(pkg) + .mockResolvedValueOnce('packages') + .mockResolvedValueOnce('continue') + api.select.mockResolvedValueOnce('skills') + api.autocompleteMultiselect + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(['pkg#core']) + .mockResolvedValueOnce([]) + await expect( + prompts(api).selectPermissions([pkg], '/package.json'), + ).resolves.toEqual(['pkg#core']) + }) + + it.each(['all', 'none'])( + 'can change exact choices to %s from package review', + async (action) => { + const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(['pkg#core']) + api.autocomplete + .mockResolvedValueOnce(pkg) + .mockResolvedValueOnce(pkg) + .mockResolvedValueOnce('continue') + api.select.mockResolvedValueOnce('skills').mockResolvedValueOnce(action) + await expect( + prompts(api).selectPermissions([pkg], '/package.json'), + ).resolves.toEqual(action === 'all' ? ['pkg'] : []) + }, + ) + + it('shows full descriptions only on request without changing permissions', async () => { + const api = runtime() + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + api.autocomplete + .mockResolvedValueOnce(pkg) + .mockResolvedValueOnce(pkg.skills[2]) + .mockResolvedValueOnce('back') + .mockResolvedValueOnce('continue') + api.select.mockResolvedValueOnce('details').mockResolvedValueOnce('back') + try { + await expect( + prompts(api).selectPermissions([pkg], '/package.json'), + ).resolves.toEqual([]) + expect(output.mock.calls.flat().join('\n')).toContain( + 'pkg#private — excluded by intent.exclude\nPrivate guidance', + ) + expect(output.mock.calls.flat().join('\n')).not.toContain('Core guidance') + expect(api.autocomplete.mock.calls[1]?.[0].options[3].label).toBe( + 'private (excluded)', + ) + } finally { + output.mockRestore() + } + }) + + it('shows exact configuration and destination only on request', async () => { + const api = runtime() + api.autocompleteMultiselect.mockResolvedValueOnce(['pkg']) + api.autocomplete + .mockResolvedValueOnce('config') + .mockResolvedValueOnce('continue') + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await expect( + prompts(api).selectPermissions([pkg], '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/app/package.json'), + ).resolves.toEqual(['pkg']) + expect(output.mock.calls.flat().join('\n')).toBe( + 'Permission destination: /app/package.json\nintent.skills: [\n "pkg"\n]', + ) + } finally { + output.mockRestore() + } + }) + + it.each([false, true])( + 'requires an explicit advanced allow-all decision: %s', + async (confirmed) => { + const api = runtime() + api.autocompleteMultiselect.mockResolvedValueOnce(['pkg']) + api.autocomplete + .mockResolvedValueOnce('all') + .mockResolvedValueOnce('continue') + api.confirm.mockResolvedValueOnce(confirmed) + await expect( + prompts(api).selectPermissions([pkg], '/package.json'), + ).resolves.toEqual(confirmed ? ['*'] : ['pkg']) + expect(api.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: false, + message: + 'Allow all current and future npm and workspace skill sources?', + }), + ) + }, + ) + + it.each(['packages', 'review', 'package', 'skills', 'details', 'advanced'])( + 'cancels once from %s', + async (stage) => { + const api = runtime() + const canceled = Symbol('cancel') + if (stage === 'packages') + api.autocompleteMultiselect.mockResolvedValueOnce(canceled) + if (stage === 'review') api.autocomplete.mockResolvedValueOnce(canceled) + if (stage === 'package') { + api.autocomplete.mockResolvedValueOnce(pkg) + api.select.mockResolvedValueOnce(canceled) + } + if (stage === 'skills') { + api.autocomplete.mockResolvedValueOnce(pkg) + api.select.mockResolvedValueOnce('skills') + api.autocompleteMultiselect + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(canceled) + } + if (stage === 'details') { + api.autocomplete + .mockResolvedValueOnce(pkg) + .mockResolvedValueOnce(canceled) + api.select.mockResolvedValueOnce('details') + } + if (stage === 'advanced') { + api.autocomplete.mockResolvedValueOnce('all') + api.confirm.mockResolvedValueOnce(canceled) + } + await expect( + prompts(api).selectPermissions([pkg], '/package.json'), + ).resolves.toBeNull() + expect(api.cancel).toHaveBeenCalledOnce() + }, + ) + + it('defaults final confirmation to No, including explicit deny-all', async () => { + const api = runtime() + await expect(prompts(api).confirmWrite(false)).resolves.toBe(false) + await expect(prompts(api).confirmWrite(true)).resolves.toBe(false) + expect(api.confirm.mock.calls[0]?.[0]).toMatchObject({ + initialValue: false, + message: 'Write this permission configuration?', + }) + expect(api.confirm.mock.calls[1]?.[0]).toMatchObject({ + initialValue: false, + message: 'Disable all skills by writing intent.skills: []?', + }) + api.confirm.mockResolvedValueOnce(Symbol('cancel')) + await expect(prompts(api).confirmWrite(false)).resolves.toBeNull() + expect(api.cancel).toHaveBeenCalledOnce() + }) + + it('searches package choices and review through real Clack with a large catalog', async () => { + vi.stubEnv('TERM', 'xterm-256color') + const input = new PassThrough() + const output = Object.assign(new PassThrough(), { columns: 80, rows: 24 }) + let rendered = '' + output.on('data', (data) => { + rendered += data.toString() + }) + const packages = Array.from({ length: 14 }, (_, index) => ({ + ...pkg, + id: `package-${index}`, + })) + let firstFrame = '' + const api: ClackPermissionRuntime = { + ...clack, + autocompleteMultiselect: (options) => { + const result = clack.autocompleteMultiselect({ + ...options, + input, + output, + }) + process.nextTick(() => { + firstFrame = stripVTControlCharacters(rendered) + input.write('package-13\t\r') + }) + return result + }, + autocomplete: (options) => { + const result = clack.autocomplete({ ...options, input, output }) + process.nextTick(() => { + input.write('no-such-package\r') + expect(stripVTControlCharacters(rendered)).toContain( + 'Choose a listed option or change your search.', + ) + process.nextTick(() => { + input.write('\x15') + process.nextTick(() => input.write('Continue\r')) + }) + }) + return result + }, + } + try { + await expect( + createPermissionPrompts(api).selectPermissions( + packages, + '/package.json', + ), + ).resolves.toEqual(['package-13']) + expect(firstFrame.match(/[◻◼]/g)?.length).toBeLessThanOrEqual(6) + expect(firstFrame).not.toContain('package-13') + expect(firstFrame).not.toContain('Core guidance') + expect(firstFrame.split('\n').length).toBeLessThan(20) + expect(stripVTControlCharacters(rendered)).toContain( + 'package-13 — all current and future skills', + ) + } finally { + vi.unstubAllEnvs() + input.destroy() + output.destroy() + } + }) +}) diff --git a/packages/intent/tests/permissions.test.ts b/packages/intent/tests/permissions.test.ts index 6b7350a..6cb2c01 100644 --- a/packages/intent/tests/permissions.test.ts +++ b/packages/intent/tests/permissions.test.ts @@ -1,17 +1,11 @@ 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 { setupInitialPermissions } from '../src/commands/install/permissions.js' import { ALLOW_ALL_NOTICE } from '../src/shared/cli-output.js' import type { - ClackPermissionRuntime, - PermissionPromptGroup, + PermissionPackage, PermissionPrompts, } from '../src/commands/install/permissions.js' import type { IntentPackage, ScanResult } from '../src/shared/types.js' @@ -65,18 +59,15 @@ function scan(packages: Array): ScanResult { } function prompts({ - allowAll = false, confirmWrite = true, selection = [], }: { - allowAll?: boolean | null confirmWrite?: boolean | null selection?: Array | null -} = {}): PermissionPrompts & { groups: Array } { +} = {}): PermissionPrompts & { groups: Array } { const result = { - groups: [] as Array, - confirmAllowAll: vi.fn(async () => allowAll), - selectPermissions: vi.fn(async (groups: Array) => { + groups: [] as Array, + selectPermissions: vi.fn(async (groups: Array) => { result.groups = groups return selection }), @@ -98,12 +89,12 @@ async function configure({ exclude?: Array packages?: Array permissionPrompts?: PermissionPrompts & { - groups?: Array + groups?: Array } } = {}): Promise<{ packageJsonPath: string permissionPrompts: PermissionPrompts & { - groups?: Array + groups?: Array } result: Awaited> }> { @@ -135,18 +126,48 @@ function configuredSkills(packageJsonPath: string): Array | undefined { } describe('interactive permission selection', () => { - it('shows discovered descriptions and versions before asking about allow-all', async () => { + it('keeps large discovery output compact before package selection', async () => { const output = vi.spyOn(console, 'log').mockImplementation(() => {}) - const permissionPrompts = prompts({ allowAll: null }) - vi.mocked(permissionPrompts.confirmAllowAll).mockImplementation(() => { + const packages = Array.from({ length: 14 }, (_, index) => + packageCandidate( + `package-${index}`, + 'npm', + Array.from( + { length: index === 13 ? 4 : 6 }, + (_, skill) => `skill-${skill}`, + ), + ), + ) + for (const pkg of packages) { + for (const skill of pkg.skills) + skill.description = 'Long agent routing description. '.repeat(30) + } + try { + await configure({ + packages, + permissionPrompts: prompts({ selection: null }), + }) 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) - }) + expect(text).toContain('Found 82 skills in 14 packages.') + expect(text).not.toContain('Long agent routing description') + expect(text.length).toBeLessThan(500) + } finally { + output.mockRestore() + } + }) + it('passes descriptions and versions to the picker without printing them', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + const permissionPrompts = prompts({ selection: null }) try { await configure({ permissionPrompts }) + expect(permissionPrompts.groups[0]).toMatchObject({ + id: '@scope/npm', + version: '1.0.0', + skills: expect.arrayContaining([ + expect.objectContaining({ description: 'core guidance' }), + ]), + }) + expect(output.mock.calls.flat().join('\n')).not.toContain('core guidance') } finally { output.mockRestore() } @@ -177,7 +198,6 @@ describe('interactive permission selection', () => { expect(result).toEqual({ status: 'unavailable' }) expect(configuredSkills(packageJsonPath)).toBeUndefined() - expect(permissionPrompts.confirmAllowAll).not.toHaveBeenCalled() expect(permissionPrompts.selectPermissions).not.toHaveBeenCalled() expect(permissionPrompts.confirmWrite).not.toHaveBeenCalled() }, @@ -204,15 +224,11 @@ describe('interactive permission selection', () => { expect(configuredSkills(packageJsonPath)).toEqual(expected) }) - it('keeps allow-all separate from grouped selections', async () => { - const permissionPrompts = prompts({ - allowAll: true, - selection: ['@scope/npm#core'], + it('writes an explicitly selected allow-all policy', async () => { + const { packageJsonPath } = await configure({ + permissionPrompts: prompts({ selection: ['*'] }), }) - const { packageJsonPath } = await configure({ permissionPrompts }) - expect(configuredSkills(packageJsonPath)).toEqual(['*']) - expect(permissionPrompts.selectPermissions).not.toHaveBeenCalled() }) it('prints the allow-all notice before final confirmation', async () => { @@ -222,7 +238,7 @@ describe('interactive permission selection', () => { .mockImplementation((message) => { events.push(String(message)) }) - const permissionPrompts = prompts({ allowAll: true }) + const permissionPrompts = prompts({ selection: ['*'] }) vi.mocked(permissionPrompts.confirmWrite).mockImplementation(() => { events.push('confirm-write') return Promise.resolve(false) @@ -251,7 +267,7 @@ describe('interactive permission selection', () => { try { await configure({ dryRun: true, - permissionPrompts: prompts({ allowAll: true }), + permissionPrompts: prompts({ selection: ['*'] }), }) } finally { errorSpy.mockRestore() @@ -260,45 +276,40 @@ describe('interactive permission selection', () => { expect(errors).toContain(` ℹ ${ALLOW_ALL_NOTICE}`) }) - it('constructs package groups with package-wide, exact, and disabled exclusion options', async () => { - const permissionPrompts = prompts({ selection: [] }) + it('preserves package identity, metadata, and exclusions for inspection', async () => { + const permissionPrompts = prompts() await configure({ exclude: ['@scope/npm#advanced', '@scope/workspace'], permissionPrompts, }) - expect(permissionPrompts.groups).toEqual([ { - label: '@scope/npm', - options: [ + id: '@scope/npm', + version: '1.0.0', + skills: [ { - label: 'All skills', - value: '@scope/npm', - hint: 'Current and future skills; exclusions still apply', + id: '@scope/npm#advanced', + name: 'advanced', + description: 'advanced guidance', + excluded: true, }, { - label: 'advanced', - value: '@scope/npm#advanced', - disabled: true, - hint: 'Excluded by intent.exclude', + id: '@scope/npm#core', + name: 'core', + description: 'core guidance', + excluded: false, }, - { 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', - }, + id: 'workspace:@scope/workspace', + version: '1.0.0', + skills: [ { - label: 'routing', - value: 'workspace:@scope/workspace#routing', - disabled: true, - hint: 'Excluded by intent.exclude', + id: 'workspace:@scope/workspace#routing', + name: 'routing', + description: 'routing guidance', + excluded: true, }, ], }, @@ -306,8 +317,7 @@ describe('interactive permission selection', () => { }) it.each([ - ['allow-all prompt', prompts({ allowAll: null })], - ['grouped selection', prompts({ selection: null })], + ['package selection', prompts({ selection: null })], ['write confirmation', prompts({ selection: [], confirmWrite: null })], ['declined write', prompts({ selection: [], confirmWrite: false })], ])( @@ -332,127 +342,3 @@ describe('interactive permission selection', () => { 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 }), - ) - }) -}) From 1c7f6aab9f16013d8660f650ac72cb8a905c06b4 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 4 Sep 2026 19:35:45 -0700 Subject: [PATCH 2/3] fix(intent): preserve compact installer permissions and skill exclusions --- .changeset/fair-tools-review.md | 2 +- docs/cli/intent-install.md | 31 +- docs/concepts/configuration.md | 26 +- docs/concepts/trust-model.md | 13 +- docs/getting-started/quick-start-consumers.md | 9 +- .../src/commands/install/package-json.ts | 33 +- .../commands/install/permission-prompts.ts | 333 +++++++-------- .../src/commands/install/permissions.ts | 157 ++++--- packages/intent/tests/cli.test.ts | 7 +- packages/intent/tests/install-writer.test.ts | 37 ++ .../intent/tests/permission-prompts.test.ts | 395 +++++++++++------- packages/intent/tests/permissions.test.ts | 207 ++++++++- 12 files changed, 819 insertions(+), 431 deletions(-) diff --git a/.changeset/fair-tools-review.md b/.changeset/fair-tools-review.md index fdbe889..04bcb9f 100644 --- a/.changeset/fair-tools-review.md +++ b/.changeset/fair-tools-review.md @@ -2,4 +2,4 @@ '@tanstack/intent': minor --- -Add interactive first-run skill permission setup to `intent install`. Choose packages from a compact searchable picker, review package-wide or individual skill permissions, and inspect descriptions and exclusions on demand. Confirm before saving; excluded skills cannot be selected, empty discovery leaves setup available for retry, and completion reports available skills and the next command. +Add interactive first-run skill permission setup to `intent install`. Enable all sources, choose packages or scopes, or select individual skills in compact searchable lists. Save package and scope rules without expanding them into individual names. Optional skill review adds exclusions under broad rules, with one confirmation before saving. Inspect descriptions and existing exclusions on demand; excluded skills cannot be enabled, 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 350c8a6..e004fa8 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -33,21 +33,28 @@ Otherwise, first-run setup requires an interactive terminal. Non-TTY execution f #### First-run flow -1. **Choose packages** from a searchable list. Type to filter, use arrow keys to move, Tab to toggle, and Enter to review. Nothing is selected by default. -2. **Review** your choices, with selected packages first. Type to find a package, then press Enter to choose individual skills, remove it, or inspect descriptions and exclusions. Full descriptions appear only when requested. -3. **Confirm** the permission summary and destination file. **Show exact configuration** previews `intent.skills`; **Continue to confirmation** asks before saving and defaults to No. -4. **Finish** with verified guidance, available skill and package counts, and a command to list those skills. +1. **Choose what to enable.** Pick **Enable all**, **Choose packages or scopes**, or **Choose individual skills**. Package and skill lists support search. +2. **Confirm once.** Check the current skill count, saved rules, and destination file. Choose **Enable selected skills** to save, **Review individual skills** to adjust the selection, or **Cancel**. Cancel is selected by default. +3. **Finish** with verified guidance, available skill and package counts, and a command to list those skills. -#### Permission choices +Descriptions, exclusions, and information about skill updates are optional choices on the setup screen. -| Choice | Effect | -| --- | --- | -| All skills in a package | Permits its current and future skills. | -| Individual skill | Permits only the named skill. | -| Advanced: allow all current and future sources | Separately confirms writing `["*"]`, replacing narrower choices. | -| Select nothing | Explicitly confirms writing `[]`, disabling current and future sources until `intent.skills` is edited. | +#### What gets enabled + +| Choice | Saved rule | Includes future additions? | +| --- | --- | --- | +| Enable all | `"*"` | All npm and workspace sources. | +| A package | `"@tanstack/ai"` | New skills in that package. | +| A whole scope | `"@tanstack/*"` | New npm packages and skills in that scope. | +| An individual skill | `"@tanstack/ai#skill"` | Only that skill name. | + +Workspace choices use the `workspace:` prefix. Scope rules are saved only when explicitly selected; choosing several packages does not grant access to the whole scope. + +**Review individual skills** lets you uncheck skills covered by a package, scope, or all-sources rule. Intent keeps the broad rule and adds those skills to `intent.exclude`. Existing exclusions always win and cannot be enabled through the picker. + +Skill instructions can change when dependencies update. Enabling access does not freeze content or record approval of specific instructions. Update notifications are not available yet. -Existing `intent.exclude` rules always apply and remain unchanged. +Selecting nothing requires explicit confirmation before writing `[]` to disable all skills. Unchecking every current skill under a broad rule excludes those skills; the rule still covers future additions. #### Files and retry behavior diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 0cbcc77..bdc6bfb 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -91,15 +91,27 @@ Run `intent list` to see which packages the current policy surfaces. | 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 opens a scrolling package picker. Review shows selected packages first, with search and optional skill details. It names the nearest owning `package.json` and requires confirmation before saving permissions and installing guidance. Choose **Show exact configuration** to inspect the allowlist. +First-run setup offers **Enable all**, **Choose packages or scopes**, and **Choose individual skills**, followed by one confirmation before saving to the nearest owning `package.json` and installing guidance. -- **Excluded candidates** can be inspected from package review 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 a separate advanced choice in review and saves `["*"]` alone after confirmation. -- **An empty selection** explicitly confirms disabling all skills with `[]`. -- **Empty or fully excluded discovery** writes nothing, so setup can be retried. +- **Compact rules:** Enable all saves `"*"`; package choices save names such as `"@tanstack/ai"`; explicit scope choices save patterns such as `"@tanstack/*"`. These rules include future matching skills and packages. Individual choices save exact names such as `"@tanstack/ai#skill"`. +- **Optional skill review:** choose **Review individual skills** at confirmation to adjust the selection. Unchecking a skill covered by a broad rule adds an exclusion. Existing and inherited exclusions stay in force. +- **Changing instructions:** access choices do not record approval of specific content. Skills can change with dependency updates; update notifications are not available yet. +- **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. +For example, enabling a scope and unchecking one skill saves: + +```json +{ + "intent": { + "skills": ["@tanstack/*"], + "exclude": ["@tanstack/ai#skill"] + } +} +``` + +This permits matching npm packages, including future additions, except the excluded skill. Selecting several packages individually never silently expands to a scope rule. + +See [Default install](../cli/intent-install#default-install) for picker controls and cancellation behavior. ### Suppressing notices temporarily diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 380fa55..445e3dc 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -11,10 +11,12 @@ A package ships skills in a `skills/` directory. Discovery finds every installed When configured, `package.json#intent.skills` controls which discovered skills can surface through the CLI and agent integrations: -- **Package entries** permit current and future skills from matching packages. -- **Exact skill entries** permit only the named skill. +- **Package entries** enable skills from matching packages, including skills added later. +- **Exact skill entries** enable only the named skill; its instructions can still change. - **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. +Enabling a source does not record approval of its specific instructions. Skill content can change when dependencies update, and Intent does not yet track or notify you about those changes. + 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 @@ -33,10 +35,9 @@ This also applies to inherited policy within a resolved workspace. Malformed JSO When no effective policy exists, `intent install` follows this flow: -1. **Discover:** summarize npm and workspace skill counts, then show a scrolling package picker. Versions appear beside focused packages; descriptions and exclusions are available through inspection. -2. **Choose:** select package-wide or exact-skill permissions. An empty selection explicitly confirms disabling all skills. -3. **Review:** show selected packages first, allow permission changes, and offer the exact `intent.skills` configuration on demand. Name the destination file before saving. -4. **Confirm:** replace `package.json` atomically only after affirmative confirmation, then install guidance. +1. **Discover:** summarize npm and workspace skill counts. Descriptions and exclusions are available through optional inspection. +2. **Choose:** enable all sources, choose packages or scopes, or select individual skills. Package and scope selections stay compact and include future matching skills. A whole scope requires an explicit selection. +3. **Confirm once:** show the current skill count, saved rules, and destination file. Optional individual review can add exclusions while retaining broad rules. Only affirmative confirmation saves permissions and exclusions atomically, then installs guidance. An empty selection explicitly confirms disabling all skills. | Outcome | Files changed | | --- | --- | diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index a962011..3d75e50 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -13,11 +13,12 @@ Run this in an interactive terminal. On first use, Intent helps you choose which Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`. -1. **Choose packages.** Type to filter, use arrow keys to move, Tab to toggle, and Enter to review. The list scrolls; nothing is selected by default. -2. **Review permissions.** Type to find a package and press Enter to inspect its skills or edit its permissions. Descriptions and exclusions are available on demand. -3. **Confirm and finish.** Review the destination `package.json`, then confirm saving. Intent writes permissions and guidance, verifies the guidance, and shows a command to list available skills. +1. **Choose what to enable.** Pick **Enable all**, **Choose packages or scopes**, or **Choose individual skills**. Package and skill lists support search. +2. **Confirm and finish.** Check the saved rules and destination `package.json`. Choose **Review individual skills** if you want exceptions, then confirm once. Intent writes permissions and guidance, verifies the guidance, and shows a command to list available skills. -Selecting a package permits its current and future skills. Choose **individual skills** from package review to permit only those names. Excluded skills cannot be selected. **Show exact configuration** previews the saved value; allowing all sources is a separate advanced choice. +**Enable all** saves `"*"`. A package choice saves `"@tanstack/ai"`; a whole scope saves `"@tanstack/*"`. These rules include future matching skills. Individual choices use `"@tanstack/ai#skill"`. Unchecking a skill during review adds an exclusion while keeping its broad rule. + +Skill instructions can change when dependencies update. Update notifications are not available yet. See **About skill access and updates** in the installer for details. 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. diff --git a/packages/intent/src/commands/install/package-json.ts b/packages/intent/src/commands/install/package-json.ts index d2ba9b6..8f8d3a8 100644 --- a/packages/intent/src/commands/install/package-json.ts +++ b/packages/intent/src/commands/install/package-json.ts @@ -77,15 +77,44 @@ function formattingOptions(content: string): { export function preparePackageSkillsUpdate( packageJsonPath: string, skills: Array, + additionalExcludes: Array = [], ): PreparedPackageSkillsUpdate { const source = readFileSync(packageJsonPath, 'utf8') - parsePackageJson(source, packageJsonPath) - const content = applyEdits( + const original = parsePackageJson(source, packageJsonPath) + let content = applyEdits( source, modify(source, ['intent', 'skills'], skills, { formattingOptions: formattingOptions(source), }), ) + if (additionalExcludes.length > 0) { + const intent = original.intent as Record | undefined + const existing = intent?.exclude ?? [] + if ( + !Array.isArray(existing) || + existing.some((value) => typeof value !== 'string') + ) { + throw new Error( + `Cannot update ${packageJsonPath}: intent.exclude must contain an array of strings.`, + ) + } + const exclude = [...new Set([...existing, ...additionalExcludes])] + content = applyEdits( + content, + modify(content, ['intent', 'exclude'], exclude, { + formattingOptions: formattingOptions(source), + }), + ) + const updated = parsePackageJson(content, packageJsonPath).intent as Record< + string, + unknown + > + if (JSON.stringify(updated.exclude) !== JSON.stringify(exclude)) { + throw new Error( + `Cannot update ${packageJsonPath}: intent.exclude validation failed.`, + ) + } + } const updated = parsePackageJson(content, packageJsonPath) const updatedIntent = updated.intent as Record if (JSON.stringify(updatedIntent.skills) !== JSON.stringify(skills)) { diff --git a/packages/intent/src/commands/install/permission-prompts.ts b/packages/intent/src/commands/install/permission-prompts.ts index 8bbff0a..398da6f 100644 --- a/packages/intent/src/commands/install/permission-prompts.ts +++ b/packages/intent/src/commands/install/permission-prompts.ts @@ -4,17 +4,16 @@ import { autocomplete, autocompleteMultiselect, cancel, - confirm, isCancel, select, } from '@clack/prompts' +import { selectedPermissionSkills } from './permissions.js' import type { PermissionPackage, PermissionPrompts } from './permissions.js' export interface ClackPermissionRuntime { autocomplete: typeof autocomplete autocompleteMultiselect: typeof autocompleteMultiselect cancel: typeof cancel - confirm: typeof confirm isCancel: typeof isCancel select: typeof select } @@ -29,7 +28,6 @@ export function createPermissionPrompts( autocomplete, autocompleteMultiselect, cancel, - confirm, isCancel, select, }, @@ -51,217 +49,176 @@ export function createPermissionPrompts( } return { - selectPermissions: async (packages, packageJsonPath) => { - let selected: Array = [] - const available = (pkg: PermissionPackage) => - pkg.skills.filter((skill) => !skill.excluded) - const selectionLabel = (pkg: PermissionPackage): string => { - if (selected.includes(pkg.id)) return 'all current and future skills' - const count = pkg.skills.filter((skill) => - selected.includes(skill.id), - ).length - return count > 0 - ? `${count} individual skill${count === 1 ? '' : 's'} selected` - : 'none selected' - } - const replaceSelection = ( - pkg: PermissionPackage, - values: Array, - ) => { - selected = selected.filter( - (value) => value !== pkg.id && !value.startsWith(`${pkg.id}#`), - ) - selected.push(...values) - } - - const choosePackages = async (): Promise => { - const result = await runtime.autocompleteMultiselect({ - ...picker, - message: 'Choose packages — allows their current and future skills', - options: packages - .filter((pkg) => available(pkg).length > 0) - .map((pkg) => ({ - value: pkg.id, - label: `${pkg.id} (${available(pkg).length} skill${available(pkg).length === 1 ? '' : 's'})`, - hint: pkg.version, - })), - initialValues: selected.filter((value) => !value.includes('#')), - required: false, - }) - if (canceled(result)) return false - // Revisiting package choices must not discard existing exact selections. - const packageIds = result - selected = selected.filter( - (value) => - value.includes('#') && - !packageIds.some((id) => value.startsWith(`${id}#`)), - ) - selected.push(...packageIds) - return true - } - - if (!(await choosePackages())) return null + selectPermissions: async (packages) => { + const availablePackages = packages.filter((pkg) => + pkg.skills.some((skill) => !skill.excluded), + ) + const availableSkills = packages + .flatMap((pkg) => pkg.skills) + .filter((skill) => !skill.excluded) for (;;) { - const selectedPackages = packages.filter((pkg) => - selected.includes(pkg.id), - ).length - const selectedSkills = selected.filter((value) => - value.includes('#'), - ).length - const action = await runtime.autocomplete({ - ...searchablePicker, - message: `Review permissions — packages: ${selectedPackages}, individual skills: ${selectedSkills}`, + const action = await runtime.select({ + ...picker, + message: 'Which skills would you like to enable?', options: [ { - value: 'continue', - label: 'Continue to confirmation', - hint: 'Nothing is saved yet', + value: 'all', + label: 'Enable all', + hint: 'All npm and workspace sources, including future additions', + }, + { + value: 'packages', + label: 'Choose packages or scopes', + hint: 'Enable all skills in selected packages or scopes', }, - ...[...packages] - .sort((left, right) => { - const hasSelection = (pkg: PermissionPackage) => - selected.some( - (value) => - value === pkg.id || value.startsWith(`${pkg.id}#`), - ) - return Number(hasSelection(right)) - Number(hasSelection(left)) - }) - .map((pkg) => ({ - value: pkg, - label: `${pkg.id} — ${selectionLabel(pkg)}`, - hint: `${pkg.version}; ${available(pkg).length} available, ${pkg.skills.length - available(pkg).length} excluded. Enter to inspect or edit.`, - })), - { value: 'packages', label: 'Change package selections' }, - { value: 'config', label: 'Show exact configuration' }, { - value: 'all', - label: 'Advanced: allow all current and future sources', + value: 'skills', + label: 'Choose individual skills', + hint: 'Select specific skills instead', }, + { value: 'inspect', label: 'Inspect descriptions and exclusions' }, + { value: 'access', label: 'About skill access and updates' }, ], }) if (canceled(action)) return null - if (action === 'continue') return selected + if (action === 'all') return ['*'] if (action === 'packages') { - if (!(await choosePackages())) return null - continue - } - if (action === 'config') { - console.log(`Permission destination: ${packageJsonPath}`) - console.log( - `intent.skills: ${JSON.stringify([...selected].sort(), null, 2)}`, - ) - continue + const selected = await runtime.autocompleteMultiselect({ + ...picker, + message: 'Choose packages or scopes — type to filter', + options: [ + ...[ + ...new Set( + availablePackages + .map((pkg) => pkg.id.match(/^(?:workspace:)?@[^/]+\//)?.[0]) + .filter((scope): scope is string => scope !== undefined), + ), + ].map((scope) => ({ + value: `${scope}*`, + label: `${scope}* (whole scope)`, + hint: 'Includes future packages and skills in this scope', + })), + ...availablePackages.map((pkg) => { + const count = pkg.skills.filter( + (skill) => !skill.excluded, + ).length + return { + value: pkg.id, + label: `${pkg.id} (${count} skill${count === 1 ? '' : 's'})`, + hint: pkg.version, + } + }), + ], + initialValues: [], + required: false, + }) + if (canceled(selected)) return null + return selected } - if (action === 'all') { - const confirmed = await runtime.confirm({ - ...io, + if (action === 'skills') { + const selected = await runtime.autocompleteMultiselect({ + ...picker, message: - 'Allow all current and future npm and workspace skill sources?', - initialValue: false, + 'Choose individual skills — type a package or skill name to filter', + options: availableSkills.map((skill) => ({ + value: skill.id, + label: skill.id, + hint: descriptionHint(skill.description), + })), + initialValues: [], + required: false, }) - if (canceled(confirmed)) return null - if (confirmed === true) return ['*'] + return canceled(selected) ? null : selected + } + if (action === 'access') { + console.log('Enabled skills can provide instructions to AI agents.') + console.log( + 'Package and scope rules include future skills in matching sources. Exact skill entries enable only that name. Exclusions always apply.', + ) + console.log( + 'Skill instructions can change when dependencies update. Intent does not yet track or notify you about those changes.', + ) continue } - - if (typeof action === 'string') continue - const pkg = action - let editing = true - while (editing) { - const choice = await runtime.select({ - ...picker, - message: `${pkg.id} — ${selectionLabel(pkg)}`, + for (;;) { + const skill = await runtime.autocomplete< + PermissionPackage['skills'][number] | 'back' + >({ + ...searchablePicker, + message: 'Inspect a skill — type a package or skill name to filter', options: [ - { value: 'back', label: 'Back to review' }, - { - value: 'skills', - label: 'Choose individual skills', - hint: 'Only these skills; future additions are not included', - disabled: available(pkg).length === 0, - }, - { - value: 'all', - label: 'Allow all current and future skills in this package', - disabled: available(pkg).length === 0, - }, - { - value: 'none', - label: 'Remove this package from the selection', - }, - { - value: 'details', - label: 'Inspect skill descriptions and exclusions', - hint: 'Viewing details does not change permissions', - }, + { value: 'back', label: 'Back to setup' }, + ...packages + .flatMap((pkg) => pkg.skills) + .map((entry) => ({ + value: entry, + label: `${entry.id}${entry.excluded ? ' (excluded)' : ''}`, + hint: entry.excluded + ? 'Unavailable because of intent.exclude' + : descriptionHint(entry.description), + })), ], }) - if (canceled(choice)) return null - if (choice === 'back') break - if (choice === 'all' || choice === 'none') { - replaceSelection(pkg, choice === 'all' ? [pkg.id] : []) - break - } - if (choice === 'skills') { - const skills = available(pkg) - const result = await runtime.autocompleteMultiselect({ - ...picker, - message: `${pkg.id} — choose individual skills`, - options: skills.map((skill) => ({ - value: skill.id, - label: skill.name, - hint: descriptionHint(skill.description), - })), - initialValues: selected.includes(pkg.id) - ? skills.map((skill) => skill.id) - : skills - .filter((skill) => selected.includes(skill.id)) - .map((skill) => skill.id), - required: false, - }) - if (canceled(result)) return null - replaceSelection(pkg, result) - editing = false - } - if (choice === 'details') { - for (;;) { - const skill = await runtime.autocomplete< - PermissionPackage['skills'][number] | 'back' - >({ - ...searchablePicker, - message: `${pkg.id} — inspect a skill`, - options: [ - { value: 'back', label: 'Back to package' }, - ...pkg.skills.map((entry) => ({ - value: entry, - label: `${entry.name}${entry.excluded ? ' (excluded)' : ''}`, - hint: entry.excluded - ? 'Unavailable because of intent.exclude' - : descriptionHint(entry.description), - })), - ], - }) - if (canceled(skill)) return null - if (skill === 'back') break - const entry = skill - console.log( - `\n${entry.id}${entry.excluded ? ' — excluded by intent.exclude' : ''}`, - ) - console.log(stripVTControlCharacters(entry.description)) - } - } + if (canceled(skill)) return null + if (skill === 'back') break + console.log( + `\n${skill.id}${skill.excluded ? ' — excluded by intent.exclude' : ''}`, + ) + console.log(stripVTControlCharacters(skill.description)) } } }, + reviewPermissions: async (packages, selection) => { + const available = packages + .flatMap((pkg) => pkg.skills) + .filter((skill) => !skill.excluded) + const selected = await runtime.autocompleteMultiselect({ + ...picker, + message: 'Review individual skills — uncheck to exclude', + options: available.map((skill) => ({ + value: skill.id, + label: skill.id, + hint: descriptionHint(skill.description), + })), + initialValues: selectedPermissionSkills(packages, selection).map( + (skill) => skill.id, + ), + required: false, + }) + if (canceled(selected)) return null + const broad = selection.skills.filter((skill) => !skill.includes('#')) + const covered = new Set( + selectedPermissionSkills(packages, { skills: broad, exclude: [] }).map( + (skill) => skill.id, + ), + ) + return { + skills: [...broad, ...selected.filter((id) => !covered.has(id))], + // Exclusions are package-name based for both npm and workspace sources. + exclude: [...covered] + .filter((id) => !selected.includes(id)) + .map((id) => id.replace(/^workspace:/, '')), + } + }, confirmWrite: async (denyAll) => { - const result = await runtime.confirm({ - ...io, + const result = await runtime.select({ + ...picker, message: denyAll ? 'Disable all skills by writing intent.skills: []?' - : 'Write this permission configuration?', - initialValue: false, + : 'Save these permissions?', + initialValue: 'cancel', + options: [ + { + value: 'save', + label: denyAll ? 'Disable all skills' : 'Enable selected skills', + }, + { value: 'review', label: 'Review individual skills' }, + { value: 'cancel', label: 'Cancel' }, + ], }) - return canceled(result) ? null : result + if (canceled(result)) return null + return result === 'review' ? 'review' : result === 'save' }, } } diff --git a/packages/intent/src/commands/install/permissions.ts b/packages/intent/src/commands/install/permissions.ts index 3e68d2b..65a2b1b 100644 --- a/packages/intent/src/commands/install/permissions.ts +++ b/packages/intent/src/commands/install/permissions.ts @@ -4,6 +4,8 @@ import { isPackageExcluded, isSkillExcluded, } from '../../core/excludes.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { isSourcePermitted } from '../../core/source-policy.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 @@ -30,12 +32,20 @@ export interface PermissionPackage { }> } +export interface PermissionSelection { + skills: Array + exclude: Array +} + export interface PermissionPrompts { selectPermissions: ( packages: Array, - packageJsonPath: string, ) => Promise | null> - confirmWrite: (denyAll: boolean) => Promise + reviewPermissions: ( + packages: Array, + selection: PermissionSelection, + ) => Promise + confirmWrite: (denyAll: boolean) => Promise } export interface PermissionSetupRuntime { @@ -75,14 +85,44 @@ function permissionPackages( }) } +export function selectedPermissionSkills( + packages: Array, + selection: PermissionSelection, +): Array { + const config = parseSkillSources(selection.skills) + const excludes = compileExcludePatterns(selection.exclude) + return packages.flatMap((pkg) => { + const kind = pkg.id.startsWith('workspace:') ? 'workspace' : 'npm' + const name = + kind === 'workspace' ? pkg.id.slice('workspace:'.length) : pkg.id + return pkg.skills.filter( + (skill) => + !skill.excluded && + isSourcePermitted(config, name, kind, skill.name) && + !isSkillExcluded(name, skill.name, excludes), + ) + }) +} + 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)) + const values = [...new Set(selected)] + if (values.includes('*')) return ['*'] + return values + .filter((value) => { + const config = parseSkillSources([value]) + if (config.mode !== 'explicit') return true + const source = config.sources[0]! + if (source.kind === 'git' || 'pattern' in source) return true + const others = parseSkillSources( + values.filter( + (other) => + other !== value && + (source.skill !== undefined || !other.includes('#')), + ), + ) + return !isSourcePermitted(others, source.id, source.kind, source.skill) + }) + .sort((left, right) => left.localeCompare(right)) } export async function setupInitialPermissions({ @@ -131,51 +171,74 @@ export async function setupInitialPermissions({ const excludedCount = discoveredSkills.length - availableSkillCount if (excludedCount > 0) { console.log( - `${excludedCount} excluded skills are unavailable. Inspect a package to view them.`, + `${excludedCount} skill${excludedCount === 1 ? '' : 's'} excluded by intent.exclude.`, ) } - const selected = await runtime.prompts.selectPermissions( - candidates, - context.targetPackageJsonPath, - ) + console.log('Skills can change when dependencies update.') + const selected = await runtime.prompts.selectPermissions(candidates) if (selected === null) return { status: 'canceled' } - const skills = normalizePermissions(selected) - const update = preparePackageSkillsUpdate( - context.targetPackageJsonPath, - skills, - ) - - console.log(`Permission destination: ${context.targetPackageJsonPath}`) - 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: package-wide permissions: ${skills.filter((skill) => !skill.includes('#')).length}; individual skills: ${skills.filter((skill) => skill.includes('#')).length}. These sources can provide instructions to AI agents.`, + let selection: PermissionSelection = { skills: selected, exclude: [] } + for (;;) { + const skills = normalizePermissions(selection.skills) + const update = preparePackageSkillsUpdate( + context.targetPackageJsonPath, + skills, + selection.exclude, ) - } - if (skills.length === 1 && skills[0] === '*') { - printNotices([ALLOW_ALL_NOTICE]) - } + const enabled = selectedPermissionSkills(candidates, selection) + const packageCount = new Set(enabled.map((skill) => skill.id.split('#')[0])) + .size - if (dryRun) { + console.log(`Permission destination: ${context.targetPackageJsonPath}`) + 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( + `Selected: ${enabled.length} skill${enabled.length === 1 ? '' : 's'} from ${packageCount} package${packageCount === 1 ? '' : 's'} currently available.`, + ) + if (skills.some((skill) => !skill.includes('#'))) { + console.log( + 'Package and scope rules also enable future skills in matching sources. Exclusions still apply.', + ) + } + } + for (const [label, values] of [ + ['intent.skills', skills], + ['Add to intent.exclude', selection.exclude], + ] as const) { + if (values.length === 0) continue + console.log( + `${label}: ${JSON.stringify(values.slice(0, 6))}${values.length > 6 ? ` (+${values.length - 6} more)` : ''}`, + ) + } + 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 === 'review') { + const reviewed = await runtime.prompts.reviewPermissions( + candidates, + selection, + ) + if (reviewed === null) return { status: 'canceled' } + selection = reviewed + continue + } + if (confirmation !== true) { + console.log('Permissions: canceled.') + return { status: 'canceled' } + } return { packageJsonPath: context.targetPackageJsonPath, - status: 'unchanged', + status: writePreparedPackageSkillsUpdate(update), } } - - 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 1e01d22..a2f6c5f 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -75,6 +75,9 @@ function permissionPrompts({ } = {}): PermissionPrompts { return { selectPermissions: vi.fn(async () => selection), + reviewPermissions: vi.fn((_groups, selection) => + Promise.resolve(selection), + ), confirmWrite: vi.fn(async () => confirmWrite), } } @@ -468,7 +471,7 @@ describe('cli commands', () => { `Permission destination: ${join(root, 'package.json')}`, ) expect(output).toContain( - 'Trust change: package-wide permissions: 1; individual skills: 0. These sources can provide instructions to AI agents.', + 'Selected: 1 skill from 1 package currently available.', ) expect(output).toContain('Permissions: updated package.json.') expect(output).toContain('Guidance: created AGENTS.md.') @@ -735,7 +738,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain( - 'Trust change: package-wide permissions: 1; individual skills: 0. These sources can provide instructions to AI agents.', + 'Selected: 1 skill from 1 package currently available.', ) expect(output).toContain('Permissions: unchanged package.json (dry run).') expect(output).toContain('Generated skill loading guidance for AGENTS.md.') diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 670d597..e96adee 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -8,6 +8,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { parse } from 'jsonc-parser' import { afterEach, describe, expect, it } from 'vitest' import { buildIntentSkillGuidanceBlock, @@ -722,6 +723,42 @@ describe('package.json permission writer', () => { expect(content.replace(/\r\n/g, '')).not.toContain('\n') }) + it('atomically appends skill exclusions while preserving existing rules and JSONC', () => { + const root = tempRoot() + const targetPath = join(root, 'package.json') + writeFileSync( + targetPath, + '{\n // keep this\n "name": "app",\n "intent": { "exclude": ["@scope/private"], "custom": true }\n}\n', + ) + const update = preparePackageSkillsUpdate( + targetPath, + ['@scope/*'], + ['@scope/npm#core', '@scope/private'], + ) + expect(readFileSync(targetPath, 'utf8')).not.toContain('"skills"') + expect(writePreparedPackageSkillsUpdate(update)).toBe('updated') + const content = readFileSync(targetPath, 'utf8') + expect(content).toContain('// keep this') + const intent = parse(content).intent + expect(intent).toEqual({ + custom: true, + skills: ['@scope/*'], + exclude: ['@scope/private', '@scope/npm#core'], + }) + expect(readdirSync(root)).toEqual(['package.json']) + }) + + it('rejects malformed exclusions instead of replacing them during review', () => { + const root = tempRoot() + const targetPath = join(root, 'package.json') + const source = '{"intent":{"exclude":"@scope/private"}}' + writeFileSync(targetPath, source) + expect(() => + preparePackageSkillsUpdate(targetPath, ['*'], ['pkg#core']), + ).toThrow('intent.exclude must contain an array of strings') + expect(readFileSync(targetPath, 'utf8')).toBe(source) + }) + it('rejects source-byte drift before replacement', () => { const root = tempRoot() const targetPath = join(root, 'package.json') diff --git a/packages/intent/tests/permission-prompts.test.ts b/packages/intent/tests/permission-prompts.test.ts index 3b49694..9b87dc1 100644 --- a/packages/intent/tests/permission-prompts.test.ts +++ b/packages/intent/tests/permission-prompts.test.ts @@ -30,239 +30,296 @@ const pkg: PermissionPackage = { }, ], } - +const workspace: PermissionPackage = { + id: 'workspace:pkg', + version: '1.0.0', + skills: [ + { + id: 'workspace:pkg#local', + name: 'local', + description: 'Workspace guidance', + excluded: false, + }, + ], +} function runtime() { return { ...clack, - autocomplete: vi.fn().mockResolvedValue('continue'), + select: vi.fn().mockResolvedValue('all'), + autocomplete: vi.fn().mockResolvedValue('back'), autocompleteMultiselect: vi.fn().mockResolvedValue([]), - select: vi.fn().mockResolvedValue('back'), - confirm: vi.fn().mockResolvedValue(false), cancel: vi.fn(), isCancel: (value: unknown): value is symbol => typeof value === 'symbol', } } -function prompts(api: ReturnType) { - return createPermissionPrompts(api) -} +describe('skill enablement', () => { + it('enables all sources with a compact wildcard without another selection screen', async () => { + const api = runtime() + await expect( + createPermissionPrompts(api).selectPermissions([pkg, workspace]), + ).resolves.toEqual(['*']) + expect(api.select).toHaveBeenCalledOnce() + expect(api.autocompleteMultiselect).not.toHaveBeenCalled() + expect(api.autocomplete).not.toHaveBeenCalled() + }) -describe('package permission picker', () => { - it('starts with no permissions selected and excludes unavailable packages', async () => { + it('keeps selected packages compact without a permission review screen', async () => { const api = runtime() + api.select.mockResolvedValueOnce('packages') + api.autocompleteMultiselect.mockResolvedValueOnce(['pkg']) const blocked = { - ...pkg, id: 'blocked', - skills: pkg.skills.filter((skill) => skill.excluded), + version: '1.0.0', + skills: [{ ...pkg.skills[2]!, id: 'blocked#private' }], } await expect( - prompts(api).selectPermissions([pkg, blocked], '/package.json'), - ).resolves.toEqual([]) - const options = api.autocompleteMultiselect.mock.calls[0]?.[0] - expect(options).toMatchObject({ + createPermissionPrompts(api).selectPermissions([pkg, workspace, blocked]), + ).resolves.toEqual(['pkg']) + expect(api.select).toHaveBeenCalledOnce() + expect(api.autocompleteMultiselect).toHaveBeenCalledOnce() + expect(api.autocompleteMultiselect.mock.calls[0]?.[0]).toMatchObject({ initialValues: [], required: false, maxItems: 6, }) expect( - options.options.map((option: { value: string }) => option.value), - ).toEqual(['pkg']) - expect(api.confirm).not.toHaveBeenCalled() + api.autocompleteMultiselect.mock.calls[0]?.[0].options.map( + (option: { value: string }) => option.value, + ), + ).toEqual(['pkg', 'workspace:pkg']) + expect(api.autocomplete).not.toHaveBeenCalled() }) - it('narrows package-wide permissions to exact skills and never offers excluded skills', async () => { + it('allows individual selections across packages with bounded descriptions', async () => { const api = runtime() - api.autocompleteMultiselect - .mockResolvedValueOnce(['pkg']) - .mockResolvedValueOnce(['pkg#core']) - api.autocomplete - .mockResolvedValueOnce(pkg) - .mockResolvedValueOnce('continue') api.select.mockResolvedValueOnce('skills') + api.autocompleteMultiselect.mockResolvedValueOnce([ + 'pkg#core', + 'workspace:pkg#local', + ]) await expect( - prompts(api).selectPermissions([pkg], '/package.json'), - ).resolves.toEqual(['pkg#core']) - const options = api.autocompleteMultiselect.mock.calls[1]?.[0] - expect(options.initialValues).toEqual(['pkg#core', 'pkg#other']) + createPermissionPrompts(api).selectPermissions([pkg, workspace]), + ).resolves.toEqual(['pkg#core', 'workspace:pkg#local']) + const options = api.autocompleteMultiselect.mock.calls[0]?.[0] + expect(options.initialValues).toEqual([]) expect( options.options.map((option: { value: string }) => option.value), - ).toEqual(['pkg#core', 'pkg#other']) + ).toEqual(['pkg#core', 'pkg#other', 'workspace:pkg#local']) expect(options.options[0].hint.length).toBeLessThanOrEqual(120) - expect(api.autocomplete.mock.calls[2]?.[0]).toBeUndefined() - expect(api.autocomplete.mock.calls[1]?.[0].options[1].label).toContain( - '1 individual skill selected', - ) + expect(api.select).toHaveBeenCalledOnce() }) - it('preserves exact choices when revisiting the package picker', async () => { - const api = runtime() - api.autocomplete - .mockResolvedValueOnce(pkg) - .mockResolvedValueOnce('packages') - .mockResolvedValueOnce('continue') - api.select.mockResolvedValueOnce('skills') - api.autocompleteMultiselect - .mockResolvedValueOnce([]) - .mockResolvedValueOnce(['pkg#core']) - .mockResolvedValueOnce([]) - await expect( - prompts(api).selectPermissions([pkg], '/package.json'), - ).resolves.toEqual(['pkg#core']) - }) - - it.each(['all', 'none'])( - 'can change exact choices to %s from package review', + it.each(['packages', 'skills'])( + 'keeps an empty %s selection empty for deny-all confirmation', async (action) => { const api = runtime() - api.autocompleteMultiselect - .mockResolvedValueOnce([]) - .mockResolvedValueOnce(['pkg#core']) - api.autocomplete - .mockResolvedValueOnce(pkg) - .mockResolvedValueOnce(pkg) - .mockResolvedValueOnce('continue') - api.select.mockResolvedValueOnce('skills').mockResolvedValueOnce(action) + api.select.mockResolvedValueOnce(action) await expect( - prompts(api).selectPermissions([pkg], '/package.json'), - ).resolves.toEqual(action === 'all' ? ['pkg'] : []) + createPermissionPrompts(api).selectPermissions([pkg]), + ).resolves.toEqual([]) }, ) - it('shows full descriptions only on request without changing permissions', async () => { + it('only shows requested descriptions and can inspect exclusions without enabling anything', async () => { const api = runtime() - const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + api.select.mockResolvedValueOnce('inspect').mockResolvedValueOnce('skills') api.autocomplete - .mockResolvedValueOnce(pkg) .mockResolvedValueOnce(pkg.skills[2]) .mockResolvedValueOnce('back') - .mockResolvedValueOnce('continue') - api.select.mockResolvedValueOnce('details').mockResolvedValueOnce('back') + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) try { await expect( - prompts(api).selectPermissions([pkg], '/package.json'), + createPermissionPrompts(api).selectPermissions([pkg]), ).resolves.toEqual([]) expect(output.mock.calls.flat().join('\n')).toContain( 'pkg#private — excluded by intent.exclude\nPrivate guidance', ) expect(output.mock.calls.flat().join('\n')).not.toContain('Core guidance') - expect(api.autocomplete.mock.calls[1]?.[0].options[3].label).toBe( - 'private (excluded)', + expect(api.autocomplete.mock.calls[0]?.[0].options[3].label).toBe( + 'pkg#private (excluded)', ) } finally { output.mockRestore() } }) - it('shows exact configuration and destination only on request', async () => { + it('keeps update limitations optional without adding an approval prompt', async () => { const api = runtime() - api.autocompleteMultiselect.mockResolvedValueOnce(['pkg']) - api.autocomplete - .mockResolvedValueOnce('config') - .mockResolvedValueOnce('continue') + api.select.mockResolvedValueOnce('access').mockResolvedValueOnce('packages') const output = vi.spyOn(console, 'log').mockImplementation(() => {}) try { await expect( - prompts(api).selectPermissions([pkg], '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/app/package.json'), - ).resolves.toEqual(['pkg']) - expect(output.mock.calls.flat().join('\n')).toBe( - 'Permission destination: /app/package.json\nintent.skills: [\n "pkg"\n]', + createPermissionPrompts(api).selectPermissions([pkg]), + ).resolves.toEqual([]) + expect(output.mock.calls.flat().join('\n')).toContain( + 'Intent does not yet track or notify you about those changes.', ) } finally { output.mockRestore() } }) - it.each([false, true])( - 'requires an explicit advanced allow-all decision: %s', - async (confirmed) => { - const api = runtime() - api.autocompleteMultiselect.mockResolvedValueOnce(['pkg']) - api.autocomplete - .mockResolvedValueOnce('all') - .mockResolvedValueOnce('continue') - api.confirm.mockResolvedValueOnce(confirmed) - await expect( - prompts(api).selectPermissions([pkg], '/package.json'), - ).resolves.toEqual(confirmed ? ['*'] : ['pkg']) - expect(api.confirm).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: false, - message: - 'Allow all current and future npm and workspace skill sources?', - }), - ) - }, - ) - - it.each(['packages', 'review', 'package', 'skills', 'details', 'advanced'])( - 'cancels once from %s', + it.each(['start', 'packages', 'skills', 'inspect'])( + 'cancels once at %s', async (stage) => { const api = runtime() const canceled = Symbol('cancel') - if (stage === 'packages') + if (stage === 'start') api.select.mockResolvedValueOnce(canceled) + else api.select.mockResolvedValueOnce(stage) + if (stage === 'packages' || stage === 'skills') api.autocompleteMultiselect.mockResolvedValueOnce(canceled) - if (stage === 'review') api.autocomplete.mockResolvedValueOnce(canceled) - if (stage === 'package') { - api.autocomplete.mockResolvedValueOnce(pkg) - api.select.mockResolvedValueOnce(canceled) - } - if (stage === 'skills') { - api.autocomplete.mockResolvedValueOnce(pkg) - api.select.mockResolvedValueOnce('skills') - api.autocompleteMultiselect - .mockResolvedValueOnce([]) - .mockResolvedValueOnce(canceled) - } - if (stage === 'details') { - api.autocomplete - .mockResolvedValueOnce(pkg) - .mockResolvedValueOnce(canceled) - api.select.mockResolvedValueOnce('details') - } - if (stage === 'advanced') { - api.autocomplete.mockResolvedValueOnce('all') - api.confirm.mockResolvedValueOnce(canceled) - } + if (stage === 'inspect') api.autocomplete.mockResolvedValueOnce(canceled) await expect( - prompts(api).selectPermissions([pkg], '/package.json'), + createPermissionPrompts(api).selectPermissions([pkg]), ).resolves.toBeNull() expect(api.cancel).toHaveBeenCalledOnce() }, ) - it('defaults final confirmation to No, including explicit deny-all', async () => { + it('offers optional review in the single final confirmation and defaults to cancel', async () => { const api = runtime() - await expect(prompts(api).confirmWrite(false)).resolves.toBe(false) - await expect(prompts(api).confirmWrite(true)).resolves.toBe(false) - expect(api.confirm.mock.calls[0]?.[0]).toMatchObject({ - initialValue: false, - message: 'Write this permission configuration?', + api.select + .mockResolvedValueOnce('cancel') + .mockResolvedValueOnce('save') + .mockResolvedValueOnce('review') + const prompts = createPermissionPrompts(api) + await expect(prompts.confirmWrite(false)).resolves.toBe(false) + await expect(prompts.confirmWrite(true)).resolves.toBe(true) + await expect(prompts.confirmWrite(false)).resolves.toBe('review') + expect(api.select.mock.calls[0]?.[0]).toMatchObject({ + initialValue: 'cancel', + message: 'Save these permissions?', }) - expect(api.confirm.mock.calls[1]?.[0]).toMatchObject({ - initialValue: false, - message: 'Disable all skills by writing intent.skills: []?', + expect(api.select.mock.calls[1]?.[0].message).toBe( + 'Disable all skills by writing intent.skills: []?', + ) + api.select.mockResolvedValueOnce(Symbol('cancel')) + await expect(prompts.confirmWrite(false)).resolves.toBeNull() + expect(api.cancel).toHaveBeenCalledOnce() + }) + + it('offers explicit npm and workspace scope choices', async () => { + const api = runtime() + api.select.mockResolvedValueOnce('packages') + api.autocompleteMultiselect.mockResolvedValueOnce(['@tanstack/*']) + await expect( + createPermissionPrompts(api).selectPermissions([ + { ...pkg, id: '@tanstack/ai' }, + { ...workspace, id: 'workspace:@tanstack/local' }, + ]), + ).resolves.toEqual(['@tanstack/*']) + expect( + api.autocompleteMultiselect.mock.calls[0]?.[0].options.map( + (option: { value: string }) => option.value, + ), + ).toEqual([ + '@tanstack/*', + 'workspace:@tanstack/*', + '@tanstack/ai', + 'workspace:@tanstack/local', + ]) + }) + + it('keeps broad rules, excludes unchecked skills, and adds outside skills exactly', async () => { + const api = runtime() + api.autocompleteMultiselect.mockResolvedValueOnce([ + 'pkg#core', + 'workspace:pkg#local', + ]) + const prompts = createPermissionPrompts(api) + await expect( + prompts.reviewPermissions([pkg, workspace], { + skills: ['pkg'], + exclude: [], + }), + ).resolves.toEqual({ + skills: ['pkg', 'workspace:pkg#local'], + exclude: ['pkg#other'], }) - api.confirm.mockResolvedValueOnce(Symbol('cancel')) - await expect(prompts(api).confirmWrite(false)).resolves.toBeNull() + expect( + api.autocompleteMultiselect.mock.calls[0]?.[0].initialValues, + ).toEqual(['pkg#core', 'pkg#other']) + expect( + api.autocompleteMultiselect.mock.calls[0]?.[0].options.map( + (option: { value: string }) => option.value, + ), + ).not.toContain('pkg#private') + }) + + it('can restore a skill excluded during review before saving', async () => { + const api = runtime() + api.autocompleteMultiselect.mockResolvedValueOnce(['pkg#core', 'pkg#other']) + await expect( + createPermissionPrompts(api).reviewPermissions([pkg], { + skills: ['pkg'], + exclude: ['pkg#other'], + }), + ).resolves.toEqual({ skills: ['pkg'], exclude: [] }) + expect( + api.autocompleteMultiselect.mock.calls[0]?.[0].initialValues, + ).toEqual(['pkg#core']) + }) + + it('writes workspace exclusions using the kind-agnostic package name', async () => { + const api = runtime() + await expect( + createPermissionPrompts(api).reviewPermissions([workspace], { + skills: ['workspace:pkg'], + exclude: [], + }), + ).resolves.toEqual({ skills: ['workspace:pkg'], exclude: ['pkg#local'] }) + }) + + it('does not broaden exact selections during review', async () => { + const api = runtime() + api.autocompleteMultiselect.mockResolvedValueOnce(['pkg#core', 'pkg#other']) + await expect( + createPermissionPrompts(api).reviewPermissions([pkg], { + skills: ['pkg#core'], + exclude: [], + }), + ).resolves.toEqual({ skills: ['pkg#core', 'pkg#other'], exclude: [] }) + }) + + it('cancels skill review without returning changes', async () => { + const api = runtime() + api.autocompleteMultiselect.mockResolvedValueOnce(Symbol('cancel')) + await expect( + createPermissionPrompts(api).reviewPermissions([pkg], { + skills: ['*'], + exclude: [], + }), + ).resolves.toBeNull() expect(api.cancel).toHaveBeenCalledOnce() }) - it('searches package choices and review through real Clack with a large catalog', async () => { + it('uses real search and keyboard selection for a bounded large package catalog', async () => { vi.stubEnv('TERM', 'xterm-256color') const input = new PassThrough() const output = Object.assign(new PassThrough(), { columns: 80, rows: 24 }) let rendered = '' + let firstFrame = '' output.on('data', (data) => { rendered += data.toString() }) const packages = Array.from({ length: 14 }, (_, index) => ({ - ...pkg, id: `package-${index}`, + version: '1.0.0', + skills: pkg.skills.map((skill) => ({ + ...skill, + id: `package-${index}#${skill.name}`, + })), })) - let firstFrame = '' const api: ClackPermissionRuntime = { ...clack, + select: (options) => { + const result = clack.select({ ...options, input, output }) + process.nextTick(() => input.write('\x1b[B\r')) + return result + }, autocompleteMultiselect: (options) => { + rendered = '' const result = clack.autocompleteMultiselect({ ...options, input, @@ -274,35 +331,53 @@ describe('package permission picker', () => { }) return result }, + } + try { + await expect( + createPermissionPrompts(api).selectPermissions(packages), + ).resolves.toEqual(['package-13']) + expect(firstFrame.match(/[◻◼]/g)?.length).toBeLessThanOrEqual(6) + expect(firstFrame).not.toContain('package-13') + expect(firstFrame).not.toContain('Core guidance') + expect(firstFrame.split('\n').length).toBeLessThan(20) + expect(stripVTControlCharacters(rendered)).not.toContain( + 'Review selection', + ) + } finally { + vi.unstubAllEnvs() + input.destroy() + output.destroy() + } + }) + + it('keeps inspection open when a search has no matches', async () => { + vi.stubEnv('TERM', 'xterm-256color') + const input = new PassThrough() + const output = new PassThrough() + let rendered = '' + output.on('data', (data) => { + rendered += data.toString() + }) + const api = runtime() + api.select.mockResolvedValueOnce('inspect') + const prompts = createPermissionPrompts({ + ...api, autocomplete: (options) => { const result = clack.autocomplete({ ...options, input, output }) process.nextTick(() => { - input.write('no-such-package\r') + input.write('no-such-skill\r') expect(stripVTControlCharacters(rendered)).toContain( 'Choose a listed option or change your search.', ) - process.nextTick(() => { - input.write('\x15') - process.nextTick(() => input.write('Continue\r')) - }) + process.nextTick(() => input.write('\x03')) }) return result }, - } + isCancel: clack.isCancel, + }) try { - await expect( - createPermissionPrompts(api).selectPermissions( - packages, - '/package.json', - ), - ).resolves.toEqual(['package-13']) - expect(firstFrame.match(/[◻◼]/g)?.length).toBeLessThanOrEqual(6) - expect(firstFrame).not.toContain('package-13') - expect(firstFrame).not.toContain('Core guidance') - expect(firstFrame.split('\n').length).toBeLessThan(20) - expect(stripVTControlCharacters(rendered)).toContain( - 'package-13 — all current and future skills', - ) + await expect(prompts.selectPermissions([pkg])).resolves.toBeNull() + expect(api.cancel).toHaveBeenCalledOnce() } finally { vi.unstubAllEnvs() input.destroy() diff --git a/packages/intent/tests/permissions.test.ts b/packages/intent/tests/permissions.test.ts index 6cb2c01..371e5ed 100644 --- a/packages/intent/tests/permissions.test.ts +++ b/packages/intent/tests/permissions.test.ts @@ -1,7 +1,21 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import * as clack from '@clack/prompts' import { afterEach, describe, expect, it, vi } from 'vitest' +import { compileExcludePatterns } from '../src/core/excludes.js' +import { createPermissionPrompts } from '../src/commands/install/permission-prompts.js' +import { + applySourcePolicy, + isSourcePermitted, + readSkillSourcesConfig, +} from '../src/core/source-policy.js' import { setupInitialPermissions } from '../src/commands/install/permissions.js' import { ALLOW_ALL_NOTICE } from '../src/shared/cli-output.js' import type { @@ -71,6 +85,9 @@ function prompts({ result.groups = groups return selection }), + reviewPermissions: vi.fn((_groups, selection) => + Promise.resolve(selection), + ), confirmWrite: vi.fn(async () => confirmWrite), } return result @@ -149,6 +166,9 @@ describe('interactive permission selection', () => { }) const text = output.mock.calls.flat().join('\n') expect(text).toContain('Found 82 skills in 14 packages.') + expect( + text.split('Skills can change when dependencies update.'), + ).toHaveLength(2) expect(text).not.toContain('Long agent routing description') expect(text.length).toBeLessThan(500) } finally { @@ -173,6 +193,173 @@ describe('interactive permission selection', () => { } }) + it('saves enable-all as a compact rule covering later packages and skills', async () => { + const select = vi + .fn() + .mockResolvedValueOnce('all') + .mockResolvedValueOnce('save') + const { packageJsonPath } = await configure({ + permissionPrompts: createPermissionPrompts({ + ...clack, + select, + }), + }) + expect(configuredSkills(packageJsonPath)).toEqual(['*']) + const policy = readSkillSourcesConfig(dirname(packageJsonPath)) + expect(isSourcePermitted(policy, '@scope/npm', 'npm', 'core')).toBe(true) + expect(isSourcePermitted(policy, '@scope/npm', 'npm', 'added-later')).toBe( + true, + ) + expect(isSourcePermitted(policy, '@scope/new', 'npm', 'core')).toBe(true) + expect( + isSourcePermitted(policy, '@scope/workspace', 'workspace', 'routing'), + ).toBe(true) + expect( + isSourcePermitted(policy, '@scope/workspace', 'npm', 'routing'), + ).toBe(true) + expect(select).toHaveBeenCalledTimes(2) + }) + + it.each(['@scope/npm', '@scope/*', '*'])( + 'reviews %s and saves exclusions without expanding the rule', + async (rule) => { + const select = vi + .fn() + .mockResolvedValueOnce('packages') + .mockResolvedValueOnce('review') + .mockResolvedValueOnce('save') + const multiselect = vi + .fn() + .mockResolvedValueOnce([rule]) + .mockResolvedValueOnce([]) + const { packageJsonPath } = await configure({ + exclude: ['@scope/npm#advanced'], + permissionPrompts: createPermissionPrompts({ + ...clack, + select, + autocompleteMultiselect: multiselect, + }), + }) + const intent = JSON.parse(readFileSync(packageJsonPath, 'utf8')).intent + expect(intent.skills).toEqual([rule]) + expect(intent.exclude).toContain('@scope/npm#advanced') + expect(intent.exclude).toContain('@scope/npm#core') + const future = applySourcePolicy( + scan([ + packageCandidate('@scope/npm', 'npm', [ + 'core', + 'advanced', + 'added-later', + ]), + packageCandidate('@scope/new', 'npm', ['core']), + packageCandidate('@else/pkg', 'npm', ['core']), + ]), + { + config: readSkillSourcesConfig(dirname(packageJsonPath)), + excludeMatchers: compileExcludePatterns(intent.exclude), + }, + ) + expect(future.packages[0]?.skills.map((skill) => skill.name)).toEqual([ + 'added-later', + ]) + expect(future.packages.some((pkg) => pkg.name === '@scope/new')).toBe( + rule !== '@scope/npm', + ) + expect(future.packages.some((pkg) => pkg.name === '@else/pkg')).toBe( + rule === '*', + ) + expect(select).toHaveBeenCalledTimes(3) + }, + ) + + it.each(['cancel', 'dry-run'])( + 'does not persist reviewed exclusions on %s', + async (action) => { + const initial = prompts({ + selection: ['@scope/npm'], + confirmWrite: false, + }) + vi.mocked(initial.confirmWrite) + .mockResolvedValueOnce('review') + .mockResolvedValueOnce(false) + vi.mocked(initial.reviewPermissions).mockResolvedValue({ + skills: ['@scope/npm'], + exclude: ['@scope/npm#core'], + }) + const { packageJsonPath } = await configure({ + dryRun: action === 'dry-run', + permissionPrompts: initial, + }) + const intent = JSON.parse(readFileSync(packageJsonPath, 'utf8')).intent + expect(intent.skills).toBeUndefined() + expect(intent.exclude).toEqual([]) + }, + ) + + it('preserves inherited exclusions while saving new exceptions locally', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-permissions-inherited-')) + tempDirs.push(root) + const child = join(root, 'packages', 'app') + mkdirSync(child, { recursive: true }) + const rootSource = JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + intent: { exclude: ['@scope/npm#advanced'] }, + }) + writeFileSync(join(root, 'package.json'), rootSource) + writeFileSync(join(child, 'package.json'), JSON.stringify({ name: 'app' })) + const runtime = createPermissionPrompts({ + ...clack, + select: vi + .fn() + .mockResolvedValueOnce('all') + .mockResolvedValueOnce('review') + .mockResolvedValueOnce('save'), + autocompleteMultiselect: vi.fn().mockResolvedValueOnce([]), + }) + await setupInitialPermissions({ + root: child, + runtime: { + prompts: runtime, + scan: () => + scan([packageCandidate('@scope/npm', 'npm', ['core', 'advanced'])]), + }, + }) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(rootSource) + expect( + JSON.parse(readFileSync(join(child, 'package.json'), 'utf8')).intent, + ).toEqual({ skills: ['*'], exclude: ['@scope/npm#core'] }) + }) + + it('counts effective skills under a scope rule and keeps large previews bounded', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await configure({ + exclude: ['@scope/npm#advanced'], + permissionPrompts: prompts({ selection: ['@scope/*'] }), + }) + expect(output.mock.calls.flat().join('\n')).toContain( + 'Selected: 1 skill from 1 package currently available.', + ) + output.mockClear() + const skills = Array.from({ length: 82 }, (_, i) => `skill-${i}`) + await configure({ + packages: [packageCandidate('pkg', 'npm', skills)], + permissionPrompts: prompts({ + selection: skills.map((skill) => `pkg#${skill}`), + }), + }) + const text = output.mock.calls.flat().join('\n') + expect(text).toContain( + 'Selected: 82 skills from 1 package currently available.', + ) + expect(text).toContain('(+76 more)') + expect(text.length).toBeLessThan(1000) + } finally { + output.mockRestore() + } + }) + it('explicitly confirms disabling all skills after an empty selection', async () => { const permissionPrompts = prompts({ selection: [] }) await configure({ permissionPrompts }) @@ -205,6 +392,22 @@ describe('interactive permission selection', () => { it.each([ ['deny all', [], []], + [ + 'scope plus package', + ['@scope/*', '@scope/npm', '@scope/npm#core'], + ['@scope/*'], + ], + ['whole package', ['@scope/npm'], ['@scope/npm']], + [ + 'separate kinds', + ['@scope/*', 'workspace:@scope/workspace'], + ['@scope/*', 'workspace:@scope/workspace'], + ], + [ + 'no implicit scope', + ['@scope/npm', '@scope/second'], + ['@scope/npm', '@scope/second'], + ], ['exact only', ['@scope/npm#core'], ['@scope/npm#core']], [ 'package plus child', From c4dc0d6917bd5f0c8a3ba55a53e6822f38e4ec61 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 4 Sep 2026 19:42:03 -0700 Subject: [PATCH 3/3] fix(intent): scope individual skill review to selected packages --- .changeset/fair-tools-review.md | 2 +- docs/cli/intent-install.md | 4 +- docs/concepts/configuration.md | 2 +- docs/concepts/trust-model.md | 2 +- docs/getting-started/quick-start-consumers.md | 2 +- .../commands/install/permission-prompts.ts | 65 ++++++-- .../intent/tests/permission-prompts.test.ts | 157 ++++++++++++++++-- packages/intent/tests/permissions.test.ts | 6 +- 8 files changed, 198 insertions(+), 42 deletions(-) diff --git a/.changeset/fair-tools-review.md b/.changeset/fair-tools-review.md index 04bcb9f..6bdd403 100644 --- a/.changeset/fair-tools-review.md +++ b/.changeset/fair-tools-review.md @@ -2,4 +2,4 @@ '@tanstack/intent': minor --- -Add interactive first-run skill permission setup to `intent install`. Enable all sources, choose packages or scopes, or select individual skills in compact searchable lists. Save package and scope rules without expanding them into individual names. Optional skill review adds exclusions under broad rules, with one confirmation before saving. Inspect descriptions and existing exclusions on demand; excluded skills cannot be enabled, empty discovery leaves setup available for retry, and completion reports available skills and the next command. +Add interactive first-run skill permission setup to `intent install`. Enable all sources, choose packages or scopes, or select individual skills in compact searchable lists. Save package and scope rules without expanding them into individual names. Optional skill review shows only selected packages and opens individual skill lists only for packages chosen for review. Keep all other selections and exclusions, with one confirmation before saving. Inspect descriptions and existing exclusions on demand; excluded skills cannot be enabled, 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 e004fa8..97dba3c 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -34,7 +34,7 @@ Otherwise, first-run setup requires an interactive terminal. Non-TTY execution f #### First-run flow 1. **Choose what to enable.** Pick **Enable all**, **Choose packages or scopes**, or **Choose individual skills**. Package and skill lists support search. -2. **Confirm once.** Check the current skill count, saved rules, and destination file. Choose **Enable selected skills** to save, **Review individual skills** to adjust the selection, or **Cancel**. Cancel is selected by default. +2. **Confirm once.** Check the current skill count, saved rules, and destination file. Choose **Continue with all selected skills** to save, **Review individual skills** to inspect specific packages, or **Cancel**. Cancel is selected by default. 3. **Finish** with verified guidance, available skill and package counts, and a command to list those skills. Descriptions, exclusions, and information about skill updates are optional choices on the setup screen. @@ -50,7 +50,7 @@ Descriptions, exclusions, and information about skill updates are optional choic Workspace choices use the `workspace:` prefix. Scope rules are saved only when explicitly selected; choosing several packages does not grant access to the whole scope. -**Review individual skills** lets you uncheck skills covered by a package, scope, or all-sources rule. Intent keeps the broad rule and adds those skills to `intent.exclude`. Existing exclusions always win and cannot be enabled through the picker. +**Review individual skills** lists only packages covered by your selection. Choose the packages you want to review, or leave the list empty to continue with all selected skills. Each chosen package opens its own skill list; other packages keep their selection. Unchecking a skill covered by a package, scope, or all-sources rule keeps the broad rule and adds that skill to `intent.exclude`. Existing exclusions always win and cannot be enabled through the picker. Skill instructions can change when dependencies update. Enabling access does not freeze content or record approval of specific instructions. Update notifications are not available yet. diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index bdc6bfb..8e8b17f 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -94,7 +94,7 @@ Run `intent list` to see which packages the current policy surfaces. First-run setup offers **Enable all**, **Choose packages or scopes**, and **Choose individual skills**, followed by one confirmation before saving to the nearest owning `package.json` and installing guidance. - **Compact rules:** Enable all saves `"*"`; package choices save names such as `"@tanstack/ai"`; explicit scope choices save patterns such as `"@tanstack/*"`. These rules include future matching skills and packages. Individual choices save exact names such as `"@tanstack/ai#skill"`. -- **Optional skill review:** choose **Review individual skills** at confirmation to adjust the selection. Unchecking a skill covered by a broad rule adds an exclusion. Existing and inherited exclusions stay in force. +- **Optional skill review:** choose **Review individual skills** at confirmation, then pick which selected packages to inspect. Leave the list empty to keep all selected skills. Only those packages open individual skill lists; unchecking a skill covered by a broad rule adds an exclusion. Existing and inherited exclusions stay in force. - **Changing instructions:** access choices do not record approval of specific content. Skills can change with dependency updates; update notifications are not available yet. - **Empty selection:** explicitly confirms disabling all skills with `[]`. Empty or fully excluded discovery writes nothing, so setup can be retried. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 445e3dc..f50b058 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -37,7 +37,7 @@ When no effective policy exists, `intent install` follows this flow: 1. **Discover:** summarize npm and workspace skill counts. Descriptions and exclusions are available through optional inspection. 2. **Choose:** enable all sources, choose packages or scopes, or select individual skills. Package and scope selections stay compact and include future matching skills. A whole scope requires an explicit selection. -3. **Confirm once:** show the current skill count, saved rules, and destination file. Optional individual review can add exclusions while retaining broad rules. Only affirmative confirmation saves permissions and exclusions atomically, then installs guidance. An empty selection explicitly confirms disabling all skills. +3. **Confirm once:** show the current skill count, saved rules, and destination file. Optional individual review opens skill lists only for the selected packages you choose to inspect. It can add exclusions while retaining broad rules; unreviewed packages keep their selection. Only affirmative confirmation saves permissions and exclusions atomically, then installs guidance. An empty selection explicitly confirms disabling all skills. | Outcome | Files changed | | --- | --- | diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 3d75e50..f3f581d 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -14,7 +14,7 @@ Run this in an interactive terminal. On first use, Intent helps you choose which Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`. 1. **Choose what to enable.** Pick **Enable all**, **Choose packages or scopes**, or **Choose individual skills**. Package and skill lists support search. -2. **Confirm and finish.** Check the saved rules and destination `package.json`. Choose **Review individual skills** if you want exceptions, then confirm once. Intent writes permissions and guidance, verifies the guidance, and shows a command to list available skills. +2. **Confirm and finish.** Check the saved rules and destination `package.json`. Choose **Continue with all selected skills** to save, or **Review individual skills** to pick which selected packages to inspect before confirming. Intent writes permissions and guidance, verifies the guidance, and shows a command to list available skills. **Enable all** saves `"*"`. A package choice saves `"@tanstack/ai"`; a whole scope saves `"@tanstack/*"`. These rules include future matching skills. Individual choices use `"@tanstack/ai#skill"`. Unchecking a skill during review adds an exclusion while keeping its broad rule. diff --git a/packages/intent/src/commands/install/permission-prompts.ts b/packages/intent/src/commands/install/permission-prompts.ts index 398da6f..8df020a 100644 --- a/packages/intent/src/commands/install/permission-prompts.ts +++ b/packages/intent/src/commands/install/permission-prompts.ts @@ -170,23 +170,48 @@ export function createPermissionPrompts( } }, reviewPermissions: async (packages, selection) => { - const available = packages - .flatMap((pkg) => pkg.skills) - .filter((skill) => !skill.excluded) - const selected = await runtime.autocompleteMultiselect({ + const reviewable = packages.filter( + (pkg) => + selectedPermissionSkills([pkg], { + skills: selection.skills, + exclude: [], + }).length > 0, + ) + const packageIds = await runtime.autocompleteMultiselect({ ...picker, - message: 'Review individual skills — uncheck to exclude', - options: available.map((skill) => ({ - value: skill.id, - label: skill.id, - hint: descriptionHint(skill.description), - })), - initialValues: selectedPermissionSkills(packages, selection).map( - (skill) => skill.id, - ), + message: 'Review individual skills — choose packages to review', + placeholder: 'Leave empty to continue with all selected skills', + options: reviewable.map((pkg) => ({ value: pkg.id, label: pkg.id })), + initialValues: [], required: false, }) - if (canceled(selected)) return null + if (canceled(packageIds)) return null + if (packageIds.length === 0) return selection + const selected = new Set( + selectedPermissionSkills(packages, selection).map((skill) => skill.id), + ) + for (const pkg of reviewable.filter((pkg) => + packageIds.includes(pkg.id), + )) { + const skills = await runtime.autocompleteMultiselect({ + ...picker, + message: `Choose skills from ${pkg.id} — uncheck to exclude`, + options: pkg.skills + .filter((skill) => !skill.excluded) + .map((skill) => ({ + value: skill.id, + label: skill.name, + hint: descriptionHint(skill.description), + })), + initialValues: pkg.skills + .filter((skill) => selected.has(skill.id)) + .map((skill) => skill.id), + required: false, + }) + if (canceled(skills)) return null + for (const skill of pkg.skills) selected.delete(skill.id) + for (const skill of skills) selected.add(skill) + } const broad = selection.skills.filter((skill) => !skill.includes('#')) const covered = new Set( selectedPermissionSkills(packages, { skills: broad, exclude: [] }).map( @@ -194,10 +219,10 @@ export function createPermissionPrompts( ), ) return { - skills: [...broad, ...selected.filter((id) => !covered.has(id))], + skills: [...broad, ...[...selected].filter((id) => !covered.has(id))], // Exclusions are package-name based for both npm and workspace sources. exclude: [...covered] - .filter((id) => !selected.includes(id)) + .filter((id) => !selected.has(id)) .map((id) => id.replace(/^workspace:/, '')), } }, @@ -211,9 +236,13 @@ export function createPermissionPrompts( options: [ { value: 'save', - label: denyAll ? 'Disable all skills' : 'Enable selected skills', + label: denyAll + ? 'Disable all skills' + : 'Continue with all selected skills', }, - { value: 'review', label: 'Review individual skills' }, + ...(denyAll + ? [] + : [{ value: 'review', label: 'Review individual skills' }]), { value: 'cancel', label: 'Cancel' }, ], }) diff --git a/packages/intent/tests/permission-prompts.test.ts b/packages/intent/tests/permission-prompts.test.ts index 9b87dc1..598add3 100644 --- a/packages/intent/tests/permission-prompts.test.ts +++ b/packages/intent/tests/permission-prompts.test.ts @@ -221,35 +221,103 @@ describe('skill enablement', () => { ]) }) - it('keeps broad rules, excludes unchecked skills, and adds outside skills exactly', async () => { + it('offers only selected packages for review and opens skills only for chosen packages', async () => { const api = runtime() - api.autocompleteMultiselect.mockResolvedValueOnce([ - 'pkg#core', - 'workspace:pkg#local', - ]) - const prompts = createPermissionPrompts(api) + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg']) + .mockResolvedValueOnce(['pkg#core']) await expect( - prompts.reviewPermissions([pkg, workspace], { + createPermissionPrompts(api).reviewPermissions([pkg, workspace], { skills: ['pkg'], exclude: [], }), - ).resolves.toEqual({ - skills: ['pkg', 'workspace:pkg#local'], - exclude: ['pkg#other'], - }) + ).resolves.toEqual({ skills: ['pkg'], exclude: ['pkg#other'] }) + expect( + api.autocompleteMultiselect.mock.calls[0]?.[0].options.map( + (option: { value: string }) => option.value, + ), + ).toEqual(['pkg']) expect( - api.autocompleteMultiselect.mock.calls[0]?.[0].initialValues, + api.autocompleteMultiselect.mock.calls[1]?.[0].options.map( + (option: { value: string }) => option.value, + ), ).toEqual(['pkg#core', 'pkg#other']) + }) + + it('continues with every selected skill without opening any skill lists', async () => { + const api = runtime() + const selection = { skills: ['*'], exclude: ['pkg#other'] } + await expect( + createPermissionPrompts(api).reviewPermissions( + [pkg, workspace], + selection, + ), + ).resolves.toEqual(selection) + expect(api.autocompleteMultiselect).toHaveBeenCalledOnce() + expect(api.autocompleteMultiselect.mock.calls[0]?.[0]).toMatchObject({ + initialValues: [], + required: false, + placeholder: 'Leave empty to continue with all selected skills', + }) + }) + + it('keeps unreviewed packages and their exceptions unchanged', async () => { + const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce(['workspace:pkg']) + .mockResolvedValueOnce([]) + await expect( + createPermissionPrompts(api).reviewPermissions([pkg, workspace], { + skills: ['*'], + exclude: ['pkg#other'], + }), + ).resolves.toEqual({ skills: ['*'], exclude: ['pkg#other', 'pkg#local'] }) + expect(api.autocompleteMultiselect).toHaveBeenCalledTimes(2) expect( - api.autocompleteMultiselect.mock.calls[0]?.[0].options.map( + api.autocompleteMultiselect.mock.calls[1]?.[0].options.map( (option: { value: string }) => option.value, ), - ).not.toContain('pkg#private') + ).toEqual(['workspace:pkg#local']) + }) + + it('keeps selected packages with all skills unchecked available for another review', async () => { + const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg']) + .mockResolvedValueOnce(['pkg#other']) + await expect( + createPermissionPrompts(api).reviewPermissions([pkg, workspace], { + skills: ['pkg'], + exclude: ['pkg#core', 'pkg#other'], + }), + ).resolves.toEqual({ skills: ['pkg'], exclude: ['pkg#core'] }) + expect( + api.autocompleteMultiselect.mock.calls[1]?.[0].initialValues, + ).toEqual([]) + }) + + it('cancels midway through reviewing multiple packages without returning partial changes', async () => { + const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg', 'workspace:pkg']) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(Symbol('cancel')) + const selection = { skills: ['*'], exclude: [] } + await expect( + createPermissionPrompts(api).reviewPermissions( + [pkg, workspace], + selection, + ), + ).resolves.toBeNull() + expect(selection).toEqual({ skills: ['*'], exclude: [] }) + expect(api.cancel).toHaveBeenCalledOnce() }) it('can restore a skill excluded during review before saving', async () => { const api = runtime() - api.autocompleteMultiselect.mockResolvedValueOnce(['pkg#core', 'pkg#other']) + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg']) + .mockResolvedValueOnce(['pkg#core', 'pkg#other']) await expect( createPermissionPrompts(api).reviewPermissions([pkg], { skills: ['pkg'], @@ -257,12 +325,15 @@ describe('skill enablement', () => { }), ).resolves.toEqual({ skills: ['pkg'], exclude: [] }) expect( - api.autocompleteMultiselect.mock.calls[0]?.[0].initialValues, + api.autocompleteMultiselect.mock.calls[1]?.[0].initialValues, ).toEqual(['pkg#core']) }) it('writes workspace exclusions using the kind-agnostic package name', async () => { const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce(['workspace:pkg']) + .mockResolvedValueOnce([]) await expect( createPermissionPrompts(api).reviewPermissions([workspace], { skills: ['workspace:pkg'], @@ -273,7 +344,9 @@ describe('skill enablement', () => { it('does not broaden exact selections during review', async () => { const api = runtime() - api.autocompleteMultiselect.mockResolvedValueOnce(['pkg#core', 'pkg#other']) + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg']) + .mockResolvedValueOnce(['pkg#core', 'pkg#other']) await expect( createPermissionPrompts(api).reviewPermissions([pkg], { skills: ['pkg#core'], @@ -294,6 +367,56 @@ describe('skill enablement', () => { expect(api.cancel).toHaveBeenCalledOnce() }) + it('supports keyboard selection of a review package and only its individual skills', async () => { + vi.stubEnv('TERM', 'xterm-256color') + const input = new PassThrough() + const output = Object.assign(new PassThrough(), { columns: 100, rows: 24 }) + const frames: Array = [] + let rendered = '' + let stage = 0 + output.on('data', (data) => { + rendered += data.toString() + }) + const api: ClackPermissionRuntime = { + ...clack, + autocompleteMultiselect: (options) => { + rendered = '' + const result = clack.autocompleteMultiselect({ + ...options, + input, + output, + }) + const keys = stage++ === 0 ? 'pkg\t\r' : 'other\t\r' + process.nextTick(() => { + frames.push(stripVTControlCharacters(rendered)) + input.write(keys) + }) + return result + }, + } + try { + await expect( + createPermissionPrompts(api).reviewPermissions([pkg, workspace], { + skills: ['pkg'], + exclude: [], + }), + ).resolves.toEqual({ skills: ['pkg'], exclude: ['pkg#other'] }) + expect(frames).toHaveLength(2) + expect(frames[0]).toContain( + 'Leave empty to continue with all selected skills', + ) + expect(frames[0]).not.toContain('workspace:pkg') + expect(frames[0]).not.toContain('Core guidance') + expect(frames[1]).toContain('Choose skills from pkg') + expect(frames[1]).not.toContain('Workspace guidance') + expect(frames[1]).not.toContain('private') + } finally { + vi.unstubAllEnvs() + input.destroy() + output.destroy() + } + }) + it('uses real search and keyboard selection for a bounded large package catalog', async () => { vi.stubEnv('TERM', 'xterm-256color') const input = new PassThrough() diff --git a/packages/intent/tests/permissions.test.ts b/packages/intent/tests/permissions.test.ts index 371e5ed..1180e96 100644 --- a/packages/intent/tests/permissions.test.ts +++ b/packages/intent/tests/permissions.test.ts @@ -231,6 +231,7 @@ describe('interactive permission selection', () => { const multiselect = vi .fn() .mockResolvedValueOnce([rule]) + .mockResolvedValueOnce(['@scope/npm']) .mockResolvedValueOnce([]) const { packageJsonPath } = await configure({ exclude: ['@scope/npm#advanced'], @@ -315,7 +316,10 @@ describe('interactive permission selection', () => { .mockResolvedValueOnce('all') .mockResolvedValueOnce('review') .mockResolvedValueOnce('save'), - autocompleteMultiselect: vi.fn().mockResolvedValueOnce([]), + autocompleteMultiselect: vi + .fn() + .mockResolvedValueOnce(['@scope/npm']) + .mockResolvedValueOnce([]), }) await setupInitialPermissions({ root: child,