@fnclaude/cli 0.7.2 → 0.7.3

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/src/argv.ts CHANGED
@@ -16,22 +16,22 @@
16
16
  // 4. System-prompt fragment injection: --append-system-prompt <merged-text>
17
17
  // composed by selectFragments + withAppendedSystemPrompts.
18
18
 
19
- import { existsSync, realpathSync } from 'node:fs';
19
+ import { existsSync } from 'node:fs';
20
20
  import { isAbsolute, join } from 'node:path';
21
- import process from 'node:process';
22
- import type { Args } from './args.js';
21
+ import type { InterceptedArgs } from './args.js';
23
22
  import {
24
23
  nameInPassthrough as _nameInPassthrough,
25
24
  settingSourcesInPassthrough,
26
25
  tokenInPassthrough,
27
- } from './argParser.js';
26
+ } from './passthrough.js';
28
27
  import type { Config } from './config.js';
28
+ import { resolveSelfPath } from './paths.js';
29
29
  import { isInteractiveSession, selectFragments, type PromptSet } from './prompts.js';
30
30
 
31
31
  // Re-export the passthrough inspection helpers from their canonical home so
32
32
  // callers can reach them via "./argv.js" — mirrors the Go reference where
33
33
  // they sit next to buildArgv. The single-source-of-truth implementation
34
- // stays in argParser.ts.
34
+ // stays in passthrough.ts.
35
35
  export { settingSourcesInPassthrough, tokenInPassthrough };
36
36
 
37
37
  // ── MCP self-injection ─────────────────────────────────────────────────────
@@ -58,14 +58,7 @@ interface McpConfigEntry {
58
58
  * → repo/bin/fnclaude symlink resolves to the real binary path).
59
59
  */
60
60
  export function buildFnclaudeMCPConfigJSON(noop: boolean): string | null {
61
- const argv1 = process.argv.length > 1 ? process.argv[1] : undefined;
62
- let exe = argv1 !== undefined && argv1 !== '' ? argv1 : process.execPath;
63
-
64
- try {
65
- exe = realpathSync(exe);
66
- } catch {
67
- // Fall back to unresolved path; symlink resolution failure isn't fatal.
68
- }
61
+ const exe = resolveSelfPath();
69
62
 
70
63
  const args = ['mcp'];
71
64
  if (noop) args.push('--noop');
@@ -123,17 +116,23 @@ export function withAppendedSystemPrompts(
123
116
  // ── buildArgv ──────────────────────────────────────────────────────────────
124
117
 
125
118
  /**
126
- * buildArgv constructs the argv slice to exec claude with, given the parsed
127
- * fnclaude args, the user's shell cwd (used to resolve relative extra-dir
128
- * paths), the loaded config, and the set of prompt fragments loaded from
129
- * the install dir.
119
+ * buildArgv constructs the argv slice to exec claude with, given the
120
+ * fnclaude args at their final pipeline stage (`InterceptedArgs` the
121
+ * intercept must have run so `worktreeMatched` is meaningful), the user's
122
+ * shell cwd (used to resolve relative extra-dir paths), the loaded config,
123
+ * and the set of prompt fragments loaded from the install dir.
124
+ *
125
+ * Accepting `InterceptedArgs` makes the ordering invariant a compile-time
126
+ * check: passing a `ParsedArgs` or `ResolvedArgs` is a type error,
127
+ * preventing the auto-tmux gate from reading a stale `worktreeMatched`
128
+ * value the parse stage couldn't know.
130
129
  *
131
130
  * `shellCWD` is the process working directory at fnclaude startup —
132
131
  * normally `process.cwd()`. It's threaded through (rather than reached for
133
132
  * directly) so tests can pin it without `chdir`-ing.
134
133
  */
135
134
  export function buildArgv(
136
- a: Args,
135
+ a: InterceptedArgs,
137
136
  shellCWD: string,
138
137
  cfg: Config,
139
138
  prompts: PromptSet,
@@ -200,5 +199,5 @@ export function buildArgv(
200
199
  }
201
200
 
202
201
  // Re-export so callers that already import nameInPassthrough from argv.ts
203
- // (parity with the Go file layout) don't have to reach into argParser.
202
+ // (parity with the Go file layout) don't have to reach into passthrough.
204
203
  export const nameInPassthrough = _nameInPassthrough;
package/src/config.ts CHANGED
@@ -22,7 +22,14 @@ function home(): string {
22
22
  // ── public types ───────────────────────────────────────────────────────────
23
23
 
24
24
  export type TmuxMode = 'never' | 'worktree';
25
- export type HandoffMode = 'never' | 'ask' | string; // or a non-negative integer-as-string
25
+ /**
26
+ * `'never'`, `'ask'`, or a non-negative integer-as-string (e.g. `'5'`).
27
+ *
28
+ * The template-literal `${number}` variant narrows correctly: a bare
29
+ * `string` would collapse the union, so runtime validation gates env-var
30
+ * and config-file inputs into this type via `normalizeHandoffMode`.
31
+ */
32
+ export type HandoffMode = 'never' | 'ask' | `${number}`;
26
33
 
27
34
  export interface NameConfig {
28
35
  /** Model used for the noop name session. */
@@ -121,34 +128,48 @@ export function parseBoolEnv(v: string): boolean {
121
128
  }
122
129
  }
123
130
 
131
+ /**
132
+ * Result of a normalize-mode call: the validated value plus an optional
133
+ * warning describing any fallback that was applied. Callers thread the
134
+ * warning into their own returned warnings list rather than mutating a
135
+ * module-global sink.
136
+ */
137
+ export interface NormalizeResult<T> {
138
+ value: T;
139
+ warning: string | null;
140
+ }
141
+
124
142
  /**
125
143
  * normalizeTmuxMode validates against the supported set and falls back to
126
- * "never" for anything else, emitting a stderr warning (except for the
127
- * empty-string case, which is the absent-value default path).
144
+ * "never" for anything else, returning the fallback value and an optional
145
+ * warning describing what was rejected (the empty-string case is the
146
+ * absent-value default path and produces no warning).
128
147
  */
129
- export function normalizeTmuxMode(v: string): TmuxMode {
130
- if (v === 'never' || v === 'worktree') return v;
131
- if (v === '') return 'never';
132
- warn(
133
- `fnclaude: auto.tmux=${JSON.stringify(v)} is not a valid mode (use "never" or "worktree"), falling back to "never"`,
134
- );
135
- return 'never';
148
+ export function normalizeTmuxMode(v: string): NormalizeResult<TmuxMode> {
149
+ if (v === 'never' || v === 'worktree') return { value: v, warning: null };
150
+ if (v === '') return { value: 'never', warning: null };
151
+ return {
152
+ value: 'never',
153
+ warning: `fnclaude: auto.tmux=${JSON.stringify(v)} is not a valid mode (use "never" or "worktree"), falling back to "never"`,
154
+ };
136
155
  }
137
156
 
138
157
  /**
139
158
  * normalizeHandoffMode validates against the supported set and falls back
140
- * to "ask" for anything else (with a stderr warning, except empty string).
141
- * Valid: "never", "ask", or a non-negative integer (as a string).
159
+ * to "ask" for anything else (with an optional warning, except empty
160
+ * string). Valid: "never", "ask", or a non-negative integer (as a string).
142
161
  */
143
- export function normalizeHandoffMode(v: string): HandoffMode {
144
- if (v === 'never' || v === 'ask') return v;
145
- if (v === '') return 'ask';
146
- // Non-negative integer (no decimal, no unit).
147
- if (/^\d+$/.test(v)) return v;
148
- warn(
149
- `fnclaude: auto.handoff=${JSON.stringify(v)} is not a valid mode (use "never", "ask", or a non-negative integer), falling back to "ask"`,
150
- );
151
- return 'ask';
162
+ export function normalizeHandoffMode(v: string): NormalizeResult<HandoffMode> {
163
+ if (v === 'never' || v === 'ask') return { value: v, warning: null };
164
+ if (v === '') return { value: 'ask', warning: null };
165
+ // Non-negative integer (no decimal, no unit). The regex guarantees the
166
+ // template-literal shape, which TS's type narrowing can't infer from a
167
+ // .test() call alone — so assert it explicitly once.
168
+ if (/^\d+$/.test(v)) return { value: v as `${number}`, warning: null };
169
+ return {
170
+ value: 'ask',
171
+ warning: `fnclaude: auto.handoff=${JSON.stringify(v)} is not a valid mode (use "never", "ask", or a non-negative integer), falling back to "ask"`,
172
+ };
152
173
  }
153
174
 
154
175
  /**
@@ -188,23 +209,6 @@ export function parseDuration(s: string): number | null {
188
209
  return total;
189
210
  }
190
211
 
191
- // Deferred stderr warnings — fnclaude collects these during config load
192
- // and flushes them via the shared warnings sink at a sensible time (after
193
- // claude exits, in run()). The local `deferredWarnings` export is kept
194
- // for backward compatibility with callers that imported it; it shadows
195
- // the shared sink's view of config-emitted warnings only.
196
- export const deferredWarnings: string[] = [];
197
-
198
- // Defer-import the shared warnings module so config remains import-cycle-
199
- // safe and any test that loads config in isolation still works without
200
- // the warnings module having been initialized.
201
- import { warn as globalWarn } from './warnings.js';
202
-
203
- function warn(msg: string): void {
204
- deferredWarnings.push(msg);
205
- globalWarn(msg);
206
- }
207
-
208
212
  // ── raw TOML shape (mirrors the Go rawConfig) ──────────────────────────────
209
213
 
210
214
  interface RawConfig {
@@ -228,26 +232,46 @@ interface RawConfig {
228
232
 
229
233
  // ── loadConfig ─────────────────────────────────────────────────────────────
230
234
 
235
+ /**
236
+ * Result of `loadConfig` — the merged Config plus any non-fatal warnings
237
+ * raised during the load (malformed file, invalid mode value, bogus
238
+ * duration, etc.). The caller threads warnings into the deferred-flush
239
+ * mechanism in `main.ts`; this module owns no global mutable state.
240
+ */
241
+ export interface LoadConfigResult {
242
+ config: Config;
243
+ warnings: readonly string[];
244
+ }
245
+
231
246
  /**
232
247
  * loadConfig loads the configuration from the config file and environment
233
248
  * variables, merging over built-in defaults. Order of precedence:
234
249
  *
235
250
  * env var > config file > built-in default
236
251
  *
237
- * A missing config file is not an error. A malformed config file queues a
238
- * warning and falls back to defaults.
252
+ * A missing config file is not an error. A malformed config file produces
253
+ * a warning and falls back to defaults.
239
254
  */
240
- export function loadConfig(): Config {
255
+ export function loadConfig(): LoadConfigResult {
241
256
  const cfg = defaultConfig();
257
+ const warnings: string[] = [];
242
258
  const path = configFilePath();
243
259
 
260
+ const recordNormalize = <T>(
261
+ r: NormalizeResult<T>,
262
+ set: (v: T) => void,
263
+ ): void => {
264
+ set(r.value);
265
+ if (r.warning !== null) warnings.push(r.warning);
266
+ };
267
+
244
268
  if (existsSync(path)) {
245
269
  let raw: RawConfig | null = null;
246
270
  try {
247
271
  const body = readFileSync(path, 'utf8');
248
272
  raw = Bun.TOML.parse(body) as RawConfig;
249
273
  } catch (err) {
250
- warn(
274
+ warnings.push(
251
275
  `fnclaude: config file ${path} is malformed, using defaults: ${(err as Error).message}`,
252
276
  );
253
277
  raw = null;
@@ -259,7 +283,7 @@ export function loadConfig(): Config {
259
283
  if (d !== null) {
260
284
  cfg.name.timeout = d;
261
285
  } else {
262
- warn(
286
+ warnings.push(
263
287
  `fnclaude: invalid timeout ${JSON.stringify(raw.name.timeout)} in config, using default`,
264
288
  );
265
289
  }
@@ -268,10 +292,14 @@ export function loadConfig(): Config {
268
292
  cfg.name.quietMissingAPIKey = raw.name.quiet_missing_api_key;
269
293
  }
270
294
  if (typeof raw.auto?.tmux === 'string' && raw.auto.tmux !== '') {
271
- cfg.auto.tmux = raw.auto.tmux as TmuxMode;
295
+ recordNormalize(normalizeTmuxMode(raw.auto.tmux), (v) => {
296
+ cfg.auto.tmux = v;
297
+ });
272
298
  }
273
299
  if (typeof raw.auto?.handoff === 'string' && raw.auto.handoff !== '') {
274
- cfg.auto.handoff = raw.auto.handoff;
300
+ recordNormalize(normalizeHandoffMode(raw.auto.handoff), (v) => {
301
+ cfg.auto.handoff = v;
302
+ });
275
303
  }
276
304
  if (
277
305
  typeof raw.auto?.spawn_command === 'string' &&
@@ -293,7 +321,7 @@ export function loadConfig(): Config {
293
321
  if (d !== null) {
294
322
  cfg.name.timeout = d;
295
323
  } else {
296
- warn(
324
+ warnings.push(
297
325
  `fnclaude: invalid FNCLAUDE_NAME_TIMEOUT ${JSON.stringify(e.FNCLAUDE_NAME_TIMEOUT)}, using current value`,
298
326
  );
299
327
  }
@@ -301,14 +329,19 @@ export function loadConfig(): Config {
301
329
  if (e.FNCLAUDE_QUIET_MISSING_API_KEY) {
302
330
  cfg.name.quietMissingAPIKey = parseBoolEnv(e.FNCLAUDE_QUIET_MISSING_API_KEY);
303
331
  }
304
- if (e.FNCLAUDE_TMUX) cfg.auto.tmux = e.FNCLAUDE_TMUX as TmuxMode;
305
- if (e.FNCLAUDE_HANDOFF) cfg.auto.handoff = e.FNCLAUDE_HANDOFF;
332
+ if (e.FNCLAUDE_TMUX) {
333
+ recordNormalize(normalizeTmuxMode(e.FNCLAUDE_TMUX), (v) => {
334
+ cfg.auto.tmux = v;
335
+ });
336
+ }
337
+ if (e.FNCLAUDE_HANDOFF) {
338
+ recordNormalize(normalizeHandoffMode(e.FNCLAUDE_HANDOFF), (v) => {
339
+ cfg.auto.handoff = v;
340
+ });
341
+ }
306
342
  if (e.FNCLAUDE_SPAWN_COMMAND) cfg.auto.spawnCommand = e.FNCLAUDE_SPAWN_COMMAND;
307
343
 
308
- cfg.auto.tmux = normalizeTmuxMode(cfg.auto.tmux);
309
- cfg.auto.handoff = normalizeHandoffMode(cfg.auto.handoff);
310
-
311
- return cfg;
344
+ return { config: cfg, warnings };
312
345
  }
313
346
 
314
347
  // ── envFromConfig ─────────────────────────────────────────────────────────
package/src/help.ts CHANGED
@@ -135,5 +135,5 @@ Examples:
135
135
  fnclaude ~/src/proj -- "fix the bug" # auto-name from prompt
136
136
  fnclaude -A docs/ ~/src/proj -V # ergonomic flag form
137
137
 
138
- For more, see https://github.com/fnrhombus/fnclaude
138
+ For more, see https://github.com/fnclaude/fnclaude
139
139
  `;
@@ -27,11 +27,22 @@ export function hostAliasesUserPath(home: string): string {
27
27
  return join(home, '.local', 'share', 'fnrhombus', 'host-aliases.json');
28
28
  }
29
29
 
30
+ /**
31
+ * Result of a host-aliases load: the merged alias map plus any non-fatal
32
+ * warnings (e.g. malformed files that were skipped). Mirrors
33
+ * `LoadConfigResult` so the caller can thread warnings into the deferred
34
+ * flush.
35
+ */
36
+ export interface LoadHostAliasesResult {
37
+ aliases: Record<string, string>;
38
+ warnings: readonly string[];
39
+ }
40
+
30
41
  /**
31
42
  * Read both files (if present) and merge them, user-level winning per
32
43
  * key. Either or both missing returns whatever is available (or empty).
33
44
  */
34
- export function loadHostAliases(home: string): Record<string, string> {
45
+ export function loadHostAliases(home: string): LoadHostAliasesResult {
35
46
  return mergeHostAliases([HOST_ALIASES_SYSTEM_PATH, hostAliasesUserPath(home)]);
36
47
  }
37
48
 
@@ -40,43 +51,57 @@ export function loadHostAliases(home: string): Record<string, string> {
40
51
  * winning over earlier ones. Callers order system-first, user-second so
41
52
  * user wins on conflict.
42
53
  */
43
- export function mergeHostAliases(paths: string[]): Record<string, string> {
54
+ export function mergeHostAliases(paths: string[]): LoadHostAliasesResult {
44
55
  const merged: Record<string, string> = {};
56
+ const warnings: string[] = [];
45
57
  for (const p of paths) {
46
- const entries = readHostAliasesFile(p);
47
- for (const k of Object.keys(entries)) {
48
- merged[k] = entries[k] as string;
58
+ const { aliases, warning } = readHostAliasesFile(p);
59
+ if (warning !== null) warnings.push(warning);
60
+ for (const k of Object.keys(aliases)) {
61
+ merged[k] = aliases[k] as string;
49
62
  }
50
63
  }
51
- return merged;
64
+ return { aliases: merged, warnings };
65
+ }
66
+
67
+ export interface ReadHostAliasesFileResult {
68
+ aliases: Record<string, string>;
69
+ warning: string | null;
52
70
  }
53
71
 
54
72
  /**
55
- * Parse one alias file. Missing file, malformed JSON, non-object root,
56
- * and non-string values all degrade to "no aliases from this file"
57
- * silently same fail-soft posture as the JS plugin.
73
+ * Parse one alias file. Missing file is the common path and stays
74
+ * silent. Malformed JSON or non-object roots produce a warning so the
75
+ * user can fix the file rather than wondering why their aliases don't
76
+ * apply. Non-string values are silently dropped (mirrors the JS plugin).
58
77
  */
59
- export function readHostAliasesFile(path: string): Record<string, string> {
78
+ export function readHostAliasesFile(path: string): ReadHostAliasesFileResult {
60
79
  let data: string;
61
80
  try {
62
81
  data = readFileSync(path, 'utf8');
63
82
  } catch {
64
- return {};
83
+ return { aliases: {}, warning: null };
65
84
  }
66
85
  let raw: unknown;
67
86
  try {
68
87
  raw = JSON.parse(data);
69
- } catch {
70
- return {};
88
+ } catch (err) {
89
+ return {
90
+ aliases: {},
91
+ warning: `fnclaude: host-aliases file ${path} is malformed, skipping: ${(err as Error).message}`,
92
+ };
71
93
  }
72
94
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
73
- return {};
95
+ return {
96
+ aliases: {},
97
+ warning: `fnclaude: host-aliases file ${path} has a non-object root, skipping`,
98
+ };
74
99
  }
75
100
  const out: Record<string, string> = {};
76
101
  for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
77
102
  if (typeof v === 'string') out[k] = v;
78
103
  }
79
- return out;
104
+ return { aliases: out, warning: null };
80
105
  }
81
106
 
82
107
  /**
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export {
9
9
  parseRepoRef,
10
10
  type RepoRef,
11
11
  } from './repoRef.js';
12
- export { expandTildePath } from './paths.js';
12
+ export { expandTildePath, resolveSelfPath } from './paths.js';
13
13
  export {
14
14
  findPromptsDir,
15
15
  isInteractiveSession,
@@ -55,9 +55,14 @@ export {
55
55
  readRequest,
56
56
  readResponse,
57
57
  type Action,
58
+ type CopyRequest,
58
59
  type Op,
59
60
  type Request,
61
+ type RequestOverrides,
60
62
  type Response,
63
+ type RestartRequest,
64
+ type SpawnRequest,
65
+ type SwitchRequest,
61
66
  } from './mcp/protocol.js';
62
67
  export {
63
68
  SocketListener,
@@ -83,9 +88,10 @@ export {
83
88
  // embedding hosts that want to render their own help.
84
89
  export { helpText, setVersion, version, wantsHelp, wantsVersion } from './help.js';
85
90
 
86
- // Warning sink + flush. Test helpers (pendingWarnings, clearWarnings) are
87
- // re-exported so harness code can introspect/reset between cases.
88
- export { clearWarnings, flushWarnings, pendingWarnings, warn } from './warnings.js';
91
+ // Warning flush. Loaders (loadConfig / loadRepoSettings / loadHostAliases
92
+ // / loadPrompts) return their warnings; flushWarnings drains a provided
93
+ // list to stderr.
94
+ export { flushWarnings } from './warnings.js';
89
95
 
90
96
  // noop dir seeding (noop fallback when fnclaude is invoked with no path).
91
97
  export { NOOP_HANDOFF_TEMPLATE, defaultNoopDir, seedNoop } from './noop.js';
@@ -94,7 +100,7 @@ export { NOOP_HANDOFF_TEMPLATE, defaultNoopDir, seedNoop } from './noop.js';
94
100
  export { silentRelaunch, silentRelaunchHandoff, spawnAndExit } from './silentRelaunch.js';
95
101
 
96
102
  // Top-level run loop + entry point.
97
- export { main, run, type RunDeps } from './main.js';
103
+ export { main, run, type RunConfig, type RunDeps, type RunIO } from './main.js';
98
104
 
99
105
  // PTY runner + shared helpers (ring buffer, cross-cwd detection,
100
106
  // reconstructArgv, ensureCWD).