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