fix(helpers): resolve the ruflo CLI from the helper root, not the project

A home-level helper or a user-level plugin hook serves every opened project.
hook-handler.cjs and statusline.cjs executed a CLI found under the project
(<cwd>/node_modules/{@claude-flow/cli,ruflo}, <cwd>/v3/...), and every npx
fallback ran with the project as cwd, where npx prefers the project's own
install. The plugin shims also resolved `ruflo` through relative PATH
entries, which resolve against the cwd.

- hook-handler.cjs / statusline.cjs (package and repo copies): candidates
  are built from the helper's install root; for a project-local helper that
  is the project, so its behaviour is unchanged.
- npx fallbacks pass `--prefix <helper root>` (generated hook-handler and
  ruflo-hook.cjs too) or, in the plugin shims, `--prefix <plugin data dir or
  home>`. The spawned CLI still runs with the project as its cwd.
- ruflo-hook.cjs (all four copies): relative/empty PATH entries are skipped.

Test: helper-cli-resolution-trust.test.ts (11 cases, 10 fail on main);
hook-handler-artifact-parity follows the npx args into cliSpawnArgs().

The signed helpers changed, so the helpers manifest needs re-signing.
This commit is contained in:
Dragan Spiridonov authored and r committed 2026-10-10 10:51:22 -04:00
1 parent 24774b9087
commit e625b7104a
10 files changed
+1600 -1370

No files matched your search

+14 -2
View File
@@ -79,6 +79,15 @@ function envValue(env, name) {
return key ? env[key] : undefined; return key ? env[key] : undefined;
} }
/**
* Where the npx fallback resolves ruflo from: the plugin's data dir or the
* user's home, never the cwd (the opened project). `--prefix` only changes
* package resolution; the CLI still runs with the project as its cwd.
*/
function npxPrefix(env = process.env) {
return envValue(env, 'CLAUDE_PLUGIN_DATA') || envValue(env, 'PLUGIN_DATA') || require('os').homedir();
}
/** /**
* Locate a command on PATH using fs only. * Locate a command on PATH using fs only.
* *
@@ -98,6 +107,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';') ? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
: ['']; : [''];
for (const dir of dirs) { for (const dir of dirs) {
// A relative or empty PATH entry resolves against the cwd, i.e. the
// opened project; never resolve the CLI from there.
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
for (const ext of extensions) { for (const ext of extensions) {
const base = path.resolve(dir || '.', command); const base = path.resolve(dir || '.', command);
const candidates = ext const candidates = ext
@@ -289,7 +301,7 @@ function main() {
// a spurious failure even though the shim itself works correctly. // a spurious failure even though the shim itself works correctly.
// The bash version doesn't hit this because it backgrounded the work. // The bash version doesn't hit this because it backgrounded the work.
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') { if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData); invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', npxPrefix(), 'ruflo@latest'], hookArgs, stdinData);
} }
done(); done();
@@ -301,4 +313,4 @@ function main() {
// invokes this file directly, so main() runs unconditionally otherwise. // invokes this file directly, so main() runs unconditionally otherwise.
if (!globalThis.__RUFLO_HOOK_IMPORT_ONLY__) main(); if (!globalThis.__RUFLO_HOOK_IMPORT_ONLY__) main();
module.exports = { invokeHook, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg }; module.exports = { invokeHook, npxPrefix, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg };
+19 -14
View File
@@ -18,6 +18,10 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const helpersDir = __dirname; const helpersDir = __dirname;
// The helper's own install root: the project for a project-local helper,
// $HOME for a home-level one. CLI resolution starts here, never at the opened
// project, so a home-level helper does not run code the project supplies.
const helperRoot = path.resolve(helpersDir, '..', '..');
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors // Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
// statusline-generator.ts's resolveCliBin() candidate list. Used only to // statusline-generator.ts's resolveCliBin() candidate list. Used only to
@@ -38,12 +42,11 @@ const helpersDir = __dirname;
function resolveCliBinForHook() { function resolveCliBinForHook() {
try { try {
const home = os.homedir(); const home = os.homedir();
const cwd = process.cwd();
const candidates = [ const candidates = [
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'), path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'), path.join(helperRoot, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
path.join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'), path.join(helperRoot, 'node_modules', 'ruflo', 'bin', 'cli.js'),
path.join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'), path.join(helperRoot, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
// helpersDir is .claude/helpers/ inside the package itself when this // helpersDir is .claude/helpers/ inside the package itself when this
// file is running from a real @claude-flow/cli install (not a project // file is running from a real @claude-flow/cli install (not a project
// that merely copied the helper) — its bin/ is two levels up. // that merely copied the helper) — its bin/ is two levels up.
@@ -77,14 +80,19 @@ function resolveCliBinForHook() {
// render path, where local-first exists purely for per-render latency. // render path, where local-first exists purely for per-render latency.
// `--prefer-offline` avoids a registry round trip for the tarball when // `--prefer-offline` avoids a registry round trip for the tarball when
// already cached while still resolving the current `@latest` version. // already cached while still resolving the current `@latest` version.
//
// `--prefix helperRoot` makes npx resolve @claude-flow/cli from the helper's
// install root instead of the cwd (the opened project); the spawned CLI still
// runs with the project as its cwd.
function cliSpawnArgs(cliBin, args, platform = process.platform) {
if (cliBin) return [process.execPath, [cliBin, ...args]];
return [platform === 'win32' ? 'npx.cmd' : 'npx', ['--prefer-offline', '--prefix', helperRoot, '@claude-flow/cli', ...args]];
}
function spawnDetachedHookRefresh(subcommand) { function spawnDetachedHookRefresh(subcommand) {
try { try {
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const cliBin = resolveCliBinForHook(); const spawnArgs = cliSpawnArgs(resolveCliBinForHook(), ['hooks', subcommand, '--quiet']);
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const spawnArgs = cliBin
? [process.execPath, [cliBin, 'hooks', subcommand, '--quiet']]
: [cmd, ['--prefer-offline', '@claude-flow/cli', 'hooks', subcommand, '--quiet']];
const child = spawn(spawnArgs[0], spawnArgs[1], { const child = spawn(spawnArgs[0], spawnArgs[1], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
@@ -151,10 +159,7 @@ function firstRunAutoEnableIfEligible() {
const cliBin = resolveCliBinForHook(); const cliBin = resolveCliBinForHook();
const runDetached = (args) => { const runDetached = (args) => {
try { try {
const spawnArgs = cliBin const spawnArgs = cliSpawnArgs(cliBin, args);
? [process.execPath, [cliBin, ...args]]
: [process.platform === 'win32' ? 'npx.cmd' : 'npx',
['--prefer-offline', '@claude-flow/cli', ...args]];
const child = spawn(spawnArgs[0], spawnArgs[1], { const child = spawn(spawnArgs[0], spawnArgs[1], {
detached: true, stdio: 'ignore', env: process.env, windowsHide: true, detached: true, stdio: 'ignore', env: process.env, windowsHide: true,
}); });
@@ -772,4 +777,4 @@ if (require.main === module) {
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555). // Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence }; const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS }; module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot };
File diff suppressed because it is too large. Load diff
+14 -2
View File
@@ -79,6 +79,15 @@ function envValue(env, name) {
return key ? env[key] : undefined; return key ? env[key] : undefined;
} }
/**
* Where the npx fallback resolves ruflo from: the plugin's data dir or the
* user's home, never the cwd (the opened project). `--prefix` only changes
* package resolution; the CLI still runs with the project as its cwd.
*/
function npxPrefix(env = process.env) {
return envValue(env, 'CLAUDE_PLUGIN_DATA') || envValue(env, 'PLUGIN_DATA') || require('os').homedir();
}
/** /**
* Locate a command on PATH using fs only. * Locate a command on PATH using fs only.
* *
@@ -98,6 +107,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';') ? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
: ['']; : [''];
for (const dir of dirs) { for (const dir of dirs) {
// A relative or empty PATH entry resolves against the cwd, i.e. the
// opened project; never resolve the CLI from there.
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
for (const ext of extensions) { for (const ext of extensions) {
const base = path.resolve(dir || '.', command); const base = path.resolve(dir || '.', command);
const candidates = ext const candidates = ext
@@ -289,7 +301,7 @@ function main() {
// a spurious failure even though the shim itself works correctly. // a spurious failure even though the shim itself works correctly.
// The bash version doesn't hit this because it backgrounded the work. // The bash version doesn't hit this because it backgrounded the work.
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') { if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData); invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', npxPrefix(), 'ruflo@latest'], hookArgs, stdinData);
} }
done(); done();
@@ -301,4 +313,4 @@ function main() {
// invokes this file directly, so main() runs unconditionally otherwise. // invokes this file directly, so main() runs unconditionally otherwise.
if (!globalThis.__RUFLO_HOOK_IMPORT_ONLY__) main(); if (!globalThis.__RUFLO_HOOK_IMPORT_ONLY__) main();
module.exports = { invokeHook, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg }; module.exports = { invokeHook, npxPrefix, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg };
+14 -2
View File
@@ -57,6 +57,15 @@ function envValue(env, name) {
return key ? env[key] : undefined; return key ? env[key] : undefined;
} }
/**
* Where the npx fallback resolves ruflo from: the plugin's data dir or the
* user's home, never the cwd (the opened project). `--prefix` only changes
* package resolution; the CLI still runs with the project as its cwd.
*/
function npxPrefix(env = process.env) {
return envValue(env, 'CLAUDE_PLUGIN_DATA') || envValue(env, 'PLUGIN_DATA') || require('os').homedir();
}
/** /**
* Locate a command on PATH using fs only. * Locate a command on PATH using fs only.
* *
@@ -76,6 +85,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';') ? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
: ['']; : [''];
for (const dir of dirs) { for (const dir of dirs) {
// A relative or empty PATH entry resolves against the cwd, i.e. the
// opened project; never resolve the CLI from there.
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
for (const ext of extensions) { for (const ext of extensions) {
const base = path.resolve(dir || '.', command); const base = path.resolve(dir || '.', command);
const candidates = ext const candidates = ext
@@ -271,7 +283,7 @@ function invokeCli(hookSubcommand, hookArgs, stdinData) {
// itself works correctly. The bash version doesn't hit this because it // itself works correctly. The bash version doesn't hit this because it
// backgrounded the work. // backgrounded the work.
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') { if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookSubcommand, hookArgs, stdinData); invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', npxPrefix(), 'ruflo@latest'], hookSubcommand, hookArgs, stdinData);
} }
} }
@@ -465,4 +477,4 @@ if (process.env.RUFLO_HOOK_UNIT_TEST !== '1') {
main(); main();
} }
module.exports = { escapeCmdArg, invokeHook, resolveCommandPath, resolveInvocation, resolveNpmShim }; module.exports = { escapeCmdArg, invokeHook, npxPrefix, resolveCommandPath, resolveInvocation, resolveNpmShim };
@@ -18,6 +18,10 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const helpersDir = __dirname; const helpersDir = __dirname;
// The helper's own install root: the project for a project-local helper,
// $HOME for a home-level one. CLI resolution starts here, never at the opened
// project, so a home-level helper does not run code the project supplies.
const helperRoot = path.resolve(helpersDir, '..', '..');
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors // Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
// statusline-generator.ts's resolveCliBin() candidate list. Used only to // statusline-generator.ts's resolveCliBin() candidate list. Used only to
@@ -38,12 +42,11 @@ const helpersDir = __dirname;
function resolveCliBinForHook() { function resolveCliBinForHook() {
try { try {
const home = os.homedir(); const home = os.homedir();
const cwd = process.cwd();
const candidates = [ const candidates = [
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'), path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'), path.join(helperRoot, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
path.join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'), path.join(helperRoot, 'node_modules', 'ruflo', 'bin', 'cli.js'),
path.join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'), path.join(helperRoot, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
// helpersDir is .claude/helpers/ inside the package itself when this // helpersDir is .claude/helpers/ inside the package itself when this
// file is running from a real @claude-flow/cli install (not a project // file is running from a real @claude-flow/cli install (not a project
// that merely copied the helper) — its bin/ is two levels up. // that merely copied the helper) — its bin/ is two levels up.
@@ -77,14 +80,19 @@ function resolveCliBinForHook() {
// render path, where local-first exists purely for per-render latency. // render path, where local-first exists purely for per-render latency.
// `--prefer-offline` avoids a registry round trip for the tarball when // `--prefer-offline` avoids a registry round trip for the tarball when
// already cached while still resolving the current `@latest` version. // already cached while still resolving the current `@latest` version.
//
// `--prefix helperRoot` makes npx resolve @claude-flow/cli from the helper's
// install root instead of the cwd (the opened project); the spawned CLI still
// runs with the project as its cwd.
function cliSpawnArgs(cliBin, args, platform = process.platform) {
if (cliBin) return [process.execPath, [cliBin, ...args]];
return [platform === 'win32' ? 'npx.cmd' : 'npx', ['--prefer-offline', '--prefix', helperRoot, '@claude-flow/cli', ...args]];
}
function spawnDetachedHookRefresh(subcommand) { function spawnDetachedHookRefresh(subcommand) {
try { try {
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const cliBin = resolveCliBinForHook(); const spawnArgs = cliSpawnArgs(resolveCliBinForHook(), ['hooks', subcommand, '--quiet']);
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const spawnArgs = cliBin
? [process.execPath, [cliBin, 'hooks', subcommand, '--quiet']]
: [cmd, ['--prefer-offline', '@claude-flow/cli', 'hooks', subcommand, '--quiet']];
const child = spawn(spawnArgs[0], spawnArgs[1], { const child = spawn(spawnArgs[0], spawnArgs[1], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
@@ -151,10 +159,7 @@ function firstRunAutoEnableIfEligible() {
const cliBin = resolveCliBinForHook(); const cliBin = resolveCliBinForHook();
const runDetached = (args) => { const runDetached = (args) => {
try { try {
const spawnArgs = cliBin const spawnArgs = cliSpawnArgs(cliBin, args);
? [process.execPath, [cliBin, ...args]]
: [process.platform === 'win32' ? 'npx.cmd' : 'npx',
['--prefer-offline', '@claude-flow/cli', ...args]];
const child = spawn(spawnArgs[0], spawnArgs[1], { const child = spawn(spawnArgs[0], spawnArgs[1], {
detached: true, stdio: 'ignore', env: process.env, windowsHide: true, detached: true, stdio: 'ignore', env: process.env, windowsHide: true,
}); });
@@ -772,4 +777,4 @@ if (require.main === module) {
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555). // Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence }; const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS }; module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot };
@@ -43,6 +43,10 @@ const CONFIG = {
}; };
const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd(); const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
// The helper's own install root: the project for a project-local helper,
// $HOME for a home-level one. The CLI is resolved from here, never from CWD,
// so a home-level statusline does not run code the opened project supplies.
const HELPER_ROOT = path.resolve(__dirname, '..', '..');
// Replaced by statusline-generator with the package root of the CLI that // Replaced by statusline-generator with the package root of the CLI that
// installed this helper. This survives custom npm prefixes and bundled Node // installed this helper. This survives custom npm prefixes and bundled Node
// runtimes whose process.execPath belongs to a different tree (#2811). // runtimes whose process.execPath belongs to a different tree (#2811).
@@ -111,9 +115,9 @@ function resolveCliBinCandidates() {
const home = os.homedir(); const home = os.homedir();
candidates.push( candidates.push(
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'), path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'), path.join(HELPER_ROOT, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
path.join(CWD, 'node_modules', 'ruflo', 'bin', 'cli.js'), path.join(HELPER_ROOT, 'node_modules', 'ruflo', 'bin', 'cli.js'),
path.join(CWD, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'), path.join(HELPER_ROOT, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
); );
try { try {
const binDir = path.dirname(process.execPath); const binDir = path.dirname(process.execPath);
@@ -239,7 +243,8 @@ function getStatuslineData() {
// delegation, so it could never get seeded on Windows either). // delegation, so it could never get seeded on Windows either).
const cmds = resolveCliBinCandidates() const cmds = resolveCliBinCandidates()
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json') .map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']); // --prefix: npx resolves the package from HELPER_ROOT, not the project.
.concat(['npx --prefer-offline --prefix "' + HELPER_ROOT + '" @claude-flow/cli hooks statusline --json']);
for (const cmd of cmds) { for (const cmd of cmds) {
try { try {
const raw = execSync( const raw = execSync(
@@ -0,0 +1,162 @@
/**
* Hook helpers and plugin hook shims launch the ruflo CLI. A home-level
* helper or a user-level plugin serves every opened project, so the CLI must
* be resolved from the helper's own install root (or the plugin data / home
* dir), never from the project: not from <project>/node_modules, not from a
* relative PATH entry, and not through npx's project-local lookup.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateHookHandler, generateRufloHookCjs } from '../src/init/helpers-generator.js';
const here = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(here, '../../../..');
const require_ = createRequire(import.meta.url);
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
});
/** A built-looking CLI install whose bin leaves a marker when it runs. */
function plantCli(pkgDir: string, marker: string) {
mkdirSync(join(pkgDir, 'bin'), { recursive: true });
mkdirSync(join(pkgDir, 'dist', 'src'), { recursive: true });
writeFileSync(join(pkgDir, 'bin', 'cli.js'), `require('fs').writeFileSync(${JSON.stringify(marker)}, '1');\n`);
writeFileSync(join(pkgDir, 'dist', 'src', 'index.js'), '');
}
function fixture() {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'ruflo-cli-trust-')));
tempRoots.push(root);
const home = join(root, 'home');
const project = join(root, 'project');
const marker = join(root, 'project-cli-ran');
mkdirSync(join(home, '.claude', 'helpers'), { recursive: true });
plantCli(join(project, 'node_modules', '@claude-flow', 'cli'), marker);
return { root, home, project, marker };
}
function nodeEval(code: string, cwd: string, env: NodeJS.ProcessEnv) {
const result = spawnSync(process.execPath, ['-e', code], { cwd, env: { ...process.env, ...env }, encoding: 'utf8', timeout: 15_000 });
expect(result.error).toBeUndefined();
return result;
}
describe('hook-handler.cjs resolves the CLI from its install root', () => {
for (const [name, source] of [
['package', resolve(here, '../.claude/helpers/hook-handler.cjs')],
['repository', resolve(REPO, '.claude/helpers/hook-handler.cjs')],
] as const) {
it(`${name}: a home-level helper ignores the project's CLI and pins npx --prefix`, () => {
const { home, project } = fixture();
const hook = join(home, '.claude', 'helpers', 'hook-handler.cjs');
copyFileSync(source, hook);
const out = nodeEval(
`const h = require(${JSON.stringify(hook)});` +
`console.log(JSON.stringify({ bin: h.resolveCliBinForHook(), posix: h.cliSpawnArgs(null, ['hooks', 'x'], 'linux'), win: h.cliSpawnArgs(null, ['hooks', 'x'], 'win32') }));`,
project, { HOME: home, USERPROFILE: home, CLAUDE_PROJECT_DIR: project },
);
const r = JSON.parse(out.stdout.trim().split('\n').pop() as string);
expect(r.bin).toBeNull();
expect(r.posix).toEqual(['npx', ['--prefer-offline', '--prefix', home, '@claude-flow/cli', 'hooks', 'x']]);
expect(r.win).toEqual(['npx.cmd', ['--prefer-offline', '--prefix', home, '@claude-flow/cli', 'hooks', 'x']]);
});
it(`${name}: a project-local helper still uses the project's CLI`, () => {
const { home, project } = fixture();
const hook = join(project, '.claude', 'helpers', 'hook-handler.cjs');
mkdirSync(dirname(hook), { recursive: true });
copyFileSync(source, hook);
const out = nodeEval(`console.log(require(${JSON.stringify(hook)}).resolveCliBinForHook())`, project, { HOME: home, USERPROFILE: home });
expect(out.stdout.trim().split('\n').pop()).toBe(join(project, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'));
});
}
it('the generated hook-handler pins its npx refresh to the helper root', () => {
expect(generateHookHandler()).toContain("'--prefer-offline', '--prefix', path.resolve(helpersDir, '..', '..'), '@claude-flow/cli'");
});
});
describe('statusline.cjs resolves the CLI from its install root', () => {
const source = resolve(here, '../.claude/helpers/statusline.cjs');
// An empty PATH keeps the npx fallback from running; the bin candidates are
// launched through process.execPath, so they still run if selected.
const render = (helper: string, home: string, project: string, emptyBin: string) =>
spawnSync(process.execPath, [helper], {
cwd: project,
env: { HOME: home, USERPROFILE: home, CLAUDE_PROJECT_DIR: project, PATH: emptyBin },
input: '{}',
encoding: 'utf8',
timeout: 30_000,
});
it("a home-level statusline never runs the project's CLI", () => {
const { root, home, project, marker } = fixture();
const emptyBin = join(root, 'empty-bin');
mkdirSync(emptyBin);
const helper = join(home, '.claude', 'helpers', 'statusline.cjs');
copyFileSync(source, helper);
render(helper, home, project, emptyBin);
expect(existsSync(marker)).toBe(false);
});
it("a project-local statusline still uses the project's CLI (control)", () => {
const { root, home, project, marker } = fixture();
const emptyBin = join(root, 'empty-bin');
mkdirSync(emptyBin);
const helper = join(project, '.claude', 'helpers', 'statusline.cjs');
mkdirSync(dirname(helper), { recursive: true });
copyFileSync(source, helper);
render(helper, home, project, emptyBin);
expect(existsSync(marker)).toBe(true);
});
});
type Shim = {
npxPrefix?: (env?: NodeJS.ProcessEnv) => string;
resolveCommandPath: (cmd: string, env?: NodeJS.ProcessEnv, platform?: string) => string | null;
};
describe('ruflo-hook.cjs plugin shims', () => {
const shims = ['plugins/ruflo-core/scripts/ruflo-hook.cjs', 'plugin/scripts/ruflo-hook.cjs', '.claude-plugin/scripts/ruflo-hook.cjs'];
for (const rel of shims) {
it(`${rel}: ignores relative PATH entries and pins the npx prefix`, () => {
// The shims run main() on load unless told they are imported for a test
// (ruflo-core reads RUFLO_HOOK_UNIT_TEST, the others a global flag).
const unitTest = process.env.RUFLO_HOOK_UNIT_TEST;
process.env.RUFLO_HOOK_UNIT_TEST = '1';
(globalThis as Record<string, unknown>).__RUFLO_HOOK_IMPORT_ONLY__ = true;
const shim = require_(resolve(REPO, rel)) as Shim;
delete (globalThis as Record<string, unknown>).__RUFLO_HOOK_IMPORT_ONLY__;
if (unitTest === undefined) delete process.env.RUFLO_HOOK_UNIT_TEST;
else process.env.RUFLO_HOOK_UNIT_TEST = unitTest;
const { root, home } = fixture();
const absBin = join(root, 'abs-bin');
mkdirSync(absBin);
const rufloAbs = join(absBin, 'ruflo');
writeFileSync(rufloAbs, '#!/bin/sh\n');
chmodSync(rufloAbs, 0o755);
// A relative entry that would resolve against the cwd is skipped
// (posix and win32 forms); an absolute entry still resolves.
expect(shim.resolveCommandPath('ruflo', { PATH: `.${':'}relative-dir` }, 'linux')).toBeNull();
expect(shim.resolveCommandPath('ruflo', { PATH: `.;relative-dir`, PATHEXT: '.CMD' }, 'win32')).toBeNull();
expect(shim.resolveCommandPath('ruflo', { PATH: `.:${absBin}` }, 'linux')).toBe(rufloAbs);
expect(shim.npxPrefix?.({ CLAUDE_PLUGIN_DATA: '/plugin/data' })).toBe('/plugin/data');
expect(shim.npxPrefix?.({ HOME: home })).toBeTypeOf('string');
});
}
it('the generated ruflo-hook.cjs pins its npx fallback to the helper root', () => {
expect(generateRufloHookCjs()).toContain("'--prefer-offline', '--yes', '--prefix', path.resolve(__dirname, '..', '..'), 'ruflo@latest'");
});
});
@@ -56,9 +56,14 @@ describe('hook-handler.cjs — resolveCliBinForHook validates a real dist, not j
const idx = source.indexOf('function spawnDetachedHookRefresh'); const idx = source.indexOf('function spawnDetachedHookRefresh');
expect(idx).toBeGreaterThan(-1); expect(idx).toBeGreaterThan(-1);
const body = source.slice(idx, idx + 700); const body = source.slice(idx, idx + 700);
expect(body).toContain('@claude-flow/cli'); expect(body).toContain('cliSpawnArgs(resolveCliBinForHook()');
expect(body).toContain('--prefer-offline');
expect(body).not.toContain('if (!cliBin) return;'); expect(body).not.toContain('if (!cliBin) return;');
// cliSpawnArgs() builds the npx fallback when no local candidate resolves.
const argsIdx = source.indexOf('function cliSpawnArgs');
expect(argsIdx).toBeGreaterThan(-1);
const argsBody = source.slice(argsIdx, argsIdx + 400);
expect(argsBody).toContain('@claude-flow/cli');
expect(argsBody).toContain('--prefer-offline');
}); });
}); });
@@ -615,7 +615,9 @@ export function generateHookHandler(): string {
'function spawnFunnelRefresh() {', 'function spawnFunnelRefresh() {',
' try {', ' try {',
" var cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';", " var cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';",
" var args = ['--prefer-offline', '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];", // --prefix: resolve the package from this helper's install root, never
// from the cwd (the opened project); the CLI still runs in the project.
" var args = ['--prefer-offline', '--prefix', path.resolve(helpersDir, '..', '..'), '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
' var child = spawn(cmd, args, {', ' var child = spawn(cmd, args, {',
" detached: true, stdio: 'ignore', env: Object.assign({}, process.env),", " detached: true, stdio: 'ignore', env: Object.assign({}, process.env),",
' });', ' });',
@@ -1637,6 +1639,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';') ? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
: ['']; : [''];
for (const dir of dirs) { for (const dir of dirs) {
// A relative or empty PATH entry resolves against the cwd, i.e. the
// opened project; never resolve the CLI from there.
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
for (const ext of extensions) { for (const ext of extensions) {
const base = path.resolve(dir || '.', command); const base = path.resolve(dir || '.', command);
const candidates = ext const candidates = ext
@@ -1789,7 +1794,9 @@ function main() {
// exists but cannot be resolved to an entrypoint still runs, via layer 2. // exists but cannot be resolved to an entrypoint still runs, via layer 2.
if (resolveCommandPath('ruflo')) { invokeHook('ruflo', [], hookArgs, stdinData); done(); } if (resolveCommandPath('ruflo')) { invokeHook('ruflo', [], hookArgs, stdinData); done(); }
if (resolveCommandPath('claude-flow')) { invokeHook('claude-flow', [], hookArgs, stdinData); done(); } if (resolveCommandPath('claude-flow')) { invokeHook('claude-flow', [], hookArgs, stdinData); done(); }
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData); // --prefix: npx resolves ruflo from this helper's install root, never from
// the cwd (the opened project); the CLI still runs in the project.
invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', path.resolve(__dirname, '..', '..'), 'ruflo@latest'], hookArgs, stdinData);
done(); done();
} }