Skip to content
Open
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
16 changes: 8 additions & 8 deletions docs/cli/intent-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ skills:
<!-- intent-skills:end -->
```

They also ask you to:
1. Check for an existing block first
2. Run `intent list` to discover installed skills
3. Ask whether you want a config target other than `AGENTS.md`
4. Update an existing block in place when one already exists
5. Add task-to-skill mappings
6. Preserve all content outside the tagged block
They also ask you to:

1. Check for an existing block first
2. Run `intent list` to discover installed skills, including any packages surfaced by the command's explicit global scan
3. Ask whether you want a config target other than `AGENTS.md`
4. Update an existing block in place when one already exists
5. Add task-to-skill mappings
6. Preserve all content outside the tagged block

If no existing block is found, `AGENTS.md` is the default target.

Expand Down
9 changes: 6 additions & 3 deletions docs/cli/intent-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ npx @tanstack/intent@latest list [--json]

## What you get

- Scans installed dependencies for intent-enabled packages and skills
- Scans project and workspace dependencies for intent-enabled packages and skills
- Intentionally includes accessible global packages when listing installed skills
- Includes warnings from discovery
- If no packages are discovered, prints `No intent-enabled packages found.`
- Summary line with package count, skill count, and detected package manager
- Package table columns: `PACKAGE`, `VERSION`, `SKILLS`, `REQUIRES`
- Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS`, `REQUIRES`
Comment on lines +18 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The discovery scope text is still narrower than the implementation.

scanForIntents() walks workspace packages directly, not just dependencies, so this wording undersells what intent list can return.

📝 Suggested wording
-- Scans project and workspace dependencies for intent-enabled packages and skills
+- Scans the project, workspace packages, and their dependencies for intent-enabled packages and skills
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/cli/intent-list.md` around lines 18 - 23, The documentation's "discovery
scope" wording is too narrow; update the intent-list docs to reflect that
scanForIntents() examines workspace packages as well as project and workspace
dependencies and that intent list can also include accessible global packages
and discovery warnings—adjust the bulleted scope lines and the sentence that
states what is scanned so it explicitly mentions "workspace packages (not just
dependencies), workspace/project dependencies, and accessible global packages"
and retain the notes about warnings, summary line, and table columns.

- Skill tree grouped by package
- Optional warnings section (`⚠ ...` per warning)

`REQUIRES` uses `intent.requires` values joined by `, `; empty values render as `–`.
`SOURCE` is a lightweight indicator showing whether the selected package came from local discovery or explicit global scanning.

## JSON output

Expand All @@ -36,6 +38,7 @@ npx @tanstack/intent@latest list [--json]
{
"name": "string",
"version": "string",
"source": "local | global",
"intent": {
"version": 1,
"repo": "string",
Expand All @@ -57,7 +60,7 @@ npx @tanstack/intent@latest list [--json]
}
```

`packages` are ordered using `intent.requires` when possible.
`packages` are ordered using `intent.requires` when possible. When the same package exists both locally and globally, `intent list` prefers the local package.

## Common errors

Expand Down
3 changes: 2 additions & 1 deletion docs/cli/intent-stale.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ npx @tanstack/intent@latest stale [--json]

## Behavior

- Scans installed intent-enabled packages
- Checks the current package by default, or all skill-bearing packages in the current workspace when run from a monorepo root
- When `dir` is provided, scopes the check to the targeted package or skills directory
- Computes one staleness report per package
- Prints text output by default or JSON with `--json`
- If no packages are found, prints `No intent-enabled packages found.`
Expand Down
2 changes: 2 additions & 0 deletions docs/getting-started/quick-start-maintainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ Manually check which skills need updates with:
npx @tanstack/intent@latest stale
```

When run from a package, this checks that package's shipped skills. When run from a monorepo root, it checks the workspace packages that ship skills.

This detects:
- **Version drift** — skill targets an older library version than currently installed
- **New sources** — sources declared in frontmatter that weren't tracked before
Expand Down
4 changes: 2 additions & 2 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ id: overview

`@tanstack/intent` is a CLI for shipping and consuming Agent Skills as package artifacts.

Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from local and accessible global `node_modules`, and helps agents load them automatically when working on matching tasks.
Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from your project and workspace by default, and helps agents load them automatically when working on matching tasks.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use proper casing for “Markdown”.

Use Markdown (proper noun) for consistency with technical documentation conventions.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~8-~8: Did you mean the formatting language “Markdown” (= proper noun)?
Context: ...lls as package artifacts.

Skills are markdown documents that teach AI coding agents h...

(MARKDOWN_NNP)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/overview.md` at line 8, Update the casing of the term "markdown" to the
proper noun "Markdown" in the sentence that defines Skills (the sentence
containing "Skills are markdown documents..."); replace the lowercase instance
with "Markdown" to ensure consistent technical documentation convention across
docs/overview.md.


## What Intent does

Expand All @@ -30,7 +30,7 @@ Intent provides tooling for two workflows:
npx @tanstack/intent@latest list
```

Scans local `node_modules` and any accessible global `node_modules` for intent-enabled packages, preferring local packages when both exist.
Scans the current project's `node_modules` and workspace dependencies for intent-enabled packages. The CLI intentionally includes accessible global packages for this command and still prefers local packages when both exist.

```bash
npx @tanstack/intent@latest install
Expand Down
8 changes: 5 additions & 3 deletions packages/intent/src/cli-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fail } from './cli-error.js'
import { resolveProjectContext } from './core/project-context.js'
import type { ScanResult, StalenessReport } from './types.js'
import type { ScanOptions, ScanResult, StalenessReport } from './types.js'

export function printWarnings(warnings: Array<string>): void {
if (warnings.length === 0) return
Expand All @@ -19,11 +19,13 @@ export function getMetaDir(): string {
return join(thisDir, '..', 'meta')
}

export async function scanIntentsOrFail(): Promise<ScanResult> {
export async function scanIntentsOrFail(
options?: ScanOptions,
): Promise<ScanResult> {
const { scanForIntents } = await import('./scanner.js')

try {
return scanForIntents()
return scanForIntents(undefined, options)
} catch (err) {
fail(err instanceof Error ? err.message : String(err))
}
Expand Down
14 changes: 11 additions & 3 deletions packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ function createCli(): CAC {
cli.usage('<command> [options]')

cli
.command('list', 'Discover intent-enabled packages')
.command(
'list',
'Discover intent-enabled packages from the project, workspace, and explicit global scan',
)
.usage('list [--json]')
.option('--json', 'Output JSON')
.example('list')
.example('list --json')
.action(async (options: { json?: boolean }) => {
await runListCommand(options, scanIntentsOrFail)
await runListCommand(options, () =>
scanIntentsOrFail({ includeGlobal: true }),
)
})

cli
Expand Down Expand Up @@ -68,7 +73,10 @@ function createCli(): CAC {
})

cli
.command('stale [dir]', 'Check skills for staleness')
.command(
'stale [dir]',
'Check skills for staleness in the current package or workspace',
)
.usage('stale [dir] [--json]')
.option('--json', 'Output JSON')
.example('stale')
Expand Down
8 changes: 5 additions & 3 deletions packages/intent/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ export async function runListCommand(
options: { json?: boolean },
scanIntentsOrFail: () => Promise<ScanResult>,
): Promise<void> {
const { computeSkillNameWidth, printSkillTree, printTable } =
await import('../display.js')
const result = await scanIntentsOrFail()

if (options.json) {
console.log(JSON.stringify(result, null, 2))
return
}

const { computeSkillNameWidth, printSkillTree, printTable } =
await import('../display.js')

const scanCoverage = formatScanCoverage(result)

if (result.packages.length === 0) {
Expand Down Expand Up @@ -69,11 +70,12 @@ export async function runListCommand(

const rows = result.packages.map((pkg) => [
pkg.name,
pkg.source,
pkg.version,
String(pkg.skills.length),
pkg.intent.requires?.join(', ') || '–',
])
printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows)
printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS', 'REQUIRES'], rows)

printVersionConflicts(result)

Expand Down
8 changes: 4 additions & 4 deletions packages/intent/src/commands/stale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ export async function runStaleCommand(
): Promise<void> {
const { reports } = await resolveStaleTargets(targetDir)

if (reports.length === 0) {
console.log('No intent-enabled packages found.')
if (options.json) {
console.log(JSON.stringify(reports, null, 2))
return
}

if (options.json) {
console.log(JSON.stringify(reports, null, 2))
if (reports.length === 0) {
console.log('No intent-enabled packages found.')
return
}

Expand Down
2 changes: 2 additions & 0 deletions packages/intent/src/discovery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createPackageRegistrar } from './register.js'
export { createDependencyWalker } from './walk.js'
129 changes: 129 additions & 0 deletions packages/intent/src/discovery/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { existsSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { listNodeModulesPackageDirs, toPosixPath } from '../utils.js'
import type {
IntentConfig,
IntentPackage,
NodeModulesScanTarget,
SkillEntry,
} from '../types.js'

type PackageJson = Record<string, unknown>

export interface CreatePackageRegistrarOptions {
comparePackageVersions: (a: string, b: string) => number
deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null
discoverSkills: (skillsDir: string, baseName: string) => Array<SkillEntry>
getPackageDepth: (packageRoot: string, projectRoot: string) => number
packageIndexes: Map<string, number>
packages: Array<IntentPackage>
projectRoot: string
readPkgJson: (dirPath: string) => PackageJson | null
rememberVariant: (pkg: IntentPackage) => void
validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null
warnings: Array<string>
}

export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) {
function scanTarget(
target: NodeModulesScanTarget,
source: IntentPackage['source'] = 'local',
): void {
if (!target.path || !target.exists || target.scanned) return
target.scanned = true

for (const dirPath of listNodeModulesPackageDirs(target.path)) {
tryRegister(dirPath, 'unknown', source)
}
}

function tryRegister(
dirPath: string,
fallbackName: string,
source: IntentPackage['source'] = 'local',
): boolean {
const skillsDir = join(dirPath, 'skills')
if (!existsSync(skillsDir)) return false

const pkgJson = opts.readPkgJson(dirPath)
if (!pkgJson) {
opts.warnings.push(`Could not read package.json for ${dirPath}`)
return false
}

const name = typeof pkgJson.name === 'string' ? pkgJson.name : fallbackName
const version =
typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0'
const intent =
opts.validateIntentField(name, pkgJson.intent) ??
opts.deriveIntentConfig(pkgJson)
if (!intent) {
opts.warnings.push(
`${name} has a skills/ directory but could not determine repo/docs from package.json (add a "repository" field or explicit "intent" config)`,
)
return false
}

const skills = opts.discoverSkills(skillsDir, name)

const isLocal =
dirPath.startsWith(opts.projectRoot + sep) ||
dirPath.startsWith(opts.projectRoot + '/')
if (isLocal) {
const hasStableSymlink =
name !== '' && existsSync(join(opts.projectRoot, 'node_modules', name))
for (const skill of skills) {
if (hasStableSymlink) {
const relFromPkg = toPosixPath(relative(dirPath, skill.path))
skill.path = `node_modules/${name}/${relFromPkg}`
} else {
skill.path = toPosixPath(relative(opts.projectRoot, skill.path))
}
}
}

const candidate: IntentPackage = {
name,
version,
intent,
skills,
packageRoot: dirPath,
source,
}
const existingIndex = opts.packageIndexes.get(name)
if (existingIndex === undefined) {
opts.rememberVariant(candidate)
opts.packageIndexes.set(name, opts.packages.push(candidate) - 1)
return true
}

const existing = opts.packages[existingIndex]!
if (existing.packageRoot === candidate.packageRoot) {
return false
}

opts.rememberVariant(existing)
opts.rememberVariant(candidate)

const existingDepth = opts.getPackageDepth(
existing.packageRoot,
opts.projectRoot,
)
const candidateDepth = opts.getPackageDepth(
candidate.packageRoot,
opts.projectRoot,
)
const shouldReplace =
candidateDepth < existingDepth ||
(candidateDepth === existingDepth &&
opts.comparePackageVersions(candidate.version, existing.version) > 0)

if (shouldReplace) {
opts.packages[existingIndex] = candidate
}
Comment on lines +108 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce local-over-global precedence before depth/version tie-breaking.

The replacement rule ignores candidate.source, so the new “prefer local over global” behavior currently depends on filesystem layout. A hoisted local package scanned from a workspace subdir can tie a nearby explicit global root on getPackageDepth(), and a newer global version will replace the local candidate.

💡 Suggested fix
     const shouldReplace =
-      candidateDepth < existingDepth ||
-      (candidateDepth === existingDepth &&
-        opts.comparePackageVersions(candidate.version, existing.version) > 0)
+      existing.source === 'global' && candidate.source === 'local'
+        ? true
+        : existing.source === 'local' && candidate.source === 'global'
+          ? false
+          : candidateDepth < existingDepth ||
+            (candidateDepth === existingDepth &&
+              opts.comparePackageVersions(
+                candidate.version,
+                existing.version,
+              ) > 0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/discovery/register.ts` around lines 108 - 123, The
replacement logic for package candidates ignores the package source and can let
a global package replace a local one; update the decision in the register logic
to enforce local-over-global first: before applying the depth/version tie-break,
check candidate.source and existing.source (e.g., if candidate.source ===
'local' and existing.source !== 'local' then set shouldReplace = true, and if
existing.source === 'local' and candidate.source !== 'local' then set
shouldReplace = false), otherwise fall back to the existingDepth/candidateDepth
and opts.comparePackageVersions(existing.version, candidate.version) logic and
then assign into opts.packages[existingIndex] when shouldReplace is true. Ensure
you reference and use existing.packageRoot, candidate.packageRoot,
existingIndex, opts.getPackageDepth and opts.comparePackageVersions as currently
used.


return true
}

return { scanTarget, tryRegister }
}
Loading
Loading