@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.
Files changed (64) hide show
  1. package/dist/agent/loop.d.ts +14 -0
  2. package/dist/agent/loop.js +47 -1
  3. package/dist/agent/session.d.ts +11 -1
  4. package/dist/agent/session.js +14 -1
  5. package/dist/approval/prompt.js +17 -3
  6. package/dist/brand/index.d.ts +1 -0
  7. package/dist/brand/index.js +1 -0
  8. package/dist/brand/voice.d.ts +74 -0
  9. package/dist/brand/voice.js +73 -0
  10. package/dist/cli/commands/checkpoint.js +1 -1
  11. package/dist/cli/commands/hooks.d.ts +8 -0
  12. package/dist/cli/commands/hooks.js +83 -0
  13. package/dist/cli/commands/init.js +1 -1
  14. package/dist/cli/commands/pr.js +1 -1
  15. package/dist/cli/commands/rollback.js +1 -1
  16. package/dist/cli/commands/run.js +13 -3
  17. package/dist/cli/commands/skills.js +2 -2
  18. package/dist/cli/program.js +5 -2
  19. package/dist/cli/repl.d.ts +2 -1
  20. package/dist/cli/repl.js +54 -3
  21. package/dist/cli/session-factory.d.ts +2 -2
  22. package/dist/cli/session-factory.js +4 -2
  23. package/dist/components/fuzzy.js +7 -1
  24. package/dist/config/schema.d.ts +81 -30
  25. package/dist/config/schema.js +22 -0
  26. package/dist/constants.d.ts +9 -0
  27. package/dist/constants.js +9 -0
  28. package/dist/errors/constructors.d.ts +16 -0
  29. package/dist/errors/constructors.js +57 -0
  30. package/dist/errors/types.d.ts +11 -0
  31. package/dist/errors/types.js +19 -0
  32. package/dist/hooks/config.d.ts +21 -0
  33. package/dist/hooks/config.js +253 -0
  34. package/dist/hooks/index.d.ts +6 -0
  35. package/dist/hooks/index.js +6 -0
  36. package/dist/hooks/runner.d.ts +76 -0
  37. package/dist/hooks/runner.js +114 -0
  38. package/dist/hooks/service.d.ts +38 -0
  39. package/dist/hooks/service.js +49 -0
  40. package/dist/hooks/slash.d.ts +48 -0
  41. package/dist/hooks/slash.js +58 -0
  42. package/dist/hooks/trust.d.ts +46 -0
  43. package/dist/hooks/trust.js +106 -0
  44. package/dist/hooks/types.d.ts +147 -0
  45. package/dist/hooks/types.js +61 -0
  46. package/dist/onboarding/steps.js +5 -2
  47. package/dist/render/capabilities.d.ts +10 -2
  48. package/dist/render/capabilities.js +26 -6
  49. package/dist/render/index.d.ts +9 -5
  50. package/dist/render/index.js +12 -5
  51. package/dist/render/plain-renderer.d.ts +3 -3
  52. package/dist/render/plain-renderer.js +10 -2
  53. package/dist/render/screen-reader-renderer.d.ts +45 -0
  54. package/dist/render/screen-reader-renderer.js +75 -0
  55. package/dist/render/types.d.ts +15 -1
  56. package/dist/theme/resolve.d.ts +18 -7
  57. package/dist/theme/resolve.js +32 -10
  58. package/dist/theme/tokens.d.ts +16 -1
  59. package/dist/theme/tokens.js +27 -0
  60. package/dist/tools/shell/exec.d.ts +53 -0
  61. package/dist/tools/shell/exec.js +128 -0
  62. package/dist/tools/shell/run-command.d.ts +4 -0
  63. package/dist/tools/shell/run-command.js +26 -116
  64. package/package.json +1 -1
@@ -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
@@ -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) {
@@ -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
  /**
@@ -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
  /**
@@ -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
- // The risk mark carries meaning by shape (`!` vs `?`), not by color alone —
41
- // so it survives NO_COLOR (U.1 accessibility groundwork).
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 = destructive ? t.danger(t.strong(" (destructive)")) : "";
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 the undo units behind `cruxy rollback`");
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 + CRUXY.md + a first run)")
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) {
@@ -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 + gated)")
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 an agent run's file changes")
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);
@@ -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 cruxy on a prompt (one-shot), or with no prompt for an interactive session")
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
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox);
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.strong("sources")} ${t.muted("(precedence high low):")}`);
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(` ${source.padEnd(8)} ${t.muted(dir)}`);
38
+ logger.print(` ${t.kv(source, t.muted(dir), 8)}`);
39
39
  }
40
40
  logger.print("");
41
41
  if (status.errors.length === 0) {
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
- import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
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(`cruxy-code — ${APP_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.
@@ -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>;