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/stable-skill-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Use stable `node_modules/<name>/...` paths for skill references instead of absolute filesystem paths containing package-manager-internal directories with version numbers. Paths no longer break when packages are updated.
7 changes: 7 additions & 0 deletions packages/intent/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ skills:
Rules:
- Use the user's own words for task descriptions
- Include the exact path from \`npx @tanstack/intent@latest list\` output so agents can load it directly
- Paths should use the stable \`node_modules/<package-name>/skills/...\` format (no version numbers)
- If a skill path from \`list\` contains package-manager-internal directories (e.g. \`.pnpm/\`, \`.bun/\`)
with version numbers, it is a transitive dependency without a stable top-level symlink.
For these skills, do NOT embed the versioned path. Instead, add a comment telling the agent
how to locate the skill at runtime:
- task: "describe the task"
# To load this skill, run: npx @tanstack/intent@latest list | grep <skill-name>
- Keep entries concise - this block is read on every agent task
- Preserve all content outside the block tags unchanged
- If the user is on Deno, note that this setup is best-effort today and relies on npm interop`
Expand Down
23 changes: 19 additions & 4 deletions packages/intent/src/library-scanner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { dirname, join, relative, sep } from 'node:path'
import { getDeps, parseFrontmatter, resolveDepDir } from './utils.js'
import { dirname, join, relative } from 'node:path'
import {
getDeps,
parseFrontmatter,
resolveDepDir,
toPosixPath,
} from './utils.js'
import type { SkillEntry } from './types.js'
import type { Dirent } from 'node:fs'

Expand Down Expand Up @@ -76,7 +81,7 @@ function discoverSkills(skillsDir: string): Array<SkillEntry> {
const skillFile = join(childDir, 'SKILL.md')
if (existsSync(skillFile)) {
const fm = parseFrontmatter(skillFile)
const relName = relative(skillsDir, childDir).split(sep).join('/')
const relName = toPosixPath(relative(skillsDir, childDir))
skills.push({
name: typeof fm?.name === 'string' ? fm.name : relName,
path: skillFile,
Expand Down Expand Up @@ -135,11 +140,21 @@ export function scanLibrary(
}

const skillsDir = join(dir, 'skills')
const skills = existsSync(skillsDir) ? discoverSkills(skillsDir) : []

// Convert absolute skill paths to stable node_modules/<name>/... paths
if (name) {
for (const skill of skills) {
const relFromPkg = toPosixPath(relative(dir, skill.path))
skill.path = `node_modules/${name}/${relFromPkg}`
}
}

packages.push({
name,
version: typeof pkg.version === 'string' ? pkg.version : '0.0.0',
description: typeof pkg.description === 'string' ? pkg.description : '',
skills: existsSync(skillsDir) ? discoverSkills(skillsDir) : [],
skills,
})

for (const depName of getDeps(pkg)) {
Expand Down
26 changes: 24 additions & 2 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
listNodeModulesPackageDirs,
parseFrontmatter,
resolveDepDir,
toPosixPath,
} from './utils.js'
import {
findWorkspaceRoot,
Expand Down Expand Up @@ -156,7 +157,7 @@ function discoverSkills(
const skillFile = join(childDir, 'SKILL.md')
if (existsSync(skillFile)) {
const fm = parseFrontmatter(skillFile)
const relName = relative(skillsDir, childDir).split(sep).join('/')
const relName = toPosixPath(relative(skillsDir, childDir))
const desc =
typeof fm?.description === 'string'
? fm.description.replace(/\s+/g, ' ').trim()
Expand Down Expand Up @@ -432,11 +433,32 @@ export function scanForIntents(root?: string): ScanResult {
return false
}

const skills = discoverSkills(skillsDir, name)

// Convert absolute skill paths to stable relative paths, preferring
// node_modules/<name>/... when a top-level symlink exists, otherwise
// falling back to a path relative to the project root.
const isLocal =
dirPath.startsWith(projectRoot + sep) ||
dirPath.startsWith(projectRoot + '/')
if (isLocal) {
const hasStableSymlink =
name !== '' && existsSync(join(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(projectRoot, skill.path))
}
}
}

const candidate: IntentPackage = {
name,
version,
intent,
skills: discoverSkills(skillsDir, name),
skills,
packageRoot: dirPath,
}
const existingIndex = packageIndexes.get(name)
Expand Down
9 changes: 8 additions & 1 deletion packages/intent/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { dirname, join, sep } from 'node:path'
import { parse as parseYaml } from 'yaml'

/**
* Convert a path to use forward slashes (for cross-platform consistency).
*/
export function toPosixPath(p: string): string {
return p.split(sep).join('/')
}

/**
* Recursively find all SKILL.md files under a directory.
*/
Expand Down
20 changes: 19 additions & 1 deletion packages/intent/tests/library-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ describe('scanLibrary', () => {
const result = scanLibrary(scriptPath(pkgDir), root)

const skill = result.packages[0]!.skills[0]!
expect(skill.path).toBe(join(pkgDir, 'skills', 'routing', 'SKILL.md'))
expect(skill.path).toBe(
'node_modules/@tanstack/router/skills/routing/SKILL.md',
)
})

it('recursively discovers deps with tanstack-intent keyword', () => {
Expand Down Expand Up @@ -308,6 +310,22 @@ describe('scanLibrary', () => {
expect(names).toContain('routing/nested-routes')
})

it('handles missing package name without producing double slashes in paths', () => {
const pkgDir = createDir(root, 'node_modules', 'no-name-pkg')
writeJson(join(pkgDir, 'package.json'), {
version: '1.0.0',
keywords: ['tanstack-intent'],
})
const skillDir = createDir(pkgDir, 'skills', 'core')
writeSkillMd(skillDir, { name: 'core', description: 'Core skill' })

const result = scanLibrary(scriptPath(pkgDir), root)

expect(result.packages).toHaveLength(1)
const skill = result.packages[0]!.skills[0]!
expect(skill.path).not.toContain('//')
})

it('returns a warning when home package.json cannot be found', () => {
const fakeScript = join(root, 'nowhere', 'bin', 'intent.js')

Expand Down
15 changes: 15 additions & 0 deletions packages/intent/tests/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ describe('scanForIntents', () => {
expect(result.packages).toEqual([])
})

it('handles empty package name without producing double-slash paths', () => {
const pkgDir = createDir(root, 'node_modules', 'no-name-pkg')
writeJson(join(pkgDir, 'package.json'), {
name: '',
version: '1.0.0',
intent: { version: 1, repo: 'test/pkg', docs: 'docs/' },
})
const skillDir = createDir(pkgDir, 'skills', 'core')
writeSkillMd(skillDir, { name: 'core', description: 'Core skill' })

const result = scanForIntents(root)
expect(result.packages).toHaveLength(1)
expect(result.packages[0]!.skills[0]!.path).not.toContain('//')
})

it('discovers an intent-enabled package with skills', () => {
const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db')
writeJson(join(pkgDir, 'package.json'), {
Expand Down
12 changes: 10 additions & 2 deletions packages/intent/tests/workspace-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
Expand All @@ -13,7 +19,9 @@ const roots: Array<string> = []
const cwdStack: Array<string> = []

function createRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'workspace-patterns-test-'))
const root = realpathSync(
mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')),
)
roots.push(root)
return root
}
Expand Down
Loading