@fnclaude/cli 1.1.1 → 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 -219
- 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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-function pieces of auto-name (§5.2 / design.md §18):
|
|
3
|
+
* - shouldAutoName(parsed) — gating condition: does this invocation
|
|
4
|
+
* qualify for auto-naming?
|
|
5
|
+
* - sanitizeLLMOutput(s) — slug-clean an LLM's freeform response
|
|
6
|
+
* - heuristicName(prompt) — deterministic fallback when no LLM
|
|
7
|
+
*
|
|
8
|
+
* The LLM call itself (Anthropic SDK with ANTHROPIC_API_KEY, or `claude
|
|
9
|
+
* -p` subprocess fallback) sits at the orchestrator layer; this module
|
|
10
|
+
* provides the building blocks it relies on.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors Go canonical src/autoname.go:1-253.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
findPromptSentinel,
|
|
17
|
+
hasPromptBody,
|
|
18
|
+
promptBody,
|
|
19
|
+
} from '../argv/sentinel.ts';
|
|
20
|
+
import type { ParsedArgsOk } from '../argv/parse.ts';
|
|
21
|
+
|
|
22
|
+
// ── shouldAutoName ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const BLOCKERS_NO_VALUE = new Set([
|
|
25
|
+
'-p',
|
|
26
|
+
'--print',
|
|
27
|
+
'-r',
|
|
28
|
+
'--resume',
|
|
29
|
+
'-c',
|
|
30
|
+
'--continue',
|
|
31
|
+
'--from-pr',
|
|
32
|
+
'-P',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Token forms like `--name=foo`, `-n=foo`, `-r=abc`, `--resume=abc`,
|
|
36
|
+
// `--from-pr=123`, `-P=123`. Stored as prefixes to check via startsWith.
|
|
37
|
+
const BLOCKERS_EQUAL_PREFIX = [
|
|
38
|
+
'--name=',
|
|
39
|
+
'-n=',
|
|
40
|
+
'--resume=',
|
|
41
|
+
'-r=',
|
|
42
|
+
'--from-pr=',
|
|
43
|
+
'-P=',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Two-token form (flag + separate value). When we see any of these, the
|
|
47
|
+
// next-token shape doesn't matter — the presence of the flag itself
|
|
48
|
+
// blocks auto-name.
|
|
49
|
+
const BLOCKERS_TWO_TOKEN = new Set(['--name', '-n']);
|
|
50
|
+
|
|
51
|
+
export function shouldAutoName(parsed: ParsedArgsOk): boolean {
|
|
52
|
+
const pt = parsed.passthrough;
|
|
53
|
+
const sentinelIdx = findPromptSentinel(pt);
|
|
54
|
+
if (sentinelIdx < 0) return false;
|
|
55
|
+
if (!hasPromptBody(pt, sentinelIdx)) return false;
|
|
56
|
+
if (promptBody(pt, sentinelIdx).every((t) => t === '')) return false;
|
|
57
|
+
|
|
58
|
+
// Scan up to the sentinel — only flags BEFORE `--` count.
|
|
59
|
+
for (let i = 0; i < sentinelIdx; i++) {
|
|
60
|
+
const tok = pt[i]!;
|
|
61
|
+
if (BLOCKERS_NO_VALUE.has(tok)) return false;
|
|
62
|
+
if (BLOCKERS_TWO_TOKEN.has(tok)) return false;
|
|
63
|
+
for (const pre of BLOCKERS_EQUAL_PREFIX) {
|
|
64
|
+
if (tok.startsWith(pre)) return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── sanitizeLLMOutput ───────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const RE_WHITESPACE = /\s+/g;
|
|
73
|
+
const RE_NON_SLUG = /[^a-z0-9-]+/g;
|
|
74
|
+
const RE_MULTI_DASH = /-{2,}/g;
|
|
75
|
+
|
|
76
|
+
export function sanitizeLLMOutput(input: string): string {
|
|
77
|
+
let s = input.trim().toLowerCase();
|
|
78
|
+
s = s.replace(RE_WHITESPACE, '-');
|
|
79
|
+
s = s.replace(RE_NON_SLUG, '');
|
|
80
|
+
s = s.replace(RE_MULTI_DASH, '-');
|
|
81
|
+
s = trimChar(s, '-');
|
|
82
|
+
// Take first 3 segments
|
|
83
|
+
const parts = s.split('-').filter((p) => p.length > 0).slice(0, 3);
|
|
84
|
+
s = parts.join('-');
|
|
85
|
+
return trimChar(s, '-');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function trimChar(s: string, ch: string): string {
|
|
89
|
+
let start = 0;
|
|
90
|
+
let end = s.length;
|
|
91
|
+
while (start < end && s[start] === ch) start++;
|
|
92
|
+
while (end > start && s[end - 1] === ch) end--;
|
|
93
|
+
return s.slice(start, end);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── heuristicName ───────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const STOP_WORDS = new Set([
|
|
99
|
+
'a', 'an', 'the',
|
|
100
|
+
'is', 'are', 'was', 'were',
|
|
101
|
+
'do', 'does', 'did',
|
|
102
|
+
'of', 'for', 'to', 'in', 'on', 'at', 'with',
|
|
103
|
+
'this', 'that',
|
|
104
|
+
'please', 'can', 'could', 'would', 'should',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const RE_NON_ALNUM = /[^a-z0-9]/g;
|
|
108
|
+
|
|
109
|
+
export function heuristicName(prompt: string): string {
|
|
110
|
+
const fields = prompt.toLowerCase().split(/\s+/).filter((w) => w.length > 0);
|
|
111
|
+
const kept: string[] = [];
|
|
112
|
+
for (const word of fields) {
|
|
113
|
+
if (STOP_WORDS.has(word)) continue;
|
|
114
|
+
const stripped = word.replace(RE_NON_ALNUM, '');
|
|
115
|
+
if (stripped === '') continue;
|
|
116
|
+
kept.push(stripped);
|
|
117
|
+
if (kept.length === 3) break;
|
|
118
|
+
}
|
|
119
|
+
if (kept.length === 0) return 'session';
|
|
120
|
+
return kept.join('-');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── autoName orchestrator ───────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export interface AutoNameOptions {
|
|
126
|
+
prompt: string;
|
|
127
|
+
/** Provide to use an LLM. If omitted, heuristic is used directly. */
|
|
128
|
+
llmCall?: (prompt: string) => Promise<string>;
|
|
129
|
+
/** Timeout in ms for the LLM call. */
|
|
130
|
+
timeoutMs?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate a session name from the prompt. Tries the LLM (if provided)
|
|
135
|
+
* first, falls back to the heuristic on timeout, error, or empty result.
|
|
136
|
+
*
|
|
137
|
+
* The LLM call surface is injected so this layer stays pure of network/
|
|
138
|
+
* API-key concerns — the CLI wires either an Anthropic SDK call (when
|
|
139
|
+
* ANTHROPIC_API_KEY is set) or a `claude -p` subprocess.
|
|
140
|
+
*/
|
|
141
|
+
export async function autoName(opts: AutoNameOptions): Promise<string> {
|
|
142
|
+
const { prompt, llmCall, timeoutMs = 3000 } = opts;
|
|
143
|
+
|
|
144
|
+
if (llmCall === undefined) return heuristicName(prompt);
|
|
145
|
+
|
|
146
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
147
|
+
const llmPromise = llmCall(prompt).then((s) => sanitizeLLMOutput(s));
|
|
148
|
+
const timeoutPromise = new Promise<string>((_, reject) => {
|
|
149
|
+
timer = setTimeout(() => reject(new Error('auto-name LLM timeout')), timeoutMs);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const result = await Promise.race([llmPromise, timeoutPromise]);
|
|
154
|
+
if (result !== '') return result;
|
|
155
|
+
} catch {
|
|
156
|
+
// fall through to heuristic
|
|
157
|
+
} finally {
|
|
158
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return heuristicName(prompt);
|
|
162
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants for the auto-name LLM call (§5.2).
|
|
3
|
+
*
|
|
4
|
+
* Both the Anthropic SDK fast-path (`sdk-llm.ts`) and the `claude -p`
|
|
5
|
+
* subprocess fallback (in `main.ts`) drive the same model with the same
|
|
6
|
+
* system prompt; extracting them here keeps the two paths in sync.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const AUTO_NAME_MODEL = 'claude-haiku-4-5';
|
|
10
|
+
|
|
11
|
+
export const AUTO_NAME_SYSTEM_PROMPT =
|
|
12
|
+
"Generate a 1-3 word lowercase hyphen-separated label for this user's request. " +
|
|
13
|
+
"Output ONLY the label — no punctuation, no quotes, no explanation, no leading 'Label:'. " +
|
|
14
|
+
"Examples: 'fix-login-bug', 'add-dark-mode', 'refactor-auth'.";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize a name for use as a path component (worktree name, etc.).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Go canonical `src/sanitize.go:1-50`.
|
|
5
|
+
*
|
|
6
|
+
* Pipeline (in order):
|
|
7
|
+
* 1. Empty input → invalid.
|
|
8
|
+
* 2. Input starting with `/` → invalid (path-escape risk).
|
|
9
|
+
* 3. Replace runs of chars NOT in [A-Za-z0-9._/-] with a single `-`.
|
|
10
|
+
* 4. Collapse `-{2,}` runs to single `-`.
|
|
11
|
+
* 5. Collapse `/{2,}` runs to single `/`.
|
|
12
|
+
* 6. TrimLeft `[-.]` (strips leading dashes and dots).
|
|
13
|
+
* 7. TrimRight `[-/]` (strips trailing dashes and slashes).
|
|
14
|
+
* 8. Empty result → invalid.
|
|
15
|
+
* 9. Result contains `..` → invalid (path-escape prevention).
|
|
16
|
+
*
|
|
17
|
+
* `/` is intentionally permitted so nested git refs (`feat/foo`,
|
|
18
|
+
* `team/x/y`) pass through and produce nested worktree paths.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RE_PATH_SAFE_BAD = /[^A-Za-z0-9._/-]+/g;
|
|
22
|
+
const RE_DASH_RUN = /-{2,}/g;
|
|
23
|
+
const RE_SLASH_RUN = /\/{2,}/g;
|
|
24
|
+
|
|
25
|
+
export type SanitizeResult =
|
|
26
|
+
| { kind: 'unchanged'; value: string }
|
|
27
|
+
| { kind: 'changed'; value: string; original: string }
|
|
28
|
+
| { kind: 'invalid'; original: string };
|
|
29
|
+
|
|
30
|
+
export function sanitizeForPath(input: string): SanitizeResult {
|
|
31
|
+
if (input === '') return { kind: 'invalid', original: input };
|
|
32
|
+
if (input.startsWith('/')) return { kind: 'invalid', original: input };
|
|
33
|
+
|
|
34
|
+
let s = input.replace(RE_PATH_SAFE_BAD, '-');
|
|
35
|
+
s = s.replace(RE_DASH_RUN, '-');
|
|
36
|
+
s = s.replace(RE_SLASH_RUN, '/');
|
|
37
|
+
s = trimLeftChars(s, '-.');
|
|
38
|
+
s = trimRightChars(s, '-/');
|
|
39
|
+
|
|
40
|
+
if (s === '') return { kind: 'invalid', original: input };
|
|
41
|
+
if (s.includes('..')) return { kind: 'invalid', original: input };
|
|
42
|
+
|
|
43
|
+
if (s === input) return { kind: 'unchanged', value: s };
|
|
44
|
+
return { kind: 'changed', value: s, original: input };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function trimLeftChars(s: string, chars: string): string {
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < s.length && chars.includes(s[i]!)) i++;
|
|
50
|
+
return s.slice(i);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function trimRightChars(s: string, chars: string): string {
|
|
54
|
+
let i = s.length;
|
|
55
|
+
while (i > 0 && chars.includes(s[i - 1]!)) i--;
|
|
56
|
+
return s.slice(0, i);
|
|
57
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic SDK fast-path for auto-naming (§5.2).
|
|
3
|
+
*
|
|
4
|
+
* When ANTHROPIC_API_KEY is set, the launcher calls the API directly via
|
|
5
|
+
* the official SDK instead of shelling out to `claude -p`. Same model
|
|
6
|
+
* (haiku) + same system prompt as the subprocess path, but skips the
|
|
7
|
+
* cold-start overhead of spawning the claude binary, parsing its config,
|
|
8
|
+
* etc. — typically saves multiple seconds.
|
|
9
|
+
*
|
|
10
|
+
* No timeout handling here; `autoName` wraps the call with Promise.race
|
|
11
|
+
* for that, and the SDK has its own retry/backoff machinery on transient
|
|
12
|
+
* errors. We just need to return the text (or throw — autoName treats
|
|
13
|
+
* either error or empty output as "fall back to heuristic").
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
17
|
+
|
|
18
|
+
import { AUTO_NAME_SYSTEM_PROMPT, AUTO_NAME_MODEL } from './llm-prompt.ts';
|
|
19
|
+
|
|
20
|
+
export async function sdkLlmCall(prompt: string): Promise<string> {
|
|
21
|
+
// SDK picks ANTHROPIC_API_KEY up from process.env by default. Letting it
|
|
22
|
+
// do so (rather than passing apiKey explicitly) keeps the env-var name
|
|
23
|
+
// the single source of truth and matches the way every other Anthropic
|
|
24
|
+
// tool documents it.
|
|
25
|
+
const client = new Anthropic();
|
|
26
|
+
const msg = await client.messages.create({
|
|
27
|
+
model: AUTO_NAME_MODEL,
|
|
28
|
+
max_tokens: 64,
|
|
29
|
+
system: AUTO_NAME_SYSTEM_PROMPT,
|
|
30
|
+
messages: [{ role: 'user', content: prompt }],
|
|
31
|
+
});
|
|
32
|
+
// The response is an array of content blocks; auto-name's prompt steers
|
|
33
|
+
// the model to a one-line label so we expect exactly one text block.
|
|
34
|
+
// Concatenate any text blocks defensively in case the model emits more
|
|
35
|
+
// than one — sanitizeLLMOutput downstream will collapse whitespace
|
|
36
|
+
// and clip to 3 segments either way.
|
|
37
|
+
let out = '';
|
|
38
|
+
for (const block of msg.content) {
|
|
39
|
+
if (block.type === 'text') out += block.text;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/src/noop/seed.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed `handoff.template.md` into the noop dir on first noop-fallback
|
|
3
|
+
* launches (design.md §19).
|
|
4
|
+
*
|
|
5
|
+
* The function is a strict guard:
|
|
6
|
+
* - if `<noopDir>/handoff.template.md` already exists → no-op
|
|
7
|
+
* - if `templateSourcePath` doesn't exist on disk → no-op (graceful
|
|
8
|
+
* degradation; the launch must not fail because the embedded template
|
|
9
|
+
* wasn't shipped)
|
|
10
|
+
* - otherwise copy source → dest, creating the noop dir if missing
|
|
11
|
+
*
|
|
12
|
+
* NB: only `handoff.template.md` is seeded. `CLAUDE.md` and every other
|
|
13
|
+
* file in the noop dir is user-owned and fnclaude never touches them.
|
|
14
|
+
* That's the README divergence the rewrite caller was warned about.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors Go canonical's `seedNoop` (src/noop.go:1–58) loosely — the Go
|
|
17
|
+
* version uses SHA-256 to detect template drift; the rewrite goes with
|
|
18
|
+
* "missing only" semantics, which is simpler and matches what users
|
|
19
|
+
* actually want (don't clobber my hand-edited template on every launch).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { copyFile, mkdir } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
export interface SeedNoopDirArgs {
|
|
27
|
+
noopDir: string;
|
|
28
|
+
templateSourcePath: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SeedNoopDirResult {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
copied: boolean;
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TEMPLATE_FILENAME = 'handoff.template.md';
|
|
38
|
+
|
|
39
|
+
export async function seedNoopDir(args: SeedNoopDirArgs): Promise<SeedNoopDirResult> {
|
|
40
|
+
if (args.templateSourcePath === null || args.templateSourcePath === '') {
|
|
41
|
+
return { ok: true, copied: false, reason: 'no source template' };
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(args.templateSourcePath)) {
|
|
44
|
+
return { ok: true, copied: false, reason: 'no source template' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dest = join(args.noopDir, TEMPLATE_FILENAME);
|
|
48
|
+
if (existsSync(dest)) {
|
|
49
|
+
return { ok: true, copied: false, reason: 'already exists' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await mkdir(args.noopDir, { recursive: true });
|
|
54
|
+
await copyFile(args.templateSourcePath, dest);
|
|
55
|
+
return { ok: true, copied: true };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
copied: false,
|
|
60
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the on-disk source for `handoff.template.md`, which fnclaude
|
|
3
|
+
* seeds into the noop dir on first noop-fallback launches (design.md §19).
|
|
4
|
+
*
|
|
5
|
+
* Precedence (mirrors prompts/dir.ts):
|
|
6
|
+
* 1. $FNC_NOOP_TEMPLATE_PATH (env override; empty string treated as unset)
|
|
7
|
+
* 2. <exe-dir>/templates/handoff.template.md (dev / sibling layout)
|
|
8
|
+
* 3. <exe-dir>/../templates/handoff.template.md (npm / monorepo layout
|
|
9
|
+
* where the bin lives
|
|
10
|
+
* in its own subdir)
|
|
11
|
+
* 4. <exe-dir>/../share/fnclaude/templates/handoff.template.md
|
|
12
|
+
* (FHS / AUR layout —
|
|
13
|
+
* this is also where
|
|
14
|
+
* the repo ships the
|
|
15
|
+
* canonical copy)
|
|
16
|
+
*
|
|
17
|
+
* Symlink resolution of exeDir is the caller's responsibility — pass the
|
|
18
|
+
* already-resolved path here.
|
|
19
|
+
*
|
|
20
|
+
* Returns the first candidate that exists and is a regular file. If none
|
|
21
|
+
* match, returns null. Seeding gracefully degrades on null (the noop dir
|
|
22
|
+
* is still created and the session still launches per design.md §19) so
|
|
23
|
+
* we don't bother surfacing a warning here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { statSync } from 'node:fs';
|
|
27
|
+
import { join, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
export interface ResolveTemplateSourceArgs {
|
|
30
|
+
envOverride: string | undefined;
|
|
31
|
+
exeDir: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolveTemplateSourceResult {
|
|
35
|
+
path: string | null;
|
|
36
|
+
tried: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isFile(path: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
return statSync(path).isFile();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveTemplateSourcePath(args: ResolveTemplateSourceArgs): ResolveTemplateSourceResult {
|
|
48
|
+
const candidates: string[] = [];
|
|
49
|
+
|
|
50
|
+
if (args.envOverride !== undefined && args.envOverride !== '') {
|
|
51
|
+
candidates.push(args.envOverride);
|
|
52
|
+
}
|
|
53
|
+
candidates.push(join(args.exeDir, 'templates', 'handoff.template.md'));
|
|
54
|
+
candidates.push(resolve(args.exeDir, '..', 'templates', 'handoff.template.md'));
|
|
55
|
+
candidates.push(resolve(args.exeDir, '..', 'share', 'fnclaude', 'templates', 'handoff.template.md'));
|
|
56
|
+
|
|
57
|
+
for (const c of candidates) {
|
|
58
|
+
if (isFile(c)) return { path: c, tried: candidates };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { path: null, tried: candidates };
|
|
62
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fabricate a missing cwd tree so Bun.spawn doesn't report ENOENT against
|
|
3
|
+
* the claude binary path. Per design.md §26 (Go canonical src/pty_run.go:154-237).
|
|
4
|
+
*
|
|
5
|
+
* Motivation: when resuming a session whose stored cwd no longer exists,
|
|
6
|
+
* the kernel returns ENOENT during spawn — but the error message blames
|
|
7
|
+
* the binary, not the cwd. Pre-creating the cwd tree gets us a clean
|
|
8
|
+
* spawn; the cwd is held by inode reference after the child chdirs, so
|
|
9
|
+
* we can remove the fabricated dirs immediately afterward and the child
|
|
10
|
+
* still has a working pwd.
|
|
11
|
+
*
|
|
12
|
+
* Algorithm:
|
|
13
|
+
* 1. Dir exists & is a directory → return ok with empty created list
|
|
14
|
+
* 2. Dir exists & is NOT a directory → error
|
|
15
|
+
* 3. Walk up the tree recording missing levels (shallowest first)
|
|
16
|
+
* 4. mkdir each missing level in order
|
|
17
|
+
* 5. Return a cleanup() that rmdirs each in deepest-first order
|
|
18
|
+
*
|
|
19
|
+
* Cleanup robustness:
|
|
20
|
+
* - Tolerates a created dir that became non-empty (rmdir refuses;
|
|
21
|
+
* we swallow the error — the spec treats this as "shouldn't happen"
|
|
22
|
+
* but we don't want cleanup to crash the launcher path)
|
|
23
|
+
* - Idempotent: safe to call cleanup() twice
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdirSync, rmdirSync, statSync } from 'node:fs';
|
|
27
|
+
import { dirname } from 'node:path';
|
|
28
|
+
|
|
29
|
+
export type EnsureCwdResult =
|
|
30
|
+
| { ok: true; created: string[]; cleanup: () => void }
|
|
31
|
+
| { ok: false; error: string };
|
|
32
|
+
|
|
33
|
+
export function ensureCwd(path: string): EnsureCwdResult {
|
|
34
|
+
// Walk up collecting missing levels (deepest first while collecting,
|
|
35
|
+
// we reverse at the end).
|
|
36
|
+
const missing: string[] = [];
|
|
37
|
+
let cur = path;
|
|
38
|
+
while (true) {
|
|
39
|
+
let st;
|
|
40
|
+
try {
|
|
41
|
+
st = statSync(cur);
|
|
42
|
+
} catch {
|
|
43
|
+
missing.push(cur);
|
|
44
|
+
const parent = dirname(cur);
|
|
45
|
+
if (parent === cur) {
|
|
46
|
+
// Reached filesystem root and still missing — implausible but defensive.
|
|
47
|
+
return { ok: false, error: `cannot ensure cwd: walked to root looking for ${path}` };
|
|
48
|
+
}
|
|
49
|
+
cur = parent;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Found an existing level.
|
|
53
|
+
if (!st.isDirectory()) {
|
|
54
|
+
return { ok: false, error: `cannot ensure cwd: ${cur} exists but is not a directory` };
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reverse so shallowest-first; that's the mkdir order we need.
|
|
60
|
+
missing.reverse();
|
|
61
|
+
const created: string[] = [];
|
|
62
|
+
for (const p of missing) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(p);
|
|
65
|
+
created.push(p);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Unwind what we created on failure.
|
|
68
|
+
cleanupCreated(created);
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
+
return { ok: false, error: `cannot ensure cwd: mkdir ${p} failed (${msg})` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let cleanedUp = false;
|
|
75
|
+
const cleanup = (): void => {
|
|
76
|
+
if (cleanedUp) return;
|
|
77
|
+
cleanedUp = true;
|
|
78
|
+
cleanupCreated(created);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return { ok: true, created, cleanup };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanupCreated(created: readonly string[]): void {
|
|
85
|
+
// Deepest first.
|
|
86
|
+
for (let i = created.length - 1; i >= 0; i--) {
|
|
87
|
+
try {
|
|
88
|
+
rmdirSync(created[i]!);
|
|
89
|
+
} catch {
|
|
90
|
+
// Dir became non-empty, was already removed, or some other condition.
|
|
91
|
+
// Don't crash the launcher; the spec treats this as "shouldn't happen"
|
|
92
|
+
// but our policy is best-effort cleanup.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-targeting primitives: tilde expansion, noop fallback, and the
|
|
3
|
+
* full launch-cwd resolver.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors Go canonical's expandTildePath (src/resolver.go:267-278) and
|
|
6
|
+
* the cwd-resolution block in main.go around lines 940-963: tilde first,
|
|
7
|
+
* then make absolute by joining against the shell cwd.
|
|
8
|
+
*
|
|
9
|
+
* Repo-reference resolution (bare-name multi-org search, owner/name,
|
|
10
|
+
* SSH/HTTPS URLs, name@owner cloneTemplate forms) is a separate concern
|
|
11
|
+
* handled in §3.4 — resolveCwd here ASSUMES its input is a filesystem
|
|
12
|
+
* path. Callers route repo references through the resolver first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isAbsolute, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const SEPARATOR = '/';
|
|
18
|
+
|
|
19
|
+
export interface ResolveEnv {
|
|
20
|
+
home: string;
|
|
21
|
+
xdgConfigHome: string | undefined;
|
|
22
|
+
shellCwd: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResolveResult {
|
|
26
|
+
launchCwd: string;
|
|
27
|
+
usedNoopFallback: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function expandTilde(input: string, home: string): string {
|
|
31
|
+
if (input === '~') return home;
|
|
32
|
+
if (input.startsWith(`~${SEPARATOR}`)) return join(home, input.slice(2));
|
|
33
|
+
return input;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function noopDir(env: { xdgConfigHome: string | undefined; home: string }): string {
|
|
37
|
+
const base = env.xdgConfigHome && env.xdgConfigHome.length > 0
|
|
38
|
+
? env.xdgConfigHome
|
|
39
|
+
: join(env.home, '.config');
|
|
40
|
+
return join(base, 'fnclaude', 'noop');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveCwd(firstPath: string | null, env: ResolveEnv): ResolveResult {
|
|
44
|
+
if (firstPath === null || firstPath === '') {
|
|
45
|
+
return {
|
|
46
|
+
launchCwd: noopDir(env),
|
|
47
|
+
usedNoopFallback: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const expanded = expandTilde(firstPath, env.home);
|
|
52
|
+
const launchCwd = isAbsolute(expanded) ? expanded : join(env.shellCwd, expanded);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
launchCwd,
|
|
56
|
+
usedNoopFallback: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the prompts directory to load fragments from (specs.md §12.1).
|
|
3
|
+
*
|
|
4
|
+
* Precedence:
|
|
5
|
+
* 1. $FNC_PROMPTS_DIR (env override; empty string treated as unset)
|
|
6
|
+
* 2. <exe-dir>/prompts/ (dev / sibling layout)
|
|
7
|
+
* 3. <exe-dir>/../prompts/ (npm / monorepo layout
|
|
8
|
+
* where the bin lives in
|
|
9
|
+
* its own subdir)
|
|
10
|
+
* 4. <exe-dir>/../share/fnclaude/prompts/ (FHS / AUR layout)
|
|
11
|
+
*
|
|
12
|
+
* Symlink resolution of exeDir is the caller's responsibility — pass the
|
|
13
|
+
* already-resolved path here (Go canonical uses filepath.EvalSymlinks
|
|
14
|
+
* before invoking this).
|
|
15
|
+
*
|
|
16
|
+
* Returns the first candidate that exists and is a directory. If none
|
|
17
|
+
* match, returns null + a warning naming all candidates tried, so the
|
|
18
|
+
* caller can degrade gracefully (PromptSet empty, session still launches
|
|
19
|
+
* per specs.md §12.1).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { statSync } from 'node:fs';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export interface ResolvePromptsDirArgs {
|
|
26
|
+
envOverride: string | undefined;
|
|
27
|
+
exeDir: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvePromptsDirResult {
|
|
31
|
+
dir: string | null;
|
|
32
|
+
warning: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDir(path: string): boolean {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(path).isDirectory();
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolvePromptsDir(args: ResolvePromptsDirArgs): ResolvePromptsDirResult {
|
|
44
|
+
const candidates: string[] = [];
|
|
45
|
+
|
|
46
|
+
if (args.envOverride !== undefined && args.envOverride !== '') {
|
|
47
|
+
candidates.push(args.envOverride);
|
|
48
|
+
}
|
|
49
|
+
candidates.push(join(args.exeDir, 'prompts'));
|
|
50
|
+
candidates.push(resolve(args.exeDir, '..', 'prompts'));
|
|
51
|
+
candidates.push(resolve(args.exeDir, '..', 'share', 'fnclaude', 'prompts'));
|
|
52
|
+
|
|
53
|
+
for (const c of candidates) {
|
|
54
|
+
if (isDir(c)) return { dir: c, warning: undefined };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
dir: null,
|
|
59
|
+
warning: `fnclaude: prompts directory not found; tried: ${candidates.join(', ')}. Set FNC_PROMPTS_DIR to override.`,
|
|
60
|
+
};
|
|
61
|
+
}
|