@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/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
|
}
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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
|
-
|
|
741
|
-
image: string;
|
|
793
|
+
hooks: {
|
|
742
794
|
enabled: boolean;
|
|
743
|
-
|
|
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
|
-
|
|
826
|
-
image?: string | undefined;
|
|
882
|
+
hooks?: {
|
|
827
883
|
enabled?: boolean | undefined;
|
|
828
|
-
|
|
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;
|
package/dist/config/schema.js
CHANGED
|
@@ -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
|
})
|
package/dist/constants.d.ts
CHANGED
|
@@ -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,
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -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). */
|
package/dist/errors/types.js
CHANGED
|
@@ -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>;
|