Files
ECC/scripts/lib/resolve-ecc-root.js
T
Gaurav DubeyandGitHub c714dc5654 fix(resolve-ecc-root): require ECC skills, not just scripts, before accepting a root (#2544) (#2577)
* fix(resolve-ecc-root): require ECC skills, not just scripts, before accepting a root (#2544)

resolveEccRoot() accepted a candidate root on script-only evidence
(scripts/lib/utils.js). A partial install that lands ECC's scripts into
~/.claude but not ECC's skills short-circuited at the standard-install
branch, so skill-resolving callers built skills/... paths against a root
where they do not exist and every command failed three layers away.

For the default probe (skill consumers, reached via INLINE_RESOLVE) a
candidate now qualifies only if it contains both the script tree and a
sentinel ECC skill; the same stricter check guards the plugin-root and
plugin-cache branches. An explicit caller probe is still honored exactly,
so script consumers (e.g. session-start-bootstrap, which probes for the
hook runner) are unaffected. Merely checking that skills/ exists is
insufficient — a user's own ~/.claude/skills/ can be present with none of
ECC's skills.

Adds a regression test for the exact partial-install scenario and updates
the resolver test fixtures to build complete roots.

* test(resolve-ecc-root): cover partial exact-plugin and cache roots; DRY skill sentinel (#2544)

Address CodeRabbit review on PR #2577:
- Extend #2544 regression coverage to the exact-plugin and versioned
  plugin-cache branches, asserting the stricter both-sentinels predicate
  rejects a scripts-only root there too (not only for ~/.claude).
- Extract the ECC_SKILL_SENTINEL constant in command-plugin-root.test.js
  and reuse it at both fixture setup sites instead of duplicating the literal.
2026-07-25 22:21:27 -07:00

156 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const CURRENT_PLUGIN_SLUG = 'ecc';
const LEGACY_PLUGIN_SLUG = 'everything-claude-code';
const CURRENT_PLUGIN_HANDLE = `${CURRENT_PLUGIN_SLUG}@${CURRENT_PLUGIN_SLUG}`;
const LEGACY_PLUGIN_HANDLE = `${LEGACY_PLUGIN_SLUG}@${LEGACY_PLUGIN_SLUG}`;
const PLUGIN_CACHE_SLUGS = [CURRENT_PLUGIN_SLUG, LEGACY_PLUGIN_SLUG];
const PLUGIN_ROOT_SEGMENTS = [
[CURRENT_PLUGIN_SLUG],
[CURRENT_PLUGIN_HANDLE],
['marketplaces', CURRENT_PLUGIN_SLUG],
[LEGACY_PLUGIN_SLUG],
[LEGACY_PLUGIN_HANDLE],
['marketplaces', LEGACY_PLUGIN_SLUG],
];
// Artifacts that identify a COMPLETE ECC root when the caller gives no explicit
// probe. A real ECC root ships both the script tree AND ECC's skills; a partial
// install (scripts copied, skills not) must not qualify for skill-resolving
// callers, which build `skills/...` paths against the resolved root (#2544).
// Checking "skills/ exists" is not enough — a user's own ~/.claude/skills/ can
// be present with none of ECC's skills — so we probe for a sentinel skill that
// ships in every ECC root and is exactly what the failing skill commands need.
// If that skill is ever renamed, move this sentinel with it.
const DEFAULT_SCRIPT_PROBE = path.join('scripts', 'lib', 'utils.js');
const DEFAULT_SKILL_PROBE = path.join('skills', 'continuous-learning-v2');
/**
* Resolve the ECC source root directory.
*
* Tries, in order:
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user)
* 2. Standard install location (~/.claude/) — when scripts exist there
* 3. Known plugin roots under ~/.claude/plugins/ (current + legacy slugs)
* 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/{ecc,everything-claude-code}/
* 5. Fallback to ~/.claude/ (original behaviour)
*
* @param {object} [options]
* @param {string} [options.homeDir] Override home directory (for testing)
* @param {string} [options.envRoot] Override CLAUDE_PLUGIN_ROOT (for testing)
* @param {string} [options.probe] Relative path used to verify a candidate
* root contains what the caller needs. When
* given, it is honored exactly (script
* consumers pass their own script path). When
* omitted, a candidate must contain BOTH the
* ECC script tree and a sentinel ECC skill,
* so a partial install (scripts without
* skills) is rejected for skill consumers.
* @returns {string} Resolved ECC root path
*/
function resolveEccRoot(options = {}) {
const envRoot = options.envRoot !== undefined
? options.envRoot
: (process.env.CLAUDE_PLUGIN_ROOT || '');
if (envRoot && envRoot.trim()) {
return envRoot.trim();
}
const homeDir = options.homeDir || os.homedir();
const claudeDir = path.join(homeDir, '.claude');
// Decide whether a candidate directory is a usable ECC root. An explicit
// caller probe is honored exactly (script consumers know the artifact they
// need). With the default probe the caller is a skill consumer, so a
// candidate must contain both ECC's scripts and a sentinel ECC skill —
// otherwise a scripts-only ~/.claude short-circuits and every skill path
// resolves to a location that does not exist (#2544).
const isRoot = options.probe
? (dir) => fs.existsSync(path.join(dir, options.probe))
: (dir) => fs.existsSync(path.join(dir, DEFAULT_SCRIPT_PROBE))
&& fs.existsSync(path.join(dir, DEFAULT_SKILL_PROBE));
// Standard install — files are copied directly into ~/.claude/
if (isRoot(claudeDir)) {
return claudeDir;
}
// Exact legacy plugin install locations. These preserve backwards
// compatibility without scanning arbitrary plugin trees.
const legacyPluginRoots = PLUGIN_ROOT_SEGMENTS.map((segments) =>
path.join(claudeDir, 'plugins', ...segments)
);
for (const candidate of legacyPluginRoots) {
if (isRoot(candidate)) {
return candidate;
}
}
// Plugin cache — Claude Code stores marketplace plugins under
// ~/.claude/plugins/cache/<plugin-name>/<org>/<version>/
try {
for (const slug of PLUGIN_CACHE_SLUGS) {
const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug);
const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true });
for (const orgEntry of orgDirs) {
if (!orgEntry.isDirectory()) continue;
const orgPath = path.join(cacheBase, orgEntry.name);
let versionDirs;
try {
versionDirs = fs.readdirSync(orgPath, { withFileTypes: true });
} catch {
continue;
}
for (const verEntry of versionDirs) {
if (!verEntry.isDirectory()) continue;
const candidate = path.join(orgPath, verEntry.name);
if (isRoot(candidate)) {
return candidate;
}
}
}
}
} catch {
// Plugin cache doesn't exist or isn't readable — continue to fallback
}
return claudeDir;
}
/**
* Compact inline locator for embedding in hooks.json and command .md code blocks.
*
* Earlier revisions inlined the *entire* resolveEccRoot() search (~700 chars,
* duplicated ~80×). That blob used a spread (`...s`) over nested array literals,
* which broke Windows hook execution due to shell quoting (#2368).
*
* This minified form contains no spread, no nested array literals, and no
* escaped double quotes, so it survives `node -e "..."` quoting on every shell.
* When CLAUDE_PLUGIN_ROOT is set (as Claude Code does for plugin hooks and
* commands) it is used directly. Otherwise the inline probes the same set of
* locations resolveEccRoot() knows about — ~/.claude, the exact plugin roots
* under ~/.claude/plugins/, and the versioned plugin cache — only far enough to
* load the committed resolve-ecc-root module, then delegates the authoritative
* decision to resolveEccRoot(). This keeps discovery behaviour identical to the
* old inline while centralising the real logic in one tested module.
*
* Usage in commands:
* const _r = <paste INLINE_RESOLVE>;
* const sm = require(_r + '/scripts/lib/session-manager');
*/
const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})()`;
module.exports = {
resolveEccRoot,
INLINE_RESOLVE,
};