@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/resolver.ts DELETED
@@ -1,519 +0,0 @@
1
- /**
2
- * Port of src/resolver.go (+ src/repo_ref.go and src/template.go) from the
3
- * Go fnclaude implementation. Resolves a user-typed string into a concrete
4
- * on-disk path, cloning from GitHub if necessary.
5
- *
6
- * Design notes:
7
- *
8
- * - All external I/O (filesystem stat, gh CLI invocation, clone) is
9
- * injected via `ResolveDeps` so tests can substitute deterministic
10
- * fakes. Production callers pass `productionDeps()` (which uses
11
- * `node:fs/promises` and `Bun.spawn`).
12
- *
13
- * - The resolver runs path-lookup and repo-lookup in PARALLEL and
14
- * surfaces an ambiguity error when both hit. Absolute / `~`-anchored
15
- * inputs short-circuit to path-only — they are unambiguously paths.
16
- *
17
- * - repo-ref parsing and template substitution live here (not in
18
- * separate modules) because they're tightly bound to Resolve's API
19
- * surface and not used elsewhere yet.
20
- */
21
-
22
- import { stat } from 'node:fs/promises';
23
- import { isAbsolute, join as pathJoin, resolve as pathResolve } from 'node:path';
24
-
25
- // ── Public types ───────────────────────────────────────────────────────────
26
-
27
- /**
28
- * RepoSettings is fnclaude's view of the shared `repoSettings` block from
29
- * Claude Code's settings.json. Only the keys the resolver consumes are
30
- * modeled; the plugin-only keys (worktreeTemplate, branchTemplate,
31
- * gateEnvVar) are intentionally absent here.
32
- */
33
- export interface RepoSettings {
34
- cloneTemplate?: string;
35
- }
36
-
37
- export interface ResolveOpts {
38
- /** User-typed reference. Required, non-empty. */
39
- input: string;
40
- /** User's shell cwd. Used to resolve cwd-relative paths. */
41
- cwd?: string;
42
- /** User's home directory (e.g. `os.homedir()`). */
43
- home: string;
44
- /** Merged repoSettings block from settings.json. */
45
- settings?: RepoSettings;
46
- /** Merged host-short LUT. */
47
- hostAliases?: Record<string, string>;
48
- }
49
-
50
- export interface ResolveResult {
51
- /** Absolute filesystem path of the resolved repo. */
52
- path: string;
53
- /** "+workspace" suffix (if any) to pass to claude via --worktree. */
54
- workspace?: string;
55
- /** True iff the resolver freshly cloned during this call. */
56
- justCloned?: boolean;
57
- }
58
-
59
- /**
60
- * Output of a gh CLI invocation. Only stdout is required for the resolver's
61
- * use cases; stderr is left for the underlying spawner to surface directly.
62
- */
63
- export interface GhResult {
64
- stdout: string;
65
- }
66
-
67
- /**
68
- * Injectable indirections — all I/O the resolver does flows through these.
69
- * Tests pass stubs; production calls `productionDeps()`.
70
- */
71
- export interface ResolveDeps {
72
- /** Return true if the path exists (file or directory). */
73
- pathExists: (path: string) => Promise<boolean>;
74
- /** Run `gh <args>` and return stdout; reject on non-zero exit. */
75
- ghCmd: (args: readonly string[]) => Promise<GhResult>;
76
- /** Shell out `gh repo clone <ownerRepo> <dest>`. */
77
- runClone: (ownerRepo: string, dest: string) => Promise<void>;
78
- /**
79
- * User-visible log line (e.g. "cloning X → Y"). Production writes to
80
- * stderr; tests stub it to silence test output and assert on the call.
81
- */
82
- log: (message: string) => void;
83
- }
84
-
85
- // ── Production dependency wiring ───────────────────────────────────────────
86
-
87
- /**
88
- * Default deps wired to real `node:fs/promises` + `Bun.spawn` for `gh`.
89
- * Constructed on demand so test imports don't trigger Bun-only globals.
90
- */
91
- export function productionDeps(): ResolveDeps {
92
- const ghCmd = async (args: readonly string[]): Promise<GhResult> => {
93
- const proc = Bun.spawn(['gh', ...args], { stderr: 'inherit', stdout: 'pipe' });
94
- const stdout = await new Response(proc.stdout).text();
95
- const code = await proc.exited;
96
- if (code !== 0) throw new Error(`gh exited ${code}: ${args.join(' ')}`);
97
- return { stdout };
98
- };
99
- const runClone = async (ownerRepo: string, dest: string): Promise<void> => {
100
- const proc = Bun.spawn(['gh', 'repo', 'clone', ownerRepo, dest], {
101
- stdin: 'inherit',
102
- stdout: 'inherit',
103
- stderr: 'inherit',
104
- });
105
- const code = await proc.exited;
106
- if (code !== 0) throw new Error(`gh repo clone exited ${code}`);
107
- };
108
- return {
109
- pathExists: async (p: string) => {
110
- try {
111
- await stat(p);
112
- return true;
113
- } catch {
114
- return false;
115
- }
116
- },
117
- ghCmd,
118
- runClone,
119
- log: (msg: string) => {
120
- process.stderr.write(msg.endsWith('\n') ? msg : `${msg}\n`);
121
- },
122
- };
123
- }
124
-
125
- // ── Repo-ref parsing (ported from src/repo_ref.go) ─────────────────────────
126
-
127
- interface RepoRef {
128
- /**
129
- * Resolved hostname (e.g. "github.com"). Empty if absent from input;
130
- * callers use `effectiveHost` to default to GitHub.
131
- */
132
- host: string;
133
- /** Owner/org. Empty when input was a bare name. */
134
- owner: string;
135
- /** Repo name. Always present after parsing. */
136
- name: string;
137
- /** Optional "+workspace" suffix. */
138
- workspace?: string;
139
- /** Raw input for error messages. */
140
- original: string;
141
- }
142
-
143
- // URL form: https:// http:// ssh://[user@]<host>/<owner>/<name>[.git]
144
- const URL_RE = /^(?:(?:https?|ssh):\/\/(?:[^@/]+@)?)([^:/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
145
- // SCP form: git@host:owner/name[.git]
146
- const SCP_RE = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
147
-
148
- function parseRepoRef(input: string): RepoRef {
149
- if (input === '') throw new Error('empty repo reference');
150
- const ref: RepoRef = { host: '', owner: '', name: '', original: input };
151
-
152
- // Split off "+workspace" first.
153
- let body = input;
154
- const plusIdx = body.indexOf('+');
155
- if (plusIdx >= 0) {
156
- ref.workspace = body.slice(plusIdx + 1);
157
- body = body.slice(0, plusIdx);
158
- if (ref.workspace === '') {
159
- throw new Error(`empty workspace after \`+\` in "${input}"`);
160
- }
161
- }
162
-
163
- // URL forms.
164
- const urlM = body.match(URL_RE);
165
- if (urlM) {
166
- ref.host = urlM[1]!;
167
- ref.owner = urlM[2]!;
168
- ref.name = urlM[3]!;
169
- return ref;
170
- }
171
- const scpM = body.match(SCP_RE);
172
- if (scpM) {
173
- ref.host = scpM[1]!;
174
- ref.owner = scpM[2]!;
175
- ref.name = scpM[3]!;
176
- return ref;
177
- }
178
-
179
- // gh:owner/name shorthand.
180
- if (body.startsWith('gh:')) {
181
- const rest = body.slice(3);
182
- const slash = rest.indexOf('/');
183
- if (slash > 0 && slash < rest.length - 1) {
184
- const owner = rest.slice(0, slash);
185
- const name = rest.slice(slash + 1);
186
- if (/[\/@:]/.test(owner) || /[\/@:]/.test(name)) {
187
- throw new Error(`invalid gh: form: "${input}"`);
188
- }
189
- ref.host = 'github.com';
190
- ref.owner = owner;
191
- ref.name = name;
192
- return ref;
193
- }
194
- throw new Error(`gh: form requires owner/name, got "${input}"`);
195
- }
196
-
197
- // owner/name (single slash, no scheme).
198
- const slashIdx = body.indexOf('/');
199
- if (slashIdx > 0) {
200
- if (body.indexOf('/', slashIdx + 1) >= 0) {
201
- throw new Error(`ambiguous form "${input}" (multiple slashes)`);
202
- }
203
- const owner = body.slice(0, slashIdx);
204
- const name = body.slice(slashIdx + 1);
205
- if (/[@:]/.test(owner) || /[@:]/.test(name) || owner === '' || name === '') {
206
- throw new Error(`invalid owner/name form: "${input}"`);
207
- }
208
- ref.owner = owner;
209
- ref.name = name;
210
- return ref;
211
- }
212
-
213
- // name@owner (Tom's local-convention form).
214
- const atIdx = body.indexOf('@');
215
- if (atIdx > 0) {
216
- const name = body.slice(0, atIdx);
217
- const owner = body.slice(atIdx + 1);
218
- if (/[@:\/]/.test(owner) || /[@:\/]/.test(name) || owner === '' || name === '') {
219
- throw new Error(`invalid name@owner form: "${input}"`);
220
- }
221
- ref.name = name;
222
- ref.owner = owner;
223
- return ref;
224
- }
225
-
226
- // Bare name — defense in depth on special chars.
227
- if (/[\/@:]/.test(body)) {
228
- throw new Error(`unparseable repo reference: "${input}"`);
229
- }
230
- ref.name = body;
231
- return ref;
232
- }
233
-
234
- function effectiveHost(ref: RepoRef): string {
235
- return ref.host || 'github.com';
236
- }
237
-
238
- function hasResolvedOwner(ref: RepoRef): boolean {
239
- return ref.owner !== '';
240
- }
241
-
242
- // ── Template substitution (ported from src/template.go) ────────────────────
243
-
244
- type TemplateVars = Record<string, () => string>;
245
-
246
- function applyTemplate(tpl: string, vars: TemplateVars): string {
247
- let out = '';
248
- let i = 0;
249
- while (i < tpl.length) {
250
- const c = tpl[i]!;
251
- if (c !== '{') {
252
- out += c;
253
- i++;
254
- continue;
255
- }
256
- const end = tpl.indexOf('}', i + 1);
257
- if (end < 0) {
258
- // Unterminated `{` — pass through literally; user's template is
259
- // malformed and erroring here would be confusing.
260
- out += c;
261
- i++;
262
- continue;
263
- }
264
- const name = tpl.slice(i + 1, end);
265
- const resolver = vars[name];
266
- if (!resolver) {
267
- throw new Error(`unknown placeholder {${name}} in template "${tpl}"`);
268
- }
269
- out += resolver();
270
- i = end + 1;
271
- }
272
- return out;
273
- }
274
-
275
- function cloneTemplateVars(
276
- repo: string,
277
- owner: string,
278
- host: string,
279
- hostAliases: Record<string, string>,
280
- ): TemplateVars {
281
- const dotIdx = host.indexOf('.');
282
- const hostPlain = dotIdx >= 0 ? host.slice(0, dotIdx) : host;
283
- return {
284
- repo: () => repo,
285
- owner: () => owner,
286
- host: () => host,
287
- 'host-plain': () => hostPlain,
288
- 'host-short': () => {
289
- const alias = hostAliases[host];
290
- if (!alias) throw missingHostShortError(host);
291
- return alias;
292
- },
293
- };
294
- }
295
-
296
- function missingHostShortError(host: string): Error {
297
- return new Error(
298
- `cannot resolve {host-short} for host "${host}": no alias configured.\n` +
299
- `Add an entry to your fnclaude host-aliases LUT, e.g.:\n` +
300
- ` { "github.com": "gh", "gitlab.com": "gl" }`,
301
- );
302
- }
303
-
304
- // ── Tilde expansion (ported from resolver.go's expandTildePath) ────────────
305
-
306
- function expandTildePath(p: string, home: string): string {
307
- if (p === '~') return home;
308
- if (p.startsWith('~/')) return pathJoin(home, p.slice(2));
309
- return p;
310
- }
311
-
312
- // ── Main resolver ──────────────────────────────────────────────────────────
313
-
314
- /**
315
- * Resolve a user-typed reference. See module docstring for the ladder.
316
- *
317
- * `deps` defaults to production wiring; tests pass deterministic stubs.
318
- */
319
- export async function Resolve(
320
- opts: ResolveOpts,
321
- deps: ResolveDeps = productionDeps(),
322
- ): Promise<ResolveResult> {
323
- if (!opts.input) throw new Error('empty input');
324
-
325
- // Absolute-path short-circuit.
326
- if (
327
- opts.input.startsWith('/') ||
328
- opts.input.startsWith('~/') ||
329
- opts.input === '~'
330
- ) {
331
- return { path: expandTildePath(opts.input, opts.home) };
332
- }
333
-
334
- // Two lookups in parallel. Each is structured: { hit, ... } so the
335
- // ambiguity branch can name both.
336
- const cwd = opts.cwd ?? process.cwd();
337
- const [pathLookup, repoLookup] = await Promise.all([
338
- resolvePathCandidate(opts.input, cwd, deps),
339
- resolveRepoCandidate(opts.input, deps),
340
- ]);
341
-
342
- if (pathLookup.hit && repoLookup.hit) {
343
- const ref = repoLookup.ref!;
344
- throw new Error(
345
- `ambiguous reference "${opts.input}":\n` +
346
- ` - resolves as a path: ${pathLookup.path} (exists)\n` +
347
- ` - resolves as a repo: ${ref.owner}/${ref.name} on ${effectiveHost(ref)}\n` +
348
- `disambiguate with:\n` +
349
- ` - an absolute or ~-anchored path for the local dir\n` +
350
- ` - "gh:${ref.owner}/${ref.name}" or a full URL for the repo`,
351
- );
352
- }
353
-
354
- if (pathLookup.hit) {
355
- // Local path with no repo identity — just use it. Workspace suffix
356
- // doesn't apply (no base repo to worktree off of).
357
- return { path: pathLookup.path! };
358
- }
359
-
360
- if (repoLookup.hit) {
361
- return cloneAndReturn(repoLookup.ref!, opts, deps);
362
- }
363
-
364
- // Neither hit.
365
- const hint = repoLookup.parseError ? ` (repo parse: ${repoLookup.parseError.message})` : '';
366
- throw new Error(
367
- `could not resolve "${opts.input}" as a local path (in ${cwd}) or a repo on a known host${hint}`,
368
- );
369
- }
370
-
371
- // ── Lookup branches ────────────────────────────────────────────────────────
372
-
373
- interface PathLookup {
374
- hit: boolean;
375
- path?: string;
376
- }
377
-
378
- async function resolvePathCandidate(
379
- input: string,
380
- cwd: string,
381
- deps: ResolveDeps,
382
- ): Promise<PathLookup> {
383
- // cwd-relative first.
384
- const rel = pathJoin(cwd, input);
385
- if (await deps.pathExists(rel)) {
386
- return { hit: true, path: pathResolve(rel) };
387
- }
388
- // Then input as-is, if absolute.
389
- if (isAbsolute(input) && (await deps.pathExists(input))) {
390
- return { hit: true, path: input };
391
- }
392
- return { hit: false };
393
- }
394
-
395
- interface RepoLookup {
396
- hit: boolean;
397
- ref?: RepoRef;
398
- parseError?: Error;
399
- }
400
-
401
- async function resolveRepoCandidate(input: string, deps: ResolveDeps): Promise<RepoLookup> {
402
- let ref: RepoRef;
403
- try {
404
- ref = parseRepoRef(input);
405
- } catch (e) {
406
- return { hit: false, parseError: e as Error };
407
- }
408
-
409
- if (hasResolvedOwner(ref)) {
410
- if (await repoExistsOnGitHub(ref.owner, ref.name, deps)) {
411
- return { hit: true, ref };
412
- }
413
- return { hit: false, ref };
414
- }
415
-
416
- // Bare name — search login + orgs.
417
- for (const owner of await userOwnerCandidates(deps)) {
418
- if (await repoExistsOnGitHub(owner, ref.name, deps)) {
419
- ref.owner = owner;
420
- return { hit: true, ref };
421
- }
422
- }
423
- return { hit: false, ref };
424
- }
425
-
426
- async function userOwnerCandidates(deps: ResolveDeps): Promise<string[]> {
427
- const owners: string[] = [];
428
- try {
429
- const { stdout } = await deps.ghCmd(['api', 'user', '--jq', '.login']);
430
- const s = stdout.trim();
431
- if (s) owners.push(s);
432
- } catch {
433
- /* swallow — return empty if gh isn't usable. */
434
- }
435
- try {
436
- const { stdout } = await deps.ghCmd(['api', '/user/orgs', '--jq', '.[].login']);
437
- for (const line of stdout.trim().split('\n')) {
438
- const s = line.trim();
439
- if (s) owners.push(s);
440
- }
441
- } catch {
442
- /* swallow */
443
- }
444
- return owners;
445
- }
446
-
447
- async function repoExistsOnGitHub(
448
- owner: string,
449
- name: string,
450
- deps: ResolveDeps,
451
- ): Promise<boolean> {
452
- try {
453
- await deps.ghCmd(['api', `repos/${owner}/${name}`, '--silent']);
454
- return true;
455
- } catch {
456
- return false;
457
- }
458
- }
459
-
460
- // ── Clone-and-return ───────────────────────────────────────────────────────
461
-
462
- async function cloneAndReturn(
463
- ref: RepoRef,
464
- opts: ResolveOpts,
465
- deps: ResolveDeps,
466
- ): Promise<ResolveResult> {
467
- const tpl = opts.settings?.cloneTemplate;
468
- if (!tpl) {
469
- throw new Error(
470
- `cannot clone ${ref.owner}/${ref.name} — no cloneTemplate configured.\n` +
471
- `Add to ~/.claude/settings.json:\n` +
472
- ` "repoSettings": { "cloneTemplate": "~/src/{repo}@{owner}" }`,
473
- );
474
- }
475
-
476
- const host = effectiveHost(ref);
477
- const vars = cloneTemplateVars(ref.name, ref.owner, host, opts.hostAliases ?? {});
478
- let target: string;
479
- try {
480
- target = applyTemplate(tpl, vars);
481
- } catch (e) {
482
- // Pass placeholder / host-short errors through verbatim; cloneTemplate
483
- // expansion errors get a wrapper for context.
484
- const msg = (e as Error).message;
485
- if (msg.includes('unknown placeholder') || msg.includes('host-short')) {
486
- throw e;
487
- }
488
- throw new Error(`cloneTemplate expansion: ${msg}`);
489
- }
490
- target = expandTildePath(target, opts.home);
491
- if (!isAbsolute(target)) {
492
- // cloneTemplate produced a relative path — anchor to home (we may
493
- // chdir before using it).
494
- target = pathJoin(opts.home, target);
495
- }
496
-
497
- if (await deps.pathExists(target)) {
498
- return {
499
- path: target,
500
- ...(ref.workspace ? { workspace: ref.workspace } : {}),
501
- };
502
- }
503
-
504
- // Clone. gh decides SSH vs HTTPS from its config.
505
- deps.log(`fnclaude: cloning ${ref.owner}/${ref.name} → ${target}`);
506
- try {
507
- await deps.runClone(`${ref.owner}/${ref.name}`, target);
508
- } catch (e) {
509
- throw new Error(`gh repo clone failed: ${(e as Error).message}`);
510
- }
511
- if (!(await deps.pathExists(target))) {
512
- throw new Error(`clone reported success but ${target} does not exist`);
513
- }
514
- return {
515
- path: target,
516
- justCloned: true,
517
- ...(ref.workspace ? { workspace: ref.workspace } : {}),
518
- };
519
- }
package/src/sanitize.ts DELETED
@@ -1,120 +0,0 @@
1
- // Path/branch-name sanitization. Ported from src/sanitize.go in the Go
2
- // reference implementation.
3
- //
4
- // sanitizeName produces a slug safe for both filesystem path components
5
- // and git ref names: collapses anything outside [A-Za-z0-9._/-] to '-',
6
- // dedupes hyphen and slash runs, strips leading [-.] and trailing [-/].
7
- // '/' is allowed so git-style nested refs (feat/foo, team/x/y/z) pass
8
- // through and produce nested worktree paths.
9
- //
10
- // Returns undefined when:
11
- // - the input is empty
12
- // - the input starts with '/' (would escape the configured path prefix)
13
- // - the result reduces to empty after sanitization
14
- // - the result contains a ".." substring (git ref-format rule; also
15
- // blocks foo/../bar style path escape)
16
- //
17
- // Caller decides whether to reject, fall back, or pass the original
18
- // through with a warning.
19
-
20
- const RE_PATH_SAFE_BAD = /[^A-Za-z0-9._/-]+/g;
21
- const RE_DASH_RUN = /-{2,}/g;
22
- const RE_SLASH_RUN = /\/{2,}/g;
23
-
24
- export function sanitizeName(s: string): string | undefined {
25
- if (s === '') return undefined;
26
- if (s.startsWith('/')) return undefined;
27
-
28
- let out = s.replace(RE_PATH_SAFE_BAD, '-');
29
- out = out.replace(RE_DASH_RUN, '-');
30
- out = out.replace(RE_SLASH_RUN, '/');
31
- out = trimLeftAny(out, '-.');
32
- out = trimRightAny(out, '-/');
33
-
34
- if (out === '') return undefined;
35
- if (out.includes('..')) return undefined;
36
- return out;
37
- }
38
-
39
- function trimLeftAny(s: string, chars: string): string {
40
- let i = 0;
41
- while (i < s.length && chars.includes(s[i]!)) i++;
42
- return s.slice(i);
43
- }
44
-
45
- function trimRightAny(s: string, chars: string): string {
46
- let i = s.length;
47
- while (i > 0 && chars.includes(s[i - 1]!)) i--;
48
- return s.slice(0, i);
49
- }
50
-
51
- // sanitizeNamesInPassthrough scans args for --name/--name=VAL/-n/-n=VAL and
52
- // rewrites VAL to a path-safe form when it contains unsafe chars. Returns
53
- // the modified slice plus one warning message per affected occurrence.
54
- //
55
- // Values that reduce to empty after sanitization are left untouched; we
56
- // only warn. This preserves the user's literal input so claude (or a
57
- // downstream hook) can surface the real error rather than fnclaude
58
- // silently substituting a synthetic name.
59
-
60
- export interface SanitizeNamesResult {
61
- readonly args: string[];
62
- readonly warnings: string[];
63
- }
64
-
65
- export function sanitizeNamesInPassthrough(p: readonly string[]): SanitizeNamesResult {
66
- const out = [...p];
67
- const warnings: string[] = [];
68
-
69
- for (let i = 0; i < out.length; i++) {
70
- const t = out[i]!;
71
- if ((t === '--name' || t === '-n') && i + 1 < out.length) {
72
- const val = out[i + 1]!;
73
- const decision = decideSanitize(val);
74
- if (decision.warning !== undefined) warnings.push(decision.warning);
75
- if (decision.replace) out[i + 1] = decision.cleaned;
76
- i++; // skip the value slot
77
- continue;
78
- }
79
- if (t.startsWith('--name=')) {
80
- const val = t.slice('--name='.length);
81
- const decision = decideSanitize(val);
82
- if (decision.warning !== undefined) warnings.push(decision.warning);
83
- if (decision.replace) out[i] = `--name=${decision.cleaned}`;
84
- continue;
85
- }
86
- if (t.startsWith('-n=')) {
87
- const val = t.slice('-n='.length);
88
- const decision = decideSanitize(val);
89
- if (decision.warning !== undefined) warnings.push(decision.warning);
90
- if (decision.replace) out[i] = `-n=${decision.cleaned}`;
91
- continue;
92
- }
93
- }
94
- return { args: out, warnings };
95
- }
96
-
97
- interface SanitizeDecision {
98
- cleaned: string;
99
- warning: string | undefined;
100
- replace: boolean;
101
- }
102
-
103
- function decideSanitize(val: string): SanitizeDecision {
104
- const cleaned = sanitizeName(val);
105
- if (cleaned === undefined) {
106
- return {
107
- cleaned: val,
108
- warning: `fnclaude: --name ${JSON.stringify(val)} has no path-safe characters; passing through unchanged`,
109
- replace: false,
110
- };
111
- }
112
- if (cleaned === val) {
113
- return { cleaned: '', warning: undefined, replace: false };
114
- }
115
- return {
116
- cleaned,
117
- warning: `fnclaude: --name ${JSON.stringify(val)} sanitized to ${JSON.stringify(cleaned)} (illegal path/branch chars)`,
118
- replace: true,
119
- };
120
- }