@cruxy/cli 0.14.0 → 0.17.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/loop.d.ts +26 -0
- package/dist/agent/loop.js +59 -3
- package/dist/agent/session.d.ts +17 -1
- package/dist/agent/session.js +23 -2
- package/dist/brand/index.d.ts +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/voice.d.ts +94 -0
- package/dist/brand/voice.js +127 -0
- package/dist/cli/commands/checkpoint.js +1 -1
- package/dist/cli/commands/hooks.d.ts +8 -0
- package/dist/cli/commands/hooks.js +83 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/pr.js +10 -2
- package/dist/cli/commands/rollback.js +1 -1
- package/dist/cli/commands/run.js +13 -3
- package/dist/cli/commands/skills.js +2 -2
- package/dist/cli/program.js +5 -2
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +54 -3
- package/dist/cli/session-factory.d.ts +2 -2
- package/dist/cli/session-factory.js +13 -2
- package/dist/config/schema.d.ts +139 -46
- package/dist/config/schema.js +42 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +9 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +98 -6
- package/dist/errors/types.d.ts +14 -0
- package/dist/errors/types.js +23 -0
- package/dist/hooks/config.d.ts +21 -0
- package/dist/hooks/config.js +253 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/runner.d.ts +76 -0
- package/dist/hooks/runner.js +114 -0
- package/dist/hooks/service.d.ts +38 -0
- package/dist/hooks/service.js +49 -0
- package/dist/hooks/slash.d.ts +48 -0
- package/dist/hooks/slash.js +58 -0
- package/dist/hooks/trust.d.ts +46 -0
- package/dist/hooks/trust.js +106 -0
- package/dist/hooks/types.d.ts +147 -0
- package/dist/hooks/types.js +61 -0
- package/dist/onboarding/steps.js +1 -1
- package/dist/plan/service.d.ts +6 -0
- package/dist/plan/service.js +4 -0
- package/dist/render/state.js +4 -1
- package/dist/render/types.d.ts +7 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +5 -0
- package/dist/routing/resolve.d.ts +17 -0
- package/dist/routing/resolve.js +18 -0
- package/dist/routing/router.d.ts +47 -0
- package/dist/routing/router.js +84 -0
- package/dist/routing/types.d.ts +42 -0
- package/dist/routing/types.js +27 -0
- package/dist/subagent/orchestrator.d.ts +6 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/subagent/types.d.ts +6 -0
- package/dist/tools/shell/exec.d.ts +53 -0
- package/dist/tools/shell/exec.js +128 -0
- package/dist/tools/shell/run-command.d.ts +4 -0
- package/dist/tools/shell/run-command.js +26 -116
- package/dist/vcs/generate.d.ts +3 -1
- package/dist/vcs/generate.js +4 -1
- package/package.json +2 -2
package/dist/agent/loop.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { HookEvent } from "../hooks/index.js";
|
|
3
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
|
+
import { type Router, type TaskClass } from "../routing/index.js";
|
|
4
6
|
import type { ToolContext } from "../tools/index.js";
|
|
5
7
|
import { ToolRegistry } from "../tools/index.js";
|
|
8
|
+
/** The lifecycle-hook firing seam (C.19). Structural so the loop stays
|
|
9
|
+
* decoupled from the concrete `HookRunner`. `fire` resolves when hooks pass (or
|
|
10
|
+
* advisory ones fail) and throws `CRUXY_E_HOOK_FAILED` on a blocking failure. */
|
|
11
|
+
export interface LifecycleHookRunner {
|
|
12
|
+
fire(event: HookEvent, ctx: ToolContext): Promise<void>;
|
|
13
|
+
}
|
|
6
14
|
export interface RunAgentArgs {
|
|
7
15
|
/**
|
|
8
16
|
* The full running conversation. The caller owns history and must append the
|
|
@@ -43,6 +51,24 @@ export interface RunAgentArgs {
|
|
|
43
51
|
* histories stay coherent — overshoot is bounded by one turn.
|
|
44
52
|
*/
|
|
45
53
|
budget?: LoopBudget;
|
|
54
|
+
/**
|
|
55
|
+
* Lifecycle hooks (C.19). When set, `before-tool` fires before each tool call
|
|
56
|
+
* (a blocking failure fails the call closed — the tool does NOT run), and
|
|
57
|
+
* `after-tool` / `on-file-change` fire after. Omitted for subagents and the
|
|
58
|
+
* no-hooks path, so their tools never fire hooks.
|
|
59
|
+
*/
|
|
60
|
+
hooks?: LifecycleHookRunner;
|
|
61
|
+
/**
|
|
62
|
+
* Multi-model routing (C.30): when set, the tier for `taskClass` is resolved
|
|
63
|
+
* ONCE up front (fail loud before any model turn) and its wire model overrides
|
|
64
|
+
* the provider default for every turn in this run; the tier is surfaced on the
|
|
65
|
+
* live line. Omitted → no override, the provider's default model is used
|
|
66
|
+
* (unchanged behavior).
|
|
67
|
+
*/
|
|
68
|
+
router?: Router;
|
|
69
|
+
/** The declared task class for routing; defaults to `main-turn`. Ignored
|
|
70
|
+
* unless `router` is set. */
|
|
71
|
+
taskClass?: TaskClass;
|
|
46
72
|
}
|
|
47
73
|
/**
|
|
48
74
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
package/dist/agent/loop.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { providerUnsupported } from "../errors/index.js";
|
|
1
|
+
import { CruxyError, providerUnsupported } from "../errors/index.js";
|
|
2
|
+
import { resolveTaskModel, } from "../routing/index.js";
|
|
2
3
|
import { buildSystemPrompt } from "./prompts.js";
|
|
4
|
+
/** Tools whose successful call is a file change (drives the `on-file-change`
|
|
5
|
+
* hook). Kept in sync with the file-mutating tool set. */
|
|
6
|
+
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch"]);
|
|
3
7
|
/**
|
|
4
8
|
* Drive the model/tool loop over an existing conversation. Streams each turn,
|
|
5
9
|
* renders assistant text, reassembles tool calls, executes them, feeds the
|
|
@@ -14,9 +18,16 @@ export async function runAgent(args) {
|
|
|
14
18
|
if (!provider.supportsTools) {
|
|
15
19
|
throw providerUnsupported(config.model.provider);
|
|
16
20
|
}
|
|
21
|
+
// Resolve the routing tier ONCE, before any model turn (C.30). A tier the
|
|
22
|
+
// gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
|
|
23
|
+
// misrouted run never reaches provider.stream (no request sent with the wrong
|
|
24
|
+
// tier), and never silently falls back to a different one.
|
|
25
|
+
const routed = args.router
|
|
26
|
+
? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
|
|
27
|
+
: null;
|
|
17
28
|
renderer?.beginTurn();
|
|
18
29
|
try {
|
|
19
|
-
return await driveLoop(args, renderer);
|
|
30
|
+
return await driveLoop(args, renderer, routed);
|
|
20
31
|
}
|
|
21
32
|
finally {
|
|
22
33
|
// Always leave the terminal clean: no orphaned status line, no held text —
|
|
@@ -25,7 +36,7 @@ export async function runAgent(args) {
|
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
38
|
/** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
|
|
28
|
-
async function driveLoop(args, renderer) {
|
|
39
|
+
async function driveLoop(args, renderer, routed) {
|
|
29
40
|
const { provider, registry, config, ctx } = args;
|
|
30
41
|
const { logger } = ctx;
|
|
31
42
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
@@ -77,11 +88,13 @@ async function driveLoop(args, renderer) {
|
|
|
77
88
|
tokens: usage.input_tokens + usage.output_tokens > 0
|
|
78
89
|
? { input: usage.input_tokens, output: usage.output_tokens }
|
|
79
90
|
: undefined,
|
|
91
|
+
tier: routed?.tier,
|
|
80
92
|
});
|
|
81
93
|
for await (const ev of provider.stream({
|
|
82
94
|
system,
|
|
83
95
|
messages,
|
|
84
96
|
tools,
|
|
97
|
+
...(routed ? { model: routed.model } : {}),
|
|
85
98
|
})) {
|
|
86
99
|
switch (ev.type) {
|
|
87
100
|
case "text_delta":
|
|
@@ -146,9 +159,26 @@ async function driveLoop(args, renderer) {
|
|
|
146
159
|
// long calls), end commits the ✓/✗ trail note. Same information as the
|
|
147
160
|
// old status/note pair, now typed and duration-aware.
|
|
148
161
|
renderer?.toolLifecycle({ event: "start", label });
|
|
162
|
+
// before-tool (C.19): a blocking pre-check that fails aborts THIS tool
|
|
163
|
+
// fail-closed — the tool never runs; the model is told via an error
|
|
164
|
+
// result. The hook command itself went through the U.3 gate + C.16 sandbox
|
|
165
|
+
// (same path as run_command), so a hook is never an approval bypass.
|
|
166
|
+
const blocked = await fireBeforeTool(args.hooks, ctx, call.id);
|
|
167
|
+
if (blocked) {
|
|
168
|
+
renderer?.toolLifecycle({ event: "end", label, ok: false });
|
|
169
|
+
toolResults.push(blocked);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
149
172
|
const result = await runToolCall(call, registry, ctx);
|
|
150
173
|
renderer?.toolLifecycle({ event: "end", label, ok: !result.is_error });
|
|
151
174
|
toolResults.push(result);
|
|
175
|
+
// after-tool + on-file-change (C.19): fire once the action is done.
|
|
176
|
+
// Advisory by default (report, don't rewrite history); a hook explicitly
|
|
177
|
+
// marked blocking here throws and aborts the run.
|
|
178
|
+
await args.hooks?.fire("after-tool", ctx);
|
|
179
|
+
if (!result.is_error && FILE_MUTATING_TOOLS.has(call.name)) {
|
|
180
|
+
await args.hooks?.fire("on-file-change", ctx);
|
|
181
|
+
}
|
|
152
182
|
}
|
|
153
183
|
messages.push({ role: "user", content: toolResults });
|
|
154
184
|
}
|
|
@@ -192,6 +222,32 @@ function describeToolCall(call) {
|
|
|
192
222
|
* results rather than thrown exceptions, so the model can read the error and
|
|
193
223
|
* self-correct on the next turn.
|
|
194
224
|
*/
|
|
225
|
+
/**
|
|
226
|
+
* Fire `before-tool` hooks. Returns `null` to proceed, or a ready-made error
|
|
227
|
+
* {@link ToolResultBlock} when a blocking hook failed — the tool is then skipped
|
|
228
|
+
* (fail-closed) and the model is told, carrying the CRUXY_E_HOOK_FAILED code so
|
|
229
|
+
* the failure is greppable. A non-blocking (advisory) hook failure never reaches
|
|
230
|
+
* here — the runner reports it and resolves normally.
|
|
231
|
+
*/
|
|
232
|
+
async function fireBeforeTool(hooks, ctx, toolUseId) {
|
|
233
|
+
if (!hooks)
|
|
234
|
+
return null;
|
|
235
|
+
try {
|
|
236
|
+
await hooks.fire("before-tool", ctx);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const content = CruxyError.is(err)
|
|
241
|
+
? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
242
|
+
: `before-tool hook failed: ${err.message}`;
|
|
243
|
+
return {
|
|
244
|
+
type: "tool_result",
|
|
245
|
+
tool_use_id: toolUseId,
|
|
246
|
+
content,
|
|
247
|
+
is_error: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
195
251
|
async function runToolCall(call, registry, ctx) {
|
|
196
252
|
const tool = registry.get(call.name);
|
|
197
253
|
if (!tool) {
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
3
|
import type { StreamRenderer } from "../render/index.js";
|
|
4
|
+
import { type Router } from "../routing/index.js";
|
|
4
5
|
import type { ToolContext } from "../tools/index.js";
|
|
5
6
|
import type { ToolRegistry } from "../tools/index.js";
|
|
6
|
-
import { type AgentResult } from "./loop.js";
|
|
7
|
+
import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
7
8
|
/**
|
|
8
9
|
* Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
|
|
9
10
|
* the plan package. When plan mode is on, `send` delegates the whole turn to
|
|
@@ -35,6 +36,18 @@ export interface SessionArgs {
|
|
|
35
36
|
planMode?: boolean;
|
|
36
37
|
/** The plan-mode turn runner; required for plan mode to actually engage. */
|
|
37
38
|
planRunner?: PlanRunner;
|
|
39
|
+
/**
|
|
40
|
+
* Lifecycle hooks (C.19). When set, `before-run` fires before each turn (a
|
|
41
|
+
* blocking failure — including an untrusted project — aborts the turn) and
|
|
42
|
+
* `after-run` fires after; the same runner is threaded into the agent loop for
|
|
43
|
+
* `before-tool`/`after-tool`/`on-file-change`.
|
|
44
|
+
*/
|
|
45
|
+
hooks?: LifecycleHookRunner;
|
|
46
|
+
/**
|
|
47
|
+
* Multi-model routing (C.30). When set, main turns route on `main-turn` and
|
|
48
|
+
* context compaction on `summarize`; omitted → the provider default (unchanged).
|
|
49
|
+
*/
|
|
50
|
+
router?: Router;
|
|
38
51
|
}
|
|
39
52
|
/**
|
|
40
53
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -65,6 +78,9 @@ export declare class Session {
|
|
|
65
78
|
/** Mutable so `/plan` can toggle plan mode mid-session. */
|
|
66
79
|
private planMode;
|
|
67
80
|
constructor(args: SessionArgs);
|
|
81
|
+
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
82
|
+
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
83
|
+
get toolContext(): ToolContext;
|
|
68
84
|
/** Whether plan mode is currently on. */
|
|
69
85
|
getPlanMode(): boolean;
|
|
70
86
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadProjectInstructions } from "../config/index.js";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveTaskModel } from "../routing/index.js";
|
|
3
|
+
import { runAgent, } from "./loop.js";
|
|
3
4
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
4
5
|
/**
|
|
5
6
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -58,6 +59,11 @@ export class Session {
|
|
|
58
59
|
// state where the plan directive is injected but nothing orchestrates it).
|
|
59
60
|
this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
|
|
60
61
|
}
|
|
62
|
+
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
63
|
+
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
64
|
+
get toolContext() {
|
|
65
|
+
return this.args.ctx;
|
|
66
|
+
}
|
|
61
67
|
/** Whether plan mode is currently on. */
|
|
62
68
|
getPlanMode() {
|
|
63
69
|
return this.planMode;
|
|
@@ -82,6 +88,10 @@ export class Session {
|
|
|
82
88
|
this.messages.push({ role: "user", content: userPrompt });
|
|
83
89
|
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
84
90
|
await this.maybeCompact();
|
|
91
|
+
// before-run (C.19): a blocking pre-run hook — or an untrusted project's
|
|
92
|
+
// hooks — throws here and aborts the turn before the model is engaged
|
|
93
|
+
// (fail-closed). No-op when hooks are disabled or none are registered.
|
|
94
|
+
await this.args.hooks?.fire("before-run", this.args.ctx);
|
|
85
95
|
// Plan mode (C.31) delegates the whole turn to the injected runner: propose a
|
|
86
96
|
// plan, approve/revise, then execute step-by-step. Falls back to the normal
|
|
87
97
|
// single-shot loop when off or unwired, so existing behavior is untouched.
|
|
@@ -93,7 +103,8 @@ export class Session {
|
|
|
93
103
|
})
|
|
94
104
|
: await runAgent({
|
|
95
105
|
messages: this.messages,
|
|
96
|
-
...this.args,
|
|
106
|
+
...this.args, // carries `router` through to the loop
|
|
107
|
+
taskClass: "main-turn",
|
|
97
108
|
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
98
109
|
projectInstructions: this.projectInstructions,
|
|
99
110
|
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
@@ -102,6 +113,10 @@ export class Session {
|
|
|
102
113
|
this.messages = result.messages;
|
|
103
114
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
104
115
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
116
|
+
// after-run (C.19): advisory by default (a blocking after-run hook throws
|
|
117
|
+
// and surfaces at the boundary). The turn already completed and its history
|
|
118
|
+
// is adopted above — an advisory failure never rewrites it.
|
|
119
|
+
await this.args.hooks?.fire("after-run", this.args.ctx);
|
|
105
120
|
return result;
|
|
106
121
|
}
|
|
107
122
|
/**
|
|
@@ -213,9 +228,15 @@ export class Session {
|
|
|
213
228
|
const transcript = renderTranscript(prefix);
|
|
214
229
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
215
230
|
let text = "";
|
|
231
|
+
// Compaction is mechanical work — route it on `summarize` (C.30) when routing
|
|
232
|
+
// is active; omitted → the provider default, unchanged.
|
|
233
|
+
const routed = this.args.router
|
|
234
|
+
? resolveTaskModel(this.args.router, "summarize")
|
|
235
|
+
: null;
|
|
216
236
|
for await (const ev of this.args.provider.stream({
|
|
217
237
|
system: SUMMARY_SYSTEM,
|
|
218
238
|
messages: [{ role: "user", content: transcript }],
|
|
239
|
+
...(routed ? { model: routed.model } : {}),
|
|
219
240
|
})) {
|
|
220
241
|
switch (ev.type) {
|
|
221
242
|
case "text_delta":
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, } from "./voice.js";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cruxy brand voice (U.8) — a tiny living document, enforced by a test, not
|
|
3
|
+
* vibes. It centralizes the product name/tagline, the canonical lexicon, and the
|
|
4
|
+
* forbidden set (deprecated synonyms + the base-model gag) so every user-facing
|
|
5
|
+
* surface reads as one product. Presentation/copy only — nothing here changes
|
|
6
|
+
* behavior; the {@link scanForbidden} helper backs the lexicon test.
|
|
7
|
+
*
|
|
8
|
+
* Voice, in one breath: concise, direct, second-person for guidance,
|
|
9
|
+
* lowercase-leaning for status, no marketing fluff, no exclamation-spam. The
|
|
10
|
+
* product name is ALWAYS lowercase `cruxy`.
|
|
11
|
+
*/
|
|
12
|
+
/** The product name — always lowercase, in running text, banners, and help. */
|
|
13
|
+
export declare const PRODUCT_NAME = "cruxy";
|
|
14
|
+
/** The one-line tagline (mirrors package.json's description). */
|
|
15
|
+
export declare const PRODUCT_TAGLINE = "an agentic coding CLI";
|
|
16
|
+
/** The `cruxy --help` / banner masthead: `cruxy — an agentic coding CLI`. */
|
|
17
|
+
export declare const PRODUCT_MASTHEAD = "cruxy \u2014 an agentic coding CLI";
|
|
18
|
+
/**
|
|
19
|
+
* The ONLY model names cruxy ever shows a user. The upstream model powering a
|
|
20
|
+
* tier is never named — a hard rule asserted by the lexicon test.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MODEL_TIERS: readonly ["kavi", "vaani", "mira"];
|
|
23
|
+
/**
|
|
24
|
+
* The canonical term for each concept — the words every surface must use.
|
|
25
|
+
* Documented here so the lexicon is reviewable in one place; the forbidden
|
|
26
|
+
* synonyms below are what the test actually enforces.
|
|
27
|
+
*/
|
|
28
|
+
export declare const CANONICAL_TERMS: {
|
|
29
|
+
readonly product: "cruxy";
|
|
30
|
+
readonly backend: "gateway";
|
|
31
|
+
readonly extension: "skill";
|
|
32
|
+
readonly lifecycleHook: "hook";
|
|
33
|
+
readonly undoUnit: "checkpoint";
|
|
34
|
+
readonly isolation: "sandbox";
|
|
35
|
+
readonly secret: "API key";
|
|
36
|
+
readonly pullRequest: "pull request";
|
|
37
|
+
readonly permission: "approval";
|
|
38
|
+
};
|
|
39
|
+
/** One deprecated/off-voice term the copy must not use, and what to use instead. */
|
|
40
|
+
export interface ForbiddenTerm {
|
|
41
|
+
/** Matches the forbidden term (word-bounded, case-insensitive). */
|
|
42
|
+
pattern: RegExp;
|
|
43
|
+
/** The forbidden term, for the failure message. */
|
|
44
|
+
term: string;
|
|
45
|
+
/** The canonical term to use instead. */
|
|
46
|
+
use: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Deprecated synonyms banned from user-facing copy. Scoped to the curated
|
|
50
|
+
* surfaces the lexicon test scans (command descriptions, error output, brand +
|
|
51
|
+
* onboarding constants) — NOT a raw source grep — so legitimate internal uses
|
|
52
|
+
* (`mcpServers` config key, docker `.Server.Version`, code comments) never
|
|
53
|
+
* false-trip, while every string a user reads is covered.
|
|
54
|
+
*/
|
|
55
|
+
export declare const FORBIDDEN_TERMS: readonly ForbiddenTerm[];
|
|
56
|
+
/**
|
|
57
|
+
* Upstream model names — the base-model gag. None may appear in user-facing
|
|
58
|
+
* copy; only {@link MODEL_TIERS} ever do. `anthropic`/`openai` are deliberately
|
|
59
|
+
* absent: they are real bring-your-own-provider config values, not model names.
|
|
60
|
+
*/
|
|
61
|
+
export declare const FORBIDDEN_MODEL_NAMES: RegExp;
|
|
62
|
+
/** The neutral token an upstream model name is replaced with in user-facing copy. */
|
|
63
|
+
export declare const MODEL_NAME_PLACEHOLDER = "\u00ABmodel\u00BB";
|
|
64
|
+
/**
|
|
65
|
+
* Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
|
|
66
|
+
* string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
|
|
67
|
+
* run to a single token.
|
|
68
|
+
*
|
|
69
|
+
* The U.8 gag is structural *inside the process* (the routing package can't
|
|
70
|
+
* produce an upstream id — see `resolveModelId`), but a gateway/provider error
|
|
71
|
+
* body is an arbitrary external string that bypasses that guarantee. This is the
|
|
72
|
+
* scrub applied at the error-construction boundary so no upstream id reaches a
|
|
73
|
+
* user-facing `CruxyError.cause`.
|
|
74
|
+
*
|
|
75
|
+
* Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
|
|
76
|
+
* inside an unrelated word/path — so legitimate output (`/home/claude`, the
|
|
77
|
+
* `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
|
|
78
|
+
* Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
|
|
79
|
+
* provider config values survive too.
|
|
80
|
+
*/
|
|
81
|
+
export declare function scrubModelNames(text: string): string;
|
|
82
|
+
/** One lexicon violation found in a scanned string. */
|
|
83
|
+
export interface LexiconViolation {
|
|
84
|
+
/** The forbidden term (or "model-name" for a gag violation). */
|
|
85
|
+
term: string;
|
|
86
|
+
/** The canonical replacement, when the term is a deprecated synonym. */
|
|
87
|
+
use?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Scan one user-facing string for lexicon violations — deprecated synonyms and
|
|
91
|
+
* upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
|
|
92
|
+
* lexicon test runs it over the curated surfaces.
|
|
93
|
+
*/
|
|
94
|
+
export declare function scanForbidden(text: string): LexiconViolation[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cruxy brand voice (U.8) — a tiny living document, enforced by a test, not
|
|
3
|
+
* vibes. It centralizes the product name/tagline, the canonical lexicon, and the
|
|
4
|
+
* forbidden set (deprecated synonyms + the base-model gag) so every user-facing
|
|
5
|
+
* surface reads as one product. Presentation/copy only — nothing here changes
|
|
6
|
+
* behavior; the {@link scanForbidden} helper backs the lexicon test.
|
|
7
|
+
*
|
|
8
|
+
* Voice, in one breath: concise, direct, second-person for guidance,
|
|
9
|
+
* lowercase-leaning for status, no marketing fluff, no exclamation-spam. The
|
|
10
|
+
* product name is ALWAYS lowercase `cruxy`.
|
|
11
|
+
*/
|
|
12
|
+
/** The product name — always lowercase, in running text, banners, and help. */
|
|
13
|
+
export const PRODUCT_NAME = "cruxy";
|
|
14
|
+
/** The one-line tagline (mirrors package.json's description). */
|
|
15
|
+
export const PRODUCT_TAGLINE = "an agentic coding CLI";
|
|
16
|
+
/** The `cruxy --help` / banner masthead: `cruxy — an agentic coding CLI`. */
|
|
17
|
+
export const PRODUCT_MASTHEAD = `${PRODUCT_NAME} — ${PRODUCT_TAGLINE}`;
|
|
18
|
+
/**
|
|
19
|
+
* The ONLY model names cruxy ever shows a user. The upstream model powering a
|
|
20
|
+
* tier is never named — a hard rule asserted by the lexicon test.
|
|
21
|
+
*/
|
|
22
|
+
export const MODEL_TIERS = ["kavi", "vaani", "mira"];
|
|
23
|
+
/**
|
|
24
|
+
* The canonical term for each concept — the words every surface must use.
|
|
25
|
+
* Documented here so the lexicon is reviewable in one place; the forbidden
|
|
26
|
+
* synonyms below are what the test actually enforces.
|
|
27
|
+
*/
|
|
28
|
+
export const CANONICAL_TERMS = {
|
|
29
|
+
product: "cruxy",
|
|
30
|
+
backend: "gateway",
|
|
31
|
+
extension: "skill",
|
|
32
|
+
lifecycleHook: "hook",
|
|
33
|
+
undoUnit: "checkpoint",
|
|
34
|
+
isolation: "sandbox",
|
|
35
|
+
secret: "API key",
|
|
36
|
+
pullRequest: "pull request",
|
|
37
|
+
permission: "approval",
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Deprecated synonyms banned from user-facing copy. Scoped to the curated
|
|
41
|
+
* surfaces the lexicon test scans (command descriptions, error output, brand +
|
|
42
|
+
* onboarding constants) — NOT a raw source grep — so legitimate internal uses
|
|
43
|
+
* (`mcpServers` config key, docker `.Server.Version`, code comments) never
|
|
44
|
+
* false-trip, while every string a user reads is covered.
|
|
45
|
+
*/
|
|
46
|
+
export const FORBIDDEN_TERMS = [
|
|
47
|
+
{ pattern: /\bplug-?ins?\b/i, term: "plugin", use: "skill" },
|
|
48
|
+
{ pattern: /\btriggers?\b/i, term: "trigger", use: "hook" },
|
|
49
|
+
{ pattern: /\bservers?\b/i, term: "server", use: "gateway" },
|
|
50
|
+
{ pattern: /\bcruxy[- ]code\b/i, term: "cruxy-code", use: "cruxy" },
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Upstream model names — the base-model gag. None may appear in user-facing
|
|
54
|
+
* copy; only {@link MODEL_TIERS} ever do. `anthropic`/`openai` are deliberately
|
|
55
|
+
* absent: they are real bring-your-own-provider config values, not model names.
|
|
56
|
+
*/
|
|
57
|
+
export const FORBIDDEN_MODEL_NAMES = /\b(claude|sonnet|opus|haiku|gpt-?\d|gemini|llama|mistral)\b/i;
|
|
58
|
+
/** The neutral token an upstream model name is replaced with in user-facing copy. */
|
|
59
|
+
export const MODEL_NAME_PLACEHOLDER = "«model»";
|
|
60
|
+
/**
|
|
61
|
+
* A model-id-SHAPED run in free text: a self-identifying `gpt-4`/`gpt-4o`, or a
|
|
62
|
+
* model word carrying a hyphen/dot tail (`claude-sonnet-4-6`, `gemini-1.5-pro`).
|
|
63
|
+
* Deliberately GENEROUS — it also matches bare words and non-model hyphenated
|
|
64
|
+
* tokens; {@link isModelId} does the real filtering in the scrub callback, and
|
|
65
|
+
* matching the whole run (not each word) is what collapses `claude-sonnet-4-6`
|
|
66
|
+
* to ONE placeholder instead of `«model»-«model»-4-6`. The leading
|
|
67
|
+
* `(?<![/\\\w])` stops it firing inside a longer word (`gptools`) or on a path
|
|
68
|
+
* segment (`/home/claude/…`) — both of which the bare `\b` form corrupts.
|
|
69
|
+
*/
|
|
70
|
+
const MODEL_ID_CANDIDATE = /(?<![/\\\w])(?:gpt-?\d[a-z0-9]*(?:[-.][a-z0-9]+)*|(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)(?:[-.][a-z0-9]+)*)/gi;
|
|
71
|
+
/** The bare model words, for the digitless-compound test in {@link isModelId}. */
|
|
72
|
+
const MODEL_WORD = /(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)/gi;
|
|
73
|
+
/**
|
|
74
|
+
* Is a {@link MODEL_ID_CANDIDATE} run an actual upstream model reference (scrub),
|
|
75
|
+
* or a legitimate token that merely contains a model word (keep)? A reference is:
|
|
76
|
+
* a self-identifying `gpt-<n>`; OR a hyphen/dot run carrying a version digit
|
|
77
|
+
* (`claude-sonnet-4-6`, `gemini-1.5-pro`); OR a digitless compound of ≥2 distinct
|
|
78
|
+
* model words (`claude-sonnet`). A bare word (`opus`, `haiku`), a path segment
|
|
79
|
+
* (`/home/claude`), or a plain hyphenated token (`claude-bot`) is none of these,
|
|
80
|
+
* so it survives verbatim — the gag scrubs model IDS, never legitimate output.
|
|
81
|
+
*/
|
|
82
|
+
function isModelId(run) {
|
|
83
|
+
if (/^gpt-?\d/i.test(run))
|
|
84
|
+
return true; // gpt-4, gpt4, gpt-4o
|
|
85
|
+
if (!/[-.]/.test(run))
|
|
86
|
+
return false; // bare word — never a model reference here
|
|
87
|
+
if (/\d/.test(run))
|
|
88
|
+
return true; // version digit ⇒ an id
|
|
89
|
+
const words = run.match(MODEL_WORD) ?? [];
|
|
90
|
+
return new Set(words.map((w) => w.toLowerCase())).size >= 2; // claude-sonnet
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
|
|
94
|
+
* string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
|
|
95
|
+
* run to a single token.
|
|
96
|
+
*
|
|
97
|
+
* The U.8 gag is structural *inside the process* (the routing package can't
|
|
98
|
+
* produce an upstream id — see `resolveModelId`), but a gateway/provider error
|
|
99
|
+
* body is an arbitrary external string that bypasses that guarantee. This is the
|
|
100
|
+
* scrub applied at the error-construction boundary so no upstream id reaches a
|
|
101
|
+
* user-facing `CruxyError.cause`.
|
|
102
|
+
*
|
|
103
|
+
* Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
|
|
104
|
+
* inside an unrelated word/path — so legitimate output (`/home/claude`, the
|
|
105
|
+
* `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
|
|
106
|
+
* Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
|
|
107
|
+
* provider config values survive too.
|
|
108
|
+
*/
|
|
109
|
+
export function scrubModelNames(text) {
|
|
110
|
+
return text.replace(MODEL_ID_CANDIDATE, (run) => isModelId(run) ? MODEL_NAME_PLACEHOLDER : run);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Scan one user-facing string for lexicon violations — deprecated synonyms and
|
|
114
|
+
* upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
|
|
115
|
+
* lexicon test runs it over the curated surfaces.
|
|
116
|
+
*/
|
|
117
|
+
export function scanForbidden(text) {
|
|
118
|
+
const violations = [];
|
|
119
|
+
for (const { pattern, term, use } of FORBIDDEN_TERMS) {
|
|
120
|
+
if (pattern.test(text))
|
|
121
|
+
violations.push({ term, use });
|
|
122
|
+
}
|
|
123
|
+
const model = text.match(FORBIDDEN_MODEL_NAMES);
|
|
124
|
+
if (model)
|
|
125
|
+
violations.push({ term: `model-name "${model[0]}"` });
|
|
126
|
+
return violations;
|
|
127
|
+
}
|
|
@@ -10,7 +10,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
10
10
|
* this command only lists.
|
|
11
11
|
*/
|
|
12
12
|
export function checkpointCommand() {
|
|
13
|
-
const cmd = new Command("checkpoint").description("working-tree checkpoints
|
|
13
|
+
const cmd = new Command("checkpoint").description("inspect working-tree checkpoints (the undo units behind rollback)");
|
|
14
14
|
cmd
|
|
15
15
|
.command("list")
|
|
16
16
|
.description("list saved checkpoints, newest first")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy hooks` — inspect and trust hooks + custom slash commands (C.19).
|
|
4
|
+
* `list` shows the resolved catalog (including which project hooks are trusted
|
|
5
|
+
* and any malformed definitions); `trust <path>` records the explicit,
|
|
6
|
+
* reviewed decision to run a project's hooks, bound to their current fingerprint.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hooksCommand(): Command;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { shouldUseColor } from "../../errors/index.js";
|
|
5
|
+
import { themeForColor } from "../../theme/index.js";
|
|
6
|
+
import { defaultHookSources, fileTrustStore, fingerprintHooks, isTrusted, loadHookCatalog, } from "../../hooks/index.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
/**
|
|
9
|
+
* `cruxy hooks` — inspect and trust hooks + custom slash commands (C.19).
|
|
10
|
+
* `list` shows the resolved catalog (including which project hooks are trusted
|
|
11
|
+
* and any malformed definitions); `trust <path>` records the explicit,
|
|
12
|
+
* reviewed decision to run a project's hooks, bound to their current fingerprint.
|
|
13
|
+
*/
|
|
14
|
+
export function hooksCommand() {
|
|
15
|
+
const cmd = new Command("hooks").description("inspect and trust hooks and custom slash commands");
|
|
16
|
+
cmd
|
|
17
|
+
.command("list", { isDefault: true })
|
|
18
|
+
.description("list configured hooks, custom commands, and load errors")
|
|
19
|
+
.action(async () => {
|
|
20
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
21
|
+
const { config } = loadConfig();
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
const catalog = await loadHookCatalog(defaultHookSources(cwd));
|
|
24
|
+
logger.print(`${t.strong("hooks:")} ${config.hooks.enabled ? t.success("enabled") : t.warning("disabled (hooks.enabled = false)")}`);
|
|
25
|
+
const projectHooks = catalog.hooks.filter((h) => h.source === "project");
|
|
26
|
+
if (projectHooks.length > 0) {
|
|
27
|
+
const trusted = isTrusted(fileTrustStore(), cwd, fingerprintHooks(projectHooks));
|
|
28
|
+
logger.print(`${t.strong("project trust:")} ${trusted ? t.success("trusted") : t.danger("NOT trusted — run `cruxy hooks trust .`")}`);
|
|
29
|
+
}
|
|
30
|
+
printHooks(catalog.hooks, t);
|
|
31
|
+
printCommands(catalog.commands, t);
|
|
32
|
+
printErrors(catalog.errors, t);
|
|
33
|
+
});
|
|
34
|
+
cmd
|
|
35
|
+
.command("trust <path>")
|
|
36
|
+
.description("trust a project's hooks after reviewing them (records the decision)")
|
|
37
|
+
.action(async (target) => {
|
|
38
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
39
|
+
const root = path.resolve(target);
|
|
40
|
+
const catalog = await loadHookCatalog(defaultHookSources(root));
|
|
41
|
+
const projectHooks = catalog.hooks.filter((h) => h.source === "project");
|
|
42
|
+
if (projectHooks.length === 0) {
|
|
43
|
+
logger.print(t.muted(`no project hooks found under ${root}`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
logger.print(t.strong(`trusting ${projectHooks.length} project hook(s):`));
|
|
47
|
+
printHooks(projectHooks, t);
|
|
48
|
+
fileTrustStore().record({
|
|
49
|
+
root,
|
|
50
|
+
fingerprint: fingerprintHooks(projectHooks),
|
|
51
|
+
at: new Date().toISOString(),
|
|
52
|
+
});
|
|
53
|
+
logger.print(`${t.success("trusted")} — these hooks may now run for ${root}. ` +
|
|
54
|
+
t.muted("changing any of them will require re-trusting."));
|
|
55
|
+
});
|
|
56
|
+
return cmd;
|
|
57
|
+
}
|
|
58
|
+
function printHooks(hooks, t) {
|
|
59
|
+
if (hooks.length === 0) {
|
|
60
|
+
logger.print(t.muted(" no hooks configured"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const h of hooks) {
|
|
64
|
+
const policy = h.blocking ? t.danger("blocking") : t.muted("advisory");
|
|
65
|
+
logger.print(` ${t.muted(`[${h.source}]`)} ${t.strong(h.name)} ${t.muted(h.event)} ${policy}\n ${t.muted("$")} ${h.command}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function printCommands(commands, t) {
|
|
69
|
+
if (commands.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
logger.print(`\n${t.heading("custom slash commands:")}`);
|
|
72
|
+
for (const c of commands) {
|
|
73
|
+
logger.print(` ${t.muted(`[${c.source}]`)} ${t.strong(`/${c.name}`)} ${t.muted(`(${c.kind})`)} — ${c.description}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function printErrors(errors, t) {
|
|
77
|
+
if (errors.length === 0)
|
|
78
|
+
return;
|
|
79
|
+
logger.print(`\n${t.danger(t.heading("malformed (excluded, never run):"))}`);
|
|
80
|
+
for (const e of errors) {
|
|
81
|
+
logger.print(` ${t.muted(`[${e.source}]`)} ${t.strong(e.name)} — ${e.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -12,7 +12,7 @@ import { runFirstWinTask } from "../onboard.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export function initCommand() {
|
|
14
14
|
return new Command("init")
|
|
15
|
-
.description("set up cruxy in this project (key
|
|
15
|
+
.description("set up cruxy in this project (API key, CRUXY.md, and a first run)")
|
|
16
16
|
.action(async () => {
|
|
17
17
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
18
18
|
if (!process.stdin.isTTY) {
|
package/dist/cli/commands/pr.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
|
5
5
|
import { authMissingKey, shouldUseColor } from "../../errors/index.js";
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { ApprovalService } from "../../approval/index.js";
|
|
8
|
+
import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
|
|
8
9
|
import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
|
|
9
10
|
/**
|
|
10
11
|
* `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
|
|
@@ -14,7 +15,7 @@ import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidan
|
|
|
14
15
|
*/
|
|
15
16
|
export function prCommand() {
|
|
16
17
|
return new Command("pr")
|
|
17
|
-
.description("open a pull request from the current changes (generated
|
|
18
|
+
.description("open a pull request from the current changes (generated and gated)")
|
|
18
19
|
.option("-b, --base <branch>", "base branch to merge into")
|
|
19
20
|
.option("-t, --title <title>", "PR title (conventional-commit subject)")
|
|
20
21
|
.option("--body <body>", "PR body")
|
|
@@ -37,6 +38,13 @@ export function prCommand() {
|
|
|
37
38
|
temperature: config.model.temperature,
|
|
38
39
|
gatewayUrl: config.cruxy.gatewayUrl,
|
|
39
40
|
});
|
|
41
|
+
// Multi-model routing (C.30): PR/commit text is `commit-msg` work. Null
|
|
42
|
+
// unless routing is configured → the provider default, unchanged. A
|
|
43
|
+
// misrouted tier fails loud here before any request.
|
|
44
|
+
const router = routerForConfig(config);
|
|
45
|
+
const genModel = router
|
|
46
|
+
? resolveTaskModel(router, "commit-msg").model
|
|
47
|
+
: undefined;
|
|
40
48
|
// resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
|
|
41
49
|
const forge = createForgeProvider(resolveForgeToken());
|
|
42
50
|
const guidance = await loadCommitGuidance(cwd);
|
|
@@ -53,7 +61,7 @@ export function prCommand() {
|
|
|
53
61
|
...i,
|
|
54
62
|
scopes: guidance.scopes,
|
|
55
63
|
skillBody: guidance.skillBody,
|
|
56
|
-
}),
|
|
64
|
+
}, { model: genModel }),
|
|
57
65
|
});
|
|
58
66
|
logger.info(t.muted("generating pull request content…"));
|
|
59
67
|
const outcome = await service.openPullRequest({
|
|
@@ -50,7 +50,7 @@ async function pickCheckpoint(service) {
|
|
|
50
50
|
*/
|
|
51
51
|
export function rollbackCommand() {
|
|
52
52
|
return new Command("rollback")
|
|
53
|
-
.description("restore the working tree to a checkpoint, undoing
|
|
53
|
+
.description("restore the working tree to a checkpoint, undoing a run's file changes")
|
|
54
54
|
.argument("[id]", "checkpoint id (defaults to the most recent)")
|
|
55
55
|
.action(async (id) => {
|
|
56
56
|
const interactive = Boolean(process.stdin.isTTY);
|