@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.
Files changed (100) hide show
  1. package/bin/fnc.js +34 -79
  2. package/package.json +6 -9
  3. package/share/fnclaude/templates/handoff.template.md +11 -0
  4. package/src/argv/classify.ts +48 -0
  5. package/src/argv/expand.ts +51 -0
  6. package/src/argv/intake.ts +52 -0
  7. package/src/argv/magic.ts +103 -0
  8. package/src/argv/parse.ts +213 -0
  9. package/src/argv/preserve-args.ts +333 -0
  10. package/src/argv/sentinel.ts +41 -0
  11. package/src/argv/short-flags.ts +152 -0
  12. package/src/config/load.ts +116 -0
  13. package/src/handoff/awaiter.ts +140 -0
  14. package/src/handoff/clean-env.ts +45 -0
  15. package/src/handoff/kill-and-exec.ts +110 -0
  16. package/src/handoff/spawn-launcher.ts +185 -0
  17. package/src/handoff/summary-file.ts +86 -0
  18. package/src/handoff/trigger.ts +90 -0
  19. package/src/help-version.ts +151 -0
  20. package/src/launch/compose-env.ts +34 -0
  21. package/src/launch/cross-cwd-parse.ts +69 -0
  22. package/src/launch/cross-cwd-relaunch.ts +95 -0
  23. package/src/launch/find-claude.ts +52 -0
  24. package/src/launch/live-permission-reader.ts +133 -0
  25. package/src/launch/ring-buffer.ts +92 -0
  26. package/src/main.ts +580 -437
  27. package/src/mcp/dispatch.ts +240 -0
  28. package/src/mcp/handlers/clipboard-backends.ts +176 -0
  29. package/src/mcp/handlers/clipboard.ts +62 -0
  30. package/src/mcp/handlers/restart.ts +156 -0
  31. package/src/mcp/handlers/spawn.ts +219 -0
  32. package/src/mcp/handlers/switch.ts +272 -0
  33. package/src/mcp/inject-config.ts +59 -0
  34. package/src/mcp/jsonrpc-server.ts +154 -0
  35. package/src/mcp/listener.ts +141 -0
  36. package/src/mcp/parent-dispatch.ts +154 -0
  37. package/src/mcp/socket-path.ts +48 -0
  38. package/src/mcp/wire.ts +181 -0
  39. package/src/name/auto-name.ts +162 -0
  40. package/src/name/llm-prompt.ts +14 -0
  41. package/src/name/sanitize.ts +57 -0
  42. package/src/name/sdk-llm.ts +42 -0
  43. package/src/noop/seed.ts +63 -0
  44. package/src/noop/template-source.ts +62 -0
  45. package/src/path/ensure-cwd.ts +95 -0
  46. package/src/path/resolve.ts +58 -0
  47. package/src/prompts/dir.ts +61 -0
  48. package/src/prompts/load.ts +100 -0
  49. package/src/prompts/select.ts +43 -0
  50. package/src/repo/clone-exec.ts +37 -0
  51. package/src/repo/clone.ts +45 -0
  52. package/src/repo/gh-runner.ts +68 -0
  53. package/src/repo/host-aliases.ts +58 -0
  54. package/src/repo/owner-lookup.ts +71 -0
  55. package/src/repo/ref.ts +146 -0
  56. package/src/repo/repo-settings.ts +99 -0
  57. package/src/repo/resolve-input.ts +179 -0
  58. package/src/repo/template.ts +92 -0
  59. package/src/warnings/buffer.ts +39 -0
  60. package/src/worktree/auto-tmux.ts +45 -0
  61. package/src/worktree/git-list.ts +73 -0
  62. package/src/worktree/intercept.ts +150 -0
  63. package/bin/preflight.js +0 -66
  64. package/prompts/agent-pitfall.md +0 -1
  65. package/prompts/noop-router.md +0 -186
  66. package/prompts/project-switch.md +0 -64
  67. package/prompts/restart.md +0 -50
  68. package/prompts/spawn.md +0 -62
  69. package/src/argParser.ts +0 -367
  70. package/src/args/preserve.ts +0 -338
  71. package/src/args.ts +0 -239
  72. package/src/argv.ts +0 -219
  73. package/src/autoname.ts +0 -273
  74. package/src/clipboard.ts +0 -149
  75. package/src/config.ts +0 -369
  76. package/src/errors.ts +0 -13
  77. package/src/handoff.ts +0 -108
  78. package/src/help.ts +0 -139
  79. package/src/hostAliases.ts +0 -139
  80. package/src/index.ts +0 -120
  81. package/src/mcp/client.ts +0 -645
  82. package/src/mcp/protocol.ts +0 -445
  83. package/src/mcp/socketListener.ts +0 -540
  84. package/src/noop.ts +0 -106
  85. package/src/passthrough.ts +0 -36
  86. package/src/paths.ts +0 -55
  87. package/src/prompts.ts +0 -279
  88. package/src/pty/unix.ts +0 -429
  89. package/src/pty/windows.ts +0 -125
  90. package/src/pty.ts +0 -380
  91. package/src/repoRef.ts +0 -158
  92. package/src/repoSettings.ts +0 -144
  93. package/src/resolver.ts +0 -519
  94. package/src/sanitize.ts +0 -120
  95. package/src/sessionState.ts +0 -220
  96. package/src/silentRelaunch.ts +0 -178
  97. package/src/spawn.ts +0 -163
  98. package/src/template.ts +0 -44
  99. package/src/warnings.ts +0 -34
  100. package/src/worktree.ts +0 -201
@@ -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
+ }