@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.
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 -203
  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,179 @@
1
+ /**
2
+ * Resolver orchestrator — takes the first positional argument (or null)
3
+ * and decides what fnclaude should do with it. Pure dispatch + filesystem
4
+ * existence checks; gh-CLI-bound side effects (owner lookup, clone
5
+ * execution) are surfaced as result variants so the caller (CLI main)
6
+ * handles them at the boundary.
7
+ *
8
+ * Mirrors Go canonical `src/resolver.go:resolveLaunchCwd` minus the gh
9
+ * branches:
10
+ * - Short-circuit for /, ~, ~/ → launch unconditionally (no fs check)
11
+ * - Everything else → dual lookup
12
+ * path: is <shellCwd>/<input> a directory?
13
+ * repo: parse, compute clone destination, does it exist?
14
+ * - Both found → ambiguous (caller errors with disambiguation msg)
15
+ * - One found → launch that one
16
+ * - Neither → needs-clone (resolved owner) or needs-owner-lookup
17
+ * (bare name) — caller invokes gh CLI
18
+ *
19
+ * See specs.md §18.1 for the user-facing description.
20
+ */
21
+
22
+ import { statSync } from 'node:fs';
23
+ import { isAbsolute, join } from 'node:path';
24
+
25
+ import { expandTilde, noopDir } from '../path/resolve.ts';
26
+ import { buildCloneUrl, computeCloneDestination } from './clone.ts';
27
+ import { hasResolvedOwner, parseRepoRef } from './ref.ts';
28
+
29
+ export interface ResolveInputArgs {
30
+ input: string | null;
31
+ shellCwd: string;
32
+ home: string;
33
+ xdgConfigHome: string | undefined;
34
+ settings: {
35
+ cloneTemplate: string;
36
+ hostAliases: Record<string, string>;
37
+ };
38
+ }
39
+
40
+ export type ResolveResult =
41
+ | {
42
+ kind: 'launch';
43
+ launchCwd: string;
44
+ workspace: string;
45
+ usedNoopFallback: boolean;
46
+ }
47
+ | {
48
+ kind: 'needs-clone';
49
+ url: string;
50
+ destination: string;
51
+ workspace: string;
52
+ }
53
+ | {
54
+ kind: 'needs-owner-lookup';
55
+ name: string;
56
+ workspace: string;
57
+ }
58
+ | {
59
+ kind: 'ambiguous';
60
+ path: string;
61
+ cloneDestination?: string;
62
+ repoRef?: string;
63
+ }
64
+ | { kind: 'error'; error: string };
65
+
66
+ function isDirectory(path: string): boolean {
67
+ try {
68
+ return statSync(path).isDirectory();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function isPathShortCircuit(input: string): boolean {
75
+ if (input === '~') return true;
76
+ if (input.startsWith('/')) return true;
77
+ if (input.startsWith('~/')) return true;
78
+ return false;
79
+ }
80
+
81
+ export function resolveInput(args: ResolveInputArgs): ResolveResult {
82
+ const { input, shellCwd, home, xdgConfigHome, settings } = args;
83
+
84
+ // 1. Null / empty → noop fallback
85
+ if (input === null || input === '') {
86
+ return {
87
+ kind: 'launch',
88
+ launchCwd: noopDir({ xdgConfigHome, home }),
89
+ usedNoopFallback: true,
90
+ workspace: '',
91
+ };
92
+ }
93
+
94
+ // 2. Path short-circuit: /, ~, ~/ skip the repo lookup entirely
95
+ // (per specs.md §18.1). Don't check whether the directory exists —
96
+ // user said "go here", we go there.
97
+ if (isPathShortCircuit(input)) {
98
+ const expanded = expandTilde(input, home);
99
+ const launchCwd = isAbsolute(expanded) ? expanded : join(shellCwd, expanded);
100
+ return { kind: 'launch', launchCwd, usedNoopFallback: false, workspace: '' };
101
+ }
102
+
103
+ // 3. Dual lookup: check both path-on-disk and repo-ref interpretation.
104
+ const pathCandidate = join(shellCwd, input);
105
+ const pathExists = isDirectory(pathCandidate);
106
+
107
+ const parseResult = parseRepoRef(input);
108
+
109
+ // If repo-ref parse failed: path is the only chance.
110
+ if (!parseResult.ok) {
111
+ if (pathExists) {
112
+ return {
113
+ kind: 'launch',
114
+ launchCwd: pathCandidate,
115
+ usedNoopFallback: false,
116
+ workspace: '',
117
+ };
118
+ }
119
+ return { kind: 'error', error: parseResult.error };
120
+ }
121
+
122
+ const ref = parseResult.ref;
123
+
124
+ // Bare name (owner not in input) — need gh CLI to find owner.
125
+ if (!hasResolvedOwner(ref)) {
126
+ if (pathExists) {
127
+ return { kind: 'ambiguous', path: pathCandidate, repoRef: input };
128
+ }
129
+ return { kind: 'needs-owner-lookup', name: ref.name, workspace: ref.workspace };
130
+ }
131
+
132
+ // Owner is resolved; compute clone destination.
133
+ if (settings.cloneTemplate === '') {
134
+ return {
135
+ kind: 'error',
136
+ error:
137
+ 'cloneTemplate is not configured in repoSettings; cannot resolve repo references. Set repoSettings.cloneTemplate in ~/.claude/settings.json (e.g. "~/src/{repo}@{owner}")',
138
+ };
139
+ }
140
+
141
+ const destResult = computeCloneDestination({
142
+ ref,
143
+ template: settings.cloneTemplate,
144
+ hostAliases: settings.hostAliases,
145
+ home,
146
+ });
147
+ if (!destResult.ok) {
148
+ return { kind: 'error', error: destResult.error };
149
+ }
150
+ const destination = destResult.path;
151
+ const destExists = isDirectory(destination);
152
+
153
+ if (pathExists && destExists) {
154
+ return { kind: 'ambiguous', path: pathCandidate, cloneDestination: destination };
155
+ }
156
+ if (pathExists) {
157
+ return {
158
+ kind: 'launch',
159
+ launchCwd: pathCandidate,
160
+ usedNoopFallback: false,
161
+ workspace: ref.workspace,
162
+ };
163
+ }
164
+ if (destExists) {
165
+ return {
166
+ kind: 'launch',
167
+ launchCwd: destination,
168
+ usedNoopFallback: false,
169
+ workspace: ref.workspace,
170
+ };
171
+ }
172
+
173
+ return {
174
+ kind: 'needs-clone',
175
+ url: buildCloneUrl(ref),
176
+ destination,
177
+ workspace: ref.workspace,
178
+ };
179
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Template substitution for cloneTemplate values (and any future templates
3
+ * fnclaude reads from repoSettings). Placeholder vocabulary aligns with
4
+ * the claude-code-worktree-paths plugin so users learn one templating
5
+ * language across both tools.
6
+ *
7
+ * Ports Go canonical's template.go. fnclaude only uses cloneTemplate,
8
+ * which is computed BEFORE a clone exists — placeholders like {repo-dir},
9
+ * {clone-path}, {input}, {cwd} aren't meaningful here and are rejected
10
+ * via the unknown-placeholder error.
11
+ *
12
+ * Lazy resolvers: {host-short} defers the LUT lookup error until the
13
+ * placeholder is actually referenced. Templates that don't use it don't
14
+ * need the LUT populated.
15
+ */
16
+
17
+ export interface TemplateResolveOk {
18
+ ok: true;
19
+ value: string;
20
+ }
21
+
22
+ export interface TemplateResolveErr {
23
+ ok: false;
24
+ error: string;
25
+ }
26
+
27
+ export type TemplateResolveResult = TemplateResolveOk | TemplateResolveErr;
28
+
29
+ export type TemplateVars = Record<string, () => TemplateResolveResult>;
30
+
31
+ export function applyTemplate(tpl: string, vars: TemplateVars): TemplateResolveResult {
32
+ let out = '';
33
+ let i = 0;
34
+ while (i < tpl.length) {
35
+ const c = tpl[i]!;
36
+ if (c !== '{') {
37
+ out += c;
38
+ i++;
39
+ continue;
40
+ }
41
+ const closeIdx = tpl.indexOf('}', i + 1);
42
+ if (closeIdx < 0) {
43
+ // Unterminated `{` — pass through literally; the user's template is
44
+ // malformed and an error here would be confusing.
45
+ out += c;
46
+ i++;
47
+ continue;
48
+ }
49
+ const name = tpl.slice(i + 1, closeIdx);
50
+ const resolver = vars[name];
51
+ if (!resolver) {
52
+ return { ok: false, error: `unknown placeholder {${name}} in template ${JSON.stringify(tpl)}` };
53
+ }
54
+ const r = resolver();
55
+ if (!r.ok) return r;
56
+ out += r.value;
57
+ i = closeIdx + 1;
58
+ }
59
+ return { ok: true, value: out };
60
+ }
61
+
62
+ /**
63
+ * Build the placeholder map for cloneTemplate expansion given the resolved
64
+ * repo coordinates. Lazy resolvers (host-short) let templates that don't
65
+ * reference them skip LUT lookups.
66
+ */
67
+ export function cloneTemplateVars(
68
+ repo: string,
69
+ owner: string,
70
+ host: string,
71
+ hostAliases: Record<string, string>,
72
+ ): TemplateVars {
73
+ const dotIdx = host.indexOf('.');
74
+ const hostPlain = dotIdx >= 0 ? host.slice(0, dotIdx) : host;
75
+
76
+ return {
77
+ repo: () => ({ ok: true, value: repo }),
78
+ owner: () => ({ ok: true, value: owner }),
79
+ host: () => ({ ok: true, value: host }),
80
+ 'host-plain': () => ({ ok: true, value: hostPlain }),
81
+ 'host-short': () => {
82
+ const alias = hostAliases[host];
83
+ if (alias === undefined) {
84
+ return {
85
+ ok: false,
86
+ error: `host ${JSON.stringify(host)} has no entry in hostAliases LUT; add one to ~/.claude/settings.json's hostShortAliases block`,
87
+ };
88
+ }
89
+ return { ok: true, value: alias };
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,39 @@
1
+ // Deferred-flush warning buffer (design.md §27).
2
+ //
3
+ // Non-fatal warnings issued during the launch sequence (config-parse,
4
+ // fragment-load, name-sanitization, etc.) shouldn't be written to stderr
5
+ // as they happen — claude's TUI takes over the terminal moments later and
6
+ // scrolls them off-screen before the user sees them. Instead, accumulate
7
+ // them here and flush once claude has exited, so they land in the user's
8
+ // shell where they have time to read them.
9
+ //
10
+ // Terminal errors that exit non-zero (bad argv, missing claude binary,
11
+ // clone failure, etc.) bypass this buffer and write directly to stderr —
12
+ // those aren't warnings, they're the reason the launch is aborting, and
13
+ // the user needs to see them immediately.
14
+
15
+ export interface WarningBuffer {
16
+ /** Queue a warning. Trailing newline is added on flush if absent. */
17
+ add(msg: string): void;
18
+ /**
19
+ * Write every queued warning to the given stream, one per line, then
20
+ * drain the buffer. Subsequent `flush` calls without new `add`s are
21
+ * no-ops. Idempotent on an empty queue.
22
+ */
23
+ flush(stream: NodeJS.WritableStream): void;
24
+ }
25
+
26
+ export function createWarningBuffer(): WarningBuffer {
27
+ const queue: string[] = [];
28
+ return {
29
+ add(msg: string): void {
30
+ queue.push(msg);
31
+ },
32
+ flush(stream: NodeJS.WritableStream): void {
33
+ while (queue.length > 0) {
34
+ const msg = queue.shift()!;
35
+ stream.write(msg.endsWith('\n') ? msg : `${msg}\n`);
36
+ }
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Auto-tmux gating (§5.4) — when `config.auto.tmux = "worktree"` and the
3
+ * user is creating a new worktree (i.e. `-w <name>` was set and the
4
+ * worktree-intercept layer did NOT find an existing match), inject
5
+ * `--tmux` into passthrough. The injection itself is the caller's job;
6
+ * this module decides whether.
7
+ *
8
+ * Per design.md §1 and prd.launcher.md "Auto-tmux for new worktrees".
9
+ *
10
+ * All five conditions must hold:
11
+ * 1. configAutoTmux === "worktree"
12
+ * 2. worktreeSet === true (user passed -w / 2nd-positional)
13
+ * 3. worktreeMatched === false (a new worktree, not entering an
14
+ * existing one)
15
+ * 4. noTmux === false (user did not pass --no-tmux escape hatch)
16
+ * 5. passthrough does NOT already contain `--tmux` or `--tmux=…`
17
+ * (short -T is assumed to have been expanded by §4.5)
18
+ *
19
+ * If any fails: do nothing — explicit user intent always wins.
20
+ */
21
+
22
+ export interface AutoTmuxArgs {
23
+ configAutoTmux: string | undefined;
24
+ worktreeSet: boolean;
25
+ worktreeMatched: boolean;
26
+ noTmux: boolean;
27
+ passthrough: readonly string[];
28
+ }
29
+
30
+ function passthroughHasTmux(passthrough: readonly string[]): boolean {
31
+ for (const tok of passthrough) {
32
+ if (tok === '--tmux') return true;
33
+ if (tok.startsWith('--tmux=')) return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ export function shouldInjectTmux(args: AutoTmuxArgs): boolean {
39
+ if (args.configAutoTmux !== 'worktree') return false;
40
+ if (!args.worktreeSet) return false;
41
+ if (args.worktreeMatched) return false;
42
+ if (args.noTmux) return false;
43
+ if (passthroughHasTmux(args.passthrough)) return false;
44
+ return true;
45
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Run `git worktree list --porcelain` in a directory and parse the output
3
+ * into Worktree[] for the intercept layer (§5.3).
4
+ *
5
+ * Porcelain block format (per git docs):
6
+ *
7
+ * worktree /abs/path
8
+ * HEAD <sha>
9
+ * branch refs/heads/<name> OR detached
10
+ *
11
+ * Blocks separated by blank lines. The branch field's "refs/heads/"
12
+ * prefix is stripped so callers can match against the user-typed name
13
+ * directly. Detached worktrees get branch = "".
14
+ *
15
+ * listWorktrees() returns null if git isn't installed, the directory
16
+ * isn't a git repo, or git errored — the intercept layer treats null as
17
+ * "no worktrees, no match" and forwards `-w` to claude unchanged.
18
+ */
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ import type { Worktree } from './intercept.ts';
23
+
24
+ const REFS_HEADS_PREFIX = 'refs/heads/';
25
+
26
+ export function parseGitWorktreeListPorcelain(out: string): Worktree[] {
27
+ const lines = out.split('\n');
28
+ const worktrees: Worktree[] = [];
29
+ let cur: { path: string | null; branch: string | null } = { path: null, branch: null };
30
+
31
+ const flush = (): void => {
32
+ if (cur.path !== null) {
33
+ worktrees.push({ path: cur.path, branch: cur.branch ?? '' });
34
+ }
35
+ cur = { path: null, branch: null };
36
+ };
37
+
38
+ for (const line of lines) {
39
+ if (line === '') {
40
+ flush();
41
+ continue;
42
+ }
43
+ if (line.startsWith('worktree ')) {
44
+ flush();
45
+ cur.path = line.slice('worktree '.length);
46
+ } else if (line.startsWith('branch ')) {
47
+ const v = line.slice('branch '.length);
48
+ cur.branch = v.startsWith(REFS_HEADS_PREFIX) ? v.slice(REFS_HEADS_PREFIX.length) : v;
49
+ } else if (line === 'detached') {
50
+ cur.branch = '';
51
+ }
52
+ // HEAD <sha> and any unknown lines: ignored.
53
+ }
54
+ flush();
55
+
56
+ return worktrees;
57
+ }
58
+
59
+ export function listWorktrees(cwd: string): Worktree[] | null {
60
+ let result;
61
+ try {
62
+ result = spawnSync('git', ['worktree', 'list', '--porcelain'], {
63
+ cwd,
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ encoding: 'utf8',
66
+ });
67
+ } catch {
68
+ return null;
69
+ }
70
+ if (result.error !== undefined) return null;
71
+ if (result.status !== 0) return null;
72
+ return parseGitWorktreeListPorcelain(result.stdout);
73
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Worktree intercept — handles `-w <name>` (and 2nd-positional, which
3
+ * the parser routes through the same worktreeArg slot).
4
+ *
5
+ * Mirrors Go canonical `src/main.go:631-743` with one rewrite deviation
6
+ * per PRD item #8: ALSO set `--name <name>` on match. Go canonical
7
+ * didn't; the rewrite always sets `--name` so the session name reflects
8
+ * the worktree name regardless of match.
9
+ *
10
+ * Behavior matrix (specs.md §10):
11
+ *
12
+ * worktreeSet=false → no-op (passthrough unchanged)
13
+ * bare -w (arg='') → push `--worktree` alone (no name)
14
+ * -w <name>, match found → swap launchCwd to match.path;
15
+ * push `--name <name>` (unless --name/-n
16
+ * already in passthrough);
17
+ * do NOT push `--worktree`
18
+ * -w <name>, no match → push `--worktree <name>`; push
19
+ * `--name <name>` (unless already set)
20
+ * -w <name>, not a repo → same as no-match (listWorktrees returns
21
+ * null; git errors degrade silently)
22
+ *
23
+ * Match priority ladder against sanitizeForPath(<name>):
24
+ * 1. branch === query
25
+ * 2. branch with "worktree-" prefix stripped === query
26
+ * 3. basename(worktree.path) === query
27
+ *
28
+ * The name is sanitized via §5.1 sanitizeForPath. Invalid → original
29
+ * kept (with a deferred warning); changed-but-valid → sanitized used
30
+ * (with a deferred warning naming both).
31
+ */
32
+
33
+ import { basename } from 'node:path';
34
+
35
+ import { sanitizeForPath } from '../name/sanitize.ts';
36
+
37
+ export interface Worktree {
38
+ path: string;
39
+ branch: string;
40
+ }
41
+
42
+ export interface InterceptArgs {
43
+ worktreeSet: boolean;
44
+ worktreeArg: string;
45
+ launchCwd: string;
46
+ passthrough: readonly string[];
47
+ /**
48
+ * Return the list of worktrees in launchCwd, or null if it's not a
49
+ * git repo (or git failed). Caller wires `git worktree list
50
+ * --porcelain` parsing.
51
+ */
52
+ listWorktrees: (cwd: string) => Worktree[] | null;
53
+ }
54
+
55
+ export interface InterceptResult {
56
+ launchCwd: string;
57
+ passthrough: string[];
58
+ worktreeMatched: boolean;
59
+ warnings: string[];
60
+ }
61
+
62
+ const WORKTREE_BRANCH_PREFIX = 'worktree-';
63
+
64
+ function hasNameInPassthrough(passthrough: readonly string[]): boolean {
65
+ for (let i = 0; i < passthrough.length; i++) {
66
+ const tok = passthrough[i]!;
67
+ if (tok === '--name' || tok === '-n') return true;
68
+ if (tok.startsWith('--name=') || tok.startsWith('-n=')) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ function findMatch(name: string, wts: Worktree[]): Worktree | null {
74
+ // Priority 1: exact branch match
75
+ for (const wt of wts) {
76
+ if (wt.branch === name) return wt;
77
+ }
78
+ // Priority 2: branch with worktree- prefix stripped
79
+ for (const wt of wts) {
80
+ if (
81
+ wt.branch.startsWith(WORKTREE_BRANCH_PREFIX) &&
82
+ wt.branch.slice(WORKTREE_BRANCH_PREFIX.length) === name
83
+ ) {
84
+ return wt;
85
+ }
86
+ }
87
+ // Priority 3: basename of worktree path
88
+ for (const wt of wts) {
89
+ if (basename(wt.path) === name) return wt;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function applyWorktreeIntercept(args: InterceptArgs): InterceptResult {
95
+ const passthrough = [...args.passthrough];
96
+ const warnings: string[] = [];
97
+
98
+ if (!args.worktreeSet) {
99
+ return { launchCwd: args.launchCwd, passthrough, worktreeMatched: false, warnings };
100
+ }
101
+
102
+ // Bare -w (no value)
103
+ if (args.worktreeArg === '') {
104
+ passthrough.push('--worktree');
105
+ return { launchCwd: args.launchCwd, passthrough, worktreeMatched: false, warnings };
106
+ }
107
+
108
+ // Sanitize the name, collecting any warning.
109
+ const san = sanitizeForPath(args.worktreeArg);
110
+ let name: string;
111
+ if (san.kind === 'unchanged') {
112
+ name = san.value;
113
+ } else if (san.kind === 'changed') {
114
+ name = san.value;
115
+ warnings.push(
116
+ `fnclaude: -w ${JSON.stringify(san.original)} sanitized to ${JSON.stringify(san.value)} (illegal path/branch chars)`,
117
+ );
118
+ } else {
119
+ // Invalid — pass original through with warning, no match attempted.
120
+ name = san.original;
121
+ warnings.push(
122
+ `fnclaude: -w ${JSON.stringify(san.original)} is not a safe path/branch name; passed through unchanged`,
123
+ );
124
+ }
125
+
126
+ const wts = args.listWorktrees(args.launchCwd);
127
+ const match = wts === null ? null : findMatch(name, wts);
128
+
129
+ if (match !== null) {
130
+ // Match: swap cwd, set --name unless already set, don't forward --worktree.
131
+ const result: InterceptResult = {
132
+ launchCwd: match.path,
133
+ passthrough,
134
+ worktreeMatched: true,
135
+ warnings,
136
+ };
137
+ if (!hasNameInPassthrough(passthrough)) {
138
+ passthrough.push('--name', name);
139
+ }
140
+ return result;
141
+ }
142
+
143
+ // No match: pass --worktree <name> through, set --name unless already set.
144
+ passthrough.push('--worktree', name);
145
+ if (!hasNameInPassthrough(args.passthrough)) {
146
+ passthrough.push('--name', name);
147
+ }
148
+
149
+ return { launchCwd: args.launchCwd, passthrough, worktreeMatched: false, warnings };
150
+ }
package/bin/preflight.js DELETED
@@ -1,66 +0,0 @@
1
- // Preflight for the cli `fnc` bin shim — decide whether to proceed,
2
- // re-exec under Bun, or hard-error out with a directive message.
3
- //
4
- // The cli relies on Bun-only globals (`Bun.spawn`, `Bun.TOML.parse`,
5
- // `Bun.which`, `process.execve`). When the bin is invoked under Node
6
- // (typically because `npm i -g @fnclaude/cli` puts it on PATH and the
7
- // user runs `fnc`), those Bun calls fail silently — `Bun.which` returns
8
- // `undefined`, `Bun.TOML.parse` throws "Bun is not defined", etc. The
9
- // cli then degrades into a "claude not found" / config-broken state
10
- // that LOOKS like a normal failure but is actually a runtime mismatch.
11
- //
12
- // This preflight runs first and either re-execs under Bun (if `bun` is
13
- // on PATH) or prints a directive error and exits non-zero — never the
14
- // silent-degradation path.
15
- //
16
- // Extracted from the shim itself to keep it unit-testable: the decision
17
- // function takes injected seams (typeof-Bun probe + PATH lookup) and
18
- // returns a discriminated union; the shim handles the dispatch.
19
-
20
- import { spawnSync } from 'node:child_process';
21
-
22
- /**
23
- * @typedef {{ kind: 'run' }
24
- * | { kind: 'reexec', bun: string }
25
- * | { kind: 'error', message: string }
26
- * } PreflightDecision
27
- */
28
-
29
- /**
30
- * Decide what the shim should do.
31
- *
32
- * @param {{ hasBun: boolean, lookupBun: () => string | null }} seams
33
- * @returns {PreflightDecision}
34
- */
35
- export function decide({ hasBun, lookupBun }) {
36
- if (hasBun) return { kind: 'run' };
37
- const bun = lookupBun();
38
- if (bun !== null) return { kind: 'reexec', bun };
39
- return {
40
- kind: 'error',
41
- message:
42
- 'fnclaude: this CLI requires Bun (https://bun.sh) to run.\n' +
43
- ' The shim was invoked under Node, but the CLI depends on Bun-only\n' +
44
- ' globals (Bun.spawn, Bun.TOML.parse, process.execve). Install Bun\n' +
45
- ' and re-run `fnc`, or invoke the script via `bun ...` directly.',
46
- };
47
- }
48
-
49
- /**
50
- * Default PATH lookup for `bun`. Returns the literal string `'bun'` on
51
- * success (the OS will resolve it via PATH at spawn time), or null when
52
- * `bun` is not reachable.
53
- *
54
- * Implementation note: `spawnSync('bun', ['--version'])` is the simplest
55
- * cross-platform probe. ENOENT comes back as `result.error.code`, and a
56
- * present-but-broken bun returns non-zero status. Both are treated as
57
- * "not usable."
58
- *
59
- * @returns {string | null}
60
- */
61
- export function defaultLookupBun() {
62
- const r = spawnSync('bun', ['--version'], { stdio: 'ignore' });
63
- if (r.error) return null;
64
- if (r.status !== 0) return null;
65
- return 'bun';
66
- }
@@ -1 +0,0 @@
1
- When using the Task / Agent tool with isolation set to "worktree", do not name the main repo's absolute path in the spawn prompt — the agent will cd there and silently bypass its isolated worktree. Phrase locations as "your worktree" or "this directory", and verify with git log on the worktree branch after the agent reports done.