diff --git a/.changeset/crisp-flies-mate.md b/.changeset/crisp-flies-mate.md new file mode 100644 index 0000000..2283bb4 --- /dev/null +++ b/.changeset/crisp-flies-mate.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': minor +--- + +Add `package.json#intent.skills` source allowlisting to gate which discovered packages can contribute skills. + +`intent.exclude` now supports skill-level matching (for example `@scope/pkg#skill-id` and globs), and policy filtering is applied consistently across `intent list`, `intent load`, `intent install`, and `intent stale`. Notices are surfaced separately from warnings to keep command output machine-safe. diff --git a/.changeset/green-dingos-refuse.md b/.changeset/green-dingos-refuse.md new file mode 100644 index 0000000..65d00f7 --- /dev/null +++ b/.changeset/green-dingos-refuse.md @@ -0,0 +1,12 @@ +--- +'@tanstack/intent': minor +--- + +Add a persistent `intent exclude` command for managing `package.json#intent.exclude` (`list`, `add`, `remove`), and document it in the CLI/config guides. + +Add notice suppression controls for automation: + +- `--no-notices` on `intent list` and `intent install` +- `INTENT_NO_NOTICES=1` environment variable + +Remove one-off CLI exclude flags from command surfaces (`list/load --exclude`); excludes are now managed via `package.json#intent.exclude` and `intent exclude`. diff --git a/docs/cli/intent-exclude.md b/docs/cli/intent-exclude.md new file mode 100644 index 0000000..1c1d4db --- /dev/null +++ b/docs/cli/intent-exclude.md @@ -0,0 +1,43 @@ +--- +title: intent exclude +id: intent-exclude +--- + +`intent exclude` manages `package.json#intent.exclude` entries. + +```bash +npx @tanstack/intent@latest exclude [list|add|remove] [pattern] [--json] +``` + +## Options + +- `--json`: print the configured exclude patterns as JSON + +## Actions + +1. `list` (default): print current excludes +2. `add `: append one exclude pattern +3. `remove `: remove one exclude pattern + +## Examples + +```bash +npx @tanstack/intent@latest exclude +npx @tanstack/intent@latest exclude list --json +npx @tanstack/intent@latest exclude add @tanstack/router#experimental-* +npx @tanstack/intent@latest exclude remove @tanstack/router#experimental-* +``` + +## Behavior + +- Reads and writes the current working directory `package.json` +- Creates `intent.exclude` when missing +- Keeps existing excludes and appends new patterns in order +- Validates pattern syntax before writing +- Refuses invalid `package.json` structures for `intent` and `intent.exclude` + +## Related + +- [Configuration](../concepts/configuration) +- [intent list](./intent-list) +- [intent load](./intent-load) diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index a36e432..8d444e0 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -6,7 +6,7 @@ id: intent-install `intent install` 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] +npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] ``` ## Options @@ -16,6 +16,7 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob - `--print-prompt`: print the agent setup prompt instead of writing files - `--global`: include global packages after project packages when `--map` is passed - `--global-only`: install mappings from global packages only when `--map` is passed +- `--no-notices`: suppress non-critical notices on stderr ## Behavior @@ -75,6 +76,8 @@ skills: - 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.` +To suppress trust and migration notices in automation, pass `--no-notices`. + ## Related - [intent list](./intent-list) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 132d06f..3fe825e 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -6,16 +6,16 @@ id: intent-list `intent list` discovers skill-enabled packages and prints available skills. ```bash -npx @tanstack/intent@latest list [--json] [--debug] [--exclude ] [--global] [--global-only] +npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--no-notices] ``` ## Options - `--json`: print JSON instead of text output - `--debug`: print discovery debug details to stderr -- `--exclude `: exclude package names matching a simple glob; can be passed more than once - `--global`: include global packages after project packages - `--global-only`: list global packages only +- `--no-notices`: suppress non-critical notices on stderr ## What you get @@ -23,13 +23,14 @@ npx @tanstack/intent@latest list [--json] [--debug] [--exclude ] [--glo - Surfaces packages permitted by `package.json#intent.skills` (see [Allowlist](#allowlist)) - Includes global packages only when `--global` or `--global-only` is passed - Includes warnings from discovery -- Excludes packages and skills matched by package.json `intent.exclude` or `--exclude` +- Excludes packages and skills matched by package.json `intent.exclude` - Prints debug details to stderr when `--debug` is passed - If no packages are discovered, prints `No intent-enabled packages found.` - Summary line with package count and skill count - Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS` - Skill tree grouped by package - Optional warnings section (`⚠ ...` per warning) +- Optional notices section on stderr (`ℹ ...` per notice), suppressed by `--no-notices` `SOURCE` is a lightweight indicator showing whether the selected package came from local discovery or explicit global scanning. When both local and global packages are scanned, local packages take precedence. @@ -113,7 +114,8 @@ A package that ships skills but is not listed is dropped. When packages are drop ## Excludes Package excludes are hard filters for packages that should not be used in a repo, applied after the allowlist. -Intent reads `intent.exclude` arrays from package.json files while walking from the workspace or project root to the current working directory, then appends any `--exclude` flags. +Intent reads `intent.exclude` arrays from package.json files while walking from the workspace or project root to the current working directory. +Manage persistent excludes with `intent exclude add|remove|list`. ```json { diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 520d0b9..28043d2 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -6,7 +6,7 @@ id: intent-load `intent load` loads a compact skill identity from the current install and prints the matching `SKILL.md` content. ```bash -npx @tanstack/intent@latest load # [--path] [--json] [--debug] [--exclude ] [--global] [--global-only] +npx @tanstack/intent@latest load # [--path] [--json] [--debug] [--global] [--global-only] ``` ## Options @@ -14,7 +14,6 @@ npx @tanstack/intent@latest load # [--path] [--json] [--debug] [ - `--path`: print the resolved skill path instead of the file content - `--json`: print structured JSON with metadata and content - `--debug`: print resolution debug details to stderr -- `--exclude `: exclude a package or skill matching a simple glob; can be passed more than once - `--global`: load from project packages first, then global packages - `--global-only`: load from global packages only @@ -24,7 +23,7 @@ npx @tanstack/intent@latest load # [--path] [--json] [--debug] [ - Scans project-local packages by default - Includes global packages only when `--global` or `--global-only` is passed - Refuses before scanning when the target package is not permitted by `package.json#intent.skills` -- Refuses before scanning when the target package or skill matches `intent.exclude` or `--exclude` +- Refuses before scanning when the target package or skill matches `intent.exclude` - Prefers local packages when `--global` is used and the same package exists locally and globally - Accepts an unambiguous short skill name when a package-prefixed skill exists - Prints raw `SKILL.md` content by default diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 6f8edd1..60fbeaf 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -46,9 +46,28 @@ A package that ships skills but is not listed is dropped. When packages are drop A project that has not set `intent.skills` keeps working. Intent surfaces every discovered package and prints the deprecation notice described under the absent form. Nothing breaks. Add an allowlist when you are ready, before a future version requires one. Run `intent list` to confirm which packages are surfaced. +### Suppressing notices temporarily + +Use `--no-notices` to suppress non-critical notices on stderr for one run: + +```bash +npx @tanstack/intent@latest list --no-notices +npx @tanstack/intent@latest install --map --no-notices +``` + +For CI or wrapper scripts, set `INTENT_NO_NOTICES=1` to suppress notices without changing command arguments. + ## `intent.exclude` -`intent.exclude` removes packages or individual skills after the allowlist resolves. It also accepts the `--exclude ` flag on `list` and `load` for one-off runs. +`intent.exclude` removes packages or individual skills after the allowlist resolves. + +Use `intent exclude` to manage this list from the CLI: + +```bash +npx @tanstack/intent@latest exclude add @tanstack/router#experimental-* +npx @tanstack/intent@latest exclude remove @tanstack/router#experimental-* +npx @tanstack/intent@latest exclude list +``` ```json { diff --git a/docs/config.json b/docs/config.json index ac979b5..656dad5 100644 --- a/docs/config.json +++ b/docs/config.json @@ -47,6 +47,10 @@ "label": "intent install", "to": "cli/intent-install" }, + { + "label": "intent exclude", + "to": "cli/intent-exclude" + }, { "label": "intent list", "to": "cli/intent-list" diff --git a/packages/intent/src/cli-output.ts b/packages/intent/src/cli-output.ts index 25623e8..58c97b2 100644 --- a/packages/intent/src/cli-output.ts +++ b/packages/intent/src/cli-output.ts @@ -7,8 +7,27 @@ export function printWarnings(warnings: Array): void { } } -export function printNotices(notices: Array): void { +export interface NoticeOutputOptions { + noNotices?: boolean +} + +const TRUE_LIKE_VALUES = new Set(['1', 'true', 'yes', 'on']) + +function envSuppressesNotices(): boolean { + const value = process.env.INTENT_NO_NOTICES?.trim().toLowerCase() + return value ? TRUE_LIKE_VALUES.has(value) : false +} + +function shouldSuppressNotices(options: NoticeOutputOptions = {}): boolean { + return options.noNotices === true || envSuppressesNotices() +} + +export function printNotices( + notices: Array, + options: NoticeOutputOptions = {}, +): void { if (notices.length === 0) return + if (shouldSuppressNotices(options)) return console.error('Notices:') for (const notice of notices) { diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 0305d41..a79843d 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -10,9 +10,10 @@ export { printNotices, printWarnings } from './cli-output.js' export interface GlobalScanFlags { debug?: boolean - exclude?: string | Array global?: boolean globalOnly?: boolean + notices?: boolean + noNotices?: boolean } export interface StaleTargetResult { @@ -89,16 +90,17 @@ export function coreOptionsFromGlobalFlags( return { debug: options.debug, - exclude: Array.isArray(options.exclude) - ? options.exclude - : options.exclude - ? [options.exclude] - : undefined, global: options.global, globalOnly: options.globalOnly, } } +export function noticeOptionsFromGlobalFlags(options: GlobalScanFlags): { + noNotices?: boolean +} { + return { noNotices: options.noNotices || options.notices === false } +} + function formatDebugValue(value: string | number | Array): string { if (Array.isArray(value)) { return value.length > 0 ? value.join(', ') : '(none)' diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 7f4570e..2ccd7f2 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { cac } from 'cac' import { fail, isCliFailure } from './cli-error.js' import type { CAC } from 'cac' +import type { ExcludeCommandOptions } from './commands/exclude.js' import type { InstallCommandOptions } from './commands/install.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' @@ -20,14 +21,12 @@ function createCli(): CAC { 'list', 'Discover intent-enabled packages from the project or workspace', ) - .usage( - 'list [--json] [--debug] [--exclude ] [--global] [--global-only]', - ) + .usage('list [--json] [--debug] [--global] [--global-only] [--no-notices]') .option('--json', 'Output JSON') .option('--debug', 'Print discovery debug details to stderr') - .option('--exclude ', 'Exclude package name glob') .option('--global', 'Include global packages after project packages') .option('--global-only', 'List global packages only') + .option('--no-notices', 'Suppress non-critical notices on stderr') .example('list') .example('list --json') .example('list --global') @@ -37,14 +36,33 @@ function createCli(): CAC { }) cli - .command('load [use]', 'Load a compact skill use and print its SKILL.md') - .usage( - 'load [--path] [--json] [--debug] [--exclude ] [--global] [--global-only]', + .command( + 'exclude [action] [pattern]', + 'Manage package.json intent.exclude entries', ) + .usage('exclude [list|add|remove] [pattern] [--json]') + .option('--json', 'Output JSON list of configured exclude patterns') + .example('exclude') + .example('exclude list --json') + .example('exclude add @tanstack/router#experimental-*') + .example('exclude remove @tanstack/router#experimental-*') + .action( + async ( + action: string | undefined, + pattern: string | undefined, + options: ExcludeCommandOptions, + ) => { + const { runExcludeCommand } = await import('./commands/exclude.js') + await runExcludeCommand(action, pattern, options) + }, + ) + + cli + .command('load [use]', 'Load a compact skill use and print its SKILL.md') + .usage('load [--path] [--json] [--debug] [--global] [--global-only]') .option('--path', 'Print the resolved skill path instead of file content') .option('--json', 'Output JSON') .option('--debug', 'Print resolution debug details to stderr') - .option('--exclude ', 'Exclude package name glob') .option('--global', 'Load from project packages, then global packages') .option('--global-only', 'Load from global packages only') .example('load @tanstack/query#core') @@ -86,7 +104,7 @@ function createCli(): CAC { 'Create or update skill loading guidance in an agent config file', ) .usage( - 'install [--map] [--dry-run] [--print-prompt] [--global] [--global-only]', + 'install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', ) .option('--map', 'Write explicit skill-to-task mappings') .option('--dry-run', 'Print the generated block without writing') @@ -96,6 +114,7 @@ function createCli(): CAC { ) .option('--global', 'Include global packages after project packages') .option('--global-only', 'Install mappings from global packages only') + .option('--no-notices', 'Suppress non-critical notices on stderr') .example('install') .example('install --map') .example('install --dry-run') diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts new file mode 100644 index 0000000..2b6f3b3 --- /dev/null +++ b/packages/intent/src/commands/exclude.ts @@ -0,0 +1,192 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fail } from '../cli-error.js' +import { compileExcludePatterns } from '../core/excludes.js' + +export interface ExcludeCommandOptions { + json?: boolean +} + +type ExcludeAction = 'add' | 'list' | 'remove' + +function normalizeAction(action: string | undefined): ExcludeAction { + if (!action) return 'list' + if (action === 'list' || action === 'add' || action === 'remove') + return action + fail(`Unknown exclude action: ${action}. Expected list, add, or remove.`) +} + +function getPackageJsonPath(cwd: string): string { + return join(cwd, 'package.json') +} + +function readPackageJson(cwd: string): Record { + const packageJsonPath = getPackageJsonPath(cwd) + if (!existsSync(packageJsonPath)) { + fail(`No package.json found in ${cwd}`) + } + + try { + return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as Record< + string, + unknown + > + } catch (err) { + fail( + `Failed to parse ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +function readConfiguredExcludes(pkg: Record): Array { + const intent = pkg.intent + if (intent === undefined) return [] + if (!intent || typeof intent !== 'object') { + fail('Invalid package.json: intent must be an object when present.') + } + + const raw = (intent as Record).exclude + if (raw === undefined) return [] + if (!Array.isArray(raw)) { + fail('Invalid package.json: intent.exclude must be an array of strings.') + } + + const excludes: Array = [] + for (const entry of raw) { + if (typeof entry !== 'string') { + fail('Invalid package.json: intent.exclude must contain only strings.') + } + const trimmed = entry.trim() + if (trimmed.length === 0) continue + excludes.push(trimmed) + } + return excludes +} + +function setConfiguredExcludes( + pkg: Record, + excludes: Array, +): void { + const intent = + pkg.intent && typeof pkg.intent === 'object' + ? (pkg.intent as Record) + : {} + + intent.exclude = excludes + pkg.intent = intent +} + +function writePackageJson(cwd: string, pkg: Record): void { + const packageJsonPath = getPackageJsonPath(cwd) + writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8') +} + +function normalizePattern( + pattern: string | undefined, + action: ExcludeAction, +): string { + if (!pattern) { + fail( + `Missing exclude pattern. Expected: intent exclude ${action} `, + ) + } + const trimmed = pattern.trim() + if (trimmed.length === 0) { + fail( + `Missing exclude pattern. Expected: intent exclude ${action} `, + ) + } + return trimmed +} + +function validatePattern(pattern: string): void { + try { + compileExcludePatterns([pattern]) + } catch (err) { + fail( + `Invalid exclude pattern "${pattern}": ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +function printExcludes(excludes: Array, json?: boolean): void { + if (json) { + console.log(JSON.stringify(excludes, null, 2)) + return + } + + if (excludes.length === 0) { + console.log('No excludes configured.') + return + } + + console.log('Configured excludes:') + for (const pattern of excludes) { + console.log(`- ${pattern}`) + } +} + +export async function runExcludeCommand( + actionArg: string | undefined, + patternArg: string | undefined, + options: ExcludeCommandOptions, +): Promise { + const action = normalizeAction(actionArg) + const cwd = process.cwd() + const pkg = readPackageJson(cwd) + const currentExcludes = readConfiguredExcludes(pkg) + + if (action === 'list') { + if (patternArg) { + fail('Unexpected pattern for list. Use: intent exclude list [--json]') + } + printExcludes(currentExcludes, options.json) + return + } + + const pattern = normalizePattern(patternArg, action) + validatePattern(pattern) + + if (action === 'add') { + if (currentExcludes.includes(pattern)) { + if (options.json) { + printExcludes(currentExcludes, true) + return + } + console.log(`Exclude pattern "${pattern}" is already configured.`) + return + } + + const updated = [...currentExcludes, pattern] + setConfiguredExcludes(pkg, updated) + writePackageJson(cwd, pkg) + if (options.json) { + printExcludes(updated, true) + return + } + console.log( + `Added exclude pattern "${pattern}" to package.json intent.exclude.`, + ) + return + } + + const updated = currentExcludes.filter((value) => value !== pattern) + if (updated.length === currentExcludes.length) { + if (options.json) { + printExcludes(currentExcludes, true) + return + } + console.log(`Exclude pattern "${pattern}" is not configured.`) + return + } + + setConfiguredExcludes(pkg, updated) + writePackageJson(cwd, pkg) + if (options.json) { + printExcludes(updated, true) + return + } + console.log( + `Removed exclude pattern "${pattern}" from package.json intent.exclude.`, + ) +} diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index e9953c5..020b0e9 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -3,6 +3,7 @@ import { fail } from '../cli-error.js' import { detectIntentCommandPackageManager } from '../command-runner.js' import { coreOptionsFromGlobalFlags, + noticeOptionsFromGlobalFlags, printNotices, printWarnings, } from '../cli-support.js' @@ -136,10 +137,11 @@ function formatMappingCount(mappingCount: number): string { function printNoActionableSkills( warnings: Array, notices: Array, + noticeOptions: { noNotices?: boolean }, ): void { console.log('No intent-enabled skills found.') printWarnings(warnings) - printNotices(notices) + printNotices(notices, noticeOptions) } function printPlacementTip(targetPath: string): void { @@ -201,6 +203,7 @@ export async function runInstallCommand( } const coreOptions = coreOptionsFromGlobalFlags(options) + const noticeOptions = noticeOptionsFromGlobalFlags(options) if (!options.map) { const generated = buildIntentSkillGuidanceBlock( @@ -256,7 +259,11 @@ export async function runInstallCommand( ) if (!targetPath) { - printNoActionableSkills(scanResult.warnings, scanResult.notices) + printNoActionableSkills( + scanResult.warnings, + scanResult.notices, + noticeOptions, + ) return } @@ -265,7 +272,7 @@ export async function runInstallCommand( ) console.log(generated.block) printWarnings(scanResult.warnings) - printNotices(scanResult.notices) + printNotices(scanResult.notices, noticeOptions) return } @@ -275,7 +282,11 @@ export async function runInstallCommand( }) if (!result.targetPath) { - printNoActionableSkills(scanResult.warnings, scanResult.notices) + printNoActionableSkills( + scanResult.warnings, + scanResult.notices, + noticeOptions, + ) return } @@ -299,5 +310,5 @@ export async function runInstallCommand( printPlacementTip(result.targetPath) printWarnings(scanResult.warnings) - printNotices(scanResult.notices) + printNotices(scanResult.notices, noticeOptions) } diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0749021..c5136b5 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,5 +1,6 @@ import { coreOptionsFromGlobalFlags, + noticeOptionsFromGlobalFlags, printDebugInfo, printNotices, printWarnings, @@ -90,6 +91,7 @@ export async function runListCommand( options: ListCommandOptions, ): Promise { const result = listIntentSkills(coreOptionsFromGlobalFlags(options)) + const noticeOptions = noticeOptionsFromGlobalFlags(options) printListDebug(result) if (options.json) { @@ -111,7 +113,7 @@ export async function runListCommand( console.log() printWarnings(result.warnings) } - printNotices(result.notices) + printNotices(result.notices, noticeOptions) return } @@ -170,5 +172,5 @@ export async function runListCommand( console.log() printWarnings(result.warnings) - printNotices(result.notices) + printNotices(result.notices, noticeOptions) } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 3b8c85a..c7385a1 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -72,6 +72,7 @@ let errorSpy: ReturnType let stdoutWriteSpy: ReturnType let tempDirs: Array let previousGlobalNodeModules: string | undefined +let previousNoNotices: string | undefined function getHelpOutput(): string { return [...infoSpy.mock.calls, ...logSpy.mock.calls] @@ -83,7 +84,9 @@ beforeEach(() => { originalCwd = process.cwd() tempDirs = [] previousGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES + previousNoNotices = process.env.INTENT_NO_NOTICES delete process.env.INTENT_GLOBAL_NODE_MODULES + delete process.env.INTENT_NO_NOTICES logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -99,6 +102,11 @@ afterEach(() => { } else { process.env.INTENT_GLOBAL_NODE_MODULES = previousGlobalNodeModules } + if (previousNoNotices === undefined) { + delete process.env.INTENT_NO_NOTICES + } else { + process.env.INTENT_NO_NOTICES = previousNoNotices + } logSpy.mockRestore() infoSpy.mockRestore() errorSpy.mockRestore() @@ -212,6 +220,117 @@ describe('cli commands', () => { expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) }) + it('lists excludes when none are configured', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-list-empty-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + process.chdir(root) + + const exitCode = await main(['exclude']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith('No excludes configured.') + }) + + it('adds and lists an exclude pattern', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-add-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + process.chdir(root) + + const addExitCode = await main([ + 'exclude', + 'add', + '@tanstack/router#experimental-*', + ]) + const listExitCode = await main(['exclude']) + const pkg = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { + intent?: { exclude?: Array } + } + const output = logSpy.mock.calls.flat().join('\n') + + expect(addExitCode).toBe(0) + expect(listExitCode).toBe(0) + expect(pkg.intent?.exclude).toEqual(['@tanstack/router#experimental-*']) + expect(output).toContain('Configured excludes:') + expect(output).toContain('- @tanstack/router#experimental-*') + }) + + it('removes an exclude pattern', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-remove-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + exclude: ['@tanstack/router#experimental-*'], + }, + }) + process.chdir(root) + + const exitCode = await main([ + 'exclude', + 'remove', + '@tanstack/router#experimental-*', + ]) + const pkg = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { + intent?: { exclude?: Array } + } + + expect(exitCode).toBe(0) + expect(pkg.intent?.exclude).toEqual([]) + expect(logSpy).toHaveBeenCalledWith( + 'Removed exclude pattern "@tanstack/router#experimental-*" from package.json intent.exclude.', + ) + }) + + it('prints excludes as JSON', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-list-json-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + exclude: ['@tanstack/router#experimental-*', '*#draft-*'], + }, + }) + process.chdir(root) + + const exitCode = await main(['exclude', 'list', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const parsed = JSON.parse(String(output)) as Array + + expect(exitCode).toBe(0) + expect(parsed).toEqual(['@tanstack/router#experimental-*', '*#draft-*']) + }) + + it('fails cleanly on unknown exclude actions', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-bad-action-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + }) + process.chdir(root) + + const exitCode = await main(['exclude', 'enable', '@tanstack/router']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown exclude action: enable. Expected list, add, or remove.', + ) + }) + it('writes skill loading guidance by default and is idempotent', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-')) const isolatedGlobalRoot = mkdtempSync( @@ -761,6 +880,61 @@ describe('cli commands', () => { expect(stdout).not.toContain('Notices:') }) + it('suppresses notices when --no-notices is passed', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-no-notices-')) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-list-no-notices-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.chdir(root) + + const exitCode = await main(['list', '--no-notices']) + const stdout = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(stdout).toContain('@tanstack/query') + expect(stderr).not.toContain('intent.skills is not set') + expect(stderr).not.toContain('Notices:') + }) + + it('suppresses notices when INTENT_NO_NOTICES=1 is set', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-env-no-notices-'), + ) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-list-env-no-notices-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_NO_NOTICES = '1' + process.chdir(root) + + const exitCode = await main(['list']) + const stdout = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(stdout).toContain('@tanstack/query') + expect(stderr).not.toContain('intent.skills is not set') + expect(stderr).not.toContain('Notices:') + }) + it('prints list debug details to stderr without changing json stdout', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-debug-')) tempDirs.push(root) @@ -1012,9 +1186,14 @@ describe('cli commands', () => { }) }) - it('excludes packages from list output with --exclude', async () => { + it('excludes packages from list output with package.json intent.exclude', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-exclude-')) tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { exclude: ['@tanstack/*devtools*'] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -1030,12 +1209,7 @@ describe('cli commands', () => { process.chdir(root) - const exitCode = await main([ - 'list', - '--json', - '--exclude', - '@tanstack/*devtools*', - ]) + const exitCode = await main(['list', '--json']) const output = logSpy.mock.calls.at(-1)?.[0] const parsed = JSON.parse(String(output)) as { packages: Array<{ name: string }> @@ -1451,9 +1625,14 @@ describe('cli commands', () => { ) }) - it('fails clearly when loading an excluded package', async () => { + it('fails clearly when loading a package excluded by package.json', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-exclude-')) tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { exclude: ['@tanstack/*devtools*'] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/devtools', version: '1.0.0', @@ -1462,12 +1641,7 @@ describe('cli commands', () => { }) process.chdir(root) - const exitCode = await main([ - 'load', - '@tanstack/devtools#panel', - '--exclude', - '@tanstack/*devtools*', - ]) + const exitCode = await main(['load', '@tanstack/devtools#panel']) expect(exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith(