diff --git a/.changeset/fair-tools-review.md b/.changeset/fair-tools-review.md index 02d70dc..6bdd403 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`. 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 0b64d6d..97dba3c 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. **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 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 **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. -#### 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. | -| Allow all sources | Writes `["*"]` and skips narrower selection. | -| 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** 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. -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 6d4d123..8e8b17f 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 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 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** stay visible in the overview but cannot be selected. Exclusions remain unchanged. -- **Package-wide choices** include current and future skills and remove redundant selected children. Exact choices permit only the named skill. -- **Allow-all** is offered separately after the overview and saves `["*"]` alone. -- **An empty selection** explicitly confirms disabling all skills with `[]`. -- **Empty or fully excluded discovery** writes nothing, so setup can be retried. +- **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, 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. -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 2eeb09c..f50b058 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:** show npm and workspace packages, versions, and skill descriptions. Excluded candidates appear in the overview but cannot be selected. -2. **Choose:** select package-wide or exact-skill permissions. An empty selection explicitly confirms disabling all skills. -3. **Review:** show the exact `intent.skills` value and destination file. -4. **Confirm:** replace `package.json` atomically only after affirmative confirmation, then install guidance. +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 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 cfb0123..f3f581d 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -13,15 +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`. -The command: +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 **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. -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 +**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. -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. +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/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/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 new file mode 100644 index 0000000..8df020a --- /dev/null +++ b/packages/intent/src/commands/install/permission-prompts.ts @@ -0,0 +1,253 @@ +import { stdin, stdout } from 'node:process' +import { stripVTControlCharacters } from 'node:util' +import { + autocomplete, + autocompleteMultiselect, + cancel, + 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 + 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, + 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) => { + const availablePackages = packages.filter((pkg) => + pkg.skills.some((skill) => !skill.excluded), + ) + const availableSkills = packages + .flatMap((pkg) => pkg.skills) + .filter((skill) => !skill.excluded) + + for (;;) { + const action = await runtime.select({ + ...picker, + message: 'Which skills would you like to enable?', + options: [ + { + 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', + }, + { + 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 === 'all') return ['*'] + if (action === 'packages') { + 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 === 'skills') { + const selected = await runtime.autocompleteMultiselect({ + ...picker, + message: + '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, + }) + 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 + } + 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 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(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 reviewable = packages.filter( + (pkg) => + selectedPermissionSkills([pkg], { + skills: selection.skills, + exclude: [], + }).length > 0, + ) + const packageIds = await runtime.autocompleteMultiselect({ + ...picker, + 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(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( + (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.has(id)) + .map((id) => id.replace(/^workspace:/, '')), + } + }, + confirmWrite: async (denyAll) => { + const result = await runtime.select({ + ...picker, + message: denyAll + ? 'Disable all skills by writing intent.skills: []?' + : 'Save these permissions?', + initialValue: 'cancel', + options: [ + { + value: 'save', + label: denyAll + ? 'Disable all skills' + : 'Continue with all selected skills', + }, + ...(denyAll + ? [] + : [{ value: 'review', label: 'Review individual skills' }]), + { value: 'cancel', label: 'Cancel' }, + ], + }) + 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 fc98a60..65a2b1b 100644 --- a/packages/intent/src/commands/install/permissions.ts +++ b/packages/intent/src/commands/install/permissions.ts @@ -1,11 +1,11 @@ -import { stdin, stdout } from 'node:process' -import { cancel, confirm, groupMultiselect, isCancel } from '@clack/prompts' import { compileExcludePatterns, getEffectiveExcludePatterns, 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 @@ -21,31 +21,31 @@ 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 PermissionPackage { + id: string + version: string + skills: Array<{ + id: string + name: string + description: string + excluded: boolean + }> } -export interface PermissionPromptGroup { - label: string - options: Array +export interface PermissionSelection { + skills: Array + exclude: Array } export interface PermissionPrompts { - confirmAllowAll: () => Promise selectPermissions: ( - groups: Array, + packages: Array, ) => Promise | null> - confirmWrite: (denyAll: boolean) => Promise -} - -export interface ClackPermissionRuntime { - cancel: typeof cancel - confirm: typeof confirm - groupMultiselect: typeof groupMultiselect - isCancel: typeof isCancel + reviewPermissions: ( + packages: Array, + selection: PermissionSelection, + ) => Promise + confirmWrite: (denyAll: boolean) => Promise } export interface PermissionSetupRuntime { @@ -62,116 +62,67 @@ 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), + })), } }) } -function normalizePermissions(selected: Array): Array { - const values = new Set(selected) - for (const value of selected) { - if (!value.includes('#')) continue - const packageSelector = value.slice(0, value.indexOf('#')) - if (values.has(packageSelector)) values.delete(value) - } - return [...values].sort((left, right) => left.localeCompare(right)) -} - -function clackResult( - value: T | symbol, - runtime: ClackPermissionRuntime, -): T | null { - if (!runtime.isCancel(value)) return value - runtime.cancel('Permissions: canceled.', { output: stdout }) - return null +export function 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), + ) + }) } -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, - ), - } +function normalizePermissions(selected: Array): Array { + 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({ @@ -200,90 +151,94 @@ 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 allowAll = await runtime.prompts.confirmAllowAll() - if (allowAll === null) return { status: 'canceled' } - const selected = allowAll - ? ['*'] - : await runtime.prompts.selectPermissions( - permissionGroups(packages, excludes), - ) - if (selected === null) return { status: 'canceled' } - const skills = allowAll ? ['*'] : normalizePermissions(selected) - const update = preparePackageSkillsUpdate( - context.targetPackageJsonPath, - skills, - ) - - console.log(`Permission destination: ${context.targetPackageJsonPath}`) - console.log(`intent.skills: ${JSON.stringify(skills, null, 2)}`) - if (skills.length === 0) { - console.log( - 'No skills selected. This disables all skill sources, including future sources, until you edit intent.skills.', - ) - } else { + const excludedCount = discoveredSkills.length - availableSkillCount + if (excludedCount > 0) { 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.', + `${excludedCount} skill${excludedCount === 1 ? '' : 's'} excluded by intent.exclude.`, ) } - if (skills.length === 1 && skills[0] === '*') { - printNotices([ALLOW_ALL_NOTICE]) - } + console.log('Skills can change when dependencies update.') + const selected = await runtime.prompts.selectPermissions(candidates) + if (selected === null) return { status: 'canceled' } + let selection: PermissionSelection = { skills: selected, exclude: [] } + for (;;) { + const skills = normalizePermissions(selection.skills) + const update = preparePackageSkillsUpdate( + context.targetPackageJsonPath, + skills, + selection.exclude, + ) + 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 2521765..a2f6c5f 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -67,17 +67,17 @@ 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), + reviewPermissions: vi.fn((_groups, selection) => + Promise.resolve(selection), + ), confirmWrite: vi.fn(async () => confirmWrite), } } @@ -470,12 +470,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.', + 'Selected: 1 skill from 1 package currently available.', ) 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 +510,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 +529,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 +641,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 +649,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 +737,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( + '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.') expect(prompts.selectPermissions).toHaveBeenCalledOnce() 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 new file mode 100644 index 0000000..598add3 --- /dev/null +++ b/packages/intent/tests/permission-prompts.test.ts @@ -0,0 +1,510 @@ +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, + }, + ], +} +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, + select: vi.fn().mockResolvedValue('all'), + autocomplete: vi.fn().mockResolvedValue('back'), + autocompleteMultiselect: vi.fn().mockResolvedValue([]), + cancel: vi.fn(), + isCancel: (value: unknown): value is symbol => typeof value === 'symbol', + } +} + +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() + }) + + it('keeps selected packages compact without a permission review screen', async () => { + const api = runtime() + api.select.mockResolvedValueOnce('packages') + api.autocompleteMultiselect.mockResolvedValueOnce(['pkg']) + const blocked = { + id: 'blocked', + version: '1.0.0', + skills: [{ ...pkg.skills[2]!, id: 'blocked#private' }], + } + await expect( + 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( + api.autocompleteMultiselect.mock.calls[0]?.[0].options.map( + (option: { value: string }) => option.value, + ), + ).toEqual(['pkg', 'workspace:pkg']) + expect(api.autocomplete).not.toHaveBeenCalled() + }) + + it('allows individual selections across packages with bounded descriptions', async () => { + const api = runtime() + api.select.mockResolvedValueOnce('skills') + api.autocompleteMultiselect.mockResolvedValueOnce([ + 'pkg#core', + 'workspace:pkg#local', + ]) + await expect( + 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', 'workspace:pkg#local']) + expect(options.options[0].hint.length).toBeLessThanOrEqual(120) + expect(api.select).toHaveBeenCalledOnce() + }) + + it.each(['packages', 'skills'])( + 'keeps an empty %s selection empty for deny-all confirmation', + async (action) => { + const api = runtime() + api.select.mockResolvedValueOnce(action) + await expect( + createPermissionPrompts(api).selectPermissions([pkg]), + ).resolves.toEqual([]) + }, + ) + + it('only shows requested descriptions and can inspect exclusions without enabling anything', async () => { + const api = runtime() + api.select.mockResolvedValueOnce('inspect').mockResolvedValueOnce('skills') + api.autocomplete + .mockResolvedValueOnce(pkg.skills[2]) + .mockResolvedValueOnce('back') + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await expect( + 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[0]?.[0].options[3].label).toBe( + 'pkg#private (excluded)', + ) + } finally { + output.mockRestore() + } + }) + + it('keeps update limitations optional without adding an approval prompt', async () => { + const api = runtime() + api.select.mockResolvedValueOnce('access').mockResolvedValueOnce('packages') + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await expect( + 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(['start', 'packages', 'skills', 'inspect'])( + 'cancels once at %s', + async (stage) => { + const api = runtime() + const canceled = Symbol('cancel') + if (stage === 'start') api.select.mockResolvedValueOnce(canceled) + else api.select.mockResolvedValueOnce(stage) + if (stage === 'packages' || stage === 'skills') + api.autocompleteMultiselect.mockResolvedValueOnce(canceled) + if (stage === 'inspect') api.autocomplete.mockResolvedValueOnce(canceled) + await expect( + createPermissionPrompts(api).selectPermissions([pkg]), + ).resolves.toBeNull() + expect(api.cancel).toHaveBeenCalledOnce() + }, + ) + + it('offers optional review in the single final confirmation and defaults to cancel', async () => { + const api = runtime() + 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.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('offers only selected packages for review and opens skills only for chosen packages', async () => { + const api = runtime() + api.autocompleteMultiselect + .mockResolvedValueOnce(['pkg']) + .mockResolvedValueOnce(['pkg#core']) + await expect( + createPermissionPrompts(api).reviewPermissions([pkg, workspace], { + skills: ['pkg'], + exclude: [], + }), + ).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[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[1]?.[0].options.map( + (option: { value: string }) => option.value, + ), + ).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']) + .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[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'], + 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']) + .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('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() + 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) => ({ + id: `package-${index}`, + version: '1.0.0', + skills: pkg.skills.map((skill) => ({ + ...skill, + id: `package-${index}#${skill.name}`, + })), + })) + 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, + output, + }) + process.nextTick(() => { + firstFrame = stripVTControlCharacters(rendered) + input.write('package-13\t\r') + }) + 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-skill\r') + expect(stripVTControlCharacters(rendered)).toContain( + 'Choose a listed option or change your search.', + ) + process.nextTick(() => input.write('\x03')) + }) + return result + }, + isCancel: clack.isCancel, + }) + try { + await expect(prompts.selectPermissions([pkg])).resolves.toBeNull() + expect(api.cancel).toHaveBeenCalledOnce() + } 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..1180e96 100644 --- a/packages/intent/tests/permissions.test.ts +++ b/packages/intent/tests/permissions.test.ts @@ -1,17 +1,25 @@ -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 { PassThrough } from 'node:stream' -import { cancel, confirm, groupMultiselect, isCancel } from '@clack/prompts' +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 { - createPermissionPrompts, - setupInitialPermissions, -} from '../src/commands/install/permissions.js' + 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 { - ClackPermissionRuntime, - PermissionPromptGroup, + PermissionPackage, PermissionPrompts, } from '../src/commands/install/permissions.js' import type { IntentPackage, ScanResult } from '../src/shared/types.js' @@ -65,21 +73,21 @@ 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 }), + reviewPermissions: vi.fn((_groups, selection) => + Promise.resolve(selection), + ), confirmWrite: vi.fn(async () => confirmWrite), } return result @@ -98,12 +106,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 +143,222 @@ 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.split('Skills can change when dependencies update.'), + ).toHaveLength(2) + 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() + } + }) + + 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(['@scope/npm']) + .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(['@scope/npm']) + .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() } @@ -177,7 +389,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() }, @@ -185,6 +396,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', @@ -204,15 +431,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 +445,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 +474,7 @@ describe('interactive permission selection', () => { try { await configure({ dryRun: true, - permissionPrompts: prompts({ allowAll: true }), + permissionPrompts: prompts({ selection: ['*'] }), }) } finally { errorSpy.mockRestore() @@ -260,45 +483,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: [ + id: 'workspace:@scope/workspace', + version: '1.0.0', + skills: [ { - label: 'All skills', - value: 'workspace:@scope/workspace', - disabled: true, - hint: 'Excluded by intent.exclude', - }, - { - label: 'routing', - value: 'workspace:@scope/workspace#routing', - disabled: true, - hint: 'Excluded by intent.exclude', + id: 'workspace:@scope/workspace#routing', + name: 'routing', + description: 'routing guidance', + excluded: true, }, ], }, @@ -306,8 +524,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 +549,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 }), - ) - }) -})