@fnclaude/cli 1.1.0 → 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 -203
  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
@@ -1,445 +0,0 @@
1
- /**
2
- * MCP internal wire protocol — newline-delimited JSON exchanged on the
3
- * parent-listener AF_UNIX socket. The `fnclaude mcp` subprocess is the
4
- * client; the parent fnclaude process is the server. Each connection
5
- * carries exactly one Request and receives exactly one Response, then
6
- * closes. No persistent state on the wire; each call stands on its own.
7
- *
8
- * Ported from src/mcp_protocol.go in the Go reference (fnclaude@fnrhombus).
9
- * Wire-format compatibility is the contract — the field names, op string
10
- * values, and Action string values MUST match Go byte-for-byte: in
11
- * production the parent listener and the `fnclaude mcp` subprocess will
12
- * still be a heterogeneous Go/TS pair during cutover.
13
- */
14
-
15
- // ── Op ─────────────────────────────────────────────────────────────────────
16
-
17
- /** Identifies the requested operation on the parent. */
18
- export type Op = 'restart' | 'switch' | 'spawn' | 'copy_to_clipboard';
19
-
20
- export const OpRestart: Op = 'restart';
21
- export const OpSwitch: Op = 'switch';
22
- export const OpSpawn: Op = 'spawn';
23
- export const OpCopy: Op = 'copy_to_clipboard';
24
-
25
- // ── Action ─────────────────────────────────────────────────────────────────
26
-
27
- /**
28
- * High-level instruction the parent returns to the MCP subprocess, which
29
- * relays it back to claude as the tool result. Each Action maps to a
30
- * distinct UX claude will perform.
31
- */
32
- export type Action =
33
- | 'done'
34
- | 'needs_confirmation' // deprecated, no longer emitted
35
- | 'auto_countdown' // deprecated, no longer emitted
36
- | 'paste_flow'
37
- | 'error';
38
-
39
- /**
40
- * The requested operation has been performed. For OpSwitch this means the
41
- * parent will kill claude and re-exec; the MCP subprocess and claude are
42
- * both about to be terminated. For OpRestart and OpCopy the parent has
43
- * completed the work.
44
- */
45
- export const ActionDone: Action = 'done';
46
-
47
- /**
48
- * Deprecated. Historical — ask mode used to return this to force a
49
- * needs_confirmation prompt before performing the switch. The constant is
50
- * retained so older test fixtures that pattern-match on the literal still
51
- * compile.
52
- */
53
- export const ActionNeedsConfirmation: Action = 'needs_confirmation';
54
-
55
- /**
56
- * Deprecated. Historical — numeric mode used to return this so claude
57
- * would print a countdown announcement, sleep, then call the tool again
58
- * with confirmed=true. The cancellation-window UX now lives in the
59
- * prompt.
60
- */
61
- export const ActionAutoCountdown: Action = 'auto_countdown';
62
-
63
- /**
64
- * The parent has prepared the relaunch Command. If ClipboardOK is true
65
- * the command is already on the user's clipboard; otherwise claude should
66
- * tell the user to copy Command manually.
67
- */
68
- export const ActionPasteFlow: Action = 'paste_flow';
69
-
70
- /**
71
- * The parent could not perform the operation. Error is the human-readable
72
- * reason; claude should surface it to the user.
73
- */
74
- export const ActionError: Action = 'error';
75
-
76
- // ── Request ────────────────────────────────────────────────────────────────
77
-
78
- /**
79
- * Shared override fields applicable to OpRestart / OpSwitch / OpSpawn.
80
- *
81
- * Empty/undef string values mean "preserve what was on the original
82
- * command line" (for restart/transfer) or "don't pass this flag" (for
83
- * spawn). For boolean fields: undefined = preserve existing; true =
84
- * ensure present; false = ensure absent.
85
- */
86
- export interface RequestOverrides {
87
- model?: string;
88
- effort?: string;
89
- permission_mode?: string;
90
- allowed_tools?: string;
91
- agent?: string;
92
- brief?: boolean | null;
93
- chrome?: boolean | null;
94
- ide?: boolean | null;
95
- verbose?: boolean | null;
96
- }
97
-
98
- /**
99
- * Discriminated union of Request variants — exactly one shape per Op.
100
- * Adding a new Op requires extending this union *and* every `switch`
101
- * over `req.op` (the dispatcher uses an exhaustive-never check so the
102
- * compiler enforces this).
103
- *
104
- * The wire shape uses snake_case keys (matching Go's `json:` tags). All
105
- * non-required fields are optional on the wire; the listener tolerates
106
- * missing keys.
107
- */
108
- export type Request =
109
- | RestartRequest
110
- | SwitchRequest
111
- | SpawnRequest
112
- | CopyRequest;
113
-
114
- /** OpRestart: restart the current session in place. */
115
- export interface RestartRequest extends RequestOverrides {
116
- op: 'restart';
117
- /** Required UUID — the current Claude session. */
118
- session_id?: string;
119
- }
120
-
121
- /** OpSwitch: kill claude and relaunch at destination. */
122
- export interface SwitchRequest extends RequestOverrides {
123
- op: 'switch';
124
- /** Destination project ref. */
125
- destination?: string;
126
- /** 3-6 word kebab-case session topic. */
127
- name?: string;
128
- /** Continuity summary content. */
129
- summary?: string;
130
- /**
131
- * Optional UUID for live-permission-mode auto-capture. Used to read the
132
- * session JSONL when no explicit permission_mode override was set.
133
- */
134
- session_id?: string;
135
- /**
136
- * Deprecated; no longer read by the server. Left on the type so older
137
- * clients that still serialize confirmed=true don't break.
138
- */
139
- confirmed?: boolean;
140
- }
141
-
142
- /** OpSpawn: launch a sibling fnclaude in a new window. */
143
- export interface SpawnRequest extends RequestOverrides {
144
- op: 'spawn';
145
- /** Destination project ref. */
146
- destination?: string;
147
- /** 3-6 word kebab-case session topic. */
148
- name?: string;
149
- /** Continuity summary content. */
150
- summary?: string;
151
- /**
152
- * Deprecated; no longer read by the server. Left on the type so older
153
- * clients that still serialize confirmed=true don't break.
154
- */
155
- confirmed?: boolean;
156
- }
157
-
158
- /** OpCopy: write text to the clipboard. Carries no override fields. */
159
- export interface CopyRequest {
160
- op: 'copy_to_clipboard';
161
- /** Text to write to the clipboard. */
162
- text?: string;
163
- }
164
-
165
- // ── Response ───────────────────────────────────────────────────────────────
166
-
167
- /** Response returned by the parent listener. */
168
- export interface Response {
169
- action: Action;
170
- /** Natural-language guidance for claude / the user. */
171
- message?: string;
172
- /** ActionPasteFlow: literal command string the user should paste-and-run. */
173
- command?: string;
174
- /** ActionPasteFlow: whether the parent succeeded in copying Command to clipboard. */
175
- clipboard_ok?: boolean;
176
- /** ActionAutoCountdown only (deprecated). */
177
- countdown_seconds?: number;
178
- /** ActionError: human-readable reason. */
179
- error?: string;
180
- }
181
-
182
- // ── Codec ──────────────────────────────────────────────────────────────────
183
-
184
- /**
185
- * Encode req as a single newline-terminated JSON line. Returned as a
186
- * Buffer; callers that want a string can `.toString()`.
187
- */
188
- export function encodeRequest(req: Request): Buffer {
189
- return Buffer.from(`${JSON.stringify(req)}\n`, 'utf8');
190
- }
191
-
192
- /** Encode resp as a single newline-terminated JSON line. */
193
- export function encodeResponse(resp: Response): Buffer {
194
- return Buffer.from(`${JSON.stringify(resp)}\n`, 'utf8');
195
- }
196
-
197
- /**
198
- * Decode one newline-terminated JSON line into a Request.
199
- *
200
- * The line may or may not include the terminating '\n'; both are accepted
201
- * (matches Go's bufio.ReadBytes which keeps the delimiter).
202
- *
203
- * Throws on malformed JSON or on payloads that fail boundary validation
204
- * (see `validateRequest` for the rules). Wire input is untrusted —
205
- * anything that knows the socket path can submit JSON, and the dispatcher
206
- * acts on it (re-exec, file writes, clipboard). Validate before handing
207
- * to the dispatcher rather than scattering checks across handlers.
208
- */
209
- export function decodeRequest(line: string | Buffer): Request {
210
- const text = typeof line === 'string' ? line : line.toString('utf8');
211
- const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
212
- const raw: unknown = JSON.parse(trimmed);
213
- return validateRequest(raw);
214
- }
215
-
216
- // ── Request validation ─────────────────────────────────────────────────────
217
-
218
- /**
219
- * Maximum byte length for each string field on a Request. Values are sized
220
- * to the field's job:
221
- * - overrides (model, effort, permission_mode, agent): short identifiers
222
- * - allowed_tools: comma-joined tool names, room for ~30 longest
223
- * - session_id: UUID-shaped, but we don't pre-validate the shape here
224
- * (that lives in the handler so the error message can mention UUID)
225
- * - destination / name: filesystem paths / kebab-case names
226
- * - text (clipboard): bounded for typical clipboard payloads
227
- * - summary: continuity blob — generous but bounded; a malicious peer
228
- * submitting hundreds of MB would otherwise wedge the writeFile path
229
- */
230
- const STRING_LIMITS: Record<string, number> = {
231
- // Shared overrides
232
- model: 64,
233
- effort: 64,
234
- permission_mode: 64,
235
- allowed_tools: 4096,
236
- agent: 256,
237
- // Restart / switch / spawn
238
- session_id: 128,
239
- destination: 4096,
240
- name: 256,
241
- summary: 1024 * 1024, // 1 MiB
242
- // Copy
243
- text: 1024 * 1024, // 1 MiB
244
- };
245
-
246
- /** Discriminated union of fields legal for each Op. */
247
- const STRING_FIELDS_BY_OP: Record<Op, readonly string[]> = {
248
- restart: ['session_id', 'model', 'effort', 'permission_mode', 'allowed_tools', 'agent'],
249
- switch: [
250
- 'destination',
251
- 'name',
252
- 'summary',
253
- 'session_id',
254
- 'model',
255
- 'effort',
256
- 'permission_mode',
257
- 'allowed_tools',
258
- 'agent',
259
- ],
260
- spawn: [
261
- 'destination',
262
- 'name',
263
- 'summary',
264
- 'model',
265
- 'effort',
266
- 'permission_mode',
267
- 'allowed_tools',
268
- 'agent',
269
- ],
270
- copy_to_clipboard: ['text'],
271
- };
272
-
273
- const BOOL_FIELDS: readonly string[] = ['brief', 'chrome', 'ide', 'verbose', 'confirmed'];
274
-
275
- /** Field names that are filesystem paths and must reject ".." traversal. */
276
- const PATH_FIELDS: readonly string[] = ['destination'];
277
-
278
- const KNOWN_OPS: readonly Op[] = ['restart', 'switch', 'spawn', 'copy_to_clipboard'];
279
-
280
- /**
281
- * Validate a parsed JSON value as a Request. Returns the validated value
282
- * (typed as Request) on success; throws Error with a human-readable
283
- * reason on failure. The caller (`decodeRequest`) is the only intended
284
- * entry point — handlers consume the typed Request and trust its shape.
285
- *
286
- * Checks performed:
287
- * 1. Value is a plain object (not array, null, or scalar).
288
- * 2. `op` is one of the four known string discriminants.
289
- * 3. Every string field on the per-op allowlist is either absent or a
290
- * string under its byte-length cap, with no null bytes.
291
- * 4. Path-shaped fields (`destination`) additionally reject `..`
292
- * segments (delimited by `/` or string ends).
293
- * 5. Boolean override fields are either absent, true, false, or null
294
- * (null is treated as "preserve existing" by handlers).
295
- *
296
- * Unknown extra keys are tolerated (forward compatibility with newer
297
- * clients) but their values are not validated.
298
- */
299
- export function validateRequest(value: unknown): Request {
300
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
301
- throw new Error('request must be a JSON object');
302
- }
303
- const obj = value as Record<string, unknown>;
304
- const op = obj.op;
305
- if (typeof op !== 'string' || !KNOWN_OPS.includes(op as Op)) {
306
- throw new Error(`unknown op ${JSON.stringify(op)}`);
307
- }
308
- const allowed = STRING_FIELDS_BY_OP[op as Op];
309
- for (const field of allowed) {
310
- const v = obj[field];
311
- if (v === undefined) continue;
312
- if (typeof v !== 'string') {
313
- throw new Error(`field ${field} must be a string`);
314
- }
315
- if (v.includes('\x00')) {
316
- throw new Error(`field ${field} contains a null byte`);
317
- }
318
- const cap = STRING_LIMITS[field] ?? 1024;
319
- if (Buffer.byteLength(v, 'utf8') > cap) {
320
- throw new Error(`field ${field} exceeds max length ${cap}`);
321
- }
322
- if (PATH_FIELDS.includes(field) && hasParentSegment(v)) {
323
- throw new Error(`field ${field} contains a path-traversal segment ("..")`);
324
- }
325
- }
326
- for (const field of BOOL_FIELDS) {
327
- const v = obj[field];
328
- if (v === undefined || v === null) continue;
329
- if (typeof v !== 'boolean') {
330
- throw new Error(`field ${field} must be a boolean`);
331
- }
332
- }
333
- return obj as unknown as Request;
334
- }
335
-
336
- /**
337
- * Returns true when the path contains a literal `..` segment — i.e. `..`
338
- * delimited by `/` (or by start/end of the string). Catches `../etc`,
339
- * `/foo/../bar`, `foo/..`, and bare `..`; ignores `foo..bar` and any
340
- * other substring where `..` is part of a larger name.
341
- */
342
- function hasParentSegment(p: string): boolean {
343
- // Split on '/' is sufficient — POSIX path semantics, and a Windows
344
- // backslash path would never be a legitimate destination on the wire.
345
- return p.split('/').includes('..');
346
- }
347
-
348
- /** Decode one newline-terminated JSON line into a Response. */
349
- export function decodeResponse(line: string | Buffer): Response {
350
- const text = typeof line === 'string' ? line : line.toString('utf8');
351
- const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
352
- return JSON.parse(trimmed) as Response;
353
- }
354
-
355
- /**
356
- * A minimal Readable surface: `on('data' | 'end' | 'error' | 'close', ...)`.
357
- * Both Node's `net.Socket` and the Readable streams from `node:stream`
358
- * satisfy this. We deliberately avoid `for await ... of` on the socket —
359
- * breaking out of that loop calls the iterator's return() which
360
- * **destroys the underlying socket**, preventing the response write.
361
- */
362
- export interface DataStream {
363
- on(event: 'data', listener: (chunk: Buffer) => void): this;
364
- on(event: 'end' | 'close', listener: () => void): this;
365
- on(event: 'error', listener: (err: Error) => void): this;
366
- off(event: 'data', listener: (chunk: Buffer) => void): this;
367
- off(event: 'end' | 'close', listener: () => void): this;
368
- off(event: 'error', listener: (err: Error) => void): this;
369
- }
370
-
371
- /**
372
- * Read one newline-terminated JSON line from a data-emitting stream (a
373
- * `net.Socket` is the common case). Returns the decoded Request or
374
- * undefined if the stream ended cleanly before any line was seen
375
- * (analogous to Go's `io.EOF` return).
376
- *
377
- * Buffers across chunk boundaries. Stops at the first '\n'; bytes past
378
- * it are silently dropped (the wire protocol is one-line-per-connection).
379
- *
380
- * Crucially, this does NOT use `for await ... of socket` — that
381
- * iterator's automatic cleanup destroys the socket on break/return,
382
- * which would prevent the caller from writing the response back. The
383
- * event-listener form leaves the socket fully writable.
384
- */
385
- export async function readRequest(stream: DataStream): Promise<Request | undefined> {
386
- const line = await readLine(stream);
387
- if (line === undefined) return undefined;
388
- return decodeRequest(line);
389
- }
390
-
391
- /** Read one newline-terminated JSON line and decode it as a Response. */
392
- export async function readResponse(stream: DataStream): Promise<Response | undefined> {
393
- const line = await readLine(stream);
394
- if (line === undefined) return undefined;
395
- return decodeResponse(line);
396
- }
397
-
398
- /** Internal — read up to and including the first '\n' via stream events. */
399
- async function readLine(stream: DataStream): Promise<string | undefined> {
400
- return new Promise<string | undefined>((resolve, reject) => {
401
- const chunks: Buffer[] = [];
402
- let total = 0;
403
- let settled = false;
404
-
405
- const cleanup = (): void => {
406
- stream.off('data', onData);
407
- stream.off('end', onEnd);
408
- stream.off('close', onEnd);
409
- stream.off('error', onError);
410
- };
411
- const settle = (value: string | undefined, err?: Error): void => {
412
- if (settled) return;
413
- settled = true;
414
- cleanup();
415
- if (err) reject(err);
416
- else resolve(value);
417
- };
418
- const onData = (raw: Buffer): void => {
419
- const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
420
- const nl = buf.indexOf(0x0a);
421
- if (nl < 0) {
422
- chunks.push(buf);
423
- total += buf.length;
424
- return;
425
- }
426
- // Slice everything up to and including the newline; drop trailing
427
- // bytes in the same chunk (one line per connection).
428
- const head = Buffer.concat([...chunks, buf.subarray(0, nl + 1)]);
429
- settle(head.toString('utf8'));
430
- };
431
- const onEnd = (): void => {
432
- if (total === 0) {
433
- settle(undefined);
434
- return;
435
- }
436
- settle(Buffer.concat(chunks).toString('utf8'));
437
- };
438
- const onError = (err: Error): void => settle(undefined, err);
439
-
440
- stream.on('data', onData);
441
- stream.on('end', onEnd);
442
- stream.on('close', onEnd);
443
- stream.on('error', onError);
444
- });
445
- }