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
5 changes: 5 additions & 0 deletions .changeset/calm-dingos-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Support `*` package patterns such as `@tanstack/*` in `intent.skills` allowlists.
5 changes: 3 additions & 2 deletions docs/cli/intent-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,16 @@ Each entry is one source:

- `@scope/pkg` or `pkg`: an npm package reachable through the dependency tree.
- `workspace:@scope/pkg`: a package in the current workspace.
- `@scope/*` or `workspace:@scope/*`: every discovered package of that kind whose name matches the pattern.
- `git:<host>/<repo>#<ref>`: reserved, and not yet supported.

The list as a whole has three special forms:

- **Absent** (no `intent.skills` key): every discovered package is surfaced, with a deprecation notice printed to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist.
- **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.
- **Wildcard** (`"skills": ["*"]`): every discovered package is surfaced, with an acknowledged-risk notice printed to stderr. This exact trust-all entry is distinct from a scoped package pattern such as `@tanstack/*`.

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).
A package that ships skills but is not listed or matched by a pattern 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. An exact entry or pattern that matches no discovered package is reported as well. Package patterns support `*` wildcards. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model).

## Excludes

Expand Down
5 changes: 3 additions & 2 deletions docs/concepts/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,18 @@ Each array entry names one source:
| ----- | ---- | ------- |
| `@scope/pkg` or `pkg` | npm | A package reachable through the dependency tree, direct or transitive. |
| `workspace:@scope/pkg` | workspace | A package in the current workspace. |
| `@scope/*` or `workspace:@scope/*` | npm or workspace | Every discovered package of that kind whose name matches the pattern. |
| `git:<host>/<repo>#<ref>` | 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. Intent currently matches an allowlist entry against a discovered package by name. This matching will tighten in a future version.
A malformed entry fails the whole command, and every bad entry is reported at once. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`. Intent matches allowlist entries against discovered package names. This matching will tighten in a future version.

### Special forms

The list as a whole has three special forms:

- **Absent.** No `intent.skills` key. Every discovered package is surfaced, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist.
- **Empty.** `"skills": []`. No package is surfaced. Intent prints an info notice to stderr.
- **Wildcard.** `"skills": ["*"]`. Every discovered package is surfaced. Intent prints an acknowledged-risk notice to stderr, since unvetted skills may reach your agent.
- **Wildcard.** `"skills": ["*"]`. Every discovered package is surfaced. Unlike a package pattern such as `@tanstack/*`, this exact entry crosses package scopes and source kinds. Intent prints an acknowledged-risk notice to stderr, since unvetted skills may reach your agent.

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.

Expand Down
4 changes: 2 additions & 2 deletions docs/concepts/trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ Intent surfaces skills from your dependencies into your coding agent's guidance.

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 it appears in the allowlist. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it.
`package.json#intent.skills` is the gate. A discovered package contributes skills only when an exact entry or `*` pattern in the allowlist matches it. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it.

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.

Trust does not propagate. A listed package may depend on another package that ships skills, but that dependency stays unlisted until you add it to `intent.skills` yourself. You allow each source on its own.
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. Exact entries allow one source; patterns such as `@tanstack/*` explicitly allow every matching source.

## Static discovery

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started/quick-start-consumers.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ Hooks add the available Intent skill catalog to supported agent sessions and kee
```json
{
"intent": {
"skills": ["@tanstack/query", "@tanstack/router"]
"skills": ["@tanstack/*"]
}
}
```

List the packages you trust. Intent then surfaces skills from those 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.
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.

## 3. Use skills in your workflow

Expand Down
15 changes: 10 additions & 5 deletions packages/intent/src/core/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ function globToRegExp(pattern: string): RegExp {
return new RegExp(`^${source}$`)
}

function compileSegment(segment: string): (value: string) => boolean {
export function compileWildcardPattern(
segment: string,
): (value: string) => boolean {
if (!segment.includes('*')) {
return (value) => value === segment
}
Expand All @@ -108,20 +110,23 @@ export function compileExcludePatterns(

const hashIndex = pattern.indexOf('#')
if (hashIndex === -1) {
return { pattern, matchesPackage: compileSegment(pattern) }
return { pattern, matchesPackage: compileWildcardPattern(pattern) }
}

const packageSegment = pattern.slice(0, hashIndex)
const skillSegment = pattern.slice(hashIndex + 1)

if (skillSegment.replace(/\*+/g, '*') === '*') {
return { pattern, matchesPackage: compileSegment(packageSegment) }
return {
pattern,
matchesPackage: compileWildcardPattern(packageSegment),
}
}

return {
pattern,
matchesPackage: compileSegment(packageSegment),
matchesSkill: compileSegment(skillSegment),
matchesPackage: compileWildcardPattern(packageSegment),
matchesSkill: compileWildcardPattern(skillSegment),
}
})
}
Expand Down
48 changes: 29 additions & 19 deletions packages/intent/src/core/skill-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
// resolves, requires, or executes any discovered package.

/**
* `kind` + `id` is the identity M2's lockfile reuses; `ref` exists only on
* `git`. The `git` variant is never constructed in M1 (git entries are rejected
* at parse time) but is defined here so M2 builds on this shape.
* Exact entries keep the `kind` + `id` identity M2's lockfile reuses. Patterns
* select multiple discovered identities and remain distinct from exact entries.
* The `git` variant is never constructed in M1 (git entries are rejected at
* parse time) but is defined here so M2 builds on this shape.
*/
type SkillSource =
| { raw: string; id: string; kind: 'npm' }
| { raw: string; id: string; kind: 'workspace' }
| ({ raw: string; kind: 'npm' | 'workspace' } & (
| { id: string }
| { pattern: string }
))
| { raw: string; id: string; kind: 'git'; ref: string }

/**
Expand Down Expand Up @@ -94,19 +97,17 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig {
continue
}

// The wildcard is a trust-all switch, so it must be the exact string `"*"`.
// Any other entry containing `*` (whitespace-wrapped, or a glob like
// `@scope/*`) is rejected rather than silently flipping to allow-all or
// becoming a bogus source — `intent.skills` is not glob-matched.
if (entry.includes('*')) {
if (entry === '*') {
allowAll = true
continue
}
if (entry === '*') {
allowAll = true
continue
}

// Only the exact raw entry is the trust-all switch. Whitespace must not
// turn a package pattern into allow-all after normalization.
if (trimmed === '*') {
issues.push({
raw: entry,
message:
'The "*" wildcard must be the exact entry "*"; globs are not supported in intent.skills.',
message: 'The "*" wildcard must be the exact entry "*".',
})
continue
}
Expand All @@ -117,7 +118,8 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig {
continue
}

const identity = `${parsed.kind}\u0000${parsed.id}`
const selector = 'pattern' in parsed ? parsed.pattern : parsed.id
const identity = `${parsed.kind}\u0000${selector}`
if (seenIdentity.has(identity)) continue
seenIdentity.add(identity)
sources.push(parsed)
Expand Down Expand Up @@ -145,7 +147,7 @@ function parseEntry(
const invalid = validateId(trimmed)
if (invalid)
return { raw, message: `Invalid npm source "${trimmed}": ${invalid}` }
return { raw, id: trimmed, kind: 'npm' }
return packageSource(raw, trimmed, 'npm')
}

const prefix = trimmed.slice(0, colon)
Expand All @@ -166,7 +168,7 @@ function parseEntry(
message: `Invalid workspace source "${trimmed}": ${invalid}`,
}
}
return { raw, id: rest, kind: 'workspace' }
return packageSource(raw, rest, 'workspace')
}
case 'git':
return {
Expand All @@ -181,6 +183,14 @@ function parseEntry(
}
}

function packageSource(
raw: string,
id: string,
kind: 'npm' | 'workspace',
): SkillSource {
return id.includes('*') ? { raw, pattern: id, kind } : { raw, id, kind }
}

function validateId(id: string): string | null {
if (id.includes('#')) {
return 'skill-level granularity (#) is not supported in intent.skills (it is package-level); use intent.exclude for skill-level control.'
Expand Down
94 changes: 65 additions & 29 deletions packages/intent/src/core/source-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { detectIntentAudience } from '../shared/environment.js'
import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js'
import {
compileExcludePatterns,
compileWildcardPattern,
getConfigDirs,
getEffectiveExcludePatterns,
isPackageExcluded,
Expand All @@ -12,7 +13,6 @@ import {
import { readPackageJson } from './package-json.js'
import { parseSkillSources } from './skill-sources.js'
import { resolveProjectContext } from './project-context.js'
import { sourceIdentityKey } from './types.js'
import type { SkillUse } from '../skills/use.js'
import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js'
import type { ExcludeMatcher } from './excludes.js'
Expand Down Expand Up @@ -48,25 +48,70 @@ export interface LoadRefusal {
message: string
}

export function isSourcePermitted(
config: SkillSourcesConfig,
packageName: string,
packageKind?: 'npm' | 'workspace',
): boolean {
type ExplicitSkillSource = Extract<
SkillSourcesConfig,
{ mode: 'explicit' }
>['sources'][number]

interface SkillSourceMatcher {
source: ExplicitSkillSource
matchesPackage: (
packageName: string,
packageKind?: 'npm' | 'workspace',
) => boolean
}

function compileSkillSourceMatcher(
source: ExplicitSkillSource,
): SkillSourceMatcher {
if (source.kind === 'git') {
return { source, matchesPackage: () => false }
}

const matchesName =
'pattern' in source
? compileWildcardPattern(source.pattern)
: (packageName: string) => source.id === packageName

return {
source,
matchesPackage: (packageName, packageKind) =>
(packageKind === undefined || source.kind === packageKind) &&
matchesName(packageName),
}
}

function compileSkillSourcePolicy(config: SkillSourcesConfig): {
matchers: Array<SkillSourceMatcher>
permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean
} {
switch (config.mode) {
case 'absent':
case 'allow-all':
return true
return { matchers: [], permits: () => true }
case 'empty':
return false
case 'explicit':
return config.sources.some((source) => {
if (source.id !== packageName) return false
return packageKind === undefined || source.kind === packageKind
})
return { matchers: [], permits: () => false }
case 'explicit': {
const matchers = config.sources.map(compileSkillSourceMatcher)
return {
matchers,
permits: (packageName, packageKind) =>
matchers.some((matcher) =>
matcher.matchesPackage(packageName, packageKind),
),
}
}
}
}

export function isSourcePermitted(
config: SkillSourcesConfig,
packageName: string,
packageKind?: 'npm' | 'workspace',
): boolean {
return compileSkillSourcePolicy(config).permits(packageName, packageKind)
}

export function packageNotListedRefusal(
use: string,
packageName: string,
Expand Down Expand Up @@ -145,6 +190,7 @@ export function applySourcePolicy(
): SourcePolicyResult {
const { config, excludeMatchers } = options
const audience = options.audience ?? 'human'
const sourcePolicy = compileSkillSourcePolicy(config)
const seen = new Set<string>()
const notices: Array<string> = []

Expand All @@ -160,7 +206,7 @@ export function applySourcePolicy(
for (const pkg of scanResult.packages) {
if (isPackageExcluded(pkg.name, excludeMatchers)) continue

if (!isSourcePermitted(config, pkg.name, pkg.kind)) {
if (!sourcePolicy.permits(pkg.name, pkg.kind)) {
if (config.mode === 'explicit') {
hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length })
}
Expand All @@ -180,23 +226,13 @@ export function applySourcePolicy(
}

if (config.mode === 'explicit') {
const discoveredKeys = new Set(
scanResult.packages.map((pkg) =>
sourceIdentityKey({ kind: pkg.kind, id: pkg.name }),
),
)
for (const source of config.sources) {
// git sources can't appear in config yet (parseSkillSources rejects them),
// and IntentPackage.kind excludes 'git', so treat as always-not-discovered
// until git discovery lands — revisit this line then.
const notDiscovered =
source.kind === 'git' ||
!discoveredKeys.has(
sourceIdentityKey({ kind: source.kind, id: source.id }),
)
for (const matcher of sourcePolicy.matchers) {
const notDiscovered = !scanResult.packages.some((pkg) =>
matcher.matchesPackage(pkg.name, pkg.kind),
)
if (notDiscovered) {
emit(
`"${source.raw}" is declared in intent.skills but was not discovered.`,
`"${matcher.source.raw}" is declared in intent.skills but was not discovered.`,
)
}
}
Expand Down
18 changes: 18 additions & 0 deletions packages/intent/tests/integration/source-policy-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ describe('source policy — all four surfaces filter excluded and unlisted', ()
)
})

it('list and load accept packages matched by an allowlist glob', () => {
writeJson(join(root, 'package.json'), {
name: 'app',
private: true,
intent: { skills: ['@scope/*'] },
})
writeIntentPackage(root, LISTED, 'core')
writeIntentPackage(root, UNLISTED, 'core')
writeIntentPackage(root, '@other/hidden', 'core')

const listed = listIntentSkills({ cwd: root })

expect(listed.packages.map((pkg) => pkg.name)).toEqual([LISTED, UNLISTED])
expect(loadIntentSkill(`${UNLISTED}#core`, { cwd: root }).packageName).toBe(
UNLISTED,
)
})

it('load refuses the unlisted and excluded packages but allows the listed one', () => {
writeStandaloneFixture()

Expand Down
Loading
Loading