@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
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Load prompt fragments from disk and inject them into the claude
3
+ * passthrough as `--append-system-prompt <combined>` (or merge into an
4
+ * existing one).
5
+ *
6
+ * Mirrors Go canonical src/prompts.go for the load + compose + inject
7
+ * pipeline. Selection logic (which fragments to load) lives in
8
+ * select.ts; this file just performs the IO + merge.
9
+ *
10
+ * Per specs.md §12:
11
+ * - Fragments joined with double newline (`\n\n`)
12
+ * - Missing fragment: deferred warning, skip, continue with others
13
+ * - If --append-system-prompt is already in passthrough, append to its
14
+ * value (don't replace) so user-provided content is preserved
15
+ *
16
+ * promptsDir is passed in — the resolver (caller) handles the directory
17
+ * search order ($FNC_PROMPTS_DIR → <exe-dir>/prompts → FHS share path).
18
+ */
19
+
20
+ import { readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ export interface LoadFragmentsResult {
24
+ content: string;
25
+ warnings: string[];
26
+ }
27
+
28
+ export function loadFragments(
29
+ names: readonly string[],
30
+ promptsDir: string,
31
+ ): LoadFragmentsResult {
32
+ const pieces: string[] = [];
33
+ const warnings: string[] = [];
34
+
35
+ for (const name of names) {
36
+ const path = join(promptsDir, name);
37
+ try {
38
+ const st = statSync(path);
39
+ if (!st.isFile()) {
40
+ warnings.push(`fnclaude: prompt fragment ${name} not a regular file at ${path}`);
41
+ continue;
42
+ }
43
+ pieces.push(readFileSync(path, 'utf8'));
44
+ } catch {
45
+ warnings.push(`fnclaude: prompt fragment ${name} missing from ${promptsDir}`);
46
+ }
47
+ }
48
+
49
+ return { content: pieces.join('\n\n'), warnings };
50
+ }
51
+
52
+ const FLAG = '--append-system-prompt';
53
+ const FLAG_EQ = `${FLAG}=`;
54
+
55
+ export function injectFragments(
56
+ passthrough: readonly string[],
57
+ content: string,
58
+ ): string[] {
59
+ if (content === '') return [...passthrough];
60
+
61
+ const out = [...passthrough];
62
+
63
+ // Find LAST occurrence of --append-system-prompt (in either form).
64
+ // Later one wins anyway in claude's parser, so we merge into it.
65
+ let lastFlagIdx = -1;
66
+ let lastFlagValIdx = -1; // -1 means inline (=val form)
67
+ for (let i = 0; i < out.length; i++) {
68
+ if (out[i] === FLAG) {
69
+ lastFlagIdx = i;
70
+ lastFlagValIdx = i + 1; // value is the next token
71
+ } else if (out[i]!.startsWith(FLAG_EQ)) {
72
+ lastFlagIdx = i;
73
+ lastFlagValIdx = -1;
74
+ }
75
+ }
76
+
77
+ if (lastFlagIdx >= 0) {
78
+ if (lastFlagValIdx === -1) {
79
+ // Inline form: --append-system-prompt=VAL
80
+ const tok = out[lastFlagIdx]!;
81
+ const existing = tok.slice(FLAG_EQ.length);
82
+ out[lastFlagIdx] = `${FLAG_EQ}${existing}\n\n${content}`;
83
+ } else if (lastFlagValIdx < out.length) {
84
+ out[lastFlagValIdx] = `${out[lastFlagValIdx]}\n\n${content}`;
85
+ } else {
86
+ // Bare --append-system-prompt at the end with no value: append.
87
+ out.push(content);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // No existing flag — splice before `--` if present, else push at end.
93
+ const sentinel = out.indexOf('--');
94
+ if (sentinel >= 0) {
95
+ out.splice(sentinel, 0, FLAG, content);
96
+ } else {
97
+ out.push(FLAG, content);
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Prompt-fragment selection (§5.5 / design.md §28, specs.md §12.2).
3
+ *
4
+ * Determines which of the 5 canonical fragment files should be injected
5
+ * via --append-system-prompt for the given launch context.
6
+ *
7
+ * Selection table:
8
+ * agent-pitfall.md — every interactive (non -p/--print) session
9
+ * spawn.md — every interactive session
10
+ * noop-router.md — noop fallback only
11
+ * project-switch.md — non-noop interactive
12
+ * restart.md — non-noop interactive
13
+ *
14
+ * Print mode (-p / --print anywhere in passthrough): no fragments
15
+ * injected — claude is being driven non-interactively.
16
+ *
17
+ * Returns fragment FILE NAMES (not contents). Loading is a separate
18
+ * concern in `prompts/load.ts`.
19
+ */
20
+
21
+ export interface SelectFragmentsArgs {
22
+ usedNoopFallback: boolean;
23
+ passthrough: readonly string[];
24
+ }
25
+
26
+ export function isInteractiveSession(passthrough: readonly string[]): boolean {
27
+ for (const tok of passthrough) {
28
+ if (tok === '-p' || tok === '--print') return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export function selectFragments(args: SelectFragmentsArgs): string[] {
34
+ if (!isInteractiveSession(args.passthrough)) return [];
35
+
36
+ const out: string[] = ['agent-pitfall.md', 'spawn.md'];
37
+ if (args.usedNoopFallback) {
38
+ out.push('noop-router.md');
39
+ } else {
40
+ out.push('project-switch.md', 'restart.md');
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Execute a `gh repo clone` to materialize a needs-clone destination.
3
+ *
4
+ * Mkdirs the parent first, then delegates to the injected `ghClone`
5
+ * runner. Surfacing both as injected callbacks keeps this module
6
+ * unit-testable without any real gh subprocess or filesystem touching.
7
+ */
8
+
9
+ import { dirname } from 'node:path';
10
+
11
+ export type GhCloneResult = { ok: true } | { ok: false; error: string };
12
+
13
+ export type GhCloneCall = (url: string, destination: string) => Promise<GhCloneResult>;
14
+
15
+ export type Mkdirp = (path: string) => Promise<void>;
16
+
17
+ export interface CloneRepoArgs {
18
+ url: string;
19
+ destination: string;
20
+ ghClone: GhCloneCall;
21
+ mkdirp: Mkdirp;
22
+ }
23
+
24
+ export type CloneRepoResult = { ok: true } | { ok: false; error: string };
25
+
26
+ export async function cloneRepo(args: CloneRepoArgs): Promise<CloneRepoResult> {
27
+ const parent = dirname(args.destination);
28
+ try {
29
+ await args.mkdirp(parent);
30
+ } catch (err) {
31
+ const msg = err instanceof Error ? err.message : String(err);
32
+ return { ok: false, error: `failed to create parent directory ${parent}: ${msg}` };
33
+ }
34
+ const r = await args.ghClone(args.url, args.destination);
35
+ if (!r.ok) return { ok: false, error: `gh repo clone failed: ${r.error}` };
36
+ return { ok: true };
37
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure preparation for `git clone` / `gh repo clone`: given a resolved
3
+ * RepoRef, compute (a) the URL to fetch from and (b) the on-disk path
4
+ * to clone into per the user's `cloneTemplate`. No filesystem, no
5
+ * network — the orchestrator runs these to plan a clone, then executes.
6
+ *
7
+ * Mirrors the bits of Go canonical's resolver.go that sit between
8
+ * parseRepoRef and the eventual `exec gh repo clone` — specifically
9
+ * the URL construction (resolver.go's `cloneURL`) and the
10
+ * template-driven destination path (resolver.go around the
11
+ * `expandCloneTemplate` call site).
12
+ */
13
+
14
+ import { effectiveHost, hasResolvedOwner, type RepoRef } from './ref.ts';
15
+ import { applyTemplate, cloneTemplateVars } from './template.ts';
16
+ import { expandTilde } from '../path/resolve.ts';
17
+
18
+ export function buildCloneUrl(ref: RepoRef): string {
19
+ if (!hasResolvedOwner(ref)) {
20
+ throw new Error(`buildCloneUrl: ref has no owner (original=${JSON.stringify(ref.original)})`);
21
+ }
22
+ if (ref.name === '') {
23
+ throw new Error(`buildCloneUrl: ref has empty name (original=${JSON.stringify(ref.original)})`);
24
+ }
25
+ return `https://${effectiveHost(ref)}/${ref.owner}/${ref.name}.git`;
26
+ }
27
+
28
+ export interface ComputeCloneDestinationArgs {
29
+ ref: RepoRef;
30
+ template: string;
31
+ hostAliases: Record<string, string>;
32
+ home: string;
33
+ }
34
+
35
+ export type ComputeCloneDestinationResult =
36
+ | { ok: true; path: string }
37
+ | { ok: false; error: string };
38
+
39
+ export function computeCloneDestination(args: ComputeCloneDestinationArgs): ComputeCloneDestinationResult {
40
+ const { ref, template, hostAliases, home } = args;
41
+ const vars = cloneTemplateVars(ref.name, ref.owner, effectiveHost(ref), hostAliases);
42
+ const applied = applyTemplate(template, vars);
43
+ if (!applied.ok) return applied;
44
+ return { ok: true, path: expandTilde(applied.value, home) };
45
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Thin Bun.spawn wrappers around the `gh` CLI calls we need at the
3
+ * resolver boundary:
4
+ *
5
+ * - `gh api <path> --jq <jq>` — used for owner lookups.
6
+ * - `gh repo clone <url> <dest>` — used to materialize needs-clone refs.
7
+ *
8
+ * These spawn real processes; orchestration logic stays in
9
+ * `owner-lookup.ts` / `clone-exec.ts` and is unit-testable via the
10
+ * injected `GhApiCall` / `GhCloneCall` callbacks. Production wiring in
11
+ * `main.ts` plugs these runners into those orchestrators.
12
+ *
13
+ * On any spawn failure (gh missing, auth missing, network), we surface a
14
+ * structured error rather than throwing — the caller decides whether to
15
+ * keep walking the candidate list or fail the whole resolution.
16
+ */
17
+
18
+ import type { GhApiResult } from './owner-lookup.ts';
19
+ import type { GhCloneResult } from './clone-exec.ts';
20
+
21
+ const GH_API_PATH_JQ: Record<string, string> = {
22
+ user: '.login',
23
+ '/user/orgs': '.[].login',
24
+ };
25
+
26
+ export async function runGhApi(path: string): Promise<GhApiResult> {
27
+ const jq = GH_API_PATH_JQ[path];
28
+ const args = jq !== undefined ? ['api', path, '--jq', jq] : ['api', path];
29
+ let proc: ReturnType<typeof Bun.spawn>;
30
+ try {
31
+ proc = Bun.spawn(['gh', ...args], {
32
+ stdin: 'ignore',
33
+ stdout: 'pipe',
34
+ stderr: 'pipe',
35
+ });
36
+ } catch (err) {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ return { ok: false, status: -1, error: `failed to spawn gh: ${msg}` };
39
+ }
40
+ const [stdout, stderr] = await Promise.all([
41
+ new Response(proc.stdout).text(),
42
+ new Response(proc.stderr).text(),
43
+ ]);
44
+ const exitCode = await proc.exited;
45
+ if (exitCode !== 0) {
46
+ return { ok: false, status: exitCode, error: stderr.trim() };
47
+ }
48
+ return { ok: true, body: stdout };
49
+ }
50
+
51
+ export async function runGhClone(url: string, destination: string): Promise<GhCloneResult> {
52
+ let proc: ReturnType<typeof Bun.spawn>;
53
+ try {
54
+ proc = Bun.spawn(['gh', 'repo', 'clone', url, destination], {
55
+ stdin: 'ignore',
56
+ stdout: 'inherit',
57
+ stderr: 'inherit',
58
+ });
59
+ } catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ return { ok: false, error: `failed to spawn gh: ${msg}` };
62
+ }
63
+ const exitCode = await proc.exited;
64
+ if (exitCode !== 0) {
65
+ return { ok: false, error: `gh exited ${exitCode}` };
66
+ }
67
+ return { ok: true };
68
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Two-layer host-aliases LUT loader for {host-short} template substitution.
3
+ *
4
+ * Mirrors Go canonical's `src/host_aliases.go:25-95`. Both fnclaude (this
5
+ * file) and the claude-code-worktree-paths plugin (`src/host-aliases.ts`
6
+ * over there) read the same files so a single config feeds both tools.
7
+ *
8
+ * Real default file locations (see design.md §23):
9
+ * system: /usr/share/fnrhombus/host-aliases.json
10
+ * user: ~/.local/share/fnrhombus/host-aliases.json
11
+ *
12
+ * Paths are injected here so tests can use temp files; callers wire in
13
+ * the real defaults.
14
+ *
15
+ * Robustness: missing file, malformed JSON, non-object root → that
16
+ * file's contribution is empty. Individual non-string values within an
17
+ * otherwise-valid object → drop that key only, keep the rest. The user
18
+ * file's keys win over system file's keys on conflict.
19
+ */
20
+
21
+ import { readFileSync, statSync } from 'node:fs';
22
+
23
+ export interface LoadHostAliasesArgs {
24
+ systemPath: string;
25
+ userPath: string;
26
+ }
27
+
28
+ export function loadHostAliases(args: LoadHostAliasesArgs): Record<string, string> {
29
+ const merged: Record<string, string> = {};
30
+ for (const v of Object.entries(readOneLayer(args.systemPath))) merged[v[0]] = v[1];
31
+ for (const v of Object.entries(readOneLayer(args.userPath))) merged[v[0]] = v[1];
32
+ return merged;
33
+ }
34
+
35
+ function readOneLayer(path: string): Record<string, string> {
36
+ let raw: string;
37
+ try {
38
+ const st = statSync(path);
39
+ if (!st.isFile()) return {};
40
+ raw = readFileSync(path, 'utf8');
41
+ } catch {
42
+ return {};
43
+ }
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ } catch {
48
+ return {};
49
+ }
50
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
51
+ return {};
52
+ }
53
+ const out: Record<string, string> = {};
54
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
55
+ if (typeof v === 'string') out[k] = v;
56
+ }
57
+ return out;
58
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cross-org bare-name owner resolution (design.md §17).
3
+ *
4
+ * Given a bare repo name with no owner, ask the gh CLI:
5
+ * 1. `gh api user` → authenticated user's login
6
+ * 2. `gh api /user/orgs` → comma/newline-separated org logins
7
+ * 3. For each candidate (user first, then orgs in API order),
8
+ * `gh api repos/<owner>/<name>` → 200 means we found it.
9
+ * First match wins.
10
+ *
11
+ * The gh subprocess is injected as `ghApi` so unit tests can stub it
12
+ * without spawning anything. The real spawner lives in `gh-runner.ts`.
13
+ *
14
+ * Failures:
15
+ * - `gh api user` errors AND we have no other candidates → 'gh-failed'.
16
+ * - `gh api /user/orgs` errors → continue with user-only candidates.
17
+ * - No candidate's repo exists → 'not-found'.
18
+ */
19
+
20
+ export type GhApiResult =
21
+ | { ok: true; body: string }
22
+ | { ok: false; status: number; error: string };
23
+
24
+ export type GhApiCall = (path: string) => Promise<GhApiResult>;
25
+
26
+ export interface FindOwnerArgs {
27
+ name: string;
28
+ ghApi: GhApiCall;
29
+ }
30
+
31
+ export type FindOwnerResult =
32
+ | { ok: true; owner: string }
33
+ | { ok: false; reason: 'gh-failed' | 'not-found' };
34
+
35
+ export async function findOwner(args: FindOwnerArgs): Promise<FindOwnerResult> {
36
+ const candidates: string[] = [];
37
+
38
+ const userR = await args.ghApi('user');
39
+ if (userR.ok) {
40
+ const login = parseLoginBody(userR.body);
41
+ if (login !== '') candidates.push(login);
42
+ }
43
+
44
+ const orgsR = await args.ghApi('/user/orgs');
45
+ if (orgsR.ok) {
46
+ candidates.push(...parseOrgsBody(orgsR.body));
47
+ }
48
+
49
+ if (candidates.length === 0) return { ok: false, reason: 'gh-failed' };
50
+
51
+ for (const owner of candidates) {
52
+ const r = await args.ghApi(`repos/${owner}/${args.name}`);
53
+ if (r.ok) return { ok: true, owner };
54
+ }
55
+
56
+ return { ok: false, reason: 'not-found' };
57
+ }
58
+
59
+ function parseLoginBody(body: string): string {
60
+ // `gh api user --jq .login` returns the login as a single line (with
61
+ // trailing newline). We don't pass --jq here, but the gh-runner uses it,
62
+ // so the body is the bare login. Be defensive about whitespace.
63
+ return body.trim();
64
+ }
65
+
66
+ function parseOrgsBody(body: string): string[] {
67
+ return body
68
+ .split('\n')
69
+ .map((s) => s.replace(/\r$/, '').trim())
70
+ .filter((s) => s !== '');
71
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Parse user-typed repo references into structured RepoRef values.
3
+ *
4
+ * Ports Go canonical `parseRepoRef` (src/repo_ref.go) and friends:
5
+ * everything is pure-string transformation. No filesystem, no network,
6
+ * no gh CLI. The existence-on-GitHub check and the on-disk clone
7
+ * happen later in the orchestrator (§3.4 follow-up).
8
+ *
9
+ * Supported input forms (with optional "+workspace" suffix on any of them):
10
+ *
11
+ * <name> → ref{name}
12
+ * <name>@<owner> → ref{name, owner}
13
+ * <owner>/<name> → ref{owner, name}
14
+ * gh:<owner>/<name> → ref{owner, name, host="github.com"}
15
+ * https://<host>/<owner>/<name>[.git] → ref{host, owner, name}
16
+ * http://... → same
17
+ * git@<host>:<owner>/<name>[.git] → ref{host, owner, name}
18
+ * ssh://[user@]<host>/<owner>/<name>[.git] → ref{host, owner, name}
19
+ *
20
+ * Inputs starting with `/`, `~/`, or bare `~` are NOT repo refs — the
21
+ * caller short-circuits before this function.
22
+ */
23
+
24
+ export interface RepoRef {
25
+ host: string;
26
+ owner: string;
27
+ name: string;
28
+ workspace: string;
29
+ original: string;
30
+ }
31
+
32
+ export interface ParseRepoRefOk {
33
+ ok: true;
34
+ ref: RepoRef;
35
+ }
36
+
37
+ export interface ParseRepoRefErr {
38
+ ok: false;
39
+ error: string;
40
+ }
41
+
42
+ export type ParseRepoRefResult = ParseRepoRefOk | ParseRepoRefErr;
43
+
44
+ const URL_RE = /^(?:(?:https?|ssh):\/\/(?:[^@/]+@)?)([^:/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
45
+ const SCP_RE = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
46
+
47
+ function makeRef(original: string): RepoRef {
48
+ return { host: '', owner: '', name: '', workspace: '', original };
49
+ }
50
+
51
+ export function parseRepoRef(input: string): ParseRepoRefResult {
52
+ if (input === '') {
53
+ return { ok: false, error: 'empty repo reference' };
54
+ }
55
+ const ref = makeRef(input);
56
+
57
+ // Split off workspace suffix first.
58
+ let body = input;
59
+ const plusIdx = body.indexOf('+');
60
+ if (plusIdx >= 0) {
61
+ ref.workspace = body.slice(plusIdx + 1);
62
+ body = body.slice(0, plusIdx);
63
+ if (ref.workspace === '') {
64
+ return { ok: false, error: `empty workspace after \`+\` in ${JSON.stringify(input)}` };
65
+ }
66
+ }
67
+
68
+ // URL forms.
69
+ const urlMatch = URL_RE.exec(body);
70
+ if (urlMatch) {
71
+ ref.host = urlMatch[1]!;
72
+ ref.owner = urlMatch[2]!;
73
+ ref.name = urlMatch[3]!;
74
+ return { ok: true, ref };
75
+ }
76
+ const scpMatch = SCP_RE.exec(body);
77
+ if (scpMatch) {
78
+ ref.host = scpMatch[1]!;
79
+ ref.owner = scpMatch[2]!;
80
+ ref.name = scpMatch[3]!;
81
+ return { ok: true, ref };
82
+ }
83
+
84
+ // gh:owner/name shorthand.
85
+ if (body.startsWith('gh:')) {
86
+ const rest = body.slice(3);
87
+ const slashIdx = rest.indexOf('/');
88
+ if (slashIdx > 0 && slashIdx < rest.length - 1) {
89
+ const owner = rest.slice(0, slashIdx);
90
+ const name = rest.slice(slashIdx + 1);
91
+ if (/[/@:]/.test(owner) || /[/@:]/.test(name)) {
92
+ return { ok: false, error: `invalid gh: form: ${JSON.stringify(input)}` };
93
+ }
94
+ ref.host = 'github.com';
95
+ ref.owner = owner;
96
+ ref.name = name;
97
+ return { ok: true, ref };
98
+ }
99
+ return { ok: false, error: `gh: form requires owner/name, got ${JSON.stringify(input)}` };
100
+ }
101
+
102
+ // owner/name (single slash, no scheme).
103
+ const slashIdx = body.indexOf('/');
104
+ if (slashIdx > 0) {
105
+ if (body.indexOf('/', slashIdx + 1) >= 0) {
106
+ return { ok: false, error: `ambiguous form ${JSON.stringify(input)} (multiple slashes)` };
107
+ }
108
+ const owner = body.slice(0, slashIdx);
109
+ const name = body.slice(slashIdx + 1);
110
+ if (/[@:]/.test(owner) || /[@:]/.test(name) || owner === '' || name === '') {
111
+ return { ok: false, error: `invalid owner/name form: ${JSON.stringify(input)}` };
112
+ }
113
+ ref.owner = owner;
114
+ ref.name = name;
115
+ return { ok: true, ref };
116
+ }
117
+
118
+ // name@owner (Tom local-convention).
119
+ const atIdx = body.indexOf('@');
120
+ if (atIdx > 0) {
121
+ const name = body.slice(0, atIdx);
122
+ const owner = body.slice(atIdx + 1);
123
+ if (/[@:/]/.test(owner) || /[@:/]/.test(name) || owner === '' || name === '') {
124
+ return { ok: false, error: `invalid name@owner form: ${JSON.stringify(input)}` };
125
+ }
126
+ ref.name = name;
127
+ ref.owner = owner;
128
+ return { ok: true, ref };
129
+ }
130
+
131
+ // Bare name — but reject if it contains URL-like special chars (defense
132
+ // in depth, the above branches should have caught these).
133
+ if (/[/@:]/.test(body)) {
134
+ return { ok: false, error: `unparseable repo reference: ${JSON.stringify(input)}` };
135
+ }
136
+ ref.name = body;
137
+ return { ok: true, ref };
138
+ }
139
+
140
+ export function hasResolvedOwner(ref: RepoRef): boolean {
141
+ return ref.owner !== '';
142
+ }
143
+
144
+ export function effectiveHost(ref: RepoRef): string {
145
+ return ref.host !== '' ? ref.host : 'github.com';
146
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Four-tier repoSettings loader. Reads the `repoSettings` block from each
3
+ * tier's settings.json and shallow-merges per field.
4
+ *
5
+ * Precedence (lowest to highest — later wins):
6
+ * 1. User — ~/.claude/settings.json
7
+ * 2. Project — <projectRoot>/.claude/settings.json
8
+ * 3. Local — <projectRoot>/.claude/settings.local.json
9
+ * 4. Managed — platform-specific (org policy; can't be overridden)
10
+ *
11
+ * The `projectRoot` for resolution-time settings is the shell cwd at
12
+ * fnclaude startup (per specs.md §18.7), which is evaluated BEFORE path
13
+ * resolution runs — that's why we can read project-tier settings without
14
+ * having resolved the launch cwd yet.
15
+ *
16
+ * Paths are passed in (not hardcoded) so tests can use temp files. Caller
17
+ * wires in the real defaults; managedPath may be omitted on platforms
18
+ * where the managed-settings file is irrelevant.
19
+ *
20
+ * Mirrors Go canonical `src/repo_settings.go:51-89`.
21
+ *
22
+ * Robustness — every failure mode degrades silently to "this tier
23
+ * contributes nothing":
24
+ * - missing file, malformed JSON, non-object root
25
+ * - `repoSettings` field is missing OR not an object
26
+ * - individual field value is not a string (drop that field only,
27
+ * keep the rest from that tier)
28
+ *
29
+ * Only the four known fields are extracted. Unknown fields under
30
+ * `repoSettings` are ignored. fnclaude itself only acts on
31
+ * `cloneTemplate`; the other three are read for completeness (the
32
+ * claude-code-worktree-paths plugin reads them).
33
+ */
34
+
35
+ import { readFileSync, statSync } from 'node:fs';
36
+
37
+ export interface RepoSettings {
38
+ cloneTemplate: string;
39
+ worktreeTemplate: string;
40
+ branchTemplate: string;
41
+ gateEnvVar: string;
42
+ }
43
+
44
+ export interface LoadRepoSettingsArgs {
45
+ userPath: string;
46
+ projectPath: string;
47
+ localPath: string;
48
+ managedPath?: string;
49
+ }
50
+
51
+ const KNOWN_FIELDS: ReadonlyArray<keyof RepoSettings> = [
52
+ 'cloneTemplate',
53
+ 'worktreeTemplate',
54
+ 'branchTemplate',
55
+ 'gateEnvVar',
56
+ ];
57
+
58
+ function emptySettings(): RepoSettings {
59
+ return { cloneTemplate: '', worktreeTemplate: '', branchTemplate: '', gateEnvVar: '' };
60
+ }
61
+
62
+ export function loadRepoSettings(args: LoadRepoSettingsArgs): RepoSettings {
63
+ const merged = emptySettings();
64
+ const tiers = [args.userPath, args.projectPath, args.localPath, args.managedPath];
65
+ for (const path of tiers) {
66
+ if (path === undefined) continue;
67
+ const tier = readTier(path);
68
+ for (const field of KNOWN_FIELDS) {
69
+ const v = tier[field];
70
+ if (typeof v === 'string') merged[field] = v;
71
+ }
72
+ }
73
+ return merged;
74
+ }
75
+
76
+ function readTier(path: string): Partial<Record<keyof RepoSettings, unknown>> {
77
+ let raw: string;
78
+ try {
79
+ const st = statSync(path);
80
+ if (!st.isFile()) return {};
81
+ raw = readFileSync(path, 'utf8');
82
+ } catch {
83
+ return {};
84
+ }
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(raw);
88
+ } catch {
89
+ return {};
90
+ }
91
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
92
+ return {};
93
+ }
94
+ const repoSettings = (parsed as Record<string, unknown>).repoSettings;
95
+ if (repoSettings === null || typeof repoSettings !== 'object' || Array.isArray(repoSettings)) {
96
+ return {};
97
+ }
98
+ return repoSettings as Record<string, unknown>;
99
+ }