@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/README.md +215 -27
- package/package.json +1 -1
- package/src/argParser.ts +19 -32
- package/src/args/preserve.ts +2 -2
- package/src/args.ts +200 -23
- package/src/argv.ts +18 -19
- package/src/config.ts +85 -52
- package/src/help.ts +1 -1
- package/src/hostAliases.ts +40 -15
- package/src/index.ts +11 -5
- package/src/main.ts +209 -88
- package/src/mcp/client.ts +12 -9
- package/src/mcp/protocol.ts +66 -26
- package/src/mcp/socketListener.ts +35 -11
- package/src/passthrough.ts +36 -0
- package/src/paths.ts +31 -0
- package/src/prompts.ts +5 -12
- package/src/pty/unix.ts +250 -107
- package/src/pty.ts +20 -13
- package/src/repoSettings.ts +35 -11
- package/src/sessionState.ts +125 -1
- package/src/silentRelaunch.ts +3 -3
- package/src/spawn.ts +2 -28
- package/src/warnings.ts +15 -31
- package/src/worktree.ts +57 -43
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
let
|
|
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
|
-
|
|
65
|
+
src = data.length - this.cap;
|
|
66
66
|
this.full = true;
|
|
67
67
|
this.pos = 0;
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
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;
|
package/src/repoSettings.ts
CHANGED
|
@@ -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
|
-
):
|
|
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
|
|
63
|
-
*
|
|
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[]):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
/**
|
package/src/sessionState.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/silentRelaunch.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
warnings
|
|
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
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
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
|
|
30
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
131
|
-
*
|
|
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 →
|
|
134
|
-
* 2. Bare -w (worktreeArg="") →
|
|
135
|
-
*
|
|
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 →
|
|
138
|
-
*
|
|
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
|
|
164
|
+
* to resolve a relative `cwd` to an absolute path before querying git.
|
|
142
165
|
*/
|
|
143
166
|
export function applyWorktreeIntercept(
|
|
144
|
-
a:
|
|
167
|
+
a: ResolvedArgs,
|
|
145
168
|
shellCWD: string,
|
|
146
169
|
runner: GitRunner = defaultGitRunner,
|
|
147
|
-
):
|
|
148
|
-
if (!a.worktreeSet)
|
|
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
|
|
153
|
-
|
|
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
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
}
|