@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
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Shared argv-preservation + override helpers.
3
+ *
4
+ * Foundation for transfer (§8.x) and silent-relaunch (§9.3) flows: both
5
+ * need to take the user's original argv, strip a destination/state-bound
6
+ * subset of flags, and optionally splice in caller-supplied overrides
7
+ * before re-execing fnc.
8
+ *
9
+ * Ports the Go canonical `preserveArgs` + `applyOverrides` from
10
+ * `fnclaude@fnrhombus/src/preserve_args.go` — same three-phase walk
11
+ * (magic → positionals → flags) and the same three-state override
12
+ * semantics (string non-empty replaces; bool undefined preserves; bool
13
+ * true/false enforces presence/absence).
14
+ *
15
+ * Pure module: no I/O, no spawn, no relaunch wiring. The §8/§9 commits
16
+ * compose this with side-effect code at the boundary.
17
+ */
18
+
19
+ import { EFFORTS, MODELS } from './classify.ts';
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Transfer denylists (consumed by §8.x — re-exported here so callers don't
23
+ // duplicate the lists)
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Flag tokens stripped when preserving args across a project transfer
28
+ * (`fnc_switch_project`). These are destination-bound or session-state-
29
+ * bound — carrying them into the new session would either be wrong
30
+ * (--add-dir is the OLD project's dir, -A the OLD extras, --mcp-config
31
+ * the OLD config, --settings the OLD settings) or actively bogus
32
+ * (--resume / --continue / --fork-session / --from-pr reference the
33
+ * OLD session id or PR; -w/--worktree is the OLD worktree name; --name
34
+ * is the OLD session name and the transfer supplies a new one).
35
+ */
36
+ export const TRANSFER_DENY_FLAGS: ReadonlySet<string> = new Set([
37
+ '-A',
38
+ '--also',
39
+ '--add-dir',
40
+ '--mcp-config',
41
+ '--settings',
42
+ '-w',
43
+ '--worktree',
44
+ '-P',
45
+ '--from-pr',
46
+ '-r',
47
+ '--resume',
48
+ '-c',
49
+ '--continue',
50
+ '-F',
51
+ '--fork-session',
52
+ '-n',
53
+ '--name',
54
+ ]);
55
+
56
+ /**
57
+ * Subset of `TRANSFER_DENY_FLAGS` that may appear in bare (no-value) form.
58
+ * For these, `preserveArgs` only consumes the following token when it
59
+ * doesn't itself look like another flag — leaving subsequent flags alone.
60
+ */
61
+ export const TRANSFER_DENY_BARE_OK: ReadonlySet<string> = new Set([
62
+ '-w',
63
+ '--worktree',
64
+ '-r',
65
+ '--resume',
66
+ '-c',
67
+ '--continue',
68
+ '-F',
69
+ '--fork-session',
70
+ '-P',
71
+ '--from-pr',
72
+ ]);
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // Magic-word membership (private — callers reach for `splitLeadingMagic`)
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ const MODEL_SET: ReadonlySet<string> = new Set(MODELS);
79
+ const EFFORT_SET: ReadonlySet<string> = new Set(EFFORTS);
80
+
81
+ function isMagicWord(tok: string): boolean {
82
+ return MODEL_SET.has(tok) || EFFORT_SET.has(tok);
83
+ }
84
+
85
+ function isFlag(tok: string): boolean {
86
+ return tok.startsWith('-');
87
+ }
88
+
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ // splitLeadingMagic
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Walks args left-to-right and returns the leading run of magic words
95
+ * (model alias / effort level — subcommands are NOT included). The first
96
+ * non-magic token ends the run. Used by transfer/restart callers that
97
+ * need to keep the user's magic prefix at the front of the relaunched
98
+ * argv without re-parsing it.
99
+ */
100
+ export function splitLeadingMagic(args: readonly string[]): {
101
+ magic: string[];
102
+ rest: string[];
103
+ } {
104
+ let i = 0;
105
+ while (i < args.length && isMagicWord(args[i]!)) {
106
+ i++;
107
+ }
108
+ return { magic: args.slice(0, i), rest: args.slice(i) };
109
+ }
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+ // preserveArgs — three-phase walk
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Returns the subset of `origArgs` to carry across an fnclaude relaunch.
117
+ *
118
+ * - Phase 1: collect leading magic words (model alias / effort level).
119
+ * - Phase 2: skip contiguous non-flag, non-magic positional tokens
120
+ * (cwd + optional worktree-name slot).
121
+ * - Phase 3: keep flag-region tokens, minus any flag listed in `deny`.
122
+ * For each denied flag, the flag token AND the immediately-following
123
+ * value token are stripped — UNLESS the flag is in `bareOK`, in which
124
+ * case the bare form is allowed and the next token is only consumed
125
+ * when it doesn't itself look like a flag. The `--flag=value` form
126
+ * is always handled as a single token.
127
+ *
128
+ * Pass an empty set for `deny` to preserve all flags.
129
+ */
130
+ export function preserveArgs(
131
+ origArgs: readonly string[],
132
+ deny: ReadonlySet<string>,
133
+ bareOK: ReadonlySet<string>,
134
+ ): string[] {
135
+ const out: string[] = [];
136
+ let i = 0;
137
+
138
+ // Phase 1 — leading magic words.
139
+ while (i < origArgs.length && isMagicWord(origArgs[i]!)) {
140
+ out.push(origArgs[i]!);
141
+ i++;
142
+ }
143
+
144
+ // Phase 2 — skip contiguous positional tokens (non-flag, non-magic).
145
+ while (i < origArgs.length && !isFlag(origArgs[i]!)) {
146
+ i++;
147
+ }
148
+
149
+ // Phase 3 — flag region with denylist applied.
150
+ while (i < origArgs.length) {
151
+ const tok = origArgs[i]!;
152
+
153
+ // Equals-form: match deny by the prefix before `=`.
154
+ const eq = tok.indexOf('=');
155
+ if (eq > 0) {
156
+ const flagPart = tok.slice(0, eq);
157
+ if (deny.has(flagPart)) {
158
+ i++;
159
+ continue;
160
+ }
161
+ }
162
+
163
+ // Bare-token deny check.
164
+ if (deny.has(tok)) {
165
+ i++;
166
+ if (i < origArgs.length) {
167
+ const next = origArgs[i]!;
168
+ if (bareOK.has(tok)) {
169
+ // bareOK: only consume next if it's not itself a flag.
170
+ if (!isFlag(next)) {
171
+ i++;
172
+ }
173
+ } else {
174
+ // Not bareOK: always consume the next token as the value.
175
+ i++;
176
+ }
177
+ }
178
+ continue;
179
+ }
180
+
181
+ out.push(tok);
182
+ i++;
183
+ }
184
+
185
+ return out;
186
+ }
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+ // applyOverrides
190
+ // ─────────────────────────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Three-state override request. String fields use empty-string-or-undefined
194
+ * = "preserve" semantics. Boolean fields use the explicit three-state form
195
+ * (undefined = preserve; true = ensure-present; false = ensure-absent).
196
+ */
197
+ export interface OverrideRequest {
198
+ model?: string;
199
+ effort?: string;
200
+ permissionMode?: string;
201
+ allowedTools?: string;
202
+ agent?: string;
203
+ brief?: boolean;
204
+ chrome?: boolean;
205
+ ide?: boolean;
206
+ verbose?: boolean;
207
+ }
208
+
209
+ /**
210
+ * Takes a preserved arg slice and replaces or appends flags according to
211
+ * `req`'s override fields. Per design.md §13:
212
+ *
213
+ * - String field set (non-empty): strip any existing occurrence of the
214
+ * corresponding flag (including the bare-magic-word form for `--model`
215
+ * and `--effort` — `opus`/`sonnet`/`haiku` and `low`/`medium`/`high`/
216
+ * `xhigh`/`max`/`auto`), then append `--flag <value>` at the end.
217
+ * - Boolean field undefined: preserve existing occurrences.
218
+ * - Boolean field true: strip existing, append `--flag`.
219
+ * - Boolean field false: strip existing, do NOT append.
220
+ *
221
+ * Overrides always emit flag form (`--model sonnet`), never the magic-
222
+ * positional form, to avoid awkward mixing.
223
+ */
224
+ export function applyOverrides(
225
+ preserved: readonly string[],
226
+ req: OverrideRequest,
227
+ ): string[] {
228
+ let out: string[] = [...preserved];
229
+
230
+ // String overrides — strip any existing form, then append flag-pair.
231
+ if (req.model !== undefined && req.model !== '') {
232
+ out = stripFlag(out, '--model');
233
+ out = stripBareMagic(out, MODEL_SET);
234
+ out.push('--model', req.model);
235
+ }
236
+ if (req.effort !== undefined && req.effort !== '') {
237
+ out = stripFlag(out, '--effort');
238
+ out = stripBareMagic(out, EFFORT_SET);
239
+ out.push('--effort', req.effort);
240
+ }
241
+ if (req.permissionMode !== undefined && req.permissionMode !== '') {
242
+ out = stripFlag(out, '--permission-mode');
243
+ out.push('--permission-mode', req.permissionMode);
244
+ }
245
+ if (req.allowedTools !== undefined && req.allowedTools !== '') {
246
+ out = stripFlag(out, '--allowedTools');
247
+ out.push('--allowedTools', req.allowedTools);
248
+ }
249
+ if (req.agent !== undefined && req.agent !== '') {
250
+ out = stripFlag(out, '--agent');
251
+ out.push('--agent', req.agent);
252
+ }
253
+
254
+ // Boolean overrides — undefined = preserve; true = strip + append; false = strip.
255
+ out = applyBoolOverride(out, '--brief', req.brief);
256
+ out = applyBoolOverride(out, '--chrome', req.chrome);
257
+ out = applyBoolOverride(out, '--ide', req.ide);
258
+ out = applyBoolOverride(out, '--verbose', req.verbose);
259
+
260
+ return out;
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────────────
264
+ // strip helpers (private)
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Remove every occurrence of `flag` (consuming the following value token
269
+ * if it's not itself a flag) and every `flag=value` token in `args`.
270
+ */
271
+ function stripFlag(args: readonly string[], flag: string): string[] {
272
+ const result: string[] = [];
273
+ const eqPrefix = `${flag}=`;
274
+ let i = 0;
275
+ while (i < args.length) {
276
+ const tok = args[i]!;
277
+ if (tok === flag) {
278
+ i++;
279
+ if (i < args.length && !isFlag(args[i]!)) {
280
+ i++;
281
+ }
282
+ continue;
283
+ }
284
+ if (tok.startsWith(eqPrefix)) {
285
+ i++;
286
+ continue;
287
+ }
288
+ result.push(tok);
289
+ i++;
290
+ }
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * Remove every bare-token occurrence of `flag` (no value consumed).
296
+ * Used for boolean flags that take no argument.
297
+ */
298
+ function stripFlagBare(args: readonly string[], flag: string): string[] {
299
+ const result: string[] = [];
300
+ for (const tok of args) {
301
+ if (tok !== flag) result.push(tok);
302
+ }
303
+ return result;
304
+ }
305
+
306
+ /**
307
+ * Remove any token whose value is in `magic`. Used so a `--model` or
308
+ * `--effort` override strips the bare magic-positional form (e.g. `opus`
309
+ * or `max`) — the resulting argv carries only the explicit flag-pair
310
+ * appended afterward.
311
+ */
312
+ function stripBareMagic(args: readonly string[], magic: ReadonlySet<string>): string[] {
313
+ const result: string[] = [];
314
+ for (const tok of args) {
315
+ if (!magic.has(tok)) result.push(tok);
316
+ }
317
+ return result;
318
+ }
319
+
320
+ /**
321
+ * Apply a tri-state bool override for a bare flag (e.g. `--ide`):
322
+ * undefined = preserve; true = strip + append once; false = strip.
323
+ */
324
+ function applyBoolOverride(
325
+ args: readonly string[],
326
+ flag: string,
327
+ b: boolean | undefined,
328
+ ): string[] {
329
+ if (b === undefined) return [...args];
330
+ const out = stripFlagBare(args, flag);
331
+ if (b) out.push(flag);
332
+ return out;
333
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `--` sentinel helpers over the parsed passthrough array.
3
+ *
4
+ * After parseArgs has done its work, the prompt body (if any) lives as
5
+ * a suffix of `passthrough` starting at the first literal `--` token.
6
+ * Pre-sentinel tokens are flags + flag values destined for claude;
7
+ * post-sentinel tokens are the user's prompt body, also passed to claude
8
+ * but as positional prompt input rather than flag content.
9
+ *
10
+ * The split matters for downstream phases:
11
+ * - the auto-name code path (§5.2) feeds promptBody() to the naming LLM
12
+ * - the prompt-fragment splicer (§5.5) inserts --append-system-prompt
13
+ * BEFORE the sentinel so claude doesn't treat the flag-pair as more
14
+ * prompt content (regression class: PR #117 in the Go-port era)
15
+ *
16
+ * Only the FIRST `--` is the sentinel; any subsequent `--` is prompt
17
+ * content. This matches conventional Unix arg-parsing semantics.
18
+ */
19
+
20
+ const SENTINEL = '--';
21
+
22
+ export function findPromptSentinel(passthrough: readonly string[]): number {
23
+ return passthrough.indexOf(SENTINEL);
24
+ }
25
+
26
+ export function hasPromptBody(passthrough: readonly string[]): boolean {
27
+ const idx = findPromptSentinel(passthrough);
28
+ return idx >= 0 && idx < passthrough.length - 1;
29
+ }
30
+
31
+ export function promptBody(passthrough: readonly string[]): string[] {
32
+ const idx = findPromptSentinel(passthrough);
33
+ if (idx < 0) return [];
34
+ return passthrough.slice(idx + 1);
35
+ }
36
+
37
+ export function preSentinelArgs(passthrough: readonly string[]): string[] {
38
+ const idx = findPromptSentinel(passthrough);
39
+ if (idx < 0) return [...passthrough];
40
+ return passthrough.slice(0, idx);
41
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Capital-letter short-flag → long-flag translation.
3
+ *
4
+ * Mirrors Go canonical `src/main.go:350-423` (parseShortFlag and cluster
5
+ * walking). Operates on the passthrough token stream after parseArgs.
6
+ *
7
+ * Three tables govern translation. Lowercase short flags (and any
8
+ * capital not in the tables) pass through verbatim — claude handles
9
+ * those itself.
10
+ *
11
+ * shortNoValue: B C D F I V — toggle flags, no value
12
+ * shortRequired: G M W — must take a value
13
+ * shortOptional: P R T — may take a value if not flag-shaped
14
+ *
15
+ * Cluster mechanics:
16
+ * - Each char walks independently.
17
+ * - shortRequired NOT at last position → ERROR (can't absorb value mid-cluster).
18
+ * - shortRequired at last position consumes next argv token; ERROR if next
19
+ * starts with `-` or there's no next token.
20
+ * - shortOptional at last position consumes next token if it does NOT
21
+ * start with `-`; otherwise emits the long flag with no value.
22
+ * - shortOptional NOT at last position emits the long flag with no value
23
+ * (no token to consume; let walking continue).
24
+ * - `-X=val` single-token form (only single-char clusters) → `--long=val`.
25
+ *
26
+ * Sentinel: anything after `--` passes through verbatim — that's prompt
27
+ * body / claude-handled flags / etc., not for us to touch.
28
+ */
29
+
30
+ const SHORT_NO_VALUE: Record<string, string> = {
31
+ B: '--brief',
32
+ C: '--chrome',
33
+ D: '--dangerously-skip-permissions',
34
+ F: '--fork-session',
35
+ I: '--ide',
36
+ V: '--verbose',
37
+ };
38
+
39
+ const SHORT_REQUIRED: Record<string, string> = {
40
+ G: '--agent',
41
+ M: '--permission-mode',
42
+ W: '--allowedTools',
43
+ };
44
+
45
+ const SHORT_OPTIONAL: Record<string, string> = {
46
+ P: '--from-pr',
47
+ R: '--remote-control',
48
+ T: '--tmux',
49
+ };
50
+
51
+ export type ExpandShortFlagsResult =
52
+ | { ok: true; tokens: string[] }
53
+ | { ok: false; error: string };
54
+
55
+ export function expandShortFlags(tokens: readonly string[]): ExpandShortFlagsResult {
56
+ const out: string[] = [];
57
+ let i = 0;
58
+ let pastSentinel = false;
59
+
60
+ while (i < tokens.length) {
61
+ const tok = tokens[i]!;
62
+
63
+ if (pastSentinel) {
64
+ out.push(tok);
65
+ i++;
66
+ continue;
67
+ }
68
+ if (tok === '--') {
69
+ pastSentinel = true;
70
+ out.push(tok);
71
+ i++;
72
+ continue;
73
+ }
74
+
75
+ // Anything not starting with `-`, or just `-`, or `--long…` passes through.
76
+ if (!tok.startsWith('-') || tok === '-' || tok.startsWith('--')) {
77
+ out.push(tok);
78
+ i++;
79
+ continue;
80
+ }
81
+
82
+ // Short cluster.
83
+ const body = tok.slice(1);
84
+
85
+ // -X=val single-char form (only for single-char clusters, per Go spec).
86
+ const eqIdx = body.indexOf('=');
87
+ if (eqIdx === 1) {
88
+ const ch = body[0]!;
89
+ const val = body.slice(2);
90
+ const long = SHORT_NO_VALUE[ch] ?? SHORT_REQUIRED[ch] ?? SHORT_OPTIONAL[ch];
91
+ if (long !== undefined) {
92
+ out.push(`${long}=${val}`);
93
+ i++;
94
+ continue;
95
+ }
96
+ // Unknown short with `=` → pass through verbatim.
97
+ out.push(tok);
98
+ i++;
99
+ continue;
100
+ }
101
+
102
+ // Walk each cluster char.
103
+ let advanceConsumedNext = false;
104
+ let errored: string | null = null;
105
+
106
+ for (let j = 0; j < body.length; j++) {
107
+ const ch = body[j]!;
108
+ const isLast = j === body.length - 1;
109
+
110
+ if (SHORT_NO_VALUE[ch] !== undefined) {
111
+ out.push(SHORT_NO_VALUE[ch]!);
112
+ continue;
113
+ }
114
+ if (SHORT_REQUIRED[ch] !== undefined) {
115
+ if (!isLast) {
116
+ errored = `fnclaude: flag -${ch} cannot be in middle of collapsed group, requires a value`;
117
+ break;
118
+ }
119
+ const next = tokens[i + 1];
120
+ if (next === undefined || next.startsWith('-')) {
121
+ errored = `fnclaude: -${ch} requires a value`;
122
+ break;
123
+ }
124
+ out.push(SHORT_REQUIRED[ch]!, next);
125
+ advanceConsumedNext = true;
126
+ continue;
127
+ }
128
+ if (SHORT_OPTIONAL[ch] !== undefined) {
129
+ if (isLast) {
130
+ const next = tokens[i + 1];
131
+ if (next !== undefined && !next.startsWith('-')) {
132
+ out.push(SHORT_OPTIONAL[ch]!, next);
133
+ advanceConsumedNext = true;
134
+ continue;
135
+ }
136
+ }
137
+ out.push(SHORT_OPTIONAL[ch]!);
138
+ continue;
139
+ }
140
+ // Unknown short — pass through verbatim as `-<char>`.
141
+ out.push(`-${ch}`);
142
+ }
143
+
144
+ if (errored !== null) {
145
+ return { ok: false, error: errored };
146
+ }
147
+
148
+ i += advanceConsumedNext ? 2 : 1;
149
+ }
150
+
151
+ return { ok: true, tokens: out };
152
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Load fnclaude's config.toml.
3
+ *
4
+ * The full config (per prd.launcher.md "Config file") looks like:
5
+ *
6
+ * [name]
7
+ * model = "claude-haiku-4-5"
8
+ * timeout = "3s"
9
+ *
10
+ * [auto]
11
+ * tmux = "never" # or "worktree"
12
+ * handoff = "ask"
13
+ * spawn_command = ""
14
+ *
15
+ * [exec.env]
16
+ * MY_VAR = "value"
17
+ *
18
+ * Only fields fnclaude actively uses are surfaced on FnConfig today.
19
+ * Others land as they're wired into the launch pipeline.
20
+ *
21
+ * Robustness: missing file / non-file at path / malformed TOML all
22
+ * degrade silently to defaults (all-undefined). Caller checks each
23
+ * field for undefined.
24
+ *
25
+ * Bun supports `import(path, { with: { type: 'toml' } })` natively, so
26
+ * no third-party TOML parser dependency.
27
+ */
28
+
29
+ import { statSync } from 'node:fs';
30
+
31
+ export interface FnConfig {
32
+ autoTmux: string | undefined;
33
+ autoHandoff: string | undefined;
34
+ /**
35
+ * `[auto] spawn_command`. Whitespace-tokenized launcher template
36
+ * consumed by §8.3 (fnc_spawn_session). Supported placeholders:
37
+ * `{bin}`, `{dest}`, `{name}`, `{summary}`. Empty/undefined means
38
+ * "fall back to $TMUX auto-detect, then paste-flow".
39
+ */
40
+ autoSpawnCommand: string | undefined;
41
+ execEnv: Record<string, string> | undefined;
42
+ }
43
+
44
+ export interface LoadConfigArgs {
45
+ path: string;
46
+ }
47
+
48
+ const EMPTY: FnConfig = {
49
+ autoTmux: undefined,
50
+ autoHandoff: undefined,
51
+ autoSpawnCommand: undefined,
52
+ execEnv: undefined,
53
+ };
54
+
55
+ export async function loadConfig(args: LoadConfigArgs): Promise<FnConfig> {
56
+ let isFile = false;
57
+ try {
58
+ isFile = statSync(args.path).isFile();
59
+ } catch {
60
+ return EMPTY;
61
+ }
62
+ if (!isFile) return EMPTY;
63
+
64
+ let parsed: unknown;
65
+ try {
66
+ const mod = await import(args.path, { with: { type: 'toml' } });
67
+ parsed = (mod as { default?: unknown }).default;
68
+ } catch {
69
+ return EMPTY;
70
+ }
71
+
72
+ if (parsed === null || typeof parsed !== 'object') return EMPTY;
73
+ const root = parsed as Record<string, unknown>;
74
+
75
+ return {
76
+ autoTmux: pickAutoTmux(root),
77
+ autoHandoff: pickAutoHandoff(root),
78
+ autoSpawnCommand: pickAutoSpawnCommand(root),
79
+ execEnv: pickExecEnv(root),
80
+ };
81
+ }
82
+
83
+ function pickAutoTmux(root: Record<string, unknown>): string | undefined {
84
+ const auto = root.auto;
85
+ if (auto === null || typeof auto !== 'object' || Array.isArray(auto)) return undefined;
86
+ const v = (auto as Record<string, unknown>).tmux;
87
+ return typeof v === 'string' ? v : undefined;
88
+ }
89
+
90
+ function pickAutoHandoff(root: Record<string, unknown>): string | undefined {
91
+ const auto = root.auto;
92
+ if (auto === null || typeof auto !== 'object' || Array.isArray(auto)) return undefined;
93
+ const v = (auto as Record<string, unknown>).handoff;
94
+ if (typeof v === 'string') return v;
95
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
96
+ return undefined;
97
+ }
98
+
99
+ function pickAutoSpawnCommand(root: Record<string, unknown>): string | undefined {
100
+ const auto = root.auto;
101
+ if (auto === null || typeof auto !== 'object' || Array.isArray(auto)) return undefined;
102
+ const v = (auto as Record<string, unknown>).spawn_command;
103
+ return typeof v === 'string' ? v : undefined;
104
+ }
105
+
106
+ function pickExecEnv(root: Record<string, unknown>): Record<string, string> | undefined {
107
+ const exec = root.exec;
108
+ if (exec === null || typeof exec !== 'object' || Array.isArray(exec)) return undefined;
109
+ const env = (exec as Record<string, unknown>).env;
110
+ if (env === null || typeof env !== 'object' || Array.isArray(env)) return undefined;
111
+ const out: Record<string, string> = {};
112
+ for (const [k, v] of Object.entries(env as Record<string, unknown>)) {
113
+ if (typeof v === 'string') out[k] = v;
114
+ }
115
+ return out;
116
+ }