mcp-launch.cjs cannot resolve a local CLI from any directory but `$HOME` — every other cwd falls back to `npx @latest`
- Dominant language
- TypeScript
- Stars
- 72.7k
- Forks
- 8.6k
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 83
Description
## Summary
`mcp-launch.cjs` resolves `@claude-flow/cli` from a fixed candidate list. Three of the
four candidates are built from `process.cwd()` with `existsSync` and no ancestor walk,
and the one cwd-independent candidate is gated behind a `dist/` guard that an unbuilt
marketplace checkout cannot pass.
The practical result: a session started anywhere other than the home directory falls
through to `npx -y @claude-flow/cli@latest` — a registry round-trip at every session
start, running whatever version was published most recently.
## The resolution function
```js
function resolveLocalCliBin(cwd, home) {
const candidates = [
join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'),
join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
];
for (const candidate of candidates) {
try {
const distEntry = join(dirname(candidate), '..', 'dist', 'src', 'index.js');
if (existsSync(candidate) && existsSync(distEntry)) {
return candidate;
}
} catch {
// try next candidate
}
}
return null;
}
```
and the fallback it reaches when that returns `null`:
```js
return {
command: npxCmd, // 'npx.cmd' on win32
args: ['-y', '@claude-flow/cli@latest', ...MCP_ARGS],
shell: process.platform === 'win32',
};
```
## Why every candidate fails on a normal install
**Candidate 1** is the only cwd-independent one — it is built from `home`. But its guard
checks `/dist/src/index.js`, and a marketplace plugin is installed by
`git clone`/`git pull` with no build step, so `dist/` does not exist. The guard is
correct to reject it (the file comment says as much: importing it "throws
ERR_MODULE_NOT_FOUND on every real command"), but the effect is that the one candidate
that could work anywhere never matches.
**Candidates 2–4** are `join(cwd, …)` tested with `existsSync`. That is a literal path
test against a single directory — it does not walk ancestors the way Node's own module
resolution does. So they match only when the process happens to start in a directory
that itself contains the package.
**A globally installed `ruflo` matches none of them.** The global package exposes
`bin/ruflo.js`, not `bin/cli.js`, so candidate 3's path does not exist even though the
CLI is installed and on `PATH`.
## Reproduction
1. `npm i -g ruflo@` — no project-local install.
2. Start a session with cwd = `$HOME`. The launcher still finds nothing and uses npx.
3. Create `$HOME/node_modules/@claude-flow/cli` pointing at the global copy
(symlink/junction). Candidate 2 now matches — **but only when cwd is exactly
`$HOME`**.
4. Start a session from any subdirectory, e.g. `$HOME/dev/some-project`. Candidate 2 is
`join(cwd, 'node_modules', …)`, which does not exist there, so the launcher returns
to the npx path.
Measured on Windows 11, Node 24.19.0, ruflo 3.38.12:
| Path taken | Handshake |
|---|---|
| `npx -y @claude-flow/cli@latest mcp start` (cold) | **34 s** |
| same, warm npx cache | **30–31 s** |
| local `bin/cli.js` via a matching candidate | **0.46 s** |
| global `bin/ruflo.js mcp start`, absolute path | **0.48 s** |
The MCP stdio handshake timeout is 30 s, so the npx path fails to connect roughly as
often as it succeeds.
## Two suggested fixes, both already patterns in this codebase
**1. Use `require.resolve`.** Replacing the `join(cwd, …)` + `existsSync` checks with
`require.resolve('@claude-flow/cli/bin/cli.js', { paths: [cwd, home] })` walks ancestor
`node_modules` the way Node does, so a single install resolves from any subdirectory.
**2. Accept an environment override.** `mcp-launch.cjs` reads no environment variable at
all — its only `process.env` reference passes the parent environment through to the
child.
The project's own hook shim, `scripts/ruflo-hook.cjs`, already does this and has **two**:
```js
const override = process.env.RUFLO_HOOK_CLI_OVERRIDE;
if (override) { … }
…
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], …);
}
```
An equivalent `RUFLO_MCP_CLI_OVERRIDE` (and a matching skip flag) would let operators
pin the launcher to a known path without patching plugin files — which is otherwise the
only option, and is wiped on the next plugin update.
Worth noting the hook shim also uses `--prefer-offline` on its fallback, while the
launcher's is `-y @claude-flow/cli@latest`. The hook shim is the better-behaved of the
two in every respect.
## Consequences worth weighing
- **Unpinned execution at startup.** Every session outside `$HOME` runs `@latest`, so an
upstream publish changes what executes with no review and no version bump on the
operator's side.
- **Silent version drift.** The nested engine is depended on by range, so the version
the launcher fetches can differ from the one the installed wrapper reports.
- **Related symptom.** #2946 (npx-invoked `ruflo mcp start` strips manually-installed packages on every fresh launch) is downstream of this same fallback: a launcher that resolved the local install would never take the npx path that causes it.
- **Disk.** Two `_npx` cache directories totalling ~1.55 GB accumulated from repeated
fallbacks on a single machine.
## Supporting example: the same cwd assumption in `pod-tick.mjs`
Not a separate request — it is the same pattern in a second place, which suggests a
convention worth revisiting rather than a one-off.
`ruflo-business-pods/scripts/pod-tick.mjs` defaults `--base-path` to `./.business-pods`,
resolved against `process.cwd()`. It then **writes state even in `--dry-run`**: a
file-based budget ledger and an agent-BBS room file per pod.
Running the documented dry-run from the plugin's own source directory:
```bash
node .../pod-tick.mjs --pod-template .json --dry-run
```
creates, relative to wherever you happened to be standing:
```
.business-pods/.agentbbs/room--.jsonl
.business-pods/budget/.json
```
Two observations:
1. **`--dry-run` writing to disk is surprising.** The flag reads as "no side effects",
and the docs present it as a validation step. Writing the ledger under a *different*
flag, or honouring dry-run for the ledger too, would match the expectation.
2. **cwd-relative default output** puts those files wherever the operator was standing —
in our case inside a plugin source tree, which is not a state directory. Defaulting
the base path relative to the pod template, or to a fixed state location, would be
more predictable.
This is the same underlying assumption as `resolveLocalCliBin()` treating
`process.cwd()` as a reliable anchor. Both behave correctly from one specific directory
and surprisingly from any other.
## Environment
- Windows 11 Pro 25H2 (10.0.26200), Node v24.19.0
- `ruflo@3.38.12` installed globally; nested `@claude-flow/cli@3.38.20`
- ruflo-core plugin 0.2.6, installed from the `ruvnet/ruflo` marketplace
- ruflo-business-pods 0.1.0 (for the supporting example)
Contributor guide
Research direction
Start with mcp-launch.cjs and inspect resolveLocalCliBin(), then compare its candidate checks with scripts/ruflo-hook.cjs. Reproduce the launcher from a project subdirectory and verify that an installed local or overridden CLI is selected without falling back to npx; confirm the MCP handshake completes without the registry round-trip.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100