@cruxy/cli 0.13.0 → 0.16.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 +14 -0
- package/dist/agent/loop.js +47 -1
- package/dist/agent/session.d.ts +11 -1
- package/dist/agent/session.js +14 -1
- package/dist/approval/prompt.js +17 -3
- package/dist/brand/index.d.ts +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/voice.d.ts +74 -0
- package/dist/brand/voice.js +73 -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 +1 -1
- 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 +4 -2
- package/dist/components/fuzzy.js +7 -1
- package/dist/config/schema.d.ts +81 -30
- package/dist/config/schema.js +22 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +9 -0
- package/dist/errors/constructors.d.ts +16 -0
- package/dist/errors/constructors.js +57 -0
- package/dist/errors/types.d.ts +11 -0
- package/dist/errors/types.js +19 -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 +5 -2
- package/dist/render/capabilities.d.ts +10 -2
- package/dist/render/capabilities.js +26 -6
- package/dist/render/index.d.ts +9 -5
- package/dist/render/index.js +12 -5
- package/dist/render/plain-renderer.d.ts +3 -3
- package/dist/render/plain-renderer.js +10 -2
- package/dist/render/screen-reader-renderer.d.ts +45 -0
- package/dist/render/screen-reader-renderer.js +75 -0
- package/dist/render/types.d.ts +15 -1
- package/dist/theme/resolve.d.ts +18 -7
- package/dist/theme/resolve.js +32 -10
- package/dist/theme/tokens.d.ts +16 -1
- package/dist/theme/tokens.js +27 -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/package.json +1 -1
package/dist/agent/loop.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
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";
|
|
4
5
|
import type { ToolContext } from "../tools/index.js";
|
|
5
6
|
import { ToolRegistry } from "../tools/index.js";
|
|
7
|
+
/** The lifecycle-hook firing seam (C.19). Structural so the loop stays
|
|
8
|
+
* decoupled from the concrete `HookRunner`. `fire` resolves when hooks pass (or
|
|
9
|
+
* advisory ones fail) and throws `CRUXY_E_HOOK_FAILED` on a blocking failure. */
|
|
10
|
+
export interface LifecycleHookRunner {
|
|
11
|
+
fire(event: HookEvent, ctx: ToolContext): Promise<void>;
|
|
12
|
+
}
|
|
6
13
|
export interface RunAgentArgs {
|
|
7
14
|
/**
|
|
8
15
|
* The full running conversation. The caller owns history and must append the
|
|
@@ -43,6 +50,13 @@ export interface RunAgentArgs {
|
|
|
43
50
|
* histories stay coherent — overshoot is bounded by one turn.
|
|
44
51
|
*/
|
|
45
52
|
budget?: LoopBudget;
|
|
53
|
+
/**
|
|
54
|
+
* Lifecycle hooks (C.19). When set, `before-tool` fires before each tool call
|
|
55
|
+
* (a blocking failure fails the call closed — the tool does NOT run), and
|
|
56
|
+
* `after-tool` / `on-file-change` fire after. Omitted for subagents and the
|
|
57
|
+
* no-hooks path, so their tools never fire hooks.
|
|
58
|
+
*/
|
|
59
|
+
hooks?: LifecycleHookRunner;
|
|
46
60
|
}
|
|
47
61
|
/**
|
|
48
62
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
package/dist/agent/loop.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { providerUnsupported } from "../errors/index.js";
|
|
1
|
+
import { CruxyError, providerUnsupported } from "../errors/index.js";
|
|
2
2
|
import { buildSystemPrompt } from "./prompts.js";
|
|
3
|
+
/** Tools whose successful call is a file change (drives the `on-file-change`
|
|
4
|
+
* hook). Kept in sync with the file-mutating tool set. */
|
|
5
|
+
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch"]);
|
|
3
6
|
/**
|
|
4
7
|
* Drive the model/tool loop over an existing conversation. Streams each turn,
|
|
5
8
|
* renders assistant text, reassembles tool calls, executes them, feeds the
|
|
@@ -146,9 +149,26 @@ async function driveLoop(args, renderer) {
|
|
|
146
149
|
// long calls), end commits the ✓/✗ trail note. Same information as the
|
|
147
150
|
// old status/note pair, now typed and duration-aware.
|
|
148
151
|
renderer?.toolLifecycle({ event: "start", label });
|
|
152
|
+
// before-tool (C.19): a blocking pre-check that fails aborts THIS tool
|
|
153
|
+
// fail-closed — the tool never runs; the model is told via an error
|
|
154
|
+
// result. The hook command itself went through the U.3 gate + C.16 sandbox
|
|
155
|
+
// (same path as run_command), so a hook is never an approval bypass.
|
|
156
|
+
const blocked = await fireBeforeTool(args.hooks, ctx, call.id);
|
|
157
|
+
if (blocked) {
|
|
158
|
+
renderer?.toolLifecycle({ event: "end", label, ok: false });
|
|
159
|
+
toolResults.push(blocked);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
149
162
|
const result = await runToolCall(call, registry, ctx);
|
|
150
163
|
renderer?.toolLifecycle({ event: "end", label, ok: !result.is_error });
|
|
151
164
|
toolResults.push(result);
|
|
165
|
+
// after-tool + on-file-change (C.19): fire once the action is done.
|
|
166
|
+
// Advisory by default (report, don't rewrite history); a hook explicitly
|
|
167
|
+
// marked blocking here throws and aborts the run.
|
|
168
|
+
await args.hooks?.fire("after-tool", ctx);
|
|
169
|
+
if (!result.is_error && FILE_MUTATING_TOOLS.has(call.name)) {
|
|
170
|
+
await args.hooks?.fire("on-file-change", ctx);
|
|
171
|
+
}
|
|
152
172
|
}
|
|
153
173
|
messages.push({ role: "user", content: toolResults });
|
|
154
174
|
}
|
|
@@ -192,6 +212,32 @@ function describeToolCall(call) {
|
|
|
192
212
|
* results rather than thrown exceptions, so the model can read the error and
|
|
193
213
|
* self-correct on the next turn.
|
|
194
214
|
*/
|
|
215
|
+
/**
|
|
216
|
+
* Fire `before-tool` hooks. Returns `null` to proceed, or a ready-made error
|
|
217
|
+
* {@link ToolResultBlock} when a blocking hook failed — the tool is then skipped
|
|
218
|
+
* (fail-closed) and the model is told, carrying the CRUXY_E_HOOK_FAILED code so
|
|
219
|
+
* the failure is greppable. A non-blocking (advisory) hook failure never reaches
|
|
220
|
+
* here — the runner reports it and resolves normally.
|
|
221
|
+
*/
|
|
222
|
+
async function fireBeforeTool(hooks, ctx, toolUseId) {
|
|
223
|
+
if (!hooks)
|
|
224
|
+
return null;
|
|
225
|
+
try {
|
|
226
|
+
await hooks.fire("before-tool", ctx);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
const content = CruxyError.is(err)
|
|
231
|
+
? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
232
|
+
: `before-tool hook failed: ${err.message}`;
|
|
233
|
+
return {
|
|
234
|
+
type: "tool_result",
|
|
235
|
+
tool_use_id: toolUseId,
|
|
236
|
+
content,
|
|
237
|
+
is_error: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
195
241
|
async function runToolCall(call, registry, ctx) {
|
|
196
242
|
const tool = registry.get(call.name);
|
|
197
243
|
if (!tool) {
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CruxyConfig } from "../config/index.js";
|
|
|
3
3
|
import type { StreamRenderer } from "../render/index.js";
|
|
4
4
|
import type { ToolContext } from "../tools/index.js";
|
|
5
5
|
import type { ToolRegistry } from "../tools/index.js";
|
|
6
|
-
import { type AgentResult } from "./loop.js";
|
|
6
|
+
import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
7
7
|
/**
|
|
8
8
|
* Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
|
|
9
9
|
* the plan package. When plan mode is on, `send` delegates the whole turn to
|
|
@@ -35,6 +35,13 @@ export interface SessionArgs {
|
|
|
35
35
|
planMode?: boolean;
|
|
36
36
|
/** The plan-mode turn runner; required for plan mode to actually engage. */
|
|
37
37
|
planRunner?: PlanRunner;
|
|
38
|
+
/**
|
|
39
|
+
* Lifecycle hooks (C.19). When set, `before-run` fires before each turn (a
|
|
40
|
+
* blocking failure — including an untrusted project — aborts the turn) and
|
|
41
|
+
* `after-run` fires after; the same runner is threaded into the agent loop for
|
|
42
|
+
* `before-tool`/`after-tool`/`on-file-change`.
|
|
43
|
+
*/
|
|
44
|
+
hooks?: LifecycleHookRunner;
|
|
38
45
|
}
|
|
39
46
|
/**
|
|
40
47
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -65,6 +72,9 @@ export declare class Session {
|
|
|
65
72
|
/** Mutable so `/plan` can toggle plan mode mid-session. */
|
|
66
73
|
private planMode;
|
|
67
74
|
constructor(args: SessionArgs);
|
|
75
|
+
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
76
|
+
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
77
|
+
get toolContext(): ToolContext;
|
|
68
78
|
/** Whether plan mode is currently on. */
|
|
69
79
|
getPlanMode(): boolean;
|
|
70
80
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadProjectInstructions } from "../config/index.js";
|
|
2
|
-
import { runAgent } from "./loop.js";
|
|
2
|
+
import { runAgent, } from "./loop.js";
|
|
3
3
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
4
4
|
/**
|
|
5
5
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -58,6 +58,11 @@ export class Session {
|
|
|
58
58
|
// state where the plan directive is injected but nothing orchestrates it).
|
|
59
59
|
this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
|
|
60
60
|
}
|
|
61
|
+
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
62
|
+
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
63
|
+
get toolContext() {
|
|
64
|
+
return this.args.ctx;
|
|
65
|
+
}
|
|
61
66
|
/** Whether plan mode is currently on. */
|
|
62
67
|
getPlanMode() {
|
|
63
68
|
return this.planMode;
|
|
@@ -82,6 +87,10 @@ export class Session {
|
|
|
82
87
|
this.messages.push({ role: "user", content: userPrompt });
|
|
83
88
|
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
84
89
|
await this.maybeCompact();
|
|
90
|
+
// before-run (C.19): a blocking pre-run hook — or an untrusted project's
|
|
91
|
+
// hooks — throws here and aborts the turn before the model is engaged
|
|
92
|
+
// (fail-closed). No-op when hooks are disabled or none are registered.
|
|
93
|
+
await this.args.hooks?.fire("before-run", this.args.ctx);
|
|
85
94
|
// Plan mode (C.31) delegates the whole turn to the injected runner: propose a
|
|
86
95
|
// plan, approve/revise, then execute step-by-step. Falls back to the normal
|
|
87
96
|
// single-shot loop when off or unwired, so existing behavior is untouched.
|
|
@@ -102,6 +111,10 @@ export class Session {
|
|
|
102
111
|
this.messages = result.messages;
|
|
103
112
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
104
113
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
114
|
+
// after-run (C.19): advisory by default (a blocking after-run hook throws
|
|
115
|
+
// and surfaces at the boundary). The turn already completed and its history
|
|
116
|
+
// is adopted above — an advisory failure never rewrites it.
|
|
117
|
+
await this.args.hooks?.fire("after-run", this.args.ctx);
|
|
105
118
|
return result;
|
|
106
119
|
}
|
|
107
120
|
/**
|
package/dist/approval/prompt.js
CHANGED
|
@@ -37,16 +37,30 @@ export async function promptForApproval(request, io) {
|
|
|
37
37
|
export function render(request, color) {
|
|
38
38
|
const t = themeForColor(color);
|
|
39
39
|
const destructive = request.tier === "destructive";
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// Risk survives all three degradations (U.11): the mark carries it by *shape*
|
|
41
|
+
// (`!` vs `?`) for NO_COLOR, and the label carries it by *word*
|
|
42
|
+
// (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone, and legible
|
|
43
|
+
// to a screen reader that would otherwise read `!` as "exclamation mark".
|
|
42
44
|
const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
|
|
43
|
-
const label =
|
|
45
|
+
const label = tierLabel(request.tier, t);
|
|
44
46
|
const lines = [];
|
|
45
47
|
lines.push(`${mark} cruxy wants to ${t.strong(request.summary)}${label}`);
|
|
46
48
|
lines.push(detail(request, t));
|
|
47
49
|
lines.push(choices(request.scope, t));
|
|
48
50
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
49
51
|
}
|
|
52
|
+
/** The worded risk tag, colored by tier — always present, so meaning never
|
|
53
|
+
* rides on the `!`/`?` shape or its color alone. */
|
|
54
|
+
function tierLabel(tier, t) {
|
|
55
|
+
switch (tier) {
|
|
56
|
+
case "destructive":
|
|
57
|
+
return t.danger(t.strong(" (destructive)"));
|
|
58
|
+
case "mutate":
|
|
59
|
+
return t.warning(" (mutate)");
|
|
60
|
+
case "read":
|
|
61
|
+
return t.muted(" (read)");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
50
64
|
/** The action detail: a diff for file actions, the command + cwd for shell/test. */
|
|
51
65
|
function detail(request, t) {
|
|
52
66
|
if (request.action.kind === "shell" || request.action.kind === "test") {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, } from "./voice.js";
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
/** One lexicon violation found in a scanned string. */
|
|
63
|
+
export interface LexiconViolation {
|
|
64
|
+
/** The forbidden term (or "model-name" for a gag violation). */
|
|
65
|
+
term: string;
|
|
66
|
+
/** The canonical replacement, when the term is a deprecated synonym. */
|
|
67
|
+
use?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Scan one user-facing string for lexicon violations — deprecated synonyms and
|
|
71
|
+
* upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
|
|
72
|
+
* lexicon test runs it over the curated surfaces.
|
|
73
|
+
*/
|
|
74
|
+
export declare function scanForbidden(text: string): LexiconViolation[];
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
/**
|
|
59
|
+
* Scan one user-facing string for lexicon violations — deprecated synonyms and
|
|
60
|
+
* upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
|
|
61
|
+
* lexicon test runs it over the curated surfaces.
|
|
62
|
+
*/
|
|
63
|
+
export function scanForbidden(text) {
|
|
64
|
+
const violations = [];
|
|
65
|
+
for (const { pattern, term, use } of FORBIDDEN_TERMS) {
|
|
66
|
+
if (pattern.test(text))
|
|
67
|
+
violations.push({ term, use });
|
|
68
|
+
}
|
|
69
|
+
const model = text.match(FORBIDDEN_MODEL_NAMES);
|
|
70
|
+
if (model)
|
|
71
|
+
violations.push({ term: `model-name "${model[0]}"` });
|
|
72
|
+
return violations;
|
|
73
|
+
}
|
|
@@ -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
|
@@ -14,7 +14,7 @@ import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidan
|
|
|
14
14
|
*/
|
|
15
15
|
export function prCommand() {
|
|
16
16
|
return new Command("pr")
|
|
17
|
-
.description("open a pull request from the current changes (generated
|
|
17
|
+
.description("open a pull request from the current changes (generated and gated)")
|
|
18
18
|
.option("-b, --base <branch>", "base branch to merge into")
|
|
19
19
|
.option("-t, --title <title>", "PR title (conventional-commit subject)")
|
|
20
20
|
.option("--body <body>", "PR body")
|
|
@@ -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);
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -6,12 +6,13 @@ import { createRenderer } from "../../render/index.js";
|
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { CheckpointService } from "../../checkpoint/index.js";
|
|
8
8
|
import { SandboxService } from "../../sandbox/index.js";
|
|
9
|
+
import { buildHooksService } from "../../hooks/index.js";
|
|
9
10
|
import { runInteractive } from "../repl.js";
|
|
10
11
|
import { buildAgentSession } from "../session-factory.js";
|
|
11
12
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
12
13
|
export function runCommand() {
|
|
13
14
|
return new Command("run")
|
|
14
|
-
.description("run
|
|
15
|
+
.description("run a task once, or start an interactive session")
|
|
15
16
|
.argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
|
|
16
17
|
.option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
|
|
17
18
|
.option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
|
|
@@ -79,9 +80,18 @@ export function runCommand() {
|
|
|
79
80
|
if (sandbox) {
|
|
80
81
|
logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
+
// Hooks + custom slash commands (C.19). Built once per run: loads the
|
|
84
|
+
// layered catalog and yields the lifecycle runner (threaded into the
|
|
85
|
+
// session) + the resolved custom slash commands (given to the REPL).
|
|
86
|
+
const hooksService = await buildHooksService({
|
|
87
|
+
cwd: process.cwd(),
|
|
88
|
+
config,
|
|
89
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
90
|
+
logger,
|
|
91
|
+
});
|
|
92
|
+
const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner);
|
|
83
93
|
if (interactive) {
|
|
84
|
-
await runInteractive(session, undefined, renderer, checkpoints);
|
|
94
|
+
await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
|
|
85
95
|
return;
|
|
86
96
|
}
|
|
87
97
|
checkpoints?.beginRun(prompt);
|
|
@@ -33,9 +33,9 @@ export function skillsCommand() {
|
|
|
33
33
|
resetSkillServices();
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
-
logger.print(`\n${t.
|
|
36
|
+
logger.print(`\n${t.heading("sources")} ${t.muted("(precedence, high to low)")}`);
|
|
37
37
|
for (const { source, dir } of status.sources) {
|
|
38
|
-
logger.print(` ${
|
|
38
|
+
logger.print(` ${t.kv(source, t.muted(dir), 8)}`);
|
|
39
39
|
}
|
|
40
40
|
logger.print("");
|
|
41
41
|
if (status.errors.length === 0) {
|
package/dist/cli/program.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { APP_NAME, APP_VERSION
|
|
2
|
+
import { APP_NAME, APP_VERSION } from "../constants.js";
|
|
3
|
+
import { PRODUCT_MASTHEAD } from "../brand/index.js";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
4
5
|
import { shouldUseColor, usageError } from "../errors/index.js";
|
|
5
6
|
import { themeForColor } from "../theme/index.js";
|
|
@@ -13,13 +14,14 @@ import { initCommand } from "./commands/init.js";
|
|
|
13
14
|
import { checkpointCommand } from "./commands/checkpoint.js";
|
|
14
15
|
import { rollbackCommand } from "./commands/rollback.js";
|
|
15
16
|
import { testCommand } from "./commands/test.js";
|
|
17
|
+
import { hooksCommand } from "./commands/hooks.js";
|
|
16
18
|
import { loadConfig } from "../config/index.js";
|
|
17
19
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
18
20
|
export function buildProgram() {
|
|
19
21
|
const program = new Command();
|
|
20
22
|
program
|
|
21
23
|
.name(APP_NAME)
|
|
22
|
-
.description(
|
|
24
|
+
.description(PRODUCT_MASTHEAD)
|
|
23
25
|
.version(APP_VERSION, "-v, --version", "print the cruxy version")
|
|
24
26
|
.option("-c, --config <path>", "use a specific config file")
|
|
25
27
|
.option("--log-level <level>", "debug | info | warn | error | silent")
|
|
@@ -42,6 +44,7 @@ export function buildProgram() {
|
|
|
42
44
|
program.addCommand(checkpointCommand());
|
|
43
45
|
program.addCommand(rollbackCommand());
|
|
44
46
|
program.addCommand(testCommand());
|
|
47
|
+
program.addCommand(hooksCommand());
|
|
45
48
|
// Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
|
|
46
49
|
// means an unknown command (Commander runs the default action with it as an
|
|
47
50
|
// operand rather than erroring), so reject it as a usage error.
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
2
|
import type { Session } from "../agent/index.js";
|
|
3
3
|
import type { CheckpointService } from "../checkpoint/index.js";
|
|
4
|
+
import { type SlashCommandSpec } from "../hooks/index.js";
|
|
4
5
|
import { type StreamRenderer } from "../render/index.js";
|
|
5
6
|
/**
|
|
6
7
|
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
@@ -23,4 +24,4 @@ export interface ReplIO {
|
|
|
23
24
|
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
24
25
|
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
25
26
|
*/
|
|
26
|
-
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService): Promise<void>;
|
|
27
|
+
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
|