@cruxy/cli 1.3.0 → 1.4.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.
@@ -0,0 +1,56 @@
1
+ import { readContext } from "./context.js";
2
+ import { modeDescription } from "./mode.js";
3
+ /** Count real user turns: `role: "user"` with STRING content — tool results
4
+ * are also role "user" but carry blocks, so this counts what the human said. */
5
+ function userTurns(session) {
6
+ return session.messages.filter((m) => m.role === "user" && typeof m.content === "string").length;
7
+ }
8
+ /** Everything `/status` and the Overview view show, from live session state. */
9
+ export function buildSessionStatus(session, git,
10
+ /**
11
+ * The tier the gateway last said actually SERVED a request, when the caller
12
+ * is somewhere that knows it. Only the TUI renderer tracks this — it arrives
13
+ * on the stream's routing frame — so `/status`, which has no renderer, omits
14
+ * it and shows the configured value alone.
15
+ */
16
+ servedTier) {
17
+ const toolCtx = session.toolContext;
18
+ const config = toolCtx.config;
19
+ const mode = session.getMode();
20
+ const roots = toolCtx.workspace.roots().map((r) => {
21
+ const info = git(r.absPath);
22
+ return {
23
+ name: r.name,
24
+ path: r.absPath,
25
+ primary: r.primary,
26
+ // Spread rather than assigned, so "not probed" stays ABSENT rather than
27
+ // becoming an explicit `undefined` the renderer would have to re-check.
28
+ ...(info === undefined ? {} : { git: info }),
29
+ };
30
+ });
31
+ const jobs = session.jobs?.list();
32
+ return {
33
+ sessionId: session.sessionId,
34
+ turns: userTurns(session),
35
+ mode,
36
+ modeDescription: modeDescription(mode),
37
+ ...(session.model ? { model: session.model.current() } : {}),
38
+ ...(servedTier === undefined ? {} : { servedTier }),
39
+ provider: config.model.provider,
40
+ roots,
41
+ context: readContext(session.messages, config.context),
42
+ sandboxEnabled: Boolean(toolCtx.sandbox),
43
+ ...(toolCtx.sandbox ? { sandboxRuntime: toolCtx.sandbox.runtimeName } : {}),
44
+ checkpoints: Boolean(toolCtx.checkpointsActive),
45
+ ...(jobs
46
+ ? {
47
+ jobs: {
48
+ total: jobs.length,
49
+ running: jobs.filter((j) => j.status === "running").length,
50
+ needingApproval: jobs.filter((j) => j.pendingApproval).length,
51
+ },
52
+ }
53
+ : {}),
54
+ tools: session.toolRegistry.list().length,
55
+ };
56
+ }
@@ -14,7 +14,7 @@ import { SandboxService } from "../../sandbox/index.js";
14
14
  import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
15
15
  import { DEFAULT_MODE, } from "../../agent/index.js";
16
16
  import { runInteractive } from "../repl.js";
17
- import { ContextGauge, createKeyLease, runTui, TuiRenderer, } from "../../tui/index.js";
17
+ import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceGitCache, } from "../../tui/index.js";
18
18
  import { buildAgentSession } from "../session-factory.js";
19
19
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
20
20
  import { resetLspServices } from "../../lsp/index.js";
@@ -71,7 +71,10 @@ export async function executeRun(promptParts, opts) {
71
71
  ]
72
72
  : [`provide a task, e.g. ${invokedAs} "fix the failing test"`]);
73
73
  }
74
- const { config, sources } = loadConfig();
74
+ // Kept whole (not destructured away) because the Settings view needs the raw
75
+ // layers this result was merged from to say where each value came from.
76
+ const loaded = loadConfig();
77
+ const { config, sources } = loaded;
75
78
  let apiKey = resolveApiKey(config.model.provider);
76
79
  // Declared workspace roots (C.26). This is the ONE place `run` reads the
77
80
  // process working directory — the invocation directory is the base for
@@ -337,6 +340,30 @@ export async function executeRun(promptParts, opts) {
337
340
  // provider, where the constructor's static value stands and `/model` declines.
338
341
  if (session.model)
339
342
  tui?.attachModel(session.model);
343
+ // The main-pane views (P7). Registered here — the one place that has both the
344
+ // renderer and the session — and after the session exists, because a view
345
+ // reads its live state. Each root gets its own cached git probe: writes fan
346
+ // across every declared root when checkpoints are on, so a primary-only
347
+ // answer would under-report what a turn actually touched.
348
+ if (tui) {
349
+ // ONE cache, both views. They ask the same question of the same trees, and
350
+ // a second instance would double the subprocesses to hold two copies of an
351
+ // answer that must agree anyway.
352
+ const workspaceGit = new WorkspaceGitCache(session.toolContext.workspace.roots().map((r) => r.absPath));
353
+ tui.attachViews([
354
+ createOverviewView(session, workspaceGit, () => tui.servedTier()),
355
+ createGitView(() => session.toolContext.workspace.roots(), workspaceGit),
356
+ // Registered even when background jobs are disabled — the view says so,
357
+ // and a nav whose rows appear and vanish with config is worse than one
358
+ // row that explains itself. `session.jobs` is undefined in that case.
359
+ createTasksView(session.jobs),
360
+ // The SAME loaded config the session was built from, not a fresh read —
361
+ // the view's whole claim is "what this session is running on", and a
362
+ // second load could already disagree with it. `apiKey` is a getter
363
+ // because onboarding may have supplied one after this line was written.
364
+ createSettingsView(loaded, () => apiKey !== undefined),
365
+ ]);
366
+ }
340
367
  if (multiTurn) {
341
368
  try {
342
369
  // Same session, same renderer seam, two shells: the TUI when the
@@ -1,7 +1,8 @@
1
1
  import { MODE_LABELS, SESSION_MODES, modeDescription, modeFromFlags, parseMode, } from "../agent/index.js";
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { contextReport, readContext } from "../agent/context.js";
4
+ import { contextReport } from "../agent/context.js";
5
+ import { buildSessionStatus } from "../agent/status.js";
5
6
  import { scaffoldProjectInstructions } from "../config/index.js";
6
7
  import { resolveSlash } from "../hooks/index.js";
7
8
  import { contextReportLines } from "../render/context-view.js";
@@ -365,57 +366,18 @@ function handleInit(ctx) {
365
366
  */
366
367
  function handleStatus(ctx) {
367
368
  const { out, session } = ctx;
368
- const toolCtx = session.toolContext;
369
- const config = toolCtx.config;
370
- const mode = session.getMode();
371
- // A real user turn is `role: "user"` with STRING content tool results are
372
- // also role "user" but carry blocks, so this counts what the human said.
373
- const turns = session.messages.filter((m) => m.role === "user" && typeof m.content === "string").length;
374
- const roots = toolCtx.workspace.roots().map((r) => {
375
- // A synchronous probe, on an explicitly-requested command. The rail's rule
376
- // (never probe on the paint path) is about frames, not about a user who
377
- // just asked; there is no frame budget to blow here.
378
- const git = getGitInfo(r.absPath);
379
- return {
380
- name: r.name,
381
- path: r.absPath,
382
- primary: r.primary,
383
- ...(git === null ? {} : { branch: git.branch, changed: git.changed }),
384
- };
385
- });
386
- const jobs = session.jobs?.list();
387
- for (const line of sessionStatusLines({
388
- sessionId: session.sessionId,
389
- turns,
390
- mode,
391
- modeDescription: modeDescription(mode),
392
- ...(session.model ? { model: session.model.current() } : {}),
393
- provider: config.model.provider,
394
- roots,
395
- context: readContext(session.messages, config.context),
396
- sandboxEnabled: Boolean(toolCtx.sandbox),
397
- ...(toolCtx.sandbox
398
- ? { sandboxRuntime: toolCtx.sandbox.runtimeName }
399
- : {}),
400
- checkpoints: Boolean(toolCtx.checkpointsActive),
401
- ...(jobs
402
- ? {
403
- jobs: {
404
- total: jobs.length,
405
- running: jobs.filter((j) => j.status === "running").length,
406
- needingApproval: jobs.filter((j) => j.pendingApproval).length,
407
- },
408
- }
409
- : {}),
410
- tools: toolCount(ctx),
411
- }, out.theme)) {
369
+ // A SYNCHRONOUS probe, and the one caller allowed one. The paint path's rule
370
+ // (never spawn during compose) is about frames; a user who just typed
371
+ // `/status` has no frame budget to blow, and an answer that says "checking…"
372
+ // for a fact this could have fetched would be worse than the 45ms.
373
+ //
374
+ // Everything else comes from the shared builder, so this and the Overview
375
+ // view cannot drift: they differ in exactly this argument and nowhere else.
376
+ const status = buildSessionStatus(session, (absPath) => getGitInfo(absPath));
377
+ for (const line of sessionStatusLines(status, out.theme)) {
412
378
  out.print(out.fit(line));
413
379
  }
414
380
  }
415
- /** Tools advertised to the model this session — the registry the loop dispatches against. */
416
- function toolCount(ctx) {
417
- return ctx.session.toolRegistry.list().length;
418
- }
419
381
  /**
420
382
  * `/diff` (P6 track 4) — what has changed on disk, without leaving the session.
421
383
  *
@@ -3,6 +3,7 @@
3
3
  * transformation — no terminal, no state — so every mapping row is directly
4
4
  * unit-testable. The stateful raw-mode plumbing lives in `input.ts`.
5
5
  */
6
+ const CTRL_B = 0x02;
6
7
  const CTRL_C = 0x03;
7
8
  const CTRL_D = 0x04;
8
9
  const CTRL_K = 0x0b;
@@ -18,6 +19,18 @@ const DELETE = 0x7f;
18
19
  * unlike the arrows it is safe to accept with OR without parameters.
19
20
  */
20
21
  const CBT = 0x5a;
22
+ /**
23
+ * CSI final byte `~` — the "tilde sequence" family, where the NUMERIC PARAMETER
24
+ * carries the identity (`5` = Page Up, `6` = Page Down) rather than the final
25
+ * byte. That inverts the arrow rule below: for `~` the parameter must be read,
26
+ * not rejected, so these are matched on the leading number instead.
27
+ */
28
+ const TILDE = 0x7e;
29
+ /** Leading `~`-sequence parameter → key. `ESC [ 5 ~` / `ESC [ 6 ~`. */
30
+ const TILDES = {
31
+ 5: "page-up",
32
+ 6: "page-down",
33
+ };
21
34
  /** CSI final byte → arrow key, for `ESC [ <final>` sequences. */
22
35
  const ARROWS = {
23
36
  0x41: "up", // A
@@ -61,6 +74,26 @@ export function decodeKeys(chunk) {
61
74
  i = j + 1;
62
75
  continue;
63
76
  }
77
+ if (buf[j] === TILDE) {
78
+ // `ESC [ <n> ~`. The number is the key, so it is read rather than
79
+ // rejected — the opposite of the arrow rule below, and for the same
80
+ // underlying reason: match on whatever byte actually carries the
81
+ // identity. A modifier (`ESC [ 5 ; 2 ~`) still means Page Up, so
82
+ // only the parameter BEFORE the first `;` is parsed; an unmapped
83
+ // number (`3` = Delete) falls through and is swallowed like any
84
+ // other unbound sequence.
85
+ let n = 0;
86
+ let digits = 0;
87
+ for (let k = i + 2; k < j && buf[k] >= 0x30 && buf[k] <= 0x39; k++) {
88
+ n = n * 10 + (buf[k] - 0x30);
89
+ digits++;
90
+ }
91
+ const tilde = digits > 0 ? TILDES[n] : undefined;
92
+ if (tilde)
93
+ keys.push({ kind: tilde });
94
+ i = j + 1;
95
+ continue;
96
+ }
64
97
  // Only a bare `ESC [ <final>` maps to an arrow; parameterized
65
98
  // sequences (modifier arrows, Home/End variants) are swallowed —
66
99
  // an unmapped combo must do nothing, not act as a plain arrow.
@@ -79,6 +112,11 @@ export function decodeKeys(chunk) {
79
112
  i++;
80
113
  continue;
81
114
  }
115
+ if (byte === CTRL_B) {
116
+ keys.push({ kind: "ctrl-b" });
117
+ i++;
118
+ continue;
119
+ }
82
120
  if (byte === CTRL_C) {
83
121
  keys.push({ kind: "ctrl-c" });
84
122
  i++;
@@ -0,0 +1,225 @@
1
+ import { ENV_OVERRIDES, } from "./manager.js";
2
+ /** What a redacted scalar renders as. A marker, never a length-preserving mask
3
+ * — a mask that matched the length would leak the length. */
4
+ export const REDACTED = "<redacted>";
5
+ /** Layers in precedence order (last wins), paired with the origin they name. */
6
+ function layerOrder(layers) {
7
+ return [
8
+ ["global", layers.global],
9
+ ["project", layers.project],
10
+ ["explicit", layers.explicit],
11
+ ["env", layers.env],
12
+ ];
13
+ }
14
+ function isPlainObject(v) {
15
+ return typeof v === "object" && v !== null && !Array.isArray(v);
16
+ }
17
+ /**
18
+ * Whether `layer` explicitly sets `path`.
19
+ *
20
+ * Presence, not definedness: `in` rather than `!== undefined`, because a key
21
+ * written as `null` was still written, and reporting it as "default" would
22
+ * point someone at the schema for a value their file is responsible for.
23
+ */
24
+ function hasPath(layer, path) {
25
+ if (layer === null)
26
+ return false;
27
+ let cursor = layer;
28
+ for (const key of path.split(".")) {
29
+ if (!isPlainObject(cursor) || !(key in cursor))
30
+ return false;
31
+ cursor = cursor[key];
32
+ }
33
+ return true;
34
+ }
35
+ /** The highest-precedence layer that set `path`, or `default` if none did. */
36
+ function originOf(layers, path) {
37
+ let found = "default";
38
+ for (const [origin, layer] of layerOrder(layers)) {
39
+ if (hasPath(layer, path))
40
+ found = origin;
41
+ }
42
+ return found;
43
+ }
44
+ /**
45
+ * Paths whose value NAMES a secret rather than being one — checked before the
46
+ * name heuristic below, which would otherwise redact both of them.
47
+ *
48
+ * This distinction is the whole point of these two fields: `credentialRef` and
49
+ * `apiKeyEnv` exist so that a config file can say WHERE the secret comes from
50
+ * without containing it, and redacting them would hide the answer to "which
51
+ * credential is this server using" while protecting nothing.
52
+ */
53
+ const SECRET_REFERENCE_PATHS = [
54
+ /^mcp\.servers\.[^.]+\.credentialRef$/,
55
+ /^web\.apiKeyEnv$/,
56
+ ];
57
+ /**
58
+ * Paths that carry a live secret, structurally.
59
+ *
60
+ * `mcp.servers.<id>.headers.<name>` is the obvious one — the schema calls a live
61
+ * header value a secret in so many words, and only accepts it from user-scope
62
+ * config for that reason. `env` is the same class by a different route: it is
63
+ * how a stdio server is handed its token (`GITHUB_TOKEN`, `…_API_KEY`), and the
64
+ * fact that it is spelled as an environment variable rather than a header
65
+ * changes nothing about what is in it.
66
+ */
67
+ const SECRET_VALUE_PATHS = [
68
+ /^mcp\.servers\.[^.]+\.headers\.[^.]+$/,
69
+ /^mcp\.servers\.[^.]+\.env\.[^.]+$/,
70
+ ];
71
+ /**
72
+ * Backstop for keys that do not exist yet.
73
+ *
74
+ * The structural rules above enumerate today's secret-carrying paths, and an
75
+ * enumeration is exactly the kind of thing a later schema addition forgets to
76
+ * update — at which point a new key holding a credential renders in full on a
77
+ * pane someone is screen-sharing. So the leaf NAME is also checked, and the
78
+ * failure mode is inverted: a new secret is redacted by default, and a false
79
+ * positive costs one line of a read-only pane (the value is still readable
80
+ * with `cruxy config get`).
81
+ *
82
+ * `token` is NOT in this list, and that omission is the whole reason the rule
83
+ * needs stating carefully. In this schema "token" almost always means an LLM
84
+ * token: `context.maxTokens`, `memory.maxRecallTokens`,
85
+ * `agent.maxTokensPerTurn`, `index.search.tokenBudget`. A heuristic that
86
+ * matched it would blank out a dozen of the most-consulted numbers in the
87
+ * product to protect nothing — a redaction that hides only ordinary values
88
+ * teaches the reader that `<redacted>` means "noise", which is exactly how a
89
+ * real one gets skimmed past. {@link isSecretPath} catches the credential-ish
90
+ * senses of the word through the value type instead: a token COUNT is a
91
+ * number, a bearer token is a string.
92
+ */
93
+ const SECRET_NAME = /(secret|password|passwd|apikey|api_key|credential)/i;
94
+ /** Names that are secret-shaped only when they carry a string. */
95
+ const SECRET_NAME_IF_STRING = /(token|bearer)/i;
96
+ /** Whether this leaf's value must never be printed. */
97
+ function isSecretPath(path, value) {
98
+ if (SECRET_REFERENCE_PATHS.some((re) => re.test(path)))
99
+ return false;
100
+ if (SECRET_VALUE_PATHS.some((re) => re.test(path)))
101
+ return true;
102
+ const leaf = path.split(".").pop() ?? "";
103
+ if (SECRET_NAME.test(leaf))
104
+ return true;
105
+ return typeof value === "string" && SECRET_NAME_IF_STRING.test(leaf);
106
+ }
107
+ /**
108
+ * A URL with its credential-carrying parts removed: userinfo (`user:pass@`) and
109
+ * the query string, which is where a remote MCP endpoint's token most often
110
+ * rides (`?token=…`). The origin and path survive, because WHICH server this is
111
+ * remains the useful part and it is not the sensitive one.
112
+ *
113
+ * Returns null when nothing had to be removed, so an ordinary URL is not
114
+ * flagged as redacted.
115
+ */
116
+ function redactUrl(raw) {
117
+ let url;
118
+ try {
119
+ url = new URL(raw);
120
+ }
121
+ catch {
122
+ // Not parseable as a URL — the schema rejects those, but this module is
123
+ // also handed layers that never reached the schema. Leave it alone rather
124
+ // than guess at its structure.
125
+ return null;
126
+ }
127
+ const hadUserinfo = url.username !== "" || url.password !== "";
128
+ const hadQuery = url.search !== "";
129
+ if (!hadUserinfo && !hadQuery)
130
+ return null;
131
+ url.username = "";
132
+ url.password = "";
133
+ if (hadQuery)
134
+ url.search = "";
135
+ let out = url.toString();
136
+ // Both removals are MARKED rather than silent: a URL that quietly lost its
137
+ // query reads as the whole address, and someone debugging why a server 401s
138
+ // would be looking at a string their config does not contain.
139
+ if (hadQuery)
140
+ out += `?${REDACTED}`;
141
+ if (hadUserinfo)
142
+ out = out.replace("://", `://${REDACTED}@`);
143
+ return out;
144
+ }
145
+ /** One leaf's display value, with any secret removed. */
146
+ function redact(path, value) {
147
+ if (isSecretPath(path, value))
148
+ return { value: REDACTED, redacted: true };
149
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) {
150
+ const stripped = redactUrl(value);
151
+ if (stripped !== null)
152
+ return { value: stripped, redacted: true };
153
+ }
154
+ return { value, redacted: false };
155
+ }
156
+ /**
157
+ * Depth-first walk to the leaves.
158
+ *
159
+ * A leaf is anything that is not a plain object — INCLUDING an array (the merge
160
+ * replaces arrays wholesale, so an array is one decision, not many) and an
161
+ * empty object, which is a real answer: `routing.map: {}` is what "routing is
162
+ * configured but inert" looks like, and a walk that recursed into it would emit
163
+ * nothing and leave the key looking unset.
164
+ *
165
+ * The walk is over the RESOLVED config, so what it yields is exactly the keys
166
+ * that have an effective value. A schema key that is optional with no default
167
+ * (`shell.executable`, `git.defaultBase`, `test.command`) therefore has no row
168
+ * while it is unset — there is no value to attribute and no layer to blame.
169
+ * The moment any layer sets one it appears, correctly attributed, because JSON
170
+ * cannot express `undefined`: a key present in a file is present in the merge.
171
+ * Enumerating the schema's unset knobs is a job for documentation and
172
+ * `cruxy config set`, not for a pane whose subject is what is in effect.
173
+ */
174
+ function walk(value, prefix, out) {
175
+ if (isPlainObject(value) && Object.keys(value).length > 0) {
176
+ for (const [key, child] of Object.entries(value)) {
177
+ walk(child, prefix === "" ? key : `${prefix}.${key}`, out);
178
+ }
179
+ return;
180
+ }
181
+ out.push({ path: prefix, value });
182
+ }
183
+ /**
184
+ * Every effective setting, in schema order, with its origin and nothing secret.
185
+ *
186
+ * Schema order rather than alphabetical or overrides-first: this is read by
187
+ * scanning, and a list that reorders itself as values change moves the row
188
+ * being read out from under the reader — the same reason the Tasks view keeps
189
+ * dispatch order.
190
+ */
191
+ export function effectiveSettings(loaded) {
192
+ const leaves = [];
193
+ walk(loaded.config, "", leaves);
194
+ return leaves.map(({ path, value }) => {
195
+ const { value: shown, redacted } = redact(path, value);
196
+ return {
197
+ path,
198
+ value: shown,
199
+ redacted,
200
+ origin: originOf(loaded.layers, path),
201
+ };
202
+ });
203
+ }
204
+ /**
205
+ * Which environment variables are contributing to this result, by name. Read
206
+ * off the layer this load actually built, through the one {@link ENV_OVERRIDES}
207
+ * table — never `process.env`, so what is named is what was USED (a variable
208
+ * exported after startup is not in effect, and must not be listed as if it is).
209
+ */
210
+ export function envOverrideNames(loaded) {
211
+ return ENV_OVERRIDES.filter(({ path }) => hasPath(loaded.layers.env, path)).map(({ envVar }) => envVar);
212
+ }
213
+ /** The file a given origin was read from, when it has one. */
214
+ export function originFile(loaded, origin) {
215
+ switch (origin) {
216
+ case "global":
217
+ return loaded.sources.global;
218
+ case "project":
219
+ return loaded.sources.project;
220
+ case "explicit":
221
+ return loaded.sources.explicit;
222
+ default:
223
+ return null;
224
+ }
225
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./schema.js";
2
2
  export * from "./paths.js";
3
3
  export * from "./manager.js";
4
+ export * from "./effective.js";
4
5
  export * from "./project.js";
5
6
  export * from "./credentials.js";
@@ -59,18 +59,35 @@ function rejectProjectScopeHeaders(obj, file) {
59
59
  }
60
60
  }
61
61
  }
62
+ /**
63
+ * Which environment variable overrides which config path — a TABLE rather than
64
+ * a run of `if`s, because two surfaces need it read in opposite directions:
65
+ * this file turns variables into a layer, and the settings view turns a layer
66
+ * back into "which variable is doing this". A second hand-written copy of the
67
+ * mapping would drift on the first variable added, and the copy that drifts is
68
+ * the one telling the user where the value came from.
69
+ */
70
+ export const ENV_OVERRIDES = [
71
+ { envVar: "CRUXY_MODEL", path: "model.model" },
72
+ { envVar: "CRUXY_PROVIDER", path: "model.provider" },
73
+ { envVar: "CRUXY_LOG_LEVEL", path: "logLevel" },
74
+ ];
62
75
  /** Overrides sourced from environment variables. */
63
76
  function envOverrides() {
64
77
  const out = {};
65
- const model = {};
66
- if (process.env.CRUXY_MODEL)
67
- model.model = process.env.CRUXY_MODEL;
68
- if (process.env.CRUXY_PROVIDER)
69
- model.provider = process.env.CRUXY_PROVIDER;
70
- if (Object.keys(model).length)
71
- out.model = model;
72
- if (process.env.CRUXY_LOG_LEVEL)
73
- out.logLevel = process.env.CRUXY_LOG_LEVEL;
78
+ for (const { envVar, path } of ENV_OVERRIDES) {
79
+ const value = process.env[envVar];
80
+ if (!value)
81
+ continue;
82
+ const keys = path.split(".");
83
+ let cursor = out;
84
+ for (const key of keys.slice(0, -1)) {
85
+ if (!isPlainObject(cursor[key]))
86
+ cursor[key] = {};
87
+ cursor = cursor[key];
88
+ }
89
+ cursor[keys[keys.length - 1]] = value;
90
+ }
74
91
  return out;
75
92
  }
76
93
  /**
@@ -83,15 +100,23 @@ export function loadConfig(opts = {}) {
83
100
  project: null,
84
101
  explicit: null,
85
102
  };
103
+ const layers = {
104
+ global: null,
105
+ project: null,
106
+ explicit: null,
107
+ env: {},
108
+ };
86
109
  let merged = {};
87
110
  const gPath = globalConfigPath();
88
111
  if (existsSync(gPath)) {
89
- merged = deepMerge(merged, readJsonFile(gPath));
112
+ layers.global = readJsonFile(gPath);
113
+ merged = deepMerge(merged, layers.global);
90
114
  sources.global = gPath;
91
115
  }
92
116
  if (opts.configPath) {
93
117
  const obj = readJsonFile(opts.configPath);
94
118
  rejectProjectScopeHeaders(obj, opts.configPath);
119
+ layers.explicit = obj;
95
120
  merged = deepMerge(merged, obj);
96
121
  sources.explicit = opts.configPath;
97
122
  }
@@ -100,11 +125,13 @@ export function loadConfig(opts = {}) {
100
125
  if (pPath) {
101
126
  const obj = readJsonFile(pPath);
102
127
  rejectProjectScopeHeaders(obj, pPath);
128
+ layers.project = obj;
103
129
  merged = deepMerge(merged, obj);
104
130
  sources.project = pPath;
105
131
  }
106
132
  }
107
- merged = deepMerge(merged, envOverrides());
133
+ layers.env = envOverrides();
134
+ merged = deepMerge(merged, layers.env);
108
135
  const result = CruxyConfigSchema.safeParse(merged);
109
136
  if (!result.success) {
110
137
  const issues = result.error.issues
@@ -112,7 +139,7 @@ export function loadConfig(opts = {}) {
112
139
  .join("\n");
113
140
  throw configInvalid(issues, sources.project ?? sources.global ?? undefined);
114
141
  }
115
- return { config: result.data, sources };
142
+ return { config: result.data, sources, layers };
116
143
  }
117
144
  /** Resolve a dot-path (e.g. "model.temperature") against a config object. */
118
145
  export function getPath(obj, path) {
@@ -176,12 +203,15 @@ export function resolveApiKey(provider) {
176
203
  }
177
204
  /** The API key from the environment for `provider`, or `undefined`. */
178
205
  function envApiKey(provider) {
179
- switch (provider) {
180
- case "cruxy":
181
- return process.env.CRUXY_API_KEY;
182
- case "openai":
183
- return process.env.OPENAI_API_KEY;
184
- default:
185
- return process.env.CRUXY_API_KEY;
186
- }
206
+ return process.env[apiKeyEnvVar(provider)];
207
+ }
208
+ /**
209
+ * The environment variable a provider's API key is read from — the NAME only,
210
+ * never the value, so a surface can say where a key would come from without
211
+ * ever holding one. Exported so nothing has to restate this mapping: a second
212
+ * copy would drift the moment a provider is added, and the copy that drifts is
213
+ * always the one telling the user where to put their key.
214
+ */
215
+ export function apiKeyEnvVar(provider) {
216
+ return provider === "openai" ? "OPENAI_API_KEY" : "CRUXY_API_KEY";
187
217
  }
@@ -99,7 +99,13 @@ export function previewStats(preview) {
99
99
  }
100
100
  return [];
101
101
  }
102
- /** `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count. */
102
+ /**
103
+ * `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count.
104
+ *
105
+ * Takes only the three fields it reads rather than a whole {@link DiffFileStat},
106
+ * so P7's git-status changes render through this same formatter instead of a
107
+ * second one that would eventually disagree with it about what `null` means.
108
+ */
103
109
  export function formatStat(stat, c) {
104
110
  const parts = [];
105
111
  if (stat.added !== null && stat.added > 0)
@@ -50,12 +50,12 @@ export function sessionStatusLines(status, t, width = Infinity) {
50
50
  lines.push(t.strong(status.roots.length === 1 ? "root" : "roots"));
51
51
  for (const r of status.roots) {
52
52
  const mark = r.primary ? t.accent(t.glyph.pointer) : " ";
53
- const git = r.branch === undefined
54
- ? t.muted("not a git repo")
55
- : `${t.strong(r.branch)} ${r.changed === undefined
56
- ? ""
57
- : r.changed > 0
58
- ? t.warning(`${r.changed} changed`)
53
+ const git = r.git === undefined
54
+ ? t.muted(`checking${t.glyph.ellipsis}`)
55
+ : r.git === null
56
+ ? t.muted("not a git repo")
57
+ : `${t.strong(r.git.branch)} ${r.git.changed > 0
58
+ ? t.warning(`${r.git.changed} changed`)
59
59
  : t.success("clean")}`;
60
60
  lines.push(`${mark} ${r.name.padEnd(12)} ${git}`);
61
61
  lines.push(` ${t.muted(r.path)}`);