@cruxy/cli 0.14.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.d.ts +26 -0
- package/dist/agent/loop.js +59 -3
- package/dist/agent/session.d.ts +17 -1
- package/dist/agent/session.js +23 -2
- package/dist/brand/index.d.ts +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/voice.d.ts +94 -0
- package/dist/brand/voice.js +127 -0
- package/dist/cli/commands/checkpoint.js +1 -1
- package/dist/cli/commands/hooks.d.ts +8 -0
- package/dist/cli/commands/hooks.js +83 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/pr.js +10 -2
- package/dist/cli/commands/rollback.js +1 -1
- package/dist/cli/commands/run.js +13 -3
- package/dist/cli/commands/skills.js +2 -2
- package/dist/cli/program.js +5 -2
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +54 -3
- package/dist/cli/session-factory.d.ts +2 -2
- package/dist/cli/session-factory.js +13 -2
- package/dist/config/schema.d.ts +139 -46
- package/dist/config/schema.js +42 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +9 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +98 -6
- package/dist/errors/types.d.ts +14 -0
- package/dist/errors/types.js +23 -0
- package/dist/hooks/config.d.ts +21 -0
- package/dist/hooks/config.js +253 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/runner.d.ts +76 -0
- package/dist/hooks/runner.js +114 -0
- package/dist/hooks/service.d.ts +38 -0
- package/dist/hooks/service.js +49 -0
- package/dist/hooks/slash.d.ts +48 -0
- package/dist/hooks/slash.js +58 -0
- package/dist/hooks/trust.d.ts +46 -0
- package/dist/hooks/trust.js +106 -0
- package/dist/hooks/types.d.ts +147 -0
- package/dist/hooks/types.js +61 -0
- package/dist/onboarding/steps.js +1 -1
- package/dist/plan/service.d.ts +6 -0
- package/dist/plan/service.js +4 -0
- package/dist/render/state.js +4 -1
- package/dist/render/types.d.ts +7 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +5 -0
- package/dist/routing/resolve.d.ts +17 -0
- package/dist/routing/resolve.js +18 -0
- package/dist/routing/router.d.ts +47 -0
- package/dist/routing/router.js +84 -0
- package/dist/routing/types.d.ts +42 -0
- package/dist/routing/types.js +27 -0
- package/dist/subagent/orchestrator.d.ts +6 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/subagent/types.d.ts +6 -0
- package/dist/tools/shell/exec.d.ts +53 -0
- package/dist/tools/shell/exec.js +128 -0
- package/dist/tools/shell/run-command.d.ts +4 -0
- package/dist/tools/shell/run-command.js +26 -116
- package/dist/vcs/generate.d.ts +3 -1
- package/dist/vcs/generate.js +4 -1
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError, AuthError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
2
|
+
import { scrubModelNames } from "../brand/index.js";
|
|
2
3
|
import { CruxyError, ErrorCode } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* Helper constructors for {@link CruxyError}. Each encodes the title, the human
|
|
@@ -15,6 +16,18 @@ export function messageOf(underlying) {
|
|
|
15
16
|
return undefined;
|
|
16
17
|
return String(underlying);
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* {@link messageOf}, gag-scrubbed (U.8) for a gateway/provider-originated error.
|
|
21
|
+
* A gateway error body is an arbitrary EXTERNAL string, so it bypasses the
|
|
22
|
+
* structural tier gag — this is the boundary that scrubs any upstream model name
|
|
23
|
+
* out of it before it can reach a user-facing `cause`. Used by the provider-error
|
|
24
|
+
* constructors below (everything `classifyProviderError` routes through). The raw
|
|
25
|
+
* message stays on `underlying`, shown verbatim only under `--verbose`.
|
|
26
|
+
*/
|
|
27
|
+
function scrubbedMessageOf(underlying) {
|
|
28
|
+
const msg = messageOf(underlying);
|
|
29
|
+
return msg === undefined ? undefined : scrubModelNames(msg);
|
|
30
|
+
}
|
|
18
31
|
// ── usage (exit 2) ────────────────────────────────────────────────────────────
|
|
19
32
|
export function usageError(title, nextSteps) {
|
|
20
33
|
return new CruxyError({
|
|
@@ -57,6 +70,28 @@ export function providerUnsupported(provider) {
|
|
|
57
70
|
meta: { provider },
|
|
58
71
|
});
|
|
59
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A task class is routed (C.30) to a tier the gateway does not offer. A usage
|
|
75
|
+
* error the user fixes in config — cruxy fails loud here rather than silently
|
|
76
|
+
* substituting a different tier (which would hand a user a model they never
|
|
77
|
+
* asked for). Distinct from runtime unavailability (overload/budget), which
|
|
78
|
+
* stays on the U.5 api codes. Params are tier/class NAMES only — never an
|
|
79
|
+
* upstream model id — so the message is gag-safe by construction (U.8).
|
|
80
|
+
*/
|
|
81
|
+
export function routingTierUnavailable(tier, taskClass, offered) {
|
|
82
|
+
return new CruxyError({
|
|
83
|
+
code: ErrorCode.RoutingTierUnavailable,
|
|
84
|
+
title: `the "${tier}" tier is not available for the "${taskClass}" task`,
|
|
85
|
+
cause: `routing maps "${taskClass}" to the "${tier}" tier, which this gateway does not currently offer`,
|
|
86
|
+
nextSteps: [
|
|
87
|
+
`route it to an available tier, e.g. \`cruxy config set routing.map.${taskClass} ${offered[0] ?? "vaani"}\``,
|
|
88
|
+
offered.length
|
|
89
|
+
? `available tiers: ${offered.join(", ")}`
|
|
90
|
+
: "no tiers are currently available",
|
|
91
|
+
],
|
|
92
|
+
meta: { tier, taskClass, offered },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
60
95
|
// ── config (exit 3) ───────────────────────────────────────────────────────────
|
|
61
96
|
export function configParse(path, underlying) {
|
|
62
97
|
return new CruxyError({
|
|
@@ -99,7 +134,7 @@ export function authInvalid(underlying) {
|
|
|
99
134
|
return new CruxyError({
|
|
100
135
|
code: ErrorCode.AuthInvalid,
|
|
101
136
|
title: "the provider rejected your credentials",
|
|
102
|
-
cause:
|
|
137
|
+
cause: scrubbedMessageOf(underlying),
|
|
103
138
|
nextSteps: [
|
|
104
139
|
"verify your API key is correct and active",
|
|
105
140
|
"re-export the key and try again",
|
|
@@ -112,7 +147,7 @@ export function gatewayUnreachable(underlying) {
|
|
|
112
147
|
return new CruxyError({
|
|
113
148
|
code: ErrorCode.GatewayUnreachable,
|
|
114
149
|
title: "could not reach the model gateway",
|
|
115
|
-
cause:
|
|
150
|
+
cause: scrubbedMessageOf(underlying),
|
|
116
151
|
nextSteps: [
|
|
117
152
|
"check your internet connection",
|
|
118
153
|
"verify the gateway URL with `cruxy config get cruxy.gatewayUrl`",
|
|
@@ -129,7 +164,7 @@ export function apiError(underlying) {
|
|
|
129
164
|
title: status
|
|
130
165
|
? `the model provider returned an error (HTTP ${status})`
|
|
131
166
|
: "the model provider returned an error",
|
|
132
|
-
cause:
|
|
167
|
+
cause: scrubbedMessageOf(underlying),
|
|
133
168
|
nextSteps: [
|
|
134
169
|
"retry in a moment; if it persists, check the provider's status",
|
|
135
170
|
],
|
|
@@ -142,7 +177,7 @@ export function apiRateLimit(underlying) {
|
|
|
142
177
|
return new CruxyError({
|
|
143
178
|
code: ErrorCode.ApiRateLimit,
|
|
144
179
|
title: "rate limited by the model provider",
|
|
145
|
-
cause:
|
|
180
|
+
cause: scrubbedMessageOf(underlying),
|
|
146
181
|
nextSteps: [
|
|
147
182
|
retryAfterMs
|
|
148
183
|
? `wait ~${Math.ceil(retryAfterMs / 1000)}s and retry`
|
|
@@ -156,7 +191,7 @@ export function apiOverloaded(underlying) {
|
|
|
156
191
|
return new CruxyError({
|
|
157
192
|
code: ErrorCode.ApiOverloaded,
|
|
158
193
|
title: "the model provider is overloaded",
|
|
159
|
-
cause:
|
|
194
|
+
cause: scrubbedMessageOf(underlying),
|
|
160
195
|
nextSteps: ["retry in a few moments"],
|
|
161
196
|
underlying,
|
|
162
197
|
});
|
|
@@ -165,7 +200,7 @@ export function budgetExhausted(underlying) {
|
|
|
165
200
|
return new CruxyError({
|
|
166
201
|
code: ErrorCode.BudgetExhausted,
|
|
167
202
|
title: "your Cruxy budget is exhausted",
|
|
168
|
-
cause:
|
|
203
|
+
cause: scrubbedMessageOf(underlying),
|
|
169
204
|
nextSteps: ["top up or raise your budget, then retry"],
|
|
170
205
|
underlying,
|
|
171
206
|
});
|
|
@@ -282,6 +317,63 @@ export function sandboxExec(underlying) {
|
|
|
282
317
|
underlying,
|
|
283
318
|
});
|
|
284
319
|
}
|
|
320
|
+
// ── hooks + custom slash commands (exit 13) — C.19 ────────────────────────────
|
|
321
|
+
/**
|
|
322
|
+
* A blocking hook failed (non-zero exit, gate-declined, or errored). The action
|
|
323
|
+
* that fired it is aborted fail-closed — a failing pre-check must stop, never
|
|
324
|
+
* proceed. Advisory hooks report but never raise this.
|
|
325
|
+
*/
|
|
326
|
+
export function hookFailed(name, reason, underlying) {
|
|
327
|
+
return new CruxyError({
|
|
328
|
+
code: ErrorCode.HookFailed,
|
|
329
|
+
title: `blocking hook "${name}" failed — action aborted`,
|
|
330
|
+
cause: reason,
|
|
331
|
+
nextSteps: [
|
|
332
|
+
`fix the hook command, or mark it advisory (blocking: false) if it should not block`,
|
|
333
|
+
"run `cruxy hooks list` to inspect the configured hooks",
|
|
334
|
+
],
|
|
335
|
+
meta: { name },
|
|
336
|
+
underlying,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* A project (a cloned/opened repo) defines hooks that have not been trusted for
|
|
341
|
+
* this machine. Cruxy NEVER runs another author's hooks silently — trust is an
|
|
342
|
+
* explicit, per-repo decision recorded in your home, not in the repo.
|
|
343
|
+
*/
|
|
344
|
+
export function hookUntrusted(root, count) {
|
|
345
|
+
return new CruxyError({
|
|
346
|
+
code: ErrorCode.HookUntrusted,
|
|
347
|
+
title: `this project defines ${count} hook${count === 1 ? "" : "s"} that are not trusted`,
|
|
348
|
+
cause: "project hooks are authored by whoever wrote the repo; cruxy will not run them until you review and trust them",
|
|
349
|
+
nextSteps: [
|
|
350
|
+
"review them with `cruxy hooks list`",
|
|
351
|
+
`trust them with \`cruxy hooks trust ${root}\` (after reviewing)`,
|
|
352
|
+
"or set hooks.enabled=false to disable hooks entirely",
|
|
353
|
+
],
|
|
354
|
+
meta: { root, count },
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/** A malformed hook definition — excluded from the catalog, never eval'd. */
|
|
358
|
+
export function hookInvalid(reason, source) {
|
|
359
|
+
return new CruxyError({
|
|
360
|
+
code: ErrorCode.HookInvalid,
|
|
361
|
+
title: "a hook definition is malformed",
|
|
362
|
+
cause: reason,
|
|
363
|
+
nextSteps: ["fix the hook definition; see `cruxy hooks list` for details"],
|
|
364
|
+
meta: { source },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** A malformed custom slash-command definition — excluded and surfaced. */
|
|
368
|
+
export function slashInvalid(reason, source) {
|
|
369
|
+
return new CruxyError({
|
|
370
|
+
code: ErrorCode.SlashInvalid,
|
|
371
|
+
title: "a custom slash-command definition is malformed",
|
|
372
|
+
cause: reason,
|
|
373
|
+
nextSteps: ["fix the command definition (frontmatter + body)"],
|
|
374
|
+
meta: { source },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
285
377
|
// ── approval (exit 10) ────────────────────────────────────────────────────────
|
|
286
378
|
/**
|
|
287
379
|
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export declare const ErrorCode: {
|
|
|
21
21
|
readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
|
|
22
22
|
readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
|
|
23
23
|
readonly CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND";
|
|
24
|
+
/** Routing (C.30): a task class is mapped to a tier the gateway does not
|
|
25
|
+
* offer — fix the config, NEVER a silent substitution to another tier. */
|
|
26
|
+
readonly RoutingTierUnavailable: "CRUXY_E_ROUTING_TIER_UNAVAILABLE";
|
|
24
27
|
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
25
28
|
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
26
29
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
@@ -57,6 +60,17 @@ export declare const ErrorCode: {
|
|
|
57
60
|
readonly SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE";
|
|
58
61
|
readonly SandboxImage: "CRUXY_E_SANDBOX_IMAGE";
|
|
59
62
|
readonly SandboxExec: "CRUXY_E_SANDBOX_EXEC";
|
|
63
|
+
/** A blocking hook failed (non-zero exit / declined / errored) → the action
|
|
64
|
+
* is aborted fail-closed. Advisory hooks never raise this. */
|
|
65
|
+
readonly HookFailed: "CRUXY_E_HOOK_FAILED";
|
|
66
|
+
/** A project defines hooks that have not been trusted for this repo — they are
|
|
67
|
+
* never run silently (supply-chain safety). */
|
|
68
|
+
readonly HookUntrusted: "CRUXY_E_HOOK_UNTRUSTED";
|
|
69
|
+
/** A malformed hook definition — excluded from the catalog and surfaced,
|
|
70
|
+
* never eval'd. */
|
|
71
|
+
readonly HookInvalid: "CRUXY_E_HOOK_INVALID";
|
|
72
|
+
/** A malformed custom slash-command definition — excluded and surfaced. */
|
|
73
|
+
readonly SlashInvalid: "CRUXY_E_SLASH_INVALID";
|
|
60
74
|
};
|
|
61
75
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
62
76
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -23,6 +23,9 @@ export const ErrorCode = {
|
|
|
23
23
|
PlanInvalid: "CRUXY_E_PLAN_INVALID",
|
|
24
24
|
PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
|
|
25
25
|
CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND",
|
|
26
|
+
/** Routing (C.30): a task class is mapped to a tier the gateway does not
|
|
27
|
+
* offer — fix the config, NEVER a silent substitution to another tier. */
|
|
28
|
+
RoutingTierUnavailable: "CRUXY_E_ROUTING_TIER_UNAVAILABLE",
|
|
26
29
|
// config (exit 3)
|
|
27
30
|
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
28
31
|
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
@@ -70,6 +73,18 @@ export const ErrorCode = {
|
|
|
70
73
|
SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE",
|
|
71
74
|
SandboxImage: "CRUXY_E_SANDBOX_IMAGE",
|
|
72
75
|
SandboxExec: "CRUXY_E_SANDBOX_EXEC",
|
|
76
|
+
// hooks + custom slash commands (exit 13) — C.19
|
|
77
|
+
/** A blocking hook failed (non-zero exit / declined / errored) → the action
|
|
78
|
+
* is aborted fail-closed. Advisory hooks never raise this. */
|
|
79
|
+
HookFailed: "CRUXY_E_HOOK_FAILED",
|
|
80
|
+
/** A project defines hooks that have not been trusted for this repo — they are
|
|
81
|
+
* never run silently (supply-chain safety). */
|
|
82
|
+
HookUntrusted: "CRUXY_E_HOOK_UNTRUSTED",
|
|
83
|
+
/** A malformed hook definition — excluded from the catalog and surfaced,
|
|
84
|
+
* never eval'd. */
|
|
85
|
+
HookInvalid: "CRUXY_E_HOOK_INVALID",
|
|
86
|
+
/** A malformed custom slash-command definition — excluded and surfaced. */
|
|
87
|
+
SlashInvalid: "CRUXY_E_SLASH_INVALID",
|
|
73
88
|
};
|
|
74
89
|
/**
|
|
75
90
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -85,6 +100,7 @@ const EXIT_CODES = {
|
|
|
85
100
|
[ErrorCode.PlanInvalid]: 2,
|
|
86
101
|
[ErrorCode.PlanRevisionLimit]: 2,
|
|
87
102
|
[ErrorCode.CheckpointNotFound]: 2,
|
|
103
|
+
[ErrorCode.RoutingTierUnavailable]: 2,
|
|
88
104
|
[ErrorCode.ConfigParse]: 3,
|
|
89
105
|
[ErrorCode.ConfigInvalid]: 3,
|
|
90
106
|
[ErrorCode.AuthMissingKey]: 4,
|
|
@@ -123,6 +139,13 @@ const EXIT_CODES = {
|
|
|
123
139
|
[ErrorCode.SandboxUnavailable]: 12,
|
|
124
140
|
[ErrorCode.SandboxImage]: 12,
|
|
125
141
|
[ErrorCode.SandboxExec]: 12,
|
|
142
|
+
// Hooks + custom slash commands (C.19). A blocking-hook failure and an
|
|
143
|
+
// untrusted project are execution-safety stops; malformed definitions are
|
|
144
|
+
// usage/config problems but share the category for a greppable exit code.
|
|
145
|
+
[ErrorCode.HookFailed]: 13,
|
|
146
|
+
[ErrorCode.HookUntrusted]: 13,
|
|
147
|
+
[ErrorCode.HookInvalid]: 13,
|
|
148
|
+
[ErrorCode.SlashInvalid]: 13,
|
|
126
149
|
};
|
|
127
150
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
128
151
|
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>;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globalDir } from "../config/paths.js";
|
|
4
|
+
import { COMMANDS_DIR_NAME, GLOBAL_DIR_NAME, HOOKS_FILE_NAME, } from "../constants.js";
|
|
5
|
+
import { defaultBlocking, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, SlashFrontmatterSchema, } from "./types.js";
|
|
6
|
+
/** The real sources for a project root. */
|
|
7
|
+
export function defaultHookSources(cwd) {
|
|
8
|
+
return {
|
|
9
|
+
project: path.join(path.resolve(cwd), GLOBAL_DIR_NAME),
|
|
10
|
+
user: globalDir(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Load and resolve the full hook + command catalog. */
|
|
14
|
+
export async function loadHookCatalog(sources) {
|
|
15
|
+
const hookCandidates = [];
|
|
16
|
+
const commandCandidates = [];
|
|
17
|
+
const errors = [];
|
|
18
|
+
// Precedence order: project first, then user.
|
|
19
|
+
for (const source of HOOK_SOURCE_PRECEDENCE) {
|
|
20
|
+
await scanHooksFile(source, sources[source], hookCandidates, errors);
|
|
21
|
+
await scanCommandsDir(source, sources[source], commandCandidates, errors);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
hooks: resolvePrecedence(hookCandidates),
|
|
25
|
+
commands: resolvePrecedence(commandCandidates),
|
|
26
|
+
errors,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// ── hooks.json ────────────────────────────────────────────────────────────────
|
|
30
|
+
async function scanHooksFile(source, dir, out, errors) {
|
|
31
|
+
const file = path.join(dir, HOOKS_FILE_NAME);
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = await fs.readFile(file, "utf8");
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// Missing file is normal (source absent); other errors are reported.
|
|
38
|
+
if (err.code !== "ENOENT") {
|
|
39
|
+
errors.push({
|
|
40
|
+
source,
|
|
41
|
+
file,
|
|
42
|
+
name: HOOKS_FILE_NAME,
|
|
43
|
+
message: `could not read ${HOOKS_FILE_NAME}: ${err.message}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let json;
|
|
49
|
+
try {
|
|
50
|
+
json = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
errors.push({
|
|
54
|
+
source,
|
|
55
|
+
file,
|
|
56
|
+
name: HOOKS_FILE_NAME,
|
|
57
|
+
message: `invalid JSON: ${err.message}`,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Top-level shape only; each hook is validated individually below so ONE bad
|
|
62
|
+
// entry is excluded + surfaced while the valid hooks in the same file survive
|
|
63
|
+
// (same discipline as the skills loader — never all-or-nothing).
|
|
64
|
+
if (json === null ||
|
|
65
|
+
typeof json !== "object" ||
|
|
66
|
+
!Array.isArray(json.hooks)) {
|
|
67
|
+
errors.push({
|
|
68
|
+
source,
|
|
69
|
+
file,
|
|
70
|
+
name: HOOKS_FILE_NAME,
|
|
71
|
+
message: 'hooks.json must be an object with a "hooks" array',
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const rawHooks = json.hooks;
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const [i, raw] of rawHooks.entries()) {
|
|
78
|
+
const label = raw &&
|
|
79
|
+
typeof raw === "object" &&
|
|
80
|
+
typeof raw.name === "string"
|
|
81
|
+
? raw.name
|
|
82
|
+
: `entry #${i}`;
|
|
83
|
+
const parsed = HookSpecSchema.safeParse(raw);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
errors.push({
|
|
86
|
+
source,
|
|
87
|
+
file,
|
|
88
|
+
name: label,
|
|
89
|
+
message: `invalid hook: ${formatZod(parsed.error)}`,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const spec = parsed.data;
|
|
94
|
+
if (seen.has(spec.name)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
source,
|
|
97
|
+
file,
|
|
98
|
+
name: spec.name,
|
|
99
|
+
message: `duplicate hook name "${spec.name}" within the ${source} source`,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
seen.add(spec.name);
|
|
104
|
+
out.push({
|
|
105
|
+
name: spec.name,
|
|
106
|
+
event: spec.event,
|
|
107
|
+
command: spec.command,
|
|
108
|
+
blocking: spec.blocking ?? defaultBlocking(spec.event),
|
|
109
|
+
source,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ── commands/*.md ─────────────────────────────────────────────────────────────
|
|
114
|
+
async function scanCommandsDir(source, dir, out, errors) {
|
|
115
|
+
const commandsDir = path.join(dir, COMMANDS_DIR_NAME);
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
entries = await fs.readdir(commandsDir, { withFileTypes: true });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err.code !== "ENOENT") {
|
|
122
|
+
errors.push({
|
|
123
|
+
source,
|
|
124
|
+
file: commandsDir,
|
|
125
|
+
name: COMMANDS_DIR_NAME,
|
|
126
|
+
message: `could not read commands dir: ${err.message}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
134
|
+
continue;
|
|
135
|
+
const name = entry.name.slice(0, -".md".length);
|
|
136
|
+
const file = path.join(commandsDir, entry.name);
|
|
137
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
138
|
+
errors.push({
|
|
139
|
+
source,
|
|
140
|
+
file,
|
|
141
|
+
name,
|
|
142
|
+
message: `command filename must be kebab-case: "${entry.name}"`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = await fs.readFile(file, "utf8");
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
errors.push({ source, file, name, message: err.message });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
out.push(parseCommand(text, name, source, file));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
errors.push({ source, file, name, message: err.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Parse + validate one `commands/<name>.md` into a {@link SlashCommandSpec}. */
|
|
163
|
+
function parseCommand(text, name, source, file) {
|
|
164
|
+
const { raw, body } = splitFrontmatter(text);
|
|
165
|
+
const parsed = SlashFrontmatterSchema.safeParse(raw);
|
|
166
|
+
if (!parsed.success) {
|
|
167
|
+
throw new Error(`invalid frontmatter: ${formatZod(parsed.error)}`);
|
|
168
|
+
}
|
|
169
|
+
const fm = parsed.data;
|
|
170
|
+
if (fm.kind === "shell") {
|
|
171
|
+
if (!fm.command) {
|
|
172
|
+
throw new Error('a "shell" command requires a `command:` in frontmatter');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
name,
|
|
176
|
+
kind: "shell",
|
|
177
|
+
description: fm.description,
|
|
178
|
+
command: fm.command,
|
|
179
|
+
source,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// prompt (default): the markdown body IS the template.
|
|
183
|
+
const template = body.trim();
|
|
184
|
+
if (template === "") {
|
|
185
|
+
throw new Error('a "prompt" command needs a non-empty template body');
|
|
186
|
+
}
|
|
187
|
+
if (fm.command) {
|
|
188
|
+
throw new Error('a "prompt" command must not set `command:` (use kind: shell)');
|
|
189
|
+
}
|
|
190
|
+
void file;
|
|
191
|
+
return {
|
|
192
|
+
name,
|
|
193
|
+
kind: "prompt",
|
|
194
|
+
description: fm.description,
|
|
195
|
+
template,
|
|
196
|
+
source,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ── shared helpers ────────────────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* First occurrence of a name wins (candidates arrive in precedence order —
|
|
202
|
+
* project before user), so a project definition overrides a user one. A
|
|
203
|
+
* duplicate *within* a source was already reported by the scanner.
|
|
204
|
+
*/
|
|
205
|
+
function resolvePrecedence(candidates) {
|
|
206
|
+
const byName = new Map();
|
|
207
|
+
for (const c of candidates)
|
|
208
|
+
if (!byName.has(c.name))
|
|
209
|
+
byName.set(c.name, c);
|
|
210
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
211
|
+
}
|
|
212
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
213
|
+
/** Split a `---`-delimited frontmatter block (flat `key: value` scalars) from
|
|
214
|
+
* the body. A strict, tiny YAML subset — anything else is a loud error. */
|
|
215
|
+
function splitFrontmatter(text) {
|
|
216
|
+
if (!/^---[ \t]*\r?\n/.test(text)) {
|
|
217
|
+
throw new Error("must begin with a YAML frontmatter block delimited by '---'");
|
|
218
|
+
}
|
|
219
|
+
const match = FRONTMATTER_RE.exec(text);
|
|
220
|
+
if (!match) {
|
|
221
|
+
throw new Error("frontmatter block is not terminated by a closing '---'");
|
|
222
|
+
}
|
|
223
|
+
const raw = {};
|
|
224
|
+
for (const rawLine of match[1].split(/\r?\n/)) {
|
|
225
|
+
const line = rawLine.trim();
|
|
226
|
+
if (line === "" || line.startsWith("#"))
|
|
227
|
+
continue;
|
|
228
|
+
const colon = line.indexOf(":");
|
|
229
|
+
if (colon === -1) {
|
|
230
|
+
throw new Error(`invalid frontmatter line (expected "key: value"): ${JSON.stringify(rawLine)}`);
|
|
231
|
+
}
|
|
232
|
+
const key = line.slice(0, colon).trim();
|
|
233
|
+
if (key in raw)
|
|
234
|
+
throw new Error(`duplicate frontmatter key: ${JSON.stringify(key)}`);
|
|
235
|
+
raw[key] = stripQuotes(line.slice(colon + 1).trim());
|
|
236
|
+
}
|
|
237
|
+
return { raw, body: match[2] ?? "" };
|
|
238
|
+
}
|
|
239
|
+
function stripQuotes(value) {
|
|
240
|
+
if (value.length >= 2) {
|
|
241
|
+
const first = value[0];
|
|
242
|
+
const last = value[value.length - 1];
|
|
243
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
244
|
+
return value.slice(1, -1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
function formatZod(error) {
|
|
250
|
+
return error.issues
|
|
251
|
+
.map((i) => i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message)
|
|
252
|
+
.join("; ");
|
|
253
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, SlashFrontmatterSchema, defaultBlocking, type HookCatalog, type HookConfigError, type HookDefinition, type HookEvent, type HookSource, type HookSpec, type HookTrust, type SlashCommandSpec, type SlashKind, } from "./types.js";
|
|
2
|
+
export { defaultHookSources, loadHookCatalog, type HookSources, } from "./config.js";
|
|
3
|
+
export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, type TrustStore, } from "./trust.js";
|
|
4
|
+
export { HookRunner, type HookRunnerDeps, type TrustPromptInfo, } from "./runner.js";
|
|
5
|
+
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, type SlashResolution, } from "./slash.js";
|
|
6
|
+
export { buildHooksService, type BuildHooksServiceOptions, type HooksService, } from "./service.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, SlashFrontmatterSchema, defaultBlocking, } from "./types.js";
|
|
2
|
+
export { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
3
|
+
export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, } from "./trust.js";
|
|
4
|
+
export { HookRunner, } from "./runner.js";
|
|
5
|
+
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, } from "./slash.js";
|
|
6
|
+
export { buildHooksService, } from "./service.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ToolContext } from "../tools/types.js";
|
|
2
|
+
import { type TrustStore } from "./trust.js";
|
|
3
|
+
import type { HookDefinition, HookEvent } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The hook runner (C.19) — the security core. When a lifecycle event fires, it
|
|
6
|
+
* runs the matching user-authored hooks, but a hook is **never** an approval
|
|
7
|
+
* bypass:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Project trust gate** (supply-chain safety). Before ANY project hook runs,
|
|
10
|
+
* the repo must be trusted for its current hook fingerprint. Untrusted →
|
|
11
|
+
* prompt (interactive) or `CRUXY_E_HOOK_UNTRUSTED` (non-interactive / declined
|
|
12
|
+
* / `trustPrompt` off). A cloned repo's hooks never execute silently. User
|
|
13
|
+
* hooks skip this prompt (you authored them) but NOT the gate below.
|
|
14
|
+
* 2. **Per-command gate + sandbox** — every hook command goes through the SAME
|
|
15
|
+
* {@link runGatedShell} as `run_command`: the U.3 approval gate then the C.16
|
|
16
|
+
* sandbox (or host). There is no privileged route.
|
|
17
|
+
* 3. **Blocking vs advisory** — a blocking hook that fails aborts the action
|
|
18
|
+
* fail-closed (`CRUXY_E_HOOK_FAILED`); an advisory hook reports and continues.
|
|
19
|
+
*/
|
|
20
|
+
/** What the interactive trust prompt is shown. */
|
|
21
|
+
export interface TrustPromptInfo {
|
|
22
|
+
root: string;
|
|
23
|
+
hooks: HookDefinition[];
|
|
24
|
+
}
|
|
25
|
+
export interface HookRunnerDeps {
|
|
26
|
+
/** Resolved hooks (with sources) for this project. */
|
|
27
|
+
hooks: HookDefinition[];
|
|
28
|
+
/** Persisted per-repo trust (see `trust.ts`). */
|
|
29
|
+
trust: TrustStore;
|
|
30
|
+
/** `config.hooks.enabled` — when false, nothing ever fires. */
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
/** `config.hooks.trustPrompt` — when false, untrusted project hooks fail loud
|
|
33
|
+
* rather than prompting (never auto-trust). */
|
|
34
|
+
trustPrompt: boolean;
|
|
35
|
+
/** Whether cruxy can actually prompt (stdin is a TTY). */
|
|
36
|
+
interactive: boolean;
|
|
37
|
+
/** Project root — the trust key and fingerprint scope. */
|
|
38
|
+
cwd: string;
|
|
39
|
+
/** Interactive trust prompt (returns true to trust). Required only when a
|
|
40
|
+
* project defines hooks and `trustPrompt` + `interactive` are both on. */
|
|
41
|
+
promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
|
|
42
|
+
/** "running hook: <name>" surface (visible on every fire). */
|
|
43
|
+
announce?: (message: string) => void;
|
|
44
|
+
/** Advisory-failure surface (blocking failures throw instead). */
|
|
45
|
+
reportFailure?: (message: string) => void;
|
|
46
|
+
/** Injectable clock for the recorded trust timestamp (tests). */
|
|
47
|
+
now?: () => string;
|
|
48
|
+
}
|
|
49
|
+
export declare class HookRunner {
|
|
50
|
+
private readonly deps;
|
|
51
|
+
constructor(deps: HookRunnerDeps);
|
|
52
|
+
/** The project hooks — the trust-gated subset. */
|
|
53
|
+
private get projectHooks();
|
|
54
|
+
/**
|
|
55
|
+
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
56
|
+
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
|
57
|
+
* when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
|
|
58
|
+
* hooks are not trusted. A no-op when hooks are disabled or none match.
|
|
59
|
+
*/
|
|
60
|
+
fire(event: HookEvent, ctx: ToolContext): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Ensure this repo's project hooks are trusted for their current fingerprint.
|
|
63
|
+
* Records trust on an interactive accept; throws `CRUXY_E_HOOK_UNTRUSTED` on
|
|
64
|
+
* decline, when `trustPrompt` is off, or when non-interactive — NEVER
|
|
65
|
+
* auto-trusts. The fingerprint covers ALL project hooks, so a change to any of
|
|
66
|
+
* them invalidates a prior decision (stale → re-prompt).
|
|
67
|
+
*/
|
|
68
|
+
private ensureProjectTrust;
|
|
69
|
+
/**
|
|
70
|
+
* Run one hook command through the shared gate + sandbox path and reduce it to
|
|
71
|
+
* a pass/fail verdict. A throw from {@link runGatedShell} (non-interactive
|
|
72
|
+
* approval, sandbox start failure) is a failure whose policy the caller
|
|
73
|
+
* applies — so an advisory hook can never abort the run on an infra error.
|
|
74
|
+
*/
|
|
75
|
+
private runOne;
|
|
76
|
+
}
|