@cruxy/cli 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/agent/loop.d.ts +14 -0
  2. package/dist/agent/loop.js +47 -1
  3. package/dist/agent/session.d.ts +11 -1
  4. package/dist/agent/session.js +14 -1
  5. package/dist/approval/prompt.js +17 -3
  6. package/dist/brand/index.d.ts +1 -0
  7. package/dist/brand/index.js +1 -0
  8. package/dist/brand/voice.d.ts +74 -0
  9. package/dist/brand/voice.js +73 -0
  10. package/dist/cli/commands/checkpoint.js +1 -1
  11. package/dist/cli/commands/hooks.d.ts +8 -0
  12. package/dist/cli/commands/hooks.js +83 -0
  13. package/dist/cli/commands/init.js +1 -1
  14. package/dist/cli/commands/pr.js +1 -1
  15. package/dist/cli/commands/rollback.js +1 -1
  16. package/dist/cli/commands/run.js +13 -3
  17. package/dist/cli/commands/skills.js +2 -2
  18. package/dist/cli/program.js +5 -2
  19. package/dist/cli/repl.d.ts +2 -1
  20. package/dist/cli/repl.js +54 -3
  21. package/dist/cli/session-factory.d.ts +2 -2
  22. package/dist/cli/session-factory.js +4 -2
  23. package/dist/components/fuzzy.js +7 -1
  24. package/dist/config/schema.d.ts +81 -30
  25. package/dist/config/schema.js +22 -0
  26. package/dist/constants.d.ts +9 -0
  27. package/dist/constants.js +9 -0
  28. package/dist/errors/constructors.d.ts +16 -0
  29. package/dist/errors/constructors.js +57 -0
  30. package/dist/errors/types.d.ts +11 -0
  31. package/dist/errors/types.js +19 -0
  32. package/dist/hooks/config.d.ts +21 -0
  33. package/dist/hooks/config.js +253 -0
  34. package/dist/hooks/index.d.ts +6 -0
  35. package/dist/hooks/index.js +6 -0
  36. package/dist/hooks/runner.d.ts +76 -0
  37. package/dist/hooks/runner.js +114 -0
  38. package/dist/hooks/service.d.ts +38 -0
  39. package/dist/hooks/service.js +49 -0
  40. package/dist/hooks/slash.d.ts +48 -0
  41. package/dist/hooks/slash.js +58 -0
  42. package/dist/hooks/trust.d.ts +46 -0
  43. package/dist/hooks/trust.js +106 -0
  44. package/dist/hooks/types.d.ts +147 -0
  45. package/dist/hooks/types.js +61 -0
  46. package/dist/onboarding/steps.js +5 -2
  47. package/dist/render/capabilities.d.ts +10 -2
  48. package/dist/render/capabilities.js +26 -6
  49. package/dist/render/index.d.ts +9 -5
  50. package/dist/render/index.js +12 -5
  51. package/dist/render/plain-renderer.d.ts +3 -3
  52. package/dist/render/plain-renderer.js +10 -2
  53. package/dist/render/screen-reader-renderer.d.ts +45 -0
  54. package/dist/render/screen-reader-renderer.js +75 -0
  55. package/dist/render/types.d.ts +15 -1
  56. package/dist/theme/resolve.d.ts +18 -7
  57. package/dist/theme/resolve.js +32 -10
  58. package/dist/theme/tokens.d.ts +16 -1
  59. package/dist/theme/tokens.js +27 -0
  60. package/dist/tools/shell/exec.d.ts +53 -0
  61. package/dist/tools/shell/exec.js +128 -0
  62. package/dist/tools/shell/run-command.d.ts +4 -0
  63. package/dist/tools/shell/run-command.js +26 -116
  64. package/package.json +1 -1
@@ -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();
@@ -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 Cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
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
- if (key === "n")
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
- * Cursor control and color are independent axes a NO_COLOR terminal still
10
- * supports in-place status updates; a dumb terminal supports neither.
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
- * Cursor control and color are independent axes a NO_COLOR terminal still
11
- * supports in-place status updates; a dumb terminal supports neither.
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
- // Same set-and-non-empty convention as NO_COLOR: any value disables.
22
- spinner: cursor &&
23
- !(env.CRUXY_NO_SPINNER !== undefined && env.CRUXY_NO_SPINNER !== ""),
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),
@@ -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: the managed-live-region
11
- * {@link TtyRenderer} when cursor control is safe, otherwise the append-only
12
- * {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
13
- * to the {@link StreamRenderer} interface and never re-probes the terminal.
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;
@@ -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: the managed-live-region
12
- * {@link TtyRenderer} when cursor control is safe, otherwise the append-only
13
- * {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
14
- * to the {@link StreamRenderer} interface and never re-probes the terminal.
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
+ }