@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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +401 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +717 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +204 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- 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
|
+
}
|
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/config/project.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
451
|
-
*
|
|
452
|
-
* and
|
|
453
|
-
*
|
|
454
|
-
*
|
|
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
|
-
/**
|
|
461
|
-
*
|
|
462
|
-
|
|
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
|
-
/**
|
|
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
|
-
.
|
|
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() { }
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -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
|
|
80
|
-
|
|
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
|
}
|