@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolver orchestrator — takes the first positional argument (or null)
|
|
3
|
+
* and decides what fnclaude should do with it. Pure dispatch + filesystem
|
|
4
|
+
* existence checks; gh-CLI-bound side effects (owner lookup, clone
|
|
5
|
+
* execution) are surfaced as result variants so the caller (CLI main)
|
|
6
|
+
* handles them at the boundary.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors Go canonical `src/resolver.go:resolveLaunchCwd` minus the gh
|
|
9
|
+
* branches:
|
|
10
|
+
* - Short-circuit for /, ~, ~/ → launch unconditionally (no fs check)
|
|
11
|
+
* - Everything else → dual lookup
|
|
12
|
+
* path: is <shellCwd>/<input> a directory?
|
|
13
|
+
* repo: parse, compute clone destination, does it exist?
|
|
14
|
+
* - Both found → ambiguous (caller errors with disambiguation msg)
|
|
15
|
+
* - One found → launch that one
|
|
16
|
+
* - Neither → needs-clone (resolved owner) or needs-owner-lookup
|
|
17
|
+
* (bare name) — caller invokes gh CLI
|
|
18
|
+
*
|
|
19
|
+
* See specs.md §18.1 for the user-facing description.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { statSync } from 'node:fs';
|
|
23
|
+
import { isAbsolute, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { expandTilde, noopDir } from '../path/resolve.ts';
|
|
26
|
+
import { buildCloneUrl, computeCloneDestination } from './clone.ts';
|
|
27
|
+
import { hasResolvedOwner, parseRepoRef } from './ref.ts';
|
|
28
|
+
|
|
29
|
+
export interface ResolveInputArgs {
|
|
30
|
+
input: string | null;
|
|
31
|
+
shellCwd: string;
|
|
32
|
+
home: string;
|
|
33
|
+
xdgConfigHome: string | undefined;
|
|
34
|
+
settings: {
|
|
35
|
+
cloneTemplate: string;
|
|
36
|
+
hostAliases: Record<string, string>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ResolveResult =
|
|
41
|
+
| {
|
|
42
|
+
kind: 'launch';
|
|
43
|
+
launchCwd: string;
|
|
44
|
+
workspace: string;
|
|
45
|
+
usedNoopFallback: boolean;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
kind: 'needs-clone';
|
|
49
|
+
url: string;
|
|
50
|
+
destination: string;
|
|
51
|
+
workspace: string;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
kind: 'needs-owner-lookup';
|
|
55
|
+
name: string;
|
|
56
|
+
workspace: string;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
kind: 'ambiguous';
|
|
60
|
+
path: string;
|
|
61
|
+
cloneDestination?: string;
|
|
62
|
+
repoRef?: string;
|
|
63
|
+
}
|
|
64
|
+
| { kind: 'error'; error: string };
|
|
65
|
+
|
|
66
|
+
function isDirectory(path: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
return statSync(path).isDirectory();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPathShortCircuit(input: string): boolean {
|
|
75
|
+
if (input === '~') return true;
|
|
76
|
+
if (input.startsWith('/')) return true;
|
|
77
|
+
if (input.startsWith('~/')) return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveInput(args: ResolveInputArgs): ResolveResult {
|
|
82
|
+
const { input, shellCwd, home, xdgConfigHome, settings } = args;
|
|
83
|
+
|
|
84
|
+
// 1. Null / empty → noop fallback
|
|
85
|
+
if (input === null || input === '') {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'launch',
|
|
88
|
+
launchCwd: noopDir({ xdgConfigHome, home }),
|
|
89
|
+
usedNoopFallback: true,
|
|
90
|
+
workspace: '',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Path short-circuit: /, ~, ~/ skip the repo lookup entirely
|
|
95
|
+
// (per specs.md §18.1). Don't check whether the directory exists —
|
|
96
|
+
// user said "go here", we go there.
|
|
97
|
+
if (isPathShortCircuit(input)) {
|
|
98
|
+
const expanded = expandTilde(input, home);
|
|
99
|
+
const launchCwd = isAbsolute(expanded) ? expanded : join(shellCwd, expanded);
|
|
100
|
+
return { kind: 'launch', launchCwd, usedNoopFallback: false, workspace: '' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Dual lookup: check both path-on-disk and repo-ref interpretation.
|
|
104
|
+
const pathCandidate = join(shellCwd, input);
|
|
105
|
+
const pathExists = isDirectory(pathCandidate);
|
|
106
|
+
|
|
107
|
+
const parseResult = parseRepoRef(input);
|
|
108
|
+
|
|
109
|
+
// If repo-ref parse failed: path is the only chance.
|
|
110
|
+
if (!parseResult.ok) {
|
|
111
|
+
if (pathExists) {
|
|
112
|
+
return {
|
|
113
|
+
kind: 'launch',
|
|
114
|
+
launchCwd: pathCandidate,
|
|
115
|
+
usedNoopFallback: false,
|
|
116
|
+
workspace: '',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { kind: 'error', error: parseResult.error };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ref = parseResult.ref;
|
|
123
|
+
|
|
124
|
+
// Bare name (owner not in input) — need gh CLI to find owner.
|
|
125
|
+
if (!hasResolvedOwner(ref)) {
|
|
126
|
+
if (pathExists) {
|
|
127
|
+
return { kind: 'ambiguous', path: pathCandidate, repoRef: input };
|
|
128
|
+
}
|
|
129
|
+
return { kind: 'needs-owner-lookup', name: ref.name, workspace: ref.workspace };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Owner is resolved; compute clone destination.
|
|
133
|
+
if (settings.cloneTemplate === '') {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'error',
|
|
136
|
+
error:
|
|
137
|
+
'cloneTemplate is not configured in repoSettings; cannot resolve repo references. Set repoSettings.cloneTemplate in ~/.claude/settings.json (e.g. "~/src/{repo}@{owner}")',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const destResult = computeCloneDestination({
|
|
142
|
+
ref,
|
|
143
|
+
template: settings.cloneTemplate,
|
|
144
|
+
hostAliases: settings.hostAliases,
|
|
145
|
+
home,
|
|
146
|
+
});
|
|
147
|
+
if (!destResult.ok) {
|
|
148
|
+
return { kind: 'error', error: destResult.error };
|
|
149
|
+
}
|
|
150
|
+
const destination = destResult.path;
|
|
151
|
+
const destExists = isDirectory(destination);
|
|
152
|
+
|
|
153
|
+
if (pathExists && destExists) {
|
|
154
|
+
return { kind: 'ambiguous', path: pathCandidate, cloneDestination: destination };
|
|
155
|
+
}
|
|
156
|
+
if (pathExists) {
|
|
157
|
+
return {
|
|
158
|
+
kind: 'launch',
|
|
159
|
+
launchCwd: pathCandidate,
|
|
160
|
+
usedNoopFallback: false,
|
|
161
|
+
workspace: ref.workspace,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (destExists) {
|
|
165
|
+
return {
|
|
166
|
+
kind: 'launch',
|
|
167
|
+
launchCwd: destination,
|
|
168
|
+
usedNoopFallback: false,
|
|
169
|
+
workspace: ref.workspace,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
kind: 'needs-clone',
|
|
175
|
+
url: buildCloneUrl(ref),
|
|
176
|
+
destination,
|
|
177
|
+
workspace: ref.workspace,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template substitution for cloneTemplate values (and any future templates
|
|
3
|
+
* fnclaude reads from repoSettings). Placeholder vocabulary aligns with
|
|
4
|
+
* the claude-code-worktree-paths plugin so users learn one templating
|
|
5
|
+
* language across both tools.
|
|
6
|
+
*
|
|
7
|
+
* Ports Go canonical's template.go. fnclaude only uses cloneTemplate,
|
|
8
|
+
* which is computed BEFORE a clone exists — placeholders like {repo-dir},
|
|
9
|
+
* {clone-path}, {input}, {cwd} aren't meaningful here and are rejected
|
|
10
|
+
* via the unknown-placeholder error.
|
|
11
|
+
*
|
|
12
|
+
* Lazy resolvers: {host-short} defers the LUT lookup error until the
|
|
13
|
+
* placeholder is actually referenced. Templates that don't use it don't
|
|
14
|
+
* need the LUT populated.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface TemplateResolveOk {
|
|
18
|
+
ok: true;
|
|
19
|
+
value: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TemplateResolveErr {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TemplateResolveResult = TemplateResolveOk | TemplateResolveErr;
|
|
28
|
+
|
|
29
|
+
export type TemplateVars = Record<string, () => TemplateResolveResult>;
|
|
30
|
+
|
|
31
|
+
export function applyTemplate(tpl: string, vars: TemplateVars): TemplateResolveResult {
|
|
32
|
+
let out = '';
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < tpl.length) {
|
|
35
|
+
const c = tpl[i]!;
|
|
36
|
+
if (c !== '{') {
|
|
37
|
+
out += c;
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const closeIdx = tpl.indexOf('}', i + 1);
|
|
42
|
+
if (closeIdx < 0) {
|
|
43
|
+
// Unterminated `{` — pass through literally; the user's template is
|
|
44
|
+
// malformed and an error here would be confusing.
|
|
45
|
+
out += c;
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const name = tpl.slice(i + 1, closeIdx);
|
|
50
|
+
const resolver = vars[name];
|
|
51
|
+
if (!resolver) {
|
|
52
|
+
return { ok: false, error: `unknown placeholder {${name}} in template ${JSON.stringify(tpl)}` };
|
|
53
|
+
}
|
|
54
|
+
const r = resolver();
|
|
55
|
+
if (!r.ok) return r;
|
|
56
|
+
out += r.value;
|
|
57
|
+
i = closeIdx + 1;
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, value: out };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the placeholder map for cloneTemplate expansion given the resolved
|
|
64
|
+
* repo coordinates. Lazy resolvers (host-short) let templates that don't
|
|
65
|
+
* reference them skip LUT lookups.
|
|
66
|
+
*/
|
|
67
|
+
export function cloneTemplateVars(
|
|
68
|
+
repo: string,
|
|
69
|
+
owner: string,
|
|
70
|
+
host: string,
|
|
71
|
+
hostAliases: Record<string, string>,
|
|
72
|
+
): TemplateVars {
|
|
73
|
+
const dotIdx = host.indexOf('.');
|
|
74
|
+
const hostPlain = dotIdx >= 0 ? host.slice(0, dotIdx) : host;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
repo: () => ({ ok: true, value: repo }),
|
|
78
|
+
owner: () => ({ ok: true, value: owner }),
|
|
79
|
+
host: () => ({ ok: true, value: host }),
|
|
80
|
+
'host-plain': () => ({ ok: true, value: hostPlain }),
|
|
81
|
+
'host-short': () => {
|
|
82
|
+
const alias = hostAliases[host];
|
|
83
|
+
if (alias === undefined) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `host ${JSON.stringify(host)} has no entry in hostAliases LUT; add one to ~/.claude/settings.json's hostShortAliases block`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, value: alias };
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Deferred-flush warning buffer (design.md §27).
|
|
2
|
+
//
|
|
3
|
+
// Non-fatal warnings issued during the launch sequence (config-parse,
|
|
4
|
+
// fragment-load, name-sanitization, etc.) shouldn't be written to stderr
|
|
5
|
+
// as they happen — claude's TUI takes over the terminal moments later and
|
|
6
|
+
// scrolls them off-screen before the user sees them. Instead, accumulate
|
|
7
|
+
// them here and flush once claude has exited, so they land in the user's
|
|
8
|
+
// shell where they have time to read them.
|
|
9
|
+
//
|
|
10
|
+
// Terminal errors that exit non-zero (bad argv, missing claude binary,
|
|
11
|
+
// clone failure, etc.) bypass this buffer and write directly to stderr —
|
|
12
|
+
// those aren't warnings, they're the reason the launch is aborting, and
|
|
13
|
+
// the user needs to see them immediately.
|
|
14
|
+
|
|
15
|
+
export interface WarningBuffer {
|
|
16
|
+
/** Queue a warning. Trailing newline is added on flush if absent. */
|
|
17
|
+
add(msg: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Write every queued warning to the given stream, one per line, then
|
|
20
|
+
* drain the buffer. Subsequent `flush` calls without new `add`s are
|
|
21
|
+
* no-ops. Idempotent on an empty queue.
|
|
22
|
+
*/
|
|
23
|
+
flush(stream: NodeJS.WritableStream): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createWarningBuffer(): WarningBuffer {
|
|
27
|
+
const queue: string[] = [];
|
|
28
|
+
return {
|
|
29
|
+
add(msg: string): void {
|
|
30
|
+
queue.push(msg);
|
|
31
|
+
},
|
|
32
|
+
flush(stream: NodeJS.WritableStream): void {
|
|
33
|
+
while (queue.length > 0) {
|
|
34
|
+
const msg = queue.shift()!;
|
|
35
|
+
stream.write(msg.endsWith('\n') ? msg : `${msg}\n`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-tmux gating (§5.4) — when `config.auto.tmux = "worktree"` and the
|
|
3
|
+
* user is creating a new worktree (i.e. `-w <name>` was set and the
|
|
4
|
+
* worktree-intercept layer did NOT find an existing match), inject
|
|
5
|
+
* `--tmux` into passthrough. The injection itself is the caller's job;
|
|
6
|
+
* this module decides whether.
|
|
7
|
+
*
|
|
8
|
+
* Per design.md §1 and prd.launcher.md "Auto-tmux for new worktrees".
|
|
9
|
+
*
|
|
10
|
+
* All five conditions must hold:
|
|
11
|
+
* 1. configAutoTmux === "worktree"
|
|
12
|
+
* 2. worktreeSet === true (user passed -w / 2nd-positional)
|
|
13
|
+
* 3. worktreeMatched === false (a new worktree, not entering an
|
|
14
|
+
* existing one)
|
|
15
|
+
* 4. noTmux === false (user did not pass --no-tmux escape hatch)
|
|
16
|
+
* 5. passthrough does NOT already contain `--tmux` or `--tmux=…`
|
|
17
|
+
* (short -T is assumed to have been expanded by §4.5)
|
|
18
|
+
*
|
|
19
|
+
* If any fails: do nothing — explicit user intent always wins.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface AutoTmuxArgs {
|
|
23
|
+
configAutoTmux: string | undefined;
|
|
24
|
+
worktreeSet: boolean;
|
|
25
|
+
worktreeMatched: boolean;
|
|
26
|
+
noTmux: boolean;
|
|
27
|
+
passthrough: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function passthroughHasTmux(passthrough: readonly string[]): boolean {
|
|
31
|
+
for (const tok of passthrough) {
|
|
32
|
+
if (tok === '--tmux') return true;
|
|
33
|
+
if (tok.startsWith('--tmux=')) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function shouldInjectTmux(args: AutoTmuxArgs): boolean {
|
|
39
|
+
if (args.configAutoTmux !== 'worktree') return false;
|
|
40
|
+
if (!args.worktreeSet) return false;
|
|
41
|
+
if (args.worktreeMatched) return false;
|
|
42
|
+
if (args.noTmux) return false;
|
|
43
|
+
if (passthroughHasTmux(args.passthrough)) return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `git worktree list --porcelain` in a directory and parse the output
|
|
3
|
+
* into Worktree[] for the intercept layer (§5.3).
|
|
4
|
+
*
|
|
5
|
+
* Porcelain block format (per git docs):
|
|
6
|
+
*
|
|
7
|
+
* worktree /abs/path
|
|
8
|
+
* HEAD <sha>
|
|
9
|
+
* branch refs/heads/<name> OR detached
|
|
10
|
+
*
|
|
11
|
+
* Blocks separated by blank lines. The branch field's "refs/heads/"
|
|
12
|
+
* prefix is stripped so callers can match against the user-typed name
|
|
13
|
+
* directly. Detached worktrees get branch = "".
|
|
14
|
+
*
|
|
15
|
+
* listWorktrees() returns null if git isn't installed, the directory
|
|
16
|
+
* isn't a git repo, or git errored — the intercept layer treats null as
|
|
17
|
+
* "no worktrees, no match" and forwards `-w` to claude unchanged.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
import type { Worktree } from './intercept.ts';
|
|
23
|
+
|
|
24
|
+
const REFS_HEADS_PREFIX = 'refs/heads/';
|
|
25
|
+
|
|
26
|
+
export function parseGitWorktreeListPorcelain(out: string): Worktree[] {
|
|
27
|
+
const lines = out.split('\n');
|
|
28
|
+
const worktrees: Worktree[] = [];
|
|
29
|
+
let cur: { path: string | null; branch: string | null } = { path: null, branch: null };
|
|
30
|
+
|
|
31
|
+
const flush = (): void => {
|
|
32
|
+
if (cur.path !== null) {
|
|
33
|
+
worktrees.push({ path: cur.path, branch: cur.branch ?? '' });
|
|
34
|
+
}
|
|
35
|
+
cur = { path: null, branch: null };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (line === '') {
|
|
40
|
+
flush();
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (line.startsWith('worktree ')) {
|
|
44
|
+
flush();
|
|
45
|
+
cur.path = line.slice('worktree '.length);
|
|
46
|
+
} else if (line.startsWith('branch ')) {
|
|
47
|
+
const v = line.slice('branch '.length);
|
|
48
|
+
cur.branch = v.startsWith(REFS_HEADS_PREFIX) ? v.slice(REFS_HEADS_PREFIX.length) : v;
|
|
49
|
+
} else if (line === 'detached') {
|
|
50
|
+
cur.branch = '';
|
|
51
|
+
}
|
|
52
|
+
// HEAD <sha> and any unknown lines: ignored.
|
|
53
|
+
}
|
|
54
|
+
flush();
|
|
55
|
+
|
|
56
|
+
return worktrees;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listWorktrees(cwd: string): Worktree[] | null {
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = spawnSync('git', ['worktree', 'list', '--porcelain'], {
|
|
63
|
+
cwd,
|
|
64
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (result.error !== undefined) return null;
|
|
71
|
+
if (result.status !== 0) return null;
|
|
72
|
+
return parseGitWorktreeListPorcelain(result.stdout);
|
|
73
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree intercept — handles `-w <name>` (and 2nd-positional, which
|
|
3
|
+
* the parser routes through the same worktreeArg slot).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors Go canonical `src/main.go:631-743` with one rewrite deviation
|
|
6
|
+
* per PRD item #8: ALSO set `--name <name>` on match. Go canonical
|
|
7
|
+
* didn't; the rewrite always sets `--name` so the session name reflects
|
|
8
|
+
* the worktree name regardless of match.
|
|
9
|
+
*
|
|
10
|
+
* Behavior matrix (specs.md §10):
|
|
11
|
+
*
|
|
12
|
+
* worktreeSet=false → no-op (passthrough unchanged)
|
|
13
|
+
* bare -w (arg='') → push `--worktree` alone (no name)
|
|
14
|
+
* -w <name>, match found → swap launchCwd to match.path;
|
|
15
|
+
* push `--name <name>` (unless --name/-n
|
|
16
|
+
* already in passthrough);
|
|
17
|
+
* do NOT push `--worktree`
|
|
18
|
+
* -w <name>, no match → push `--worktree <name>`; push
|
|
19
|
+
* `--name <name>` (unless already set)
|
|
20
|
+
* -w <name>, not a repo → same as no-match (listWorktrees returns
|
|
21
|
+
* null; git errors degrade silently)
|
|
22
|
+
*
|
|
23
|
+
* Match priority ladder against sanitizeForPath(<name>):
|
|
24
|
+
* 1. branch === query
|
|
25
|
+
* 2. branch with "worktree-" prefix stripped === query
|
|
26
|
+
* 3. basename(worktree.path) === query
|
|
27
|
+
*
|
|
28
|
+
* The name is sanitized via §5.1 sanitizeForPath. Invalid → original
|
|
29
|
+
* kept (with a deferred warning); changed-but-valid → sanitized used
|
|
30
|
+
* (with a deferred warning naming both).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { basename } from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { sanitizeForPath } from '../name/sanitize.ts';
|
|
36
|
+
|
|
37
|
+
export interface Worktree {
|
|
38
|
+
path: string;
|
|
39
|
+
branch: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InterceptArgs {
|
|
43
|
+
worktreeSet: boolean;
|
|
44
|
+
worktreeArg: string;
|
|
45
|
+
launchCwd: string;
|
|
46
|
+
passthrough: readonly string[];
|
|
47
|
+
/**
|
|
48
|
+
* Return the list of worktrees in launchCwd, or null if it's not a
|
|
49
|
+
* git repo (or git failed). Caller wires `git worktree list
|
|
50
|
+
* --porcelain` parsing.
|
|
51
|
+
*/
|
|
52
|
+
listWorktrees: (cwd: string) => Worktree[] | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InterceptResult {
|
|
56
|
+
launchCwd: string;
|
|
57
|
+
passthrough: string[];
|
|
58
|
+
worktreeMatched: boolean;
|
|
59
|
+
warnings: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const WORKTREE_BRANCH_PREFIX = 'worktree-';
|
|
63
|
+
|
|
64
|
+
function hasNameInPassthrough(passthrough: readonly string[]): boolean {
|
|
65
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
66
|
+
const tok = passthrough[i]!;
|
|
67
|
+
if (tok === '--name' || tok === '-n') return true;
|
|
68
|
+
if (tok.startsWith('--name=') || tok.startsWith('-n=')) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findMatch(name: string, wts: Worktree[]): Worktree | null {
|
|
74
|
+
// Priority 1: exact branch match
|
|
75
|
+
for (const wt of wts) {
|
|
76
|
+
if (wt.branch === name) return wt;
|
|
77
|
+
}
|
|
78
|
+
// Priority 2: branch with worktree- prefix stripped
|
|
79
|
+
for (const wt of wts) {
|
|
80
|
+
if (
|
|
81
|
+
wt.branch.startsWith(WORKTREE_BRANCH_PREFIX) &&
|
|
82
|
+
wt.branch.slice(WORKTREE_BRANCH_PREFIX.length) === name
|
|
83
|
+
) {
|
|
84
|
+
return wt;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Priority 3: basename of worktree path
|
|
88
|
+
for (const wt of wts) {
|
|
89
|
+
if (basename(wt.path) === name) return wt;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function applyWorktreeIntercept(args: InterceptArgs): InterceptResult {
|
|
95
|
+
const passthrough = [...args.passthrough];
|
|
96
|
+
const warnings: string[] = [];
|
|
97
|
+
|
|
98
|
+
if (!args.worktreeSet) {
|
|
99
|
+
return { launchCwd: args.launchCwd, passthrough, worktreeMatched: false, warnings };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Bare -w (no value)
|
|
103
|
+
if (args.worktreeArg === '') {
|
|
104
|
+
passthrough.push('--worktree');
|
|
105
|
+
return { launchCwd: args.launchCwd, passthrough, worktreeMatched: false, warnings };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Sanitize the name, collecting any warning.
|
|
109
|
+
const san = sanitizeForPath(args.worktreeArg);
|
|
110
|
+
let name: string;
|
|
111
|
+
if (san.kind === 'unchanged') {
|
|
112
|
+
name = san.value;
|
|
113
|
+
} else if (san.kind === 'changed') {
|
|
114
|
+
name = san.value;
|
|
115
|
+
warnings.push(
|
|
116
|
+
`fnclaude: -w ${JSON.stringify(san.original)} sanitized to ${JSON.stringify(san.value)} (illegal path/branch chars)`,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
// Invalid — pass original through with warning, no match attempted.
|
|
120
|
+
name = san.original;
|
|
121
|
+
warnings.push(
|
|
122
|
+
`fnclaude: -w ${JSON.stringify(san.original)} is not a safe path/branch name; passed through unchanged`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const wts = args.listWorktrees(args.launchCwd);
|
|
127
|
+
const match = wts === null ? null : findMatch(name, wts);
|
|
128
|
+
|
|
129
|
+
if (match !== null) {
|
|
130
|
+
// Match: swap cwd, set --name unless already set, don't forward --worktree.
|
|
131
|
+
const result: InterceptResult = {
|
|
132
|
+
launchCwd: match.path,
|
|
133
|
+
passthrough,
|
|
134
|
+
worktreeMatched: true,
|
|
135
|
+
warnings,
|
|
136
|
+
};
|
|
137
|
+
if (!hasNameInPassthrough(passthrough)) {
|
|
138
|
+
passthrough.push('--name', name);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// No match: pass --worktree <name> through, set --name unless already set.
|
|
144
|
+
passthrough.push('--worktree', name);
|
|
145
|
+
if (!hasNameInPassthrough(args.passthrough)) {
|
|
146
|
+
passthrough.push('--name', name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { launchCwd: args.launchCwd, passthrough, worktreeMatched: false, warnings };
|
|
150
|
+
}
|
package/bin/preflight.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// Preflight for the cli `fnc` bin shim — decide whether to proceed,
|
|
2
|
-
// re-exec under Bun, or hard-error out with a directive message.
|
|
3
|
-
//
|
|
4
|
-
// The cli relies on Bun-only globals (`Bun.spawn`, `Bun.TOML.parse`,
|
|
5
|
-
// `Bun.which`, `process.execve`). When the bin is invoked under Node
|
|
6
|
-
// (typically because `npm i -g @fnclaude/cli` puts it on PATH and the
|
|
7
|
-
// user runs `fnc`), those Bun calls fail silently — `Bun.which` returns
|
|
8
|
-
// `undefined`, `Bun.TOML.parse` throws "Bun is not defined", etc. The
|
|
9
|
-
// cli then degrades into a "claude not found" / config-broken state
|
|
10
|
-
// that LOOKS like a normal failure but is actually a runtime mismatch.
|
|
11
|
-
//
|
|
12
|
-
// This preflight runs first and either re-execs under Bun (if `bun` is
|
|
13
|
-
// on PATH) or prints a directive error and exits non-zero — never the
|
|
14
|
-
// silent-degradation path.
|
|
15
|
-
//
|
|
16
|
-
// Extracted from the shim itself to keep it unit-testable: the decision
|
|
17
|
-
// function takes injected seams (typeof-Bun probe + PATH lookup) and
|
|
18
|
-
// returns a discriminated union; the shim handles the dispatch.
|
|
19
|
-
|
|
20
|
-
import { spawnSync } from 'node:child_process';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @typedef {{ kind: 'run' }
|
|
24
|
-
* | { kind: 'reexec', bun: string }
|
|
25
|
-
* | { kind: 'error', message: string }
|
|
26
|
-
* } PreflightDecision
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Decide what the shim should do.
|
|
31
|
-
*
|
|
32
|
-
* @param {{ hasBun: boolean, lookupBun: () => string | null }} seams
|
|
33
|
-
* @returns {PreflightDecision}
|
|
34
|
-
*/
|
|
35
|
-
export function decide({ hasBun, lookupBun }) {
|
|
36
|
-
if (hasBun) return { kind: 'run' };
|
|
37
|
-
const bun = lookupBun();
|
|
38
|
-
if (bun !== null) return { kind: 'reexec', bun };
|
|
39
|
-
return {
|
|
40
|
-
kind: 'error',
|
|
41
|
-
message:
|
|
42
|
-
'fnclaude: this CLI requires Bun (https://bun.sh) to run.\n' +
|
|
43
|
-
' The shim was invoked under Node, but the CLI depends on Bun-only\n' +
|
|
44
|
-
' globals (Bun.spawn, Bun.TOML.parse, process.execve). Install Bun\n' +
|
|
45
|
-
' and re-run `fnc`, or invoke the script via `bun ...` directly.',
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Default PATH lookup for `bun`. Returns the literal string `'bun'` on
|
|
51
|
-
* success (the OS will resolve it via PATH at spawn time), or null when
|
|
52
|
-
* `bun` is not reachable.
|
|
53
|
-
*
|
|
54
|
-
* Implementation note: `spawnSync('bun', ['--version'])` is the simplest
|
|
55
|
-
* cross-platform probe. ENOENT comes back as `result.error.code`, and a
|
|
56
|
-
* present-but-broken bun returns non-zero status. Both are treated as
|
|
57
|
-
* "not usable."
|
|
58
|
-
*
|
|
59
|
-
* @returns {string | null}
|
|
60
|
-
*/
|
|
61
|
-
export function defaultLookupBun() {
|
|
62
|
-
const r = spawnSync('bun', ['--version'], { stdio: 'ignore' });
|
|
63
|
-
if (r.error) return null;
|
|
64
|
-
if (r.status !== 0) return null;
|
|
65
|
-
return 'bun';
|
|
66
|
-
}
|
package/prompts/agent-pitfall.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
When using the Task / Agent tool with isolation set to "worktree", do not name the main repo's absolute path in the spawn prompt — the agent will cd there and silently bypass its isolated worktree. Phrase locations as "your worktree" or "this directory", and verify with git log on the worktree branch after the agent reports done.
|