@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.
- package/dist/agent/status.js +56 -0
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/session-commands.js +11 -49
- package/dist/components/keys.js +38 -0
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/render/diff.js +7 -1
- package/dist/render/status-view.js +6 -6
- package/dist/tui/app.js +125 -2
- package/dist/tui/git-status.js +46 -1
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +7 -2
- package/dist/tui/layout.js +45 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/renderer.js +365 -20
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/views.js +66 -0
- package/dist/utils/git.js +113 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
*
|
package/dist/components/keys.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/config/index.js
CHANGED
package/dist/config/manager.js
CHANGED
|
@@ -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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
}
|
package/dist/render/diff.js
CHANGED
|
@@ -99,7 +99,13 @@ export function previewStats(preview) {
|
|
|
99
99
|
}
|
|
100
100
|
return [];
|
|
101
101
|
}
|
|
102
|
-
/**
|
|
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.
|
|
54
|
-
? t.muted(
|
|
55
|
-
:
|
|
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)}`);
|