@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.
- 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 -203
- 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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.3 — `fnc_spawn_session` handler.
|
|
3
|
+
*
|
|
4
|
+
* Spec (design.mcp.md §4.3 + spawn.go + socket_listener.go.handleSpawn):
|
|
5
|
+
*
|
|
6
|
+
* - Required args: `destination`, `name`, `summary`. **No `session_id`** —
|
|
7
|
+
* spawn starts fresh, with no preservation of flags or session state.
|
|
8
|
+
* - Optional args: override fields (model / effort / permission_mode /
|
|
9
|
+
* allowed_tools / agent / brief / chrome / ide / verbose).
|
|
10
|
+
*
|
|
11
|
+
* Algorithm:
|
|
12
|
+
*
|
|
13
|
+
* 1. Validate required args. Missing/empty → ActionError.
|
|
14
|
+
* 2. Write `summary` to a unique 0600 file under XDG_RUNTIME_DIR.
|
|
15
|
+
* 3. Build override args from `applyOverrides([], req)`. No preservation
|
|
16
|
+
* → only override-derived flag tokens appear. Result feeds the spawn
|
|
17
|
+
* template's surrounding context AND the paste-flow command string.
|
|
18
|
+
* 4. Build the cleaned env (`cleanEnvForSpawn` strips FNC_SOCKET,
|
|
19
|
+
* FNCLAUDE_HANDOFF, CLAUDE_CODE_SESSION_ID).
|
|
20
|
+
* 5. Decide launcher: `auto.spawnCommand` → `$TMUX` → paste-flow.
|
|
21
|
+
* 6. On launcher success → ActionDone. On no-launcher → ActionPasteFlow
|
|
22
|
+
* + clipboard write of the rendered relaunch command.
|
|
23
|
+
*
|
|
24
|
+
* Unlike §8.1 (restart) and §8.2 (switch), spawn NEVER stashes argv or
|
|
25
|
+
* fires the handoff trigger — the current session keeps running. The
|
|
26
|
+
* spawned sibling is its own independent fnclaude (own socket, own MCP
|
|
27
|
+
* env, own claude). design.mcp.md §4.3.
|
|
28
|
+
*
|
|
29
|
+
* Wire-protocol contract: returns a `WireResponse`. The handler never
|
|
30
|
+
* throws — internal failures (launcher errors, file-write errors,
|
|
31
|
+
* missing args) flow back as `{ action: 'error', error }`.
|
|
32
|
+
*
|
|
33
|
+
* Dependencies are injected via `createSpawnHandler` so tests can
|
|
34
|
+
* exercise the algorithm without touching real launchers, real
|
|
35
|
+
* clipboards, or real summary files. Production wiring in main.ts
|
|
36
|
+
* spreads the deps from the parent's runtime context.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
applyOverrides,
|
|
41
|
+
type OverrideRequest,
|
|
42
|
+
} from '../../argv/preserve-args.ts';
|
|
43
|
+
import { cleanEnvForSpawn } from '../../handoff/clean-env.ts';
|
|
44
|
+
import {
|
|
45
|
+
chooseAndSpawn,
|
|
46
|
+
defaultSpawn,
|
|
47
|
+
renderSpawnCommand,
|
|
48
|
+
type SpawnFn,
|
|
49
|
+
} from '../../handoff/spawn-launcher.ts';
|
|
50
|
+
import { writeSummaryFile } from '../../handoff/summary-file.ts';
|
|
51
|
+
import type { ParentDispatchHandler } from '../parent-dispatch.ts';
|
|
52
|
+
import type { WireRequest, WireResponse } from '../wire.ts';
|
|
53
|
+
|
|
54
|
+
export interface SpawnHandlerConfig {
|
|
55
|
+
/** `cfg.auto.spawnCommand` — undefined or empty = "not configured". */
|
|
56
|
+
autoSpawnCommand?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SpawnHandlerDeps {
|
|
60
|
+
config: SpawnHandlerConfig;
|
|
61
|
+
/** Env source (test seam). Production passes `process.env`. */
|
|
62
|
+
processEnv: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
63
|
+
/** Absolute fnclaude bin path for `{bin}` substitution. */
|
|
64
|
+
fncBinPath: string;
|
|
65
|
+
/**
|
|
66
|
+
* Override the launcher spawner (test seam). Production omits this and
|
|
67
|
+
* gets `defaultSpawn` (a thin `Bun.spawn` adapter).
|
|
68
|
+
*/
|
|
69
|
+
spawnLauncher?: SpawnFn;
|
|
70
|
+
/**
|
|
71
|
+
* Handler used for paste-flow fallback to put the rendered relaunch
|
|
72
|
+
* command on the clipboard. Same signature as §8.4's `handleCopyToClipboard`.
|
|
73
|
+
* Production wires §8.4's handler here; tests inject a stub.
|
|
74
|
+
*/
|
|
75
|
+
handleCopyToClipboard?: (req: WireRequest) => Promise<WireResponse>;
|
|
76
|
+
/**
|
|
77
|
+
* Override the summary-file writer (test seam). Production omits this
|
|
78
|
+
* and gets the real `writeSummaryFile`.
|
|
79
|
+
*/
|
|
80
|
+
writeSummaryFile?: (args: { content: string }) => Promise<string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the spawn dispatch handler with injected deps. Returns a
|
|
85
|
+
* `ParentDispatchHandler` (the shape `createParentDispatcher` expects).
|
|
86
|
+
*/
|
|
87
|
+
export function createSpawnHandler(deps: SpawnHandlerDeps): ParentDispatchHandler {
|
|
88
|
+
const spawnFn = deps.spawnLauncher ?? defaultSpawn;
|
|
89
|
+
const copyHandler = deps.handleCopyToClipboard;
|
|
90
|
+
const writeSummary =
|
|
91
|
+
deps.writeSummaryFile ?? ((args: { content: string }) => writeSummaryFile({ content: args.content }));
|
|
92
|
+
|
|
93
|
+
return async (req: WireRequest): Promise<WireResponse> => {
|
|
94
|
+
// 1. Validate required args.
|
|
95
|
+
const dest = typeof req.destination === 'string' ? req.destination : '';
|
|
96
|
+
const name = typeof req.name === 'string' ? req.name : '';
|
|
97
|
+
const summary = typeof req.summary === 'string' ? req.summary : '';
|
|
98
|
+
|
|
99
|
+
if (dest === '') {
|
|
100
|
+
return errorResponse('spawn requires a destination');
|
|
101
|
+
}
|
|
102
|
+
if (name === '') {
|
|
103
|
+
return errorResponse('spawn requires a name');
|
|
104
|
+
}
|
|
105
|
+
if (summary === '') {
|
|
106
|
+
return errorResponse('spawn requires a summary');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. Persist the summary to disk.
|
|
110
|
+
let summaryPath: string;
|
|
111
|
+
try {
|
|
112
|
+
summaryPath = await writeSummary({ content: summary });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return errorResponse(`write summary: ${(err as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Build the override flag tokens. No preservation — spawn is a
|
|
118
|
+
// fresh start, so the input slice is empty and `applyOverrides`
|
|
119
|
+
// emits ONLY override-derived flags.
|
|
120
|
+
const extraArgs = applyOverrides([], extractOverrides(req));
|
|
121
|
+
|
|
122
|
+
// 4. Build the cleaned spawn env.
|
|
123
|
+
const spawnEnv = cleanEnvForSpawn(deps.processEnv);
|
|
124
|
+
|
|
125
|
+
// 5. Pick launcher and dispatch.
|
|
126
|
+
let result: ReturnType<typeof chooseAndSpawn>;
|
|
127
|
+
try {
|
|
128
|
+
result = chooseAndSpawn({
|
|
129
|
+
autoSpawnCommand: deps.config.autoSpawnCommand ?? '',
|
|
130
|
+
env: deps.processEnv,
|
|
131
|
+
spawnEnv,
|
|
132
|
+
fncBin: deps.fncBinPath,
|
|
133
|
+
dest,
|
|
134
|
+
name,
|
|
135
|
+
summary: summaryPath,
|
|
136
|
+
extraArgs,
|
|
137
|
+
spawn: spawnFn,
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return errorResponse(`spawn: ${(err as Error).message}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (result.ok) {
|
|
144
|
+
return {
|
|
145
|
+
action: 'done',
|
|
146
|
+
message: `Spawned sibling fnclaude for ${dest} in a new window.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 6. No launcher resolved — paste-flow fallback. Surface the
|
|
151
|
+
// auto.spawnCommand config knob so users in unrecognized terminals
|
|
152
|
+
// discover the customization point without reading source.
|
|
153
|
+
const command = renderSpawnCommand({
|
|
154
|
+
dest,
|
|
155
|
+
name,
|
|
156
|
+
summary: summaryPath,
|
|
157
|
+
extraArgs,
|
|
158
|
+
});
|
|
159
|
+
const clipboardOk = await tryCopyToClipboard(copyHandler, command);
|
|
160
|
+
const message = clipboardOk
|
|
161
|
+
? 'No spawn launcher configured for this terminal — the relaunch command is on your clipboard; paste it into a new terminal window. Set `auto.spawnCommand` in ~/.config/fnclaude/config.toml to enable auto-spawn (use {bin}, {dest}, {name}, {summary} placeholders).'
|
|
162
|
+
: 'No spawn launcher configured for this terminal — copy this command and run it in a new terminal window. Set `auto.spawnCommand` in ~/.config/fnclaude/config.toml to enable auto-spawn (use {bin}, {dest}, {name}, {summary} placeholders):';
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
action: 'paste_flow',
|
|
166
|
+
message,
|
|
167
|
+
command,
|
|
168
|
+
clipboard_ok: clipboardOk,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function errorResponse(error: string): WireResponse {
|
|
174
|
+
return { action: 'error', error };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pull the override fields off the request envelope and shape them as
|
|
179
|
+
* an `OverrideRequest` for `applyOverrides`. Non-string / non-bool
|
|
180
|
+
* values are ignored — the wire is loose by design (§7.6) and any
|
|
181
|
+
* caller writing the wrong type per field gets the "preserve" branch.
|
|
182
|
+
*/
|
|
183
|
+
function extractOverrides(req: WireRequest): OverrideRequest {
|
|
184
|
+
const out: OverrideRequest = {};
|
|
185
|
+
if (typeof req.model === 'string' && req.model !== '') out.model = req.model;
|
|
186
|
+
if (typeof req.effort === 'string' && req.effort !== '') out.effort = req.effort;
|
|
187
|
+
if (typeof req.permission_mode === 'string' && req.permission_mode !== '') {
|
|
188
|
+
out.permissionMode = req.permission_mode;
|
|
189
|
+
}
|
|
190
|
+
if (typeof req.allowed_tools === 'string' && req.allowed_tools !== '') {
|
|
191
|
+
out.allowedTools = req.allowed_tools;
|
|
192
|
+
}
|
|
193
|
+
if (typeof req.agent === 'string' && req.agent !== '') out.agent = req.agent;
|
|
194
|
+
if (typeof req.brief === 'boolean') out.brief = req.brief;
|
|
195
|
+
if (typeof req.chrome === 'boolean') out.chrome = req.chrome;
|
|
196
|
+
if (typeof req.ide === 'boolean') out.ide = req.ide;
|
|
197
|
+
if (typeof req.verbose === 'boolean') out.verbose = req.verbose;
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Best-effort clipboard write for paste-flow Responses. Routes the
|
|
203
|
+
* command string through the injected §8.4 handler. Any failure
|
|
204
|
+
* (no handler injected, handler returns non-`done`, handler throws)
|
|
205
|
+
* collapses to `false` — the response still surfaces, claude just
|
|
206
|
+
* tells the user to copy manually.
|
|
207
|
+
*/
|
|
208
|
+
async function tryCopyToClipboard(
|
|
209
|
+
copyHandler: ((req: WireRequest) => Promise<WireResponse>) | undefined,
|
|
210
|
+
text: string,
|
|
211
|
+
): Promise<boolean> {
|
|
212
|
+
if (copyHandler === undefined) return false;
|
|
213
|
+
try {
|
|
214
|
+
const r = await copyHandler({ op: 'copy_to_clipboard', text });
|
|
215
|
+
return r.clipboard_ok === true;
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.2 — `fnc_switch_project` handler.
|
|
3
|
+
*
|
|
4
|
+
* Spec (docs/design.mcp.md §4.2):
|
|
5
|
+
* - Required args: `destination`, `name`, `summary`.
|
|
6
|
+
* - Optional: override fields (`model`, `effort`, `permission_mode`,
|
|
7
|
+
* `allowed_tools`, `agent`, `brief`, `chrome`, `ide`, `verbose`) and
|
|
8
|
+
* `session_id` (for live permission-mode capture).
|
|
9
|
+
*
|
|
10
|
+
* Algorithm:
|
|
11
|
+
* 1. Validate the three required strings; missing one → `action: 'error'`.
|
|
12
|
+
* 2. Write `summary` to `<base>/fnclaude-handoff-content-<16hex>.md`
|
|
13
|
+
* (mode 0600) via `writeSummaryFile`.
|
|
14
|
+
* 3. If `permission_mode === 'never'`: build the relaunch command,
|
|
15
|
+
* copy it to the clipboard, return `action: 'paste_flow'`. The
|
|
16
|
+
* current session keeps running.
|
|
17
|
+
* 4. Otherwise:
|
|
18
|
+
* - `preserved = preserveArgs(origArgs, TRANSFER_DENY_FLAGS, TRANSFER_DENY_BARE_OK)`
|
|
19
|
+
* - `withOverrides = applyOverrides(preserved, req)`
|
|
20
|
+
* - If no permission_mode override AND no preserved permission-mode
|
|
21
|
+
* AND `session_id` is present → live-capture from session JSONL
|
|
22
|
+
* and append `--permission-mode <live>`.
|
|
23
|
+
* - `{magic, rest} = splitLeadingMagic(withOverrides)`
|
|
24
|
+
* - argv = `[...magic, destination, ...rest, '--name', name, '@' + summaryPath]`
|
|
25
|
+
* - `trigger.stashArgv(argv)` + `trigger.fire()`.
|
|
26
|
+
* - Return `action: 'done'`.
|
|
27
|
+
*
|
|
28
|
+
* Side effects (clipboard exec, file write, trigger fire) all flow
|
|
29
|
+
* through injected dependencies so unit tests stay hermetic. Production
|
|
30
|
+
* callers in main.ts wire the real `handoffTrigger` singleton and the
|
|
31
|
+
* real clipboard / summary-file modules.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
applyOverrides,
|
|
36
|
+
preserveArgs,
|
|
37
|
+
splitLeadingMagic,
|
|
38
|
+
TRANSFER_DENY_BARE_OK,
|
|
39
|
+
TRANSFER_DENY_FLAGS,
|
|
40
|
+
type OverrideRequest,
|
|
41
|
+
} from '../../argv/preserve-args.ts';
|
|
42
|
+
import { writeSummaryFile, type BaseDirResolver } from '../../handoff/summary-file.ts';
|
|
43
|
+
import type { HandoffTrigger } from '../../handoff/trigger.ts';
|
|
44
|
+
import type { ParentDispatchHandler } from '../parent-dispatch.ts';
|
|
45
|
+
import type { WireRequest, WireResponse } from '../wire.ts';
|
|
46
|
+
import { handleCopyToClipboard } from './clipboard.ts';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the most recent live permission-mode value from claude's
|
|
50
|
+
* per-session JSONL. Returns null when no record exists or the file
|
|
51
|
+
* isn't reachable. The default factory returns null (live capture is a
|
|
52
|
+
* Wave-2 §8.1/§8.2 add; the §8.2 commit ships the wiring, the JSONL
|
|
53
|
+
* reader lands separately).
|
|
54
|
+
*/
|
|
55
|
+
export type LivePermissionModeReader = (sessionId: string) => string | null;
|
|
56
|
+
|
|
57
|
+
const NULL_LIVE_PERMISSION_READER: LivePermissionModeReader = () => null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write-summary seam — defaults to {@link writeSummaryFile}. Tests pass
|
|
61
|
+
* a stub that records the call and returns a deterministic path.
|
|
62
|
+
*/
|
|
63
|
+
export type WriteSummaryFn = (args: {
|
|
64
|
+
summary: string;
|
|
65
|
+
baseDir?: BaseDirResolver;
|
|
66
|
+
}) => Promise<{ path: string }>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clipboard seam — defaults to {@link handleCopyToClipboard}. Same
|
|
70
|
+
* signature as the §8.4 handler so production wiring is a direct
|
|
71
|
+
* reference, no adapter needed.
|
|
72
|
+
*/
|
|
73
|
+
export type CopyToClipboardFn = (req: WireRequest) => Promise<WireResponse>;
|
|
74
|
+
|
|
75
|
+
export interface CreateSwitchHandlerArgs {
|
|
76
|
+
/**
|
|
77
|
+
* The os.argv[1:] snapshot captured at fnclaude startup. Used to
|
|
78
|
+
* preserve user-supplied flags across the relaunch (minus the
|
|
79
|
+
* transfer denylist).
|
|
80
|
+
*/
|
|
81
|
+
origArgs: readonly string[];
|
|
82
|
+
/** Shared handoff trigger — receives `stashArgv` + `fire` on success. */
|
|
83
|
+
trigger: HandoffTrigger;
|
|
84
|
+
/**
|
|
85
|
+
* Optional live permission-mode reader (defaults to a null stub).
|
|
86
|
+
* Production wiring passes the session-JSONL reader from §8.1/§8.2.
|
|
87
|
+
*/
|
|
88
|
+
livePermissionModeReader?: LivePermissionModeReader;
|
|
89
|
+
/** Optional summary-file write seam (defaults to `writeSummaryFile`). */
|
|
90
|
+
writeSummary?: WriteSummaryFn;
|
|
91
|
+
/** Optional clipboard handler seam (defaults to `handleCopyToClipboard`). */
|
|
92
|
+
handleCopyToClipboard?: CopyToClipboardFn;
|
|
93
|
+
/** Optional base-dir resolver — forwarded to the summary-file writer. */
|
|
94
|
+
baseDirResolver?: BaseDirResolver;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a `ParentDispatchHandler` bound to the parent's startup state.
|
|
99
|
+
* The returned function is what main.ts plugs into
|
|
100
|
+
* `createParentDispatcher`'s `handlers.switch` slot.
|
|
101
|
+
*/
|
|
102
|
+
export function createSwitchHandler(args: CreateSwitchHandlerArgs): ParentDispatchHandler {
|
|
103
|
+
const liveReader = args.livePermissionModeReader ?? NULL_LIVE_PERMISSION_READER;
|
|
104
|
+
const writeSummary = args.writeSummary ?? writeSummaryFile;
|
|
105
|
+
const copyToClipboard = args.handleCopyToClipboard ?? handleCopyToClipboard;
|
|
106
|
+
const baseDir = args.baseDirResolver;
|
|
107
|
+
|
|
108
|
+
return async (req: WireRequest): Promise<WireResponse> => {
|
|
109
|
+
const destination = stringField(req, 'destination');
|
|
110
|
+
const name = stringField(req, 'name');
|
|
111
|
+
const summary = stringField(req, 'summary');
|
|
112
|
+
|
|
113
|
+
if (destination === '' || name === '' || summary === '') {
|
|
114
|
+
const missing: string[] = [];
|
|
115
|
+
if (destination === '') missing.push('destination');
|
|
116
|
+
if (name === '') missing.push('name');
|
|
117
|
+
if (summary === '') missing.push('summary');
|
|
118
|
+
return {
|
|
119
|
+
action: 'error',
|
|
120
|
+
error: `switch requires ${missing.join(', ')}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let summaryPath: string;
|
|
125
|
+
try {
|
|
126
|
+
const r = await writeSummary({ summary, baseDir });
|
|
127
|
+
summaryPath = r.path;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return {
|
|
130
|
+
action: 'error',
|
|
131
|
+
error: `write summary: ${(err as Error).message}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const overrides = overrideRequestFrom(req);
|
|
136
|
+
const sessionId = stringField(req, 'session_id');
|
|
137
|
+
|
|
138
|
+
// Never-mode paste-flow branch. Render the command, copy it, return
|
|
139
|
+
// action='paste_flow'. The current session keeps running — no
|
|
140
|
+
// stashArgv, no fire.
|
|
141
|
+
//
|
|
142
|
+
// 'never' is a control signal, not a real permission-mode value:
|
|
143
|
+
// strip it from the overrides before rendering so the relaunch
|
|
144
|
+
// command the user pastes is a valid invocation. Go canonical
|
|
145
|
+
// (which branches on cfg.Auto.Handoff="never" instead) gets the
|
|
146
|
+
// same result by not having a `--permission-mode never` token to
|
|
147
|
+
// begin with.
|
|
148
|
+
if (overrides.permissionMode === 'never') {
|
|
149
|
+
const pasteOverrides: OverrideRequest = { ...overrides, permissionMode: undefined };
|
|
150
|
+
const preserved = preserveArgs(args.origArgs, TRANSFER_DENY_FLAGS, TRANSFER_DENY_BARE_OK);
|
|
151
|
+
const withOverrides = applyOverrides(preserved, pasteOverrides);
|
|
152
|
+
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
153
|
+
const command = renderSwitchCommand(magic, destination, rest, name, summaryPath);
|
|
154
|
+
const clipResp = await copyToClipboard({
|
|
155
|
+
op: 'copy_to_clipboard',
|
|
156
|
+
text: command,
|
|
157
|
+
});
|
|
158
|
+
const clipboardOk = clipResp.clipboard_ok === true;
|
|
159
|
+
const message = clipboardOk
|
|
160
|
+
? "I've prepared the handoff command (already on your clipboard)."
|
|
161
|
+
: 'Copy this command and run it:';
|
|
162
|
+
return {
|
|
163
|
+
action: 'paste_flow',
|
|
164
|
+
message,
|
|
165
|
+
command,
|
|
166
|
+
clipboard_ok: clipboardOk,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Normal switch branch. Preserve user flags from startup (minus
|
|
171
|
+
// the transfer denylist), apply MCP overrides, auto-capture the
|
|
172
|
+
// live permission mode when neither side supplied one, then split
|
|
173
|
+
// magic from rest and build the relaunch argv.
|
|
174
|
+
const preserved = preserveArgs(args.origArgs, TRANSFER_DENY_FLAGS, TRANSFER_DENY_BARE_OK);
|
|
175
|
+
let withOverrides = applyOverrides(preserved, overrides);
|
|
176
|
+
|
|
177
|
+
const hasPermissionOverride =
|
|
178
|
+
overrides.permissionMode !== undefined && overrides.permissionMode !== '';
|
|
179
|
+
if (!hasPermissionOverride && !flagPresent(withOverrides, '--permission-mode') && sessionId !== '') {
|
|
180
|
+
const live = liveReader(sessionId);
|
|
181
|
+
if (live !== null && live !== '') {
|
|
182
|
+
withOverrides = [...withOverrides, '--permission-mode', live];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
186
|
+
|
|
187
|
+
const argv: string[] = [
|
|
188
|
+
...magic,
|
|
189
|
+
destination,
|
|
190
|
+
...rest,
|
|
191
|
+
'--name',
|
|
192
|
+
name,
|
|
193
|
+
`@${summaryPath}`,
|
|
194
|
+
];
|
|
195
|
+
args.trigger.stashArgv(argv);
|
|
196
|
+
args.trigger.fire();
|
|
197
|
+
return { action: 'done' };
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the user-visible relaunch command string for paste-flow
|
|
203
|
+
* responses. Mirrors `renderSwitchCommand` in the Go canonical
|
|
204
|
+
* (src/socket_listener.go:451). Magic words come first, then the
|
|
205
|
+
* destination, then the preserved/override flags, then
|
|
206
|
+
* `--name <name> @<summaryPath>`.
|
|
207
|
+
*
|
|
208
|
+
* Each token is shell-safe enough as-is: override values come from a
|
|
209
|
+
* controlled vocabulary (model aliases, effort levels, permission
|
|
210
|
+
* modes, allowedTools comma-list, agent names), and the summary path
|
|
211
|
+
* is `<base>/fnclaude-handoff-content-<hex>.md` — no whitespace, no
|
|
212
|
+
* metacharacters.
|
|
213
|
+
*/
|
|
214
|
+
function renderSwitchCommand(
|
|
215
|
+
magic: readonly string[],
|
|
216
|
+
destination: string,
|
|
217
|
+
rest: readonly string[],
|
|
218
|
+
name: string,
|
|
219
|
+
summaryPath: string,
|
|
220
|
+
): string {
|
|
221
|
+
const parts: string[] = ['fnclaude', ...magic, destination, ...rest, '--name', name, `@${summaryPath}`];
|
|
222
|
+
return parts.join(' ');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Coerce wire-request fields into an `OverrideRequest`. Wire keys use
|
|
227
|
+
* snake_case per the JSON contract; the override struct uses
|
|
228
|
+
* camelCase. Unknown / missing / wrong-type fields collapse to
|
|
229
|
+
* undefined so `applyOverrides` falls through to its preserve branch.
|
|
230
|
+
*/
|
|
231
|
+
function overrideRequestFrom(req: WireRequest): OverrideRequest {
|
|
232
|
+
return {
|
|
233
|
+
model: stringFieldOrUndef(req, 'model'),
|
|
234
|
+
effort: stringFieldOrUndef(req, 'effort'),
|
|
235
|
+
permissionMode: stringFieldOrUndef(req, 'permission_mode'),
|
|
236
|
+
allowedTools: stringFieldOrUndef(req, 'allowed_tools'),
|
|
237
|
+
agent: stringFieldOrUndef(req, 'agent'),
|
|
238
|
+
brief: boolFieldOrUndef(req, 'brief'),
|
|
239
|
+
chrome: boolFieldOrUndef(req, 'chrome'),
|
|
240
|
+
ide: boolFieldOrUndef(req, 'ide'),
|
|
241
|
+
verbose: boolFieldOrUndef(req, 'verbose'),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function stringField(req: WireRequest, key: string): string {
|
|
246
|
+
const v = req[key];
|
|
247
|
+
return typeof v === 'string' ? v : '';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function stringFieldOrUndef(req: WireRequest, key: string): string | undefined {
|
|
251
|
+
const v = req[key];
|
|
252
|
+
return typeof v === 'string' ? v : undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function boolFieldOrUndef(req: WireRequest, key: string): boolean | undefined {
|
|
256
|
+
const v = req[key];
|
|
257
|
+
return typeof v === 'boolean' ? v : undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether `args` carries `flag` as a bare token or as `flag=value`.
|
|
262
|
+
* Mirrors Go canonical's `flagPresent` (src/socket_listener.go:260).
|
|
263
|
+
*/
|
|
264
|
+
function flagPresent(argsArr: readonly string[], flag: string): boolean {
|
|
265
|
+
const prefix = `${flag}=`;
|
|
266
|
+
for (const t of argsArr) {
|
|
267
|
+
if (t === flag || t.startsWith(prefix)) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-MCP `--mcp-config` injection (§7.4).
|
|
3
|
+
*
|
|
4
|
+
* Builds an inline JSON config that points claude at the fnclaude binary
|
|
5
|
+
* as an MCP server, then appends `--mcp-config <json>` to the claude argv
|
|
6
|
+
* the launcher is about to spawn. The subprocess claude spawns from this
|
|
7
|
+
* config dials the parent over $FNC_SOCKET — see design.mcp.md §2.1.
|
|
8
|
+
*
|
|
9
|
+
* Wire shape (design.md §29, design.mcp.md §2.1):
|
|
10
|
+
*
|
|
11
|
+
* {"mcpServers":{"fnclaude":{"command":"<bunExec>","args":["<fncBin>","mcp"]}}}
|
|
12
|
+
*
|
|
13
|
+
* Noop sessions add `"--noop"` to args. The Go canonical resolves the exe
|
|
14
|
+
* path via `filepath.EvalSymlinks(os.Executable())`; the TS equivalent is
|
|
15
|
+
* `realpathSync(process.argv[1] ?? '')` paired with `process.execPath` for
|
|
16
|
+
* the runtime that will actually `exec` it. The two-element shape (bun +
|
|
17
|
+
* script path, vs. a single bundled binary in Go) is necessary because
|
|
18
|
+
* fnc.js is a script — claude spawning bare `<fncBin>` would invoke node,
|
|
19
|
+
* not bun, and node can't run the bun-only main.ts.
|
|
20
|
+
*
|
|
21
|
+
* Gate: print mode (-p / --print) doesn't get the config — claude is being
|
|
22
|
+
* driven non-interactively, no MCP tools would be useful. The launcher
|
|
23
|
+
* also skips the call entirely on win32 where there's no listener.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface InjectMcpConfigArgs {
|
|
27
|
+
claudeArgs: readonly string[];
|
|
28
|
+
/** Path to the bun executable that will run the MCP subprocess (typically process.execPath). */
|
|
29
|
+
bunExec: string;
|
|
30
|
+
/** Absolute path to the fnc bin script (typically realpathSync(process.argv[1])). */
|
|
31
|
+
fncBin: string;
|
|
32
|
+
/** True when the launcher used the noop fallback; appends "--noop" to args. */
|
|
33
|
+
noop: boolean;
|
|
34
|
+
/** False for -p / --print sessions; skips injection per design.md §29 gate. */
|
|
35
|
+
interactive: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function injectMcpConfig(args: InjectMcpConfigArgs): string[] {
|
|
39
|
+
// Gate per design.md §29: only interactive sessions get the config.
|
|
40
|
+
if (!args.interactive) return [...args.claudeArgs];
|
|
41
|
+
// Bail out if the launcher couldn't resolve its own path. Without an
|
|
42
|
+
// absolute fnc bin the spawned subprocess wouldn't be able to find
|
|
43
|
+
// itself; better to skip than to inject a broken config.
|
|
44
|
+
if (args.fncBin === '') return [...args.claudeArgs];
|
|
45
|
+
|
|
46
|
+
const subprocessArgs: string[] = [args.fncBin, 'mcp'];
|
|
47
|
+
if (args.noop) subprocessArgs.push('--noop');
|
|
48
|
+
|
|
49
|
+
const config = {
|
|
50
|
+
mcpServers: {
|
|
51
|
+
fnclaude: {
|
|
52
|
+
command: args.bunExec,
|
|
53
|
+
args: subprocessArgs,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return [...args.claudeArgs, '--mcp-config', JSON.stringify(config)];
|
|
59
|
+
}
|