@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.
- package/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -203
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- package/src/worktree.ts +0 -201
package/src/pty.ts
DELETED
|
@@ -1,380 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared PTY scaffolding — RingBuffer, cross-cwd detection regex,
|
|
3
|
-
* reconstructArgv helper, ensureCWD safety wrapper, and the platform-
|
|
4
|
-
* dispatching `runWithPTY` entry point.
|
|
5
|
-
*
|
|
6
|
-
* Ported from src/pty_run.go in the Go reference (fnclaude@fnrhombus).
|
|
7
|
-
*
|
|
8
|
-
* Platform-specific spawn lives in:
|
|
9
|
-
* - src/pty/unix.ts — node-pty under POSIX
|
|
10
|
-
* - src/pty/windows.ts — direct child_process.spawn, no PTY (stub)
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { mkdir, rmdir, stat } from 'node:fs/promises';
|
|
14
|
-
import type { Stats } from 'node:fs';
|
|
15
|
-
import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
16
|
-
import type { Config } from './config.js';
|
|
17
|
-
import { errorMessage } from './errors.js';
|
|
18
|
-
import type { HandoffSpec } from './handoff.js';
|
|
19
|
-
import { isFlag, isMagicWord, preserveArgs, splitLeadingMagic } from './args/preserve.js';
|
|
20
|
-
|
|
21
|
-
// ── RingBuffer ─────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Capacity of the PTY output tail kept for the post-exit cross-cwd scan.
|
|
25
|
-
*
|
|
26
|
-
* Sized to comfortably hold the cross-cwd message plus all the screen-cleanup
|
|
27
|
-
* escapes claude emits while tearing down its TUI on exit. An earlier 4 KB
|
|
28
|
-
* value was just big enough for the original captured fixture but failed in
|
|
29
|
-
* the wild when claude 2.1.143 emitted more trailing cleanup before exit —
|
|
30
|
-
* the message rotated out of the tail and the intercept silently failed.
|
|
31
|
-
*/
|
|
32
|
-
export const RING_BUFFER_SIZE = 64 * 1024;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Fixed-capacity circular byte buffer. Writes that overflow the capacity
|
|
36
|
-
* discard the oldest data. Only the most recent `cap` bytes are kept, which
|
|
37
|
-
* is all we need for post-exit pattern scanning.
|
|
38
|
-
*
|
|
39
|
-
* Implementation note: backed by a Node Buffer (vs Uint8Array) so the
|
|
40
|
-
* outbound `bytes()` slice is already in the form that node:net /
|
|
41
|
-
* RegExp.exec(buffer.toString('utf8' | 'binary')) callers expect.
|
|
42
|
-
*/
|
|
43
|
-
export class RingBuffer {
|
|
44
|
-
private readonly buf: Buffer;
|
|
45
|
-
readonly cap: number;
|
|
46
|
-
private pos = 0;
|
|
47
|
-
private full = false;
|
|
48
|
-
|
|
49
|
-
constructor(capacity: number) {
|
|
50
|
-
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
51
|
-
throw new RangeError(`RingBuffer capacity must be a positive integer, got ${capacity}`);
|
|
52
|
-
}
|
|
53
|
-
this.cap = capacity;
|
|
54
|
-
this.buf = Buffer.alloc(capacity);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Append data, dropping oldest bytes when full. */
|
|
58
|
-
write(p: Buffer | Uint8Array | string): void {
|
|
59
|
-
const data = typeof p === 'string' ? Buffer.from(p) : Buffer.from(p);
|
|
60
|
-
if (data.length === 0) return;
|
|
61
|
-
// For oversize writes (> cap) skip ahead — the prefix we'd write would
|
|
62
|
-
// be immediately overwritten by the suffix. Land on a clean state where
|
|
63
|
-
// pos = 0, full = true, and we copy the trailing `cap` bytes in one go.
|
|
64
|
-
let src = 0;
|
|
65
|
-
if (data.length > this.cap) {
|
|
66
|
-
src = data.length - this.cap;
|
|
67
|
-
this.full = true;
|
|
68
|
-
this.pos = 0;
|
|
69
|
-
}
|
|
70
|
-
// Copy in up to two chunks: from src to end-of-buf, then wrapped around
|
|
71
|
-
// from start-of-buf for the remainder. `Buffer.copy` is a memcpy under
|
|
72
|
-
// the hood — substantially cheaper than the per-byte assignment loop
|
|
73
|
-
// this replaces, for the same final buffer state.
|
|
74
|
-
while (src < data.length) {
|
|
75
|
-
const writable = Math.min(data.length - src, this.cap - this.pos);
|
|
76
|
-
data.copy(this.buf, this.pos, src, src + writable);
|
|
77
|
-
src += writable;
|
|
78
|
-
this.pos = (this.pos + writable) % this.cap;
|
|
79
|
-
if (this.pos === 0) this.full = true;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Return ring contents in chronological order (oldest first). */
|
|
84
|
-
bytes(): Buffer {
|
|
85
|
-
if (!this.full) {
|
|
86
|
-
return Buffer.from(this.buf.subarray(0, this.pos));
|
|
87
|
-
}
|
|
88
|
-
return Buffer.concat([
|
|
89
|
-
this.buf.subarray(this.pos),
|
|
90
|
-
this.buf.subarray(0, this.pos),
|
|
91
|
-
]);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ── cross-cwd detection ────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Matches the cd-and-resume line claude prints when the selected session
|
|
99
|
-
* belongs to a different directory. SOURCE OF TRUTH — keep byte-for-byte
|
|
100
|
-
* identical to src/pty_run.go's `crossCwdRe`.
|
|
101
|
-
*
|
|
102
|
-
* We can't anchor on the "This conversation is from a different directory."
|
|
103
|
-
* preamble: claude's TUI emits cursor-right escapes (e.g. `\x1b[1C`) between
|
|
104
|
-
* words instead of literal spaces, so that sentence is never plain-text in
|
|
105
|
-
* the PTY stream. The "To resume, run:" line, by contrast, is rendered as
|
|
106
|
-
* plain ASCII with real spaces, as is the `cd <path> && claude --resume <uuid>`
|
|
107
|
-
* command — both anchors survive the TUI rendering intact.
|
|
108
|
-
*
|
|
109
|
-
* The `[\s\S]*?` between anchors swallows whatever ANSI / CR / cursor-move
|
|
110
|
-
* goo appears between the two lines (varies by terminal width and TUI
|
|
111
|
-
* layout — observed: `\x1b[K\r\x1b[1C\x1b[1B`).
|
|
112
|
-
*/
|
|
113
|
-
export const crossCwdRe =
|
|
114
|
-
/To resume, run:[\s\S]*?cd (\S+) && claude --resume ([0-9a-fA-F-]{36})/g;
|
|
115
|
-
|
|
116
|
-
export interface CrossCwdMatch {
|
|
117
|
-
dest: string;
|
|
118
|
-
uuid: string;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Scan `tail` for the cross-cwd redirect message. Returns undefined when no
|
|
123
|
-
* match is found OR when the captured `dest` fails safety validation.
|
|
124
|
-
* When multiple matches appear (unlikely but defensive), the LAST match
|
|
125
|
-
* wins.
|
|
126
|
-
*
|
|
127
|
-
* Security note: the `dest` capture flows into `silentRelaunch` and
|
|
128
|
-
* becomes the cwd for the relaunched process. The PTY stream is not a
|
|
129
|
-
* trusted channel — a hostile MCP tool (or any subprocess that prints to
|
|
130
|
-
* claude's terminal) can emit a fake "To resume, run: cd /tmp/evil &&
|
|
131
|
-
* claude --resume <uuid>" line and steer the parent into relaunching in
|
|
132
|
-
* an attacker-controlled directory. We refuse to act on a dest unless
|
|
133
|
-
* it's an absolute path that survives canonicalisation unchanged and
|
|
134
|
-
* contains no null bytes / `..` segments.
|
|
135
|
-
*/
|
|
136
|
-
export function detectCrossCwd(tail: Buffer): CrossCwdMatch | undefined {
|
|
137
|
-
// Decode as Latin-1 so every byte maps to a code unit; the regex matches
|
|
138
|
-
// ASCII anchors so the multi-byte representation of any non-ASCII bytes
|
|
139
|
-
// never participates in a match. This is the JS equivalent of Go's
|
|
140
|
-
// []byte-scanning behavior.
|
|
141
|
-
const s = tail.toString('latin1');
|
|
142
|
-
// matchAll iterates from a fresh internal cursor each call — no
|
|
143
|
-
// module-level `lastIndex` to reset. The exported `crossCwdRe` stays
|
|
144
|
-
// `g`-flagged (matchAll requires it) but is only ever consumed as an
|
|
145
|
-
// anchor for tests / the source-of-truth comparison.
|
|
146
|
-
let last: RegExpMatchArray | undefined;
|
|
147
|
-
for (const m of s.matchAll(crossCwdRe)) {
|
|
148
|
-
last = m;
|
|
149
|
-
}
|
|
150
|
-
if (last === undefined) return undefined;
|
|
151
|
-
const dest = last[1]!;
|
|
152
|
-
if (!isSafeDest(dest)) return undefined;
|
|
153
|
-
return { dest, uuid: last[2]! };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Reject `dest` values that shouldn't be honored as relaunch cwds:
|
|
158
|
-
* - contains a null byte
|
|
159
|
-
* - is not an absolute path (a relative dest would resolve against
|
|
160
|
-
* whatever the current cwd happens to be — non-obvious to a user
|
|
161
|
-
* reading the relaunch and easy to abuse)
|
|
162
|
-
* - contains a `..` segment delimited by `/` (path traversal)
|
|
163
|
-
* - doesn't round-trip through `path.resolve` (catches `/foo/./bar`,
|
|
164
|
-
* trailing slashes, and any other non-canonical form a peer might
|
|
165
|
-
* cook up to slip past parent-segment detection)
|
|
166
|
-
*
|
|
167
|
-
* On Windows we'd also want backslash handling; the cross-cwd-resume
|
|
168
|
-
* flow is POSIX-only by design (the Windows PTY stub disables it) so
|
|
169
|
-
* this validator targets POSIX paths.
|
|
170
|
-
*/
|
|
171
|
-
function isSafeDest(dest: string): boolean {
|
|
172
|
-
if (dest.includes('\x00')) return false;
|
|
173
|
-
if (!isAbsolute(dest)) return false;
|
|
174
|
-
if (dest.split('/').includes('..')) return false;
|
|
175
|
-
if (resolvePath(dest) !== dest) return false;
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── reconstructArgv ────────────────────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Build the new fnclaude argument list when silently relaunching after a
|
|
183
|
-
* cross-cwd session resume.
|
|
184
|
-
*
|
|
185
|
-
* `origArgs` is `process.argv.slice(2)` from the original invocation.
|
|
186
|
-
* `dest` is the destination directory extracted from claude's message;
|
|
187
|
-
* `uuid` is the session id to resume.
|
|
188
|
-
*
|
|
189
|
-
* Algorithm (delegated to preserveArgs): keep leading magic words, strip
|
|
190
|
-
* positional path tokens, keep everything from the first flag onward (no
|
|
191
|
-
* denylist — cross-cwd resume preserves all flags).
|
|
192
|
-
*
|
|
193
|
-
* Result: preserved_magic + [dest] + ["--resume", uuid] + rest.
|
|
194
|
-
*
|
|
195
|
-
* Note: if the original argv already contained --resume / -r / --continue /
|
|
196
|
-
* -c, the picker wouldn't have been shown, the cross-cwd pattern wouldn't
|
|
197
|
-
* have been emitted, and this function wouldn't be called. No special-case
|
|
198
|
-
* is needed for those flags.
|
|
199
|
-
*/
|
|
200
|
-
export function reconstructArgv(
|
|
201
|
-
origArgs: readonly string[],
|
|
202
|
-
dest: string,
|
|
203
|
-
uuid: string,
|
|
204
|
-
): string[] {
|
|
205
|
-
const preserved = preserveArgs(origArgs, null, null);
|
|
206
|
-
const { magic, rest } = splitLeadingMagic(preserved);
|
|
207
|
-
return [...magic, dest, '--resume', uuid, ...rest];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Re-export magic helpers so callers can do everything via the pty module.
|
|
211
|
-
export { isFlag, isMagicWord, splitLeadingMagic };
|
|
212
|
-
|
|
213
|
-
// ── clearScreen ────────────────────────────────────────────────────────────
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Write the ANSI escape sequence that clears the screen and moves the
|
|
217
|
-
* cursor to the top-left. Called before relaunching to hide the brief
|
|
218
|
-
* flicker of the "different directory" message that already scrolled to
|
|
219
|
-
* the terminal before we detected it.
|
|
220
|
-
*/
|
|
221
|
-
export function clearScreen(out: NodeJS.WriteStream = process.stdout): void {
|
|
222
|
-
out.write('\x1b[2J\x1b[H');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── ensureCWD ──────────────────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
export interface EnsureCWDHandle {
|
|
228
|
-
/**
|
|
229
|
-
* Best-effort tear-down of any directory tree fabricated by ensureCWD.
|
|
230
|
-
* Walks back through the dirs we created (deepest first). A dir that
|
|
231
|
-
* was already removed by something else is treated as success
|
|
232
|
-
* (postcondition already satisfied). A dir that's unexpectedly
|
|
233
|
-
* non-empty surfaces as a thrown error.
|
|
234
|
-
*/
|
|
235
|
-
cleanup(): Promise<void>;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Guarantee `dir` exists at the moment of process spawn.
|
|
240
|
-
*
|
|
241
|
-
* Motivation: when fnclaude resumes a session whose stored cwd no longer
|
|
242
|
-
* exists on disk, the kernel returns ENOENT during exec — but Node /
|
|
243
|
-
* Bun formats that against the binary path ("ENOENT … spawn …"), which
|
|
244
|
-
* falsely blames the claude binary. The fix is to ensure the cwd exists
|
|
245
|
-
* before spawn. When it doesn't, we fabricate the full tree, then
|
|
246
|
-
* IMMEDIATELY unwind it after the child has been spawned — once claude
|
|
247
|
-
* has chdir'd into the dir its kernel cwd is held by inode reference and
|
|
248
|
-
* the path on disk is no longer needed.
|
|
249
|
-
*
|
|
250
|
-
* If the path exists but isn't a directory, ensureCWD rejects without
|
|
251
|
-
* touching the filesystem. If the path doesn't exist and an ancestor is
|
|
252
|
-
* a file, ensureCWD likewise rejects without touching the filesystem.
|
|
253
|
-
*/
|
|
254
|
-
export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
|
|
255
|
-
let info: Stats | undefined;
|
|
256
|
-
try {
|
|
257
|
-
info = await stat(dir);
|
|
258
|
-
} catch (err) {
|
|
259
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
260
|
-
}
|
|
261
|
-
if (info !== undefined) {
|
|
262
|
-
if (!info.isDirectory()) {
|
|
263
|
-
throw new Error(`session cwd ${dir} exists but is not a directory`);
|
|
264
|
-
}
|
|
265
|
-
return { cleanup: async () => undefined };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Walk up to find the deepest pre-existing ancestor, recording every
|
|
269
|
-
// missing level shallowest-first. We mkdir each level explicitly (rather
|
|
270
|
-
// than calling mkdir({recursive: true})) so cleanup only touches dirs
|
|
271
|
-
// we actually created.
|
|
272
|
-
const missing: string[] = [];
|
|
273
|
-
let p = dir;
|
|
274
|
-
for (;;) {
|
|
275
|
-
missing.unshift(p);
|
|
276
|
-
const parent = dirname(p);
|
|
277
|
-
if (parent === p) {
|
|
278
|
-
throw new Error(`session cwd ${dir} does not exist and has no existing ancestor`);
|
|
279
|
-
}
|
|
280
|
-
let parentInfo: Stats | undefined;
|
|
281
|
-
try {
|
|
282
|
-
parentInfo = await stat(parent);
|
|
283
|
-
} catch (err) {
|
|
284
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
285
|
-
}
|
|
286
|
-
if (parentInfo !== undefined) {
|
|
287
|
-
if (!parentInfo.isDirectory()) {
|
|
288
|
-
throw new Error(
|
|
289
|
-
`session cwd ${dir} cannot be created: ancestor ${parent} is not a directory`,
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
break;
|
|
293
|
-
}
|
|
294
|
-
p = parent;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const created: string[] = []; // shallowest-first; cleanup reverses
|
|
298
|
-
for (const level of missing) {
|
|
299
|
-
try {
|
|
300
|
-
await mkdir(level, { mode: 0o755 });
|
|
301
|
-
} catch (err) {
|
|
302
|
-
// Roll back what we already created so we leave the filesystem
|
|
303
|
-
// exactly as we found it.
|
|
304
|
-
for (let i = created.length - 1; i >= 0; i--) {
|
|
305
|
-
try {
|
|
306
|
-
await rmdir(created[i]!);
|
|
307
|
-
} catch {
|
|
308
|
-
// best-effort
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
throw new Error(
|
|
312
|
-
`session cwd ${dir} does not exist and could not be created: ${errorMessage(err)}`,
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
created.push(level);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
cleanup: async () => {
|
|
320
|
-
for (let i = created.length - 1; i >= 0; i--) {
|
|
321
|
-
const level = created[i]!;
|
|
322
|
-
try {
|
|
323
|
-
// rmdir() — non-recursive — so a non-empty dir surfaces as an
|
|
324
|
-
// error rather than nuking unexpected content.
|
|
325
|
-
await rmdir(level);
|
|
326
|
-
} catch (err) {
|
|
327
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
328
|
-
if (code === 'ENOENT') continue; // already gone — fine
|
|
329
|
-
throw new Error(`could not clean up auto-created ${level}: ${errorMessage(err)}`);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ── runWithPTY ─────────────────────────────────────────────────────────────
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Result returned by `runWithPTY`. `tail` is the ring buffer contents at
|
|
340
|
-
* the moment the child exited; `handoffArgv` is populated only when the
|
|
341
|
-
* socket listener fired `triggered()` and stashed a relaunch argv.
|
|
342
|
-
*
|
|
343
|
-
* On Windows the tail is undefined (no PTY, no ring buffer,
|
|
344
|
-
* cross-cwd-resume is a no-op).
|
|
345
|
-
*/
|
|
346
|
-
export interface RunResult {
|
|
347
|
-
exitCode: number;
|
|
348
|
-
tail: Buffer | undefined;
|
|
349
|
-
handoffArgv: string[] | undefined;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export interface RunOptions {
|
|
353
|
-
/**
|
|
354
|
-
* argv to invoke. claudeArgv[0] is conventionally the program name and
|
|
355
|
-
* is ignored by the spawn; claudeArgv.slice(1) is passed as positional
|
|
356
|
-
* args to the child.
|
|
357
|
-
*/
|
|
358
|
-
claudeArgv: string[];
|
|
359
|
-
launchCWD: string;
|
|
360
|
-
cfg: Config;
|
|
361
|
-
/** Undefined disables handoff (no env injection, no listener). */
|
|
362
|
-
handoff: HandoffSpec | undefined;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Spawn claude under a PTY (POSIX) or with inherited stdio (Windows),
|
|
367
|
-
* starting the AF_UNIX listener first when `handoff` is set so the socket
|
|
368
|
-
* is ready the moment the child starts.
|
|
369
|
-
*
|
|
370
|
-
* The implementation lives in pty/unix.ts or pty/windows.ts; this is the
|
|
371
|
-
* dispatcher.
|
|
372
|
-
*/
|
|
373
|
-
export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
374
|
-
if (process.platform === 'win32') {
|
|
375
|
-
const mod = await import('./pty/windows.js');
|
|
376
|
-
return mod.runWithPTY(opts);
|
|
377
|
-
}
|
|
378
|
-
const mod = await import('./pty/unix.js');
|
|
379
|
-
return mod.runWithPTY(opts);
|
|
380
|
-
}
|
package/src/repoRef.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
// Parse user-typed repo references into structured RepoRef values.
|
|
2
|
-
// Ported from src/repo_ref.go.
|
|
3
|
-
//
|
|
4
|
-
// Supported input forms (with optional "+workspace" suffix on any of them):
|
|
5
|
-
//
|
|
6
|
-
// <name> → { name }
|
|
7
|
-
// <name>@<owner> → { name, owner }
|
|
8
|
-
// <owner>/<name> → { owner, name }
|
|
9
|
-
// gh:<owner>/<name> → { owner, name, host: "github.com" }
|
|
10
|
-
// https://<host>/<owner>/<name>[.git] → { host, owner, name }
|
|
11
|
-
// git@<host>:<owner>/<name>[.git] → { host, owner, name }
|
|
12
|
-
// ssh://[user@]<host>/<owner>/<name>[.git] → { host, owner, name }
|
|
13
|
-
//
|
|
14
|
-
// Inputs starting with `/` or `~/` are NOT repo refs (they're paths); the
|
|
15
|
-
// caller short-circuits before this function.
|
|
16
|
-
//
|
|
17
|
-
// Returns undefined when the input is empty or otherwise unparseable. The
|
|
18
|
-
// Go version returns (RepoRef, error); the TS port branches on undefined
|
|
19
|
-
// instead, which matches the rest of the CLI's "no exceptions for
|
|
20
|
-
// user-input validation" style.
|
|
21
|
-
|
|
22
|
-
export interface RepoRef {
|
|
23
|
-
/**
|
|
24
|
-
* Host is the resolved hostname (e.g. "github.com"). Empty when the user
|
|
25
|
-
* didn't include one (bare name, owner/name, name@owner). Callers default
|
|
26
|
-
* to "github.com" when empty (see `effectiveHost`).
|
|
27
|
-
*/
|
|
28
|
-
readonly host: string;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Owner is the repo's owner/org. Empty when the user typed only a bare
|
|
32
|
-
* name; the resolver fills it by searching the user's orgs.
|
|
33
|
-
*/
|
|
34
|
-
readonly owner: string;
|
|
35
|
-
|
|
36
|
-
/** Repo name. Always populated after a successful parse. */
|
|
37
|
-
readonly name: string;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Workspace is the "+workspace" suffix when present. Maps to claude's
|
|
41
|
-
* --worktree flag and the plugin's worktreeTemplate.
|
|
42
|
-
*/
|
|
43
|
-
readonly workspace: string;
|
|
44
|
-
|
|
45
|
-
/** Original raw input, retained for error messages. */
|
|
46
|
-
readonly original: string;
|
|
47
|
-
|
|
48
|
-
/** True when owner was supplied explicitly (no org search needed). */
|
|
49
|
-
readonly hasResolvedOwner: boolean;
|
|
50
|
-
|
|
51
|
-
/** Host if set, else "github.com". */
|
|
52
|
-
readonly effectiveHost: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const URL_RE =
|
|
56
|
-
/^(?:(?:https?|ssh):\/\/(?:[^@/]+@)?)([^:/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
|
|
57
|
-
const SCP_RE = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
|
|
58
|
-
|
|
59
|
-
export function parseRepoRef(input: string): RepoRef | undefined {
|
|
60
|
-
if (input === '') return undefined;
|
|
61
|
-
|
|
62
|
-
// Split off workspace suffix first.
|
|
63
|
-
let body = input;
|
|
64
|
-
let workspace = '';
|
|
65
|
-
const plusIdx = body.indexOf('+');
|
|
66
|
-
if (plusIdx >= 0) {
|
|
67
|
-
workspace = body.slice(plusIdx + 1);
|
|
68
|
-
body = body.slice(0, plusIdx);
|
|
69
|
-
if (workspace === '') return undefined; // trailing `+` with no workspace
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// URL forms.
|
|
73
|
-
// RegExp.exec returns null for "no match" — third-party API shape, kept
|
|
74
|
-
// verbatim rather than coerced.
|
|
75
|
-
const urlMatch = URL_RE.exec(body);
|
|
76
|
-
if (urlMatch !== null) {
|
|
77
|
-
return finalise({
|
|
78
|
-
host: urlMatch[1]!,
|
|
79
|
-
owner: urlMatch[2]!,
|
|
80
|
-
name: urlMatch[3]!,
|
|
81
|
-
workspace,
|
|
82
|
-
original: input,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
const scpMatch = SCP_RE.exec(body);
|
|
86
|
-
if (scpMatch !== null) {
|
|
87
|
-
return finalise({
|
|
88
|
-
host: scpMatch[1]!,
|
|
89
|
-
owner: scpMatch[2]!,
|
|
90
|
-
name: scpMatch[3]!,
|
|
91
|
-
workspace,
|
|
92
|
-
original: input,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// gh:owner/name shorthand.
|
|
97
|
-
if (body.startsWith('gh:')) {
|
|
98
|
-
const rest = body.slice(3);
|
|
99
|
-
const slashIdx = rest.indexOf('/');
|
|
100
|
-
if (slashIdx > 0 && slashIdx < rest.length - 1) {
|
|
101
|
-
const owner = rest.slice(0, slashIdx);
|
|
102
|
-
const name = rest.slice(slashIdx + 1);
|
|
103
|
-
if (containsAny(owner, '/@:') || containsAny(name, '/@:')) return undefined;
|
|
104
|
-
return finalise({ host: 'github.com', owner, name, workspace, original: input });
|
|
105
|
-
}
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// owner/name (single slash, no scheme).
|
|
110
|
-
const slashIdx = body.indexOf('/');
|
|
111
|
-
if (slashIdx > 0) {
|
|
112
|
-
// Reject multiple slashes (ambiguous).
|
|
113
|
-
if (body.indexOf('/', slashIdx + 1) >= 0) return undefined;
|
|
114
|
-
const owner = body.slice(0, slashIdx);
|
|
115
|
-
const name = body.slice(slashIdx + 1);
|
|
116
|
-
if (containsAny(owner, '@:') || containsAny(name, '@:')) return undefined;
|
|
117
|
-
if (owner === '' || name === '') return undefined;
|
|
118
|
-
return finalise({ host: '', owner, name, workspace, original: input });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// name@owner.
|
|
122
|
-
const atIdx = body.indexOf('@');
|
|
123
|
-
if (atIdx > 0) {
|
|
124
|
-
const name = body.slice(0, atIdx);
|
|
125
|
-
const owner = body.slice(atIdx + 1);
|
|
126
|
-
if (containsAny(owner, '@:/') || containsAny(name, '@:/')) return undefined;
|
|
127
|
-
if (owner === '' || name === '') return undefined;
|
|
128
|
-
return finalise({ host: '', owner, name, workspace, original: input });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Bare name. Defense-in-depth: reject anything that looks like a special
|
|
132
|
-
// form we already had a chance to match.
|
|
133
|
-
if (containsAny(body, '/@:')) return undefined;
|
|
134
|
-
return finalise({ host: '', owner: '', name: body, workspace, original: input });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
interface RepoRefCore {
|
|
138
|
-
host: string;
|
|
139
|
-
owner: string;
|
|
140
|
-
name: string;
|
|
141
|
-
workspace: string;
|
|
142
|
-
original: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function finalise(core: RepoRefCore): RepoRef {
|
|
146
|
-
return {
|
|
147
|
-
...core,
|
|
148
|
-
hasResolvedOwner: core.owner !== '',
|
|
149
|
-
effectiveHost: core.host === '' ? 'github.com' : core.host,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function containsAny(s: string, chars: string): boolean {
|
|
154
|
-
for (const c of chars) {
|
|
155
|
-
if (s.includes(c)) return true;
|
|
156
|
-
}
|
|
157
|
-
return false;
|
|
158
|
-
}
|
package/src/repoSettings.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Port of src/repo_settings.go (fnclaude/fnclaude Go reference).
|
|
2
|
-
//
|
|
3
|
-
// Read the `repoSettings` block from Claude Code's four settings tiers,
|
|
4
|
-
// shallow-merged per field. Documented precedence (highest → lowest):
|
|
5
|
-
//
|
|
6
|
-
// managed > local > project > user
|
|
7
|
-
//
|
|
8
|
-
// Mirrors the JS plugin's settings.ts behavior so both consumers agree on
|
|
9
|
-
// what each tier provides.
|
|
10
|
-
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
12
|
-
import { homedir, platform } from 'node:os';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import { errorMessage } from './errors.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* fnclaude's view of the shared `repoSettings` block. Only the keys
|
|
18
|
-
* fnclaude consumes are documented as load-bearing here; the plugin-only
|
|
19
|
-
* keys (worktreeTemplate, branchTemplate, gateEnvVar) are decoded for
|
|
20
|
-
* completeness so callers can inspect them, but fnclaude doesn't act on
|
|
21
|
-
* them.
|
|
22
|
-
*/
|
|
23
|
-
export interface RepoSettings {
|
|
24
|
-
/** Template fnclaude uses to compute where a freshly-cloned repo should live. */
|
|
25
|
-
cloneTemplate?: string;
|
|
26
|
-
/** Template the worktree-paths plugin uses for `claude --worktree`. */
|
|
27
|
-
worktreeTemplate?: string;
|
|
28
|
-
/** Template the worktree-paths plugin uses for newly-created worktree branch names. */
|
|
29
|
-
branchTemplate?: string;
|
|
30
|
-
/** Env-var name the plugin uses to conditionally apply its templates. */
|
|
31
|
-
gateEnvVar?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface SettingsFile {
|
|
35
|
-
repoSettings?: RepoSettings;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function home(): string {
|
|
39
|
-
return process.env.HOME ?? homedir();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Result of a repo-settings load: the merged settings plus any non-fatal
|
|
44
|
-
* warnings (e.g. malformed JSON files that were skipped). Mirrors
|
|
45
|
-
* `LoadConfigResult` so the caller can thread warnings into the deferred
|
|
46
|
-
* flush.
|
|
47
|
-
*/
|
|
48
|
-
export interface LoadRepoSettingsResult {
|
|
49
|
-
settings: RepoSettings;
|
|
50
|
-
warnings: readonly string[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Resolve the four-tier merge for the user's environment.
|
|
55
|
-
* `projectRoot` is the cwd Claude Code anchors project/local tiers
|
|
56
|
-
* against — typically the launch cwd or the resolved git toplevel.
|
|
57
|
-
*/
|
|
58
|
-
export function loadRepoSettings(
|
|
59
|
-
homeDir: string,
|
|
60
|
-
projectRoot: string,
|
|
61
|
-
): LoadRepoSettingsResult {
|
|
62
|
-
const paths: string[] = [
|
|
63
|
-
join(homeDir, '.claude', 'settings.json'), // user
|
|
64
|
-
join(projectRoot, '.claude', 'settings.json'), // project
|
|
65
|
-
join(projectRoot, '.claude', 'settings.local.json'), // local
|
|
66
|
-
];
|
|
67
|
-
const mp = managedSettingsPath();
|
|
68
|
-
if (mp) paths.push(mp);
|
|
69
|
-
return mergeRepoSettings(paths);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Read each path (if it exists) and merge per-field with later entries
|
|
74
|
-
* winning over earlier ones. Missing files are silently skipped (the
|
|
75
|
-
* fail-soft posture the plugin matches); malformed files produce a
|
|
76
|
-
* warning so the user can fix them rather than wondering why their
|
|
77
|
-
* settings don't apply.
|
|
78
|
-
*/
|
|
79
|
-
export function mergeRepoSettings(paths: string[]): LoadRepoSettingsResult {
|
|
80
|
-
const merged: RepoSettings = {};
|
|
81
|
-
const warnings: string[] = [];
|
|
82
|
-
for (const p of paths) {
|
|
83
|
-
const { settings: f, warning } = readRepoSettings(p);
|
|
84
|
-
if (warning !== undefined) warnings.push(warning);
|
|
85
|
-
if (!f) continue;
|
|
86
|
-
// Shallow-merge per field: only overwrite when the higher tier sets
|
|
87
|
-
// a non-empty value.
|
|
88
|
-
if (f.cloneTemplate) merged.cloneTemplate = f.cloneTemplate;
|
|
89
|
-
if (f.worktreeTemplate) merged.worktreeTemplate = f.worktreeTemplate;
|
|
90
|
-
if (f.branchTemplate) merged.branchTemplate = f.branchTemplate;
|
|
91
|
-
if (f.gateEnvVar) merged.gateEnvVar = f.gateEnvVar;
|
|
92
|
-
}
|
|
93
|
-
return { settings: merged, warnings };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
interface ReadRepoSettingsResult {
|
|
97
|
-
settings: RepoSettings | undefined;
|
|
98
|
-
warning: string | undefined;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function readRepoSettings(path: string): ReadRepoSettingsResult {
|
|
102
|
-
let data: string;
|
|
103
|
-
try {
|
|
104
|
-
data = readFileSync(path, 'utf8');
|
|
105
|
-
} catch {
|
|
106
|
-
// Missing file is the common path — stay silent.
|
|
107
|
-
return { settings: undefined, warning: undefined };
|
|
108
|
-
}
|
|
109
|
-
let f: SettingsFile;
|
|
110
|
-
try {
|
|
111
|
-
f = JSON.parse(data) as SettingsFile;
|
|
112
|
-
} catch (err) {
|
|
113
|
-
return {
|
|
114
|
-
settings: undefined,
|
|
115
|
-
warning: `fnclaude: repo-settings file ${path} is malformed, skipping: ${errorMessage(err)}`,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
return { settings: f.repoSettings ?? undefined, warning: undefined };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Platform-specific path to Claude Code's managed-settings.json, or
|
|
123
|
-
* `undefined` on platforms with no such convention.
|
|
124
|
-
*/
|
|
125
|
-
export function managedSettingsPath(): string | undefined {
|
|
126
|
-
switch (platform()) {
|
|
127
|
-
case 'linux':
|
|
128
|
-
return '/etc/claude-code/managed-settings.json';
|
|
129
|
-
case 'darwin':
|
|
130
|
-
return '/Library/Application Support/ClaudeCode/managed-settings.json';
|
|
131
|
-
case 'win32': {
|
|
132
|
-
const pd = process.env.ProgramData;
|
|
133
|
-
if (pd) return join(pd, 'ClaudeCode', 'managed-settings.json');
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
default:
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Re-export for callers that don't pass homeDir explicitly.
|
|
142
|
-
export function userHome(): string {
|
|
143
|
-
return home();
|
|
144
|
-
}
|