diff --git a/.changeset/nested-variant-skill-path.md b/.changeset/nested-variant-skill-path.md new file mode 100644 index 00000000..88c2a4dd --- /dev/null +++ b/.changeset/nested-variant-skill-path.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix `intent load` for a skill that ships in a nested copy of a dependency. When a workspace package such as `packages/bar` installed its own version of `@tanstack/query`, and the root `node_modules/@tanstack/query` was a different copy, `list` showed the nested skill but `load` looked for it under `node_modules/@tanstack/query/...` and failed. Intent now uses the `node_modules//...` path only when that entry is the same copy. Otherwise it reports the project-relative path, such as `packages/bar/node_modules/@tanstack/query/skills/fetching/SKILL.md`. Hoisted installs and pnpm or bun symlinks to the same copy keep their `node_modules//...` paths. diff --git a/packages/intent/src/skills/paths.ts b/packages/intent/src/skills/paths.ts index 7092140a..58f159ed 100644 --- a/packages/intent/src/skills/paths.ts +++ b/packages/intent/src/skills/paths.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { realpathSync } from 'node:fs' import { join, relative } from 'node:path' import { toPosixPath } from '../shared/utils.js' import { formatIntentCommand } from '../shared/command-runner.js' @@ -52,12 +52,19 @@ export function rewriteSkillLoadPaths({ }): void { const topLevelPackageRoot = packageName !== '' ? join(projectRoot, 'node_modules', packageName) : '' - const hasStableSymlink = - topLevelPackageRoot !== '' && - (packageRoot === topLevelPackageRoot || existsSync(topLevelPackageRoot)) + let matchesTopLevelEntry: boolean + try { + matchesTopLevelEntry = + topLevelPackageRoot !== '' && + (packageRoot === topLevelPackageRoot || + realpathSync.native(topLevelPackageRoot) === + realpathSync.native(packageRoot)) + } catch { + matchesTopLevelEntry = false + } for (const skill of skills) { - if (hasStableSymlink) { + if (matchesTopLevelEntry) { const relFromPackage = toPosixPath(relative(packageRoot, skill.path)) skill.path = `node_modules/${packageName}/${relFromPackage}` } else { diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 6be0edde..a075290d 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -968,6 +968,41 @@ describe('loadIntentSkill', () => { ) }) + it('loads a nested dependency copy when the root has a different copy', () => { + const barDir = join(root, 'packages', 'bar') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(barDir, 'package.json'), { + name: 'bar', + dependencies: { + '@tanstack/query': '5.0.0', + }, + }) + writeJson( + join(root, 'node_modules', '@tanstack', 'query', 'package.json'), + { + name: '@tanstack/query', + version: '4.0.0', + }, + ) + writeInstalledIntentPackage(barDir, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + + expect(result.version).toBe('5.0.0') + expect(result.path).toBe( + 'packages/bar/node_modules/@tanstack/query/skills/fetching/SKILL.md', + ) + }) + it('rewrites relative markdown destinations in loaded content', () => { const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') diff --git a/packages/intent/tests/skill-paths.test.ts b/packages/intent/tests/skill-paths.test.ts index 4759608a..86c9cbf5 100644 --- a/packages/intent/tests/skill-paths.test.ts +++ b/packages/intent/tests/skill-paths.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -79,7 +79,7 @@ describe('skill path helpers', () => { expect(isStableLoadPath('')).toBe(false) }) - it('rewrites to stable node_modules load paths when a top-level package path exists', () => { + it('rewrites to stable node_modules load paths when the package root is the top-level entry', () => { const root = tempRoot() const packageRoot = join(root, 'node_modules', '@tanstack', 'query') const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') @@ -98,6 +98,68 @@ describe('skill path helpers', () => { ) }) + it('keeps the project-relative path when the top-level package is a different copy', () => { + const root = tempRoot() + mkdirSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + }) + const packageRoot = join( + root, + 'packages', + 'bar', + 'node_modules', + '@tanstack', + 'query', + ) + const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(join(packageRoot, 'skills', 'core'), { recursive: true }) + const skills = [skill(skillPath)] + + rewriteSkillLoadPaths({ + packageName: '@tanstack/query', + packageRoot, + projectRoot: root, + skills, + }) + + expect(skills[0]!.path).toBe( + 'packages/bar/node_modules/@tanstack/query/skills/core/SKILL.md', + ) + }) + + it('rewrites a symlinked store copy to the top-level link that targets it', () => { + const root = tempRoot() + const packageRoot = join( + root, + 'node_modules', + '.pnpm', + '@tanstack+query@5.0.0', + 'node_modules', + '@tanstack', + 'query', + ) + const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(join(packageRoot, 'skills', 'core'), { recursive: true }) + mkdirSync(join(root, 'node_modules', '@tanstack')) + symlinkSync( + packageRoot, + join(root, 'node_modules', '@tanstack', 'query'), + 'dir', + ) + const skills = [skill(skillPath)] + + rewriteSkillLoadPaths({ + packageName: '@tanstack/query', + packageRoot, + projectRoot: root, + skills, + }) + + expect(skills[0]!.path).toBe( + 'node_modules/@tanstack/query/skills/core/SKILL.md', + ) + }) + it('does not invent stable top-level load paths for pnpm-internal package paths', () => { const root = tempRoot() const packageRoot = join(