@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
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
- }
@@ -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
- }