@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.
Files changed (100) hide show
  1. package/bin/fnc.js +34 -79
  2. package/package.json +6 -9
  3. package/share/fnclaude/templates/handoff.template.md +11 -0
  4. package/src/argv/classify.ts +48 -0
  5. package/src/argv/expand.ts +51 -0
  6. package/src/argv/intake.ts +52 -0
  7. package/src/argv/magic.ts +103 -0
  8. package/src/argv/parse.ts +213 -0
  9. package/src/argv/preserve-args.ts +333 -0
  10. package/src/argv/sentinel.ts +41 -0
  11. package/src/argv/short-flags.ts +152 -0
  12. package/src/config/load.ts +116 -0
  13. package/src/handoff/awaiter.ts +140 -0
  14. package/src/handoff/clean-env.ts +45 -0
  15. package/src/handoff/kill-and-exec.ts +110 -0
  16. package/src/handoff/spawn-launcher.ts +185 -0
  17. package/src/handoff/summary-file.ts +86 -0
  18. package/src/handoff/trigger.ts +90 -0
  19. package/src/help-version.ts +151 -0
  20. package/src/launch/compose-env.ts +34 -0
  21. package/src/launch/cross-cwd-parse.ts +69 -0
  22. package/src/launch/cross-cwd-relaunch.ts +95 -0
  23. package/src/launch/find-claude.ts +52 -0
  24. package/src/launch/live-permission-reader.ts +133 -0
  25. package/src/launch/ring-buffer.ts +92 -0
  26. package/src/main.ts +580 -437
  27. package/src/mcp/dispatch.ts +240 -0
  28. package/src/mcp/handlers/clipboard-backends.ts +176 -0
  29. package/src/mcp/handlers/clipboard.ts +62 -0
  30. package/src/mcp/handlers/restart.ts +156 -0
  31. package/src/mcp/handlers/spawn.ts +219 -0
  32. package/src/mcp/handlers/switch.ts +272 -0
  33. package/src/mcp/inject-config.ts +59 -0
  34. package/src/mcp/jsonrpc-server.ts +154 -0
  35. package/src/mcp/listener.ts +141 -0
  36. package/src/mcp/parent-dispatch.ts +154 -0
  37. package/src/mcp/socket-path.ts +48 -0
  38. package/src/mcp/wire.ts +181 -0
  39. package/src/name/auto-name.ts +162 -0
  40. package/src/name/llm-prompt.ts +14 -0
  41. package/src/name/sanitize.ts +57 -0
  42. package/src/name/sdk-llm.ts +42 -0
  43. package/src/noop/seed.ts +63 -0
  44. package/src/noop/template-source.ts +62 -0
  45. package/src/path/ensure-cwd.ts +95 -0
  46. package/src/path/resolve.ts +58 -0
  47. package/src/prompts/dir.ts +61 -0
  48. package/src/prompts/load.ts +100 -0
  49. package/src/prompts/select.ts +43 -0
  50. package/src/repo/clone-exec.ts +37 -0
  51. package/src/repo/clone.ts +45 -0
  52. package/src/repo/gh-runner.ts +68 -0
  53. package/src/repo/host-aliases.ts +58 -0
  54. package/src/repo/owner-lookup.ts +71 -0
  55. package/src/repo/ref.ts +146 -0
  56. package/src/repo/repo-settings.ts +99 -0
  57. package/src/repo/resolve-input.ts +179 -0
  58. package/src/repo/template.ts +92 -0
  59. package/src/warnings/buffer.ts +39 -0
  60. package/src/worktree/auto-tmux.ts +45 -0
  61. package/src/worktree/git-list.ts +73 -0
  62. package/src/worktree/intercept.ts +150 -0
  63. package/bin/preflight.js +0 -66
  64. package/prompts/agent-pitfall.md +0 -1
  65. package/prompts/noop-router.md +0 -186
  66. package/prompts/project-switch.md +0 -64
  67. package/prompts/restart.md +0 -50
  68. package/prompts/spawn.md +0 -62
  69. package/src/argParser.ts +0 -367
  70. package/src/args/preserve.ts +0 -338
  71. package/src/args.ts +0 -239
  72. package/src/argv.ts +0 -219
  73. package/src/autoname.ts +0 -273
  74. package/src/clipboard.ts +0 -149
  75. package/src/config.ts +0 -369
  76. package/src/errors.ts +0 -13
  77. package/src/handoff.ts +0 -108
  78. package/src/help.ts +0 -139
  79. package/src/hostAliases.ts +0 -139
  80. package/src/index.ts +0 -120
  81. package/src/mcp/client.ts +0 -645
  82. package/src/mcp/protocol.ts +0 -445
  83. package/src/mcp/socketListener.ts +0 -540
  84. package/src/noop.ts +0 -106
  85. package/src/passthrough.ts +0 -36
  86. package/src/paths.ts +0 -55
  87. package/src/prompts.ts +0 -279
  88. package/src/pty/unix.ts +0 -429
  89. package/src/pty/windows.ts +0 -125
  90. package/src/pty.ts +0 -380
  91. package/src/repoRef.ts +0 -158
  92. package/src/repoSettings.ts +0 -144
  93. package/src/resolver.ts +0 -519
  94. package/src/sanitize.ts +0 -120
  95. package/src/sessionState.ts +0 -220
  96. package/src/silentRelaunch.ts +0 -178
  97. package/src/spawn.ts +0 -163
  98. package/src/template.ts +0 -44
  99. package/src/warnings.ts +0 -34
  100. package/src/worktree.ts +0 -201
@@ -0,0 +1,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
+ }
@@ -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
+ }