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. 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..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, 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 +81,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 +140,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..100da63 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,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//... 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) 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..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(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 +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') 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'), { diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index 9176d38..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, 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 = mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')) + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')), + ) roots.push(root) return root }