@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
@@ -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
  }
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./parser.js";
3
+ export * from "./loader.js";
4
+ export * from "./service.js";
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./parser.js";
3
+ export * from "./loader.js";
4
+ export * from "./service.js";
@@ -0,0 +1,43 @@
1
+ import { CruxyError } from "../errors/index.js";
2
+ import { type Skill, type SkillCatalog, type SkillError, type SkillSource } from "./types.js";
3
+ /** The three source directories the loader scans. */
4
+ export interface LoaderSources {
5
+ /** `<cwd>/.cruxy/skills` */
6
+ project: string;
7
+ /** `~/.cruxy/skills` */
8
+ user: string;
9
+ /** `<pkg>/skills` (shipped). */
10
+ builtin: string;
11
+ }
12
+ /** A valid skill discovered in a source, before precedence is resolved. */
13
+ interface SkillCandidate {
14
+ name: string;
15
+ description: string;
16
+ source: SkillSource;
17
+ dir: string;
18
+ }
19
+ /** Thrown by `getSkill` when no catalog entry matches the requested name. */
20
+ export declare class SkillNotFoundError extends CruxyError {
21
+ constructor(message: string);
22
+ }
23
+ /**
24
+ * Scan all three sources, validate every SKILL.md, and resolve precedence into a
25
+ * {@link SkillCatalog}. Validation failures become {@link SkillError}s (excluded
26
+ * from the catalog, surfaced in `cruxy skills --status`) rather than throwing —
27
+ * one bad skill never breaks the rest.
28
+ */
29
+ export declare function loadCatalog(sources: LoaderSources): Promise<SkillCatalog>;
30
+ /**
31
+ * Resolve candidates into a catalog. Pure (no I/O) so precedence and collision
32
+ * rules are directly testable. `candidates` must arrive in precedence order
33
+ * (project first); the first occurrence of a name wins across sources, while a
34
+ * second occurrence *within the same source* is a loud, excluded error.
35
+ */
36
+ export declare function resolvePrecedence(candidates: SkillCandidate[], baseErrors?: SkillError[]): SkillCatalog;
37
+ /**
38
+ * Read a skill's full body and resolve its asset paths on demand. The body is
39
+ * re-read from disk every call (never cached), so edits are always live. Throws
40
+ * {@link SkillNotFoundError} if `name` isn't in the catalog.
41
+ */
42
+ export declare function getSkill(catalog: SkillCatalog, name: string): Promise<Skill>;
43
+ export {};
Binary file