@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
package/dist/cli/repl.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import readline from "node:readline";
2
2
  import { makeReplCompleter } from "../components/index.js";
3
+ import { resolveSlash } from "../hooks/index.js";
4
+ import { runGatedShell } from "../tools/shell/exec.js";
3
5
  import { themeForColor } from "../theme/index.js";
4
6
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
5
7
  import { createRenderer } from "../render/index.js";
@@ -70,6 +72,35 @@ function readLine(io, prompt) {
70
72
  });
71
73
  });
72
74
  }
75
+ /**
76
+ * Run a shell-bound custom slash command (C.19) through the SAME gated +
77
+ * sandboxed path as `run_command` (`runGatedShell` over the session's tool
78
+ * context) — never a privileged route. Prints the result like a shell run; a
79
+ * gate rejection or a thrown coded error (e.g. sandbox) is surfaced, not fatal.
80
+ */
81
+ async function runSlashShell(spec, session) {
82
+ logger.print(theme.muted(`running /${spec.name}${theme.glyph.ellipsis}`));
83
+ try {
84
+ const outcome = await runGatedShell(spec.command ?? "", session.toolContext);
85
+ if (!outcome.approved) {
86
+ logger.print(theme.muted(outcome.rejection ?? "command denied by the user"));
87
+ return;
88
+ }
89
+ const e = outcome.exec;
90
+ if (e.timedOut) {
91
+ logger.print(theme.warning("timed out"));
92
+ return;
93
+ }
94
+ if (e.spawnError !== undefined) {
95
+ logger.print(theme.danger(e.spawnError));
96
+ return;
97
+ }
98
+ logger.print(`exit code ${e.exitCode ?? e.signal ?? "unknown"}\n${e.output}`);
99
+ }
100
+ catch (err) {
101
+ printReplError(err);
102
+ }
103
+ }
73
104
  /**
74
105
  * Render a non-fatal error inline (classified + formatted, the same 4-part
75
106
  * shape as the fatal boundary) and return to the prompt — the REPL must survive
@@ -93,16 +124,16 @@ function printReplError(err) {
93
124
  * live region, anything else the plain append-only renderer); `cruxy run`
94
125
  * passes its own so the approval prompt's status-suspend hook shares it.
95
126
  */
96
- export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints) {
127
+ export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints, slashCommands = []) {
97
128
  logger.print(theme.muted("interactive session — /help for commands, /exit or Ctrl+D to quit"));
98
129
  try {
99
- await replLoop(session, io, renderer, checkpoints);
130
+ await replLoop(session, io, renderer, checkpoints, slashCommands);
100
131
  }
101
132
  finally {
102
133
  renderer.close();
103
134
  }
104
135
  }
105
- async function replLoop(session, io, renderer, checkpoints) {
136
+ async function replLoop(session, io, renderer, checkpoints, slashCommands = []) {
106
137
  for (;;) {
107
138
  const line = await readLine(io, PROMPT);
108
139
  // EOF / Ctrl+D.
@@ -151,6 +182,26 @@ async function replLoop(session, io, renderer, checkpoints) {
151
182
  logger.print(HELP);
152
183
  continue;
153
184
  }
185
+ // Custom slash commands (C.19) — consulted AFTER builtins, so a custom
186
+ // command can never shadow /help, /exit, etc. A `prompt` command expands to
187
+ // text fed to the agent (safe); a `shell` command runs through the SAME
188
+ // gate + sandbox as any command (never a bypass). Unknown "/…" input falls
189
+ // through to a normal turn, preserving prior behavior.
190
+ const slash = resolveSlash(trimmed, slashCommands);
191
+ if (slash.kind === "prompt") {
192
+ try {
193
+ checkpoints?.beginRun(slash.prompt);
194
+ await session.send(slash.prompt, renderer);
195
+ }
196
+ catch (err) {
197
+ printReplError(err);
198
+ }
199
+ continue;
200
+ }
201
+ if (slash.kind === "shell") {
202
+ await runSlashShell(slash.spec, session);
203
+ continue;
204
+ }
154
205
  // A real turn. Assistant text streams through the renderer delta by delta
155
206
  // (leading blank lines trimmed, code fences highlighted); the agent loop
156
207
  // closes each segment with one newline, so the next prompt lands on its own
@@ -4,7 +4,7 @@ import type { CheckpointService } from "../checkpoint/index.js";
4
4
  import type { SandboxService } from "../sandbox/index.js";
5
5
  import type { StreamRenderer } from "../render/index.js";
6
6
  import { type ApproveAction } from "../tools/index.js";
7
- import { Session } from "../agent/index.js";
7
+ import { Session, type LifecycleHookRunner } from "../agent/index.js";
8
8
  /**
9
9
  * Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
10
10
  * whole point: a tool mutates only *after* `requestApproval` resolves, so
@@ -24,4 +24,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
24
24
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
25
25
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
26
26
  */
27
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService): Session;
27
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService, hooks?: LifecycleHookRunner): Session;
@@ -5,7 +5,7 @@ import { getGitInfo } from "../utils/git.js";
5
5
  import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
6
6
  import { shouldUseColor } from "../errors/index.js";
7
7
  import { buildDefaultRegistry } from "../tools/index.js";
8
- import { Session } from "../agent/index.js";
8
+ import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
10
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
11
11
  /**
@@ -87,7 +87,7 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
87
87
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
88
88
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
89
89
  */
90
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox) {
90
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks) {
91
91
  const provider = createProvider({
92
92
  provider: config.model.provider,
93
93
  apiKey,
@@ -166,6 +166,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
166
166
  projectInstructions,
167
167
  planMode: true,
168
168
  planRunner,
169
+ hooks,
169
170
  });
170
171
  }
171
172
  const approval = new ApprovalService({
@@ -181,5 +182,6 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
181
182
  ctx,
182
183
  git,
183
184
  projectInstructions,
185
+ hooks,
184
186
  });
185
187
  }
@@ -106,7 +106,9 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
106
106
  lines.push(t.heading(opts.title));
107
107
  lines.push(`${t.accent(g.caret)} ${query}${t.muted(g.cursorBar)}`);
108
108
  if (ranked.length === 0) {
109
- lines.push(t.muted(" no results backspace to widen"));
109
+ // No dead-end: the only escapes (backspace, esc) stay advertised even
110
+ // when nothing matches — the user is never stranded with no visible exit.
111
+ lines.push(t.muted(" no results — backspace to widen · esc cancel"));
110
112
  }
111
113
  else {
112
114
  // Keep the highlighted row inside the viewport.
@@ -122,6 +124,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
122
124
  if (hidden > 0)
123
125
  lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
124
126
  }
127
+ // Persistent key hint (U.11 keyboard-completeness): every operable key is
128
+ // advertised, mirroring selectList — type filters, arrows move, enter
129
+ // selects, esc cancels. Present in both the results and no-results states.
130
+ lines.push(t.muted(` type to filter ${g.sep} ${g.caretUp}/${g.caretDown} move ${g.sep} enter select ${g.sep} esc cancel`));
125
131
  frame.render(lines);
126
132
  };
127
133
  io.keys.begin();
@@ -339,6 +339,32 @@ export declare const SandboxConfigSchema: z.ZodObject<{
339
339
  mounts?: string[] | undefined;
340
340
  }>;
341
341
  export type SandboxConfig = z.infer<typeof SandboxConfigSchema>;
342
+ /**
343
+ * Hooks + custom slash commands (C.19). Hooks run user-authored shell commands
344
+ * on lifecycle events — a real execution surface — so they are OFF by default
345
+ * and, when on, still funnel through the SAME U.3 gate + C.16 sandbox as any
346
+ * agent command (never a bypass). The hook *definitions* live in layered
347
+ * `hooks.json` files (project > user, source-tracked for the trust model); this
348
+ * schema holds only the global toggles.
349
+ */
350
+ export declare const HooksConfigSchema: z.ZodObject<{
351
+ /** Master switch. When false, NO hook ever fires (config with hooks stays
352
+ * inert until explicitly enabled). */
353
+ enabled: z.ZodDefault<z.ZodBoolean>;
354
+ /**
355
+ * Whether to interactively prompt to trust a project's hooks the first time
356
+ * they would run. When false, untrusted project hooks fail loud
357
+ * (CRUXY_E_HOOK_UNTRUSTED) instead of prompting — never auto-trust.
358
+ */
359
+ trustPrompt: z.ZodDefault<z.ZodBoolean>;
360
+ }, "strict", z.ZodTypeAny, {
361
+ enabled: boolean;
362
+ trustPrompt: boolean;
363
+ }, {
364
+ enabled?: boolean | undefined;
365
+ trustPrompt?: boolean | undefined;
366
+ }>;
367
+ export type HooksConfig = z.infer<typeof HooksConfigSchema>;
342
368
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
343
369
  export declare const McpServerSchema: z.ZodObject<{
344
370
  command: z.ZodOptional<z.ZodString>;
@@ -654,6 +680,23 @@ export declare const CruxyConfigSchema: z.ZodObject<{
654
680
  timeout?: number | undefined;
655
681
  mounts?: string[] | undefined;
656
682
  }>>;
683
+ hooks: z.ZodDefault<z.ZodObject<{
684
+ /** Master switch. When false, NO hook ever fires (config with hooks stays
685
+ * inert until explicitly enabled). */
686
+ enabled: z.ZodDefault<z.ZodBoolean>;
687
+ /**
688
+ * Whether to interactively prompt to trust a project's hooks the first time
689
+ * they would run. When false, untrusted project hooks fail loud
690
+ * (CRUXY_E_HOOK_UNTRUSTED) instead of prompting — never auto-trust.
691
+ */
692
+ trustPrompt: z.ZodDefault<z.ZodBoolean>;
693
+ }, "strict", z.ZodTypeAny, {
694
+ enabled: boolean;
695
+ trustPrompt: boolean;
696
+ }, {
697
+ enabled?: boolean | undefined;
698
+ trustPrompt?: boolean | undefined;
699
+ }>>;
657
700
  mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
658
701
  command: z.ZodOptional<z.ZodString>;
659
702
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -672,6 +715,23 @@ export declare const CruxyConfigSchema: z.ZodObject<{
672
715
  cruxy: {
673
716
  gatewayUrl: string;
674
717
  };
718
+ checkpoint: {
719
+ enabled: boolean;
720
+ retention: number;
721
+ };
722
+ sandbox: {
723
+ image: string;
724
+ enabled: boolean;
725
+ memory: string;
726
+ network: "none" | "host-loopback" | "full";
727
+ pids: number;
728
+ cpus: number;
729
+ mounts: string[];
730
+ timeout?: number | undefined;
731
+ };
732
+ approval: {
733
+ mode: "prompt";
734
+ };
675
735
  model: {
676
736
  provider: "cruxy" | "anthropic" | "openai" | "custom";
677
737
  model: string;
@@ -702,9 +762,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
702
762
  compactThreshold: number;
703
763
  keepRecentMessages: number;
704
764
  };
705
- approval: {
706
- mode: "prompt";
707
- };
708
765
  index: {
709
766
  search: {
710
767
  defaultK: number;
@@ -720,10 +777,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
720
777
  overlapLines: number;
721
778
  };
722
779
  };
723
- checkpoint: {
724
- enabled: boolean;
725
- retention: number;
726
- };
727
780
  subagent: {
728
781
  maxDepth: number;
729
782
  defaultBudget: {
@@ -737,15 +790,9 @@ export declare const CruxyConfigSchema: z.ZodObject<{
737
790
  captureBytes: number;
738
791
  command?: string | undefined;
739
792
  };
740
- sandbox: {
741
- image: string;
793
+ hooks: {
742
794
  enabled: boolean;
743
- memory: string;
744
- network: "none" | "host-loopback" | "full";
745
- pids: number;
746
- cpus: number;
747
- mounts: string[];
748
- timeout?: number | undefined;
795
+ trustPrompt: boolean;
749
796
  };
750
797
  mcpServers: Record<string, {
751
798
  command?: string | undefined;
@@ -757,6 +804,23 @@ export declare const CruxyConfigSchema: z.ZodObject<{
757
804
  cruxy?: {
758
805
  gatewayUrl?: string | undefined;
759
806
  } | undefined;
807
+ checkpoint?: {
808
+ enabled?: boolean | undefined;
809
+ retention?: number | undefined;
810
+ } | undefined;
811
+ sandbox?: {
812
+ image?: string | undefined;
813
+ enabled?: boolean | undefined;
814
+ memory?: string | undefined;
815
+ network?: "none" | "host-loopback" | "full" | undefined;
816
+ pids?: number | undefined;
817
+ cpus?: number | undefined;
818
+ timeout?: number | undefined;
819
+ mounts?: string[] | undefined;
820
+ } | undefined;
821
+ approval?: {
822
+ mode?: "prompt" | undefined;
823
+ } | undefined;
760
824
  model?: {
761
825
  provider?: "cruxy" | "anthropic" | "openai" | "custom" | undefined;
762
826
  model?: string | undefined;
@@ -787,9 +851,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
787
851
  compactThreshold?: number | undefined;
788
852
  keepRecentMessages?: number | undefined;
789
853
  } | undefined;
790
- approval?: {
791
- mode?: "prompt" | undefined;
792
- } | undefined;
793
854
  index?: {
794
855
  search?: {
795
856
  defaultK?: number | undefined;
@@ -805,10 +866,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
805
866
  overlapLines?: number | undefined;
806
867
  } | undefined;
807
868
  } | undefined;
808
- checkpoint?: {
809
- enabled?: boolean | undefined;
810
- retention?: number | undefined;
811
- } | undefined;
812
869
  subagent?: {
813
870
  maxDepth?: number | undefined;
814
871
  defaultBudget?: {
@@ -822,15 +879,9 @@ export declare const CruxyConfigSchema: z.ZodObject<{
822
879
  command?: string | undefined;
823
880
  captureBytes?: number | undefined;
824
881
  } | undefined;
825
- sandbox?: {
826
- image?: string | undefined;
882
+ hooks?: {
827
883
  enabled?: boolean | undefined;
828
- memory?: string | undefined;
829
- network?: "none" | "host-loopback" | "full" | undefined;
830
- pids?: number | undefined;
831
- cpus?: number | undefined;
832
- timeout?: number | undefined;
833
- mounts?: string[] | undefined;
884
+ trustPrompt?: boolean | undefined;
834
885
  } | undefined;
835
886
  mcpServers?: Record<string, {
836
887
  command?: string | undefined;
@@ -233,6 +233,27 @@ export const SandboxConfigSchema = z
233
233
  mounts: z.array(z.string().min(1)).default([]),
234
234
  })
235
235
  .strict();
236
+ /**
237
+ * Hooks + custom slash commands (C.19). Hooks run user-authored shell commands
238
+ * on lifecycle events — a real execution surface — so they are OFF by default
239
+ * and, when on, still funnel through the SAME U.3 gate + C.16 sandbox as any
240
+ * agent command (never a bypass). The hook *definitions* live in layered
241
+ * `hooks.json` files (project > user, source-tracked for the trust model); this
242
+ * schema holds only the global toggles.
243
+ */
244
+ export const HooksConfigSchema = z
245
+ .object({
246
+ /** Master switch. When false, NO hook ever fires (config with hooks stays
247
+ * inert until explicitly enabled). */
248
+ enabled: z.boolean().default(false),
249
+ /**
250
+ * Whether to interactively prompt to trust a project's hooks the first time
251
+ * they would run. When false, untrusted project hooks fail loud
252
+ * (CRUXY_E_HOOK_UNTRUSTED) instead of prompting — never auto-trust.
253
+ */
254
+ trustPrompt: z.boolean().default(true),
255
+ })
256
+ .strict();
236
257
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
237
258
  export const McpServerSchema = z
238
259
  .object({
@@ -256,6 +277,7 @@ export const CruxyConfigSchema = z
256
277
  subagent: SubagentConfigSchema.default({}),
257
278
  test: TestConfigSchema.default({}),
258
279
  sandbox: SandboxConfigSchema.default({}),
280
+ hooks: HooksConfigSchema.default({}),
259
281
  mcpServers: z.record(z.string(), McpServerSchema).default({}),
260
282
  logLevel: z.enum(LOG_LEVELS).default("info"),
261
283
  })
@@ -21,6 +21,15 @@ export declare const PROJECT_INSTRUCTION_FILENAMES: string[];
21
21
  * root for shipped builtins.
22
22
  */
23
23
  export declare const SKILLS_DIR_NAME = "skills";
24
+ /** Hook definitions file, under the project dir (`<cwd>/.cruxy/hooks.json`) and
25
+ * the global dir (`~/.cruxy/hooks.json`). Pure data, validated, never eval'd (C.19). */
26
+ export declare const HOOKS_FILE_NAME = "hooks.json";
27
+ /** Custom slash-command directory (`<cwd>/.cruxy/commands`, `~/.cruxy/commands`);
28
+ * one `<name>.md` per command (frontmatter + template body), mirroring skills. */
29
+ export declare const COMMANDS_DIR_NAME = "commands";
30
+ /** Per-repo hook-trust record, in the GLOBAL dir only (`~/.cruxy/trust.json`) —
31
+ * never in a repo, so cloning carries no trust (C.19 supply-chain safety). */
32
+ export declare const TRUST_FILE_NAME = "trust.json";
24
33
  /**
25
34
  * Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
26
35
  * Anchored the same way as the package.json lookup above: both `dist/` and
package/dist/constants.js CHANGED
@@ -41,6 +41,15 @@ export const PROJECT_INSTRUCTION_FILENAMES = ["CRUXY.md", "AGENTS.md"];
41
41
  * root for shipped builtins.
42
42
  */
43
43
  export const SKILLS_DIR_NAME = "skills";
44
+ /** Hook definitions file, under the project dir (`<cwd>/.cruxy/hooks.json`) and
45
+ * the global dir (`~/.cruxy/hooks.json`). Pure data, validated, never eval'd (C.19). */
46
+ export const HOOKS_FILE_NAME = "hooks.json";
47
+ /** Custom slash-command directory (`<cwd>/.cruxy/commands`, `~/.cruxy/commands`);
48
+ * one `<name>.md` per command (frontmatter + template body), mirroring skills. */
49
+ export const COMMANDS_DIR_NAME = "commands";
50
+ /** Per-repo hook-trust record, in the GLOBAL dir only (`~/.cruxy/trust.json`) —
51
+ * never in a repo, so cloning carries no trust (C.19 supply-chain safety). */
52
+ export const TRUST_FILE_NAME = "trust.json";
44
53
  /**
45
54
  * Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
46
55
  * Anchored the same way as the package.json lookup above: both `dist/` and
@@ -46,6 +46,22 @@ export declare function sandboxImage(image: string, underlying?: unknown): Cruxy
46
46
  * can't launch is never papered over with a host run.
47
47
  */
48
48
  export declare function sandboxExec(underlying?: unknown): CruxyError;
49
+ /**
50
+ * A blocking hook failed (non-zero exit, gate-declined, or errored). The action
51
+ * that fired it is aborted fail-closed — a failing pre-check must stop, never
52
+ * proceed. Advisory hooks report but never raise this.
53
+ */
54
+ export declare function hookFailed(name: string, reason: string, underlying?: unknown): CruxyError;
55
+ /**
56
+ * A project (a cloned/opened repo) defines hooks that have not been trusted for
57
+ * this machine. Cruxy NEVER runs another author's hooks silently — trust is an
58
+ * explicit, per-repo decision recorded in your home, not in the repo.
59
+ */
60
+ export declare function hookUntrusted(root: string, count: number): CruxyError;
61
+ /** A malformed hook definition — excluded from the catalog, never eval'd. */
62
+ export declare function hookInvalid(reason: string, source?: string): CruxyError;
63
+ /** A malformed custom slash-command definition — excluded and surfaced. */
64
+ export declare function slashInvalid(reason: string, source?: string): CruxyError;
49
65
  /**
50
66
  * A side-effecting action needs approval but cruxy can't ask (non-interactive,
51
67
  * no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
@@ -282,6 +282,63 @@ export function sandboxExec(underlying) {
282
282
  underlying,
283
283
  });
284
284
  }
285
+ // ── hooks + custom slash commands (exit 13) — C.19 ────────────────────────────
286
+ /**
287
+ * A blocking hook failed (non-zero exit, gate-declined, or errored). The action
288
+ * that fired it is aborted fail-closed — a failing pre-check must stop, never
289
+ * proceed. Advisory hooks report but never raise this.
290
+ */
291
+ export function hookFailed(name, reason, underlying) {
292
+ return new CruxyError({
293
+ code: ErrorCode.HookFailed,
294
+ title: `blocking hook "${name}" failed — action aborted`,
295
+ cause: reason,
296
+ nextSteps: [
297
+ `fix the hook command, or mark it advisory (blocking: false) if it should not block`,
298
+ "run `cruxy hooks list` to inspect the configured hooks",
299
+ ],
300
+ meta: { name },
301
+ underlying,
302
+ });
303
+ }
304
+ /**
305
+ * A project (a cloned/opened repo) defines hooks that have not been trusted for
306
+ * this machine. Cruxy NEVER runs another author's hooks silently — trust is an
307
+ * explicit, per-repo decision recorded in your home, not in the repo.
308
+ */
309
+ export function hookUntrusted(root, count) {
310
+ return new CruxyError({
311
+ code: ErrorCode.HookUntrusted,
312
+ title: `this project defines ${count} hook${count === 1 ? "" : "s"} that are not trusted`,
313
+ cause: "project hooks are authored by whoever wrote the repo; cruxy will not run them until you review and trust them",
314
+ nextSteps: [
315
+ "review them with `cruxy hooks list`",
316
+ `trust them with \`cruxy hooks trust ${root}\` (after reviewing)`,
317
+ "or set hooks.enabled=false to disable hooks entirely",
318
+ ],
319
+ meta: { root, count },
320
+ });
321
+ }
322
+ /** A malformed hook definition — excluded from the catalog, never eval'd. */
323
+ export function hookInvalid(reason, source) {
324
+ return new CruxyError({
325
+ code: ErrorCode.HookInvalid,
326
+ title: "a hook definition is malformed",
327
+ cause: reason,
328
+ nextSteps: ["fix the hook definition; see `cruxy hooks list` for details"],
329
+ meta: { source },
330
+ });
331
+ }
332
+ /** A malformed custom slash-command definition — excluded and surfaced. */
333
+ export function slashInvalid(reason, source) {
334
+ return new CruxyError({
335
+ code: ErrorCode.SlashInvalid,
336
+ title: "a custom slash-command definition is malformed",
337
+ cause: reason,
338
+ nextSteps: ["fix the command definition (frontmatter + body)"],
339
+ meta: { source },
340
+ });
341
+ }
285
342
  // ── approval (exit 10) ────────────────────────────────────────────────────────
286
343
  /**
287
344
  * A side-effecting action needs approval but cruxy can't ask (non-interactive,
@@ -57,6 +57,17 @@ export declare const ErrorCode: {
57
57
  readonly SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE";
58
58
  readonly SandboxImage: "CRUXY_E_SANDBOX_IMAGE";
59
59
  readonly SandboxExec: "CRUXY_E_SANDBOX_EXEC";
60
+ /** A blocking hook failed (non-zero exit / declined / errored) → the action
61
+ * is aborted fail-closed. Advisory hooks never raise this. */
62
+ readonly HookFailed: "CRUXY_E_HOOK_FAILED";
63
+ /** A project defines hooks that have not been trusted for this repo — they are
64
+ * never run silently (supply-chain safety). */
65
+ readonly HookUntrusted: "CRUXY_E_HOOK_UNTRUSTED";
66
+ /** A malformed hook definition — excluded from the catalog and surfaced,
67
+ * never eval'd. */
68
+ readonly HookInvalid: "CRUXY_E_HOOK_INVALID";
69
+ /** A malformed custom slash-command definition — excluded and surfaced. */
70
+ readonly SlashInvalid: "CRUXY_E_SLASH_INVALID";
60
71
  };
61
72
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
62
73
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -70,6 +70,18 @@ export const ErrorCode = {
70
70
  SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE",
71
71
  SandboxImage: "CRUXY_E_SANDBOX_IMAGE",
72
72
  SandboxExec: "CRUXY_E_SANDBOX_EXEC",
73
+ // hooks + custom slash commands (exit 13) — C.19
74
+ /** A blocking hook failed (non-zero exit / declined / errored) → the action
75
+ * is aborted fail-closed. Advisory hooks never raise this. */
76
+ HookFailed: "CRUXY_E_HOOK_FAILED",
77
+ /** A project defines hooks that have not been trusted for this repo — they are
78
+ * never run silently (supply-chain safety). */
79
+ HookUntrusted: "CRUXY_E_HOOK_UNTRUSTED",
80
+ /** A malformed hook definition — excluded from the catalog and surfaced,
81
+ * never eval'd. */
82
+ HookInvalid: "CRUXY_E_HOOK_INVALID",
83
+ /** A malformed custom slash-command definition — excluded and surfaced. */
84
+ SlashInvalid: "CRUXY_E_SLASH_INVALID",
73
85
  };
74
86
  /**
75
87
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -123,6 +135,13 @@ const EXIT_CODES = {
123
135
  [ErrorCode.SandboxUnavailable]: 12,
124
136
  [ErrorCode.SandboxImage]: 12,
125
137
  [ErrorCode.SandboxExec]: 12,
138
+ // Hooks + custom slash commands (C.19). A blocking-hook failure and an
139
+ // untrusted project are execution-safety stops; malformed definitions are
140
+ // usage/config problems but share the category for a greppable exit code.
141
+ [ErrorCode.HookFailed]: 13,
142
+ [ErrorCode.HookUntrusted]: 13,
143
+ [ErrorCode.HookInvalid]: 13,
144
+ [ErrorCode.SlashInvalid]: 13,
126
145
  };
127
146
  /** The process exit code for an error code (defaults to 1 for safety). */
128
147
  export function exitCodeFor(code) {
@@ -0,0 +1,21 @@
1
+ import { type HookCatalog } from "./types.js";
2
+ /**
3
+ * Discovery for hooks + custom slash commands (C.19), mirroring the C.18 skills
4
+ * loader: scan layered sources (project > user), validate every definition
5
+ * fail-loud, exclude the malformed (collected as {@link HookConfigError}, never
6
+ * thrown past the loader, never eval'd), and resolve precedence. Hook
7
+ * definitions carry their {@link HookSource} — the trust model depends on
8
+ * telling a repo's hooks (project) apart from your own (user).
9
+ */
10
+ /** The two source directories (each a `.cruxy` dir holding `hooks.json` and
11
+ * `commands/`). */
12
+ export interface HookSources {
13
+ /** `<cwd>/.cruxy` */
14
+ project: string;
15
+ /** `~/.cruxy` */
16
+ user: string;
17
+ }
18
+ /** The real sources for a project root. */
19
+ export declare function defaultHookSources(cwd: string): HookSources;
20
+ /** Load and resolve the full hook + command catalog. */
21
+ export declare function loadHookCatalog(sources: HookSources): Promise<HookCatalog>;