From 25a29e8b0fe7f12d32a644707ce55a1433405ff2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 26 Mar 2026 09:08:11 -0600 Subject: [PATCH 1/5] fix(intent): use stable node_modules paths for skill references Skill paths previously stored absolute filesystem paths that included package-manager-internal directories with version numbers (e.g. .pnpm/@scope+pkg@1.2.3/node_modules/...), breaking whenever packages were updated. Now uses stable node_modules//... paths for direct deps, falls back to project-relative paths for transitive deps, and keeps absolute paths for global packages. Also updates the install prompt to guide agents on handling transitive dep paths, extracts a toPosixPath utility, fixes macOS /var symlink issue in workspace-patterns tests, and guards against empty package names producing malformed paths. Co-Authored-By: Claude Opus 4.6 --- packages/intent/src/commands/install.ts | 7 +++++ packages/intent/src/library-scanner.ts | 18 ++++++++++--- packages/intent/src/scanner.ts | 27 +++++++++++++++++-- packages/intent/src/utils.ts | 9 ++++++- packages/intent/tests/library-scanner.test.ts | 18 ++++++++++++- .../intent/tests/workspace-patterns.test.ts | 4 +-- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index 91fa79a..f7c1708 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -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//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 - 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` diff --git a/packages/intent/src/library-scanner.ts b/packages/intent/src/library-scanner.ts index 44e3e81..94da567 100644 --- a/packages/intent/src/library-scanner.ts +++ b/packages/intent/src/library-scanner.ts @@ -1,6 +1,6 @@ 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' @@ -76,7 +76,7 @@ function discoverSkills(skillsDir: string): Array { 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, @@ -135,11 +135,21 @@ export function scanLibrary( } const skillsDir = join(dir, 'skills') + const skills = existsSync(skillsDir) ? discoverSkills(skillsDir) : [] + + // Convert absolute skill paths to stable node_modules//... 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)) { diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index ca15745..b882479 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -6,6 +6,7 @@ import { listNodeModulesPackageDirs, parseFrontmatter, resolveDepDir, + toPosixPath, } from './utils.js' import { findWorkspaceRoot, @@ -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() @@ -432,11 +433,33 @@ export function scanForIntents(root?: string): ScanResult { return false } + const skills = discoverSkills(skillsDir, name) + + // Convert absolute skill paths to stable relative paths, preferring + // node_modules//... 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 = 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) diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index 4e68b5b..e70b98e 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -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. */ diff --git a/packages/intent/tests/library-scanner.test.ts b/packages/intent/tests/library-scanner.test.ts index e7eaf26..2530719 100644 --- a/packages/intent/tests/library-scanner.test.ts +++ b/packages/intent/tests/library-scanner.test.ts @@ -94,7 +94,7 @@ 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', () => { @@ -308,6 +308,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') diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index 9176d38..1b039c6 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -1,4 +1,4 @@ -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' @@ -13,7 +13,7 @@ const roots: Array = [] const cwdStack: Array = [] 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 } From 6c3b3912daff10d4d00faa2cefc2bc7fe2907c3e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 26 Mar 2026 09:08:44 -0600 Subject: [PATCH 2/5] Add changeset for stable skill paths fix Co-Authored-By: Claude Opus 4.6 --- .changeset/stable-skill-paths.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stable-skill-paths.md diff --git a/.changeset/stable-skill-paths.md b/.changeset/stable-skill-paths.md new file mode 100644 index 0000000..34048fe --- /dev/null +++ b/.changeset/stable-skill-paths.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Use stable `node_modules//...` 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. From 69a553678a91fe42f253073a78a4980e6a1e4749 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:09:58 +0000 Subject: [PATCH 3/5] ci: apply automated fixes --- packages/intent/src/library-scanner.ts | 7 ++++++- packages/intent/tests/library-scanner.test.ts | 4 +++- packages/intent/tests/workspace-patterns.test.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/library-scanner.ts b/packages/intent/src/library-scanner.ts index 94da567..b50989f 100644 --- a/packages/intent/src/library-scanner.ts +++ b/packages/intent/src/library-scanner.ts @@ -1,6 +1,11 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join, relative } from 'node:path' -import { getDeps, parseFrontmatter, resolveDepDir, toPosixPath } from './utils.js' +import { + getDeps, + parseFrontmatter, + resolveDepDir, + toPosixPath, +} from './utils.js' import type { SkillEntry } from './types.js' import type { Dirent } from 'node:fs' diff --git a/packages/intent/tests/library-scanner.test.ts b/packages/intent/tests/library-scanner.test.ts index 2530719..a243dc8 100644 --- a/packages/intent/tests/library-scanner.test.ts +++ b/packages/intent/tests/library-scanner.test.ts @@ -94,7 +94,9 @@ describe('scanLibrary', () => { const result = scanLibrary(scriptPath(pkgDir), root) const skill = result.packages[0]!.skills[0]! - expect(skill.path).toBe('node_modules/@tanstack/router/skills/routing/SKILL.md') + expect(skill.path).toBe( + 'node_modules/@tanstack/router/skills/routing/SKILL.md', + ) }) it('recursively discovers deps with tanstack-intent keyword', () => { diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index 1b039c6..e48f646 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, realpathSync, 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' @@ -13,7 +19,9 @@ const roots: Array = [] const cwdStack: Array = [] function createRoot(): string { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'workspace-patterns-test-'))) + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')), + ) roots.push(root) return root } From 627047f6ceebcdc5c706d0ac16c7aebb88357673 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 26 Mar 2026 11:24:40 -0600 Subject: [PATCH 4/5] fix(intent): guard against empty package name in scanner path normalization Matches the existing guard in library-scanner.ts. Without this, join(projectRoot, 'node_modules', '') resolves to node_modules/ which exists, causing hasStableSymlink to be true and producing paths like node_modules//skills/... Co-Authored-By: Claude Opus 4.6 --- packages/intent/src/scanner.ts | 6 +++--- packages/intent/tests/scanner.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index b882479..3fba7e8 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -442,9 +442,9 @@ export function scanForIntents(root?: string): ScanResult { dirPath.startsWith(projectRoot + sep) || dirPath.startsWith(projectRoot + '/') if (isLocal) { - const hasStableSymlink = existsSync( - join(projectRoot, 'node_modules', name), - ) + const hasStableSymlink = + name !== '' && + existsSync(join(projectRoot, 'node_modules', name)) for (const skill of skills) { if (hasStableSymlink) { const relFromPkg = toPosixPath(relative(dirPath, skill.path)) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 50ceeb2..3722415 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -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'), { From d0db92ab8d06cfa5814f5122811ac2d21511d29d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:25:40 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/intent/src/scanner.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 3fba7e8..100da63 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -443,8 +443,7 @@ export function scanForIntents(root?: string): ScanResult { dirPath.startsWith(projectRoot + '/') if (isLocal) { const hasStableSymlink = - name !== '' && - existsSync(join(projectRoot, 'node_modules', name)) + name !== '' && existsSync(join(projectRoot, 'node_modules', name)) for (const skill of skills) { if (hasStableSymlink) { const relFromPkg = toPosixPath(relative(dirPath, skill.path))