Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fa5a8a2
docs: add workspace scanning & integration tests implementation plan
KyleAMathews Mar 14, 2026
53b23d1
docs: update plan with createRequire, transitive workspace walking, r…
KyleAMathews Mar 14, 2026
b531999
docs: fix plan per round 2 review — keep global scanning, add tryRegi…
KyleAMathews Mar 14, 2026
fd02ab6
feat: replace resolveDepDir with createRequire-based resolution
KyleAMathews Mar 14, 2026
33d75ed
refactor: export resolveWorkspacePackages from setup.ts
KyleAMathews Mar 14, 2026
0477d99
feat: add workspace scanning phase to discover skills in monorepo pac…
KyleAMathews Mar 14, 2026
d45901f
test: add integration test fixture packages
KyleAMathews Mar 14, 2026
2cadfd1
test: add Verdaccio-based integration test infrastructure
KyleAMathews Mar 14, 2026
a87c4b4
test: add 49-test integration grid for scanner across PM/structure/de…
KyleAMathews Mar 14, 2026
fccd8a3
fix: add --provenance=false to npm publish, fix test:lib exclude pattern
KyleAMathews Mar 14, 2026
bf50f7d
fix: handle export maps, isolate npm cache, detect lockfiles from wor…
KyleAMathews Mar 14, 2026
86a520d
fix: apply code review and simplification fixes
KyleAMathews Mar 14, 2026
2d251d5
chore: update changeset to cover full PR scope
KyleAMathews Mar 14, 2026
4f5f66d
ci: apply automated fixes
autofix-ci[bot] Mar 14, 2026
1fd068b
chore: replace changeset for workspace scanning PR
KyleAMathews Mar 14, 2026
e08f77d
ci: ignore verdaccio devDependencies in knip config
KyleAMathews Mar 14, 2026
94dfa34
chore: gitignore docs/superpowers and remove from index
KyleAMathews Mar 15, 2026
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
5 changes: 0 additions & 5 deletions .changeset/fix-npx-symlink-silent-failure.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/workspace-scanning-integration-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Add workspace-aware scanning so `intent list` discovers skills in monorepo workspace packages when run from the root. Replace `resolveDepDir` with `createRequire`-based resolution that handles hoisted deps, pnpm symlinks, and export maps. The `resolveDepDir` public API signature changed from 4 parameters to 2 — callers using the old signature should update to `resolveDepDir(depName, parentDir)`. `detectPackageManager` now checks workspace root for lockfiles when scanning from a subdir.
Comment on lines +1 to +5
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a non-patch bump for this release.

This changeset documents a public resolveDepDir signature break, so shipping it as patch would let downstream users take a breaking change on a patch upgrade.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/workspace-scanning-integration-tests.md around lines 1 - 5, The
changeset frontmatter currently uses 'patch' but the diff documents a breaking
public API change (resolveDepDir signature), so update the changeset release
type from 'patch' to 'major' (or appropriate non-patch level) in the frontmatter
to prevent shipping this breaking change as a patch; ensure the description
still mentions the resolveDepDir signature change and the detectPackageManager
behavior so reviewers can see why a non-patch bump is required.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,5 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*

.angular

docs/superpowers
3 changes: 2 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"src/intent-library.ts",
"src/library-scanner.ts"
],
"ignore": ["meta/**"]
"ignore": ["meta/**"],
"ignoreDependencies": ["verdaccio", "@verdaccio/node-api"]
}
}
}
7 changes: 5 additions & 2 deletions packages/intent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@
"yaml": "^2.7.0"
},
"devDependencies": {
"tsdown": "^0.19.0"
"@verdaccio/node-api": "6.0.0-6-next.76",
"tsdown": "^0.19.0",
"verdaccio": "^6.3.2"
},
"scripts": {
"prepack": "npm run build",
"build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts --format esm --dts",
"test:lib": "vitest run",
"test:lib": "vitest run --exclude 'tests/integration/**'",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In npm package.json scripts, are single quotes portable on Windows cmd.exe, or should double quotes be used for cross-platform glob arguments?

💡 Result:

Single quotes in package.json are not portable to Windows cmd.exe. npm runs scripts via /bin/sh on POSIX but via cmd.exe on Windows by default, and cmd.exe doesn’t treat '...' as quoting—it treats the ' characters as literals—so your glob argument may be passed with the quotes included (and tools may fail to match). Use double quotes for cross-platform arguments (escaped for JSON), e.g. "lint": "eslint \"src/**/*.js\"" (or avoid shell-quoting by using a JS wrapper / a cross-shell helper). [1] [2] [3]

Sources:
[1] npm Docs – scripts run with /bin/sh (POSIX) or cmd.exe (Windows): (docs.npmjs.com)
[2] 2ality – Windows Command shell doesn’t support single quotes for quoting: (2ality.com)
[3] Stack Overflow – cmd.exe does not recognize single quotes as string delimiters: (stackoverflow.com)

Citations:


Use double quotes for the exclude glob in test:lib.

npm runs package scripts through cmd.exe on Windows, and cmd.exe does not treat single quotes as quoting. That means the literal 'tests/integration/**' is passed to Vitest there, so the integration suite will not actually be excluded. Use escaped double quotes instead. (docs.npmjs.com)

🔧 Proposed fix
-    "test:lib": "vitest run --exclude 'tests/integration/**'",
+    "test:lib": "vitest run --exclude \"tests/integration/**\"",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"test:lib": "vitest run --exclude 'tests/integration/**'",
"test:lib": "vitest run --exclude \"tests/integration/**\"",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/package.json` at line 40, The "test:lib" npm script currently
uses single quotes for the exclude glob which fails on Windows; update the
script value for "test:lib" in package.json (the npm script named test:lib) to
use escaped double quotes for the glob (e.g., \"tests/integration/**\") so
Vitest actually receives the exclude pattern on Windows; keep the rest of the
command ("vitest run --exclude") unchanged.

"test:integration": "vitest run tests/integration/",
"test:types": "tsc --noEmit"
}
}
5 changes: 2 additions & 3 deletions packages/intent/src/library-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,8 @@ function discoverSkills(skillsDir: string): Array<SkillEntry> {

export async function scanLibrary(
scriptPath: string,
projectRoot?: string,
_projectRoot?: string,
): Promise<LibraryScanResult> {
Comment on lines 92 to 95
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep honoring projectRoot or remove the parameter in a breaking change.

scanLibrary() still accepts a second argument, but it no longer constrains resolution even though resolveDepDir() can now walk all the way to the filesystem root. That silently expands the scan scope and can pull in a same-named dependency from an unrelated ancestor node_modules.

Also applies to: 133-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/library-scanner.ts` around lines 92 - 95, scanLibrary
currently accepts a second parameter projectRoot but ignores it, allowing
resolveDepDir to walk past the intended root and pick up unrelated ancestor
node_modules; fix by honoring projectRoot (or remove the parameter as a breaking
change). Update scanLibrary (and the other affected callsites around the later
block) to pass projectRoot into resolveDepDir and change resolveDepDir to stop
walking past the provided projectRoot boundary (or if you choose the breaking
change, remove the _projectRoot parameter from scanLibrary signature and callers
and update types like LibraryScanResult usage accordingly). Ensure you reference
the scanLibrary function and resolveDepDir callsites so the upward traversal is
bounded by projectRoot.

const nodeModulesDir = join(projectRoot ?? process.cwd(), 'node_modules')
const packages: Array<LibraryPackage> = []
const warnings: Array<string> = []
const visited = new Set<string>()
Expand Down Expand Up @@ -132,7 +131,7 @@ export async function scanLibrary(
})

for (const depName of getDeps(pkg)) {
const depDir = resolveDepDir(depName, dir, name, nodeModulesDir)
const depDir = resolveDepDir(depName, dir)
if (!depDir) continue
const depPkg = readPkgJson(depDir)
if (depPkg && hasIntentBin(depPkg)) {
Expand Down
83 changes: 57 additions & 26 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
import { join, relative, sep } from 'node:path'
import {
detectGlobalNodeModules,
Expand All @@ -7,6 +7,11 @@ import {
parseFrontmatter,
resolveDepDir,
} from './utils.js'
import {
findWorkspaceRoot,
readWorkspacePatterns,
resolveWorkspacePackages,
} from './setup.js'
import type {
InstalledVariant,
IntentConfig,
Expand All @@ -16,7 +21,6 @@ import type {
SkillEntry,
VersionConflict,
} from './types.js'
import type { Dirent } from 'node:fs'

// ---------------------------------------------------------------------------
// Package manager detection
Expand All @@ -40,11 +44,17 @@ function detectPackageManager(root: string): PackageManager {
)
}

if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm'
if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')))
return 'bun'
if (existsSync(join(root, 'yarn.lock'))) return 'yarn'
if (existsSync(join(root, 'package-lock.json'))) return 'npm'
const dirsToCheck = [root]
const wsRoot = findWorkspaceRoot(root)
if (wsRoot && wsRoot !== root) dirsToCheck.push(wsRoot)

for (const dir of dirsToCheck) {
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm'
if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock')))
return 'bun'
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn'
if (existsSync(join(dir, 'package-lock.json'))) return 'npm'
}
return 'unknown'
}

Expand Down Expand Up @@ -337,8 +347,6 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
: undefined,
},
}
const resolutionRoots = [nodeModulesDir]

// Track registered package names to avoid duplicates across phases
const packageIndexes = new Map<string, number>()
const packageJsonCache = new Map<string, Record<string, unknown> | null>()
Expand Down Expand Up @@ -369,15 +377,6 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
? existsSync(detected.path)
: false
}

if (
nodeModules.global.exists &&
nodeModules.global.path &&
nodeModules.global.path !== nodeModulesDir &&
!resolutionRoots.includes(nodeModules.global.path)
) {
resolutionRoots.push(nodeModules.global.path)
}
}

function readPkgJson(dirPath: string): Record<string, unknown> | null {
Expand Down Expand Up @@ -490,7 +489,7 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
}

for (const depName of getDeps(pkgJson)) {
const depDir = resolveDepDir(depName, pkgDir, pkgName, resolutionRoots)
const depDir = resolveDepDir(depName, pkgDir)
if (!depDir || walkVisited.has(depDir)) continue

tryRegister(depDir, depName)
Expand Down Expand Up @@ -524,20 +523,52 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
}

if (!projectPkg) return
walkDepsFromPkgJson(projectPkg, projectRoot, true)
}

for (const depName of getDeps(projectPkg, true)) {
const depDir = resolveDepDir(
depName,
projectRoot,
depName,
resolutionRoots,
)
/** Resolve and walk deps listed in a package.json. */
function walkDepsFromPkgJson(
pkgJson: Record<string, unknown>,
fromDir: string,
includeDevDeps = false,
): void {
for (const depName of getDeps(pkgJson, includeDevDeps)) {
const depDir = resolveDepDir(depName, fromDir)
if (depDir && !walkVisited.has(depDir)) {
tryRegister(depDir, depName)
walkDeps(depDir, depName)
}
}
}

/**
* In monorepos, discover workspace packages and walk their deps.
* Handles pnpm monorepos (workspace-specific node_modules) and ensures
* transitive skills packages are found through workspace package dependencies.
*/
function walkWorkspacePackages(): void {
const workspacePatterns = readWorkspacePatterns(projectRoot)
if (!workspacePatterns) return

for (const wsDir of resolveWorkspacePackages(
projectRoot,
workspacePatterns,
)) {
const wsNodeModules = join(wsDir, 'node_modules')
if (existsSync(wsNodeModules)) {
for (const dirPath of listNodeModulesPackageDirs(wsNodeModules)) {
tryRegister(dirPath, 'unknown')
}
}

const wsPkg = readPkgJson(wsDir)
if (wsPkg) {
walkDepsFromPkgJson(wsPkg, wsDir)
}
}
Comment on lines +549 to +568
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Register the workspace package itself before walking its dependencies.

This phase only scans wsDir/node_modules and package.json dependencies. A workspace package that owns skills/ directly is still invisible unless some other phase finds it through a symlinked node_modules entry.

Suggested fix
       const wsPkg = readPkgJson(wsDir)
       if (wsPkg) {
+        const wsName = typeof wsPkg.name === 'string' ? wsPkg.name : 'unknown'
+        tryRegister(wsDir, wsName)
         walkDepsFromPkgJson(wsPkg, wsDir)
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/scanner.ts` around lines 549 - 568, In
walkWorkspacePackages(), register the workspace package directory itself before
scanning its node_modules and dependencies by calling tryRegister(wsDir,
'workspace') (or another explicit tag) immediately after obtaining wsDir; keep
the existing calls to listNodeModulesPackageDirs and walkDepsFromPkgJson
(readPkgJson, resolveWorkspacePackages) but move or add the tryRegister(wsDir,
'workspace') so packages that own skills/ in their workspace root are discovered
even when no symlinked node_modules entry exists.

}

walkWorkspacePackages()
walkKnownPackages()
walkProjectDeps()

Expand Down
10 changes: 8 additions & 2 deletions packages/intent/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ export function readWorkspacePatterns(root: string): Array<string> | null {
* Handles simple patterns like "packages/*" and "packages/**".
* Each resolved directory must contain a package.json.
*/
function resolveWorkspacePackages(
export function resolveWorkspacePackages(
root: string,
patterns: Array<string>,
): Array<string> {
Expand All @@ -497,7 +497,13 @@ function resolveWorkspacePackages(
collectPackageDirs(baseDir, dirs)
} else if (pattern.endsWith('/*')) {
// Single level: direct children
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
let entries: Array<import('node:fs').Dirent>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does the @typescript-eslint/consistent-type-importsrule allow inlineimport('node:fs').Direnttype annotations, or should this useimport type { Dirent } from 'node:fs'?

💡 Result:

With the rule’s default options, no: @typescript-eslint/consistent-type-imports sets disallowTypeAnnotations: true, which disallows inline type annotations using import(...) (e.g. type T = import('Foo').Foo). (typescript-eslint.io)

So import('node:fs').Dirent will be flagged, and you should write:

import type { Dirent } from 'node:fs';

If you want to allow import('node:fs').Dirent, set disallowTypeAnnotations: false. (typescript-eslint.io)

Citations:


🏁 Script executed:

# Check if the file exists and examine line 500
if [ -f "packages/intent/src/setup.ts" ]; then
  echo "=== File exists ==="
  echo "=== Lines around 500 ==="
  sed -n '495,505p' "packages/intent/src/setup.ts"
else
  echo "File not found at packages/intent/src/setup.ts"
fi

Repository: TanStack/intent

Length of output: 469


🏁 Script executed:

# Find and check ESLint/TypeScript configuration files
echo "=== Checking for ESLint config files ==="
find . -maxdepth 2 -type f \( -name ".eslintrc*" -o -name "eslint.config.*" -o -name "tsconfig.json" \) 2>/dev/null | head -20

Repository: TanStack/intent

Length of output: 134


🏁 Script executed:

# Check if `@typescript-eslint/consistent-type-imports` rule is configured
echo "=== Searching for consistent-type-imports configuration ==="
rg "consistent-type-imports" --type json --type js --type ts 2>/dev/null | head -20

Repository: TanStack/intent

Length of output: 118


🏁 Script executed:

# Read the ESLint config to check rule settings
echo "=== ESLint Configuration ==="
cat ./eslint.config.js | head -100

Repository: TanStack/intent

Length of output: 636


🏁 Script executed:

# Also check the imports at the top of the setup.ts file to verify current state
echo "=== Top of setup.ts file (first 50 lines) ==="
head -50 "packages/intent/src/setup.ts"

Repository: TanStack/intent

Length of output: 1312


🏁 Script executed:

# Check what `@tanstack/eslint-config` contains
echo "=== Looking for `@tanstack/eslint-config` ==="
fd "@tanstack/eslint-config" --type f 2>/dev/null | head -5

Repository: TanStack/intent

Length of output: 102


🏁 Script executed:

# Search node_modules or check package.json for the config
echo "=== Checking package.json dependencies ==="
rg "@tanstack/eslint-config" . --type json 2>/dev/null | head -10

Repository: TanStack/intent

Length of output: 156


🏁 Script executed:

# Check if there's a way to see what tanstackConfig includes
echo "=== Search for references to consistent-type-imports in the project ==="
rg "consistent-type-imports" . 2>/dev/null

Repository: TanStack/intent

Length of output: 130


Add a type import for Dirent from 'node:fs'.

The inline type annotation Array<import('node:fs').Dirent> violates the @typescript-eslint/consistent-type-imports rule, which requires type-only imports to be declared using the import type syntax.

Proposed fix
 import {
   existsSync,
   mkdirSync,
   readFileSync,
   readdirSync,
   writeFileSync,
 } from 'node:fs'
+import type { Dirent } from 'node:fs'
@@
-      let entries: Array<import('node:fs').Dirent>
+      let entries: Array<Dirent>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let entries: Array<import('node:fs').Dirent>
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from 'node:fs'
import type { Dirent } from 'node:fs'
let entries: Array<Dirent>
🧰 Tools
🪛 ESLint

[error] 500-500: import() type annotations are forbidden.

(@typescript-eslint/consistent-type-imports)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/setup.ts` at line 500, The declaration uses an inline
type import Array<import('node:fs').Dirent> which violates the
consistent-type-imports rule; add a top-level type-only import (import type {
Dirent } from 'node:fs') and update the variable declaration (the entries
variable in setup.ts) to use Dirent or Dirent[] instead of the inline import
form; ensure you remove the inline import usage and keep only the type import so
TypeScript and ESLint are satisfied.

try {
entries = readdirSync(baseDir, { withFileTypes: true })
} catch {
continue
}
Comment on lines +501 to +505
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't hide readdirSync() failures in the common packages/* path.

Line 493 already proved baseDir exists, so anything reaching this catch is unexpected. Silently continuing here makes workspace discovery return partial results with no diagnostic, unlike collectPackageDirs() below which does warn.

🔧 Proposed fix
-      } catch {
-        continue
+      } catch (err: unknown) {
+        console.error(
+          `Warning: could not read directory ${baseDir}: ${err instanceof Error ? err.message : err}`,
+        )
+        continue
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
entries = readdirSync(baseDir, { withFileTypes: true })
} catch {
continue
}
try {
entries = readdirSync(baseDir, { withFileTypes: true })
} catch (err: unknown) {
console.error(
`Warning: could not read directory ${baseDir}: ${err instanceof Error ? err.message : err}`,
)
continue
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/setup.ts` around lines 501 - 505, The code currently
swallows errors from readdirSync(baseDir) which hides unexpected failures;
change the empty catch to mirror collectPackageDirs() by catching the error,
logging a warning that includes the baseDir and the caught error details, and
then continue or rethrow as appropriate for discovery (do not silently continue
without a diagnostic). Update the try/catch around readdirSync(baseDir, {
withFileTypes: true }) in setup.ts to log the error (include
error.message/stack) and provide contextual text about workspace discovery
failing for that baseDir, using the same logging facility used by
collectPackageDirs().

for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = join(baseDir, entry.name)
if (existsSync(join(dir, 'package.json'))) {
Expand Down
66 changes: 25 additions & 41 deletions packages/intent/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { execFileSync } from 'node:child_process'
import {
existsSync,
readFileSync,
readdirSync,
realpathSync,
type Dirent,
} from 'node:fs'
import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'

Expand Down Expand Up @@ -144,57 +139,46 @@ export function detectGlobalNodeModules(packageManager: string): {
}

/**
* Resolve the directory of a dependency by name. First checks the top-level
* node_modules (hoisted layout — npm, yarn, bun), then resolves through the
* parent package's real path to handle pnpm's virtual store layout where
* transitive deps are siblings in the .pnpm virtual store node_modules.
* Resolve the directory of a dependency by name. Tries createRequire first
* (handles pnpm symlinks), then falls back to walking up node_modules
* directories (handles packages with export maps that block ./package.json).
*/
export function resolveDepDir(
depName: string,
parentDir: string,
parentName: string,
nodeModulesDirs: string | Array<string>,
): string | null {
if (!parentName) return null

const roots = Array.isArray(nodeModulesDirs)
? nodeModulesDirs
: [nodeModulesDirs]

// 1. Top-level (hoisted)
for (const nodeModulesDir of roots) {
const topLevel = join(nodeModulesDir, depName)
if (existsSync(join(topLevel, 'package.json'))) return topLevel
}

// 2. Resolve nested installs under the parent package (npm/pnpm/bun)
const nestedNodeModules = join(parentDir, 'node_modules', depName)
if (existsSync(join(nestedNodeModules, 'package.json'))) {
return nestedNodeModules
}

// 3. Resolve through parent's real path (pnpm virtual store)
// Try createRequire — works for most packages including pnpm virtual store
try {
const realParent = realpathSync(parentDir)
const segments = parentName.split('/').length
let nmDir = realParent
for (let i = 0; i < segments; i++) {
nmDir = dirname(nmDir)
}
const nested = join(nmDir, depName)
if (existsSync(join(nested, 'package.json'))) return nested
const req = createRequire(join(parentDir, 'package.json'))
const pkgJsonPath = req.resolve(join(depName, 'package.json'))
return dirname(pkgJsonPath)
} catch (err: unknown) {
const code =
err && typeof err === 'object' && 'code' in err
? (err as NodeJS.ErrnoException).code
: undefined
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
if (
code &&
code !== 'MODULE_NOT_FOUND' &&
code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
) {
console.warn(
`Warning: could not resolve ${depName} from ${parentDir}: ${err instanceof Error ? err.message : String(err)}`,
)
}
}

// Fallback: walk up from parentDir checking node_modules/<depName>.
// Handles packages with exports maps that don't expose ./package.json.
let dir = parentDir
while (true) {
const candidate = join(dir, 'node_modules', depName)
if (existsSync(join(candidate, 'package.json'))) return candidate
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}

return null
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@test-intent/skills-leaf",
"version": "1.0.0",
"intent": {
"version": 1,
"repo": "test/skills-leaf",
"docs": "https://example.com/docs"
},
"files": [
"skills"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: core
description: 'Core skill for integration testing'
type: core
---

# Core Skill

This is a test skill used by integration tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@test-intent/wrapper-1",
"version": "1.0.0",
"dependencies": {
"@test-intent/skills-leaf": "1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@test-intent/wrapper-2",
"version": "1.0.0",
"dependencies": {
"@test-intent/wrapper-1": "1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@test-intent/wrapper-3",
"version": "1.0.0",
"dependencies": {
"@test-intent/wrapper-2": "1.0.0"
}
}
Loading
Loading