@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --help / --version short-circuits. Pure detection functions plus the
|
|
3
|
+
* help text and lazy version reader.
|
|
4
|
+
*
|
|
5
|
+
* Both detectors scan argv left-to-right and stop at the first literal
|
|
6
|
+
* `--` — anything after the sentinel is prompt body, not fnclaude flags
|
|
7
|
+
* (matches Go canonical's wantsHelp/wantsVersion at src/main.go:463-481,
|
|
8
|
+
* 569-585).
|
|
9
|
+
*
|
|
10
|
+
* `-v` is a fnclaude-claimed short flag — the user reaches claude's own
|
|
11
|
+
* --version via `claude --version` directly. This is the ONLY lowercase
|
|
12
|
+
* short flag fnclaude reserves; everything else is uppercase-only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SENTINEL = '--';
|
|
16
|
+
|
|
17
|
+
export function wantsHelp(args: readonly string[]): boolean {
|
|
18
|
+
for (const tok of args) {
|
|
19
|
+
if (tok === SENTINEL) return false;
|
|
20
|
+
if (tok === '-h' || tok === '--help') return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function wantsVersion(args: readonly string[]): boolean {
|
|
26
|
+
for (const tok of args) {
|
|
27
|
+
if (tok === SENTINEL) return false;
|
|
28
|
+
if (tok === '-v' || tok === '--version') return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const helpText = `fnclaude — claude CLI launcher with quality-of-life features
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
fnc [MODEL] [EFFORT] [SUBCOMMAND] [CWD [WORKTREE]] [FLAGS...] [-- PROMPT]
|
|
37
|
+
|
|
38
|
+
Magic positional words (order-independent for SUBCOMMAND; MODEL/EFFORT scanned
|
|
39
|
+
left-to-right at the head of argv before any flags):
|
|
40
|
+
Model alias: opus | sonnet | haiku → --model <alias>
|
|
41
|
+
Effort level: low | medium | high | xhigh | max | auto → --effort <level>
|
|
42
|
+
(effort alone at position 1 implies opus)
|
|
43
|
+
Subcommand: resume | res → --resume
|
|
44
|
+
continue | con → --continue
|
|
45
|
+
fork | fk → --resume --fork-session
|
|
46
|
+
To use a directory literally named one of these, prefix with ./
|
|
47
|
+
|
|
48
|
+
Positional paths (max 2 after magic/subcommand tokens):
|
|
49
|
+
1st remaining → cwd to launch claude in
|
|
50
|
+
Accepts: absolute path, ~-prefixed, ./-prefixed, bare repo
|
|
51
|
+
name (gh-resolved), name@owner, owner/name, gh:owner/name,
|
|
52
|
+
HTTPS URL, SSH URL. Missing repos are cloned per the
|
|
53
|
+
cloneTemplate in repoSettings.
|
|
54
|
+
(fallback when no path: $XDG_CONFIG_HOME/fnclaude/noop)
|
|
55
|
+
2nd remaining → worktree name (same semantics as -w <name>)
|
|
56
|
+
3rd+ remaining → error. Use -A/--also for extra dirs.
|
|
57
|
+
|
|
58
|
+
Reserved subcommands:
|
|
59
|
+
mcp [--noop] — internal MCP server (invoked automatically by claude via
|
|
60
|
+
injected --mcp-config; not for direct use)
|
|
61
|
+
To use a directory literally named mcp, prefix with ./
|
|
62
|
+
|
|
63
|
+
fnclaude-owned flags (consumed by the launcher, NOT forwarded to claude):
|
|
64
|
+
-A, --also <dir> additional extra-dir (repeatable; deferred — see PRD)
|
|
65
|
+
--no-tmux suppress auto-tmux injection for this invocation
|
|
66
|
+
-w, --worktree <name> worktree intercept (matches existing → swap cwd;
|
|
67
|
+
no match → forwarded as new-worktree request)
|
|
68
|
+
-h, --help show this help and exit
|
|
69
|
+
-v, --version print fnclaude's version and exit
|
|
70
|
+
(shadows claude's -v; use \`claude --version\` directly)
|
|
71
|
+
|
|
72
|
+
Capital-letter shortcuts (translate to claude long-form flags):
|
|
73
|
+
-B → --brief -M → --permission-mode <mode>
|
|
74
|
+
-C → --chrome -P → --from-pr [value]
|
|
75
|
+
-D → --dangerously-skip-permissions -R → --remote-control [name]
|
|
76
|
+
-F → --fork-session -T → --tmux [classic]
|
|
77
|
+
-G → --agent <agent> -V → --verbose
|
|
78
|
+
-I → --ide -W → --allowedTools <tools>
|
|
79
|
+
|
|
80
|
+
All other claude flags pass through verbatim — run \`claude --help\` for the
|
|
81
|
+
full reference. POSIX collapsing is supported (-BVC = -B -V -C); only the
|
|
82
|
+
last flag in a collapsed group may take a value. shortRequired flags
|
|
83
|
+
(-G/-M/-W) must be the final character of a cluster, not in the middle.
|
|
84
|
+
|
|
85
|
+
Cross-cwd resume: when claude shows the resume picker and you select a
|
|
86
|
+
session from a different cwd, fnclaude transparently re-launches in that
|
|
87
|
+
cwd. All flags from the original invocation are preserved.
|
|
88
|
+
|
|
89
|
+
Worktree intercept: -w <name> (or a 2nd positional) matching an existing
|
|
90
|
+
worktree of the project repo swaps fnclaude's cwd to that worktree.
|
|
91
|
+
Non-matching names pass through as a new-worktree request. --name is
|
|
92
|
+
always set, whether entering or creating.
|
|
93
|
+
|
|
94
|
+
Auto-name: when --, a prompt, and no --name/-n are all present, fnclaude
|
|
95
|
+
generates a 1-3 word session label via Haiku. With ANTHROPIC_API_KEY set,
|
|
96
|
+
the call goes through the Anthropic SDK directly (fast-path, no claude
|
|
97
|
+
spawn). Without it, fnclaude shells out to \`claude -p\` which uses your
|
|
98
|
+
subscription auth. Falls back silently to a heuristic on failure / timeout.
|
|
99
|
+
|
|
100
|
+
Auto-tmux: with \`[auto] tmux = "worktree"\` in config, fnclaude injects
|
|
101
|
+
--tmux whenever you create a new worktree (-w <name> with no match).
|
|
102
|
+
Pass --no-tmux to skip this for a single invocation without editing config.
|
|
103
|
+
|
|
104
|
+
Environment variables (override config; precedence: CLI > env > config):
|
|
105
|
+
ANTHROPIC_API_KEY direct-API auth for auto-name (else shells \`claude -p\`)
|
|
106
|
+
XDG_CONFIG_HOME config dir base (default: ~/.config)
|
|
107
|
+
FNC_PROMPTS_DIR override install-dir prompts location
|
|
108
|
+
(default: <exe-dir>/prompts, <exe-dir>/../prompts,
|
|
109
|
+
or <exe-dir>/../share/fnclaude/prompts)
|
|
110
|
+
FNC_NOOP_TEMPLATE_PATH
|
|
111
|
+
override handoff.template.md source path used when
|
|
112
|
+
seeding the noop fallback directory on first launch
|
|
113
|
+
|
|
114
|
+
Config file: $XDG_CONFIG_HOME/fnclaude/config.toml
|
|
115
|
+
[name] model = "claude-haiku-4-5", timeout = "3s"
|
|
116
|
+
[auto] tmux = "never" | "worktree"
|
|
117
|
+
handoff = "never" | "ask" | <N seconds>
|
|
118
|
+
spawn_command = "..." # for opening new terminal windows
|
|
119
|
+
[exec.env] NAME = "value" # injected into every claude child env
|
|
120
|
+
|
|
121
|
+
Repo settings (~/.claude/settings.json):
|
|
122
|
+
cloneTemplate / worktreeTemplate / branchTemplate — shared with the
|
|
123
|
+
claude-code-worktree-paths plugin. Layered with project + local + managed
|
|
124
|
+
tiers in standard claude-settings precedence.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
fnc # noop session in ~/.config/fnclaude/noop
|
|
128
|
+
fnc opus max ~/src/proj # opus + max effort in ~/src/proj
|
|
129
|
+
fnc ~/src/proj feature # cwd + worktree name (same as -w feature)
|
|
130
|
+
fnc sonnet ~/src/proj -- "fix the bug"
|
|
131
|
+
# auto-name from prompt, sonnet model
|
|
132
|
+
fnc resume ~/src/proj # session picker for ~/src/proj
|
|
133
|
+
fnc fnclaude@fnrhombus # owner-qualified repo ref (auto-cloned)
|
|
134
|
+
fnc -BVC # --brief --verbose --chrome
|
|
135
|
+
|
|
136
|
+
For more, see https://github.com/fnrhombus/fnclaude
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
let cachedVersion: string | null = null;
|
|
140
|
+
|
|
141
|
+
export async function getVersion(): Promise<string> {
|
|
142
|
+
if (cachedVersion !== null) return cachedVersion;
|
|
143
|
+
try {
|
|
144
|
+
const pkgUrl = new URL('../package.json', import.meta.url);
|
|
145
|
+
const pkg = (await Bun.file(pkgUrl).json()) as { version?: unknown };
|
|
146
|
+
cachedVersion = typeof pkg.version === 'string' ? pkg.version : '0.0.0-dev';
|
|
147
|
+
} catch {
|
|
148
|
+
cachedVersion = '0.0.0-dev';
|
|
149
|
+
}
|
|
150
|
+
return cachedVersion;
|
|
151
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the env passed to claude's child process.
|
|
3
|
+
*
|
|
4
|
+
* Order (each step wins over earlier ones):
|
|
5
|
+
* 1. processEnv — the launcher's inherited env, filtered to defined values.
|
|
6
|
+
* 2. execEnv — `[exec.env]` from fnclaude's config.toml.
|
|
7
|
+
* 3. handoff — sets FNCLAUDE_HANDOFF if defined; per design.md §5
|
|
8
|
+
* this is the resolved auto.handoff value.
|
|
9
|
+
* 4. socket — sets FNC_SOCKET if defined; the AF_UNIX path the
|
|
10
|
+
* MCP subprocess dials for tool calls.
|
|
11
|
+
*
|
|
12
|
+
* Per design.md §5: "These are appended AFTER os.Environ() + envFromConfig,
|
|
13
|
+
* so they win against any same-name entries from user config."
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ComposeEnvArgs {
|
|
17
|
+
processEnv: Record<string, string | undefined>;
|
|
18
|
+
execEnv: Record<string, string> | undefined;
|
|
19
|
+
handoff: string | undefined;
|
|
20
|
+
socket: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function composeEnv(args: ComposeEnvArgs): Record<string, string> {
|
|
24
|
+
const out: Record<string, string> = {};
|
|
25
|
+
for (const [k, v] of Object.entries(args.processEnv)) {
|
|
26
|
+
if (v !== undefined) out[k] = v;
|
|
27
|
+
}
|
|
28
|
+
if (args.execEnv !== undefined) {
|
|
29
|
+
for (const [k, v] of Object.entries(args.execEnv)) out[k] = v;
|
|
30
|
+
}
|
|
31
|
+
if (args.handoff !== undefined) out.FNCLAUDE_HANDOFF = args.handoff;
|
|
32
|
+
if (args.socket !== undefined) out.FNC_SOCKET = args.socket;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse claude's "To resume, run: cd <dir> && claude --resume <uuid>" hint
|
|
3
|
+
* out of captured PTY output (typically the tail of fnclaude's 64 KB ring
|
|
4
|
+
* buffer after claude exits).
|
|
5
|
+
*
|
|
6
|
+
* Per design.md §4 (Go canonical src/pty_run.go:17–99):
|
|
7
|
+
* - Regex: `/To resume, run:[\s\S]*?cd (\S+) && claude --resume ([0-9a-fA-F-]{36})/g`
|
|
8
|
+
* - When multiple matches appear in the tail, LAST match wins. claude's
|
|
9
|
+
* own most-recent print is the authoritative hint; earlier matches are
|
|
10
|
+
* stale history.
|
|
11
|
+
* - Destination cwd must pass `isSafeDest` — absolute path, no shell
|
|
12
|
+
* metacharacters, no `..` segment. We reject hostile hints rather than
|
|
13
|
+
* silently falling back to an earlier valid one: an attacker who can
|
|
14
|
+
* inject bytes into claude's TTY shouldn't get to redirect the relaunch
|
|
15
|
+
* just because they followed a benign hint with a hostile one.
|
|
16
|
+
* - UUID is defensively re-validated against the canonical 8-4-4-4-12
|
|
17
|
+
* shape. The regex character class allows any combination of hex+dash
|
|
18
|
+
* in a 36-char run, so e.g. a 36-char hex-only string matches the
|
|
19
|
+
* regex but isn't a real UUID — the shape check filters those out.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface CrossCwdHint {
|
|
23
|
+
cwd: string;
|
|
24
|
+
uuid: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HINT_RE = /To resume, run:[\s\S]*?cd (\S+) && claude --resume ([0-9a-fA-F-]{36})/g;
|
|
28
|
+
|
|
29
|
+
const UUID_SHAPE_RE =
|
|
30
|
+
/^[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}$/;
|
|
31
|
+
|
|
32
|
+
// Shell metacharacters and quote characters that have no business in a
|
|
33
|
+
// path we're about to hand to `cd` (even though we don't actually invoke
|
|
34
|
+
// a shell — defense in depth in case a future call site does).
|
|
35
|
+
const UNSAFE_CHARS = new Set([
|
|
36
|
+
';', '|', '&', '$', '`', '<', '>', '(', ')',
|
|
37
|
+
'{', '}', '[', ']', '#', '!', '\\', "'", '"',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function isSafeDest(dest: string): boolean {
|
|
41
|
+
if (dest === '') return false;
|
|
42
|
+
// POSIX absolute path. Windows handling is a follow-up.
|
|
43
|
+
if (!dest.startsWith('/')) return false;
|
|
44
|
+
for (const ch of dest) {
|
|
45
|
+
if (UNSAFE_CHARS.has(ch)) return false;
|
|
46
|
+
}
|
|
47
|
+
// No `..` path segment.
|
|
48
|
+
for (const seg of dest.split('/')) {
|
|
49
|
+
if (seg === '..') return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseCrossCwdHint(text: string): CrossCwdHint | null {
|
|
55
|
+
// Reset the global regex's lastIndex defensively — matchAll handles this
|
|
56
|
+
// correctly for us, but we treat HINT_RE as a module-level constant and
|
|
57
|
+
// never want stateful surprises.
|
|
58
|
+
let last: { cwd: string; uuid: string } | null = null;
|
|
59
|
+
for (const m of text.matchAll(HINT_RE)) {
|
|
60
|
+
const cwd = m[1];
|
|
61
|
+
const uuid = m[2];
|
|
62
|
+
if (cwd === undefined || uuid === undefined) continue;
|
|
63
|
+
last = { cwd, uuid };
|
|
64
|
+
}
|
|
65
|
+
if (last === null) return null;
|
|
66
|
+
if (!isSafeDest(last.cwd)) return null;
|
|
67
|
+
if (!UUID_SHAPE_RE.test(last.uuid)) return null;
|
|
68
|
+
return last;
|
|
69
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §9.3 — Cross-cwd silent relaunch decision.
|
|
3
|
+
*
|
|
4
|
+
* After claude exits the parent fnclaude scans the captured PTY tail
|
|
5
|
+
* for the "To resume, run: cd <dir> && claude --resume <uuid>" hint
|
|
6
|
+
* claude prints when the user picks a session from a different
|
|
7
|
+
* directory via Ctrl-A. When the hint is present (and no other handoff
|
|
8
|
+
* is already in flight), fnclaude silently re-execs itself with a
|
|
9
|
+
* reconstructed argv pointing at the new cwd + session uuid.
|
|
10
|
+
*
|
|
11
|
+
* This module is the *pure* part of that flow: takes the post-exit
|
|
12
|
+
* inputs, returns a relaunch decision (yes/no + argv). The side-
|
|
13
|
+
* effecting re-exec lives in `handoff/awaiter.ts` (shared with §8.5's
|
|
14
|
+
* MCP handoff path) and the call-site lives in `main.ts`.
|
|
15
|
+
*
|
|
16
|
+
* Port of Go canonical `detectCrossCwd` + `reconstructArgv` + the
|
|
17
|
+
* post-exit check in `main.go::run()`. See Go pty_run.go:84+ and
|
|
18
|
+
* main.go:1006+.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
applyOverrides,
|
|
23
|
+
preserveArgs,
|
|
24
|
+
splitLeadingMagic,
|
|
25
|
+
} from '../argv/preserve-args.ts';
|
|
26
|
+
import { parseCrossCwdHint } from './cross-cwd-parse.ts';
|
|
27
|
+
|
|
28
|
+
export interface CrossCwdRelaunchInput {
|
|
29
|
+
/** claude's exit code. Non-zero short-circuits — no relaunch. */
|
|
30
|
+
exitCode: number;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the handoff trigger has already accepted a stash. True
|
|
33
|
+
* means an MCP-handoff is in motion; cross-cwd would race it and we
|
|
34
|
+
* defer to the handoff path. Mirrors Go canonical's `len(handoffArgv) > 0`
|
|
35
|
+
* gate in main.go::run().
|
|
36
|
+
*/
|
|
37
|
+
alreadyStashed: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Tail of PTY output captured during the session (§9.1's ring
|
|
40
|
+
* buffer). Decoded to text via TextDecoder; the hint matcher tolerates
|
|
41
|
+
* surrounding ANSI escapes.
|
|
42
|
+
*/
|
|
43
|
+
ringSnapshot: Uint8Array;
|
|
44
|
+
/**
|
|
45
|
+
* `process.argv.slice(2)` from the original fnclaude invocation —
|
|
46
|
+
* the user-supplied argv before any internal massaging. This is what
|
|
47
|
+
* survives across the relaunch (modulo positional path → dest swap
|
|
48
|
+
* + injected `--resume`).
|
|
49
|
+
*/
|
|
50
|
+
origArgs: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CrossCwdRelaunchDecision =
|
|
54
|
+
| { relaunch: false }
|
|
55
|
+
| { relaunch: true; argv: string[] };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether to silently relaunch fnclaude in a different cwd.
|
|
59
|
+
*
|
|
60
|
+
* Returns `{relaunch: false}` when any gate fails (non-zero exit,
|
|
61
|
+
* handoff already stashed, no hint, unsafe hint). Returns
|
|
62
|
+
* `{relaunch: true, argv}` when all gates pass — argv is the
|
|
63
|
+
* reconstructed user-side argv ready to feed to the re-exec primitive.
|
|
64
|
+
*
|
|
65
|
+
* Reconstruction mirrors Go canonical's `reconstructArgv`:
|
|
66
|
+
* 1. `preserveArgs(origArgs, {}, {})` — keep magic + flags, drop
|
|
67
|
+
* positionals (the dest replaces them).
|
|
68
|
+
* 2. `applyOverrides(preserved, {})` — no-op since we don't override
|
|
69
|
+
* anything cross-cwd; included for parity with the
|
|
70
|
+
* transfer/restart shape so future overrides have a place to land.
|
|
71
|
+
* 3. `splitLeadingMagic` to peel the magic prefix off the front.
|
|
72
|
+
* 4. `[...magic, dest, '--resume', uuid, ...rest]`.
|
|
73
|
+
*/
|
|
74
|
+
export function decideCrossCwdRelaunch(
|
|
75
|
+
input: CrossCwdRelaunchInput,
|
|
76
|
+
): CrossCwdRelaunchDecision {
|
|
77
|
+
if (input.exitCode !== 0) return { relaunch: false };
|
|
78
|
+
if (input.alreadyStashed) return { relaunch: false };
|
|
79
|
+
if (input.ringSnapshot.length === 0) return { relaunch: false };
|
|
80
|
+
|
|
81
|
+
const text = new TextDecoder().decode(input.ringSnapshot);
|
|
82
|
+
const hint = parseCrossCwdHint(text);
|
|
83
|
+
if (hint === null) return { relaunch: false };
|
|
84
|
+
|
|
85
|
+
const preserved = preserveArgs(input.origArgs, EMPTY_DENY, EMPTY_BARE_OK);
|
|
86
|
+
const withOverrides = applyOverrides(preserved, {});
|
|
87
|
+
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
88
|
+
|
|
89
|
+
const argv: string[] = [...magic, hint.cwd, '--resume', hint.uuid, ...rest];
|
|
90
|
+
return { relaunch: true, argv };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pre-allocated empty sets so we don't allocate per call.
|
|
94
|
+
const EMPTY_DENY: ReadonlySet<string> = new Set();
|
|
95
|
+
const EMPTY_BARE_OK: ReadonlySet<string> = new Set();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate `claude` on PATH so we can fail fast with a clean error when it
|
|
3
|
+
* isn't installed — rather than blowing up at Bun.spawn time with a less
|
|
4
|
+
* helpful ENOENT.
|
|
5
|
+
*
|
|
6
|
+
* We walk PATH directly (rather than calling `Bun.which`) so callers can
|
|
7
|
+
* pass an explicit PATH for testing. This matches the standard PATH
|
|
8
|
+
* resolution: left-to-right, first executable file with the right name
|
|
9
|
+
* wins, non-existent directories are skipped silently.
|
|
10
|
+
*
|
|
11
|
+
* Windows handling (PATHEXT, .exe etc.) is a follow-up; the rewrite's
|
|
12
|
+
* primary Unix path lands here first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export interface FindClaudeArgs {
|
|
19
|
+
pathEnv: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type FindClaudeResult =
|
|
23
|
+
| { ok: true; path: string }
|
|
24
|
+
| { ok: false; error: string };
|
|
25
|
+
|
|
26
|
+
export function findClaude(args: FindClaudeArgs): FindClaudeResult {
|
|
27
|
+
if (args.pathEnv === '') {
|
|
28
|
+
return errorResult();
|
|
29
|
+
}
|
|
30
|
+
const dirs = args.pathEnv.split(':');
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
if (dir === '') continue;
|
|
33
|
+
const candidate = join(dir, 'claude');
|
|
34
|
+
try {
|
|
35
|
+
const st = statSync(candidate);
|
|
36
|
+
if (!st.isFile()) continue;
|
|
37
|
+
accessSync(candidate, constants.X_OK);
|
|
38
|
+
return { ok: true, path: candidate };
|
|
39
|
+
} catch {
|
|
40
|
+
// Missing, not executable, or unreadable — keep walking.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return errorResult();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errorResult(): FindClaudeResult {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error:
|
|
50
|
+
'fnclaude: `claude` not found on PATH. Install Claude Code (https://docs.claude.com/en/docs/agents/claude-code) and ensure the `claude` binary is on your PATH.',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TS port of Go canonical's `session_state.go` — reads the most recent
|
|
3
|
+
* permission-mode claude wrote into its per-session JSONL log.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code appends records of the form
|
|
6
|
+
*
|
|
7
|
+
* {"type":"permission-mode","permissionMode":"acceptEdits|auto|bypassPermissions|default|dontAsk|plan"}
|
|
8
|
+
*
|
|
9
|
+
* to `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl` whenever the
|
|
10
|
+
* user toggles permission mode in-session. fnclaude reads the latest
|
|
11
|
+
* value back during §8.1 (restart) and §8.2 (switch) so the relaunched
|
|
12
|
+
* session inherits whatever the user landed on, not whatever flag the
|
|
13
|
+
* session was originally launched with.
|
|
14
|
+
*
|
|
15
|
+
* Three exports mirror the Go shape:
|
|
16
|
+
*
|
|
17
|
+
* - `encodeCWDForProjects(cwd)` — pure transform: every char NOT in
|
|
18
|
+
* `[A-Za-z0-9-]` collapses to `-`. Verified empirically against real
|
|
19
|
+
* on-disk session directories (claude replaces `/`, `@`, `+`, `_`,
|
|
20
|
+
* `.`, … — the safe rule is the allowlist).
|
|
21
|
+
* - `sessionJSONLPath(launchCWD, sessionID)` — joins HOME +
|
|
22
|
+
* `.claude/projects` + encoded-cwd + `<sid>.jsonl`.
|
|
23
|
+
* - `readLivePermissionMode(launchCWD, sessionID)` — opens, last-wins
|
|
24
|
+
* scan, returns `null` on miss/error/unreadable.
|
|
25
|
+
*
|
|
26
|
+
* Sync IO via `readFileSync` is fine here: session JSONLs are
|
|
27
|
+
* bounded-size and the call only fires on MCP-dispatched restart/switch,
|
|
28
|
+
* never on a hot path. Streaming would add complexity without benefit.
|
|
29
|
+
*
|
|
30
|
+
* Only `type === "permission-mode"` records are authoritative — other
|
|
31
|
+
* record types (user / assistant / system messages) may serialize a
|
|
32
|
+
* cached `permissionMode` snapshot which is NOT the source of truth per
|
|
33
|
+
* the Go canonical's comment.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Encode `cwd` into the directory name claude uses under
|
|
42
|
+
* `~/.claude/projects/`. The scheme: every character that is NOT in
|
|
43
|
+
* `[A-Za-z0-9-]` becomes `-`. A canonical absolute path like
|
|
44
|
+
* `/home/tom/src/fnclaude@fnclaude` encodes to
|
|
45
|
+
* `-home-tom-src-fnclaude-fnclaude`.
|
|
46
|
+
*/
|
|
47
|
+
export function encodeCWDForProjects(cwd: string): string {
|
|
48
|
+
let out = '';
|
|
49
|
+
for (const ch of cwd) {
|
|
50
|
+
if (
|
|
51
|
+
(ch >= 'a' && ch <= 'z') ||
|
|
52
|
+
(ch >= 'A' && ch <= 'Z') ||
|
|
53
|
+
(ch >= '0' && ch <= '9') ||
|
|
54
|
+
ch === '-'
|
|
55
|
+
) {
|
|
56
|
+
out += ch;
|
|
57
|
+
} else {
|
|
58
|
+
out += '-';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns the absolute path to claude's per-session JSONL log for
|
|
66
|
+
* `sessionID` running in `launchCWD`. Caller is responsible for
|
|
67
|
+
* checking existence — `readLivePermissionMode` handles ENOENT itself.
|
|
68
|
+
*/
|
|
69
|
+
export function sessionJSONLPath(launchCWD: string, sessionID: string): string {
|
|
70
|
+
return join(resolveHome(), '.claude', 'projects', encodeCWDForProjects(launchCWD), `${sessionID}.jsonl`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads the most recent `permission-mode` value recorded in claude's
|
|
75
|
+
* session JSONL for `sessionID` under `launchCWD`. Returns `null` if:
|
|
76
|
+
*
|
|
77
|
+
* - the file doesn't exist (ENOENT) or is unreadable,
|
|
78
|
+
* - the file exists but contains no `{type:"permission-mode",...}`
|
|
79
|
+
* records, or
|
|
80
|
+
* - every such record has an empty `permissionMode` field.
|
|
81
|
+
*
|
|
82
|
+
* Last-wins semantics: claude's JSONL is append-only, so a forward
|
|
83
|
+
* linear scan returns the most-recently-written value. Malformed lines
|
|
84
|
+
* are skipped silently (defensive against partial writes / future
|
|
85
|
+
* record-shape changes).
|
|
86
|
+
*/
|
|
87
|
+
export function readLivePermissionMode(launchCWD: string, sessionID: string): string | null {
|
|
88
|
+
const path = sessionJSONLPath(launchCWD, sessionID);
|
|
89
|
+
let raw: string;
|
|
90
|
+
try {
|
|
91
|
+
raw = readFileSync(path, 'utf8');
|
|
92
|
+
} catch {
|
|
93
|
+
// ENOENT / EACCES / EISDIR / anything else → no live override.
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let latest: string | null = null;
|
|
98
|
+
for (const line of raw.split('\n')) {
|
|
99
|
+
if (line === '') continue;
|
|
100
|
+
let rec: unknown;
|
|
101
|
+
try {
|
|
102
|
+
rec = JSON.parse(line);
|
|
103
|
+
} catch {
|
|
104
|
+
continue; // malformed line — ignore
|
|
105
|
+
}
|
|
106
|
+
if (typeof rec !== 'object' || rec === null) continue;
|
|
107
|
+
const obj = rec as Record<string, unknown>;
|
|
108
|
+
if (obj.type !== 'permission-mode') continue;
|
|
109
|
+
const mode = obj.permissionMode;
|
|
110
|
+
if (typeof mode !== 'string' || mode === '') continue;
|
|
111
|
+
latest = mode;
|
|
112
|
+
}
|
|
113
|
+
return latest;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* HOME resolution. Reads `process.env.HOME` first — it's the canonical
|
|
118
|
+
* source on POSIX, respects per-test env overrides, and is what `claude`
|
|
119
|
+
* itself uses to find `~/.claude/`. Falls back to `homedir()` for the
|
|
120
|
+
* rare case the env var is unset (cron, bare systemd unit), and to `''`
|
|
121
|
+
* if even that throws (defensive — mirrors Go's `os.UserHomeDir()` →
|
|
122
|
+
* `os.Getenv("HOME")` fallback chain, just with the order reversed
|
|
123
|
+
* because `homedir()` caches in Bun and `process.env.HOME` doesn't).
|
|
124
|
+
*/
|
|
125
|
+
function resolveHome(): string {
|
|
126
|
+
const fromEnv = process.env.HOME;
|
|
127
|
+
if (fromEnv !== undefined && fromEnv !== '') return fromEnv;
|
|
128
|
+
try {
|
|
129
|
+
return homedir();
|
|
130
|
+
} catch {
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-capacity circular byte buffer for capturing the tail of the PTY
|
|
3
|
+
* output stream. Used by §9.2 to scan claude's last screenful for the
|
|
4
|
+
* cross-cwd resume hint after exit.
|
|
5
|
+
*
|
|
6
|
+
* Default capacity is 64 KB per design.md §4. Earlier Go versions used
|
|
7
|
+
* 4 KB; the Go source bumped it after claude 2.1.143 emitted more
|
|
8
|
+
* trailing cleanup and rotated the "To resume, run:" message out of the
|
|
9
|
+
* 4 KB tail.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* - push(chunk): append bytes; when the buffer is full, wrap and
|
|
13
|
+
* overwrite the oldest bytes. A chunk larger than the capacity keeps
|
|
14
|
+
* only its trailing `capacity` bytes (the older prefix is dropped).
|
|
15
|
+
* Zero-length chunks are no-ops.
|
|
16
|
+
* - snapshot(): a fresh Uint8Array containing the current valid bytes
|
|
17
|
+
* in chronological order (oldest first). Mutating the snapshot does
|
|
18
|
+
* not affect the buffer; subsequent pushes do not mutate prior
|
|
19
|
+
* snapshots.
|
|
20
|
+
* - size: number of valid bytes currently held, 0 ≤ size ≤ capacity.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CAPACITY = 64 * 1024;
|
|
24
|
+
|
|
25
|
+
export class RingBuffer {
|
|
26
|
+
readonly capacity: number;
|
|
27
|
+
private readonly buf: Uint8Array;
|
|
28
|
+
/** Index of the oldest valid byte. Only meaningful when full. */
|
|
29
|
+
private start = 0;
|
|
30
|
+
/** Number of valid bytes currently held. */
|
|
31
|
+
private len = 0;
|
|
32
|
+
|
|
33
|
+
constructor(capacity: number = DEFAULT_CAPACITY) {
|
|
34
|
+
this.capacity = capacity;
|
|
35
|
+
this.buf = new Uint8Array(capacity);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get size(): number {
|
|
39
|
+
return this.len;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
push(chunk: Uint8Array): void {
|
|
43
|
+
if (chunk.length === 0 || this.capacity === 0) return;
|
|
44
|
+
|
|
45
|
+
// Chunk larger than capacity: drop its prefix, keep only the trailing
|
|
46
|
+
// `capacity` bytes. Replaces the buffer wholesale — start resets.
|
|
47
|
+
if (chunk.length >= this.capacity) {
|
|
48
|
+
this.buf.set(chunk.subarray(chunk.length - this.capacity));
|
|
49
|
+
this.start = 0;
|
|
50
|
+
this.len = this.capacity;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Compute the write position (one past the last valid byte, modulo
|
|
55
|
+
// capacity). When the buffer is full, that's the same slot as `start`
|
|
56
|
+
// — the oldest byte gets overwritten and `start` advances.
|
|
57
|
+
const writePos = (this.start + this.len) % this.capacity;
|
|
58
|
+
const tail = this.capacity - writePos;
|
|
59
|
+
if (chunk.length <= tail) {
|
|
60
|
+
this.buf.set(chunk, writePos);
|
|
61
|
+
} else {
|
|
62
|
+
// Two-segment copy: fill to end of array, then wrap to index 0.
|
|
63
|
+
this.buf.set(chunk.subarray(0, tail), writePos);
|
|
64
|
+
this.buf.set(chunk.subarray(tail), 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (this.len + chunk.length <= this.capacity) {
|
|
68
|
+
this.len += chunk.length;
|
|
69
|
+
} else {
|
|
70
|
+
// Overflow: bytes past capacity overwrite the oldest bytes; start
|
|
71
|
+
// advances by the overflow amount.
|
|
72
|
+
const overflow = this.len + chunk.length - this.capacity;
|
|
73
|
+
this.start = (this.start + overflow) % this.capacity;
|
|
74
|
+
this.len = this.capacity;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
snapshot(): Uint8Array {
|
|
79
|
+
if (this.len === 0) return new Uint8Array(0);
|
|
80
|
+
const out = new Uint8Array(this.len);
|
|
81
|
+
// Either contiguous (start..start+len fits before capacity) or split
|
|
82
|
+
// (head wraps past the end of the underlying array).
|
|
83
|
+
const tail = this.capacity - this.start;
|
|
84
|
+
if (this.len <= tail) {
|
|
85
|
+
out.set(this.buf.subarray(this.start, this.start + this.len));
|
|
86
|
+
} else {
|
|
87
|
+
out.set(this.buf.subarray(this.start, this.capacity), 0);
|
|
88
|
+
out.set(this.buf.subarray(0, this.len - tail), tail);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
}
|