@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
|
@@ -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
|
+
}
|
package/src/repo/ref.ts
ADDED
|
@@ -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
|
+
}
|