diff --git a/.changeset/nested-variant-skill-path.md b/.changeset/nested-variant-skill-path.md new file mode 100644 index 00000000..88657e63 --- /dev/null +++ b/.changeset/nested-variant-skill-path.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Point a skill from a nested dependency copy at that copy. When a workspace package installed its own version of a package, such as `packages/a/node_modules/foo`, and the root had a different copy at `node_modules/foo`, `list` showed the nested skill but `load` reported its path as `node_modules/foo/...`. Loading then failed with `Resolved skill file was not found`, or read the root copy's file instead. Intent now uses `node_modules//...` only when that entry is the same copy, and otherwise reports a project-relative path such as `packages/a/node_modules/foo/skills/core/SKILL.md`. pnpm and bun direct dependencies keep their stable `node_modules//...` paths. diff --git a/packages/intent/src/skills/paths.ts b/packages/intent/src/skills/paths.ts index 6980a02e..d38eb37e 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' @@ -50,9 +50,15 @@ export function rewriteSkillLoadPaths({ projectRoot: string skills: Array }): void { - const hasStableSymlink = - packageName !== '' && - existsSync(join(projectRoot, 'node_modules', packageName)) + let hasStableSymlink: boolean + try { + hasStableSymlink = + packageName !== '' && + realpathSync.native(join(projectRoot, 'node_modules', packageName)) === + realpathSync.native(packageRoot) + } catch { + hasStableSymlink = false + } for (const skill of skills) { if (hasStableSymlink) { diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 6be0edde..3ef062d4 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -78,6 +78,39 @@ function writeInstalledIntentPackage( }) } +function writeNestedVariantMonorepo( + root: string, + { rootCopyHasSkill }: { rootCopyHasSkill: boolean }, +): void { + writeJson(join(root, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'], + dependencies: { foo: '1.0.0' }, + }) + writeJson(join(root, 'packages', 'a', 'package.json'), { + name: 'a', + dependencies: { foo: '2.0.0' }, + }) + for (const [pkgDir, version, hasSkill] of [ + [join(root, 'node_modules', 'foo'), '1.0.0', rootCopyHasSkill], + [join(root, 'packages', 'a', 'node_modules', 'foo'), '2.0.0', true], + ] as const) { + writeJson(join(pkgDir, 'package.json'), { + name: 'foo', + version, + repository: 'https://github.com/x/foo', + }) + if (hasSkill) { + writeSkillMd({ + content: `foo v${version[0]} content`, + dir: join(pkgDir, 'skills', 'core'), + frontmatter: { name: 'core', description: `foo v${version[0]} core` }, + }) + } + } +} + let root: string let originalCwd: string @@ -968,6 +1001,30 @@ describe('loadIntentSkill', () => { ) }) + it('loads a nested workspace dependency variant when the root link is a different copy', () => { + writeNestedVariantMonorepo(root, { rootCopyHasSkill: false }) + + const listed = listIntentSkills({ audience: 'human', cwd: root }) + const nestedRoot = join(root, 'packages', 'a', 'node_modules', 'foo') + expect(listed.skills).toEqual([ + expect.objectContaining({ + use: 'foo#core', + packageRoot: nestedRoot, + packageVersion: '2.0.0', + }), + ]) + + const resolved = resolveIntentSkill('foo#core', { cwd: root }) + expect(resolved.packageRoot).toBe(nestedRoot) + expect(resolved.path).toBe( + 'packages/a/node_modules/foo/skills/core/SKILL.md', + ) + + const loaded = loadIntentSkill('foo#core', { cwd: root }) + expect(loaded.version).toBe('2.0.0') + expect(loaded.content).toContain('foo v2 content') + }) + 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/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 970016c3..7751d728 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1908,6 +1908,103 @@ describe('scanIntentPackageAtRoot', () => { }) }) +describe('nested dependency variants', () => { + function writeFoo(pkgDir: string, version: string): void { + writeJson(join(createDir(pkgDir), 'package.json'), { + name: 'foo', + version, + repository: 'https://github.com/x/foo', + }) + } + + function writeMonorepo({ rootCopyHasSkill }: { rootCopyHasSkill: boolean }): { + rootCopy: string + nestedCopy: string + } { + writeJson(join(root, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'], + dependencies: { foo: '1.0.0' }, + }) + writeJson(join(createDir(root, 'packages', 'a'), 'package.json'), { + name: 'a', + dependencies: { foo: '2.0.0' }, + }) + const rootCopy = join(root, 'node_modules', 'foo') + const nestedCopy = join(root, 'packages', 'a', 'node_modules', 'foo') + writeFoo(rootCopy, '1.0.0') + writeFoo(nestedCopy, '2.0.0') + writeSkillMd(createDir(nestedCopy, 'skills', 'core'), { + name: 'core', + description: 'foo v2 core', + }) + if (rootCopyHasSkill) { + writeSkillMd(createDir(rootCopy, 'skills', 'core'), { + name: 'core', + description: 'foo v1 core', + }) + } + return { rootCopy, nestedCopy } + } + + it('lists a nested variant with a path into its own copy, not the root link', () => { + const { nestedCopy } = writeMonorepo({ rootCopyHasSkill: false }) + + const result = scanForIntents(root) + + const foo = result.packages.find((pkg) => pkg.name === 'foo') + expect(foo?.packageRoot).toBe(nestedCopy) + expect(foo?.skills.map((skill) => skill.path)).toEqual([ + 'packages/a/node_modules/foo/skills/core/SKILL.md', + ]) + }) + + it('never points a nested variant at the root copy of the same package', () => { + const { nestedCopy } = writeMonorepo({ rootCopyHasSkill: true }) + + const result = scanIntentPackageAtRoot(nestedCopy, { + fallbackName: 'foo', + projectRoot: root, + }) + + expect(result.package?.version).toBe('2.0.0') + expect(result.package?.skills).toEqual([ + expect.objectContaining({ + name: 'core', + path: 'packages/a/node_modules/foo/skills/core/SKILL.md', + description: 'foo v2 core', + }), + ]) + }) + + it('keeps the stable root path for a root link that targets the same copy', () => { + const storeCopy = join( + root, + 'node_modules', + '.pnpm', + 'foo@2.0.0', + 'node_modules', + 'foo', + ) + writeFoo(storeCopy, '2.0.0') + writeSkillMd(createDir(storeCopy, 'skills', 'core'), { + name: 'core', + description: 'foo core', + }) + symlinkSync(storeCopy, join(root, 'node_modules', 'foo'), 'dir') + + const result = scanIntentPackageAtRoot(storeCopy, { + fallbackName: 'foo', + projectRoot: root, + }) + + expect(result.package?.skills.map((skill) => skill.path)).toEqual([ + 'node_modules/foo/skills/core/SKILL.md', + ]) + }) +}) + describe('back-compat frontmatter reader (metadata.* fallback)', () => { function writeRawSkillMd(dir: string, frontmatter: string): void { mkdirSync(dir, { recursive: true }) diff --git a/packages/intent/tests/skill-paths.test.ts b/packages/intent/tests/skill-paths.test.ts index 4759608a..41c5f662 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' @@ -98,6 +98,51 @@ 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', 'foo'), { recursive: true }) + const packageRoot = join(root, 'packages', 'a', 'node_modules', 'foo') + const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(join(packageRoot, 'skills', 'core'), { recursive: true }) + const skills = [skill(skillPath)] + + rewriteSkillLoadPaths({ + packageName: 'foo', + packageRoot, + projectRoot: root, + skills, + }) + + expect(skills[0]!.path).toBe( + 'packages/a/node_modules/foo/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', + 'foo@1.0.0', + 'node_modules', + 'foo', + ) + const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(join(packageRoot, 'skills', 'core'), { recursive: true }) + symlinkSync(packageRoot, join(root, 'node_modules', 'foo'), 'dir') + const skills = [skill(skillPath)] + + rewriteSkillLoadPaths({ + packageName: 'foo', + packageRoot, + projectRoot: root, + skills, + }) + + expect(skills[0]!.path).toBe('node_modules/foo/skills/core/SKILL.md') + }) + it('does not invent stable top-level load paths for pnpm-internal package paths', () => { const root = tempRoot() const packageRoot = join(