@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,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP subcommand dispatch. fnc's first arg of "mcp" routes to the embedded
|
|
3
|
+
* JSON-RPC subprocess (the one claude invokes via the injected
|
|
4
|
+
* --mcp-config; see docs/design.mcp.md §2).
|
|
5
|
+
*
|
|
6
|
+
* §2.7 contributed the routing wrapper. §7.5 (here) wires the entry point:
|
|
7
|
+
* - Read $FNC_SOCKET from env; fatal exit 2 if absent.
|
|
8
|
+
* - Build the four tool handlers, each of which dials the parent over
|
|
9
|
+
* the AF_UNIX socket using §7.6's dialAndCall.
|
|
10
|
+
* - Read line-delimited JSON-RPC requests from stdin until EOF, route
|
|
11
|
+
* `tools/call` requests to the appropriate handler, and write JSON
|
|
12
|
+
* responses to stdout. (Full JSON-RPC scaffolding — initialize,
|
|
13
|
+
* tools/list, notifications — lands in §7.3.)
|
|
14
|
+
*
|
|
15
|
+
* Matches Go canonical's dispatch shape (src/main.go:879-887): mcp
|
|
16
|
+
* subcommand recognized ONLY at argv[0], '--noop' is the sole flag that
|
|
17
|
+
* affects server behavior, anything else is ignored.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { dialAndCall, type WireOp, type WireRequest, type WireResponse } from './wire.ts';
|
|
21
|
+
|
|
22
|
+
const SUBCOMMAND = 'mcp';
|
|
23
|
+
const NOOP_FLAG = '--noop';
|
|
24
|
+
|
|
25
|
+
export function isMcpSubcommand(args: readonly string[]): boolean {
|
|
26
|
+
return args.length > 0 && args[0] === SUBCOMMAND;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface McpFlags {
|
|
30
|
+
noop: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseMcpFlags(tail: readonly string[]): McpFlags {
|
|
34
|
+
return { noop: tail.includes(NOOP_FLAG) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The four tool names exposed by the subprocess, per design.mcp.md §4.
|
|
39
|
+
* Order matches the spec table; consumers should not depend on order
|
|
40
|
+
* but `tools/list` rendering is deterministic if they do.
|
|
41
|
+
*/
|
|
42
|
+
export const MCP_TOOL_NAMES = [
|
|
43
|
+
'fnc_restart',
|
|
44
|
+
'fnc_switch_project',
|
|
45
|
+
'fnc_spawn_session',
|
|
46
|
+
'fnc_copy_to_clipboard',
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
export type McpToolName = (typeof MCP_TOOL_NAMES)[number];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Mapping from the MCP-visible tool name to the wire `op` value the
|
|
53
|
+
* parent dispatcher routes on. See design.mcp.md §4.
|
|
54
|
+
*/
|
|
55
|
+
const TOOL_TO_OP: Record<McpToolName, WireOp> = {
|
|
56
|
+
fnc_restart: 'restart',
|
|
57
|
+
fnc_switch_project: 'switch',
|
|
58
|
+
fnc_spawn_session: 'spawn',
|
|
59
|
+
fnc_copy_to_clipboard: 'copy_to_clipboard',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface McpTool {
|
|
63
|
+
name: McpToolName;
|
|
64
|
+
handler: (args: unknown) => Promise<WireResponse>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BuildToolsArgs {
|
|
68
|
+
socketPath: string;
|
|
69
|
+
/** Injectable for tests; defaults to the real {@link dialAndCall}. */
|
|
70
|
+
dialAndCall?: (a: {
|
|
71
|
+
socketPath: string;
|
|
72
|
+
request: WireRequest;
|
|
73
|
+
}) => Promise<WireResponse>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Construct the four tool handlers. Each handler accepts an MCP tool-args
|
|
78
|
+
* payload (object literal from the model's `tools/call`), wraps it in a
|
|
79
|
+
* WireRequest with the matching `op`, and forwards through `dialAndCall`.
|
|
80
|
+
*
|
|
81
|
+
* §8 will add per-tool input validation in front of `dialAndCall`. This
|
|
82
|
+
* function ships the wiring shape; the validation hooks in beside the
|
|
83
|
+
* `op:` field assignment without changing the call-graph.
|
|
84
|
+
*/
|
|
85
|
+
export function buildTools(args: BuildToolsArgs): McpTool[] {
|
|
86
|
+
const dialer = args.dialAndCall ?? dialAndCall;
|
|
87
|
+
return MCP_TOOL_NAMES.map((name) => ({
|
|
88
|
+
name,
|
|
89
|
+
handler: async (payload: unknown): Promise<WireResponse> => {
|
|
90
|
+
const op = TOOL_TO_OP[name];
|
|
91
|
+
const request: WireRequest = { op };
|
|
92
|
+
if (payload !== null && payload !== undefined && typeof payload === 'object') {
|
|
93
|
+
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
|
|
94
|
+
// Don't let a caller-supplied `op` field override our routing.
|
|
95
|
+
if (k === 'op') continue;
|
|
96
|
+
(request as Record<string, unknown>)[k] = v;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return dialer({ socketPath: args.socketPath, request });
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Entry point for `fnc mcp [--noop]`.
|
|
106
|
+
*
|
|
107
|
+
* Returns the process exit code. The launcher (main.ts) calls
|
|
108
|
+
* `process.exit(exitCode)` with the return value.
|
|
109
|
+
*/
|
|
110
|
+
export async function runMcpServer(_flags: McpFlags): Promise<number> {
|
|
111
|
+
const socketPath = process.env.FNC_SOCKET;
|
|
112
|
+
if (socketPath === undefined || socketPath === '') {
|
|
113
|
+
process.stderr.write(
|
|
114
|
+
'fnc mcp: FNC_SOCKET not set; subprocess must be invoked by fnclaude launcher.\n',
|
|
115
|
+
);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tools = buildTools({ socketPath });
|
|
120
|
+
const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
121
|
+
|
|
122
|
+
await runStdinLoop({ toolsByName });
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface StdinLoopArgs {
|
|
127
|
+
toolsByName: Map<string, McpTool>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Placeholder JSON-RPC loop until §7.3 lands. Reads newline-delimited
|
|
132
|
+
* JSON from stdin; for each `tools/call` request, calls the matching
|
|
133
|
+
* handler and writes a minimal JSON-RPC 2.0 response. For everything
|
|
134
|
+
* else (including the eventual `initialize` / `tools/list`) writes a
|
|
135
|
+
* method-not-found error.
|
|
136
|
+
*
|
|
137
|
+
* The shape here is just enough to surface a real tool-call to the
|
|
138
|
+
* parent over the wire; §7.3 replaces this with the full scaffold.
|
|
139
|
+
*/
|
|
140
|
+
async function runStdinLoop(args: StdinLoopArgs): Promise<void> {
|
|
141
|
+
const decoder = new TextDecoder('utf-8');
|
|
142
|
+
let buffer = '';
|
|
143
|
+
|
|
144
|
+
// Read raw bytes from stdin; node:process exposes stdin as an async
|
|
145
|
+
// iterable of Uint8Array chunks.
|
|
146
|
+
for await (const chunk of process.stdin) {
|
|
147
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
|
|
148
|
+
let nl: number;
|
|
149
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
150
|
+
const line = buffer.slice(0, nl).trim();
|
|
151
|
+
buffer = buffer.slice(nl + 1);
|
|
152
|
+
if (line === '') continue;
|
|
153
|
+
await handleLine({ line, toolsByName: args.toolsByName });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Flush whatever bytes remain after EOF.
|
|
157
|
+
const tail = buffer.trim();
|
|
158
|
+
if (tail !== '') {
|
|
159
|
+
await handleLine({ line: tail, toolsByName: args.toolsByName });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface JsonRpcRequest {
|
|
164
|
+
jsonrpc?: string;
|
|
165
|
+
id?: number | string | null;
|
|
166
|
+
method?: string;
|
|
167
|
+
params?: unknown;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function handleLine(args: {
|
|
171
|
+
line: string;
|
|
172
|
+
toolsByName: Map<string, McpTool>;
|
|
173
|
+
}): Promise<void> {
|
|
174
|
+
let req: JsonRpcRequest;
|
|
175
|
+
try {
|
|
176
|
+
req = JSON.parse(args.line) as JsonRpcRequest;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
writeRpcError(null, -32700, `parse error: ${(err as Error).message}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Notifications (no `id`) get no response, even on error.
|
|
183
|
+
const id = req.id ?? null;
|
|
184
|
+
const isNotification = req.id === undefined;
|
|
185
|
+
|
|
186
|
+
if (req.method === 'tools/call') {
|
|
187
|
+
const params = (req.params ?? {}) as { name?: string; arguments?: unknown };
|
|
188
|
+
const tool = params.name !== undefined ? args.toolsByName.get(params.name) : undefined;
|
|
189
|
+
if (tool === undefined) {
|
|
190
|
+
if (!isNotification) {
|
|
191
|
+
writeRpcError(id, -32601, `unknown tool: ${params.name ?? '<missing>'}`);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const result = await tool.handler(params.arguments);
|
|
197
|
+
if (!isNotification) writeRpcToolResult(id, result);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (!isNotification) {
|
|
200
|
+
writeRpcError(id, -32000, `tool error: ${(err as Error).message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Full method dispatch (initialize, tools/list, etc.) lands with §7.3.
|
|
207
|
+
if (!isNotification) {
|
|
208
|
+
writeRpcError(id, -32601, `method not implemented yet (§7.3): ${req.method ?? '<missing>'}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function writeRpcToolResult(
|
|
213
|
+
id: number | string | null,
|
|
214
|
+
result: WireResponse,
|
|
215
|
+
): void {
|
|
216
|
+
// Per design.mcp.md §2.3: marshal the Response JSON as a single text
|
|
217
|
+
// content item. The model reads `action` / `message` / `command` /
|
|
218
|
+
// `clipboard_ok` out of the embedded JSON string.
|
|
219
|
+
const envelope = {
|
|
220
|
+
jsonrpc: '2.0',
|
|
221
|
+
id,
|
|
222
|
+
result: {
|
|
223
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
process.stdout.write(JSON.stringify(envelope) + '\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function writeRpcError(
|
|
230
|
+
id: number | string | null,
|
|
231
|
+
code: number,
|
|
232
|
+
message: string,
|
|
233
|
+
): void {
|
|
234
|
+
const envelope = {
|
|
235
|
+
jsonrpc: '2.0',
|
|
236
|
+
id,
|
|
237
|
+
error: { code, message },
|
|
238
|
+
};
|
|
239
|
+
process.stdout.write(JSON.stringify(envelope) + '\n');
|
|
240
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.4 — Clipboard backend detection + invocation (pure module).
|
|
3
|
+
*
|
|
4
|
+
* Backend priority per design.md §25 + design.mcp.md §4.4:
|
|
5
|
+
*
|
|
6
|
+
* 1. `wl-copy` (Wayland)
|
|
7
|
+
* 2. `xclip` (X11 — preferred over xsel)
|
|
8
|
+
* 3. `xsel` (X11 fallback)
|
|
9
|
+
* 4. `pbcopy` (macOS)
|
|
10
|
+
* 5. `clip.exe` (Windows / WSL — reachable on WSL via PATH)
|
|
11
|
+
*
|
|
12
|
+
* Detection here is by `which`-style PATH lookup, not by the GOOS+env
|
|
13
|
+
* gating the Go canonical uses. That's intentional for the rewrite: a
|
|
14
|
+
* Linux box without WAYLAND_DISPLAY but with `xclip` on PATH will still
|
|
15
|
+
* work; on WSL `clip.exe` is reachable from a "linux" runtime; on macOS
|
|
16
|
+
* only `pbcopy` is in PATH by default. The injected `which` keeps tests
|
|
17
|
+
* pure.
|
|
18
|
+
*
|
|
19
|
+
* Each backend takes the clipboard payload via stdin and writes nothing
|
|
20
|
+
* useful to stdout. Exit 0 = success. The handler never throws — spawn
|
|
21
|
+
* failures, exec failures, non-zero exits all flow back as `false` so
|
|
22
|
+
* the caller can report `clipboard_ok: false` without lighting up an
|
|
23
|
+
* error path the model doesn't expect.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Locate an executable on PATH, returning its absolute path or null. */
|
|
27
|
+
export type WhichFn = (name: string) => string | null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Minimal spawn surface we exercise from this module. The real
|
|
31
|
+
* implementation is a thin adapter over `Bun.spawn` (see {@link defaultSpawn}).
|
|
32
|
+
* Tests inject a fake to avoid touching real processes.
|
|
33
|
+
*/
|
|
34
|
+
export interface SpawnedProc {
|
|
35
|
+
stdin: {
|
|
36
|
+
write(chunk: string | Uint8Array): void;
|
|
37
|
+
end(): void;
|
|
38
|
+
};
|
|
39
|
+
exited: Promise<number>;
|
|
40
|
+
kill(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SpawnFn = (
|
|
44
|
+
args: readonly string[],
|
|
45
|
+
opts: { stdin: 'pipe' },
|
|
46
|
+
) => SpawnedProc;
|
|
47
|
+
|
|
48
|
+
export interface Backend {
|
|
49
|
+
name: 'wl-copy' | 'xclip' | 'xsel' | 'pbcopy' | 'clip.exe';
|
|
50
|
+
command: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PRIORITY: ReadonlyArray<Backend['name']> = [
|
|
54
|
+
'wl-copy',
|
|
55
|
+
'xclip',
|
|
56
|
+
'xsel',
|
|
57
|
+
'pbcopy',
|
|
58
|
+
'clip.exe',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Walk the priority list and return the first backend whose executable
|
|
63
|
+
* `which` finds. Returns null if nothing is installed.
|
|
64
|
+
*/
|
|
65
|
+
export function detectBackend(args: { which: WhichFn }): Backend | null {
|
|
66
|
+
for (const name of PRIORITY) {
|
|
67
|
+
const command = args.which(name);
|
|
68
|
+
if (command !== null && command !== '') {
|
|
69
|
+
return { name, command };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function backendArgs(name: Backend['name']): readonly string[] {
|
|
76
|
+
// Selection flags matter for X11 — without `-selection clipboard` xclip
|
|
77
|
+
// writes to the PRIMARY selection (middle-click paste), not the
|
|
78
|
+
// clipboard the user reaches via Ctrl-V. Same shape for xsel's `-ib`.
|
|
79
|
+
switch (name) {
|
|
80
|
+
case 'xclip':
|
|
81
|
+
return ['-selection', 'clipboard'];
|
|
82
|
+
case 'xsel':
|
|
83
|
+
return ['-ib'];
|
|
84
|
+
case 'wl-copy':
|
|
85
|
+
case 'pbcopy':
|
|
86
|
+
case 'clip.exe':
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RunBackendArgs {
|
|
92
|
+
backend: Backend;
|
|
93
|
+
text: string;
|
|
94
|
+
spawn: SpawnFn;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Invoke the chosen backend, piping `text` to its stdin. Returns true on
|
|
99
|
+
* clean exit (code 0), false on any failure: spawn throwing, stdin write
|
|
100
|
+
* throwing, non-zero exit, or the exited promise rejecting.
|
|
101
|
+
*
|
|
102
|
+
* Crucially, this function never throws. The caller relies on the
|
|
103
|
+
* boolean return to populate `clipboard_ok`.
|
|
104
|
+
*/
|
|
105
|
+
export async function runBackend(args: RunBackendArgs): Promise<boolean> {
|
|
106
|
+
const argv = [args.backend.command, ...backendArgs(args.backend.name)];
|
|
107
|
+
let proc: SpawnedProc;
|
|
108
|
+
try {
|
|
109
|
+
proc = args.spawn(argv, { stdin: 'pipe' });
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
proc.stdin.write(args.text);
|
|
116
|
+
proc.stdin.end();
|
|
117
|
+
} catch {
|
|
118
|
+
// stdin closed early or the process died before we could write.
|
|
119
|
+
// Make sure the spawned process isn't lingering, then report
|
|
120
|
+
// failure. Some backends (xclip on Wayland-only boxes, for one)
|
|
121
|
+
// exit immediately on EPIPE and we shouldn't wait on exited
|
|
122
|
+
// forever; the kill is best-effort.
|
|
123
|
+
try {
|
|
124
|
+
proc.kill();
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let exitCode: number;
|
|
132
|
+
try {
|
|
133
|
+
exitCode = await proc.exited;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return exitCode === 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Production `which`: thin wrapper around Bun.which. Returns null when
|
|
142
|
+
* the name isn't on PATH; both `null` and `undefined` from Bun.which are
|
|
143
|
+
* treated as "not found".
|
|
144
|
+
*/
|
|
145
|
+
export const defaultWhich: WhichFn = (name) => {
|
|
146
|
+
const r = Bun.which(name);
|
|
147
|
+
return r === null || r === undefined ? null : r;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Production `spawn`: adapter that yields the minimal SpawnedProc shape
|
|
152
|
+
* over Bun.spawn. We pipe stdin (for the text payload), ignore stdout
|
|
153
|
+
* (no useful output from these backends), and inherit stderr so a real
|
|
154
|
+
* misconfiguration surfaces in the parent's terminal during development.
|
|
155
|
+
*/
|
|
156
|
+
export const defaultSpawn: SpawnFn = (args, _opts) => {
|
|
157
|
+
const proc = Bun.spawn([...args], {
|
|
158
|
+
stdin: 'pipe',
|
|
159
|
+
stdout: 'ignore',
|
|
160
|
+
stderr: 'inherit',
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
stdin: {
|
|
164
|
+
write(chunk: string | Uint8Array) {
|
|
165
|
+
proc.stdin.write(chunk);
|
|
166
|
+
},
|
|
167
|
+
end() {
|
|
168
|
+
proc.stdin.end();
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
exited: proc.exited,
|
|
172
|
+
kill() {
|
|
173
|
+
proc.kill();
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.4 — `fnc_copy_to_clipboard` handler.
|
|
3
|
+
*
|
|
4
|
+
* Spec (design.mcp.md §4.4):
|
|
5
|
+
* - Required arg: `text: string`
|
|
6
|
+
* - Returns `{ action: 'done', clipboard_ok: boolean }`
|
|
7
|
+
* - NEVER errors — clipboard absence flows through the boolean flag,
|
|
8
|
+
* not an error response. Same shape on every failure mode: no
|
|
9
|
+
* backend on PATH, backend exits non-zero, spawn throws, text arg
|
|
10
|
+
* is missing or wrong type.
|
|
11
|
+
*
|
|
12
|
+
* Wave 1 (this PR) ships the pure module only. §7.7's parent
|
|
13
|
+
* dispatcher will route `op: 'copy_to_clipboard'` to this handler in
|
|
14
|
+
* Wave 2.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { WireRequest, WireResponse } from '../wire.ts';
|
|
18
|
+
import {
|
|
19
|
+
defaultSpawn,
|
|
20
|
+
defaultWhich,
|
|
21
|
+
detectBackend,
|
|
22
|
+
runBackend,
|
|
23
|
+
type SpawnFn,
|
|
24
|
+
type WhichFn,
|
|
25
|
+
} from './clipboard-backends.ts';
|
|
26
|
+
|
|
27
|
+
export interface HandleCopyToClipboardDeps {
|
|
28
|
+
which?: WhichFn;
|
|
29
|
+
spawn?: SpawnFn;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Process a `copy_to_clipboard` request. Always resolves to a `done`
|
|
34
|
+
* response; the `clipboard_ok` flag carries the actual outcome.
|
|
35
|
+
*
|
|
36
|
+
* Deps are injected for tests; production callers omit them and get the
|
|
37
|
+
* Bun.which / Bun.spawn defaults.
|
|
38
|
+
*/
|
|
39
|
+
export async function handleCopyToClipboard(
|
|
40
|
+
req: WireRequest,
|
|
41
|
+
deps: HandleCopyToClipboardDeps = {},
|
|
42
|
+
): Promise<WireResponse> {
|
|
43
|
+
const which = deps.which ?? defaultWhich;
|
|
44
|
+
const spawn = deps.spawn ?? defaultSpawn;
|
|
45
|
+
|
|
46
|
+
const text = req.text;
|
|
47
|
+
if (typeof text !== 'string') {
|
|
48
|
+
return done(false);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const backend = detectBackend({ which });
|
|
52
|
+
if (backend === null) {
|
|
53
|
+
return done(false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ok = await runBackend({ backend, text, spawn });
|
|
57
|
+
return done(ok);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function done(clipboardOk: boolean): WireResponse {
|
|
61
|
+
return { action: 'done', clipboard_ok: clipboardOk };
|
|
62
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.1 — `fnc_restart` handler.
|
|
3
|
+
*
|
|
4
|
+
* Ports the Go canonical `handleRestart` from
|
|
5
|
+
* `fnclaude@fnrhombus/src/socket_listener.go` lines 221-256. The
|
|
6
|
+
* restart flow rebuilds the launch argv with the same magic prefix the
|
|
7
|
+
* user originally typed, swaps in `--resume <session_id>` immediately
|
|
8
|
+
* after the cwd positional, applies MCP-supplied overrides, then stashes
|
|
9
|
+
* the result + fires the handoff trigger so §8.5's awaiter can SIGTERM
|
|
10
|
+
* claude and re-exec fnc with the new argv.
|
|
11
|
+
*
|
|
12
|
+
* Algorithm (matches Go canonical):
|
|
13
|
+
* 1. Validate session_id present + UUID 8-4-4-4-12 hex.
|
|
14
|
+
* 2. `preserveArgs(origArgs, ∅, ∅)` — restart uses NO denylist.
|
|
15
|
+
* 3. `applyOverrides(preserved, req)` — splices in MCP overrides.
|
|
16
|
+
* 4. If no caller-supplied permission_mode AND no preserved
|
|
17
|
+
* `--permission-mode`, ask the injected `livePermissionModeReader`
|
|
18
|
+
* for the value claude wrote into the session JSONL. The reader is
|
|
19
|
+
* optional; production wiring stubs it out for now (TODO file IO).
|
|
20
|
+
* 5. Split the leading magic-word run; rebuild argv as:
|
|
21
|
+
* `[...magic, launchCWD, '--resume', sid, ...rest]`
|
|
22
|
+
* 6. `trigger.stashArgv(argv)` (first-stash-wins).
|
|
23
|
+
* 7. `trigger.fire()` to wake §8.5's awaiter.
|
|
24
|
+
* 8. Respond `{ action: 'done' }`.
|
|
25
|
+
*
|
|
26
|
+
* Design: docs/design.mcp.md §4.1, §5; docs/design.md §12-13.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
applyOverrides,
|
|
31
|
+
preserveArgs,
|
|
32
|
+
splitLeadingMagic,
|
|
33
|
+
type OverrideRequest,
|
|
34
|
+
} from '../../argv/preserve-args.ts';
|
|
35
|
+
import type { HandoffTrigger } from '../../handoff/trigger.ts';
|
|
36
|
+
import type { ParentDispatchHandler } from '../parent-dispatch.ts';
|
|
37
|
+
import type { WireRequest, WireResponse } from '../wire.ts';
|
|
38
|
+
|
|
39
|
+
const SESSION_ID_RE =
|
|
40
|
+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
41
|
+
|
|
42
|
+
const EMPTY_DENY: ReadonlySet<string> = new Set<string>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reader for the live permission-mode value claude persists in the
|
|
46
|
+
* session JSONL (`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`).
|
|
47
|
+
* `launchCWD` is bound at construction time in main.ts, so the reader
|
|
48
|
+
* only takes the per-call `sessionID`. Returns `null` when no value is
|
|
49
|
+
* available (file missing, no matching record, etc.) — the auto-capture
|
|
50
|
+
* append is skipped in that case. Same shape as switch.ts uses, so a
|
|
51
|
+
* single closure can be wired into both handlers.
|
|
52
|
+
*/
|
|
53
|
+
export type LivePermissionModeReader = (sessionID: string) => string | null;
|
|
54
|
+
|
|
55
|
+
export interface CreateRestartHandlerArgs {
|
|
56
|
+
/** The user's original argv as fnc saw it at startup (post-readArgv). */
|
|
57
|
+
origArgs: readonly string[];
|
|
58
|
+
/** The directory fnc launched claude into — emitted as the first positional. */
|
|
59
|
+
launchCWD: string;
|
|
60
|
+
/** Shared handoff trigger; first-stash-wins for argv. */
|
|
61
|
+
trigger: HandoffTrigger;
|
|
62
|
+
/** Optional injected reader. Omit to disable live-capture entirely. */
|
|
63
|
+
livePermissionModeReader?: LivePermissionModeReader;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the restart handler with all collaborators bound. Returned
|
|
68
|
+
* function plugs straight into `createParentDispatcher({ handlers: { restart, ... } })`.
|
|
69
|
+
*/
|
|
70
|
+
export function createRestartHandler(args: CreateRestartHandlerArgs): ParentDispatchHandler {
|
|
71
|
+
const { origArgs, launchCWD, trigger, livePermissionModeReader } = args;
|
|
72
|
+
|
|
73
|
+
return async (req: WireRequest): Promise<WireResponse> => {
|
|
74
|
+
const sessionID = req.session_id;
|
|
75
|
+
if (typeof sessionID !== 'string' || sessionID === '') {
|
|
76
|
+
return {
|
|
77
|
+
action: 'error',
|
|
78
|
+
error:
|
|
79
|
+
'restart requires a session id; pass it as the fnc_restart session_id argument (read $CLAUDE_CODE_SESSION_ID via Bash).',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (!SESSION_ID_RE.test(sessionID)) {
|
|
83
|
+
return {
|
|
84
|
+
action: 'error',
|
|
85
|
+
error: `session_id ${JSON.stringify(sessionID)} is not a valid UUID; expected the 8-4-4-4-12 hex form.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Preserve user flags (no denylist for restart — everything carries).
|
|
90
|
+
const preserved = preserveArgs(origArgs, EMPTY_DENY, EMPTY_DENY);
|
|
91
|
+
|
|
92
|
+
// Apply MCP-supplied overrides. Wire snake_case → OverrideRequest camelCase.
|
|
93
|
+
const overrides = wireToOverrideRequest(req);
|
|
94
|
+
let withOverrides = applyOverrides(preserved, overrides);
|
|
95
|
+
|
|
96
|
+
// Auto-capture live permission-mode when no override was passed AND
|
|
97
|
+
// no preserved flag carries one. Mirrors Go canonical — runs only
|
|
98
|
+
// when an injected reader is available.
|
|
99
|
+
const permissionModeFromReq = req.permission_mode;
|
|
100
|
+
const callerSuppliedPermissionMode =
|
|
101
|
+
typeof permissionModeFromReq === 'string' && permissionModeFromReq !== '';
|
|
102
|
+
if (
|
|
103
|
+
!callerSuppliedPermissionMode &&
|
|
104
|
+
!flagPresent(withOverrides, '--permission-mode') &&
|
|
105
|
+
livePermissionModeReader !== undefined
|
|
106
|
+
) {
|
|
107
|
+
const live = livePermissionModeReader(sessionID);
|
|
108
|
+
if (live !== null && live !== '') {
|
|
109
|
+
withOverrides = [...withOverrides, '--permission-mode', live];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
114
|
+
const argv: string[] = [...magic, launchCWD, '--resume', sessionID, ...rest];
|
|
115
|
+
|
|
116
|
+
trigger.stashArgv(argv);
|
|
117
|
+
trigger.fire();
|
|
118
|
+
return { action: 'done' };
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Translate the wire request's snake_case override fields into the
|
|
124
|
+
* `OverrideRequest` shape `applyOverrides` consumes. Fields not present
|
|
125
|
+
* (or of the wrong type) are silently omitted — the caller's MCP layer
|
|
126
|
+
* validates shapes; defensive typing here just keeps applyOverrides safe.
|
|
127
|
+
*/
|
|
128
|
+
function wireToOverrideRequest(req: WireRequest): OverrideRequest {
|
|
129
|
+
const out: OverrideRequest = {};
|
|
130
|
+
if (typeof req.model === 'string' && req.model !== '') out.model = req.model;
|
|
131
|
+
if (typeof req.effort === 'string' && req.effort !== '') out.effort = req.effort;
|
|
132
|
+
if (typeof req.permission_mode === 'string' && req.permission_mode !== '') {
|
|
133
|
+
out.permissionMode = req.permission_mode;
|
|
134
|
+
}
|
|
135
|
+
if (typeof req.allowed_tools === 'string' && req.allowed_tools !== '') {
|
|
136
|
+
out.allowedTools = req.allowed_tools;
|
|
137
|
+
}
|
|
138
|
+
if (typeof req.agent === 'string' && req.agent !== '') out.agent = req.agent;
|
|
139
|
+
if (typeof req.brief === 'boolean') out.brief = req.brief;
|
|
140
|
+
if (typeof req.chrome === 'boolean') out.chrome = req.chrome;
|
|
141
|
+
if (typeof req.ide === 'boolean') out.ide = req.ide;
|
|
142
|
+
if (typeof req.verbose === 'boolean') out.verbose = req.verbose;
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Returns true when `args` contains `flag` as a standalone token or in
|
|
148
|
+
* `--flag=value` form. Mirrors Go canonical's `flagPresent`.
|
|
149
|
+
*/
|
|
150
|
+
function flagPresent(args: readonly string[], flag: string): boolean {
|
|
151
|
+
const eqPrefix = `${flag}=`;
|
|
152
|
+
for (const tok of args) {
|
|
153
|
+
if (tok === flag || tok.startsWith(eqPrefix)) return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|