Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/thick-snakes-yell.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 10 additions & 2 deletions docs/cli/intent-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/intent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
33 changes: 32 additions & 1 deletion packages/intent/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
const result = listIntentSkills(coreOptionsFromGlobalFlags(options))
const audience = detectIntentAudience()
const result = listIntentSkills({
...coreOptionsFromGlobalFlags(options),
audience,
})
const noticeOptions = noticeOptionsFromGlobalFlags(options)
printListDebug(result)

Expand All @@ -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)
Expand All @@ -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) => ({
Expand Down
15 changes: 9 additions & 6 deletions packages/intent/src/core/intent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 46 additions & 12 deletions packages/intent/src/core/source-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { scanForIntents } from '../discovery/scanner.js'
import { detectIntentAudience } from '../shared/environment.js'
import {
compileExcludePatterns,
getConfigDirs,
Expand All @@ -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.'
Expand All @@ -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<ExcludeMatcher>
}
Expand Down Expand Up @@ -91,13 +97,29 @@ export function checkLoadAllowed(
return null
}

function formatUnlistedNotice(names: Array<string>): 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<IntentHiddenSourceSummary>,
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<IntentHiddenSourceSummary>
packages: Array<IntentPackage>
notices: Array<string>
}
Expand All @@ -107,6 +129,7 @@ export function applySourcePolicy(
options: SourcePolicyOptions,
): SourcePolicyResult {
const { config, excludeMatchers } = options
const audience = options.audience ?? 'human'
const seen = new Set<string>()
const notices: Array<string> = []

Expand All @@ -117,14 +140,14 @@ export function applySourcePolicy(
}

const packages: Array<IntentPackage> = []
const unlistedNames: Array<string> = []
const hiddenSources: Array<IntentHiddenSourceSummary> = []

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
}
Expand All @@ -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') {
Expand All @@ -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
Expand All @@ -180,6 +208,8 @@ export function readSkillSourcesConfig(
}

export interface PolicedScan {
hiddenSourceCount: number
hiddenSources: Array<IntentHiddenSourceSummary>
scan: ScanResult
excludePatterns: Array<string>
}
Expand All @@ -192,13 +222,15 @@ 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)
const excludePatterns = getEffectiveExcludePatterns(coreOptions, context)
const excludeMatchers = compileExcludePatterns(excludePatterns)

const policy = applySourcePolicy(scanResult, {
audience,
config,
excludeMatchers,
})
Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/intent/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ import type {
} from '../shared/types.js'

export interface IntentCoreOptions {
audience?: IntentAudience
cwd?: string
debug?: boolean
global?: boolean
globalOnly?: boolean
exclude?: Array<string>
}

export type IntentAudience = 'agent' | 'human'

export interface IntentHiddenSourceSummary {
name: string
skillCount: number
}

export interface IntentSkillSummary {
use: string
packageName: string
Expand All @@ -38,6 +46,8 @@ export interface IntentSkillList {
packageManager: PackageManager
skills: Array<IntentSkillSummary>
packages: Array<IntentPackageSummary>
hiddenSourceCount: number
hiddenSources: Array<IntentHiddenSourceSummary>
warnings: Array<string>
notices: Array<string>
conflicts: Array<VersionConflict>
Expand Down
18 changes: 18 additions & 0 deletions packages/intent/src/shared/environment.ts
Original file line number Diff line number Diff line change
@@ -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'
}
Loading
Loading