@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
package/src/main.ts
CHANGED
|
@@ -1,477 +1,620 @@
|
|
|
1
|
-
//
|
|
1
|
+
// `fnc`: launch `claude` in the resolved cwd (or the noop fallback when
|
|
2
|
+
// no positional was given). Bun-only (top-level await, Bun.spawn).
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// 1. Parse argv (with --help / --version / `mcp` subcommand short-circuits)
|
|
7
|
-
// 2. Resolve user-typed cwd into an absolute path (path + repo lookups)
|
|
8
|
-
// 3. Apply -w worktree intercept (may swap cwd to an existing worktree)
|
|
9
|
-
// 4. Auto-name if the invocation qualifies (--, prompt, no --name, etc.)
|
|
10
|
-
// 5. Sanitize any --name / -n values to a path-safe slug
|
|
11
|
-
// 6. Build the claude argv (extra-dirs, self-MCP, auto-tmux, prompts)
|
|
12
|
-
// 7. Spawn claude under a PTY with the AF_UNIX socket listener active
|
|
13
|
-
// 8. On exit:
|
|
14
|
-
// - If the listener fired (auto-handoff): silentRelaunchHandoff
|
|
15
|
-
// - Else if claude printed a cross-cwd marker: silentRelaunch
|
|
16
|
-
// - Else: propagate claude's exit code
|
|
4
|
+
// This file is the launcher entry. Argv parsing, path resolution, and
|
|
5
|
+
// feature transforms live in their own modules under src/; main composes
|
|
6
|
+
// them in order.
|
|
17
7
|
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
import { mkdir } from 'node:fs/promises';
|
|
18
10
|
import { homedir } from 'node:os';
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} from './
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} from './
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
runMCPServer?: typeof runMCPServer;
|
|
91
|
-
/** Override seedNoop (best-effort dir seeder). */
|
|
92
|
-
seedNoop?: typeof seedNoop;
|
|
93
|
-
/** Override generateName for auto-name (skip the LLM call). */
|
|
94
|
-
generateName?: typeof generateName;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* GitRunner for applyWorktreeIntercept. Tests that want to drive a
|
|
98
|
-
* fake `git worktree list` reply pass one here; production uses the
|
|
99
|
-
* module's `defaultGitRunner`.
|
|
100
|
-
*/
|
|
101
|
-
gitRunner?: GitRunner;
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Resolver I/O seams (path-exists check, gh CLI, clone). Tests pass a
|
|
105
|
-
* stub set so the resolver runs without touching the network or
|
|
106
|
-
* filesystem; production uses `productionDeps()` from resolver.ts.
|
|
107
|
-
*/
|
|
108
|
-
resolveDeps?: ResolveDeps;
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { readArgv } from './argv/intake.ts';
|
|
14
|
+
import { expandAliases } from './argv/expand.ts';
|
|
15
|
+
import { parseArgs } from './argv/parse.ts';
|
|
16
|
+
import { expandShortFlags } from './argv/short-flags.ts';
|
|
17
|
+
import { loadConfig } from './config/load.ts';
|
|
18
|
+
import { reexecSelf, startHandoffAwaiter } from './handoff/awaiter.ts';
|
|
19
|
+
import { handoffTrigger } from './handoff/trigger.ts';
|
|
20
|
+
import { getVersion, helpText, wantsHelp, wantsVersion } from './help-version.ts';
|
|
21
|
+
import { composeEnv } from './launch/compose-env.ts';
|
|
22
|
+
import { decideCrossCwdRelaunch } from './launch/cross-cwd-relaunch.ts';
|
|
23
|
+
import { findClaude } from './launch/find-claude.ts';
|
|
24
|
+
import { readLivePermissionMode } from './launch/live-permission-reader.ts';
|
|
25
|
+
import { RingBuffer } from './launch/ring-buffer.ts';
|
|
26
|
+
import { isMcpSubcommand, parseMcpFlags, runMcpServer } from './mcp/dispatch.ts';
|
|
27
|
+
import { handleCopyToClipboard } from './mcp/handlers/clipboard.ts';
|
|
28
|
+
import { createRestartHandler } from './mcp/handlers/restart.ts';
|
|
29
|
+
import { createSpawnHandler } from './mcp/handlers/spawn.ts';
|
|
30
|
+
import { createSwitchHandler } from './mcp/handlers/switch.ts';
|
|
31
|
+
import { injectMcpConfig } from './mcp/inject-config.ts';
|
|
32
|
+
import { startMcpListener } from './mcp/listener.ts';
|
|
33
|
+
import { createParentDispatcher, stubParentHandlers } from './mcp/parent-dispatch.ts';
|
|
34
|
+
import { computeSocketPath } from './mcp/socket-path.ts';
|
|
35
|
+
import { autoName, shouldAutoName } from './name/auto-name.ts';
|
|
36
|
+
import { AUTO_NAME_MODEL, AUTO_NAME_SYSTEM_PROMPT } from './name/llm-prompt.ts';
|
|
37
|
+
import { sanitizeForPath } from './name/sanitize.ts';
|
|
38
|
+
import { sdkLlmCall } from './name/sdk-llm.ts';
|
|
39
|
+
import { findPromptSentinel, promptBody } from './argv/sentinel.ts';
|
|
40
|
+
import { seedNoopDir } from './noop/seed.ts';
|
|
41
|
+
import { resolveTemplateSourcePath } from './noop/template-source.ts';
|
|
42
|
+
import { ensureCwd } from './path/ensure-cwd.ts';
|
|
43
|
+
import { resolvePromptsDir } from './prompts/dir.ts';
|
|
44
|
+
import { injectFragments, loadFragments } from './prompts/load.ts';
|
|
45
|
+
import { isInteractiveSession, selectFragments } from './prompts/select.ts';
|
|
46
|
+
import { buildCloneUrl, computeCloneDestination } from './repo/clone.ts';
|
|
47
|
+
import { cloneRepo } from './repo/clone-exec.ts';
|
|
48
|
+
import { runGhApi, runGhClone } from './repo/gh-runner.ts';
|
|
49
|
+
import { loadHostAliases } from './repo/host-aliases.ts';
|
|
50
|
+
import { findOwner } from './repo/owner-lookup.ts';
|
|
51
|
+
import { loadRepoSettings } from './repo/repo-settings.ts';
|
|
52
|
+
import { resolveInput } from './repo/resolve-input.ts';
|
|
53
|
+
import { createWarningBuffer } from './warnings/buffer.ts';
|
|
54
|
+
import { shouldInjectTmux } from './worktree/auto-tmux.ts';
|
|
55
|
+
import { listWorktrees } from './worktree/git-list.ts';
|
|
56
|
+
import { applyWorktreeIntercept } from './worktree/intercept.ts';
|
|
57
|
+
|
|
58
|
+
const argv = readArgv();
|
|
59
|
+
|
|
60
|
+
// Non-fatal warnings accumulate here; we flush after claude exits so the
|
|
61
|
+
// user actually sees them. Terminal errors (the `exit(2)` / `exit(127)`
|
|
62
|
+
// paths below) bypass the buffer and write straight to stderr — they're
|
|
63
|
+
// the reason we're not launching, not background noise. Design: §27.
|
|
64
|
+
const warnings = createWarningBuffer();
|
|
65
|
+
|
|
66
|
+
// Internal test hook: dump raw argv before any other work. Lets e2e tests
|
|
67
|
+
// verify the preflight + intake chain preserves `--` without spawning anything.
|
|
68
|
+
if (process.env.FNC_INTERNAL_DUMP_ARGV === '1') {
|
|
69
|
+
process.stdout.write(`${JSON.stringify(argv)}\n`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (wantsHelp(argv)) {
|
|
74
|
+
process.stdout.write(helpText);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (wantsVersion(argv)) {
|
|
79
|
+
const version = await getVersion();
|
|
80
|
+
process.stdout.write(`fnc ${version}\n`);
|
|
81
|
+
process.exit(0);
|
|
109
82
|
}
|
|
110
83
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
* loadRepoSettings / loadHostAliases) is skipped and the supplied value
|
|
115
|
-
* is used directly. Tests build a hermetic config payload up-front; in
|
|
116
|
-
* production every field is omitted and the loaders run for real.
|
|
117
|
-
*
|
|
118
|
-
* These were previously expressed as `loadConfig: typeof loadConfig`
|
|
119
|
-
* function seams in the unified `RunDeps`. The 1:1 thin-wrapper pattern
|
|
120
|
-
* was double-injection — in production they have zero variance, and in
|
|
121
|
-
* tests they were always loader stubs that returned a fixed payload.
|
|
122
|
-
* Storing the payload directly removes a layer of function plumbing.
|
|
123
|
-
*/
|
|
124
|
-
export interface RunConfig {
|
|
125
|
-
/** Pre-loaded config. Omit to call `loadConfig()`. */
|
|
126
|
-
config?: Config;
|
|
127
|
-
/** Pre-loaded prompts. Omit to call `loadPrompts()`. */
|
|
128
|
-
prompts?: PromptSet;
|
|
129
|
-
/** Pre-loaded repo settings. Omit to call `loadRepoSettings(home, cwd)`. */
|
|
130
|
-
repoSettings?: RepoSettings;
|
|
131
|
-
/** Pre-loaded host aliases. Omit to call `loadHostAliases(home)`. */
|
|
132
|
-
hostAliases?: Record<string, string>;
|
|
84
|
+
if (isMcpSubcommand(argv)) {
|
|
85
|
+
const exitCode = await runMcpServer(parseMcpFlags(argv.slice(1)));
|
|
86
|
+
process.exit(exitCode);
|
|
133
87
|
}
|
|
134
88
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
export interface RunDeps {
|
|
142
|
-
/** Process-shaped seams (streams, env, external behaviours). */
|
|
143
|
-
io?: RunIO;
|
|
144
|
-
/** Pre-loaded data payloads (skips the corresponding loaders). */
|
|
145
|
-
data?: RunConfig;
|
|
89
|
+
// Parse argv into structured launcher inputs. Magic positionals, fnclaude-eaten
|
|
90
|
+
// flags, subcommands, and the passthrough split happen here.
|
|
91
|
+
const parsed = parseArgs(argv);
|
|
92
|
+
if (!parsed.ok) {
|
|
93
|
+
process.stderr.write(`${parsed.error}\n`);
|
|
94
|
+
process.exit(2);
|
|
146
95
|
}
|
|
147
96
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
97
|
+
// Load fnclaude config (auto.tmux + other settings the launcher consults).
|
|
98
|
+
const HOME = homedir();
|
|
99
|
+
const shellCwd = process.cwd();
|
|
100
|
+
const configBase = process.env.XDG_CONFIG_HOME ?? join(HOME, '.config');
|
|
101
|
+
const config = await loadConfig({ path: join(configBase, 'fnclaude', 'config.toml') });
|
|
102
|
+
|
|
103
|
+
// Load settings before resolution. Resolution-time settings only need user +
|
|
104
|
+
// managed tiers (project/local require knowing projectRoot, which only matters
|
|
105
|
+
// after launch). The managed-settings path is Linux-only for now; macOS &
|
|
106
|
+
// Windows resolution to come.
|
|
107
|
+
const settings = loadRepoSettings({
|
|
108
|
+
userPath: join(HOME, '.claude', 'settings.json'),
|
|
109
|
+
projectPath: join(shellCwd, '.claude', 'settings.json'),
|
|
110
|
+
localPath: join(shellCwd, '.claude', 'settings.local.json'),
|
|
111
|
+
managedPath: '/etc/claude-code/managed-settings.json',
|
|
112
|
+
});
|
|
113
|
+
const hostAliases = loadHostAliases({
|
|
114
|
+
systemPath: '/usr/share/fnrhombus/host-aliases.json',
|
|
115
|
+
userPath: join(HOME, '.local', 'share', 'fnrhombus', 'host-aliases.json'),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Resolve the first positional (path or repo ref) to a launch cwd. The
|
|
119
|
+
// resolver handles path short-circuit (/, ~, ~/) AND repo-ref refs whose owner
|
|
120
|
+
// is already known (URL forms, owner/name, name@owner, gh:owner/name). Bare
|
|
121
|
+
// names and clone execution route through the gh-CLI branches below; ambiguous
|
|
122
|
+
// matches surface a clean error.
|
|
123
|
+
const resolved = resolveInput({
|
|
124
|
+
input: parsed.firstPath,
|
|
125
|
+
shellCwd,
|
|
126
|
+
home: HOME,
|
|
127
|
+
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
128
|
+
settings: { cloneTemplate: settings.cloneTemplate, hostAliases },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let cwd: string;
|
|
132
|
+
let usedNoopFallback = false;
|
|
133
|
+
let workspaceFromRef = '';
|
|
134
|
+
switch (resolved.kind) {
|
|
135
|
+
case 'launch':
|
|
136
|
+
cwd = resolved.launchCwd;
|
|
137
|
+
usedNoopFallback = resolved.usedNoopFallback;
|
|
138
|
+
workspaceFromRef = resolved.workspace;
|
|
139
|
+
if (usedNoopFallback) {
|
|
140
|
+
await mkdir(cwd, { recursive: true });
|
|
141
|
+
// Seed handoff.template.md on first noop-fallback launches (design.md
|
|
142
|
+
// §19). Resolves the template source from <exe-dir> sibling layouts
|
|
143
|
+
// and only copies if dest is missing — never clobbers an existing
|
|
144
|
+
// hand-edited template. Failures here don't block the launch.
|
|
145
|
+
const binPathForSeed = process.argv[1] ?? '';
|
|
146
|
+
const exeDirForSeed = binPathForSeed !== '' ? dirname(realpathSync(binPathForSeed)) : process.cwd();
|
|
147
|
+
const tmplSource = resolveTemplateSourcePath({
|
|
148
|
+
envOverride: process.env.FNC_NOOP_TEMPLATE_PATH,
|
|
149
|
+
exeDir: exeDirForSeed,
|
|
150
|
+
});
|
|
151
|
+
await seedNoopDir({ noopDir: cwd, templateSourcePath: tmplSource.path });
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case 'needs-clone': {
|
|
155
|
+
// Repo ref resolved cleanly but the destination doesn't exist on disk.
|
|
156
|
+
// Clone it, then launch in the new directory.
|
|
157
|
+
process.stderr.write(`fnclaude: cloning ${resolved.url} → ${resolved.destination}\n`);
|
|
158
|
+
const cloneR = await cloneRepo({
|
|
159
|
+
url: resolved.url,
|
|
160
|
+
destination: resolved.destination,
|
|
161
|
+
ghClone: runGhClone,
|
|
162
|
+
mkdirp: async (path) => {
|
|
163
|
+
await mkdir(path, { recursive: true });
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
if (!cloneR.ok) {
|
|
167
|
+
process.stderr.write(`fnclaude: ${cloneR.error}\n`);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
cwd = resolved.destination;
|
|
171
|
+
workspaceFromRef = resolved.workspace;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'needs-owner-lookup': {
|
|
175
|
+
// Bare-name ref — ask gh which org owns a repo by this name, then
|
|
176
|
+
// re-route through the resolver as if owner had been on the input.
|
|
177
|
+
const ownerR = await findOwner({ name: resolved.name, ghApi: runGhApi });
|
|
178
|
+
if (!ownerR.ok) {
|
|
179
|
+
if (ownerR.reason === 'gh-failed') {
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`fnclaude: bare name "${resolved.name}" — gh CLI lookup failed (not authenticated? no network?). Try \`gh auth login\` or pass owner explicitly (\`${resolved.name}@<owner>\` or \`<owner>/${resolved.name}\`).\n`,
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
process.stderr.write(
|
|
185
|
+
`fnclaude: no repo named "${resolved.name}" found under your gh user or any of your orgs.\n`,
|
|
186
|
+
);
|
|
175
187
|
}
|
|
176
|
-
|
|
177
|
-
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
// Build a synthetic ref for the resolved owner and recompute destination.
|
|
191
|
+
const syntheticRef = {
|
|
192
|
+
host: '',
|
|
193
|
+
owner: ownerR.owner,
|
|
194
|
+
name: resolved.name,
|
|
195
|
+
workspace: resolved.workspace,
|
|
196
|
+
original: resolved.name,
|
|
197
|
+
};
|
|
198
|
+
if (settings.cloneTemplate === '') {
|
|
199
|
+
process.stderr.write(
|
|
200
|
+
`fnclaude: cloneTemplate is not configured in repoSettings; cannot resolve bare-name refs.\n`,
|
|
201
|
+
);
|
|
202
|
+
process.exit(2);
|
|
203
|
+
}
|
|
204
|
+
const destR = computeCloneDestination({
|
|
205
|
+
ref: syntheticRef,
|
|
206
|
+
template: settings.cloneTemplate,
|
|
207
|
+
hostAliases,
|
|
208
|
+
home: HOME,
|
|
209
|
+
});
|
|
210
|
+
if (!destR.ok) {
|
|
211
|
+
process.stderr.write(`fnclaude: ${destR.error}\n`);
|
|
212
|
+
process.exit(2);
|
|
213
|
+
}
|
|
214
|
+
// If the destination already exists, just launch there. Otherwise clone.
|
|
215
|
+
const { existsSync } = await import('node:fs');
|
|
216
|
+
if (existsSync(destR.path)) {
|
|
217
|
+
cwd = destR.path;
|
|
218
|
+
workspaceFromRef = resolved.workspace;
|
|
219
|
+
break;
|
|
178
220
|
}
|
|
221
|
+
const url = buildCloneUrl(syntheticRef);
|
|
222
|
+
process.stderr.write(`fnclaude: cloning ${url} → ${destR.path}\n`);
|
|
223
|
+
const cloneR = await cloneRepo({
|
|
224
|
+
url,
|
|
225
|
+
destination: destR.path,
|
|
226
|
+
ghClone: runGhClone,
|
|
227
|
+
mkdirp: async (path) => {
|
|
228
|
+
await mkdir(path, { recursive: true });
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
if (!cloneR.ok) {
|
|
232
|
+
process.stderr.write(`fnclaude: ${cloneR.error}\n`);
|
|
233
|
+
process.exit(2);
|
|
234
|
+
}
|
|
235
|
+
cwd = destR.path;
|
|
236
|
+
workspaceFromRef = resolved.workspace;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'ambiguous': {
|
|
240
|
+
const both = resolved.cloneDestination ?? resolved.repoRef ?? '?';
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
`fnclaude: ambiguous reference — could be the local directory ${resolved.path} OR ${both}. Disambiguate by typing './<name>' for the local path.\n`,
|
|
243
|
+
);
|
|
244
|
+
process.exit(2);
|
|
179
245
|
}
|
|
180
|
-
|
|
246
|
+
case 'error':
|
|
247
|
+
process.stderr.write(`fnclaude: ${resolved.error}\n`);
|
|
248
|
+
process.exit(2);
|
|
181
249
|
}
|
|
182
250
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
251
|
+
// Worktree intercept: when -w <name> is set, possibly swap cwd to an
|
|
252
|
+
// existing worktree's path. The intercept also pushes `--worktree`/`--name`
|
|
253
|
+
// into passthrough as appropriate per spec §10.
|
|
254
|
+
//
|
|
255
|
+
// `+workspace` suffix on a repo ref (parsed by resolveInput) feeds into here
|
|
256
|
+
// as if the user had typed `-w <workspace>` — but explicit `-w` always wins.
|
|
257
|
+
const effectiveWorktreeSet = parsed.worktreeSet || workspaceFromRef !== '';
|
|
258
|
+
const effectiveWorktreeArg = parsed.worktreeSet ? parsed.worktreeArg : workspaceFromRef;
|
|
259
|
+
const intercept = applyWorktreeIntercept({
|
|
260
|
+
worktreeSet: effectiveWorktreeSet,
|
|
261
|
+
worktreeArg: effectiveWorktreeArg,
|
|
262
|
+
launchCwd: cwd,
|
|
263
|
+
passthrough: parsed.passthrough,
|
|
264
|
+
listWorktrees,
|
|
265
|
+
});
|
|
266
|
+
for (const w of intercept.warnings) warnings.add(w);
|
|
267
|
+
cwd = intercept.launchCwd;
|
|
268
|
+
const parsedWithIntercept = { ...parsed, passthrough: intercept.passthrough };
|
|
269
|
+
|
|
270
|
+
// Build the final claude argv: prepend magic-captured flags (model/effort/
|
|
271
|
+
// subcommand), then expand any short-flag clusters in the passthrough.
|
|
272
|
+
const withAliases = expandAliases(parsedWithIntercept);
|
|
273
|
+
const shortExpanded = expandShortFlags(withAliases);
|
|
274
|
+
if (!shortExpanded.ok) {
|
|
275
|
+
process.stderr.write(`${shortExpanded.error}\n`);
|
|
276
|
+
process.exit(2);
|
|
196
277
|
}
|
|
278
|
+
let claudeArgs = shortExpanded.tokens;
|
|
197
279
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// No module-global sink — keeps tests hermetic.
|
|
234
|
-
const warnings: string[] = [];
|
|
235
|
-
let flushed = false;
|
|
236
|
-
const flushOnce = (): void => {
|
|
237
|
-
if (flushed) return;
|
|
238
|
-
flushed = true;
|
|
239
|
-
flushWarnings(warnings, stderr);
|
|
280
|
+
// Auto-tmux: if config has auto.tmux = "worktree" AND this is a brand-new
|
|
281
|
+
// worktree (worktreeSet + no match) AND user didn't opt out, inject --tmux.
|
|
282
|
+
if (
|
|
283
|
+
shouldInjectTmux({
|
|
284
|
+
configAutoTmux: config.autoTmux,
|
|
285
|
+
worktreeSet: parsed.worktreeSet,
|
|
286
|
+
worktreeMatched: intercept.worktreeMatched,
|
|
287
|
+
noTmux: parsed.noTmux,
|
|
288
|
+
passthrough: claudeArgs,
|
|
289
|
+
})
|
|
290
|
+
) {
|
|
291
|
+
claudeArgs = [...claudeArgs, '--tmux'];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Auto-name: when the user has typed a prompt body via `--` and hasn't given
|
|
295
|
+
// --name / -n (and the session isn't print/resume/continue/from-pr), generate
|
|
296
|
+
// a session name. Spec defaults: 15s timeout, heuristic fallback on error/
|
|
297
|
+
// timeout. When ANTHROPIC_API_KEY is set we hit the API via the SDK directly
|
|
298
|
+
// (saves a claude cold-start); otherwise we shell out to `claude -p`.
|
|
299
|
+
//
|
|
300
|
+
// FNC_INTERNAL_DISABLE_AUTONAME=1 is an internal test escape — when set,
|
|
301
|
+
// autoName is skipped entirely so e2e tests don't have to wait on a real
|
|
302
|
+
// claude -p call (and don't see --name pollute their assertion shapes).
|
|
303
|
+
if (process.env.FNC_INTERNAL_DISABLE_AUTONAME !== '1' && shouldAutoName(parsedWithIntercept)) {
|
|
304
|
+
const sentinelIdx = findPromptSentinel(parsedWithIntercept.passthrough);
|
|
305
|
+
const body = promptBody(parsedWithIntercept.passthrough, sentinelIdx).join(' ').trim();
|
|
306
|
+
const claudePLlmCall = async (prompt: string): Promise<string> => {
|
|
307
|
+
const proc = Bun.spawn(
|
|
308
|
+
['claude', '-p', '--model', AUTO_NAME_MODEL, `${AUTO_NAME_SYSTEM_PROMPT}\n\nUser request: ${prompt}`],
|
|
309
|
+
{ stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
310
|
+
);
|
|
311
|
+
const out = await new Response(proc.stdout).text();
|
|
312
|
+
const exit = await proc.exited;
|
|
313
|
+
if (exit !== 0) throw new Error(`claude -p exited ${exit}`);
|
|
314
|
+
return out;
|
|
240
315
|
};
|
|
316
|
+
const llmCall = process.env.ANTHROPIC_API_KEY !== undefined ? sdkLlmCall : claudePLlmCall;
|
|
317
|
+
const generated = await autoName({ prompt: body, llmCall, timeoutMs: 15_000 });
|
|
318
|
+
const san = sanitizeForPath(generated);
|
|
319
|
+
const final = san.kind === 'invalid' ? generated : san.value;
|
|
320
|
+
claudeArgs = [...claudeArgs, '--name', final];
|
|
321
|
+
}
|
|
241
322
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
323
|
+
// Inject prompt fragments via --append-system-prompt. Selection depends on
|
|
324
|
+
// noop fallback + interactive (non-print) state of the session.
|
|
325
|
+
const fragmentNames = selectFragments({ usedNoopFallback, passthrough: claudeArgs });
|
|
326
|
+
if (fragmentNames.length > 0) {
|
|
327
|
+
// process.argv[1] is the BIN script (bin/fnc.js after preflight, or whatever
|
|
328
|
+
// node invoked). Realpath it so symlinked installs (npm's .bin/ → package
|
|
329
|
+
// bin/) resolve to the actual layout. The "prompts" directory candidates
|
|
330
|
+
// (../prompts, ../share/...) are sibling-relative to that resolved bin.
|
|
331
|
+
const binPath = process.argv[1] ?? '';
|
|
332
|
+
const exeDir = binPath !== '' ? dirname(realpathSync(binPath)) : process.cwd();
|
|
333
|
+
const promptsDir = resolvePromptsDir({
|
|
334
|
+
envOverride: process.env.FNC_PROMPTS_DIR,
|
|
335
|
+
exeDir,
|
|
336
|
+
});
|
|
337
|
+
if (promptsDir.dir !== null) {
|
|
338
|
+
const loaded = loadFragments(fragmentNames, promptsDir.dir);
|
|
339
|
+
for (const w of loaded.warnings) warnings.add(w);
|
|
340
|
+
claudeArgs = injectFragments(claudeArgs, loaded.content);
|
|
341
|
+
} else if (promptsDir.warning !== undefined) {
|
|
342
|
+
warnings.add(promptsDir.warning);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
252
345
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
346
|
+
// Compute the MCP socket path. On Unix this also feeds FNC_SOCKET into
|
|
347
|
+
// the child env so the MCP subprocess (which claude spawns per the
|
|
348
|
+
// injected --mcp-config) knows where to dial. On win32, AF_UNIX over
|
|
349
|
+
// Bun.listen({ unix }) isn't supported yet — skip the socket entirely
|
|
350
|
+
// so the launcher still works without self-MCP.
|
|
351
|
+
let mcpSocketPath: string | undefined;
|
|
352
|
+
let mcpListenerStop: (() => Promise<void>) | undefined;
|
|
353
|
+
if (process.platform !== 'win32') {
|
|
354
|
+
mcpSocketPath = computeSocketPath({
|
|
355
|
+
env: process.env,
|
|
356
|
+
pid: process.pid,
|
|
357
|
+
platform: process.platform,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
266
360
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
361
|
+
// Compose the child env: process.env → [exec.env] from config → FNCLAUDE_HANDOFF
|
|
362
|
+
// → FNC_SOCKET. Later entries win against same-name earlier entries per
|
|
363
|
+
// design.md §5.
|
|
364
|
+
const childEnv = composeEnv({
|
|
365
|
+
processEnv: process.env,
|
|
366
|
+
execEnv: config.execEnv,
|
|
367
|
+
handoff: config.autoHandoff,
|
|
368
|
+
socket: mcpSocketPath,
|
|
369
|
+
});
|
|
275
370
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
371
|
+
// Self-MCP --mcp-config injection (§7.4). Skipped when there's no socket
|
|
372
|
+
// to dial back to (win32 — no listener), and gated to interactive
|
|
373
|
+
// sessions per design.md §29. The fnc bin is realpath'd so symlinked
|
|
374
|
+
// installs (npm's .bin/) resolve to the actual layout; process.execPath
|
|
375
|
+
// is the bun runtime that will exec the subprocess script. Decision: bun
|
|
376
|
+
// + script-path is a two-element shape because fnc.js is a bun script,
|
|
377
|
+
// not a self-contained binary — see decisions.md 2026-05-27 entry.
|
|
378
|
+
if (mcpSocketPath !== undefined) {
|
|
379
|
+
const binPathForMcp = process.argv[1] ?? '';
|
|
380
|
+
const fncBin = binPathForMcp !== '' ? realpathSync(binPathForMcp) : '';
|
|
381
|
+
claudeArgs = injectMcpConfig({
|
|
382
|
+
claudeArgs,
|
|
383
|
+
bunExec: process.execPath,
|
|
384
|
+
fncBin,
|
|
385
|
+
noop: usedNoopFallback,
|
|
386
|
+
interactive: isInteractiveSession(claudeArgs),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
284
389
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
390
|
+
// Internal test hook: dump the launch plan as JSON and exit 0 BEFORE spawning
|
|
391
|
+
// claude. Lets e2e tests verify the full pipeline composition (cwd + final
|
|
392
|
+
// claude args) without needing a real claude on PATH or a fake-claude harness.
|
|
393
|
+
if (process.env.FNC_INTERNAL_DUMP_PLAN === '1') {
|
|
394
|
+
// Dump only env values fnclaude actively manages (handoff/socket + execEnv
|
|
395
|
+
// keys) to keep the dump small and predictable in tests. The full process
|
|
396
|
+
// env would leak shell state into snapshots.
|
|
397
|
+
const dumpEnv: Record<string, string> = {};
|
|
398
|
+
if (config.execEnv !== undefined) {
|
|
399
|
+
for (const k of Object.keys(config.execEnv)) {
|
|
400
|
+
if (k in childEnv) dumpEnv[k] = childEnv[k]!;
|
|
293
401
|
}
|
|
402
|
+
}
|
|
403
|
+
if ('FNCLAUDE_HANDOFF' in childEnv) dumpEnv.FNCLAUDE_HANDOFF = childEnv.FNCLAUDE_HANDOFF!;
|
|
404
|
+
if ('FNC_SOCKET' in childEnv) dumpEnv.FNC_SOCKET = childEnv.FNC_SOCKET!;
|
|
405
|
+
process.stdout.write(
|
|
406
|
+
`${JSON.stringify({ cwd, claudeArgs, usedNoopFallback, env: dumpEnv })}\n`,
|
|
407
|
+
);
|
|
408
|
+
process.exit(0);
|
|
409
|
+
}
|
|
294
410
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
!parsed.usedNoopFallback &&
|
|
304
|
-
parsed.cwd !== '' &&
|
|
305
|
-
!isAbsolute(parsed.cwd) &&
|
|
306
|
-
!parsed.cwd.startsWith('~')
|
|
307
|
-
) {
|
|
308
|
-
let rs: RepoSettings;
|
|
309
|
-
if (data.repoSettings !== undefined) {
|
|
310
|
-
rs = data.repoSettings;
|
|
311
|
-
} else {
|
|
312
|
-
const loaded = loadRepoSettings(home, shellCWD);
|
|
313
|
-
rs = loaded.settings;
|
|
314
|
-
warnings.push(...loaded.warnings);
|
|
315
|
-
}
|
|
316
|
-
let aliases: Record<string, string>;
|
|
317
|
-
if (data.hostAliases !== undefined) {
|
|
318
|
-
aliases = data.hostAliases;
|
|
319
|
-
} else {
|
|
320
|
-
const loaded = loadHostAliases(home);
|
|
321
|
-
aliases = loaded.aliases;
|
|
322
|
-
warnings.push(...loaded.warnings);
|
|
323
|
-
}
|
|
324
|
-
let result;
|
|
325
|
-
try {
|
|
326
|
-
// Resolver's inner deps (path-exists, gh CLI, clone): use the
|
|
327
|
-
// injected ones in tests, fall back to productionDeps in real
|
|
328
|
-
// runs. Passing `undefined` lets Resolve default-construct
|
|
329
|
-
// productionDeps() itself.
|
|
330
|
-
result = await (io.resolveDeps
|
|
331
|
-
? Resolve(
|
|
332
|
-
{
|
|
333
|
-
input: parsed.cwd,
|
|
334
|
-
cwd: shellCWD,
|
|
335
|
-
home,
|
|
336
|
-
settings: rs,
|
|
337
|
-
hostAliases: aliases,
|
|
338
|
-
},
|
|
339
|
-
io.resolveDeps,
|
|
340
|
-
)
|
|
341
|
-
: Resolve({
|
|
342
|
-
input: parsed.cwd,
|
|
343
|
-
cwd: shellCWD,
|
|
344
|
-
home,
|
|
345
|
-
settings: rs,
|
|
346
|
-
hostAliases: aliases,
|
|
347
|
-
}));
|
|
348
|
-
} catch (err) {
|
|
349
|
-
stderr.write(`${errorMessage(err)}\n`);
|
|
350
|
-
return 1;
|
|
351
|
-
}
|
|
352
|
-
// If the user's reference had a +workspace suffix AND they didn't
|
|
353
|
-
// pass -w explicitly, propagate the workspace to the intercept
|
|
354
|
-
// layer.
|
|
355
|
-
const promoteWorkspace = !!result.workspace && !parsed.worktreeSet;
|
|
356
|
-
resolved = withResolved(parsed, {
|
|
357
|
-
cwd: result.path,
|
|
358
|
-
...(promoteWorkspace
|
|
359
|
-
? { worktreeSet: true, worktreeArg: result.workspace! }
|
|
360
|
-
: {}),
|
|
361
|
-
});
|
|
362
|
-
} else if (parsed.cwd.startsWith('~')) {
|
|
363
|
-
// Tilde-expand absolute-shaped inputs that didn't go through the
|
|
364
|
-
// resolver (resolver expands tildes for its short-circuit path, but
|
|
365
|
-
// it isn't called for tilde-prefixed inputs here).
|
|
366
|
-
resolved = withResolved(parsed, { cwd: expandTildePath(parsed.cwd) });
|
|
367
|
-
} else {
|
|
368
|
-
resolved = brandResolved(parsed);
|
|
369
|
-
}
|
|
411
|
+
// Verify claude is on PATH before doing any spawn-time setup. Failing here
|
|
412
|
+
// gives a far better error than Bun.spawn's bare ENOENT.
|
|
413
|
+
const claudeBin = findClaude({ pathEnv: process.env.PATH ?? '' });
|
|
414
|
+
if (!claudeBin.ok) {
|
|
415
|
+
process.stderr.write(`${claudeBin.error}\n`);
|
|
416
|
+
process.exit(127);
|
|
417
|
+
}
|
|
370
418
|
|
|
371
|
-
|
|
419
|
+
// Bind the MCP listener (Unix only). Must happen BEFORE Bun.spawn so the
|
|
420
|
+
// subprocess claude launches per --mcp-config can dial back over
|
|
421
|
+
// $FNC_SOCKET. Bind failure is fatal per Go canonical — we can't run
|
|
422
|
+
// without it once tools are wired (§8). design.mcp.md §2.1.
|
|
423
|
+
if (mcpSocketPath !== undefined) {
|
|
424
|
+
try {
|
|
425
|
+
// §7.7 + §8.x: wire per-tool dispatch onto each accepted socket.
|
|
426
|
+
// §8.1 (restart), §8.2 (switch), §8.3 (spawn) and §8.4 (clipboard)
|
|
427
|
+
// replace their stubs; nothing remains stubbed in §8.
|
|
372
428
|
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// ── Sanitize any --name / -n value to a path-safe slug. ──────────────
|
|
400
|
-
const sanitizeResult = sanitizeNamesInPassthrough(named.passthrough);
|
|
401
|
-
warnings.push(...sanitizeResult.warnings);
|
|
402
|
-
const sanitized = withPassthroughUpdate(named, {
|
|
403
|
-
passthrough: sanitizeResult.args,
|
|
429
|
+
// Live permission-mode reader binds `cwd` (the launch cwd) at
|
|
430
|
+
// construction so both handlers consume the same
|
|
431
|
+
// `(sessionId) => string | null` shape. Reads claude's
|
|
432
|
+
// `~/.claude/projects/<encoded-cwd>/<sid>.jsonl` for the latest
|
|
433
|
+
// `{type:"permission-mode",...}` record — last-wins, null on miss.
|
|
434
|
+
const livePermissionModeReader = (sessionId: string): string | null =>
|
|
435
|
+
readLivePermissionMode(cwd, sessionId);
|
|
436
|
+
const restartHandler = createRestartHandler({
|
|
437
|
+
origArgs: argv,
|
|
438
|
+
launchCWD: cwd,
|
|
439
|
+
trigger: handoffTrigger,
|
|
440
|
+
livePermissionModeReader,
|
|
441
|
+
});
|
|
442
|
+
const switchHandler = createSwitchHandler({
|
|
443
|
+
origArgs: argv,
|
|
444
|
+
trigger: handoffTrigger,
|
|
445
|
+
livePermissionModeReader,
|
|
446
|
+
});
|
|
447
|
+
const binPathForListener = process.argv[1] ?? '';
|
|
448
|
+
const fncBinAbs = binPathForListener !== '' ? realpathSync(binPathForListener) : '';
|
|
449
|
+
const spawnHandler = createSpawnHandler({
|
|
450
|
+
config: { autoSpawnCommand: config.autoSpawnCommand },
|
|
451
|
+
processEnv: process.env,
|
|
452
|
+
fncBinPath: fncBinAbs,
|
|
453
|
+
handleCopyToClipboard,
|
|
404
454
|
});
|
|
455
|
+
const dispatcher = createParentDispatcher({
|
|
456
|
+
handlers: {
|
|
457
|
+
...stubParentHandlers,
|
|
458
|
+
restart: restartHandler,
|
|
459
|
+
switch: switchHandler,
|
|
460
|
+
spawn: spawnHandler,
|
|
461
|
+
copy_to_clipboard: handleCopyToClipboard,
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
const listener = await startMcpListener({
|
|
465
|
+
socketPath: mcpSocketPath,
|
|
466
|
+
onConnection: dispatcher,
|
|
467
|
+
});
|
|
468
|
+
mcpListenerStop = listener.stop;
|
|
469
|
+
} catch (err) {
|
|
470
|
+
process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
|
|
471
|
+
process.exit(2);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
405
474
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
475
|
+
// Fabricate the cwd tree if missing — Bun.spawn would otherwise return ENOENT
|
|
476
|
+
// blaming the claude binary. The cleanup() unlinks any fabricated dirs right
|
|
477
|
+
// after spawn, since the kernel holds the cwd by inode reference once the
|
|
478
|
+
// child has chdir'd (which posix_spawn does before returning to us).
|
|
479
|
+
const ensured = ensureCwd(cwd);
|
|
480
|
+
if (!ensured.ok) {
|
|
481
|
+
process.stderr.write(`fnclaude: ${ensured.error}\n`);
|
|
482
|
+
process.exit(2);
|
|
483
|
+
}
|
|
415
484
|
|
|
416
|
-
|
|
485
|
+
// §9.0: spawn claude via Bun.Terminal on POSIX so the launcher can tee PTY
|
|
486
|
+
// output through a ring buffer for cross-cwd resume detection (§9.1+). On
|
|
487
|
+
// Windows we fall back to stdio inherit until Bun.Terminal lands on win32.
|
|
488
|
+
// Non-TTY contexts (piped stdin, FNC_INTERNAL_DUMP_PLAN tests) also use the
|
|
489
|
+
// inherit shape — raw-mode forwarding requires a real terminal anyway.
|
|
490
|
+
const useTerminal =
|
|
491
|
+
process.platform !== 'win32' &&
|
|
492
|
+
process.stdin.isTTY === true &&
|
|
493
|
+
process.stdout.isTTY === true;
|
|
417
494
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
495
|
+
// Kernel routes Ctrl-C to the whole foreground pgrp; claude handles its
|
|
496
|
+
// own SIGINT. Swallow it here so fnc survives to read claude's exit code.
|
|
497
|
+
// Under Bun.Terminal the parent isn't in the same pgrp as the child, so
|
|
498
|
+
// these handlers mostly cover the inherit branch — harmless either way.
|
|
499
|
+
process.on('SIGINT', () => {});
|
|
500
|
+
process.on('SIGTERM', () => {});
|
|
423
501
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
};
|
|
502
|
+
// §9.1: capture the tail of PTY output for §9.2's cross-cwd detection.
|
|
503
|
+
// Hoisted here so it stays reachable after `proc.exited` resolves below.
|
|
504
|
+
// Only meaningful on the useTerminal branch; under stdio inherit the
|
|
505
|
+
// buffer stays empty and post-exit consumers will simply see no match.
|
|
506
|
+
const ringBuffer = new RingBuffer();
|
|
430
507
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
508
|
+
let exitCode: number;
|
|
509
|
+
try {
|
|
510
|
+
let proc: Bun.Subprocess;
|
|
511
|
+
if (useTerminal) {
|
|
512
|
+
// Tee PTY output → process.stdout AND the ring buffer. §9.3 consumes
|
|
513
|
+
// the buffer after exit to scan for claude's cross-cwd resume hint.
|
|
514
|
+
const term = new Bun.Terminal({
|
|
515
|
+
cols: process.stdout.columns ?? 80,
|
|
516
|
+
rows: process.stdout.rows ?? 24,
|
|
517
|
+
data: (_t, chunk) => {
|
|
518
|
+
process.stdout.write(chunk);
|
|
519
|
+
ringBuffer.push(chunk);
|
|
520
|
+
},
|
|
436
521
|
});
|
|
437
522
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
relaunchHandoff(handoffArgv);
|
|
444
|
-
// If we get here, execve failed; fall through to cross-cwd detection
|
|
445
|
-
// (won't match in practice) and return claude's exit code.
|
|
446
|
-
}
|
|
523
|
+
proc = Bun.spawn([claudeBin.path, ...claudeArgs], {
|
|
524
|
+
cwd,
|
|
525
|
+
env: childEnv,
|
|
526
|
+
terminal: term,
|
|
527
|
+
});
|
|
447
528
|
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
529
|
+
// Forward user stdin → PTY. Raw mode so the shell line discipline
|
|
530
|
+
// doesn't eat control sequences (Ctrl-C, arrow keys, etc.) before
|
|
531
|
+
// claude sees them. bun#25779 (control bytes not delivering signals
|
|
532
|
+
// through Bun.Terminal.write) was fixed before 1.3.14 — verified
|
|
533
|
+
// empirically on this version; no byte-interception workaround
|
|
534
|
+
// needed.
|
|
535
|
+
process.stdin.setRawMode(true);
|
|
536
|
+
process.stdin.on('data', (chunk: Buffer) => {
|
|
537
|
+
term.write(chunk);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// SIGWINCH no longer reaches claude directly (the PTY is owned by the
|
|
541
|
+
// launcher), so plumb terminal resizes through manually.
|
|
542
|
+
process.stdout.on('resize', () => {
|
|
543
|
+
term.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24);
|
|
544
|
+
});
|
|
545
|
+
} else {
|
|
546
|
+
proc = Bun.spawn([claudeBin.path, ...claudeArgs], {
|
|
547
|
+
cwd,
|
|
548
|
+
env: childEnv,
|
|
549
|
+
stdin: 'inherit',
|
|
550
|
+
stdout: 'inherit',
|
|
551
|
+
stderr: 'inherit',
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
ensured.cleanup();
|
|
457
556
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
557
|
+
// §8.5: arm the kill-and-exec awaiter as a side-promise. If an MCP
|
|
558
|
+
// tool dispatches a handoff during the session, the awaiter wakes,
|
|
559
|
+
// SIGTERMs claude (escalating to SIGKILL after 200 ms if needed),
|
|
560
|
+
// waits for proc.exited, then re-execs fnclaude with the stashed
|
|
561
|
+
// argv via Bun.spawn (no native execve binding — see decisions.md).
|
|
562
|
+
// If no handoff fires, this promise sits idle until process exit
|
|
563
|
+
// and gets GC'd; the orphaned-promise pattern matches Go canonical's
|
|
564
|
+
// background goroutine. design.mcp.md §6.
|
|
565
|
+
void startHandoffAwaiter({
|
|
566
|
+
trigger: handoffTrigger,
|
|
567
|
+
proc,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
exitCode = await proc.exited;
|
|
571
|
+
} finally {
|
|
572
|
+
// Stop the MCP listener + unlink the socket file even if spawn or
|
|
573
|
+
// proc.exited throws. design.mcp.md §7 — socket file cleanup is the
|
|
574
|
+
// parent's job.
|
|
575
|
+
if (mcpListenerStop !== undefined) {
|
|
576
|
+
await mcpListenerStop();
|
|
461
577
|
}
|
|
462
578
|
}
|
|
463
579
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
580
|
+
if (useTerminal) {
|
|
581
|
+
// Restore the terminal so the user's shell prompt comes back in cooked
|
|
582
|
+
// mode. setRawMode is a no-op when stdin isn't a TTY; we only entered
|
|
583
|
+
// raw mode in the useTerminal branch, so this is safe to call.
|
|
584
|
+
process.stdin.setRawMode(false);
|
|
585
|
+
// Stop reading stdin so process.exit doesn't block on an open listener.
|
|
586
|
+
process.stdin.pause();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// §9.3: cross-cwd silent relaunch. After a clean exit, scan the ring
|
|
590
|
+
// buffer for claude's "To resume, run: cd X && claude --resume UUID"
|
|
591
|
+
// hint and silently re-exec fnclaude in the new cwd. Gated to skip
|
|
592
|
+
// when an MCP handoff has already stashed argv (that path owns the
|
|
593
|
+
// relaunch) and when claude exited non-zero (don't relaunch on a
|
|
594
|
+
// crash). On Windows the ring buffer stays empty (inherit branch), so
|
|
595
|
+
// the decision always returns false there — keeps the call shape
|
|
596
|
+
// platform-uniform without a separate guard.
|
|
597
|
+
const crossCwdDecision = decideCrossCwdRelaunch({
|
|
598
|
+
exitCode,
|
|
599
|
+
alreadyStashed: handoffTrigger.getStashedArgv() !== null,
|
|
600
|
+
ringSnapshot: ringBuffer.snapshot(),
|
|
601
|
+
origArgs: argv,
|
|
602
|
+
});
|
|
603
|
+
if (crossCwdDecision.relaunch) {
|
|
604
|
+
// Stash so future getStashedArgv() callers see this relaunch as
|
|
605
|
+
// owned. The cross-cwd path is "silent" — we skip the warnings
|
|
606
|
+
// flush below; the new fnclaude process re-evaluates and re-queues
|
|
607
|
+
// anything still applicable.
|
|
608
|
+
handoffTrigger.stashArgv(crossCwdDecision.argv);
|
|
609
|
+
await reexecSelf({ argv: crossCwdDecision.argv });
|
|
610
|
+
// Unreachable: reexecSelf calls process.exit. Typed as Promise<never>.
|
|
477
611
|
}
|
|
612
|
+
|
|
613
|
+
// Flush accumulated warnings to stderr now that claude has exited and the
|
|
614
|
+
// user is back at their shell prompt where they have time to read them.
|
|
615
|
+
// Silent-relaunch paths (cross-cwd resume above; MCP handoff via the
|
|
616
|
+
// awaiter side-promise) skip this flush — the new fnclaude process
|
|
617
|
+
// re-evaluates and re-queues any still-applicable warnings.
|
|
618
|
+
warnings.flush(process.stderr);
|
|
619
|
+
|
|
620
|
+
process.exit(exitCode);
|