@fnclaude/cli 1.1.1 → 2.0.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.
Files changed (100) hide show
  1. package/bin/fnc.js +34 -79
  2. package/package.json +6 -9
  3. package/share/fnclaude/templates/handoff.template.md +11 -0
  4. package/src/argv/classify.ts +48 -0
  5. package/src/argv/expand.ts +51 -0
  6. package/src/argv/intake.ts +52 -0
  7. package/src/argv/magic.ts +103 -0
  8. package/src/argv/parse.ts +213 -0
  9. package/src/argv/preserve-args.ts +333 -0
  10. package/src/argv/sentinel.ts +41 -0
  11. package/src/argv/short-flags.ts +152 -0
  12. package/src/config/load.ts +116 -0
  13. package/src/handoff/awaiter.ts +140 -0
  14. package/src/handoff/clean-env.ts +45 -0
  15. package/src/handoff/kill-and-exec.ts +110 -0
  16. package/src/handoff/spawn-launcher.ts +185 -0
  17. package/src/handoff/summary-file.ts +86 -0
  18. package/src/handoff/trigger.ts +90 -0
  19. package/src/help-version.ts +151 -0
  20. package/src/launch/compose-env.ts +34 -0
  21. package/src/launch/cross-cwd-parse.ts +69 -0
  22. package/src/launch/cross-cwd-relaunch.ts +95 -0
  23. package/src/launch/find-claude.ts +52 -0
  24. package/src/launch/live-permission-reader.ts +133 -0
  25. package/src/launch/ring-buffer.ts +92 -0
  26. package/src/main.ts +580 -437
  27. package/src/mcp/dispatch.ts +240 -0
  28. package/src/mcp/handlers/clipboard-backends.ts +176 -0
  29. package/src/mcp/handlers/clipboard.ts +62 -0
  30. package/src/mcp/handlers/restart.ts +156 -0
  31. package/src/mcp/handlers/spawn.ts +219 -0
  32. package/src/mcp/handlers/switch.ts +272 -0
  33. package/src/mcp/inject-config.ts +59 -0
  34. package/src/mcp/jsonrpc-server.ts +154 -0
  35. package/src/mcp/listener.ts +141 -0
  36. package/src/mcp/parent-dispatch.ts +154 -0
  37. package/src/mcp/socket-path.ts +48 -0
  38. package/src/mcp/wire.ts +181 -0
  39. package/src/name/auto-name.ts +162 -0
  40. package/src/name/llm-prompt.ts +14 -0
  41. package/src/name/sanitize.ts +57 -0
  42. package/src/name/sdk-llm.ts +42 -0
  43. package/src/noop/seed.ts +63 -0
  44. package/src/noop/template-source.ts +62 -0
  45. package/src/path/ensure-cwd.ts +95 -0
  46. package/src/path/resolve.ts +58 -0
  47. package/src/prompts/dir.ts +61 -0
  48. package/src/prompts/load.ts +100 -0
  49. package/src/prompts/select.ts +43 -0
  50. package/src/repo/clone-exec.ts +37 -0
  51. package/src/repo/clone.ts +45 -0
  52. package/src/repo/gh-runner.ts +68 -0
  53. package/src/repo/host-aliases.ts +58 -0
  54. package/src/repo/owner-lookup.ts +71 -0
  55. package/src/repo/ref.ts +146 -0
  56. package/src/repo/repo-settings.ts +99 -0
  57. package/src/repo/resolve-input.ts +179 -0
  58. package/src/repo/template.ts +92 -0
  59. package/src/warnings/buffer.ts +39 -0
  60. package/src/worktree/auto-tmux.ts +45 -0
  61. package/src/worktree/git-list.ts +73 -0
  62. package/src/worktree/intercept.ts +150 -0
  63. package/bin/preflight.js +0 -66
  64. package/prompts/agent-pitfall.md +0 -1
  65. package/prompts/noop-router.md +0 -186
  66. package/prompts/project-switch.md +0 -64
  67. package/prompts/restart.md +0 -50
  68. package/prompts/spawn.md +0 -62
  69. package/src/argParser.ts +0 -367
  70. package/src/args/preserve.ts +0 -338
  71. package/src/args.ts +0 -239
  72. package/src/argv.ts +0 -219
  73. package/src/autoname.ts +0 -273
  74. package/src/clipboard.ts +0 -149
  75. package/src/config.ts +0 -369
  76. package/src/errors.ts +0 -13
  77. package/src/handoff.ts +0 -108
  78. package/src/help.ts +0 -139
  79. package/src/hostAliases.ts +0 -139
  80. package/src/index.ts +0 -120
  81. package/src/mcp/client.ts +0 -645
  82. package/src/mcp/protocol.ts +0 -445
  83. package/src/mcp/socketListener.ts +0 -540
  84. package/src/noop.ts +0 -106
  85. package/src/passthrough.ts +0 -36
  86. package/src/paths.ts +0 -55
  87. package/src/prompts.ts +0 -279
  88. package/src/pty/unix.ts +0 -429
  89. package/src/pty/windows.ts +0 -125
  90. package/src/pty.ts +0 -380
  91. package/src/repoRef.ts +0 -158
  92. package/src/repoSettings.ts +0 -144
  93. package/src/resolver.ts +0 -519
  94. package/src/sanitize.ts +0 -120
  95. package/src/sessionState.ts +0 -220
  96. package/src/silentRelaunch.ts +0 -178
  97. package/src/spawn.ts +0 -163
  98. package/src/template.ts +0 -44
  99. package/src/warnings.ts +0 -34
  100. package/src/worktree.ts +0 -201
package/bin/fnc.js CHANGED
@@ -1,90 +1,45 @@
1
1
  #!/usr/bin/env node
2
- // @fnclaude/cli entry point — owns the Node→Bun preflight and re-execs
3
- // itself under Bun when needed.
4
2
  //
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.
3
+ // Node→Bun preflight. Bun 1.3.14 still strips the literal `--` sentinel from
4
+ // `process.argv` (see docs/decisions.md), which would corrupt `fnc -- <prompt>`
5
+ // invocations. Running this shim under Node first preserves the unstripped
6
+ // argv long enough to stuff it into FNC_ARGS_JSON, then re-execs under Bun
7
+ // where main.ts reads back from the env var instead of process.argv.
14
8
  //
15
- // Module resolution: dist/main.js is what npm-installed users execute;
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';
22
- import { fileURLToPath, pathToFileURL } from 'node:url';
23
- import { decide, defaultLookupBun } from './preflight.js';
9
+ // When this file is invoked directly under Bun (e.g. `bun bin/fnc.js`, or
10
+ // via the `#!/usr/bin/env bun` future state), `typeof Bun !== 'undefined'`
11
+ // short-circuits the preflight and we jump straight to main.
24
12
 
25
- const selfPath = fileURLToPath(import.meta.url);
26
- const here = dirname(selfPath);
13
+ import { fileURLToPath } from 'node:url';
27
14
 
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], {
15
+ if (typeof Bun === 'undefined') {
16
+ const { spawnSync } = await import('node:child_process');
17
+ const self = fileURLToPath(import.meta.url);
18
+ const argvJson = JSON.stringify(process.argv.slice(2));
19
+ const result = spawnSync('bun', [self], {
67
20
  stdio: 'inherit',
68
- env: {
69
- ...process.env,
70
- FNC_ARGS_JSON: JSON.stringify(argv),
71
- },
21
+ env: { ...process.env, FNC_ARGS_JSON: argvJson },
72
22
  });
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`);
23
+ if (result.error) {
24
+ const err = result.error;
25
+ const isMissingBun = /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT';
26
+ if (isMissingBun) {
27
+ process.stderr.write(
28
+ 'fnc: Bun runtime not found on PATH.\n' +
29
+ ' fnclaude requires Bun (Node alone is not supported).\n' +
30
+ ' Install: https://bun.sh — `curl -fsSL https://bun.sh/install | bash`\n',
31
+ );
32
+ } else {
33
+ process.stderr.write(`fnc: failed to re-exec under bun (${err.message})\n`);
34
+ }
35
+ process.exit(127);
36
+ }
37
+ if (result.signal) {
38
+ process.kill(process.pid, result.signal);
39
+ // Unreachable on Unix; defensive return for Windows where kill-self doesn't terminate.
78
40
  process.exit(1);
79
41
  }
80
- process.exit(r.status ?? 0);
42
+ process.exit(result.status ?? 1);
81
43
  }
82
44
 
83
- // decision.kind === 'run' — we're already under Bun. Import main() and
84
- // dispatch.
85
- const distMain = resolve(here, '..', 'dist', 'main.js');
86
- const srcMain = resolve(here, '..', 'src', 'main.ts');
87
- const target = existsSync(distMain) ? distMain : srcMain;
88
- const { main } = await import(pathToFileURL(target).href);
89
-
90
- main();
45
+ await import('../src/main.ts');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fnclaude/cli",
3
- "version": "1.1.1",
3
+ "version": "2.0.0",
4
4
  "description": "fnclaude CLI implementation (TypeScript rewrite, in progress)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,28 +9,25 @@
9
9
  "directory": "packages/cli"
10
10
  },
11
11
  "type": "module",
12
- "main": "./dist/index.js",
13
- "types": "./dist/index.d.ts",
12
+ "main": "./src/main.ts",
14
13
  "bin": {
15
14
  "fnc": "./bin/fnc.js"
16
15
  },
17
16
  "files": [
18
17
  "bin",
19
- "dist",
20
- "prompts",
18
+ "share",
21
19
  "src"
22
20
  ],
23
21
  "scripts": {
24
- "lint": "echo 'lint stub (cli)' && exit 0",
22
+ "lint": "echo 'lint stub (cli, rewrite window)' && exit 0",
25
23
  "test": "bun test",
26
- "build": "tsc -p tsconfig.json"
24
+ "build": "echo 'no build step (bun runs ts directly)' && exit 0"
27
25
  },
28
26
  "publishConfig": {
29
27
  "access": "public",
30
28
  "provenance": true
31
29
  },
32
30
  "dependencies": {
33
- "@anthropic-ai/sdk": "^0.98.0",
34
- "node-pty": "1.2.0-beta.13"
31
+ "@anthropic-ai/sdk": "^0.99.0"
35
32
  }
36
33
  }
@@ -0,0 +1,11 @@
1
+ <!-- BURN AFTER READING -->
2
+
3
+ This file is a scratch handoff buffer claude writes when one noop session
4
+ hands the conversation off to another. The receiving session reads it,
5
+ acts on it, and is free to overwrite or delete it.
6
+
7
+ Treat anything below this line as the previous session's outgoing brief.
8
+ If the file is otherwise empty, no prior session has written one yet —
9
+ that's the normal first-launch state.
10
+
11
+ ---
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Token classification. Pure functions over a single argv token.
3
+ *
4
+ * The Go canonical does this inline inside its argv loop; here it's
5
+ * lifted out as a pure function so the magic-positional state machine
6
+ * (§2.3) and short-flag expander (§4.5) can share the same classifier
7
+ * without restating the alphabets.
8
+ *
9
+ * Case-sensitive on the magic words — `Opus`, `OPUS`, etc. are positionals.
10
+ * Matches Go canonical, which does exact string comparison against
11
+ * lowercase literals.
12
+ */
13
+
14
+ export const MODELS = ['opus', 'sonnet', 'haiku'] as const;
15
+ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'auto'] as const;
16
+
17
+ export type Model = (typeof MODELS)[number];
18
+ export type Effort = (typeof EFFORTS)[number];
19
+ export type CanonicalSubcommand = 'resume' | 'continue' | 'fork';
20
+
21
+ export const SUBCOMMAND_ALIASES: Readonly<Record<string, CanonicalSubcommand>> = Object.freeze({
22
+ resume: 'resume',
23
+ res: 'resume',
24
+ continue: 'continue',
25
+ con: 'continue',
26
+ fork: 'fork',
27
+ fk: 'fork',
28
+ });
29
+
30
+ const MODEL_SET = new Set<string>(MODELS);
31
+ const EFFORT_SET = new Set<string>(EFFORTS);
32
+
33
+ export type TokenKind = 'flag' | 'model' | 'effort' | 'subcommand' | 'positional';
34
+
35
+ export function classifyToken(tok: string): TokenKind {
36
+ // Flag-shape check is first because tokens starting with `-` shouldn't
37
+ // ever match a magic word alphabet (none start with `-`), and a leading
38
+ // `-` is the cheapest possible discriminator.
39
+ if (tok.startsWith('-')) return 'flag';
40
+ if (MODEL_SET.has(tok)) return 'model';
41
+ if (EFFORT_SET.has(tok)) return 'effort';
42
+ if (tok in SUBCOMMAND_ALIASES) return 'subcommand';
43
+ return 'positional';
44
+ }
45
+
46
+ export function canonicalSubcommand(tok: string): CanonicalSubcommand | null {
47
+ return SUBCOMMAND_ALIASES[tok] ?? null;
48
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Expand magic-captured aliases into the passthrough flag list claude
3
+ * eventually receives. The parser captures `model`/`effort`/`subcommand`
4
+ * into structured fields; this module emits the corresponding
5
+ * `--model`/`--effort`/`--resume`/`--fork-session` flags and prepends
6
+ * them to the original passthrough.
7
+ *
8
+ * Order: magic flags first (so explicit user-supplied flags appear later
9
+ * and win via claude's last-occurrence semantics). Within the magic
10
+ * block: model → effort → subcommand.
11
+ *
12
+ * §4.3 (effort-without-model → opus) is already handled in the parser
13
+ * by setting `model = 'opus'` when a bare effort is captured at the
14
+ * first magic slot; this module just emits whatever the parser captured.
15
+ *
16
+ * Subcommand mapping (§4.4 / design.md §1):
17
+ * resume → --resume
18
+ * continue → --continue
19
+ * fork → --resume --fork-session
20
+ *
21
+ * Mirrors Go canonical `buildClaudeArgs` (`src/main.go` near the magic
22
+ * + subcommand merge point).
23
+ */
24
+
25
+ import type { CanonicalSubcommand } from './classify.ts';
26
+ import type { ParsedArgsOk } from './parse.ts';
27
+
28
+ const SUBCOMMAND_FLAGS: Record<CanonicalSubcommand, readonly string[]> = {
29
+ resume: ['--resume'],
30
+ continue: ['--continue'],
31
+ fork: ['--resume', '--fork-session'],
32
+ };
33
+
34
+ export function expandAliases(parsed: ParsedArgsOk): string[] {
35
+ const out: string[] = [];
36
+
37
+ if (parsed.model !== null) {
38
+ out.push('--model', parsed.model);
39
+ }
40
+ if (parsed.effort !== null) {
41
+ out.push('--effort', parsed.effort);
42
+ }
43
+ if (parsed.subcommand !== null) {
44
+ for (const f of SUBCOMMAND_FLAGS[parsed.subcommand]) out.push(f);
45
+ }
46
+
47
+ // Append the original passthrough verbatim.
48
+ for (const tok of parsed.passthrough) out.push(tok);
49
+
50
+ return out;
51
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Argv intake. Reads the user's command-line arguments, working around
3
+ * bun's argv-stripping behavior via the Node-shebang preflight indirection.
4
+ *
5
+ * When invoked under Node first, `bin/fnc.js`'s preflight stuffs the raw
6
+ * argv into `FNC_ARGS_JSON` and re-execs under Bun. By the time main.ts
7
+ * runs, `process.argv` may have lost its `--` sentinel (bun 1.3.14 still
8
+ * strips it; see `docs/decisions.md`), so we prefer the env var.
9
+ *
10
+ * Direct `bun fnc.js` invocations (no preflight) still work — there's
11
+ * just no way to recover `--` in that path.
12
+ */
13
+
14
+ const ENV_KEY = 'FNC_ARGS_JSON';
15
+
16
+ function warnStderr(msg: string): void {
17
+ try {
18
+ Bun.write(Bun.stderr, `fnc: ${msg}\n`);
19
+ } catch {
20
+ // Stderr write failure is non-fatal — the fallback still applies.
21
+ }
22
+ }
23
+
24
+ function tryParseArgsJson(raw: string): readonly string[] | null {
25
+ let parsed: unknown;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ } catch (err) {
29
+ warnStderr(`${ENV_KEY} is not valid JSON (${(err as Error).message}); falling back to process.argv`);
30
+ return null;
31
+ }
32
+ if (!Array.isArray(parsed)) {
33
+ warnStderr(`${ENV_KEY} did not parse to an array; falling back to process.argv`);
34
+ return null;
35
+ }
36
+ for (const el of parsed) {
37
+ if (typeof el !== 'string') {
38
+ warnStderr(`${ENV_KEY} contains a non-string element; falling back to process.argv`);
39
+ return null;
40
+ }
41
+ }
42
+ return parsed as readonly string[];
43
+ }
44
+
45
+ export function readArgv(): readonly string[] {
46
+ const raw = process.env[ENV_KEY];
47
+ if (raw !== undefined) {
48
+ const parsed = tryParseArgsJson(raw);
49
+ if (parsed !== null) return parsed;
50
+ }
51
+ return process.argv.slice(2);
52
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Magic-positional state machine.
3
+ *
4
+ * Walks the prefix of argv consuming model/effort/subcommand tokens per
5
+ * the rules in docs/design.md §1. Subcommands are order-independent and
6
+ * do not advance the model/effort state; model+effort move through a
7
+ * three-state machine:
8
+ *
9
+ * state 0 → next token may be model OR effort (effort implies opus,
10
+ * rewrite extension)
11
+ * state 1 → next token may be effort (model already consumed)
12
+ * state 2 → magic scanning is done
13
+ *
14
+ * Magic also ends as soon as a flag (token starting with `-`) is seen.
15
+ *
16
+ * Only one subcommand per invocation; the second is an error.
17
+ *
18
+ * The fn returns `consumed` (the count of leading tokens absorbed by the
19
+ * scan) rather than slicing — the caller still owns the original argv
20
+ * for downstream phases that need indices into it.
21
+ */
22
+
23
+ import {
24
+ canonicalSubcommand,
25
+ classifyToken,
26
+ type CanonicalSubcommand,
27
+ type Effort,
28
+ type Model,
29
+ } from './classify.ts';
30
+
31
+ export interface MagicResultOk {
32
+ ok: true;
33
+ model: Model | null;
34
+ effort: Effort | null;
35
+ subcommand: CanonicalSubcommand | null;
36
+ consumed: number;
37
+ }
38
+
39
+ export interface MagicResultErr {
40
+ ok: false;
41
+ error: string;
42
+ }
43
+
44
+ export type MagicResult = MagicResultOk | MagicResultErr;
45
+
46
+ const ERR_DUPLICATE_SUBCOMMAND = 'fnc: only one of resume/continue/fork may be used per invocation';
47
+
48
+ export function scanMagic(args: readonly string[]): MagicResult {
49
+ let model: Model | null = null;
50
+ let effort: Effort | null = null;
51
+ let subcommand: CanonicalSubcommand | null = null;
52
+ let state: 0 | 1 | 2 = 0;
53
+ let i = 0;
54
+
55
+ while (i < args.length) {
56
+ const tok = args[i]!;
57
+ const kind = classifyToken(tok);
58
+
59
+ if (kind === 'flag') break;
60
+
61
+ if (kind === 'subcommand') {
62
+ if (subcommand !== null) {
63
+ return { ok: false, error: ERR_DUPLICATE_SUBCOMMAND };
64
+ }
65
+ subcommand = canonicalSubcommand(tok);
66
+ i++;
67
+ continue;
68
+ }
69
+
70
+ if (state === 0) {
71
+ if (kind === 'model') {
72
+ model = tok as Model;
73
+ state = 1;
74
+ i++;
75
+ continue;
76
+ }
77
+ if (kind === 'effort') {
78
+ // Rewrite extension: effort-only at pos 1 implies opus.
79
+ effort = tok as Effort;
80
+ model = 'opus';
81
+ state = 2;
82
+ i++;
83
+ continue;
84
+ }
85
+ break;
86
+ }
87
+
88
+ if (state === 1) {
89
+ if (kind === 'effort') {
90
+ effort = tok as Effort;
91
+ state = 2;
92
+ i++;
93
+ continue;
94
+ }
95
+ break;
96
+ }
97
+
98
+ // state === 2 — magic is done, leave the rest to downstream phases.
99
+ break;
100
+ }
101
+
102
+ return { ok: true, model, effort, subcommand, consumed: i };
103
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Full argv parser. Consumes the user's raw argv (post-intake) and produces
3
+ * a structured Args record for the launcher and MCP pipeline.
4
+ *
5
+ * Mirrors Go canonical `parseArgs` (`fnclaude@fnrhombus/src/main.go:130–343`):
6
+ * one left-to-right walk with a sticky `inFlags` boundary. Magic positionals
7
+ * (model/effort/subcommand) consume the leading prefix; then a maximum of
8
+ * two non-flag positionals fill firstPath and worktreeArg; once the first
9
+ * flag-shape token is seen, all subsequent tokens are flag territory.
10
+ *
11
+ * fnclaude-eaten flags (NOT forwarded to claude):
12
+ * --no-tmux → noTmux = true
13
+ * -A | --also <dir> → push to extraDirs
14
+ * -A=<dir> | --also=<dir>
15
+ * -w | --worktree <name> → worktreeSet, worktreeArg
16
+ * -w=<name> | --worktree=<name>
17
+ * bare -w or --worktree → worktreeSet, worktreeArg = ''
18
+ *
19
+ * Subcommand tokens (resume/res/continue/con/fork/fk) are recognized at
20
+ * any positional slot, are order-independent with magic, and do not
21
+ * advance the magic state. At most one per invocation.
22
+ *
23
+ * Short-flag clusters (-BVC, -BVCM plan) are NOT expanded here — they
24
+ * land in passthrough verbatim and §4.5 transforms them later. Unknown
25
+ * long flags also pass through unchanged.
26
+ */
27
+
28
+ import {
29
+ canonicalSubcommand,
30
+ classifyToken,
31
+ type CanonicalSubcommand,
32
+ type Effort,
33
+ type Model,
34
+ } from './classify.ts';
35
+
36
+ export interface ParsedArgsOk {
37
+ ok: true;
38
+ model: Model | null;
39
+ effort: Effort | null;
40
+ subcommand: CanonicalSubcommand | null;
41
+ firstPath: string | null;
42
+ worktreeSet: boolean;
43
+ worktreeArg: string;
44
+ extraDirs: string[];
45
+ noTmux: boolean;
46
+ passthrough: string[];
47
+ }
48
+
49
+ export interface ParsedArgsErr {
50
+ ok: false;
51
+ error: string;
52
+ }
53
+
54
+ export type ParsedArgs = ParsedArgsOk | ParsedArgsErr;
55
+
56
+ const ERR_DUPLICATE_SUBCOMMAND = 'fnc: only one of resume/continue/fork may be used per invocation';
57
+ const ERR_TOO_MANY_POSITIONALS = (got: string): string =>
58
+ `fnc: too many positional arguments (got ${JSON.stringify(got)}; max is 2 — cwd and worktree-name)`;
59
+
60
+ export function parseArgs(args: readonly string[]): ParsedArgs {
61
+ let model: Model | null = null;
62
+ let effort: Effort | null = null;
63
+ let subcommand: CanonicalSubcommand | null = null;
64
+ let firstPath: string | null = null;
65
+ let worktreeSet = false;
66
+ let worktreeArg = '';
67
+ const extraDirs: string[] = [];
68
+ let noTmux = false;
69
+ const passthrough: string[] = [];
70
+
71
+ let magicState: 0 | 1 | 2 = 0;
72
+ let inFlags = false;
73
+
74
+ let i = 0;
75
+ while (i < args.length) {
76
+ const tok = args[i]!;
77
+
78
+ // ── Positional territory (before the first flag-shape token) ────────────
79
+ if (!inFlags && !tok.startsWith('-')) {
80
+ const kind = classifyToken(tok);
81
+
82
+ // Subcommand: any positional slot, doesn't advance magic state.
83
+ if (kind === 'subcommand') {
84
+ if (subcommand !== null) {
85
+ return { ok: false, error: ERR_DUPLICATE_SUBCOMMAND };
86
+ }
87
+ subcommand = canonicalSubcommand(tok);
88
+ i++;
89
+ continue;
90
+ }
91
+
92
+ // Magic state 0 → check model OR effort (effort implies opus).
93
+ if (magicState === 0) {
94
+ if (kind === 'model') {
95
+ model = tok as Model;
96
+ magicState = 1;
97
+ i++;
98
+ continue;
99
+ }
100
+ if (kind === 'effort') {
101
+ effort = tok as Effort;
102
+ model = 'opus';
103
+ magicState = 2;
104
+ i++;
105
+ continue;
106
+ }
107
+ // Not magic — fall through to positional slot assignment, but advance
108
+ // magicState so position 2's effort check doesn't re-fire.
109
+ magicState = 2;
110
+ } else if (magicState === 1) {
111
+ // Magic state 1 → check effort (model was matched at state 0).
112
+ if (kind === 'effort') {
113
+ effort = tok as Effort;
114
+ magicState = 2;
115
+ i++;
116
+ continue;
117
+ }
118
+ magicState = 2;
119
+ }
120
+ // magicState === 2 from here on.
121
+
122
+ // Positional slot assignment.
123
+ if (firstPath === null) {
124
+ firstPath = tok;
125
+ } else if (!worktreeSet) {
126
+ worktreeSet = true;
127
+ worktreeArg = tok;
128
+ } else {
129
+ return { ok: false, error: ERR_TOO_MANY_POSITIONALS(tok) };
130
+ }
131
+ i++;
132
+ continue;
133
+ }
134
+
135
+ // ── Flag territory (sticky) ─────────────────────────────────────────────
136
+ inFlags = true;
137
+
138
+ // fnclaude-eaten: --no-tmux
139
+ if (tok === '--no-tmux') {
140
+ noTmux = true;
141
+ i++;
142
+ continue;
143
+ }
144
+
145
+ // fnclaude-eaten: -A / --also (extra dirs)
146
+ if (tok === '-A' || tok === '--also') {
147
+ const next = args[i + 1];
148
+ if (next === undefined || next.startsWith('-')) {
149
+ const which = next === undefined ? tok : `${tok} ${next}`;
150
+ return { ok: false, error: `fnc: ${which} requires a directory argument` };
151
+ }
152
+ extraDirs.push(next);
153
+ i += 2;
154
+ continue;
155
+ }
156
+ if (tok.startsWith('-A=')) {
157
+ const val = tok.slice(3);
158
+ if (val === '') return { ok: false, error: 'fnc: -A= requires a directory argument' };
159
+ extraDirs.push(val);
160
+ i++;
161
+ continue;
162
+ }
163
+ if (tok.startsWith('--also=')) {
164
+ const val = tok.slice(7);
165
+ if (val === '') return { ok: false, error: 'fnc: --also= requires a directory argument' };
166
+ extraDirs.push(val);
167
+ i++;
168
+ continue;
169
+ }
170
+
171
+ // fnclaude-eaten: -w / --worktree (worktree flag)
172
+ if (tok === '-w' || tok === '--worktree') {
173
+ worktreeSet = true;
174
+ const next = args[i + 1];
175
+ if (next !== undefined && !next.startsWith('-')) {
176
+ worktreeArg = next;
177
+ i += 2;
178
+ } else {
179
+ i++;
180
+ }
181
+ continue;
182
+ }
183
+ if (tok.startsWith('-w=')) {
184
+ worktreeSet = true;
185
+ worktreeArg = tok.slice(3);
186
+ i++;
187
+ continue;
188
+ }
189
+ if (tok.startsWith('--worktree=')) {
190
+ worktreeSet = true;
191
+ worktreeArg = tok.slice('--worktree='.length);
192
+ i++;
193
+ continue;
194
+ }
195
+
196
+ // Everything else (including `--` and following prompt body) → passthrough
197
+ passthrough.push(tok);
198
+ i++;
199
+ }
200
+
201
+ return {
202
+ ok: true,
203
+ model,
204
+ effort,
205
+ subcommand,
206
+ firstPath,
207
+ worktreeSet,
208
+ worktreeArg,
209
+ extraDirs,
210
+ noTmux,
211
+ passthrough,
212
+ };
213
+ }