@cruxy/cli 1.3.0 → 1.5.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,96 @@
1
+ import { readContext } from "./context.js";
2
+ import { modeDescription } from "./mode.js";
3
+ import { globalDir } from "../config/paths.js";
4
+ import { GLOBAL_DIR_NAME } from "../constants.js";
5
+ /**
6
+ * Where the disk figures come from: every declared root, plus `~/.cruxy`.
7
+ *
8
+ * The home directory is in the list because it is the one that fills up
9
+ * invisibly. A root's usage is the user's own files, which they know about;
10
+ * `~/.cruxy` accumulates session transcripts, the usage store and — the big one
11
+ * — a checkpoint shadow copy per run. Reporting only the roots would leave the
12
+ * cruxy-specific half of "why is this disk full" out of cruxy's own status
13
+ * screen.
14
+ */
15
+ function diskLocations(roots, disk) {
16
+ const locations = [];
17
+ for (const root of roots) {
18
+ const capacity = disk(root.absPath);
19
+ if (capacity)
20
+ locations.push({ label: root.name, capacity });
21
+ }
22
+ const home = disk(globalDir());
23
+ // Labelled `~/.cruxy` rather than the resolved path: the row is 40 columns of
24
+ // numbers already, and the literal home path adds width without adding a fact
25
+ // — `/status` prints every root's real path a few lines further down anyway.
26
+ if (home)
27
+ locations.push({ label: `~/${GLOBAL_DIR_NAME}`, capacity: home });
28
+ return locations;
29
+ }
30
+ /** Count real user turns: `role: "user"` with STRING content — tool results
31
+ * are also role "user" but carry blocks, so this counts what the human said. */
32
+ function userTurns(session) {
33
+ return session.messages.filter((m) => m.role === "user" && typeof m.content === "string").length;
34
+ }
35
+ /** Everything `/status` and the Overview view show, from live session state. */
36
+ export function buildSessionStatus(session, git,
37
+ /**
38
+ * The tier the gateway last said actually SERVED a request, when the caller
39
+ * is somewhere that knows it. Only the TUI renderer tracks this — it arrives
40
+ * on the stream's routing frame — so `/status`, which has no renderer, omits
41
+ * it and shows the configured value alone.
42
+ */
43
+ servedTier,
44
+ /**
45
+ * Free space where cruxy writes. Omitted rather than defaulted to a live
46
+ * probe: a caller that doesn't supply one gets no disk rows, which is the
47
+ * only safe default for a function whose whole contract is that it never
48
+ * touches the world itself.
49
+ */
50
+ disk) {
51
+ const toolCtx = session.toolContext;
52
+ const config = toolCtx.config;
53
+ const mode = session.getMode();
54
+ const roots = toolCtx.workspace.roots().map((r) => {
55
+ const info = git(r.absPath);
56
+ return {
57
+ name: r.name,
58
+ path: r.absPath,
59
+ primary: r.primary,
60
+ // Spread rather than assigned, so "not probed" stays ABSENT rather than
61
+ // becoming an explicit `undefined` the renderer would have to re-check.
62
+ ...(info === undefined ? {} : { git: info }),
63
+ };
64
+ });
65
+ const jobs = session.jobs?.list();
66
+ const locations = disk
67
+ ? diskLocations(toolCtx.workspace.roots(), disk)
68
+ : undefined;
69
+ return {
70
+ sessionId: session.sessionId,
71
+ turns: userTurns(session),
72
+ mode,
73
+ modeDescription: modeDescription(mode),
74
+ ...(session.model ? { model: session.model.current() } : {}),
75
+ ...(servedTier === undefined ? {} : { servedTier }),
76
+ provider: config.model.provider,
77
+ roots,
78
+ context: readContext(session.messages, config.context),
79
+ sandboxEnabled: Boolean(toolCtx.sandbox),
80
+ ...(toolCtx.sandbox ? { sandboxRuntime: toolCtx.sandbox.runtimeName } : {}),
81
+ checkpoints: Boolean(toolCtx.checkpointsActive),
82
+ // Absent when nothing could be read, not an empty array — same rule as the
83
+ // rest of this object: an omitted field is a fact we don't have.
84
+ ...(locations && locations.length > 0 ? { disk: locations } : {}),
85
+ ...(jobs
86
+ ? {
87
+ jobs: {
88
+ total: jobs.length,
89
+ running: jobs.filter((j) => j.status === "running").length,
90
+ needingApproval: jobs.filter((j) => j.pendingApproval).length,
91
+ },
92
+ }
93
+ : {}),
94
+ tools: session.toolRegistry.list().length,
95
+ };
96
+ }
@@ -1,10 +1,12 @@
1
1
  import { Command } from "commander";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { LimitsClient } from "@cruxy/sdk";
4
+ import { LimitsCache } from "../../limits/index.js";
3
5
  import { logger } from "../../utils/logger.js";
4
6
  import { SessionLog, listSessions, resumeById, resumePicker, shortId, } from "../../session/index.js";
5
7
  /** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
6
8
  const SIDEBAR_SESSIONS = 10;
7
- import { loadConfig, resolveApiKey } from "../../config/index.js";
9
+ import { globalDir, loadConfig, resolveApiKey } from "../../config/index.js";
8
10
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
9
11
  import { createRenderer } from "../../render/index.js";
10
12
  import { themeForColor } from "../../theme/index.js";
@@ -14,7 +16,7 @@ import { SandboxService } from "../../sandbox/index.js";
14
16
  import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
15
17
  import { DEFAULT_MODE, } from "../../agent/index.js";
16
18
  import { runInteractive } from "../repl.js";
17
- import { ContextGauge, createKeyLease, runTui, TuiRenderer, } from "../../tui/index.js";
19
+ import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
18
20
  import { buildAgentSession } from "../session-factory.js";
19
21
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
20
22
  import { resetLspServices } from "../../lsp/index.js";
@@ -71,7 +73,10 @@ export async function executeRun(promptParts, opts) {
71
73
  ]
72
74
  : [`provide a task, e.g. ${invokedAs} "fix the failing test"`]);
73
75
  }
74
- const { config, sources } = loadConfig();
76
+ // Kept whole (not destructured away) because the Settings view needs the raw
77
+ // layers this result was merged from to say where each value came from.
78
+ const loaded = loadConfig();
79
+ const { config, sources } = loaded;
75
80
  let apiKey = resolveApiKey(config.model.provider);
76
81
  // Declared workspace roots (C.26). This is the ONE place `run` reads the
77
82
  // process working directory — the invocation directory is the base for
@@ -337,6 +342,52 @@ export async function executeRun(promptParts, opts) {
337
342
  // provider, where the constructor's static value stands and `/model` declines.
338
343
  if (session.model)
339
344
  tui?.attachModel(session.model);
345
+ // The limits rail panel (P9) — the account's headroom, read from the gateway
346
+ // that enforces it.
347
+ //
348
+ // ONLY ON THE CRUXY PROVIDER, and only with a key. `/limits` is a cruxy
349
+ // gateway endpoint; a bring-your-own-provider session has no such notion, and
350
+ // pointing this at someone else's base URL would be a request to a stranger.
351
+ // The cache is still attached without a key so the panel can say "not signed
352
+ // in" — a fact worth stating, and one that costs no request to establish.
353
+ const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
354
+ tui?.attachLimits(new LimitsCache(limitsKey === undefined
355
+ ? undefined
356
+ : (signal) => new LimitsClient({
357
+ apiKey: limitsKey,
358
+ gatewayUrl: config.cruxy.gatewayUrl,
359
+ }).read(signal)));
360
+ // The main-pane views (P7). Registered here — the one place that has both the
361
+ // renderer and the session — and after the session exists, because a view
362
+ // reads its live state. Each root gets its own cached git probe: writes fan
363
+ // across every declared root when checkpoints are on, so a primary-only
364
+ // answer would under-report what a turn actually touched.
365
+ if (tui) {
366
+ // ONE cache, both views. They ask the same question of the same trees, and
367
+ // a second instance would double the subprocesses to hold two copies of an
368
+ // answer that must agree anyway.
369
+ const workspaceGit = new WorkspaceGitCache(session.toolContext.workspace.roots().map((r) => r.absPath));
370
+ // Every root, plus `~/.cruxy` — the two kinds of place a run fills up. The
371
+ // home directory is the one nobody watches: checkpoints shadow-copy the
372
+ // tree once per run, and they accumulate there rather than in the repo.
373
+ const workspaceDisk = new WorkspaceDiskCache([
374
+ ...session.toolContext.workspace.roots().map((r) => r.absPath),
375
+ globalDir(),
376
+ ]);
377
+ tui.attachViews([
378
+ createOverviewView(session, workspaceGit, () => tui.servedTier(), workspaceDisk),
379
+ createGitView(() => session.toolContext.workspace.roots(), workspaceGit),
380
+ // Registered even when background jobs are disabled — the view says so,
381
+ // and a nav whose rows appear and vanish with config is worse than one
382
+ // row that explains itself. `session.jobs` is undefined in that case.
383
+ createTasksView(session.jobs),
384
+ // The SAME loaded config the session was built from, not a fresh read —
385
+ // the view's whole claim is "what this session is running on", and a
386
+ // second load could already disagree with it. `apiKey` is a getter
387
+ // because onboarding may have supplied one after this line was written.
388
+ createSettingsView(loaded, () => apiKey !== undefined),
389
+ ]);
390
+ }
340
391
  if (multiTurn) {
341
392
  try {
342
393
  // Same session, same renderer seam, two shells: the TUI when the
@@ -1,13 +1,15 @@
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";
8
9
  import { renderUnifiedDiff } from "../render/index.js";
9
10
  import { sessionStatusLines } from "../render/status-view.js";
10
11
  import { defaultExportName, exportMarkdown } from "../session/index.js";
12
+ import { readDiskCapacitySync } from "../utils/disk.js";
11
13
  import { getGitInfo } from "../utils/git.js";
12
14
  import { currentBranch, diffAgainst, hasChanges } from "../vcs/git.js";
13
15
  import { MODEL_CHOICES, describeModelChoice, parseModelChoice, } from "../routing/index.js";
@@ -365,57 +367,22 @@ function handleInit(ctx) {
365
367
  */
366
368
  function handleStatus(ctx) {
367
369
  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)) {
370
+ // A SYNCHRONOUS probe, and the one caller allowed one. The paint path's rule
371
+ // (never spawn during compose) is about frames; a user who just typed
372
+ // `/status` has no frame budget to blow, and an answer that says "checking…"
373
+ // for a fact this could have fetched would be worse than the 45ms.
374
+ //
375
+ // Everything else comes from the shared builder, so this and the Overview
376
+ // view cannot drift: they differ in exactly this argument and nowhere else.
377
+ const status = buildSessionStatus(session, (absPath) => getGitInfo(absPath), undefined,
378
+ // Synchronous here for the same reason git is: the user asked. This one is
379
+ // a single syscall rather than two subprocesses, so it costs microseconds
380
+ // on any disk that is answering at all.
381
+ (path) => readDiskCapacitySync(path));
382
+ for (const line of sessionStatusLines(status, out.theme)) {
412
383
  out.print(out.fit(line));
413
384
  }
414
385
  }
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
386
  /**
420
387
  * `/diff` (P6 track 4) — what has changed on disk, without leaving the session.
421
388
  *
@@ -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";