@cruxy/cli 0.3.0 → 0.4.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/README.md CHANGED
@@ -70,6 +70,27 @@ Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
70
70
  `~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
71
71
  built-in `using-skills` skill for the authoring rules.
72
72
 
73
+ ## Errors & exit codes
74
+
75
+ Every user-facing error prints a title, the cause (when known), concrete next
76
+ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
77
+ (or `--log-level debug` / `DEBUG=1`) to see the underlying error and stack;
78
+ `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
79
+ branch on them:
80
+
81
+ | Exit | Category | Example codes |
82
+ | ---- | ---------- | ----------------------------------------------------------------------------------------------- |
83
+ | `0` | success | — |
84
+ | `1` | internal | `CRUXY_E_INTERNAL` |
85
+ | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED` |
86
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
87
+ | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID` |
88
+ | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE` |
89
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED` |
90
+ | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
91
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
92
+ | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
93
+
73
94
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
74
95
  provider-agnostic, built over `fetch`, with no vendor SDKs.
75
96
 
@@ -1,3 +1,4 @@
1
+ import { providerUnsupported } from "../errors/index.js";
1
2
  import { buildSystemPrompt } from "./prompts.js";
2
3
  /**
3
4
  * Drive the model/tool loop over an existing conversation. Streams each turn,
@@ -11,7 +12,7 @@ import { buildSystemPrompt } from "./prompts.js";
11
12
  export async function runAgent(args) {
12
13
  const { provider, registry, config, ctx } = args;
13
14
  if (!provider.supportsTools) {
14
- throw new Error(`provider ${config.model.provider} does not support tool use; cruxy run requires a tool-capable provider`);
15
+ throw providerUnsupported(config.model.provider);
15
16
  }
16
17
  const { logger } = ctx;
17
18
  // Work on a copy so we never mutate the caller's array as a side effect; the
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import pc from "picocolors";
3
+ import { configKeyUnknown } from "../../errors/index.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
5
6
  export function configCommand() {
@@ -19,9 +20,7 @@ export function configCommand() {
19
20
  const { config } = loadConfig();
20
21
  const value = getPath(config, key);
21
22
  if (value === undefined) {
22
- logger.error(`no such config key: ${pc.bold(key)}`);
23
- process.exitCode = 1;
24
- return;
23
+ throw configKeyUnknown(key);
25
24
  }
26
25
  logger.print(typeof value === "object"
27
26
  ? JSON.stringify(value, null, 2)
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { loadConfig } from "../../config/index.js";
5
+ import { CruxyError, indexFailed } from "../../errors/index.js";
5
6
  import { getIndexService, indexDbPath, resetIndexServices, } from "../../indexing/index.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  /**
@@ -41,8 +42,10 @@ export function indexCommand() {
41
42
  pc.dim(`(${stats.filesSkipped} unchanged, ${stats.filesPurged} purged, ${stats.durationMs}ms)`));
42
43
  }
43
44
  catch (err) {
44
- logger.error(`indexing failed: ${err.message}`);
45
- process.exitCode = 1;
45
+ // Typed index failures (embedder/store unavailable) already carry a
46
+ // code; anything else becomes CRUXY_E_INDEX_FAILED. The boundary formats
47
+ // and sets the exit code.
48
+ throw err instanceof CruxyError ? err : indexFailed(err);
46
49
  }
47
50
  finally {
48
51
  await resetIndexServices();
@@ -3,6 +3,7 @@ import pc from "picocolors";
3
3
  import { createProvider } from "@cruxy/sdk";
4
4
  import { logger } from "../../utils/logger.js";
5
5
  import { loadConfig, resolveApiKey, loadProjectInstructions, } from "../../config/index.js";
6
+ import { authMissingKey, usageError } from "../../errors/index.js";
6
7
  import { buildDefaultRegistry } from "../../tools/index.js";
7
8
  import { Session, createApprover } from "../../agent/index.js";
8
9
  import { runInteractive } from "../repl.js";
@@ -20,16 +21,14 @@ export function runCommand() {
20
21
  // No prompt and stdin isn't a terminal: there's no way to read input and
21
22
  // nothing to do — fail fast instead of hanging on a line that never comes.
22
23
  if (interactive && !process.stdin.isTTY) {
23
- logger.error('cruxy run needs a prompt when stdin is not a terminal try: cruxy run "<task>"');
24
- return;
24
+ throw usageError("cruxy run needs a prompt when stdin is not a terminal", ['provide a task, e.g. cruxy run "fix the failing test"']);
25
25
  }
26
26
  const { config, sources } = loadConfig();
27
27
  const apiKey = resolveApiKey(config.model.provider);
28
28
  logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
29
29
  logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
30
30
  if (!apiKey) {
31
- logger.warn(`no API key for provider ${pc.bold(config.model.provider)} — set it in the environment before running.`);
32
- return;
31
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
33
32
  }
34
33
  const provider = createProvider({
35
34
  provider: config.model.provider,
@@ -72,14 +71,23 @@ export function runCommand() {
72
71
  // through a printer that trims the model's leading blank lines; the agent
73
72
  // loop terminates the line.
74
73
  logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
75
- try {
76
- const print = createStreamPrinter((text) => process.stdout.write(text));
77
- const result = await session.send(prompt, print);
78
- logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
79
- `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
80
- }
81
- catch (err) {
82
- logger.error(err.message);
83
- }
74
+ // Provider/network/auth failures propagate to the top-level boundary,
75
+ // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
76
+ // the matching code — a one-shot run must fail non-zero on error.
77
+ const print = createStreamPrinter((text) => process.stdout.write(text));
78
+ const result = await session.send(prompt, print);
79
+ logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
80
+ `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
84
81
  });
85
82
  }
83
+ /** Environment variable that holds the API key for a provider. */
84
+ function apiKeyEnvVar(provider) {
85
+ switch (provider) {
86
+ case "anthropic":
87
+ return "ANTHROPIC_API_KEY";
88
+ case "openai":
89
+ return "OPENAI_API_KEY";
90
+ default:
91
+ return "CRUXY_API_KEY";
92
+ }
93
+ }
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import pc from "picocolors";
3
3
  import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
4
4
  import { logger } from "../utils/logger.js";
5
+ import { usageError } from "../errors/index.js";
5
6
  import { runCommand } from "./commands/run.js";
6
7
  import { configCommand } from "./commands/config.js";
7
8
  import { indexCommand } from "./commands/index.js";
@@ -14,8 +15,7 @@ export function buildProgram() {
14
15
  .version(APP_VERSION, "-v, --version", "print the cruxy version")
15
16
  .option("-c, --config <path>", "use a specific config file")
16
17
  .option("--log-level <level>", "debug | info | warn | error | silent")
17
- .option("--verbose", "shorthand for --log-level debug")
18
- .showHelpAfterError("(run `cruxy --help` for usage)");
18
+ .option("--verbose", "shorthand for --log-level debug");
19
19
  // Apply global options as early as possible.
20
20
  program.hook("preAction", (thisCommand) => {
21
21
  const opts = thisCommand.opts();
@@ -28,13 +28,33 @@ export function buildProgram() {
28
28
  program.addCommand(configCommand());
29
29
  program.addCommand(indexCommand());
30
30
  program.addCommand(skillsCommand());
31
- // Default action: no subcommand -> interactive entrypoint (stub for now).
32
- program.action(() => {
31
+ // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
32
+ // means an unknown command (Commander runs the default action with it as an
33
+ // operand rather than erroring), so reject it as a usage error.
34
+ program.action((_opts, command) => {
35
+ if (command.args.length > 0) {
36
+ throw usageError(`unknown command: ${command.args[0]}`, [
37
+ "run `cruxy --help` to see available commands",
38
+ ]);
39
+ }
33
40
  logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
34
41
  logger.print(pc.dim("an agentic coding CLI\n"));
35
42
  logger.print("The interactive REPL lands in C.3 (terminal UI).");
36
43
  logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
37
44
  logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
38
45
  });
46
+ // Throw CommanderError instead of calling process.exit, and suppress
47
+ // Commander's own "error:" line — so parse errors (unknown command/option,
48
+ // missing argument) at *any* level route through the single error boundary as
49
+ // CRUXY_E_USAGE, sharing one exit code (2) and format. Applied recursively
50
+ // because these settings are not inherited by subcommands.
51
+ routeErrorsToBoundary(program);
39
52
  return program;
40
53
  }
54
+ /** Recursively make a command (and its subcommands) throw on error, silently. */
55
+ function routeErrorsToBoundary(command) {
56
+ command.exitOverride();
57
+ command.configureOutput({ outputError: () => { } });
58
+ for (const sub of command.commands)
59
+ routeErrorsToBoundary(sub);
60
+ }
package/dist/cli/repl.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import readline from "node:readline";
2
2
  import pc from "picocolors";
3
+ import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { createStreamPrinter } from "./stream-print.js";
5
6
  const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
@@ -49,6 +50,18 @@ function readLine(io, prompt) {
49
50
  });
50
51
  });
51
52
  }
53
+ /**
54
+ * Render a non-fatal error inline (classified + formatted, the same 4-part
55
+ * shape as the fatal boundary) and return to the prompt — the REPL must survive
56
+ * a failed turn rather than exit.
57
+ */
58
+ function printReplError(err) {
59
+ const cruxy = fromUnknown(err);
60
+ logger.print(formatError(cruxy, {
61
+ verbose: isVerbose(),
62
+ color: shouldUseColor(process.stdout),
63
+ }));
64
+ }
52
65
  /**
53
66
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
54
67
  * commands or run a turn, repeat. Assistant text streams to stdout from within
@@ -83,7 +96,7 @@ export async function runInteractive(session, io = defaultIO()) {
83
96
  logger.print(pc.dim(n ? `compacted ${n} older messages` : "nothing to compact yet"));
84
97
  }
85
98
  catch (err) {
86
- logger.error(err.message);
99
+ printReplError(err);
87
100
  }
88
101
  continue;
89
102
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { configInvalid, configParse } from "../errors/index.js";
3
4
  import { CruxyConfigSchema } from "./schema.js";
4
5
  import { globalConfigPath, findProjectConfig } from "./paths.js";
5
6
  function isPlainObject(v) {
@@ -23,13 +24,13 @@ function readJsonFile(path) {
23
24
  try {
24
25
  const parsed = JSON.parse(readFileSync(path, "utf8"));
25
26
  if (!isPlainObject(parsed)) {
26
- throw new Error(`config at ${path} must be a JSON object`);
27
+ throw configParse(path, new Error("top-level value is not a JSON object"));
27
28
  }
28
29
  return parsed;
29
30
  }
30
31
  catch (err) {
31
32
  if (err instanceof SyntaxError) {
32
- throw new Error(`config at ${path} is not valid JSON: ${err.message}`);
33
+ throw configParse(path, err);
33
34
  }
34
35
  throw err;
35
36
  }
@@ -81,7 +82,7 @@ export function loadConfig(opts = {}) {
81
82
  const issues = result.error.issues
82
83
  .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
83
84
  .join("\n");
84
- throw new Error(`invalid configuration:\n${issues}`);
85
+ throw configInvalid(issues, sources.project ?? sources.global ?? undefined);
85
86
  }
86
87
  return { config: result.data, sources };
87
88
  }
@@ -121,7 +122,7 @@ export function setValue(path, rawValue, file) {
121
122
  const issues = check.error.issues
122
123
  .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
123
124
  .join("\n");
124
- throw new Error(`refusing to write invalid config:\n${issues}`);
125
+ throw configInvalid(issues, file);
125
126
  }
126
127
  mkdirSync(dirname(file), { recursive: true });
127
128
  writeFileSync(file, JSON.stringify(current, null, 2) + "\n", "utf8");
@@ -0,0 +1,43 @@
1
+ import { type Formatter } from "./format.js";
2
+ import { CruxyError } from "./types.js";
3
+ /**
4
+ * The single top-level error boundary. {@link handleFatal} classifies any thrown
5
+ * value into a {@link CruxyError}, formats it, writes it to stderr, and exits
6
+ * with the error's stable exit code. Nothing escapes to a raw Node stack trace:
7
+ * an unknown error becomes {@link internal} (CRUXY_E_INTERNAL) with a
8
+ * bug-report hint, and the underlying stack is shown only under `--verbose`.
9
+ */
10
+ /**
11
+ * Normalize any thrown value into a {@link CruxyError}:
12
+ * - already a CruxyError → returned as-is;
13
+ * - a Commander parse error → a usage error (CRUXY_E_USAGE);
14
+ * - a known provider/transport error → its mapped code;
15
+ * - anything else → CRUXY_E_INTERNAL, preserving the original as `underlying`.
16
+ */
17
+ export declare function fromUnknown(err: unknown): CruxyError;
18
+ /** A Commander "error" that's actually success (`--help`, `--version`). */
19
+ export declare function isCommanderSuccess(err: unknown): boolean;
20
+ export interface HandleFatalOptions {
21
+ /** Show the underlying error/stack. Defaults to {@link isVerbose}(). */
22
+ verbose?: boolean;
23
+ /** Override the formatter (e.g. a future JSON formatter). */
24
+ formatter?: Formatter;
25
+ /** Force color on/off. Defaults to {@link shouldUseColor}(). */
26
+ color?: boolean;
27
+ /** Sink for the formatted output (default: stderr). For tests. */
28
+ write?: (text: string) => void;
29
+ /** Process exit (default: process.exit). For tests. */
30
+ exit?: (code: number) => never;
31
+ }
32
+ /**
33
+ * Classify → format → print → exit. Install this as the *only* catch at the CLI
34
+ * entry. `write`/`exit` are injectable so the boundary is testable without
35
+ * touching the real process.
36
+ */
37
+ export declare function handleFatal(err: unknown, opts?: HandleFatalOptions): never;
38
+ /**
39
+ * Whether the invocation requested verbose output: `--verbose`,
40
+ * `--log-level debug`, `DEBUG`, or `CRUXY_LOG_LEVEL=debug`. Read from argv/env so
41
+ * it works even when a failure happens before the CLI finishes parsing flags.
42
+ */
43
+ export declare function isVerbose(argv?: string[], env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,73 @@
1
+ import { CommanderError } from "commander";
2
+ import { classifyProviderError, internal, usageError } from "./constructors.js";
3
+ import { shouldUseColor, terminalFormatter } from "./format.js";
4
+ import { CruxyError } from "./types.js";
5
+ /**
6
+ * The single top-level error boundary. {@link handleFatal} classifies any thrown
7
+ * value into a {@link CruxyError}, formats it, writes it to stderr, and exits
8
+ * with the error's stable exit code. Nothing escapes to a raw Node stack trace:
9
+ * an unknown error becomes {@link internal} (CRUXY_E_INTERNAL) with a
10
+ * bug-report hint, and the underlying stack is shown only under `--verbose`.
11
+ */
12
+ /**
13
+ * Normalize any thrown value into a {@link CruxyError}:
14
+ * - already a CruxyError → returned as-is;
15
+ * - a Commander parse error → a usage error (CRUXY_E_USAGE);
16
+ * - a known provider/transport error → its mapped code;
17
+ * - anything else → CRUXY_E_INTERNAL, preserving the original as `underlying`.
18
+ */
19
+ export function fromUnknown(err) {
20
+ if (err instanceof CruxyError)
21
+ return err;
22
+ if (err instanceof CommanderError) {
23
+ return usageError(stripErrorPrefix(err.message));
24
+ }
25
+ return classifyProviderError(err) ?? internal(err);
26
+ }
27
+ /** A Commander "error" that's actually success (`--help`, `--version`). */
28
+ export function isCommanderSuccess(err) {
29
+ return err instanceof CommanderError && err.exitCode === 0;
30
+ }
31
+ /**
32
+ * Classify → format → print → exit. Install this as the *only* catch at the CLI
33
+ * entry. `write`/`exit` are injectable so the boundary is testable without
34
+ * touching the real process.
35
+ */
36
+ export function handleFatal(err, opts = {}) {
37
+ const cruxy = fromUnknown(err);
38
+ const verbose = opts.verbose ?? isVerbose();
39
+ const color = opts.color ?? shouldUseColor();
40
+ const formatter = opts.formatter ?? terminalFormatter;
41
+ const write = opts.write ?? ((text) => void process.stderr.write(text));
42
+ const exit = opts.exit ?? ((code) => process.exit(code));
43
+ write(`${formatter.format(cruxy, { verbose, color })}\n`);
44
+ return exit(cruxy.exitCode);
45
+ }
46
+ /**
47
+ * Whether the invocation requested verbose output: `--verbose`,
48
+ * `--log-level debug`, `DEBUG`, or `CRUXY_LOG_LEVEL=debug`. Read from argv/env so
49
+ * it works even when a failure happens before the CLI finishes parsing flags.
50
+ */
51
+ export function isVerbose(argv = process.argv, env = process.env) {
52
+ if (argv.includes("--verbose"))
53
+ return true;
54
+ if (argv.includes("--log-level=debug"))
55
+ return true;
56
+ const idx = argv.indexOf("--log-level");
57
+ if (idx !== -1 && argv[idx + 1] === "debug")
58
+ return true;
59
+ if (env.CRUXY_LOG_LEVEL === "debug")
60
+ return true;
61
+ const debug = env.DEBUG;
62
+ if (debug !== undefined &&
63
+ debug !== "" &&
64
+ debug !== "0" &&
65
+ debug !== "false") {
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /** Strip Commander's leading "error: " so the title reads cleanly. */
71
+ function stripErrorPrefix(message) {
72
+ return message.replace(/^error:\s*/i, "");
73
+ }
@@ -0,0 +1,27 @@
1
+ import { CruxyError } from "./types.js";
2
+ /** Best-effort human message for an arbitrary thrown value. */
3
+ export declare function messageOf(underlying: unknown): string | undefined;
4
+ export declare function usageError(title: string, nextSteps?: string[]): CruxyError;
5
+ export declare function configKeyUnknown(key: string): CruxyError;
6
+ export declare function providerUnsupported(provider: string): CruxyError;
7
+ export declare function configParse(path: string, underlying?: unknown): CruxyError;
8
+ export declare function configInvalid(issues: string, path?: string): CruxyError;
9
+ export declare function authMissingKey(provider: string, envVar: string): CruxyError;
10
+ export declare function authInvalid(underlying?: unknown): CruxyError;
11
+ export declare function gatewayUnreachable(underlying?: unknown): CruxyError;
12
+ export declare function apiError(underlying?: unknown): CruxyError;
13
+ export declare function apiRateLimit(underlying?: unknown): CruxyError;
14
+ export declare function apiOverloaded(underlying?: unknown): CruxyError;
15
+ export declare function budgetExhausted(underlying?: unknown): CruxyError;
16
+ export declare function fileNotFound(path: string, underlying?: unknown): CruxyError;
17
+ export declare function permissionDenied(path: string, underlying?: unknown): CruxyError;
18
+ export declare function indexEmbedderUnavailable(underlying?: unknown): CruxyError;
19
+ export declare function indexStoreUnavailable(underlying?: unknown): CruxyError;
20
+ export declare function indexFailed(underlying?: unknown): CruxyError;
21
+ export declare function internal(underlying?: unknown): CruxyError;
22
+ /**
23
+ * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
24
+ * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
25
+ * subclasses before the `ApiError` base.
26
+ */
27
+ export declare function classifyProviderError(underlying: unknown): CruxyError | null;
@@ -0,0 +1,246 @@
1
+ import { ApiError, AuthError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
2
+ import { CruxyError, ErrorCode } from "./types.js";
3
+ /**
4
+ * Helper constructors for {@link CruxyError}. Each encodes the title, the human
5
+ * cause, and the concrete next steps for one failure mode, so call sites stay a
6
+ * one-liner and the wording lives in one place. Lower-level errors are passed as
7
+ * `underlying` (preserved, shown only under `--verbose`).
8
+ */
9
+ const ISSUE_URL = "https://github.com/cruxy-ai/cli/issues";
10
+ /** Best-effort human message for an arbitrary thrown value. */
11
+ export function messageOf(underlying) {
12
+ if (underlying instanceof Error)
13
+ return underlying.message;
14
+ if (underlying === undefined || underlying === null)
15
+ return undefined;
16
+ return String(underlying);
17
+ }
18
+ // ── usage (exit 2) ────────────────────────────────────────────────────────────
19
+ export function usageError(title, nextSteps) {
20
+ return new CruxyError({
21
+ code: ErrorCode.Usage,
22
+ title,
23
+ nextSteps: nextSteps ?? ["run `cruxy --help` for usage"],
24
+ });
25
+ }
26
+ export function configKeyUnknown(key) {
27
+ return new CruxyError({
28
+ code: ErrorCode.ConfigKeyUnknown,
29
+ title: `no such config key: ${key}`,
30
+ nextSteps: ["run `cruxy config list` to see all available keys"],
31
+ meta: { key },
32
+ });
33
+ }
34
+ export function providerUnsupported(provider) {
35
+ return new CruxyError({
36
+ code: ErrorCode.ProviderUnsupported,
37
+ title: `provider "${provider}" does not support tool use`,
38
+ cause: "cruxy run drives an agent loop, which requires a tool-capable provider",
39
+ nextSteps: [
40
+ "switch to a tool-capable provider, e.g. `cruxy config set model.provider anthropic`",
41
+ ],
42
+ meta: { provider },
43
+ });
44
+ }
45
+ // ── config (exit 3) ───────────────────────────────────────────────────────────
46
+ export function configParse(path, underlying) {
47
+ return new CruxyError({
48
+ code: ErrorCode.ConfigParse,
49
+ title: `could not parse config file: ${path}`,
50
+ cause: messageOf(underlying),
51
+ nextSteps: [
52
+ "fix the JSON syntax, or delete the file to fall back to defaults",
53
+ ],
54
+ underlying,
55
+ meta: { path },
56
+ });
57
+ }
58
+ export function configInvalid(issues, path) {
59
+ return new CruxyError({
60
+ code: ErrorCode.ConfigInvalid,
61
+ title: "the configuration is invalid",
62
+ cause: issues,
63
+ nextSteps: [
64
+ "correct the reported field(s)",
65
+ "see valid keys with `cruxy config list`",
66
+ ],
67
+ meta: path ? { path } : undefined,
68
+ });
69
+ }
70
+ // ── auth (exit 4) ─────────────────────────────────────────────────────────────
71
+ export function authMissingKey(provider, envVar) {
72
+ return new CruxyError({
73
+ code: ErrorCode.AuthMissingKey,
74
+ title: `no API key for provider "${provider}"`,
75
+ cause: `the ${envVar} environment variable is not set`,
76
+ nextSteps: [
77
+ `export ${envVar}=… in your shell (keys are read from the environment, never from config)`,
78
+ ],
79
+ meta: { provider, envVar },
80
+ });
81
+ }
82
+ export function authInvalid(underlying) {
83
+ return new CruxyError({
84
+ code: ErrorCode.AuthInvalid,
85
+ title: "the provider rejected your credentials",
86
+ cause: messageOf(underlying),
87
+ nextSteps: [
88
+ "verify your API key is correct and active",
89
+ "re-export the key and try again",
90
+ ],
91
+ underlying,
92
+ });
93
+ }
94
+ // ── network (exit 5) ──────────────────────────────────────────────────────────
95
+ export function gatewayUnreachable(underlying) {
96
+ return new CruxyError({
97
+ code: ErrorCode.GatewayUnreachable,
98
+ title: "could not reach the model gateway",
99
+ cause: messageOf(underlying),
100
+ nextSteps: [
101
+ "check your internet connection",
102
+ "verify the gateway URL with `cruxy config get cruxy.gatewayUrl`",
103
+ "retry in a moment",
104
+ ],
105
+ underlying,
106
+ });
107
+ }
108
+ // ── api (exit 6) ──────────────────────────────────────────────────────────────
109
+ export function apiError(underlying) {
110
+ const status = underlying instanceof ApiError ? underlying.status : undefined;
111
+ return new CruxyError({
112
+ code: ErrorCode.Api,
113
+ title: status
114
+ ? `the model provider returned an error (HTTP ${status})`
115
+ : "the model provider returned an error",
116
+ cause: messageOf(underlying),
117
+ nextSteps: [
118
+ "retry in a moment; if it persists, check the provider's status",
119
+ ],
120
+ underlying,
121
+ meta: status ? { status } : undefined,
122
+ });
123
+ }
124
+ export function apiRateLimit(underlying) {
125
+ const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
126
+ return new CruxyError({
127
+ code: ErrorCode.ApiRateLimit,
128
+ title: "rate limited by the model provider",
129
+ cause: messageOf(underlying),
130
+ nextSteps: [
131
+ retryAfterMs
132
+ ? `wait ~${Math.ceil(retryAfterMs / 1000)}s and retry`
133
+ : "wait a moment and retry",
134
+ ],
135
+ underlying,
136
+ meta: retryAfterMs ? { retryAfterMs } : undefined,
137
+ });
138
+ }
139
+ export function apiOverloaded(underlying) {
140
+ return new CruxyError({
141
+ code: ErrorCode.ApiOverloaded,
142
+ title: "the model provider is overloaded",
143
+ cause: messageOf(underlying),
144
+ nextSteps: ["retry in a few moments"],
145
+ underlying,
146
+ });
147
+ }
148
+ export function budgetExhausted(underlying) {
149
+ return new CruxyError({
150
+ code: ErrorCode.BudgetExhausted,
151
+ title: "your Cruxy budget is exhausted",
152
+ cause: messageOf(underlying),
153
+ nextSteps: ["top up or raise your budget, then retry"],
154
+ underlying,
155
+ });
156
+ }
157
+ // ── filesystem (exit 7) ───────────────────────────────────────────────────────
158
+ export function fileNotFound(path, underlying) {
159
+ return new CruxyError({
160
+ code: ErrorCode.FileNotFound,
161
+ title: `file not found: ${path}`,
162
+ cause: messageOf(underlying),
163
+ nextSteps: ["check the path and try again"],
164
+ underlying,
165
+ meta: { path },
166
+ });
167
+ }
168
+ export function permissionDenied(path, underlying) {
169
+ return new CruxyError({
170
+ code: ErrorCode.PermissionDenied,
171
+ title: `permission denied: ${path}`,
172
+ cause: messageOf(underlying),
173
+ nextSteps: ["check the file's permissions, or run with the right user"],
174
+ underlying,
175
+ meta: { path },
176
+ });
177
+ }
178
+ // ── index (exit 8) ────────────────────────────────────────────────────────────
179
+ export function indexEmbedderUnavailable(underlying) {
180
+ return new CruxyError({
181
+ code: ErrorCode.IndexEmbedderUnavailable,
182
+ title: "the local embedding model (fastembed) could not be loaded",
183
+ cause: messageOf(underlying),
184
+ nextSteps: [
185
+ "reinstall dependencies with `pnpm install`",
186
+ "ensure the native onnxruntime-node addon built for your platform",
187
+ ],
188
+ underlying,
189
+ });
190
+ }
191
+ export function indexStoreUnavailable(underlying) {
192
+ return new CruxyError({
193
+ code: ErrorCode.IndexStoreUnavailable,
194
+ title: "the codebase index store (SQLite) is unavailable",
195
+ cause: messageOf(underlying),
196
+ nextSteps: [
197
+ "reinstall dependencies with `pnpm install` (better-sqlite3 must build), or set index.store = memory",
198
+ ],
199
+ underlying,
200
+ });
201
+ }
202
+ export function indexFailed(underlying) {
203
+ return new CruxyError({
204
+ code: ErrorCode.IndexFailed,
205
+ title: "building the codebase index failed",
206
+ cause: messageOf(underlying),
207
+ nextSteps: ["re-run with --verbose for details"],
208
+ underlying,
209
+ });
210
+ }
211
+ // ── internal (exit 1) ─────────────────────────────────────────────────────────
212
+ export function internal(underlying) {
213
+ return new CruxyError({
214
+ code: ErrorCode.Internal,
215
+ title: "an unexpected internal error occurred",
216
+ cause: messageOf(underlying),
217
+ nextSteps: [
218
+ "re-run with --verbose (or --log-level debug) to see the underlying error",
219
+ `report it at ${ISSUE_URL} with the error code and details`,
220
+ ],
221
+ underlying,
222
+ });
223
+ }
224
+ /**
225
+ * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
226
+ * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
227
+ * subclasses before the `ApiError` base.
228
+ */
229
+ export function classifyProviderError(underlying) {
230
+ if (underlying instanceof AuthError)
231
+ return authInvalid(underlying);
232
+ if (underlying instanceof RateLimitError)
233
+ return apiRateLimit(underlying);
234
+ if (underlying instanceof OverloadedError)
235
+ return apiOverloaded(underlying);
236
+ if (underlying instanceof NetworkError)
237
+ return gatewayUnreachable(underlying);
238
+ if (underlying instanceof ApiError) {
239
+ // BudgetExhaustedError isn't exported by the SDK; match by name.
240
+ if (underlying.name === "BudgetExhaustedError") {
241
+ return budgetExhausted(underlying);
242
+ }
243
+ return apiError(underlying);
244
+ }
245
+ return null;
246
+ }
@@ -0,0 +1,31 @@
1
+ import { CruxyError } from "./types.js";
2
+ /**
3
+ * Rendering for {@link CruxyError}, kept separate from the data so it's testable
4
+ * and swappable. {@link TerminalFormatter} renders the human, 4-part terminal
5
+ * form; a `JsonFormatter` for headless output can drop in behind the same
6
+ * {@link Formatter} interface later (out of scope here — this is the seam).
7
+ */
8
+ export interface FormatOptions {
9
+ /** Append the underlying error's stack/message (hidden by default). */
10
+ verbose: boolean;
11
+ /** Emit ANSI color. Resolve with {@link shouldUseColor}. */
12
+ color: boolean;
13
+ }
14
+ export interface Formatter {
15
+ format(err: CruxyError, opts: FormatOptions): string;
16
+ }
17
+ /**
18
+ * Decide whether to colorize: honor `NO_COLOR` (disable) and `FORCE_COLOR`
19
+ * (enable), otherwise color only when writing to a TTY.
20
+ */
21
+ export declare function shouldUseColor(stream?: {
22
+ isTTY?: boolean;
23
+ }, env?: NodeJS.ProcessEnv): boolean;
24
+ /** The default terminal formatter: title, cause, next steps, code (+ verbose). */
25
+ export declare class TerminalFormatter implements Formatter {
26
+ format(err: CruxyError, opts: FormatOptions): string;
27
+ }
28
+ /** The shared default instance. */
29
+ export declare const terminalFormatter: TerminalFormatter;
30
+ /** Convenience: render with the default terminal formatter. */
31
+ export declare function formatError(err: CruxyError, opts: FormatOptions): string;
@@ -0,0 +1,60 @@
1
+ import pc from "picocolors";
2
+ /**
3
+ * Decide whether to colorize: honor `NO_COLOR` (disable) and `FORCE_COLOR`
4
+ * (enable), otherwise color only when writing to a TTY.
5
+ */
6
+ export function shouldUseColor(stream = process.stderr, env = process.env) {
7
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
8
+ return false;
9
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "")
10
+ return true;
11
+ return Boolean(stream.isTTY);
12
+ }
13
+ /** The default terminal formatter: title, cause, next steps, code (+ verbose). */
14
+ export class TerminalFormatter {
15
+ format(err, opts) {
16
+ const c = pc.createColors(opts.color);
17
+ const lines = [];
18
+ // 1. Title — one plain line, what failed.
19
+ lines.push(c.red(c.bold(err.title)));
20
+ // 2. Cause — the specific reason, when known.
21
+ if (err.cause)
22
+ lines.push(`${c.dim("Cause:")} ${err.cause}`);
23
+ // 3. Next step(s) — the concrete action(s) to take.
24
+ if (err.nextSteps.length > 0) {
25
+ lines.push("");
26
+ lines.push(c.bold("Next steps:"));
27
+ for (const step of err.nextSteps)
28
+ lines.push(` ${c.cyan("→")} ${step}`);
29
+ }
30
+ // 4. Code — the stable, greppable id.
31
+ lines.push("");
32
+ lines.push(c.dim(`[${err.code}]`));
33
+ // Verbose-only: the preserved underlying error.
34
+ if (opts.verbose && err.underlying !== undefined) {
35
+ lines.push("");
36
+ lines.push(c.dim("Underlying error:"));
37
+ lines.push(indent(stackOf(err.underlying)));
38
+ }
39
+ return lines.join("\n");
40
+ }
41
+ }
42
+ /** The shared default instance. */
43
+ export const terminalFormatter = new TerminalFormatter();
44
+ /** Convenience: render with the default terminal formatter. */
45
+ export function formatError(err, opts) {
46
+ return terminalFormatter.format(err, opts);
47
+ }
48
+ /** The stack (preferred) or message of an underlying error, as a string. */
49
+ function stackOf(underlying) {
50
+ if (underlying instanceof Error) {
51
+ return underlying.stack ?? `${underlying.name}: ${underlying.message}`;
52
+ }
53
+ return String(underlying);
54
+ }
55
+ function indent(text, by = " ") {
56
+ return text
57
+ .split("\n")
58
+ .map((line) => by + line)
59
+ .join("\n");
60
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./format.js";
3
+ export * from "./constructors.js";
4
+ export * from "./boundary.js";
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./format.js";
3
+ export * from "./constructors.js";
4
+ export * from "./boundary.js";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The one error type for every user-facing failure (U.5). A {@link CruxyError}
3
+ * carries structured fields — title, optional human cause, next steps, a stable
4
+ * greppable {@link ErrorCode}, and a process exit code — while *formatting* lives
5
+ * separately (see `format.ts`). The raw underlying error is preserved but shown
6
+ * only under `--verbose`; no Node stack trace ever reaches the user by default.
7
+ */
8
+ /**
9
+ * Stable, greppable error identifiers. The string value *is* the id that appears
10
+ * in output (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`) — grep for it in logs/issues.
11
+ * Grouped by category; each category maps to a distinct process exit code
12
+ * (see {@link exitCodeFor}).
13
+ */
14
+ export declare const ErrorCode: {
15
+ readonly Internal: "CRUXY_E_INTERNAL";
16
+ readonly Usage: "CRUXY_E_USAGE";
17
+ readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
18
+ readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
19
+ readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
20
+ readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
21
+ readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
22
+ readonly AuthInvalid: "CRUXY_E_AUTH_INVALID";
23
+ readonly GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE";
24
+ readonly Api: "CRUXY_E_API";
25
+ readonly ApiRateLimit: "CRUXY_E_API_RATE_LIMIT";
26
+ readonly ApiOverloaded: "CRUXY_E_API_OVERLOADED";
27
+ readonly BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED";
28
+ readonly FileNotFound: "CRUXY_E_FILE_NOT_FOUND";
29
+ readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
30
+ readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
31
+ readonly IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE";
32
+ readonly IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE";
33
+ readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
34
+ readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
35
+ readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
36
+ };
37
+ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
38
+ /** The process exit code for an error code (defaults to 1 for safety). */
39
+ export declare function exitCodeFor(code: ErrorCode): number;
40
+ export interface CruxyErrorInit {
41
+ /** Stable error id (drives the exit code and appears in output). */
42
+ code: ErrorCode;
43
+ /** One plain line: what failed. */
44
+ title: string;
45
+ /** The specific reason, when known. Always shown (part 2 of the standard). */
46
+ cause?: string;
47
+ /** Concrete actions the user can take. */
48
+ nextSteps?: string[];
49
+ /** Structured context (not rendered; for future JSON output / telemetry). */
50
+ meta?: Record<string, unknown>;
51
+ /** The original thrown error, preserved and shown only under `--verbose`. */
52
+ underlying?: unknown;
53
+ /** Override the exit code; defaults to {@link exitCodeFor}(code). */
54
+ exitCode?: number;
55
+ }
56
+ /**
57
+ * Every user-facing failure is (or becomes) one of these. Extends `Error` so it
58
+ * flows through normal throw/catch; `.message` mirrors `title` so logs and
59
+ * `instanceof Error` consumers stay useful.
60
+ */
61
+ export declare class CruxyError extends Error {
62
+ readonly code: ErrorCode;
63
+ readonly title: string;
64
+ /** Human-readable reason (part 2). Narrows the inherited `Error.cause`. */
65
+ readonly cause?: string;
66
+ readonly nextSteps: string[];
67
+ readonly exitCode: number;
68
+ readonly meta?: Record<string, unknown>;
69
+ /** The wrapped lower-level error — rendered only under `--verbose`. */
70
+ readonly underlying?: unknown;
71
+ constructor(init: CruxyErrorInit);
72
+ /** Type guard — true for any CruxyError (across realms, via the brand). */
73
+ static is(err: unknown): err is CruxyError;
74
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The one error type for every user-facing failure (U.5). A {@link CruxyError}
3
+ * carries structured fields — title, optional human cause, next steps, a stable
4
+ * greppable {@link ErrorCode}, and a process exit code — while *formatting* lives
5
+ * separately (see `format.ts`). The raw underlying error is preserved but shown
6
+ * only under `--verbose`; no Node stack trace ever reaches the user by default.
7
+ */
8
+ /**
9
+ * Stable, greppable error identifiers. The string value *is* the id that appears
10
+ * in output (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`) — grep for it in logs/issues.
11
+ * Grouped by category; each category maps to a distinct process exit code
12
+ * (see {@link exitCodeFor}).
13
+ */
14
+ export const ErrorCode = {
15
+ // internal (exit 1)
16
+ Internal: "CRUXY_E_INTERNAL",
17
+ // usage (exit 2)
18
+ Usage: "CRUXY_E_USAGE",
19
+ ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
20
+ ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
21
+ // config (exit 3)
22
+ ConfigParse: "CRUXY_E_CONFIG_PARSE",
23
+ ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
24
+ // auth (exit 4)
25
+ AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
26
+ AuthInvalid: "CRUXY_E_AUTH_INVALID",
27
+ // network (exit 5)
28
+ GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE",
29
+ // api (exit 6)
30
+ Api: "CRUXY_E_API",
31
+ ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
32
+ ApiOverloaded: "CRUXY_E_API_OVERLOADED",
33
+ BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
34
+ // filesystem (exit 7)
35
+ FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
36
+ PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
37
+ PathEscape: "CRUXY_E_PATH_ESCAPE",
38
+ // index (exit 8)
39
+ IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE",
40
+ IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE",
41
+ IndexFailed: "CRUXY_E_INDEX_FAILED",
42
+ // skill (exit 9)
43
+ SkillInvalid: "CRUXY_E_SKILL_INVALID",
44
+ SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND",
45
+ };
46
+ /**
47
+ * Category exit codes. Distinct per category so a caller (CI, a script) can
48
+ * branch on *why* cruxy failed. Documented in the README error table.
49
+ */
50
+ const EXIT_CODES = {
51
+ [ErrorCode.Internal]: 1,
52
+ [ErrorCode.Usage]: 2,
53
+ [ErrorCode.ConfigKeyUnknown]: 2,
54
+ [ErrorCode.ProviderUnsupported]: 2,
55
+ [ErrorCode.ConfigParse]: 3,
56
+ [ErrorCode.ConfigInvalid]: 3,
57
+ [ErrorCode.AuthMissingKey]: 4,
58
+ [ErrorCode.AuthInvalid]: 4,
59
+ [ErrorCode.GatewayUnreachable]: 5,
60
+ [ErrorCode.Api]: 6,
61
+ [ErrorCode.ApiRateLimit]: 6,
62
+ [ErrorCode.ApiOverloaded]: 6,
63
+ [ErrorCode.BudgetExhausted]: 6,
64
+ [ErrorCode.FileNotFound]: 7,
65
+ [ErrorCode.PermissionDenied]: 7,
66
+ [ErrorCode.PathEscape]: 7,
67
+ [ErrorCode.IndexEmbedderUnavailable]: 8,
68
+ [ErrorCode.IndexStoreUnavailable]: 8,
69
+ [ErrorCode.IndexFailed]: 8,
70
+ [ErrorCode.SkillInvalid]: 9,
71
+ [ErrorCode.SkillNotFound]: 9,
72
+ };
73
+ /** The process exit code for an error code (defaults to 1 for safety). */
74
+ export function exitCodeFor(code) {
75
+ return EXIT_CODES[code] ?? 1;
76
+ }
77
+ /**
78
+ * Every user-facing failure is (or becomes) one of these. Extends `Error` so it
79
+ * flows through normal throw/catch; `.message` mirrors `title` so logs and
80
+ * `instanceof Error` consumers stay useful.
81
+ */
82
+ export class CruxyError extends Error {
83
+ code;
84
+ title;
85
+ /** Human-readable reason (part 2). Narrows the inherited `Error.cause`. */
86
+ cause;
87
+ nextSteps;
88
+ exitCode;
89
+ meta;
90
+ /** The wrapped lower-level error — rendered only under `--verbose`. */
91
+ underlying;
92
+ constructor(init) {
93
+ super(init.title);
94
+ this.name = "CruxyError";
95
+ this.code = init.code;
96
+ this.title = init.title;
97
+ this.cause = init.cause;
98
+ this.nextSteps = init.nextSteps ?? [];
99
+ this.meta = init.meta;
100
+ this.underlying = init.underlying;
101
+ this.exitCode = init.exitCode ?? exitCodeFor(init.code);
102
+ }
103
+ /** Type guard — true for any CruxyError (across realms, via the brand). */
104
+ static is(err) {
105
+ return err instanceof CruxyError;
106
+ }
107
+ }
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import pc from "picocolors";
3
2
  import { buildProgram } from "./cli/program.js";
4
- import { logger } from "./utils/logger.js";
3
+ import { handleFatal, isCommanderSuccess, isVerbose } from "./errors/index.js";
5
4
  async function main() {
6
5
  const program = buildProgram();
7
6
  await program.parseAsync(process.argv);
8
7
  }
9
8
  main().catch((err) => {
10
- const message = err instanceof Error ? err.message : String(err);
11
- logger.error(pc.red(message));
12
- process.exit(1);
9
+ // Commander signals `--help` / `--version` by throwing with exit code 0 — that
10
+ // is success, not a failure. Everything else goes through the one boundary:
11
+ // CruxyError → formatted output + its exit code; unknown → CRUXY_E_INTERNAL,
12
+ // with the raw stack shown only under --verbose.
13
+ if (isCommanderSuccess(err))
14
+ process.exit(0);
15
+ handleFatal(err, { verbose: isVerbose() });
13
16
  });
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import { indexEmbedderUnavailable } from "../errors/index.js";
2
3
  import { l2normalize } from "./util.js";
3
4
  /**
4
5
  * Output dimensionality of bge-small-en-v1.5, and the default size of the
@@ -132,9 +133,8 @@ export async function createEmbedder(opts = {}) {
132
133
  await import("fastembed");
133
134
  }
134
135
  catch (err) {
135
- throw new Error(`the local embedding model (fastembed) could not be loaded: ${err.message}. ` +
136
- "It requires the onnxruntime-node native addon — reinstall with `pnpm install` and " +
137
- "ensure the native build completed. The codebase index is unavailable until this is fixed.");
136
+ // Fail loud (the C.17 guarantee), now with a stable code + next steps.
137
+ throw indexEmbedderUnavailable(err);
138
138
  }
139
139
  return new FastEmbedEmbedder({ cacheDir: opts.cacheDir });
140
140
  }
@@ -1,6 +1,7 @@
1
1
  import { promises as fsp } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/index.js";
4
+ import { indexStoreUnavailable } from "../errors/index.js";
4
5
  import { GLOBAL_DIR_NAME } from "../constants.js";
5
6
  import { createEmbedder } from "./embedder.js";
6
7
  import { Indexer } from "./indexer.js";
@@ -124,8 +125,10 @@ async function openStore(root, kind, logger) {
124
125
  return { store, storePath: dbPath };
125
126
  }
126
127
  catch (err) {
128
+ // store=sqlite is explicit: fail loud with a code. store=auto degrades to
129
+ // an in-memory index with a warning (no quality loss, just no persistence).
127
130
  if (kind === "sqlite")
128
- throw err;
131
+ throw indexStoreUnavailable(err);
129
132
  logger.warn(`sqlite index unavailable (${err.message}); using an in-memory index`);
130
133
  return { store: new InMemoryVectorStore(), storePath: null };
131
134
  }
@@ -1,3 +1,4 @@
1
+ import { CruxyError } from "../errors/index.js";
1
2
  import { type Skill, type SkillCatalog, type SkillError, type SkillSource } from "./types.js";
2
3
  /** The three source directories the loader scans. */
3
4
  export interface LoaderSources {
@@ -16,7 +17,7 @@ interface SkillCandidate {
16
17
  dir: string;
17
18
  }
18
19
  /** Thrown by `getSkill` when no catalog entry matches the requested name. */
19
- export declare class SkillNotFoundError extends Error {
20
+ export declare class SkillNotFoundError extends CruxyError {
20
21
  constructor(message: string);
21
22
  }
22
23
  /**
Binary file
@@ -1,10 +1,12 @@
1
+ import { CruxyError } from "../errors/index.js";
1
2
  import { type SkillFrontmatter } from "./types.js";
2
3
  /**
3
- * Thrown when a SKILL.md is malformed or fails validation. The message is
4
- * actionable (it names the problem) and is caught by the loader, which records
5
- * it as a `SkillError` and excludes the skill — loud, never silently skipped.
4
+ * Thrown when a SKILL.md is malformed or fails validation. A {@link CruxyError}
5
+ * (code CRUXY_E_SKILL_INVALID) so it carries a stable code if it reaches the
6
+ * boundary; the loader still catches it to record a `SkillError` and exclude the
7
+ * skill — loud, never silently skipped.
6
8
  */
7
- export declare class SkillValidationError extends Error {
9
+ export declare class SkillValidationError extends CruxyError {
8
10
  constructor(message: string);
9
11
  }
10
12
  /** The validated frontmatter plus the markdown body that followed it. */
@@ -1,12 +1,20 @@
1
+ import { CruxyError, ErrorCode } from "../errors/index.js";
1
2
  import { SkillFrontmatterSchema } from "./types.js";
2
3
  /**
3
- * Thrown when a SKILL.md is malformed or fails validation. The message is
4
- * actionable (it names the problem) and is caught by the loader, which records
5
- * it as a `SkillError` and excludes the skill — loud, never silently skipped.
4
+ * Thrown when a SKILL.md is malformed or fails validation. A {@link CruxyError}
5
+ * (code CRUXY_E_SKILL_INVALID) so it carries a stable code if it reaches the
6
+ * boundary; the loader still catches it to record a `SkillError` and exclude the
7
+ * skill — loud, never silently skipped.
6
8
  */
7
- export class SkillValidationError extends Error {
9
+ export class SkillValidationError extends CruxyError {
8
10
  constructor(message) {
9
- super(message);
11
+ super({
12
+ code: ErrorCode.SkillInvalid,
13
+ title: message,
14
+ nextSteps: [
15
+ "see the built-in `using-skills` skill for the SKILL.md rules",
16
+ ],
17
+ });
10
18
  this.name = "SkillValidationError";
11
19
  }
12
20
  }
@@ -1,10 +1,12 @@
1
+ import { CruxyError } from "../../errors/index.js";
1
2
  import type { ToolContext } from "../types.js";
2
3
  /**
3
4
  * Thrown when a tool argument resolves to a path outside the project root —
4
5
  * whether via `../` traversal, an absolute path, or a symlink pointing outward.
5
- * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
6
+ * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
7
+ * reaches the boundary; tools still catch it and surface `{ ok:false }`.
6
8
  */
7
- export declare class PathEscapeError extends Error {
9
+ export declare class PathEscapeError extends CruxyError {
8
10
  constructor(message: string);
9
11
  }
10
12
  /**
@@ -1,13 +1,15 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { CruxyError, ErrorCode } from "../../errors/index.js";
3
4
  /**
4
5
  * Thrown when a tool argument resolves to a path outside the project root —
5
6
  * whether via `../` traversal, an absolute path, or a symlink pointing outward.
6
- * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
7
+ * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
8
+ * reaches the boundary; tools still catch it and surface `{ ok:false }`.
7
9
  */
8
- export class PathEscapeError extends Error {
10
+ export class PathEscapeError extends CruxyError {
9
11
  constructor(message) {
10
- super(message);
12
+ super({ code: ErrorCode.PathEscape, title: message });
11
13
  this.name = "PathEscapeError";
12
14
  }
13
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {