diff --git a/.changeset/thick-snakes-yell.md b/.changeset/thick-snakes-yell.md new file mode 100644 index 0000000..396e212 --- /dev/null +++ b/.changeset/thick-snakes-yell.md @@ -0,0 +1,9 @@ +--- +'@tanstack/intent': patch +--- + +Add agent-safe hidden skill source handling to `intent list`. + +`intent list` now detects agent sessions with `std-env` and redacts unlisted allowlist candidates from agent-facing output. Agents see allowed skills plus a count-only hidden-source notice, while `intent list --show-hidden` reveals hidden source names only outside agent sessions. + +Agent-mode JSON output includes `hiddenSourceCount` but leaves `hiddenSources` empty, preventing structured output from leaking package names that could be added to `package.json#intent.skills`. diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 3fe825e..ada58a1 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -6,7 +6,7 @@ id: intent-list `intent list` discovers skill-enabled packages and prints available skills. ```bash -npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--no-notices] +npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--no-notices] ``` ## Options @@ -15,6 +15,7 @@ npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [ - `--debug`: print discovery debug details to stderr - `--global`: include global packages after project packages - `--global-only`: list global packages only +- `--show-hidden`: show unlisted hidden skill sources when run outside an agent session - `--no-notices`: suppress non-critical notices on stderr ## What you get @@ -63,6 +64,13 @@ When both local and global packages are scanned, local packages take precedence. "skillCount": 1 } ], + "hiddenSourceCount": 1, + "hiddenSources": [ + { + "name": "hidden-package", + "skillCount": 1 + } + ], "warnings": ["string"], "conflicts": [ { @@ -109,7 +117,7 @@ The list as a whole has three special forms: - **Empty** (`"skills": []`): no package is surfaced, with an info notice printed to stderr. - **Wildcard** (`"skills": ["*"]`): every discovered package is surfaced, with an acknowledged-risk notice printed to stderr. -A package that ships skills but is not listed is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. A listed package that was not discovered is reported as well. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). +A package that ships skills but is not listed is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. A listed package that was not discovered is reported as well. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). ## Excludes diff --git a/packages/intent/package.json b/packages/intent/package.json index cdb6ca1..d26484c 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -29,6 +29,7 @@ "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", + "std-env": "^4.1.0", "yaml": "2.9.0" }, "devDependencies": { diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 0498628..2d488e0 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -22,11 +22,17 @@ function createCli(): CAC { 'list', 'Discover intent-enabled packages from the project or workspace', ) - .usage('list [--json] [--debug] [--global] [--global-only] [--no-notices]') + .usage( + 'list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--no-notices]', + ) .option('--json', 'Output JSON') .option('--debug', 'Print discovery debug details to stderr') .option('--global', 'Include global packages after project packages') .option('--global-only', 'List global packages only') + .option( + '--show-hidden', + 'Show hidden skill sources not listed in intent.skills', + ) .option('--no-notices', 'Suppress non-critical notices on stderr') .example('list') .example('list --json') diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 1108a33..3f32eb7 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -5,6 +5,7 @@ import { printNotices, printWarnings, } from './support.js' +import { detectIntentAudience } from '../shared/environment.js' import { formatIntentCommand } from '../shared/command-runner.js' import { listIntentSkills } from '../core/index.js' import type { GlobalScanFlags } from './support.js' @@ -17,6 +18,7 @@ import type { ScanResult } from '../shared/types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean + showHidden?: boolean } function printListDebug(result: IntentSkillList): void { @@ -87,10 +89,32 @@ function formatLoadCommand( return formatIntentCommand(packageManager, `load ${skill.use}${scopeFlag}`) } +function printHiddenSources(result: IntentSkillList, audience: string): void { + if (audience === 'agent') { + console.log( + 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', + ) + return + } + + if (result.hiddenSources.length === 0) return + + console.log('\nHidden skill sources:\n') + for (const source of result.hiddenSources) { + console.log( + ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})`, + ) + } +} + export async function runListCommand( options: ListCommandOptions, ): Promise { - const result = listIntentSkills(coreOptionsFromGlobalFlags(options)) + const audience = detectIntentAudience() + const result = listIntentSkills({ + ...coreOptionsFromGlobalFlags(options), + audience, + }) const noticeOptions = noticeOptionsFromGlobalFlags(options) printListDebug(result) @@ -109,6 +133,9 @@ export async function runListCommand( if (result.packages.length === 0) { console.log('No intent-enabled packages found.') + if (options.showHidden && result.hiddenSourceCount > 0) { + printHiddenSources(result, audience) + } if (result.warnings.length > 0) { console.log() printWarnings(result.warnings) @@ -131,6 +158,10 @@ export async function runListCommand( printVersionConflicts(result) + if (options.showHidden) { + printHiddenSources(result, audience) + } + const skillsByPackageRoot = groupSkillsByPackageRoot(result.skills) const allSkills = result.packages.map((pkg) => getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index dd13592..ac0e5e5 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -99,12 +99,13 @@ export function listIntentSkills( const scanOptions = toScanOptions(options) const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) - const { scan, excludePatterns } = scanForPolicedIntents({ - cwd, - scanOptions: withFsCache(scanOptions, fsCache), - coreOptions: options, - context: projectContext, - }) + const { hiddenSourceCount, hiddenSources, scan, excludePatterns } = + scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) const packages = scan.packages const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { @@ -132,6 +133,8 @@ export function listIntentSkills( packageRoot: pkg.packageRoot, skillCount: pkg.skills.length, })), + hiddenSourceCount, + hiddenSources, warnings: scan.warnings, notices: scan.notices, conflicts: scan.conflicts, diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index c44c777..5cd24ad 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -1,4 +1,5 @@ import { scanForIntents } from '../discovery/scanner.js' +import { detectIntentAudience } from '../shared/environment.js' import { compileExcludePatterns, getConfigDirs, @@ -10,12 +11,16 @@ import { import { readPackageJson } from './package-json.js' import { parseSkillSources } from './skill-sources.js' import { resolveProjectContext } from './project-context.js' +import type { SkillUse } from '../skills/use.js' +import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' import type { ExcludeMatcher } from './excludes.js' import type { ProjectContext } from './project-context.js' import type { SkillSourcesConfig } from './skill-sources.js' -import type { SkillUse } from '../skills/use.js' -import type { IntentCoreOptions } from './types.js' -import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' +import type { + IntentAudience, + IntentCoreOptions, + IntentHiddenSourceSummary, +} from './types.js' export const ALLOW_ALL_NOTICE = 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' @@ -27,6 +32,7 @@ export const EMPTY_NOTE = 'intent.skills is empty — no skill sources are permitted.' export interface SourcePolicyOptions { + audience?: IntentAudience config: SkillSourcesConfig excludeMatchers: Array } @@ -91,13 +97,29 @@ export function checkLoadAllowed( return null } -function formatUnlistedNotice(names: Array): string { - const sorted = [...names].sort() - const noun = sorted.length === 1 ? 'package ships' : 'packages ship' - return `${sorted.length} discovered ${noun} skills but ${sorted.length === 1 ? 'is' : 'are'} not listed in intent.skills: ${sorted.join(', ')}. Add to opt in.` +function pluralize(count: number, singular: string, plural: string): string { + return count === 1 ? singular : plural +} + +function formatUnlistedNotice( + hiddenSources: Array, + audience: IntentAudience, +): string { + const sorted = [...hiddenSources].sort((a, b) => a.name.localeCompare(b.name)) + const sourceCount = sorted.length + const skillCount = sorted.reduce((sum, source) => sum + source.skillCount, 0) + + if (audience === 'agent') { + return `${sourceCount} discovered ${pluralize(sourceCount, 'skill source', 'skill sources')} with ${skillCount} ${pluralize(skillCount, 'skill', 'skills')} ${pluralize(sourceCount, 'is', 'are')} hidden because ${pluralize(sourceCount, 'it is', 'they are')} not listed in intent.skills. Ask the user to run \`intent list --show-hidden\` outside the agent session to review candidates.` + } + + const noun = sourceCount === 1 ? 'package ships' : 'packages ship' + return `${sourceCount} discovered ${noun} skills but ${sourceCount === 1 ? 'is' : 'are'} not listed in intent.skills: ${sorted.map((source) => source.name).join(', ')}. Add to opt in.` } export interface SourcePolicyResult { + hiddenSourceCount: number + hiddenSources: Array packages: Array notices: Array } @@ -107,6 +129,7 @@ export function applySourcePolicy( options: SourcePolicyOptions, ): SourcePolicyResult { const { config, excludeMatchers } = options + const audience = options.audience ?? 'human' const seen = new Set() const notices: Array = [] @@ -117,14 +140,14 @@ export function applySourcePolicy( } const packages: Array = [] - const unlistedNames: Array = [] + const hiddenSources: Array = [] for (const pkg of scanResult.packages) { if (isPackageExcluded(pkg.name, excludeMatchers)) continue if (!isSourcePermitted(config, pkg.name)) { if (config.mode === 'explicit') { - unlistedNames.push(pkg.name) + hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length }) } continue } @@ -137,8 +160,8 @@ export function applySourcePolicy( ) } - if (unlistedNames.length > 0) { - emit(formatUnlistedNotice(unlistedNames)) + if (hiddenSources.length > 0) { + emit(formatUnlistedNotice(hiddenSources, audience)) } if (config.mode === 'explicit') { @@ -156,7 +179,12 @@ export function applySourcePolicy( else if (config.mode === 'allow-all') emit(ALLOW_ALL_NOTICE) else if (config.mode === 'empty') emit(EMPTY_NOTE) - return { packages, notices } + return { + hiddenSourceCount: hiddenSources.length, + hiddenSources, + packages, + notices, + } } // A null/undefined intent.skills is treated as not-declared so it cannot @@ -180,6 +208,8 @@ export function readSkillSourcesConfig( } export interface PolicedScan { + hiddenSourceCount: number + hiddenSources: Array scan: ScanResult excludePatterns: Array } @@ -192,6 +222,7 @@ export function scanForPolicedIntents(params: { }): PolicedScan { const { cwd, scanOptions, coreOptions } = params const context = params.context ?? resolveProjectContext({ cwd }) + const audience = detectIntentAudience(coreOptions.audience) const scanResult = scanForIntents(cwd, scanOptions) const config = readSkillSourcesConfig(cwd, context) @@ -199,6 +230,7 @@ export function scanForPolicedIntents(params: { const excludeMatchers = compileExcludePatterns(excludePatterns) const policy = applySourcePolicy(scanResult, { + audience, config, excludeMatchers, }) @@ -209,6 +241,8 @@ export function scanForPolicedIntents(params: { .filter((name) => !survivingNames.has(name)) return { + hiddenSourceCount: policy.hiddenSourceCount, + hiddenSources: audience === 'agent' ? [] : policy.hiddenSources, scan: { ...scanResult, packages: policy.packages, diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 361a0f6..7212a3c 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -7,6 +7,7 @@ import type { } from '../shared/types.js' export interface IntentCoreOptions { + audience?: IntentAudience cwd?: string debug?: boolean global?: boolean @@ -14,6 +15,13 @@ export interface IntentCoreOptions { exclude?: Array } +export type IntentAudience = 'agent' | 'human' + +export interface IntentHiddenSourceSummary { + name: string + skillCount: number +} + export interface IntentSkillSummary { use: string packageName: string @@ -38,6 +46,8 @@ export interface IntentSkillList { packageManager: PackageManager skills: Array packages: Array + hiddenSourceCount: number + hiddenSources: Array warnings: Array notices: Array conflicts: Array diff --git a/packages/intent/src/shared/environment.ts b/packages/intent/src/shared/environment.ts new file mode 100644 index 0000000..38069fa --- /dev/null +++ b/packages/intent/src/shared/environment.ts @@ -0,0 +1,18 @@ +import { detectAgent, env } from 'std-env' +import type { IntentAudience } from '../core/types.js' + +export function detectIntentAudience( + explicit?: IntentAudience, +): IntentAudience { + if (explicit) return explicit + + const override = env.INTENT_AUDIENCE?.trim().toLowerCase() + if (override === 'agent' || override === 'human') return override + if (override) { + throw new Error( + 'Invalid INTENT_AUDIENCE value. Expected "agent" or "human".', + ) + } + + return detectAgent().name ? 'agent' : 'human' +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 21b20e9..c81b62b 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -73,6 +73,7 @@ let stdoutWriteSpy: ReturnType let tempDirs: Array let previousGlobalNodeModules: string | undefined let previousNoNotices: string | undefined +let previousIntentAudience: string | undefined function getHelpOutput(): string { return [...infoSpy.mock.calls, ...logSpy.mock.calls] @@ -85,8 +86,10 @@ beforeEach(() => { tempDirs = [] previousGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES previousNoNotices = process.env.INTENT_NO_NOTICES + previousIntentAudience = process.env.INTENT_AUDIENCE delete process.env.INTENT_GLOBAL_NODE_MODULES delete process.env.INTENT_NO_NOTICES + delete process.env.INTENT_AUDIENCE logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -107,6 +110,11 @@ afterEach(() => { } else { process.env.INTENT_NO_NOTICES = previousNoNotices } + if (previousIntentAudience === undefined) { + delete process.env.INTENT_AUDIENCE + } else { + process.env.INTENT_AUDIENCE = previousIntentAudience + } logSpy.mockRestore() infoSpy.mockRestore() errorSpy.mockRestore() @@ -211,6 +219,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('$ intent list [--json]') expect(output).toContain('--json') + expect(output).toContain('--show-hidden') }) it('prints the install prompt', async () => { @@ -863,6 +872,121 @@ describe('cli commands', () => { ) }) + it('reveals hidden skill sources for human list output when requested', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-human-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: 'get-tsconfig', + version: '4.0.0', + skillName: 'config', + description: 'TypeScript config lookup', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Hidden skill sources:') + expect(output).toContain('get-tsconfig') + expect(output).toContain('1 skill') + expect(stderr).toContain('get-tsconfig') + expect(stderr).toContain('Add to opt in') + }) + + it('does not reveal hidden skill sources to agent list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-agent-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: 'get-tsconfig', + version: '4.0.0', + skillName: 'config', + description: 'TypeScript config lookup', + }) + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + const combined = `${output}\n${stderr}` + + expect(exitCode).toBe(0) + expect(combined).toContain( + 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', + ) + expect(combined).toContain( + '1 discovered skill source with 1 skill is hidden', + ) + expect(combined).not.toContain('get-tsconfig') + expect(combined).not.toContain('Add to opt in') + }) + + it('does not reveal hidden skill sources in agent JSON output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-json-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: 'get-tsconfig', + version: '4.0.0', + skillName: 'config', + description: 'TypeScript config lookup', + }) + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['list', '--json']) + const output = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + const parsed = JSON.parse(output) as { + hiddenSourceCount: number + hiddenSources: Array + notices: Array + } + + expect(exitCode).toBe(0) + expect(parsed.hiddenSourceCount).toBe(1) + expect(parsed.hiddenSources).toEqual([]) + expect(parsed.notices).toEqual([ + '1 discovered skill source with 1 skill is hidden because it is not listed in intent.skills. Ask the user to run `intent list --show-hidden` outside the agent session to review candidates.', + ]) + expect(output).not.toContain('get-tsconfig') + expect(output).not.toContain('Add to opt in') + }) + it.each([ ['pnpm-lock.yaml', 'pnpm dlx @tanstack/intent@latest'], ['yarn.lock', 'yarn dlx @tanstack/intent@latest'], diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 0d55629..4b8800c 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -107,7 +107,7 @@ describe('listIntentSkills', () => { framework: 'react', }) - const result = listIntentSkills({ cwd: root }) + const result = listIntentSkills({ audience: 'human', cwd: root }) expect(result).toEqual({ packageManager: 'unknown', @@ -133,6 +133,8 @@ describe('listIntentSkills', () => { skillCount: 1, }, ], + hiddenSourceCount: 0, + hiddenSources: [], warnings: [], notices: [], conflicts: [], @@ -193,7 +195,7 @@ describe('listIntentSkills', () => { description: 'Devtools panel skill', }) - const result = listIntentSkills({ cwd: root }) + const result = listIntentSkills({ audience: 'human', cwd: root }) expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) expect(result.skills.map((skill) => skill.use)).toEqual([ @@ -261,14 +263,49 @@ describe('listIntentSkills', () => { description: 'Unlisted skill', }) - const result = listIntentSkills({ cwd: root }) + const result = listIntentSkills({ audience: 'human', cwd: root }) expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) + expect(result.hiddenSourceCount).toBe(1) + expect(result.hiddenSources).toEqual([ + { name: '@tanstack/unlisted', skillCount: 1 }, + ]) expect(result.notices).toEqual([ '1 discovered package ships skills but is not listed in intent.skills: @tanstack/unlisted. Add to opt in.', ]) }) + it('redacts unlisted package names from agent list notices', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/unlisted', + version: '1.0.0', + skillName: 'panel', + description: 'Unlisted skill', + }) + + const result = listIntentSkills({ audience: 'agent', cwd: root }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) + expect(result.hiddenSourceCount).toBe(1) + expect(result.hiddenSources).toEqual([]) + expect(result.notices).toEqual([ + '1 discovered skill source with 1 skill is hidden because it is not listed in intent.skills. Ask the user to run `intent list --show-hidden` outside the agent session to review candidates.', + ]) + expect(JSON.stringify(result)).not.toContain('@tanstack/unlisted') + expect(JSON.stringify(result)).not.toContain('Add to opt in') + }) + it('drops a skill-level excluded skill from an allowlisted package', () => { writeJson(join(root, 'package.json'), { name: 'test-app', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d62027..546d7ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: semver: specifier: ^7.8.4 version: 7.8.4 + std-env: + specifier: ^4.1.0 + version: 4.1.0 yaml: specifier: 2.9.0 version: 2.9.0