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
17 changes: 17 additions & 0 deletions .changeset/yellow-queens-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@tanstack/intent': patch
---

Fix skill discovery in Yarn Berry (PnP) projects. With `nodeLinker: pnp` and no
`node_modules`, dependencies live in `.yarn/cache/*.zip` archives readable only
through Yarn's libzip-patched filesystem. `intent list` and `intent load` now
read package metadata and `SKILL.md` files from those archives — including when
Intent runs via `npx`/`dlx` from outside the project's PnP graph. A failed PnP
load fails closed with a clear diagnostic, and the PnP resolution hook is no
longer left installed in Intent's process.

Speed up skill discovery. Frontmatter parsing now reads only the leading region
of each `SKILL.md` instead of the whole file (~4x faster on large skill bodies),
and dependency resolution reuses its module resolver per package instead of
rebuilding it for every dependency. Also drops redundant filesystem checks in the
skill-file walk.
16 changes: 12 additions & 4 deletions packages/intent/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFileSync, realpathSync } from 'node:fs'
import { isAbsolute, relative, resolve } from 'node:path'
import {
compileExcludePatterns,
Expand All @@ -15,6 +14,7 @@ import { formatSkillUse, parseSkillUse } from './skill-use.js'
import { scanForIntents } from './scanner.js'
import type { ResolveSkillResult } from './resolver.js'
import type { IntentFsCache } from './fs-cache.js'
import type { ReadFs } from './utils.js'
import type { ScanOptions, ScanScope } from './types.js'
import type {
IntentCoreErrorCode,
Expand Down Expand Up @@ -178,22 +178,26 @@ function toResolvedIntentSkill(
cwd: string,
use: string,
resolved: ResolveSkillResult,
readFs: ReadFs,
debug?: LoadedIntentSkillDebug,
): {
realPackageRoot: string
realResolvedPath: string
readFs: ReadFs
result: ResolvedIntentSkill
} {
let realResolvedPath: string
try {
realResolvedPath = realpathSync.native(resolveFromCwd(cwd, resolved.path))
realResolvedPath = readFs.realpathSync.native(
resolveFromCwd(cwd, resolved.path),
)
} catch {
throw new IntentCoreError(
'skill-file-not-found',
`Resolved skill file was not found: ${resolved.path}`,
)
}
const realPackageRoot = realpathSync.native(
const realPackageRoot = readFs.realpathSync.native(
resolveFromCwd(cwd, resolved.packageRoot),
)

Expand Down Expand Up @@ -222,6 +226,7 @@ function toResolvedIntentSkill(
return {
realPackageRoot,
realResolvedPath,
readFs,
result,
}
}
Expand Down Expand Up @@ -263,6 +268,7 @@ function resolveIntentSkillInCwd(
): {
realPackageRoot: string
realResolvedPath: string
readFs: ReadFs
result: ResolvedIntentSkill
} {
let parsedUse: ReturnType<typeof parseSkillUse>
Expand Down Expand Up @@ -301,6 +307,7 @@ function resolveIntentSkillInCwd(
cwd,
use,
fastPathResolved,
fsCache.getReadFs(),
options.debug
? createLoadedSkillDebug({
cwd,
Expand Down Expand Up @@ -331,6 +338,7 @@ function resolveIntentSkillInCwd(
cwd,
use,
resolved,
fsCache.getReadFs(),
options.debug
? createLoadedSkillDebug({
cwd,
Expand Down Expand Up @@ -358,7 +366,7 @@ export function loadIntentSkill(
const cwd = resolveCoreCwd(options)
const resolved = resolveIntentSkillInCwd(cwd, use, options)
const content = rewriteLoadedSkillMarkdownDestinations({
content: readFileSync(resolved.realResolvedPath, 'utf8'),
content: resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8'),
cwd,
packageRoot: resolved.realPackageRoot,
skillFilePath: resolved.realResolvedPath,
Expand Down
8 changes: 7 additions & 1 deletion packages/intent/src/discovery/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export interface CreatePackageRegistrarOptions {
projectRoot: string
readPkgJson: (dirPath: string) => PackageJson | null
getFsIdentity: (path: string) => string
/**
* Existence check routed through the scan's active filesystem, so package
* roots inside a Yarn PnP zip cache are seen (the static `node:fs` binding
* does not see Yarn's libzip patch).
*/
exists: (path: string) => boolean
rememberVariant: (pkg: IntentPackage) => void
validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null
warnings: Array<string>
Expand Down Expand Up @@ -76,7 +82,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) {
if (!shouldAttemptPackageRoot(dirPath)) return false

const skillsDir = join(dirPath, 'skills')
if (!existsSync(skillsDir)) return false
if (!opts.exists(skillsDir)) return false

const pkgJson = opts.readPkgJson(dirPath)
if (!pkgJson) {
Expand Down
25 changes: 21 additions & 4 deletions packages/intent/src/fs-cache.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
createFsIdentityCache,
findSkillFiles as findSkillFilesUncached,
nodeReadFs,
} from './utils.js'
import type { ReadFs } from './utils.js'

type PackageJsonReadResult = {
packageJson: Record<string, unknown> | null
Expand All @@ -21,6 +22,16 @@ export type IntentFsCache = {
findSkillFiles: (dir: string) => Array<string>
getFsIdentity: (path: string) => string
getStats: () => IntentFsCacheStats
/**
* Swap the filesystem used for all reads. Under Yarn PnP the scanner installs
* Yarn's libzip-patched `fs` here once, so subsequent reads reach files inside
* `.yarn/cache/*.zip`. The patched `fs` also serves real paths, so it is safe
* to use for every read after the swap.
*/
useFs: (fs: ReadFs) => void
/** The filesystem currently used for reads (patched under Yarn PnP). */
getReadFs: () => ReadFs
exists: (path: string) => boolean
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand All @@ -30,7 +41,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
export function createIntentFsCache(): IntentFsCache {
const packageJsonCache = new Map<string, PackageJsonReadResult>()
const skillFilesCache = new Map<string, Array<string>>()
const getFsIdentity = createFsIdentityCache()
let activeFs: ReadFs = nodeReadFs
const getFsIdentity = createFsIdentityCache(() => activeFs)
const stats: IntentFsCacheStats = {
packageJsonReadCount: 0,
packageJsonCacheHits: 0,
Expand All @@ -47,7 +59,7 @@ export function createIntentFsCache(): IntentFsCache {
stats.packageJsonReadCount += 1
try {
const parsed = JSON.parse(
readFileSync(join(dir, 'package.json'), 'utf8'),
activeFs.readFileSync(join(dir, 'package.json'), 'utf8'),
) as unknown
const result = {
packageJson: isRecord(parsed) ? parsed : null,
Expand All @@ -73,7 +85,7 @@ export function createIntentFsCache(): IntentFsCache {
return [...cached]
}

const files = findSkillFilesUncached(dir)
const files = findSkillFilesUncached(dir, activeFs)
skillFilesCache.set(key, files)
return [...files]
}
Expand All @@ -84,5 +96,10 @@ export function createIntentFsCache(): IntentFsCache {
findSkillFiles,
getFsIdentity,
getStats: () => ({ ...stats }),
useFs: (fs: ReadFs) => {
activeFs = fs
},
getReadFs: () => activeFs,
exists: (path: string) => activeFs.existsSync(path),
}
}
67 changes: 55 additions & 12 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
} from './discovery/index.js'
import {
detectGlobalNodeModules,
nodeReadFs,
parseFrontmatter,
toPosixPath,
} from './utils.js'
import { createIntentFsCache } from './fs-cache.js'
import { detectPackageManager } from './package-manager.js'
import { findWorkspaceRoot } from './workspace-patterns.js'
import type { IntentFsCache } from './fs-cache.js'
import type { ReadFs } from './utils.js'
import type {
InstalledVariant,
IntentConfig,
Expand Down Expand Up @@ -50,6 +52,21 @@ interface PnpApi {
topLevel?: PnpPackageLocator
}

interface LoadedPnp {
api: PnpApi
/**
* Yarn's libzip-patched `fs`, captured after `.pnp.cjs` `setup()` runs. The
* scanner installs it as the active read filesystem so package roots inside
* `.yarn/cache/*.zip` are readable.
*/
readFs: ReadFs
}

interface NodeModuleInternals {
_resolveFilename: (...args: Array<unknown>) => unknown
findPnpApi?: (lookupSource: string) => PnpApi | null
}

const requireFromHere = createRequire(import.meta.url)

function findPnpFile(start: string): string | null {
Expand Down Expand Up @@ -80,32 +97,48 @@ function assertLocalNodeModulesSupported(root: string): void {
}
}

function loadPnpApi(root: string): PnpApi | null {
function loadPnpApi(root: string): LoadedPnp | null {
const pnpPath = findPnpFile(root)
if (!pnpPath) return null

const moduleApi = requireFromHere('node:module') as NodeModuleInternals
const originalResolveFilename = moduleApi._resolveFilename
// Capture `fs` before setup(). Yarn's `setup()` patches the `fs` module in
// place (libzip layer for reading inside `.yarn/cache/*.zip`), so this
// reference becomes patched without routing a post-setup `require('node:fs')`
// through Yarn's resolver hook (which rejects the `node:fs` specifier under
// Yarn 1 PnP).
const readFs = requireFromHere('node:fs') as unknown as ReadFs

try {
const pnpModule = requireFromHere(pnpPath) as PnpApi
if (typeof pnpModule.setup === 'function') {
pnpModule.setup()
}

// setup() also installs a global CommonJS module-resolution hook. Restore
// the resolver: Intent reads package data as files and never requires
// candidate package code, so leaving the resolver installed is an
// unnecessary, process-wide side effect (notably for a long-running
// `mcp serve`). The in-place `fs` patch survives the restore.
moduleApi._resolveFilename = originalResolveFilename

if (
typeof pnpModule.getDependencyTreeRoots === 'function' &&
typeof pnpModule.getPackageInformation === 'function'
) {
return pnpModule
return { api: pnpModule, readFs }
}

const projectRequire = createRequire(join(dirname(pnpPath), 'package.json'))
return projectRequire('pnpapi') as PnpApi
return { api: projectRequire('pnpapi') as PnpApi, readFs }
} catch (err) {
moduleApi._resolveFilename = originalResolveFilename
try {
const moduleApi = requireFromHere('node:module') as {
findPnpApi?: (lookupSource: string) => PnpApi | null
}
const foundApi = moduleApi.findPnpApi?.(root)
if (foundApi) return foundApi
if (foundApi) {
return { api: foundApi, readFs }
}
} catch {
// Ignore and report the project PnP load error below.
}
Expand Down Expand Up @@ -215,8 +248,9 @@ function readSkillEntry(
skillsDir: string,
childDir: string,
skillFile: string,
readFs: ReadFs = nodeReadFs,
): SkillEntry {
const fm = parseFrontmatter(skillFile)
const fm = parseFrontmatter(skillFile, readFs)
const relName = toPosixPath(relative(skillsDir, childDir))
const desc =
typeof fm?.description === 'string'
Expand All @@ -236,6 +270,7 @@ function discoverSkillByNameHint(
skillsDir: string,
packageName: string,
skillNameHint: string,
readFs: ReadFs = nodeReadFs,
): Array<SkillEntry> {
const skills: Array<SkillEntry> = []
const seen = new Set<string>()
Expand All @@ -246,9 +281,9 @@ function discoverSkillByNameHint(
if (!resolvedHint) continue

const { childDir, skillFile } = resolvedHint
if (!existsSync(skillFile)) continue
if (!readFs.existsSync(skillFile)) continue

const skill = readSkillEntry(skillsDir, childDir, skillFile)
const skill = readSkillEntry(skillsDir, childDir, skillFile, readFs)
if (skill.name !== hint || seen.has(skill.name)) continue

seen.add(skill.name)
Expand All @@ -262,12 +297,13 @@ function discoverSkills(
skillsDir: string,
fsCache: IntentFsCache,
): Array<SkillEntry> {
const readFs = fsCache.getReadFs()
return fsCache
.findSkillFiles(skillsDir)
.flatMap((skillFile): Array<SkillEntry> => {
const childDir = dirname(skillFile)
if (childDir === skillsDir) return []
return [readSkillEntry(skillsDir, childDir, skillFile)]
return [readSkillEntry(skillsDir, childDir, skillFile, readFs)]
})
}

Expand Down Expand Up @@ -456,7 +492,11 @@ export function scanForIntents(
function getPnpApi(): PnpApi | null {
if (scanScope === 'global') return null
if (pnpApi === undefined) {
pnpApi = loadPnpApi(projectRoot)
const loaded = loadPnpApi(projectRoot)
pnpApi = loaded?.api ?? null
// Install Yarn's libzip-patched fs before any package inside the zip
// cache is read (scanPnpPackages runs after this).
if (loaded) fsCache.useFs(loaded.readFs)
}
return pnpApi
}
Expand Down Expand Up @@ -500,6 +540,7 @@ export function scanForIntents(
discoverSkills: (skillsDir) => discoverSkills(skillsDir, fsCache),
getPackageDepth,
getFsIdentity: fsCache.getFsIdentity,
exists: fsCache.exists,
packageIndexes,
packages,
projectRoot,
Expand Down Expand Up @@ -691,10 +732,12 @@ export function scanIntentPackageAtRoot(
skillsDir,
packageName,
options.skillNameHint!,
fsCache.getReadFs(),
)
: (skillsDir) => discoverSkills(skillsDir, fsCache),
getPackageDepth,
getFsIdentity: fsCache.getFsIdentity,
exists: fsCache.exists,
packageIndexes,
packages,
projectRoot,
Expand Down
Loading
Loading