@fnclaude/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fnc.js CHANGED
@@ -1,24 +1,89 @@
1
- #!/usr/bin/env bun
2
- // fnclaude entry point — invokes the main run loop.
1
+ #!/usr/bin/env node
2
+ // @fnclaude/cli entry point — owns the Node→Bun preflight and re-execs
3
+ // itself under Bun when needed.
3
4
  //
4
- // Bun is the runtime: we rely on Bun.TOML, Bun.spawn, process.execve, and
5
- // node-pty's Bun adaptation. The shebang prefers `bun` directly; PATH
6
- // resolution should pick up the user's mise-managed bun (per project
7
- // CLAUDE.md tooling discipline). When installed via npm, the package's
8
- // `bin` entry rewires to whatever Bun lives in the user's environment.
5
+ // Why Node-shebang for a Bun cli: when `npm i -g @fnclaude/cli` puts
6
+ // `fnc` on PATH, the user's shell invokes it under whichever runtime npm
7
+ // happened to link against typically Node, since npm itself runs under
8
+ // Node. We must therefore be loadable under Node, do our own runtime
9
+ // check, and re-exec under Bun ourselves. This shim used to live in the
10
+ // umbrella package (`fnclaude`), which meant standalone installs of
11
+ // `@fnclaude/cli` skipped it and silently degraded (and, on the Bun
12
+ // side, hit the `--`-stripping bug). Owning the preflight here is the
13
+ // single source of truth.
9
14
  //
10
15
  // Module resolution: dist/main.js is what npm-installed users execute;
11
- // local devs running from source use Bun's TS support to import src/main.ts
12
- // directly. We attempt dist first and fall back to src so both workflows
13
- // are first-class without a separate dev shim.
14
- import { existsSync } from 'node:fs';
15
- import { dirname, resolve } from 'node:path';
16
+ // local devs running from source use Bun's TS support to import
17
+ // src/main.ts directly. We attempt dist first and fall back to src so
18
+ // both workflows are first-class without a separate dev shim.
19
+ import { spawnSync } from 'node:child_process';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
16
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
+ import { decide, defaultLookupBun } from './preflight.js';
17
24
 
18
- const here = dirname(fileURLToPath(import.meta.url));
25
+ const selfPath = fileURLToPath(import.meta.url);
26
+ const here = dirname(selfPath);
27
+
28
+ // Short-circuit --version BEFORE the Bun preflight. Reading package.json
29
+ // is pure Node-stdlib work and doesn't need Bun — friendlier when
30
+ // someone is diagnosing a broken install without Bun on PATH.
31
+ const argv = process.argv.slice(2);
32
+ if (argv.includes('--version') || argv.includes('-v')) {
33
+ const pkg = JSON.parse(
34
+ readFileSync(join(here, '..', 'package.json'), 'utf8'),
35
+ );
36
+ process.stdout.write(`fnclaude ${pkg.version}\n`);
37
+ process.exit(0);
38
+ }
39
+
40
+ const decision = decide({
41
+ hasBun: typeof globalThis.Bun !== 'undefined',
42
+ lookupBun: defaultLookupBun,
43
+ });
44
+
45
+ if (decision.kind === 'error') {
46
+ process.stderr.write(`${decision.message}\n`);
47
+ process.exit(1);
48
+ }
49
+
50
+ if (decision.kind === 'reexec') {
51
+ // Re-launch ourselves under Bun. `spawnSync` with stdio:'inherit'
52
+ // forwards streams transparently; we propagate the child's exit code
53
+ // so the OS / parent process sees the same status it would have seen
54
+ // had Bun been the launcher all along.
55
+ //
56
+ // Argv-via-env: Bun strips the first `--` from a script's argv,
57
+ // regardless of where it appears (script invocation, `bun --`, `bun
58
+ // run`, shebang). Confirmed empirically. So passing the user's args
59
+ // as Bun-script argv would silently mangle `fnc -- "prompt"` into
60
+ // `fnc "prompt"` — the cli then treats the prompt as a cwd
61
+ // positional, the resolver fires, and 8 GitHub orgs 404 in series
62
+ // before the user gets a misleading "could not resolve" error. We
63
+ // sidestep by serialising the user's args into FNC_ARGS_JSON; main()
64
+ // reads from there when present (and deletes the env var to avoid
65
+ // leaking to its own children).
66
+ const r = spawnSync(decision.bun, [selfPath], {
67
+ stdio: 'inherit',
68
+ env: {
69
+ ...process.env,
70
+ FNC_ARGS_JSON: JSON.stringify(argv),
71
+ },
72
+ });
73
+ if (r.error) {
74
+ // ENOENT shouldn't reach here — lookupBun confirmed bun is reachable
75
+ // — but if something else broke (EACCES, ETXTBSY), surface it
76
+ // instead of swallowing.
77
+ process.stderr.write(`fnclaude: failed to re-exec under bun: ${r.error.message}\n`);
78
+ process.exit(1);
79
+ }
80
+ process.exit(r.status ?? 0);
81
+ }
82
+
83
+ // decision.kind === 'run' — we're already under Bun. Import main() and
84
+ // dispatch.
19
85
  const distMain = resolve(here, '..', 'dist', 'main.js');
20
86
  const srcMain = resolve(here, '..', 'src', 'main.ts');
21
-
22
87
  const target = existsSync(distMain) ? distMain : srcMain;
23
88
  const { main } = await import(pathToFileURL(target).href);
24
89
 
@@ -0,0 +1,66 @@
1
+ // Preflight for the cli `fnc` bin shim — decide whether to proceed,
2
+ // re-exec under Bun, or hard-error out with a directive message.
3
+ //
4
+ // The cli relies on Bun-only globals (`Bun.spawn`, `Bun.TOML.parse`,
5
+ // `Bun.which`, `process.execve`). When the bin is invoked under Node
6
+ // (typically because `npm i -g @fnclaude/cli` puts it on PATH and the
7
+ // user runs `fnc`), those Bun calls fail silently — `Bun.which` returns
8
+ // `undefined`, `Bun.TOML.parse` throws "Bun is not defined", etc. The
9
+ // cli then degrades into a "claude not found" / config-broken state
10
+ // that LOOKS like a normal failure but is actually a runtime mismatch.
11
+ //
12
+ // This preflight runs first and either re-execs under Bun (if `bun` is
13
+ // on PATH) or prints a directive error and exits non-zero — never the
14
+ // silent-degradation path.
15
+ //
16
+ // Extracted from the shim itself to keep it unit-testable: the decision
17
+ // function takes injected seams (typeof-Bun probe + PATH lookup) and
18
+ // returns a discriminated union; the shim handles the dispatch.
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ /**
23
+ * @typedef {{ kind: 'run' }
24
+ * | { kind: 'reexec', bun: string }
25
+ * | { kind: 'error', message: string }
26
+ * } PreflightDecision
27
+ */
28
+
29
+ /**
30
+ * Decide what the shim should do.
31
+ *
32
+ * @param {{ hasBun: boolean, lookupBun: () => string | null }} seams
33
+ * @returns {PreflightDecision}
34
+ */
35
+ export function decide({ hasBun, lookupBun }) {
36
+ if (hasBun) return { kind: 'run' };
37
+ const bun = lookupBun();
38
+ if (bun !== null) return { kind: 'reexec', bun };
39
+ return {
40
+ kind: 'error',
41
+ message:
42
+ 'fnclaude: this CLI requires Bun (https://bun.sh) to run.\n' +
43
+ ' The shim was invoked under Node, but the CLI depends on Bun-only\n' +
44
+ ' globals (Bun.spawn, Bun.TOML.parse, process.execve). Install Bun\n' +
45
+ ' and re-run `fnc`, or invoke the script via `bun ...` directly.',
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Default PATH lookup for `bun`. Returns the literal string `'bun'` on
51
+ * success (the OS will resolve it via PATH at spawn time), or null when
52
+ * `bun` is not reachable.
53
+ *
54
+ * Implementation note: `spawnSync('bun', ['--version'])` is the simplest
55
+ * cross-platform probe. ENOENT comes back as `result.error.code`, and a
56
+ * present-but-broken bun returns non-zero status. Both are treated as
57
+ * "not usable."
58
+ *
59
+ * @returns {string | null}
60
+ */
61
+ export function defaultLookupBun() {
62
+ const r = spawnSync('bun', ['--version'], { stdio: 'ignore' });
63
+ if (r.error) return null;
64
+ if (r.status !== 0) return null;
65
+ return 'bun';
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fnclaude/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "fnclaude CLI implementation (TypeScript rewrite, in progress)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/main.ts CHANGED
@@ -145,6 +145,41 @@ export interface RunDeps {
145
145
  data?: RunConfig;
146
146
  }
147
147
 
148
+ /**
149
+ * Read the user's argv, preferring `FNC_ARGS_JSON` over `process.argv`.
150
+ *
151
+ * The umbrella shim (packages/fnclaude/bin/fnc.js) sets `FNC_ARGS_JSON`
152
+ * on the env it spawns Bun with, because Bun strips the first `--`
153
+ * from a script's argv (confirmed empirically across `bun script.js`,
154
+ * `bun --`, `bun run`, and shebang invocations). Passing user args via
155
+ * the env var sidesteps that — Bun never sees them as its own argv.
156
+ *
157
+ * We DELETE the var after consumption so any child processes the cli
158
+ * spawns (claude, gh, the relaunch chain) don't inherit a stale value.
159
+ *
160
+ * Defensive: malformed or wrong-shape values fall through to
161
+ * `process.argv.slice(2)` rather than throwing — a corrupted env var
162
+ * shouldn't break the cli, just degrade to the pre-fix behaviour.
163
+ */
164
+ function readArgvFromEnvOrProcess(): readonly string[] {
165
+ const envArgs = process.env.FNC_ARGS_JSON;
166
+ if (envArgs !== undefined) {
167
+ delete process.env.FNC_ARGS_JSON;
168
+ try {
169
+ const parsed: unknown = JSON.parse(envArgs);
170
+ if (
171
+ Array.isArray(parsed) &&
172
+ parsed.every((x): x is string => typeof x === 'string')
173
+ ) {
174
+ return parsed;
175
+ }
176
+ } catch {
177
+ // Fall through to process.argv.
178
+ }
179
+ }
180
+ return process.argv.slice(2);
181
+ }
182
+
148
183
  function lookupClaudeFromPath(name: string): string | undefined {
149
184
  // Bun's PATH lookup: Bun.which() returns null when not found. Coerce to
150
185
  // undefined to keep the absent-value sentinel consistent with the rest of
@@ -174,7 +209,7 @@ export async function run(deps: RunDeps = {}): Promise<number> {
174
209
  const io = deps.io ?? {};
175
210
  const data = deps.data ?? {};
176
211
 
177
- const argv = io.argv ?? process.argv.slice(2);
212
+ const argv = io.argv ?? readArgvFromEnvOrProcess();
178
213
  const stdout = io.stdout ?? process.stdout;
179
214
  const stderr = io.stderr ?? process.stderr;
180
215
  const home = io.home ?? process.env.HOME ?? homedir();