Skip to content
5 changes: 5 additions & 0 deletions .changeset/dull-tigers-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Fix package discovery for Yarn PnP projects and pnpm symlinked dependency trees, and use the detected package manager when printing Intent commands.
8 changes: 6 additions & 2 deletions docs/getting-started/quick-start-consumers.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ The install command guides your agent through the setup process:
npx @tanstack/intent@latest install
```

Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`.

This creates or updates an `intent-skills` guidance block. It:

1. Checks for existing `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.)
Expand All @@ -30,13 +32,15 @@ Intent creates guidance like:
## Skill Loading

Before substantial work:
- Skill check: run `npx @tanstack/intent@latest list`, or use skills already listed in context.
- Skill guidance: if one local skill clearly matches the task, run `npx @tanstack/intent@latest load <package>#<skill>` and follow the returned `SKILL.md`.
- Skill check: run `pnpm dlx @tanstack/intent@latest list`, or use skills already listed in context.
- Skill guidance: if one local skill clearly matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` and follow the returned `SKILL.md`.
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
<!-- intent-skills:end -->
```

Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`.

## 2. Use skills in your workflow

When your agent works on a task that matches an available skill, it loads the matching `SKILL.md` into context.
Expand Down
19 changes: 14 additions & 5 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,20 @@ Intent provides tooling for two workflows:

## How it works

### Discovery and installation

```bash
npx @tanstack/intent@latest list
```
### Discovery and installation

Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner:

| Tool | Pattern |
| ---- | -------------------------------------------- |
| npm | `npx @tanstack/intent@latest <command>` |
| pnpm | `pnpm dlx @tanstack/intent@latest <command>` |
| Yarn | `yarn dlx @tanstack/intent@latest <command>` |
| Bun | `bunx @tanstack/intent@latest <command>` |

```bash
npx @tanstack/intent@latest list
```

Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`.
Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages.
Expand Down
21 changes: 21 additions & 0 deletions packages/intent/src/command-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { detectPackageManager } from './package-manager.js'
import type { PackageManager } from './types.js'

export { detectPackageManager as detectIntentCommandPackageManager }

const runnerByPackageManager: Record<PackageManager, string> = {
bun: 'bunx @tanstack/intent@latest',
npm: 'npx @tanstack/intent@latest',
pnpm: 'pnpm dlx @tanstack/intent@latest',
unknown: 'npx @tanstack/intent@latest',
yarn: 'yarn dlx @tanstack/intent@latest',
}

export function formatIntentCommand(
packageManager: PackageManager,
args: string,
): string {
const command = runnerByPackageManager[packageManager]
const trimmedArgs = args.trim()
return trimmedArgs ? `${command} ${trimmedArgs}` : command
}
20 changes: 16 additions & 4 deletions packages/intent/src/commands/install-writer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { formatIntentCommand } from '../command-runner.js'
import { formatSkillUse, parseSkillUse } from '../skill-use.js'
import type { ScanResult, SkillEntry } from '../types.js'

Expand Down Expand Up @@ -240,7 +241,10 @@ export function buildIntentSkillsBlock(
): IntentSkillsBlockResult {
const lines = [
INTENT_SKILLS_START,
'# Skill mappings - load `use` with `npx @tanstack/intent@latest load <use>`.',
`# Skill mappings - load \`use\` with \`${formatIntentCommand(
scanResult.packageManager,
'load <use>',
)}\`.`,
'skills:',
]
let mappingCount = 0
Expand Down Expand Up @@ -268,15 +272,23 @@ export function buildIntentSkillsBlock(
}
}

export function buildIntentSkillGuidanceBlock(): IntentSkillsBlockResult {
export function buildIntentSkillGuidanceBlock(
packageManager: ScanResult['packageManager'] = 'unknown',
): IntentSkillsBlockResult {
const listCommand = formatIntentCommand(packageManager, 'list')
const loadCommand = formatIntentCommand(
packageManager,
'load <package>#<skill>',
)

return {
block: `${[
INTENT_SKILLS_START,
'## Skill Loading',
'',
'Before substantial work:',
'- Skill check: run `npx @tanstack/intent@latest list`, or use skills already listed in context.',
'- Skill guidance: if one local skill clearly matches the task, run `npx @tanstack/intent@latest load <package>#<skill>` and follow the returned `SKILL.md`.',
`- Skill check: run \`${listCommand}\`, or use skills already listed in context.`,
`- Skill guidance: if one local skill clearly matches the task, run \`${loadCommand}\` and follow the returned \`SKILL.md\`.`,
'- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.',
'- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.',
INTENT_SKILLS_END,
Expand Down
11 changes: 6 additions & 5 deletions packages/intent/src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { relative } from 'node:path'
import { fail } from '../cli-error.js'
import { detectIntentCommandPackageManager } from '../command-runner.js'
import { printWarnings, scanOptionsFromGlobalFlags } from '../cli-support.js'
import {
buildIntentSkillGuidanceBlock,
Expand Down Expand Up @@ -190,10 +191,12 @@ export async function runInstallCommand(
return
}

scanOptionsFromGlobalFlags(options)
const scanOptions = scanOptionsFromGlobalFlags(options)

if (!options.map) {
const generated = buildIntentSkillGuidanceBlock()
const generated = buildIntentSkillGuidanceBlock(
detectIntentCommandPackageManager(),
)

if (options.dryRun) {
const targetPath = resolveIntentSkillsBlockTargetPath(process.cwd(), 1)
Expand Down Expand Up @@ -234,9 +237,7 @@ export async function runInstallCommand(
return
}

const scanResult = await scanIntentsOrFail(
scanOptionsFromGlobalFlags(options),
)
const scanResult = await scanIntentsOrFail(scanOptions)
const generated = buildIntentSkillsBlock(scanResult)

if (options.dryRun) {
Expand Down
21 changes: 20 additions & 1 deletion packages/intent/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
printWarnings,
type GlobalScanFlags,
} from '../cli-support.js'
import { formatIntentCommand } from '../command-runner.js'
import { listIntentSkills } from '../core.js'
import type {
IntentPackageSummary,
Expand Down Expand Up @@ -75,6 +76,14 @@ function getPackageSkills(
return skillsByPackageRoot.get(pkg.packageRoot) ?? []
}

function formatLoadCommand(
skill: IntentSkillSummary,
packageManager: ScanResult['packageManager'],
scopeFlag: string,
): string {
return formatIntentCommand(packageManager, `load ${skill.use}${scopeFlag}`)
}

export async function runListCommand(
options: ListCommandOptions,
_scanIntentsOrFail?: (options?: ScanOptions) => Promise<ScanResult>,
Expand All @@ -83,7 +92,11 @@ export async function runListCommand(
printListDebug(result)

if (options.json) {
const { debug: _debug, ...jsonResult } = result
const {
debug: _debug,
packageManager: _packageManager,
...jsonResult
} = result
console.log(JSON.stringify(jsonResult, null, 2))
return
}
Expand Down Expand Up @@ -124,6 +137,11 @@ export async function runListCommand(
)
const nameWidth = computeSkillNameWidth(allSkills)
const showTypes = result.skills.some((skill) => skill.type)
const scopeFlag = options.globalOnly
? ' --global-only'
: options.global
? ' --global'
: ''

console.log(`\nSkills:\n`)
for (const pkg of result.packages) {
Expand All @@ -132,6 +150,7 @@ export async function runListCommand(
getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({
name: skill.skillName,
description: skill.description,
loadCommand: formatLoadCommand(skill, result.packageManager, scopeFlag),
type: skill.type,
})),
{ nameWidth, packageName: pkg.name, showTypes },
Expand Down
1 change: 1 addition & 0 deletions packages/intent/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function listIntentSkills(
)

const result: IntentSkillList = {
packageManager: scanResult.packageManager,
skills,
packages: packages.map((pkg) => ({
name: pkg.name,
Expand Down
2 changes: 2 additions & 0 deletions packages/intent/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
IntentPackage,
PackageManager,
ScanScope,
ScanStats,
VersionConflict,
Expand Down Expand Up @@ -34,6 +35,7 @@ export interface IntentPackageSummary {
}

export interface IntentSkillList {
packageManager: PackageManager
skills: Array<IntentSkillSummary>
packages: Array<IntentPackageSummary>
warnings: Array<string>
Expand Down
11 changes: 4 additions & 7 deletions packages/intent/src/discovery/register.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { join, sep } from 'node:path'
import { rewriteSkillLoadPaths } from '../skill-paths.js'
import { listNodeModulesPackageDirs } from '../utils.js'
import type {
Expand All @@ -18,10 +18,6 @@ function isLocalToProject(dirPath: string, projectRoot: string): boolean {
)
}

function getFsIdentity(path: string): string {
return resolve(path)
}

export interface CreatePackageRegistrarOptions {
comparePackageVersions: (a: string, b: string) => number
deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null
Expand All @@ -31,6 +27,7 @@ export interface CreatePackageRegistrarOptions {
packages: Array<IntentPackage>
projectRoot: string
readPkgJson: (dirPath: string) => PackageJson | null
getFsIdentity: (path: string) => string
rememberVariant: (pkg: IntentPackage) => void
validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null
warnings: Array<string>
Expand All @@ -41,7 +38,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) {
const scannedNodeModulesDirs = new Set<string>()

function shouldAttemptPackageRoot(dirPath: string): boolean {
const key = getFsIdentity(dirPath)
const key = opts.getFsIdentity(dirPath)
if (attemptedPackageRoots.has(key)) return false
attemptedPackageRoots.add(key)
return true
Expand All @@ -53,7 +50,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) {
): void {
if (!existsSync(nodeModulesDir)) return

const key = getFsIdentity(nodeModulesDir)
const key = opts.getFsIdentity(nodeModulesDir)
if (scannedNodeModulesDirs.has(key)) return
scannedNodeModulesDirs.add(key)

Expand Down
34 changes: 28 additions & 6 deletions packages/intent/src/discovery/walk.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { join } from 'node:path'
import { resolveDepDir, getDeps } from '../utils.js'
import {
getDeps,
listNestedNodeModulesPackageDirs,
resolveDepDir,
} from '../utils.js'
import { findWorkspacePackages } from '../workspace-patterns.js'
import type { IntentFsCache } from '../fs-cache.js'
import type { IntentPackage } from '../types.js'
Expand All @@ -10,6 +14,7 @@ export interface CreateDependencyWalkerOptions {
fsCache: IntentFsCache
projectRoot: string
readPkgJson: (dirPath: string) => PackageJson | null
getFsIdentity: (path: string) => string
scanNodeModulesDir: (nodeModulesDir: string) => void
tryRegister: (dirPath: string, fallbackName: string) => boolean
packages: Array<IntentPackage>
Expand All @@ -24,10 +29,11 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) {
depName: string,
fromDir: string,
): string | null {
let byDepName = depDirCache.get(fromDir)
const fromKey = opts.getFsIdentity(fromDir)
let byDepName = depDirCache.get(fromKey)
if (!byDepName) {
byDepName = new Map()
depDirCache.set(fromDir, byDepName)
depDirCache.set(fromKey, byDepName)
}

if (!byDepName.has(depName)) {
Expand All @@ -44,16 +50,17 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) {
): void {
for (const depName of getDeps(pkgJson, includeDevDeps)) {
const depDir = resolveDepDirCached(depName, fromDir)
if (!depDir || walkVisited.has(depDir)) continue
if (!depDir) continue

opts.tryRegister(depDir, depName)
walkDeps(depDir, depName)
}
}

function walkDeps(pkgDir: string, pkgName: string): void {
if (walkVisited.has(pkgDir)) return
walkVisited.add(pkgDir)
const pkgKey = opts.getFsIdentity(pkgDir)
if (walkVisited.has(pkgKey)) return
walkVisited.add(pkgKey)

const pkgJson = opts.readPkgJson(pkgDir)
if (!pkgJson) {
Expand Down Expand Up @@ -111,7 +118,22 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) {
}
}

function scanNestedNodeModulesDir(nodeModulesDir: string): void {
for (const dirPath of listNestedNodeModulesPackageDirs(
nodeModulesDir,
opts.getFsIdentity,
)) {
if (!opts.tryRegister(dirPath, 'unknown')) continue

const pkgJson = opts.readPkgJson(dirPath)
const pkgName =
typeof pkgJson?.name === 'string' ? pkgJson.name : 'unknown'
walkDeps(dirPath, pkgName)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
scanNestedNodeModulesDir,
walkKnownPackages,
walkProjectDeps,
walkWorkspacePackages,
Expand Down
Loading
Loading