@cruxy/cli 1.2.1 → 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.
Files changed (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { PROJECT_INSTRUCTION_FILENAMES } from "../constants.js";
4
4
  /** Cap on instruction file size; the rest is dropped with a notice. */
@@ -34,3 +34,55 @@ export function loadProjectInstructions(cwd) {
34
34
  }
35
35
  return null;
36
36
  }
37
+ /**
38
+ * The starting `CRUXY.md` (P6 track 4).
39
+ *
40
+ * Deliberately a SKELETON with prompts rather than filled-in guesses. cruxy
41
+ * cannot know this project's conventions at scaffold time, and a template that
42
+ * asserted some would be injected into every subsequent turn's system prompt as
43
+ * though the user had written it — instructions the agent follows and nobody
44
+ * chose. Empty headings ask; invented content misleads.
45
+ */
46
+ export const PROJECT_INSTRUCTIONS_TEMPLATE = `# Project instructions for cruxy
47
+
48
+ These notes are loaded into cruxy's context on every run. Keep them short and
49
+ high-signal — conventions, where things live, how to build and test.
50
+
51
+ ## Conventions
52
+
53
+ - (e.g. language, style, naming rules the agent should follow)
54
+
55
+ ## Build & test
56
+
57
+ - (e.g. how to install deps, run the app, run the test suite)
58
+
59
+ ## Gotchas
60
+
61
+ - (e.g. anything non-obvious about this codebase)
62
+ `;
63
+ /**
64
+ * Write the starter `CRUXY.md` into `cwd`, unless project instructions already
65
+ * exist there.
66
+ *
67
+ * NEVER OVERWRITES, and the check is {@link loadProjectInstructions} rather than
68
+ * a bare `existsSync("CRUXY.md")` — a project whose instructions live in
69
+ * `AGENTS.md` already has them, and scaffolding a second file beside it would
70
+ * create two sources for one thing where the loader honours only the first.
71
+ *
72
+ * Extracted from the onboarding scaffold step (P6 track 4) so `/init` and
73
+ * `cruxy init` write the same file. The step's own y/N prompt and IO stay in
74
+ * `onboarding/steps.ts`, which is the part that genuinely differs between an
75
+ * onboarding flow and a slash command.
76
+ */
77
+ export function scaffoldProjectInstructions(cwd) {
78
+ if (loadProjectInstructions(cwd) !== null)
79
+ return { kind: "exists" };
80
+ const file = join(cwd, PROJECT_INSTRUCTION_FILENAMES[0]);
81
+ try {
82
+ writeFileSync(file, PROJECT_INSTRUCTIONS_TEMPLATE, "utf8");
83
+ }
84
+ catch (err) {
85
+ return { kind: "failed", message: err.message };
86
+ }
87
+ return { kind: "written", file };
88
+ }
@@ -49,8 +49,14 @@ export const AgentConfigSchema = z
49
49
  * cumulative across turns.
50
50
  */
51
51
  maxTokensPerTurn: z.number().int().nonnegative().default(0),
52
- /** Skip per-action confirmation prompts. */
53
- autoApprove: z.boolean().default(false),
52
+ // `autoApprove` used to sit here (P5 track 3 removed it). It had ZERO
53
+ // consumers — nothing in the codebase ever read it — while promising to
54
+ // "skip per-action confirmation prompts", and `ApprovalConfigSchema` below
55
+ // stated in the same file that no such mode exists. A flag on disk also
56
+ // disarms every approval in every session that loads it, with nothing on
57
+ // screen to say so. Auto-approve is now a runtime SESSION MODE
58
+ // (`agent/mode.ts`): chosen in the session it affects, shown while active,
59
+ // gone when the session ends.
54
60
  /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
55
61
  planMode: z.boolean().default(false),
56
62
  })
@@ -120,9 +126,13 @@ export const ContextConfigSchema = z
120
126
  .strict();
121
127
  /**
122
128
  * How tool-action approval is resolved. Only `prompt` exists: ask interactively
123
- * and **deny by default** when non-interactive. There is deliberately no
124
- * auto-approve / skip mode — the policy seam for a future CI mode lives in
125
- * `src/approval` (see U.3), not behind a footgun flag.
129
+ * and **deny by default** when non-interactive.
130
+ *
131
+ * There is still deliberately no auto-approve mode HERE, and that is the point
132
+ * this schema has always made: not that unattended execution is forbidden, but
133
+ * that it must not be armed from a file. It is a runtime session mode
134
+ * (`agent/mode.ts`) reached through the policy seam in `src/approval` — chosen
135
+ * in the session it affects and visible the whole time it is on.
126
136
  */
127
137
  export const ApprovalConfigSchema = z
128
138
  .object({
@@ -436,7 +446,13 @@ export const LspConfigSchema = z
436
446
  maxResults: z.number().int().positive().default(100),
437
447
  })
438
448
  .strict();
439
- /** A per-tier price, in the user's own currency, PER MILLION TOKENS (C.22). */
449
+ /**
450
+ * RETIRED (C.22): a per-tier price, in the user's own currency, PER MILLION
451
+ * TOKENS. Nothing reads it. Kept only so `usage.prices` still VALIDATES — see
452
+ * {@link UsageConfigSchema}.
453
+ *
454
+ * @deprecated cost comes from the gateway now; delete `usage.prices` from config.
455
+ */
440
456
  export const TierPriceSchema = z
441
457
  .object({
442
458
  /** Price per 1,000,000 input tokens. */
@@ -447,22 +463,39 @@ export const TierPriceSchema = z
447
463
  .strict();
448
464
  /**
449
465
  * Usage telemetry + cost tracking (C.22): LOCAL usage accounting only — nothing
450
- * here is ever transmitted. Costs are opt-in and honest: prices are per-TIER and
451
- * per-million-tokens, configured by the user; with none set, tokens are shown
452
- * and cost is omitted (cruxy ships no bundled price table). The `prices` keys are
453
- * the fixed {@link MODEL_TIERS}, so an upstream model name can never appear here
454
- * (U.8 gag holds by construction) and a mistyped tier is rejected at config load.
466
+ * here is ever transmitted.
467
+ *
468
+ * `currency` and `prices` are RETIRED and read by nothing. They modelled a
469
+ * per-million-token cost basis that no cruxy subscriber is billed on: the
470
+ * gateway meters a weighted token pool and quotes its own cost per request, and
471
+ * `cruxy usage` now shows both of those instead of arithmetic over a number the
472
+ * user typed in.
473
+ *
474
+ * They are still ACCEPTED, and deliberately so. This schema is `.strict()` and
475
+ * `loadConfig` turns any parse failure into a hard error, so deleting the keys
476
+ * would mean every user who had set them gets a CLI that refuses to start until
477
+ * they hand-edit a file — a broken binary as the punishment for having used a
478
+ * documented feature. Tolerating a dead key costs nothing; rejecting it costs
479
+ * the whole tool. `cruxy usage` tells anyone who still has them set that they no
480
+ * longer do anything, which is the part that actually needed saying.
481
+ *
482
+ * Both are now `.optional()` with NO default, so a freshly-initialized config
483
+ * doesn't write retired keys into a new file — absent is the new normal, present
484
+ * is the tolerated legacy.
455
485
  */
456
486
  export const UsageConfigSchema = z
457
487
  .object({
458
488
  /** Master switch. When false, no usage is collected, persisted, or shown. */
459
489
  enabled: z.boolean().default(true),
460
- /** Currency label to prefix costs with (e.g. "$", "€"). Empty ⇒ no symbol is
461
- * assumed a bare number is shown, since the user configured the prices. */
462
- currency: z.string().default(""),
490
+ /**
491
+ * @deprecated retired, unread. Cost is shown in the gateway's own currency.
492
+ */
493
+ currency: z.string().optional(),
463
494
  /** How many past runs to keep in the store; older ones are pruned oldest-first. */
464
495
  retention: z.number().int().positive().default(50),
465
- /** Per-tier prices. Any tier omitted is simply unpriced (cost omitted for it). */
496
+ /**
497
+ * @deprecated retired, unread. Cost comes from the gateway per request.
498
+ */
466
499
  prices: z
467
500
  .object({
468
501
  kavi: TierPriceSchema.optional(),
@@ -470,7 +503,7 @@ export const UsageConfigSchema = z
470
503
  mira: TierPriceSchema.optional(),
471
504
  })
472
505
  .strict()
473
- .default({}),
506
+ .optional(),
474
507
  })
475
508
  .strict();
476
509
  /**
@@ -1,4 +1,5 @@
1
1
  import { themeForColor } from "../theme/index.js";
2
+ import { changedSteps } from "../render/plan-view.js";
2
3
  /** Non-terminal capabilities: a background job renders to NOTHING on screen. */
3
4
  const OFFSCREEN_CAPS = {
4
5
  tty: false,
@@ -8,7 +9,12 @@ const OFFSCREEN_CAPS = {
8
9
  reducedMotion: true,
9
10
  screenReader: false,
10
11
  unicode: true,
12
+ // No stdin and no screen: a job is never interactive and never repaints, so
13
+ // both input-axis flags are false regardless of what the foreground has.
14
+ stdinTty: false,
15
+ interactive: false,
11
16
  width: 80,
17
+ height: 24,
12
18
  };
13
19
  /**
14
20
  * A {@link StreamRenderer} for a background job (C.28) that captures activity into
@@ -26,6 +32,8 @@ export class JobLogRenderer {
26
32
  caps = OFFSCREEN_CAPS;
27
33
  theme = themeForColor(false);
28
34
  pending = "";
35
+ /** Last plan snapshot logged, so `setPlan` records only what changed. */
36
+ planSteps = [];
29
37
  constructor(sink) {
30
38
  this.sink = sink;
31
39
  }
@@ -51,6 +59,14 @@ export class JobLogRenderer {
51
59
  note(text) {
52
60
  this.sink("out", text);
53
61
  }
62
+ /**
63
+ * Ignored: a job log is an append-only transcript, and a served tier is
64
+ * standing state rather than an event. The run's tier is recorded by the
65
+ * usage store, which is where a job's accounting is read from.
66
+ */
67
+ servedRouting() {
68
+ // no-op
69
+ }
54
70
  toolLifecycle(event) {
55
71
  if (event.event !== "end")
56
72
  return; // only the committed outcome is log-worthy
@@ -60,6 +76,37 @@ export class JobLogRenderer {
60
76
  endTurn() {
61
77
  this.endSegment();
62
78
  }
79
+ /**
80
+ * Plan steps are committed outcomes, so a background job's log records them —
81
+ * one line per step whose status actually changed (the whole list arrives on
82
+ * every transition). Status is a word, not a glyph, matching this log's
83
+ * `[ok]`/`[fail]` style: nothing here is a terminal, so a themed mark would
84
+ * only add bytes `cruxy logs` has to strip.
85
+ */
86
+ setPlan(steps) {
87
+ if (steps === null) {
88
+ this.planSteps = [];
89
+ return;
90
+ }
91
+ for (const step of changedSteps(this.planSteps, steps)) {
92
+ this.sink(step.status === "failed" ? "err" : "out", `[${step.status}] ${step.id}. ${step.title}`);
93
+ }
94
+ this.planSteps = steps.map((s) => ({ ...s }));
95
+ }
96
+ /**
97
+ * A job's test run is exactly the kind of outcome `cruxy logs` exists to
98
+ * show. Plain text, no theme glyphs, matching this log's `[ok]`/`[fail]`
99
+ * style — and no count this renderer was not given.
100
+ */
101
+ testResult(report) {
102
+ const counted = report.total !== undefined
103
+ ? ` ${report.failures.length}/${report.total} failed`
104
+ : "";
105
+ this.sink(report.passed ? "out" : "err", `[${report.passed ? "tests ok" : "tests failed"}]${report.passed ? "" : counted} ${report.command} (${Math.round(report.durationMs)}ms)`);
106
+ for (const f of report.failures) {
107
+ this.sink("err", ` ${f.name}${f.file === undefined ? "" : ` ${f.file}${f.line === undefined ? "" : `:${f.line}`}`}`);
108
+ }
109
+ }
63
110
  // Transient decor / previews have no place in an append-only job log.
64
111
  preview() { }
65
112
  status() { }
@@ -1,8 +1,6 @@
1
- import { writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { themeForColor } from "../theme/index.js";
4
2
  import { CREATE_KEY_URL } from "../constants.js";
5
- import { loadProjectInstructions } from "../config/index.js";
3
+ import { loadProjectInstructions, scaffoldProjectInstructions, } from "../config/index.js";
6
4
  /**
7
5
  * The individual onboarding steps (U.6). Each returns a {@link StepResult} and
8
6
  * never throws across its boundary; the secret is read masked and is never echoed
@@ -45,26 +43,14 @@ export async function acquireKeyStep(io, deps, provider) {
45
43
  }
46
44
  return { status: "failed", message: "key rejected after 3 attempts" };
47
45
  }
48
- const CRUXY_MD_TEMPLATE = `# Project instructions for cruxy
49
-
50
- These notes are loaded into cruxy's context on every run. Keep them short and
51
- high-signal — conventions, where things live, how to build and test.
52
-
53
- ## Conventions
54
-
55
- - (e.g. language, style, naming rules the agent should follow)
56
-
57
- ## Build & test
58
-
59
- - (e.g. how to install deps, run the app, run the test suite)
60
-
61
- ## Gotchas
62
-
63
- - (e.g. anything non-obvious about this codebase)
64
- `;
65
46
  /**
66
47
  * Offer to scaffold a project `CRUXY.md`. Skipped silently when one already
67
48
  * exists (or `AGENTS.md`); otherwise a `y` confirmation writes the template.
49
+ *
50
+ * The template and the write moved to `config/project.ts` (P6 track 4) so
51
+ * `/init` writes the same file. What stays here is the part that genuinely
52
+ * differs between an onboarding flow and a slash command: the y/N prompt and
53
+ * the IO it is drawn on.
68
54
  */
69
55
  export async function scaffoldStep(io, cwd) {
70
56
  const col = c(io);
@@ -76,8 +62,13 @@ export async function scaffoldStep(io, cwd) {
76
62
  io.write("\n");
77
63
  if (key !== "y")
78
64
  return { status: "skipped" };
79
- const file = join(cwd, "CRUXY.md");
80
- writeFileSync(file, CRUXY_MD_TEMPLATE, "utf8");
65
+ const outcome = scaffoldProjectInstructions(cwd);
66
+ if (outcome.kind === "failed") {
67
+ // Previously this threw out of the step on an unwritable directory. A step
68
+ // must not throw across its boundary, so a failed write is reported.
69
+ io.write(`${col.danger(col.glyph.failure)} could not write CRUXY.md — ${outcome.message}\n`);
70
+ return { status: "failed", message: outcome.message };
71
+ }
81
72
  io.write(`${col.success(col.glyph.success)} wrote ${col.strong("CRUXY.md")}\n`);
82
73
  return { status: "ok" };
83
74
  }