@cruxy/cli 0.2.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.
Files changed (47) hide show
  1. package/README.md +39 -1
  2. package/dist/agent/loop.js +2 -1
  3. package/dist/cli/commands/config.js +2 -3
  4. package/dist/cli/commands/index.js +5 -2
  5. package/dist/cli/commands/run.js +21 -13
  6. package/dist/cli/commands/skills.d.ts +8 -0
  7. package/dist/cli/commands/skills.js +51 -0
  8. package/dist/cli/program.js +26 -4
  9. package/dist/cli/repl.js +14 -1
  10. package/dist/config/manager.js +5 -4
  11. package/dist/constants.d.ts +13 -0
  12. package/dist/constants.js +13 -0
  13. package/dist/errors/boundary.d.ts +43 -0
  14. package/dist/errors/boundary.js +73 -0
  15. package/dist/errors/constructors.d.ts +27 -0
  16. package/dist/errors/constructors.js +246 -0
  17. package/dist/errors/format.d.ts +31 -0
  18. package/dist/errors/format.js +60 -0
  19. package/dist/errors/index.d.ts +4 -0
  20. package/dist/errors/index.js +4 -0
  21. package/dist/errors/types.d.ts +74 -0
  22. package/dist/errors/types.js +107 -0
  23. package/dist/index.js +8 -5
  24. package/dist/indexing/embedder.js +3 -3
  25. package/dist/indexing/service.js +4 -1
  26. package/dist/skills/index.d.ts +4 -0
  27. package/dist/skills/index.js +4 -0
  28. package/dist/skills/loader.d.ts +43 -0
  29. package/dist/skills/loader.js +0 -0
  30. package/dist/skills/parser.d.ts +31 -0
  31. package/dist/skills/parser.js +98 -0
  32. package/dist/skills/service.d.ts +41 -0
  33. package/dist/skills/service.js +92 -0
  34. package/dist/skills/types.d.ts +94 -0
  35. package/dist/skills/types.js +21 -0
  36. package/dist/tools/file/paths.d.ts +4 -2
  37. package/dist/tools/file/paths.js +5 -3
  38. package/dist/tools/index.d.ts +2 -0
  39. package/dist/tools/index.js +2 -0
  40. package/dist/tools/list-skills.d.ts +9 -0
  41. package/dist/tools/list-skills.js +34 -0
  42. package/dist/tools/load-skill.d.ts +21 -0
  43. package/dist/tools/load-skill.js +49 -0
  44. package/dist/tools/registry.js +4 -0
  45. package/package.json +3 -2
  46. package/skills/git-commit/SKILL.md +60 -0
  47. package/skills/using-skills/SKILL.md +62 -0
package/README.md CHANGED
@@ -25,12 +25,18 @@ cruxy run # interactive session
25
25
  ## Features
26
26
 
27
27
  - **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
28
- `grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`.
28
+ `grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
29
+ `list_skills`, `load_skill`.
29
30
  - **Codebase index** — a local, incremental semantic index (`cruxy index`)
30
31
  behind the `search_codebase` tool. Embeddings run on-device via fastembed
31
32
  (ONNX, bge-small-en-v1.5) with no network calls; the store is SQLite at
32
33
  `.cruxy/index.db`. Only changed files are re-embedded; `.gitignore` /
33
34
  `.cruxyignore` are respected and secrets (`.env*`, keys) are never indexed.
35
+ - **Skills** — reusable, task-specific instructions in a `SKILL.md`
36
+ (frontmatter + markdown), discovered via `list_skills` and pulled on demand
37
+ with `load_skill` (progressive disclosure: only name + description are in
38
+ context until a skill is loaded). Layered project > user > builtin, strictly
39
+ validated, and never auto-executed.
34
40
  - **Agent** — streaming output, multi-turn interactive sessions, context
35
41
  compaction, and awareness of git state and project instructions (`CRUXY.md`).
36
42
  - **Safety** — a single approval gate with diff previews that fails closed;
@@ -53,6 +59,38 @@ The index refreshes itself lazily the first time the agent calls
53
59
  `search_codebase`, so it works even without running `cruxy index` first. The
54
60
  bge-small embedding model (~130 MB) downloads and caches on first use.
55
61
 
62
+ ### Skills
63
+
64
+ ```bash
65
+ cruxy skills # list the resolved skill catalog
66
+ cruxy skills --status # show source directories and validation errors
67
+ ```
68
+
69
+ Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
70
+ `~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
71
+ built-in `using-skills` skill for the authoring rules.
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
+
56
94
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
57
95
  provider-agnostic, built over `fetch`, with no vendor SDKs.
58
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
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy skills` — list the skills available to the agent (the same catalog the
4
+ * `list_skills` tool sees). `--status` additionally shows the three source
5
+ * directories in precedence order and any validation errors (skills that were
6
+ * excluded for being malformed).
7
+ */
8
+ export declare function skillsCommand(): Command;
@@ -0,0 +1,51 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { getSkillService, resetSkillServices } from "../../skills/index.js";
4
+ import { logger } from "../../utils/logger.js";
5
+ /**
6
+ * `cruxy skills` — list the skills available to the agent (the same catalog the
7
+ * `list_skills` tool sees). `--status` additionally shows the three source
8
+ * directories in precedence order and any validation errors (skills that were
9
+ * excluded for being malformed).
10
+ */
11
+ export function skillsCommand() {
12
+ return new Command("skills")
13
+ .description("list the skills available to the agent")
14
+ .option("--status", "show source directories and validation errors")
15
+ .action(async (opts) => {
16
+ const service = getSkillService(process.cwd(), logger);
17
+ const status = await service.status();
18
+ if (status.entries.length === 0) {
19
+ logger.print(pc.dim("no skills found"));
20
+ }
21
+ else {
22
+ for (const entry of status.entries) {
23
+ logger.print(`${pc.bold(entry.name)} ${pc.dim(`[${entry.source}]`)}`);
24
+ logger.print(` ${entry.description}`);
25
+ }
26
+ }
27
+ if (!opts.status) {
28
+ if (status.errors.length > 0) {
29
+ logger.print(pc.yellow(`\n${status.errors.length} skill(s) failed validation — run ${pc.bold("cruxy skills --status")} for details`));
30
+ }
31
+ resetSkillServices();
32
+ return;
33
+ }
34
+ logger.print(`\n${pc.bold("sources")} ${pc.dim("(precedence high → low):")}`);
35
+ for (const { source, dir } of status.sources) {
36
+ logger.print(` ${source.padEnd(8)} ${pc.dim(dir)}`);
37
+ }
38
+ logger.print("");
39
+ if (status.errors.length === 0) {
40
+ logger.print(pc.green("no validation errors"));
41
+ }
42
+ else {
43
+ logger.print(pc.yellow(`validation errors (${status.errors.length}):`));
44
+ for (const err of status.errors) {
45
+ logger.print(` ${pc.red("✗")} ${pc.bold(err.name)} ${pc.dim(`[${err.source}]`)}: ${err.message}`);
46
+ logger.print(` ${pc.dim(err.dir)}`);
47
+ }
48
+ }
49
+ resetSkillServices();
50
+ });
51
+ }
@@ -2,9 +2,11 @@ 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";
9
+ import { skillsCommand } from "./commands/skills.js";
8
10
  export function buildProgram() {
9
11
  const program = new Command();
10
12
  program
@@ -13,8 +15,7 @@ export function buildProgram() {
13
15
  .version(APP_VERSION, "-v, --version", "print the cruxy version")
14
16
  .option("-c, --config <path>", "use a specific config file")
15
17
  .option("--log-level <level>", "debug | info | warn | error | silent")
16
- .option("--verbose", "shorthand for --log-level debug")
17
- .showHelpAfterError("(run `cruxy --help` for usage)");
18
+ .option("--verbose", "shorthand for --log-level debug");
18
19
  // Apply global options as early as possible.
19
20
  program.hook("preAction", (thisCommand) => {
20
21
  const opts = thisCommand.opts();
@@ -26,13 +27,34 @@ export function buildProgram() {
26
27
  program.addCommand(runCommand());
27
28
  program.addCommand(configCommand());
28
29
  program.addCommand(indexCommand());
29
- // Default action: no subcommand -> interactive entrypoint (stub for now).
30
- program.action(() => {
30
+ program.addCommand(skillsCommand());
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
+ }
31
40
  logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
32
41
  logger.print(pc.dim("an agentic coding CLI\n"));
33
42
  logger.print("The interactive REPL lands in C.3 (terminal UI).");
34
43
  logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
35
44
  logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
36
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);
37
52
  return program;
38
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");
@@ -9,3 +9,16 @@ export declare const CONFIG_FILE_NAME = "config.json";
9
9
  export declare const PROJECT_CONFIG_FILENAMES: string[];
10
10
  /** Project-instruction filenames, checked in order (first match wins). */
11
11
  export declare const PROJECT_INSTRUCTION_FILENAMES: string[];
12
+ /**
13
+ * Name of the skills subdirectory, used under the project dir
14
+ * (`<cwd>/.cruxy/skills`), the global dir (`~/.cruxy/skills`), and the package
15
+ * root for shipped builtins.
16
+ */
17
+ export declare const SKILLS_DIR_NAME = "skills";
18
+ /**
19
+ * Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
20
+ * Anchored the same way as the package.json lookup above: both `dist/` and
21
+ * `src/` sit one level below the package root, so `../skills` resolves to the
22
+ * shipped `skills/` directory in dev, in `dist`, and when published.
23
+ */
24
+ export declare const BUILTIN_SKILLS_DIR: string;
package/dist/constants.js CHANGED
@@ -29,3 +29,16 @@ export const PROJECT_CONFIG_FILENAMES = [
29
29
  ];
30
30
  /** Project-instruction filenames, checked in order (first match wins). */
31
31
  export const PROJECT_INSTRUCTION_FILENAMES = ["CRUXY.md", "AGENTS.md"];
32
+ /**
33
+ * Name of the skills subdirectory, used under the project dir
34
+ * (`<cwd>/.cruxy/skills`), the global dir (`~/.cruxy/skills`), and the package
35
+ * root for shipped builtins.
36
+ */
37
+ export const SKILLS_DIR_NAME = "skills";
38
+ /**
39
+ * Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
40
+ * Anchored the same way as the package.json lookup above: both `dist/` and
41
+ * `src/` sit one level below the package root, so `../skills` resolves to the
42
+ * shipped `skills/` directory in dev, in `dist`, and when published.
43
+ */
44
+ export const BUILTIN_SKILLS_DIR = join(__dirname, "..", SKILLS_DIR_NAME);
@@ -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;