@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.
- package/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -219
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- 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
|
+
}
|