@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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { HookDefinition, HookTrust } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The project-hook trust model (C.19). Trust is recorded in the GLOBAL dir
|
|
4
|
+
* (`~/.cruxy/trust.json`) — in the user's home, NEVER inside a repo — so cloning
|
|
5
|
+
* a repo carries zero trust and an attacker cannot ship a pre-trusted marker.
|
|
6
|
+
*
|
|
7
|
+
* Trust is bound to a {@link fingerprintHooks fingerprint} of the exact project
|
|
8
|
+
* hook commands seen at trust time. It is re-checked on every run: if the
|
|
9
|
+
* project's hooks change (a command / event / blocking edit), the fingerprint no
|
|
10
|
+
* longer matches and trust is stale → the user is re-prompted. This is what
|
|
11
|
+
* defeats trust-then-swap.
|
|
12
|
+
*/
|
|
13
|
+
/** ~/.cruxy/trust.json */
|
|
14
|
+
export declare function trustPath(): string;
|
|
15
|
+
/**
|
|
16
|
+
* A stable content fingerprint of a repo's PROJECT hook definitions. Canonical
|
|
17
|
+
* by construction so a benign reformat of `hooks.json` (reindent, reordered
|
|
18
|
+
* keys, extra whitespace inside a command) does NOT change it, while any real
|
|
19
|
+
* change to a command, event, or blocking flag DOES:
|
|
20
|
+
* - only the meaning-bearing fields are hashed (name, event, command, blocking);
|
|
21
|
+
* - the command string is whitespace-normalized (trim + collapse runs);
|
|
22
|
+
* - specs are sorted by name and serialized with a fixed key order.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fingerprintHooks(hooks: readonly HookDefinition[]): string;
|
|
25
|
+
/** The persisted trust seam — a file-backed store in production, injectable for
|
|
26
|
+
* tests. `get`/`record` are synchronous (the record is tiny). */
|
|
27
|
+
export interface TrustStore {
|
|
28
|
+
/** The recorded decision for a repo root, or undefined if never trusted. */
|
|
29
|
+
get(root: string): HookTrust | undefined;
|
|
30
|
+
/** Persist a trust decision (overwrites any prior one for the same root). */
|
|
31
|
+
record(trust: HookTrust): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Is this repo's current project-hook set trusted? True only when a decision
|
|
35
|
+
* exists AND its fingerprint matches the current one — a changed hook set is
|
|
36
|
+
* treated as untrusted (stale), forcing a fresh decision.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isTrusted(store: TrustStore, root: string, currentFingerprint: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The real store, persisting to `~/.cruxy/trust.json` as `{ [root]: HookTrust }`.
|
|
41
|
+
* Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
|
|
42
|
+
* (fail-closed — a broken trust file must never grant trust).
|
|
43
|
+
*/
|
|
44
|
+
export declare function fileTrustStore(file?: string): TrustStore;
|
|
45
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
46
|
+
export declare function memoryTrustStore(seed?: HookTrust[]): TrustStore;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { globalDir } from "../config/paths.js";
|
|
5
|
+
import { TRUST_FILE_NAME } from "../constants.js";
|
|
6
|
+
/**
|
|
7
|
+
* The project-hook trust model (C.19). Trust is recorded in the GLOBAL dir
|
|
8
|
+
* (`~/.cruxy/trust.json`) — in the user's home, NEVER inside a repo — so cloning
|
|
9
|
+
* a repo carries zero trust and an attacker cannot ship a pre-trusted marker.
|
|
10
|
+
*
|
|
11
|
+
* Trust is bound to a {@link fingerprintHooks fingerprint} of the exact project
|
|
12
|
+
* hook commands seen at trust time. It is re-checked on every run: if the
|
|
13
|
+
* project's hooks change (a command / event / blocking edit), the fingerprint no
|
|
14
|
+
* longer matches and trust is stale → the user is re-prompted. This is what
|
|
15
|
+
* defeats trust-then-swap.
|
|
16
|
+
*/
|
|
17
|
+
/** ~/.cruxy/trust.json */
|
|
18
|
+
export function trustPath() {
|
|
19
|
+
return path.join(globalDir(), TRUST_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A stable content fingerprint of a repo's PROJECT hook definitions. Canonical
|
|
23
|
+
* by construction so a benign reformat of `hooks.json` (reindent, reordered
|
|
24
|
+
* keys, extra whitespace inside a command) does NOT change it, while any real
|
|
25
|
+
* change to a command, event, or blocking flag DOES:
|
|
26
|
+
* - only the meaning-bearing fields are hashed (name, event, command, blocking);
|
|
27
|
+
* - the command string is whitespace-normalized (trim + collapse runs);
|
|
28
|
+
* - specs are sorted by name and serialized with a fixed key order.
|
|
29
|
+
*/
|
|
30
|
+
export function fingerprintHooks(hooks) {
|
|
31
|
+
const canonical = hooks
|
|
32
|
+
.map((h) => ({
|
|
33
|
+
name: h.name,
|
|
34
|
+
event: h.event,
|
|
35
|
+
command: h.command.trim().replace(/\s+/g, " "),
|
|
36
|
+
blocking: h.blocking,
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
39
|
+
// Fixed key order — JSON of an object literal preserves insertion order.
|
|
40
|
+
.map((h) => [h.name, h.event, h.command, h.blocking]);
|
|
41
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Is this repo's current project-hook set trusted? True only when a decision
|
|
45
|
+
* exists AND its fingerprint matches the current one — a changed hook set is
|
|
46
|
+
* treated as untrusted (stale), forcing a fresh decision.
|
|
47
|
+
*/
|
|
48
|
+
export function isTrusted(store, root, currentFingerprint) {
|
|
49
|
+
const record = store.get(path.resolve(root));
|
|
50
|
+
return record !== undefined && record.fingerprint === currentFingerprint;
|
|
51
|
+
}
|
|
52
|
+
// ── file-backed store ─────────────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* The real store, persisting to `~/.cruxy/trust.json` as `{ [root]: HookTrust }`.
|
|
55
|
+
* Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
|
|
56
|
+
* (fail-closed — a broken trust file must never grant trust).
|
|
57
|
+
*/
|
|
58
|
+
export function fileTrustStore(file = trustPath()) {
|
|
59
|
+
let cache = null;
|
|
60
|
+
const load = () => {
|
|
61
|
+
if (cache)
|
|
62
|
+
return cache;
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
65
|
+
cache =
|
|
66
|
+
raw && typeof raw === "object"
|
|
67
|
+
? raw
|
|
68
|
+
: {};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Missing or corrupt → no trust (fail-closed).
|
|
72
|
+
cache = {};
|
|
73
|
+
}
|
|
74
|
+
return cache;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
get(root) {
|
|
78
|
+
return load()[path.resolve(root)];
|
|
79
|
+
},
|
|
80
|
+
record(trust) {
|
|
81
|
+
const store = load();
|
|
82
|
+
store[path.resolve(trust.root)] = {
|
|
83
|
+
...trust,
|
|
84
|
+
root: path.resolve(trust.root),
|
|
85
|
+
};
|
|
86
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
// 0600: trust records name local paths; keep them owner-only.
|
|
88
|
+
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
89
|
+
if (existsSync(file))
|
|
90
|
+
cache = store;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
95
|
+
export function memoryTrustStore(seed = []) {
|
|
96
|
+
const store = new Map();
|
|
97
|
+
for (const t of seed)
|
|
98
|
+
store.set(path.resolve(t.root), t);
|
|
99
|
+
return {
|
|
100
|
+
get: (root) => store.get(path.resolve(root)),
|
|
101
|
+
record: (trust) => void store.set(path.resolve(trust.root), {
|
|
102
|
+
...trust,
|
|
103
|
+
root: path.resolve(trust.root),
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Types for hooks + custom slash commands (C.19). Everything here is **data**:
|
|
4
|
+
* hook and command definitions are validated (fail-loud, malformed excluded) and
|
|
5
|
+
* NEVER eval'd — a hook's `command` string only ever reaches execution through
|
|
6
|
+
* the shared U.3-gated + C.16-sandboxed shell path (see `runner.ts`), exactly
|
|
7
|
+
* like an agent-run command.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The core hook lifecycle events. Deliberately a small, closed set for this
|
|
11
|
+
* build; the enum is the extension point for future events (out of scope).
|
|
12
|
+
* - `before-run` / `after-run` — around a whole user turn (the agent loop).
|
|
13
|
+
* - `before-tool` / `after-tool` — around each tool call.
|
|
14
|
+
* - `on-file-change` — after a file-mutating tool (write/edit/patch) succeeds.
|
|
15
|
+
*/
|
|
16
|
+
export declare const HOOK_EVENTS: readonly ["before-run", "after-run", "before-tool", "after-tool", "on-file-change"];
|
|
17
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
18
|
+
/** `before-*` events default to blocking (fail-closed pre-checks); everything
|
|
19
|
+
* that fires *after* an action defaults to advisory. Overridable per hook. */
|
|
20
|
+
export declare function defaultBlocking(event: HookEvent): boolean;
|
|
21
|
+
/** Where a hook / command was discovered. `project` is the supply-chain-risky
|
|
22
|
+
* source (another author); `user` is your own `~/.cruxy`. */
|
|
23
|
+
export type HookSource = "project" | "user";
|
|
24
|
+
/** Precedence: a project definition overrides a user one of the same name. */
|
|
25
|
+
export declare const HOOK_SOURCE_PRECEDENCE: readonly HookSource[];
|
|
26
|
+
/** Strict schema for one hook definition in a `hooks.json`. Unknown keys are a
|
|
27
|
+
* loud error (`.strict()`); `blocking` is optional (resolved from the event). */
|
|
28
|
+
export declare const HookSpecSchema: z.ZodObject<{
|
|
29
|
+
name: z.ZodString;
|
|
30
|
+
event: z.ZodEnum<["before-run", "after-run", "before-tool", "after-tool", "on-file-change"]>;
|
|
31
|
+
command: z.ZodString;
|
|
32
|
+
blocking: z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
}, "strict", z.ZodTypeAny, {
|
|
34
|
+
name: string;
|
|
35
|
+
command: string;
|
|
36
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
37
|
+
blocking?: boolean | undefined;
|
|
38
|
+
}, {
|
|
39
|
+
name: string;
|
|
40
|
+
command: string;
|
|
41
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
42
|
+
blocking?: boolean | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
export type HookSpec = z.infer<typeof HookSpecSchema>;
|
|
45
|
+
/** The whole `hooks.json` file shape. */
|
|
46
|
+
export declare const HooksFileSchema: z.ZodObject<{
|
|
47
|
+
hooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
48
|
+
name: z.ZodString;
|
|
49
|
+
event: z.ZodEnum<["before-run", "after-run", "before-tool", "after-tool", "on-file-change"]>;
|
|
50
|
+
command: z.ZodString;
|
|
51
|
+
blocking: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
+
}, "strict", z.ZodTypeAny, {
|
|
53
|
+
name: string;
|
|
54
|
+
command: string;
|
|
55
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
56
|
+
blocking?: boolean | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
name: string;
|
|
59
|
+
command: string;
|
|
60
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
61
|
+
blocking?: boolean | undefined;
|
|
62
|
+
}>, "many">>;
|
|
63
|
+
}, "strict", z.ZodTypeAny, {
|
|
64
|
+
hooks: {
|
|
65
|
+
name: string;
|
|
66
|
+
command: string;
|
|
67
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
68
|
+
blocking?: boolean | undefined;
|
|
69
|
+
}[];
|
|
70
|
+
}, {
|
|
71
|
+
hooks?: {
|
|
72
|
+
name: string;
|
|
73
|
+
command: string;
|
|
74
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
75
|
+
blocking?: boolean | undefined;
|
|
76
|
+
}[] | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
/** A validated hook, tagged with its source and with `blocking` resolved. */
|
|
79
|
+
export interface HookDefinition {
|
|
80
|
+
name: string;
|
|
81
|
+
event: HookEvent;
|
|
82
|
+
command: string;
|
|
83
|
+
/** Resolved (event default applied) — the value the runner acts on. */
|
|
84
|
+
blocking: boolean;
|
|
85
|
+
source: HookSource;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A custom slash command. `prompt` (the safe default) expands a text template
|
|
89
|
+
* fed to the agent; `shell` binds a command that runs through the SAME gate +
|
|
90
|
+
* sandbox as everything else. Never arbitrary code by default.
|
|
91
|
+
*/
|
|
92
|
+
export type SlashKind = "prompt" | "shell";
|
|
93
|
+
/** Strict frontmatter for a `commands/<name>.md`. The command NAME comes from
|
|
94
|
+
* the filename; the markdown BODY is the prompt template (kind `prompt`). */
|
|
95
|
+
export declare const SlashFrontmatterSchema: z.ZodObject<{
|
|
96
|
+
kind: z.ZodDefault<z.ZodEnum<["prompt", "shell"]>>;
|
|
97
|
+
description: z.ZodString;
|
|
98
|
+
/** Required (and only meaningful) when `kind: "shell"`. */
|
|
99
|
+
command: z.ZodOptional<z.ZodString>;
|
|
100
|
+
}, "strict", z.ZodTypeAny, {
|
|
101
|
+
kind: "shell" | "prompt";
|
|
102
|
+
description: string;
|
|
103
|
+
command?: string | undefined;
|
|
104
|
+
}, {
|
|
105
|
+
description: string;
|
|
106
|
+
command?: string | undefined;
|
|
107
|
+
kind?: "shell" | "prompt" | undefined;
|
|
108
|
+
}>;
|
|
109
|
+
export type SlashFrontmatter = z.infer<typeof SlashFrontmatterSchema>;
|
|
110
|
+
/** A validated custom slash command, tagged with source. */
|
|
111
|
+
export interface SlashCommandSpec {
|
|
112
|
+
name: string;
|
|
113
|
+
kind: SlashKind;
|
|
114
|
+
description: string;
|
|
115
|
+
/** Prompt template (kind `prompt`); `{{args}}` is substituted at expansion. */
|
|
116
|
+
template?: string;
|
|
117
|
+
/** Shell command (kind `shell`) — gated + sandboxed when run. */
|
|
118
|
+
command?: string;
|
|
119
|
+
source: HookSource;
|
|
120
|
+
}
|
|
121
|
+
/** A malformed hook / command, excluded from the catalog and surfaced loudly
|
|
122
|
+
* (never eval'd, never silently dropped). */
|
|
123
|
+
export interface HookConfigError {
|
|
124
|
+
source: HookSource;
|
|
125
|
+
/** Absolute path of the offending file. */
|
|
126
|
+
file: string;
|
|
127
|
+
/** The would-be name (filename / spec name), for display. */
|
|
128
|
+
name: string;
|
|
129
|
+
message: string;
|
|
130
|
+
}
|
|
131
|
+
/** The resolved hook catalog: valid definitions (deduped by precedence) + the
|
|
132
|
+
* malformed ones that were excluded. */
|
|
133
|
+
export interface HookCatalog {
|
|
134
|
+
hooks: HookDefinition[];
|
|
135
|
+
commands: SlashCommandSpec[];
|
|
136
|
+
errors: HookConfigError[];
|
|
137
|
+
}
|
|
138
|
+
/** One repo's recorded trust decision (persisted in `~/.cruxy/trust.json`). The
|
|
139
|
+
* `fingerprint` binds trust to the exact hook commands seen at trust time. */
|
|
140
|
+
export interface HookTrust {
|
|
141
|
+
/** Absolute project root. */
|
|
142
|
+
root: string;
|
|
143
|
+
/** sha256 of the canonicalized project hook specs (see `trust.ts`). */
|
|
144
|
+
fingerprint: string;
|
|
145
|
+
/** ISO timestamp the decision was recorded. */
|
|
146
|
+
at: string;
|
|
147
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Types for hooks + custom slash commands (C.19). Everything here is **data**:
|
|
4
|
+
* hook and command definitions are validated (fail-loud, malformed excluded) and
|
|
5
|
+
* NEVER eval'd — a hook's `command` string only ever reaches execution through
|
|
6
|
+
* the shared U.3-gated + C.16-sandboxed shell path (see `runner.ts`), exactly
|
|
7
|
+
* like an agent-run command.
|
|
8
|
+
*/
|
|
9
|
+
// ── lifecycle events ──────────────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* The core hook lifecycle events. Deliberately a small, closed set for this
|
|
12
|
+
* build; the enum is the extension point for future events (out of scope).
|
|
13
|
+
* - `before-run` / `after-run` — around a whole user turn (the agent loop).
|
|
14
|
+
* - `before-tool` / `after-tool` — around each tool call.
|
|
15
|
+
* - `on-file-change` — after a file-mutating tool (write/edit/patch) succeeds.
|
|
16
|
+
*/
|
|
17
|
+
export const HOOK_EVENTS = [
|
|
18
|
+
"before-run",
|
|
19
|
+
"after-run",
|
|
20
|
+
"before-tool",
|
|
21
|
+
"after-tool",
|
|
22
|
+
"on-file-change",
|
|
23
|
+
];
|
|
24
|
+
/** `before-*` events default to blocking (fail-closed pre-checks); everything
|
|
25
|
+
* that fires *after* an action defaults to advisory. Overridable per hook. */
|
|
26
|
+
export function defaultBlocking(event) {
|
|
27
|
+
return event === "before-run" || event === "before-tool";
|
|
28
|
+
}
|
|
29
|
+
/** Precedence: a project definition overrides a user one of the same name. */
|
|
30
|
+
export const HOOK_SOURCE_PRECEDENCE = [
|
|
31
|
+
"project",
|
|
32
|
+
"user",
|
|
33
|
+
];
|
|
34
|
+
// ── hook specs ────────────────────────────────────────────────────────────────
|
|
35
|
+
/** Strict schema for one hook definition in a `hooks.json`. Unknown keys are a
|
|
36
|
+
* loud error (`.strict()`); `blocking` is optional (resolved from the event). */
|
|
37
|
+
export const HookSpecSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
name: z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, "hook name must be kebab-case (lowercase letters, digits, hyphens)"),
|
|
43
|
+
event: z.enum(HOOK_EVENTS),
|
|
44
|
+
command: z.string().min(1, "hook command must be a non-empty string"),
|
|
45
|
+
blocking: z.boolean().optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
/** The whole `hooks.json` file shape. */
|
|
49
|
+
export const HooksFileSchema = z
|
|
50
|
+
.object({ hooks: z.array(HookSpecSchema).default([]) })
|
|
51
|
+
.strict();
|
|
52
|
+
/** Strict frontmatter for a `commands/<name>.md`. The command NAME comes from
|
|
53
|
+
* the filename; the markdown BODY is the prompt template (kind `prompt`). */
|
|
54
|
+
export const SlashFrontmatterSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
kind: z.enum(["prompt", "shell"]).default("prompt"),
|
|
57
|
+
description: z.string().min(1),
|
|
58
|
+
/** Required (and only meaningful) when `kind: "shell"`. */
|
|
59
|
+
command: z.string().min(1).optional(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -17,7 +17,7 @@ const c = (io) => themeForColor(io.color);
|
|
|
17
17
|
*/
|
|
18
18
|
export async function acquireKeyStep(io, deps, provider) {
|
|
19
19
|
const col = c(io);
|
|
20
|
-
io.write(`\nYou'll need a
|
|
20
|
+
io.write(`\nYou'll need a cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
|
|
21
21
|
for (let attempt = 1; attempt <= MAX_KEY_ATTEMPTS; attempt++) {
|
|
22
22
|
io.write(col.strong("Paste your API key: "));
|
|
23
23
|
const key = (await io.readSecret()).trim();
|
|
@@ -93,7 +93,10 @@ export async function firstWinStep(io, deps) {
|
|
|
93
93
|
io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.muted("[Y/n]")} `);
|
|
94
94
|
const key = (await io.readKey()).toLowerCase();
|
|
95
95
|
io.write("\n");
|
|
96
|
-
|
|
96
|
+
// Decline on an explicit `n`, and treat Ctrl-C / EOF / escape (readKey → "")
|
|
97
|
+
// as a clean cancel rather than "proceed": a cancel must never launch a task.
|
|
98
|
+
// Enter (readKey → "\n") keeps the `[Y/n]` default and runs the demo.
|
|
99
|
+
if (key === "n" || key === "")
|
|
97
100
|
return { status: "skipped" };
|
|
98
101
|
await deps.runTask(FIRST_WIN_PROMPT);
|
|
99
102
|
return { status: "ok" };
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
4
|
+
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
5
|
+
* through this one axis. A screen reader implies it (no live region to animate).
|
|
6
|
+
* The single source of the spinner gate.
|
|
7
|
+
*/
|
|
8
|
+
export declare function detectReducedMotion(env?: NodeJS.ProcessEnv): boolean;
|
|
2
9
|
/**
|
|
3
10
|
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
4
11
|
* given its inputs (stream + env are injectable), so every row of the
|
|
@@ -6,7 +13,8 @@ import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
|
6
13
|
*
|
|
7
14
|
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
8
15
|
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
16
|
+
* The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
|
|
17
|
+
* in-place status updates; a dumb terminal supports neither; reduced motion and
|
|
18
|
+
* screen-reader mode compose orthogonally with color and unicode.
|
|
11
19
|
*/
|
|
12
20
|
export declare function detectCapabilities(stream?: RenderStream, env?: NodeJS.ProcessEnv): RenderCapabilities;
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { shouldUseColor } from "../errors/index.js";
|
|
2
|
-
import { detectUnicode } from "../theme/index.js";
|
|
2
|
+
import { detectScreenReader, detectUnicode } from "../theme/index.js";
|
|
3
|
+
/** Set-and-non-empty (the NO_COLOR convention): any non-empty value counts. */
|
|
4
|
+
function isSet(value) {
|
|
5
|
+
return value !== undefined && value !== "";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
9
|
+
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
10
|
+
* through this one axis. A screen reader implies it (no live region to animate).
|
|
11
|
+
* The single source of the spinner gate.
|
|
12
|
+
*/
|
|
13
|
+
export function detectReducedMotion(env = process.env) {
|
|
14
|
+
return (isSet(env.NO_MOTION) ||
|
|
15
|
+
isSet(env.CRUXY_REDUCED_MOTION) ||
|
|
16
|
+
isSet(env.CRUXY_NO_SPINNER) ||
|
|
17
|
+
detectScreenReader(env));
|
|
18
|
+
}
|
|
3
19
|
/**
|
|
4
20
|
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
5
21
|
* given its inputs (stream + env are injectable), so every row of the
|
|
@@ -7,20 +23,24 @@ import { detectUnicode } from "../theme/index.js";
|
|
|
7
23
|
*
|
|
8
24
|
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
9
25
|
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
26
|
+
* The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
|
|
27
|
+
* in-place status updates; a dumb terminal supports neither; reduced motion and
|
|
28
|
+
* screen-reader mode compose orthogonally with color and unicode.
|
|
12
29
|
*/
|
|
13
30
|
export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
14
31
|
const tty = Boolean(stream.isTTY);
|
|
15
32
|
const dumb = env.TERM === "dumb";
|
|
16
33
|
const cursor = tty && !dumb;
|
|
34
|
+
const reducedMotion = detectReducedMotion(env);
|
|
17
35
|
return {
|
|
18
36
|
tty,
|
|
19
37
|
color: shouldUseColor(stream, env) && !dumb,
|
|
20
38
|
cursor,
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
// Motion is the single gate now: CRUXY_NO_SPINNER flows through it (alias),
|
|
40
|
+
// as do NO_MOTION / CRUXY_REDUCED_MOTION and an implied screen reader.
|
|
41
|
+
spinner: cursor && !reducedMotion,
|
|
42
|
+
reducedMotion,
|
|
43
|
+
screenReader: detectScreenReader(env),
|
|
24
44
|
// Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
|
|
25
45
|
// ASCII glyphs; everything else (incl. pipes) keeps unicode.
|
|
26
46
|
unicode: detectUnicode(env),
|
package/dist/render/index.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { RenderStream, StreamRenderer } from "./types.js";
|
|
2
2
|
export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
|
|
3
|
-
export { detectCapabilities } from "./capabilities.js";
|
|
3
|
+
export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
|
|
4
4
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
5
5
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
6
6
|
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
7
7
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
8
|
+
export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
8
9
|
export { TtyRenderer } from "./tty-renderer.js";
|
|
9
10
|
/**
|
|
10
|
-
* Build the renderer for the detected environment
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Build the renderer for the detected environment (U.11 adds the first branch):
|
|
12
|
+
* - `screenReader` → the linear, worded {@link ScreenReaderRenderer}, regardless
|
|
13
|
+
* of cursor support (a screen-reader TTY must not get the live region);
|
|
14
|
+
* - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
|
|
15
|
+
* timer, when `reducedMotion`);
|
|
16
|
+
* - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
|
|
17
|
+
* Everything downstream talks to {@link StreamRenderer} and never re-probes.
|
|
14
18
|
*/
|
|
15
19
|
export declare function createRenderer(out?: RenderStream, err?: RenderStream, env?: NodeJS.ProcessEnv): StreamRenderer;
|
package/dist/render/index.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { detectCapabilities } from "./capabilities.js";
|
|
2
2
|
import { PlainRenderer } from "./plain-renderer.js";
|
|
3
|
+
import { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
3
4
|
import { TtyRenderer } from "./tty-renderer.js";
|
|
4
|
-
export { detectCapabilities } from "./capabilities.js";
|
|
5
|
+
export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
|
|
5
6
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
6
7
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
7
8
|
export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
|
|
8
9
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
10
|
+
export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
9
11
|
export { TtyRenderer } from "./tty-renderer.js";
|
|
10
12
|
/**
|
|
11
|
-
* Build the renderer for the detected environment
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Build the renderer for the detected environment (U.11 adds the first branch):
|
|
14
|
+
* - `screenReader` → the linear, worded {@link ScreenReaderRenderer}, regardless
|
|
15
|
+
* of cursor support (a screen-reader TTY must not get the live region);
|
|
16
|
+
* - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
|
|
17
|
+
* timer, when `reducedMotion`);
|
|
18
|
+
* - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
|
|
19
|
+
* Everything downstream talks to {@link StreamRenderer} and never re-probes.
|
|
15
20
|
*/
|
|
16
21
|
export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
|
|
17
22
|
const caps = detectCapabilities(out, env);
|
|
23
|
+
if (caps.screenReader)
|
|
24
|
+
return new ScreenReaderRenderer(caps, out, err);
|
|
18
25
|
return caps.cursor
|
|
19
26
|
? new TtyRenderer(caps, out)
|
|
20
27
|
: new PlainRenderer(caps, out, err);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
2
|
import { type Theme } from "../theme/index.js";
|
|
3
|
-
import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
|
+
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
4
4
|
/**
|
|
5
5
|
* The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
|
|
6
6
|
* cursor-control sequences ever, and no color unless the capabilities say so
|
|
@@ -30,8 +30,8 @@ export declare class PlainRenderer implements StreamRenderer {
|
|
|
30
30
|
note(text: string): void;
|
|
31
31
|
preview(preview: ActionPreview): void;
|
|
32
32
|
status(): void;
|
|
33
|
-
setPhase(): void;
|
|
34
|
-
progress(): void;
|
|
33
|
+
setPhase(phase: RenderPhase | null): void;
|
|
34
|
+
progress(state: ProgressState | null): void;
|
|
35
35
|
toolLifecycle(event: ToolLifecycleEvent): void;
|
|
36
36
|
promptResolved(): void;
|
|
37
37
|
endTurn(): void;
|
|
@@ -59,12 +59,20 @@ export class PlainRenderer {
|
|
|
59
59
|
status() {
|
|
60
60
|
// Append-only medium: transient state is dropped by design.
|
|
61
61
|
}
|
|
62
|
-
setPhase
|
|
62
|
+
// setPhase / progress declare their StreamRenderer params because the
|
|
63
|
+
// ScreenReaderRenderer subclass overrides them to announce (the override must
|
|
64
|
+
// be signature-compatible). In the plain medium they are no-ops: there is no
|
|
65
|
+
// live region to update, so the guard simply returns.
|
|
66
|
+
setPhase(phase) {
|
|
63
67
|
// Phases are live-region state; there is no live region here (U.4).
|
|
68
|
+
if (phase !== null)
|
|
69
|
+
return;
|
|
64
70
|
}
|
|
65
|
-
progress() {
|
|
71
|
+
progress(state) {
|
|
66
72
|
// The committed plan trail (C.31, via PromptIO) is the record in this
|
|
67
73
|
// medium; a live [i/n] prefix would just duplicate it line by line.
|
|
74
|
+
if (state !== null)
|
|
75
|
+
return;
|
|
68
76
|
}
|
|
69
77
|
toolLifecycle(event) {
|
|
70
78
|
if (event.event === "start") {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PlainRenderer } from "./plain-renderer.js";
|
|
2
|
+
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, ToolLifecycleEvent } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
|
|
5
|
+
* append-only, zero-cursor-control output — plus two things a screen reader
|
|
6
|
+
* needs that the plain path drops.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
|
|
9
|
+
* table (via `caps.screenReader`), so every inherited code path that writes
|
|
10
|
+
* `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
|
|
11
|
+
* Nothing here special-cases it — the glyph table does the work.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Announced state.** A screen reader cannot see an in-place live region, so
|
|
14
|
+
* each state change is emitted as its own committed line (never a redraw):
|
|
15
|
+
* a phase becomes `working: read_file src/x.ts`, a tool run brackets as
|
|
16
|
+
* `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
|
|
17
|
+
* Announcements are deduped by phase *identity* so token-count updates within
|
|
18
|
+
* the same activity don't repeat a line.
|
|
19
|
+
*
|
|
20
|
+
* Everything else — text streaming, `endSegment`, `note`, `preview` — is
|
|
21
|
+
* inherited unchanged. This is the whole of screen-reader mode: no parallel
|
|
22
|
+
* path, no behavior change to the plain or TTY renderers.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ScreenReaderRenderer extends PlainRenderer {
|
|
25
|
+
/** Identity of the last phase announced, so we speak each activity once. */
|
|
26
|
+
private lastPhaseId;
|
|
27
|
+
/** Text of the last progress line announced, to avoid repeats. */
|
|
28
|
+
private lastProgress;
|
|
29
|
+
constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
|
|
30
|
+
/**
|
|
31
|
+
* Announce a phase transition as a committed line. `null`, `awaiting-approval`
|
|
32
|
+
* (the prompt block is its own announcement), and `calling-tool` (announced by
|
|
33
|
+
* {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
|
|
34
|
+
*/
|
|
35
|
+
setPhase(phase: RenderPhase | null): void;
|
|
36
|
+
/** Announce plan-step progress as `step i of n: title`, deduped. */
|
|
37
|
+
progress(state: ProgressState | null): void;
|
|
38
|
+
/**
|
|
39
|
+
* Bracket a tool call with two committed lines — `working: label` on start,
|
|
40
|
+
* `done/failed label` on end — so a screen-reader user hears both that a call
|
|
41
|
+
* began (crucial for long ones) and how it resolved. The end note + honest
|
|
42
|
+
* duration is the inherited plain behavior; only the start line is added.
|
|
43
|
+
*/
|
|
44
|
+
toolLifecycle(event: ToolLifecycleEvent): void;
|
|
45
|
+
}
|