@fnclaude/cli 0.7.2 → 0.7.4

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/src/pty.ts CHANGED
@@ -57,18 +57,24 @@ export class RingBuffer {
57
57
  write(p: Buffer | Uint8Array | string): void {
58
58
  const data = typeof p === 'string' ? Buffer.from(p) : Buffer.from(p);
59
59
  if (data.length === 0) return;
60
- // Hot path for small writes: per-byte loop matches Go's reference impl.
61
- // For large writes (> cap) skip ahead so we don't churn over discarded
62
- // bytes that will immediately be overwritten.
63
- let start = 0;
60
+ // For oversize writes (> cap) skip ahead the prefix we'd write would
61
+ // be immediately overwritten by the suffix. Land on a clean state where
62
+ // pos = 0, full = true, and we copy the trailing `cap` bytes in one go.
63
+ let src = 0;
64
64
  if (data.length > this.cap) {
65
- start = data.length - this.cap;
65
+ src = data.length - this.cap;
66
66
  this.full = true;
67
67
  this.pos = 0;
68
68
  }
69
- for (let i = start; i < data.length; i++) {
70
- this.buf[this.pos] = data[i] as number;
71
- this.pos = (this.pos + 1) % this.cap;
69
+ // Copy in up to two chunks: from src to end-of-buf, then wrapped around
70
+ // from start-of-buf for the remainder. `Buffer.copy` is a memcpy under
71
+ // the hood substantially cheaper than the per-byte assignment loop
72
+ // this replaces, for the same final buffer state.
73
+ while (src < data.length) {
74
+ const writable = Math.min(data.length - src, this.cap - this.pos);
75
+ data.copy(this.buf, this.pos, src, src + writable);
76
+ src += writable;
77
+ this.pos = (this.pos + writable) % this.cap;
72
78
  if (this.pos === 0) this.full = true;
73
79
  }
74
80
  }
@@ -117,16 +123,17 @@ export interface CrossCwdMatch {
117
123
  * the LAST match wins.
118
124
  */
119
125
  export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
120
- // Reset regex internal state — crossCwdRe is `g`-flagged.
121
- crossCwdRe.lastIndex = 0;
122
126
  // Decode as Latin-1 so every byte maps to a code unit; the regex matches
123
127
  // ASCII anchors so the multi-byte representation of any non-ASCII bytes
124
128
  // never participates in a match. This is the JS equivalent of Go's
125
129
  // []byte-scanning behavior.
126
130
  const s = tail.toString('latin1');
127
- let last: RegExpExecArray | null = null;
128
- // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
129
- for (let m: RegExpExecArray | null; (m = crossCwdRe.exec(s)) !== null; ) {
131
+ // matchAll iterates from a fresh internal cursor each call — no
132
+ // module-level `lastIndex` to reset. The exported `crossCwdRe` stays
133
+ // `g`-flagged (matchAll requires it) but is only ever consumed as an
134
+ // anchor for tests / the source-of-truth comparison.
135
+ let last: RegExpMatchArray | null = null;
136
+ for (const m of s.matchAll(crossCwdRe)) {
130
137
  last = m;
131
138
  }
132
139
  if (last === null) return null;
@@ -38,6 +38,17 @@ function home(): string {
38
38
  return process.env.HOME ?? homedir();
39
39
  }
40
40
 
41
+ /**
42
+ * Result of a repo-settings load: the merged settings plus any non-fatal
43
+ * warnings (e.g. malformed JSON files that were skipped). Mirrors
44
+ * `LoadConfigResult` so the caller can thread warnings into the deferred
45
+ * flush.
46
+ */
47
+ export interface LoadRepoSettingsResult {
48
+ settings: RepoSettings;
49
+ warnings: readonly string[];
50
+ }
51
+
41
52
  /**
42
53
  * Resolve the four-tier merge for the user's environment.
43
54
  * `projectRoot` is the cwd Claude Code anchors project/local tiers
@@ -46,7 +57,7 @@ function home(): string {
46
57
  export function loadRepoSettings(
47
58
  homeDir: string,
48
59
  projectRoot: string,
49
- ): RepoSettings {
60
+ ): LoadRepoSettingsResult {
50
61
  const paths: string[] = [
51
62
  join(homeDir, '.claude', 'settings.json'), // user
52
63
  join(projectRoot, '.claude', 'settings.json'), // project
@@ -59,13 +70,17 @@ export function loadRepoSettings(
59
70
 
60
71
  /**
61
72
  * Read each path (if it exists) and merge per-field with later entries
62
- * winning over earlier ones. Missing or malformed files are silently
63
- * skipped — same fail-soft posture as the plugin.
73
+ * winning over earlier ones. Missing files are silently skipped (the
74
+ * fail-soft posture the plugin matches); malformed files produce a
75
+ * warning so the user can fix them rather than wondering why their
76
+ * settings don't apply.
64
77
  */
65
- export function mergeRepoSettings(paths: string[]): RepoSettings {
78
+ export function mergeRepoSettings(paths: string[]): LoadRepoSettingsResult {
66
79
  const merged: RepoSettings = {};
80
+ const warnings: string[] = [];
67
81
  for (const p of paths) {
68
- const f = readRepoSettings(p);
82
+ const { settings: f, warning } = readRepoSettings(p);
83
+ if (warning !== null) warnings.push(warning);
69
84
  if (!f) continue;
70
85
  // Shallow-merge per field: only overwrite when the higher tier sets
71
86
  // a non-empty value.
@@ -74,23 +89,32 @@ export function mergeRepoSettings(paths: string[]): RepoSettings {
74
89
  if (f.branchTemplate) merged.branchTemplate = f.branchTemplate;
75
90
  if (f.gateEnvVar) merged.gateEnvVar = f.gateEnvVar;
76
91
  }
77
- return merged;
92
+ return { settings: merged, warnings };
78
93
  }
79
94
 
80
- function readRepoSettings(path: string): RepoSettings | null {
95
+ interface ReadRepoSettingsResult {
96
+ settings: RepoSettings | null;
97
+ warning: string | null;
98
+ }
99
+
100
+ function readRepoSettings(path: string): ReadRepoSettingsResult {
81
101
  let data: string;
82
102
  try {
83
103
  data = readFileSync(path, 'utf8');
84
104
  } catch {
85
- return null;
105
+ // Missing file is the common path — stay silent.
106
+ return { settings: null, warning: null };
86
107
  }
87
108
  let f: SettingsFile;
88
109
  try {
89
110
  f = JSON.parse(data) as SettingsFile;
90
- } catch {
91
- return null;
111
+ } catch (err) {
112
+ return {
113
+ settings: null,
114
+ warning: `fnclaude: repo-settings file ${path} is malformed, skipping: ${(err as Error).message}`,
115
+ };
92
116
  }
93
- return f.repoSettings ?? null;
117
+ return { settings: f.repoSettings ?? null, warning: null };
94
118
  }
95
119
 
96
120
  /**
@@ -3,7 +3,8 @@
3
3
  // CWD encoding for Claude Code's project dir naming scheme, and JSONL
4
4
  // permission-mode last-wins scan over a session log.
5
5
 
6
- import { readFileSync } from 'node:fs';
6
+ import { appendFileSync, readFileSync } from 'node:fs';
7
+ import { randomUUID } from 'node:crypto';
7
8
  import { homedir } from 'node:os';
8
9
  import { join } from 'node:path';
9
10
 
@@ -92,3 +93,126 @@ export function readLivePermissionMode(
92
93
  }
93
94
  return latest;
94
95
  }
96
+
97
+ /**
98
+ * Overrides that may have landed alongside the restart. When any of these
99
+ * are set the appended reminder names them so the resumed model can briefly
100
+ * acknowledge the change before continuing the pre-restart work.
101
+ */
102
+ export interface RestartReminderOverrides {
103
+ model?: string;
104
+ effort?: string;
105
+ permissionMode?: string;
106
+ agent?: string;
107
+ /** True iff `--ide` is being added on the relaunch. */
108
+ ide?: boolean;
109
+ }
110
+
111
+ /**
112
+ * Read the trailing entries of `data` and return the most recent `uuid`
113
+ * field, or `null` if none found. Used to link the appended reminder into
114
+ * the JSONL parent-chain.
115
+ */
116
+ function lastEntryUUID(data: string): string | null {
117
+ const lines = data.split('\n');
118
+ for (let i = lines.length - 1; i >= 0; i--) {
119
+ const line = lines[i];
120
+ if (!line || line.length === 0) continue;
121
+ let parsed: { uuid?: unknown };
122
+ try {
123
+ parsed = JSON.parse(line) as typeof parsed;
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (typeof parsed.uuid === 'string' && parsed.uuid.length > 0) {
128
+ return parsed.uuid;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** Render the system-reminder body text, optionally naming overrides. */
135
+ export function renderRestartReminderContent(
136
+ overrides?: RestartReminderOverrides,
137
+ ): string {
138
+ const parts: string[] = [];
139
+ if (overrides?.model && overrides.model !== '') {
140
+ parts.push(`model swap to ${overrides.model}`);
141
+ }
142
+ if (overrides?.effort && overrides.effort !== '') {
143
+ parts.push(`effort=${overrides.effort}`);
144
+ }
145
+ if (overrides?.permissionMode && overrides.permissionMode !== '') {
146
+ parts.push(`permission-mode=${overrides.permissionMode}`);
147
+ }
148
+ if (overrides?.agent && overrides.agent !== '') {
149
+ parts.push(`agent=${overrides.agent}`);
150
+ }
151
+ if (overrides?.ide) {
152
+ parts.push('--ide connected');
153
+ }
154
+ const overrideClause =
155
+ parts.length > 0
156
+ ? ` Restart-specific overrides applied: ${parts.join(', ')} — acknowledge briefly, then continue.`
157
+ : '';
158
+ return (
159
+ '<system-reminder>\n' +
160
+ 'This session was restarted via fnc_restart (all prior context and the ' +
161
+ 'session JSONL are preserved). Resume the work that was in flight ' +
162
+ 'before the restart — finish the task, monitor what you were ' +
163
+ 'monitoring, surface results — rather than treating this as a fresh ' +
164
+ 'session.' +
165
+ overrideClause +
166
+ '\n</system-reminder>'
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Append an `isMeta:true` user-message bearing a `<system-reminder>` block
172
+ * to the session JSONL at `launchCWD` / `sessionID`. Best-effort: missing
173
+ * or unreadable JSONL is silently tolerated (the restart should still
174
+ * proceed; the reminder is a UX nicety, not a hard requirement).
175
+ *
176
+ * Shape matches the entries Claude Code itself emits for inline reminders
177
+ * — `type:"user"`, `message:{role:"user",content:"<system-reminder>…</system-reminder>"}`,
178
+ * `isMeta:true`. The `parentUuid` is linked to the most recent entry's
179
+ * `uuid` so the resumed session reads it as a fresh terminal user turn.
180
+ */
181
+ export function appendRestartReminder(
182
+ launchCWD: string,
183
+ sessionID: string,
184
+ overrides?: RestartReminderOverrides,
185
+ ): void {
186
+ const path = sessionJSONLPath(launchCWD, sessionID);
187
+ let existing: string;
188
+ try {
189
+ existing = readFileSync(path, 'utf8');
190
+ } catch {
191
+ // No JSONL — nothing to append to. The relaunched claude will start
192
+ // fresh anyway, so the reminder would be off-target.
193
+ return;
194
+ }
195
+ const parentUuid = lastEntryUUID(existing);
196
+ const entry = {
197
+ parentUuid,
198
+ isSidechain: false,
199
+ type: 'user' as const,
200
+ message: {
201
+ role: 'user' as const,
202
+ content: renderRestartReminderContent(overrides),
203
+ },
204
+ isMeta: true,
205
+ uuid: randomUUID(),
206
+ timestamp: new Date().toISOString(),
207
+ userType: 'external' as const,
208
+ cwd: launchCWD,
209
+ sessionId: sessionID,
210
+ };
211
+ try {
212
+ appendFileSync(path, `${JSON.stringify(entry)}\n`);
213
+ } catch {
214
+ // Best-effort — disk full, permission denied, raced unlink, etc. The
215
+ // restart proceeds; user gets the historical "Restarted." idle behavior
216
+ // rather than a hard failure.
217
+ }
218
+ }
@@ -18,7 +18,7 @@
18
18
  import { spawn } from 'node:child_process';
19
19
  import process from 'node:process';
20
20
  import { clearScreen, reconstructArgv } from './pty.js';
21
- import { selfPath } from './spawn.js';
21
+ import { resolveSelfPath } from './paths.js';
22
22
 
23
23
  // `process.execve` is a Bun-native POSIX-only API (1.3.14+). @types/bun
24
24
  // hasn't typed it yet — declare the shape inline so TS strict mode is happy.
@@ -53,7 +53,7 @@ export function silentRelaunch(
53
53
  ): void {
54
54
  let self: string;
55
55
  try {
56
- self = selfPath();
56
+ self = resolveSelfPath();
57
57
  } catch (err) {
58
58
  process.stderr.write(
59
59
  `fnclaude: cannot determine executable, cannot relaunch: ${(err as Error).message}\n`,
@@ -84,7 +84,7 @@ export function silentRelaunchHandoff(
84
84
  ): void {
85
85
  let self: string;
86
86
  try {
87
- self = selfPath();
87
+ self = resolveSelfPath();
88
88
  } catch (err) {
89
89
  process.stderr.write(
90
90
  `fnclaude: cannot determine executable, cannot relaunch: ${(err as Error).message}\n`,
package/src/spawn.ts CHANGED
@@ -9,10 +9,9 @@
9
9
  // The indirection via spawnFn lets tests inject a mock without launching
10
10
  // real processes.
11
11
 
12
- import { realpathSync } from 'node:fs';
13
- import { dirname } from 'node:path';
14
12
  import process from 'node:process';
15
13
  import type { Config } from './config.js';
14
+ import { resolveSelfPath } from './paths.js';
16
15
  import { substitute } from './template.js';
17
16
 
18
17
  // ── env cleaning ───────────────────────────────────────────────────────────
@@ -39,31 +38,6 @@ export function cleanEnvForSpawn(env: string[]): string[] {
39
38
  return out;
40
39
  }
41
40
 
42
- // ── selfPath ───────────────────────────────────────────────────────────────
43
-
44
- /**
45
- * Return the absolute, symlink-resolved path to this fnclaude script,
46
- * suitable for `{bin}` substitution in a spawn-launcher template.
47
- *
48
- * Preference order mirrors prompts.ts (Unit 6):
49
- * 1. process.argv[1] — the CLI script path (anchors to the script, not the
50
- * Bun interpreter).
51
- * 2. process.execPath — the Bun binary; fallback when argv[1] is absent.
52
- *
53
- * Symlinks are resolved so the spawned launcher gets the real path, not a
54
- * shim that might not be on the PATH inside the new window.
55
- */
56
- export function selfPath(): string {
57
- const argv1 = process.argv.length > 1 ? process.argv[1] : undefined;
58
- let exe = argv1 !== undefined && argv1 !== '' ? argv1 : process.execPath;
59
- try {
60
- exe = realpathSync(exe);
61
- } catch {
62
- // symlink resolution failure is not fatal — use the unresolved path
63
- }
64
- return exe;
65
- }
66
-
67
41
  // ── autoDetectSpawnCommand ─────────────────────────────────────────────────
68
42
 
69
43
  /**
@@ -162,7 +136,7 @@ export async function spawnSibling(
162
136
  extraArgs: string[],
163
137
  spawnFn: SpawnFn = defaultSpawnFn,
164
138
  ): Promise<boolean> {
165
- const bin = selfPath();
139
+ const bin = resolveSelfPath();
166
140
 
167
141
  let tmpl = cfg.auto.spawnCommand;
168
142
  if (!tmpl) {
package/src/warnings.ts CHANGED
@@ -5,46 +5,30 @@
5
5
  // scroll off-screen too fast to read; flushing on exit shows them in the
6
6
  // user's shell where they have time to actually be seen.
7
7
  //
8
+ // There is no module-global queue here — every loader (loadConfig,
9
+ // loadRepoSettings, loadHostAliases, loadPrompts) returns its warnings
10
+ // alongside its result, and `main.ts` threads them into a single local
11
+ // list that `flushWarnings` drains at the deferred-flush point. The old
12
+ // global queue made test fixtures share state across files and forced
13
+ // callers to know about a sink module they otherwise didn't depend on;
14
+ // the explicit-thread shape is the fix.
15
+ //
8
16
  // Fatal errors that prevent launch entirely (e.g. claude binary not on PATH)
9
17
  // should still print directly to stderr and exit non-zero — those don't
10
18
  // need deferring because there's no claude session about to drown them out.
11
19
 
12
20
  import process from 'node:process';
13
21
 
14
- const warnings: string[] = [];
15
-
16
22
  /**
17
- * Queue a non-fatal warning. Accepts a pre-formatted message (callers do
18
- * their own templating); printed verbatim on flush.
23
+ * Print each warning to `stream` on its own line. Returns the number of
24
+ * warnings written (useful for tests). Empty input is a no-op.
19
25
  */
20
- export function warn(msg: string): void {
21
- warnings.push(msg);
22
- }
23
-
24
- /**
25
- * Print all queued warnings to stderr in order, then clear the queue.
26
- * Called from `run()` after claude exits. Returns the number of warnings
27
- * that were flushed (useful for tests).
28
- */
29
- export function flushWarnings(stream: NodeJS.WriteStream = process.stderr): number {
30
- const n = warnings.length;
26
+ export function flushWarnings(
27
+ warnings: readonly string[],
28
+ stream: NodeJS.WriteStream = process.stderr,
29
+ ): number {
31
30
  for (const w of warnings) {
32
31
  stream.write(`${w}\n`);
33
32
  }
34
- warnings.length = 0;
35
- return n;
36
- }
37
-
38
- /**
39
- * Test helper: return a snapshot of the current queue without flushing.
40
- */
41
- export function pendingWarnings(): readonly string[] {
42
- return [...warnings];
43
- }
44
-
45
- /**
46
- * Test helper: clear the queue without emitting anything.
47
- */
48
- export function clearWarnings(): void {
49
- warnings.length = 0;
33
+ return warnings.length;
50
34
  }
package/src/worktree.ts CHANGED
@@ -8,14 +8,18 @@
8
8
  // pass a GitRunner in, with `defaultGitRunner` exported for production
9
9
  // use. Both shapes give tests deterministic control without an env or
10
10
  // module-state assumption.
11
- // - applyWorktreeIntercept mutates Args in place to match the Go signature
12
- // `*Args`. This keeps the call site in run() simple: `applyWorktreeIntercept(args, cwd)`
13
- // reads naturally, and Args is a single-owner container at that point in
14
- // the lifecycle no aliasing concerns.
11
+ // - applyWorktreeIntercept is a pure stage transition: takes a
12
+ // `ResolvedArgs`, returns an `InterceptedArgs` that carries the new
13
+ // `worktreeMatched` invariant. The cwd / passthrough overrides flow
14
+ // through `withIntercepted`; nothing is mutated in place.
15
15
 
16
- import { execFileSync } from 'node:child_process';
17
16
  import { isAbsolute, join } from 'node:path';
18
- import type { Args } from './args.js';
17
+ import {
18
+ withIntercepted,
19
+ type InterceptedArgs,
20
+ type ResolvedArgs,
21
+ } from './args.js';
22
+ import { nameInPassthrough } from './passthrough.js';
19
23
 
20
24
  /**
21
25
  * GitRunner is a thin wrapper around `git -C <dir> <args...>`. Returns the
@@ -26,14 +30,30 @@ import type { Args } from './args.js';
26
30
  export type GitRunner = (dir: string, ...args: string[]) => string;
27
31
 
28
32
  /**
29
- * Production GitRunner. Spawns git synchronously (same shape as Go's
30
- * `exec.Command(...).Output()` posture in the reference).
33
+ * Production GitRunner. Spawns git synchronously via Bun.spawnSync same
34
+ * mechanism the rest of the codebase uses for its child-process work
35
+ * (autoname, resolver, clipboard, spawn). Node's `execFileSync` here was
36
+ * the lone holdout; switching unifies the spawn layer.
37
+ *
38
+ * Behaviour preserved from the prior `execFileSync` version:
39
+ * - On a successful run (exit 0), returns the UTF-8-decoded stdout.
40
+ * - On non-zero exit, throws an Error with the git stderr verbatim —
41
+ * callers (listWorktrees) catch any thrown value and treat it as
42
+ * "no match possible", so the exact shape of the error doesn't matter
43
+ * beyond being throwable.
44
+ * - If `git` isn't on PATH, Bun.spawnSync throws ENOENT itself (same
45
+ * posture as execFileSync did).
31
46
  */
32
47
  export const defaultGitRunner: GitRunner = (dir, ...args) => {
33
- return execFileSync('git', ['-C', dir, ...args], {
34
- encoding: 'utf8',
35
- stdio: ['ignore', 'pipe', 'pipe'],
48
+ const proc = Bun.spawnSync(['git', '-C', dir, ...args], {
49
+ stdout: 'pipe',
50
+ stderr: 'pipe',
36
51
  });
52
+ if (proc.exitCode !== 0) {
53
+ const stderr = proc.stderr?.toString('utf8') ?? '';
54
+ throw new Error(`git -C ${dir} ${args.join(' ')} exited ${proc.exitCode}: ${stderr.trim()}`);
55
+ }
56
+ return proc.stdout?.toString('utf8') ?? '';
37
57
  };
38
58
 
39
59
  /**
@@ -127,30 +147,37 @@ function basename(p: string): string {
127
147
  }
128
148
 
129
149
  /**
130
- * applyWorktreeIntercept applies the -w / --worktree intercept logic to a.
131
- * It may modify a.cwd, a.passthrough, and a.worktreeMatched in place.
150
+ * applyWorktreeIntercept applies the -w / --worktree intercept logic.
151
+ *
152
+ * Pure function: takes a `ResolvedArgs`, returns a new `InterceptedArgs`.
153
+ * No input is mutated. The four cases:
132
154
  *
133
- * 1. worktreeSet=false → no-op.
134
- * 2. Bare -w (worktreeArg="") → push --worktree through unchanged.
135
- * 3. Existing worktree matched → swap a.cwd to the worktree, set
155
+ * 1. worktreeSet=false → carry through with worktreeMatched=false.
156
+ * 2. Bare -w (worktreeArg="") → append --worktree to passthrough,
157
+ * worktreeMatched=false.
158
+ * 3. Existing worktree matched → swap cwd to the worktree path, set
136
159
  * worktreeMatched=true, suppress --worktree.
137
- * 4. Otherwise → push --worktree <name> through, plus --name <name>
138
- * (when --name isn't already set).
160
+ * 4. Otherwise → append --worktree <name>, plus --name <name> when
161
+ * --name isn't already set; worktreeMatched=false.
139
162
  *
140
163
  * `shellCWD` is the process working directory at fnclaude startup, used
141
- * to resolve a relative a.cwd to an absolute path before querying git.
164
+ * to resolve a relative `cwd` to an absolute path before querying git.
142
165
  */
143
166
  export function applyWorktreeIntercept(
144
- a: Args,
167
+ a: ResolvedArgs,
145
168
  shellCWD: string,
146
169
  runner: GitRunner = defaultGitRunner,
147
- ): void {
148
- if (!a.worktreeSet) return;
170
+ ): InterceptedArgs {
171
+ if (!a.worktreeSet) {
172
+ return withIntercepted(a, { worktreeMatched: false });
173
+ }
149
174
 
150
175
  // Bare -w with no name: push --worktree back through unchanged.
151
176
  if (a.worktreeArg === '') {
152
- a.passthrough.push('--worktree');
153
- return;
177
+ return withIntercepted(a, {
178
+ passthrough: [...a.passthrough, '--worktree'],
179
+ worktreeMatched: false,
180
+ });
154
181
  }
155
182
 
156
183
  // Resolve absolute cwd for git queries.
@@ -161,26 +188,13 @@ export function applyWorktreeIntercept(
161
188
  const hit = findWorktree(listWorktrees(dir, runner), a.worktreeArg);
162
189
  if (hit) {
163
190
  // Existing worktree matched: swap cwd, suppress -w.
164
- a.cwd = hit.path;
165
- a.worktreeMatched = true;
166
- return;
191
+ return withIntercepted(a, { cwd: hit.path, worktreeMatched: true });
167
192
  }
168
193
 
169
194
  // No match (or not a repo): pass --worktree through and attach --name.
170
- a.passthrough.push('--worktree', a.worktreeArg);
171
- if (!nameInPassthrough(a.passthrough)) {
172
- a.passthrough.push('--name', a.worktreeArg);
173
- }
174
- }
175
-
176
- /**
177
- * nameInPassthrough — local copy of the helper in argParser.ts. Replicated
178
- * to avoid an import cycle (argParser → buildArgv → worktree → argParser).
179
- * Both copies must agree; the shared contract is "--name or -n, bare or
180
- * =value, anywhere in the slice."
181
- */
182
- function nameInPassthrough(passthrough: readonly string[]): boolean {
183
- return passthrough.some(
184
- (t) => t === '--name' || t === '-n' || t.startsWith('--name=') || t.startsWith('-n='),
185
- );
195
+ const withWt = [...a.passthrough, '--worktree', a.worktreeArg];
196
+ const passthrough = nameInPassthrough(withWt)
197
+ ? withWt
198
+ : [...withWt, '--name', a.worktreeArg];
199
+ return withIntercepted(a, { passthrough, worktreeMatched: false });
186
200
  }