@cruxy/cli 0.3.0 → 0.6.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 +46 -1
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.js +2 -2
- package/dist/agent/prompts.d.ts +0 -2
- package/dist/agent/prompts.js +3 -3
- package/dist/approval/classify.d.ts +18 -0
- package/dist/approval/classify.js +162 -0
- package/dist/approval/index.d.ts +5 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/policy.d.ts +37 -0
- package/dist/approval/policy.js +81 -0
- package/dist/approval/prompt.d.ts +33 -0
- package/dist/approval/prompt.js +212 -0
- package/dist/approval/service.d.ts +36 -0
- package/dist/approval/service.js +37 -0
- package/dist/approval/types.d.ts +64 -0
- package/dist/approval/types.js +1 -0
- package/dist/cli/commands/config.js +2 -3
- package/dist/cli/commands/index.js +5 -2
- package/dist/cli/commands/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +31 -23
- package/dist/cli/program.js +26 -4
- package/dist/cli/repl.js +15 -2
- package/dist/config/manager.js +5 -4
- package/dist/config/schema.d.ts +38 -9
- package/dist/config/schema.js +13 -4
- package/dist/errors/boundary.d.ts +43 -0
- package/dist/errors/boundary.js +73 -0
- package/dist/errors/constructors.d.ts +52 -0
- package/dist/errors/constructors.js +329 -0
- package/dist/errors/format.d.ts +31 -0
- package/dist/errors/format.js +60 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.js +4 -0
- package/dist/errors/types.d.ts +79 -0
- package/dist/errors/types.js +118 -0
- package/dist/index.js +8 -5
- package/dist/indexing/embedder.js +3 -3
- package/dist/indexing/service.js +4 -1
- package/dist/skills/loader.d.ts +2 -1
- package/dist/skills/loader.js +0 -0
- package/dist/skills/parser.d.ts +6 -4
- package/dist/skills/parser.js +13 -5
- package/dist/tools/create-pull-request.d.ts +24 -0
- package/dist/tools/create-pull-request.js +83 -0
- package/dist/tools/file/apply-patch.js +3 -3
- package/dist/tools/file/edit-file.js +6 -3
- package/dist/tools/file/paths.d.ts +4 -2
- package/dist/tools/file/paths.js +5 -3
- package/dist/tools/file/write-file.js +6 -3
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/shell/run-command.js +11 -3
- package/dist/tools/types.d.ts +25 -6
- package/dist/vcs/auth.d.ts +22 -0
- package/dist/vcs/auth.js +29 -0
- package/dist/vcs/generate.d.ts +72 -0
- package/dist/vcs/generate.js +265 -0
- package/dist/vcs/git.d.ts +52 -0
- package/dist/vcs/git.js +152 -0
- package/dist/vcs/github.d.ts +44 -0
- package/dist/vcs/github.js +145 -0
- package/dist/vcs/guidance.d.ts +20 -0
- package/dist/vcs/guidance.js +76 -0
- package/dist/vcs/index.d.ts +7 -0
- package/dist/vcs/index.js +7 -0
- package/dist/vcs/service.d.ts +53 -0
- package/dist/vcs/service.js +79 -0
- package/dist/vcs/types.d.ts +57 -0
- package/dist/vcs/types.js +6 -0
- package/package.json +1 -1
- package/dist/agent/approval.d.ts +0 -41
- package/dist/agent/approval.js +0 -179
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
// ── approval (exit 10) ────────────────────────────────────────────────────────
|
|
212
|
+
/**
|
|
213
|
+
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
|
214
|
+
* no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
|
|
215
|
+
* can tell "needed approval" apart from a usage error.
|
|
216
|
+
*/
|
|
217
|
+
export function approvalRequired(summary) {
|
|
218
|
+
return new CruxyError({
|
|
219
|
+
code: ErrorCode.ApprovalRequired,
|
|
220
|
+
title: "this action needs your approval, but cruxy is running non-interactively",
|
|
221
|
+
cause: `pending action — ${summary}`,
|
|
222
|
+
nextSteps: [
|
|
223
|
+
"run cruxy in an interactive terminal so you can approve actions",
|
|
224
|
+
"or scope the task to read-only actions (no file writes or shell commands)",
|
|
225
|
+
],
|
|
226
|
+
meta: { summary },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// ── vcs: forge + git (exit 4 / 2 / 6 / 5) ─────────────────────────────────────
|
|
230
|
+
/**
|
|
231
|
+
* No forge token could be resolved (PR generation, C.15). The chain is env →
|
|
232
|
+
* `gh auth token` → fail. We never prompt for, store, or persist a token, so the
|
|
233
|
+
* fix is always to provide one in the environment.
|
|
234
|
+
*/
|
|
235
|
+
export function forgeAuth(host = "github.com") {
|
|
236
|
+
return new CruxyError({
|
|
237
|
+
code: ErrorCode.ForgeAuth,
|
|
238
|
+
title: `no ${host} token available to open a pull request`,
|
|
239
|
+
cause: "checked GITHUB_TOKEN, GH_TOKEN, and `gh auth token` — none provided a token",
|
|
240
|
+
nextSteps: [
|
|
241
|
+
"export GITHUB_TOKEN=… (a personal access token with `repo` scope)",
|
|
242
|
+
"or install the GitHub CLI and run `gh auth login`",
|
|
243
|
+
],
|
|
244
|
+
meta: { host },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* A commit/push was attempted on a protected branch (`main`/`master`/configured).
|
|
249
|
+
* The PR flow must branch off first; this is the last-line guard.
|
|
250
|
+
*/
|
|
251
|
+
export function gitProtectedBranch(branch) {
|
|
252
|
+
return new CruxyError({
|
|
253
|
+
code: ErrorCode.GitProtectedBranch,
|
|
254
|
+
title: `refusing to commit or push on the protected branch "${branch}"`,
|
|
255
|
+
cause: "cruxy never writes directly to a protected branch",
|
|
256
|
+
nextSteps: [
|
|
257
|
+
"switch to a feature branch first, e.g. `git switch -c feat/my-change`",
|
|
258
|
+
"adjust the protected list with `git.protectedBranches` in your config",
|
|
259
|
+
],
|
|
260
|
+
meta: { branch },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** The forge REST API returned an error (non-auth) while opening a PR. */
|
|
264
|
+
export function forgeApi(title, underlying, meta) {
|
|
265
|
+
return new CruxyError({
|
|
266
|
+
code: ErrorCode.ForgeApi,
|
|
267
|
+
title,
|
|
268
|
+
cause: messageOf(underlying),
|
|
269
|
+
nextSteps: [
|
|
270
|
+
"retry in a moment; if it persists, check the forge's status",
|
|
271
|
+
"re-run with --verbose to see the API response",
|
|
272
|
+
],
|
|
273
|
+
underlying,
|
|
274
|
+
meta,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `git push` failed — most often the husky `pre-push` verify hook (build ·
|
|
279
|
+
* typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
|
|
280
|
+
* `--no-verify`, so the underlying reason is surfaced verbatim.
|
|
281
|
+
*/
|
|
282
|
+
export function gitPushFailed(branch, stderr) {
|
|
283
|
+
return new CruxyError({
|
|
284
|
+
code: ErrorCode.GitPushFailed,
|
|
285
|
+
title: `failed to push branch "${branch}"`,
|
|
286
|
+
cause: stderr?.trim() || undefined,
|
|
287
|
+
nextSteps: [
|
|
288
|
+
"check the error above (the pre-push hook runs build · typecheck · lint · test)",
|
|
289
|
+
"fix the reported issue and try again — cruxy never force-pushes or skips hooks",
|
|
290
|
+
],
|
|
291
|
+
meta: { branch },
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
295
|
+
export function internal(underlying) {
|
|
296
|
+
return new CruxyError({
|
|
297
|
+
code: ErrorCode.Internal,
|
|
298
|
+
title: "an unexpected internal error occurred",
|
|
299
|
+
cause: messageOf(underlying),
|
|
300
|
+
nextSteps: [
|
|
301
|
+
"re-run with --verbose (or --log-level debug) to see the underlying error",
|
|
302
|
+
`report it at ${ISSUE_URL} with the error code and details`,
|
|
303
|
+
],
|
|
304
|
+
underlying,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
309
|
+
* {@link CruxyError}, or `null` if it isn't one. Order matters: specific
|
|
310
|
+
* subclasses before the `ApiError` base.
|
|
311
|
+
*/
|
|
312
|
+
export function classifyProviderError(underlying) {
|
|
313
|
+
if (underlying instanceof AuthError)
|
|
314
|
+
return authInvalid(underlying);
|
|
315
|
+
if (underlying instanceof RateLimitError)
|
|
316
|
+
return apiRateLimit(underlying);
|
|
317
|
+
if (underlying instanceof OverloadedError)
|
|
318
|
+
return apiOverloaded(underlying);
|
|
319
|
+
if (underlying instanceof NetworkError)
|
|
320
|
+
return gatewayUnreachable(underlying);
|
|
321
|
+
if (underlying instanceof ApiError) {
|
|
322
|
+
// BudgetExhaustedError isn't exported by the SDK; match by name.
|
|
323
|
+
if (underlying.name === "BudgetExhaustedError") {
|
|
324
|
+
return budgetExhausted(underlying);
|
|
325
|
+
}
|
|
326
|
+
return apiError(underlying);
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
@@ -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,79 @@
|
|
|
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 GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
|
|
20
|
+
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
21
|
+
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
22
|
+
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
23
|
+
readonly AuthInvalid: "CRUXY_E_AUTH_INVALID";
|
|
24
|
+
readonly ForgeAuth: "CRUXY_E_FORGE_AUTH";
|
|
25
|
+
readonly GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE";
|
|
26
|
+
readonly GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED";
|
|
27
|
+
readonly Api: "CRUXY_E_API";
|
|
28
|
+
readonly ApiRateLimit: "CRUXY_E_API_RATE_LIMIT";
|
|
29
|
+
readonly ApiOverloaded: "CRUXY_E_API_OVERLOADED";
|
|
30
|
+
readonly BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED";
|
|
31
|
+
readonly ForgeApi: "CRUXY_E_FORGE_API";
|
|
32
|
+
readonly FileNotFound: "CRUXY_E_FILE_NOT_FOUND";
|
|
33
|
+
readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
|
|
34
|
+
readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
|
|
35
|
+
readonly IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE";
|
|
36
|
+
readonly IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE";
|
|
37
|
+
readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
|
|
38
|
+
readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
|
|
39
|
+
readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
|
|
40
|
+
readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
|
|
41
|
+
};
|
|
42
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
43
|
+
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
44
|
+
export declare function exitCodeFor(code: ErrorCode): number;
|
|
45
|
+
export interface CruxyErrorInit {
|
|
46
|
+
/** Stable error id (drives the exit code and appears in output). */
|
|
47
|
+
code: ErrorCode;
|
|
48
|
+
/** One plain line: what failed. */
|
|
49
|
+
title: string;
|
|
50
|
+
/** The specific reason, when known. Always shown (part 2 of the standard). */
|
|
51
|
+
cause?: string;
|
|
52
|
+
/** Concrete actions the user can take. */
|
|
53
|
+
nextSteps?: string[];
|
|
54
|
+
/** Structured context (not rendered; for future JSON output / telemetry). */
|
|
55
|
+
meta?: Record<string, unknown>;
|
|
56
|
+
/** The original thrown error, preserved and shown only under `--verbose`. */
|
|
57
|
+
underlying?: unknown;
|
|
58
|
+
/** Override the exit code; defaults to {@link exitCodeFor}(code). */
|
|
59
|
+
exitCode?: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every user-facing failure is (or becomes) one of these. Extends `Error` so it
|
|
63
|
+
* flows through normal throw/catch; `.message` mirrors `title` so logs and
|
|
64
|
+
* `instanceof Error` consumers stay useful.
|
|
65
|
+
*/
|
|
66
|
+
export declare class CruxyError extends Error {
|
|
67
|
+
readonly code: ErrorCode;
|
|
68
|
+
readonly title: string;
|
|
69
|
+
/** Human-readable reason (part 2). Narrows the inherited `Error.cause`. */
|
|
70
|
+
readonly cause?: string;
|
|
71
|
+
readonly nextSteps: string[];
|
|
72
|
+
readonly exitCode: number;
|
|
73
|
+
readonly meta?: Record<string, unknown>;
|
|
74
|
+
/** The wrapped lower-level error — rendered only under `--verbose`. */
|
|
75
|
+
readonly underlying?: unknown;
|
|
76
|
+
constructor(init: CruxyErrorInit);
|
|
77
|
+
/** Type guard — true for any CruxyError (across realms, via the brand). */
|
|
78
|
+
static is(err: unknown): err is CruxyError;
|
|
79
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
|
|
22
|
+
// config (exit 3)
|
|
23
|
+
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
24
|
+
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
25
|
+
// auth (exit 4)
|
|
26
|
+
AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
|
|
27
|
+
AuthInvalid: "CRUXY_E_AUTH_INVALID",
|
|
28
|
+
ForgeAuth: "CRUXY_E_FORGE_AUTH",
|
|
29
|
+
// network (exit 5)
|
|
30
|
+
GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE",
|
|
31
|
+
GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
|
|
32
|
+
// api (exit 6)
|
|
33
|
+
Api: "CRUXY_E_API",
|
|
34
|
+
ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
|
|
35
|
+
ApiOverloaded: "CRUXY_E_API_OVERLOADED",
|
|
36
|
+
BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
|
|
37
|
+
ForgeApi: "CRUXY_E_FORGE_API",
|
|
38
|
+
// filesystem (exit 7)
|
|
39
|
+
FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
|
|
40
|
+
PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
|
|
41
|
+
PathEscape: "CRUXY_E_PATH_ESCAPE",
|
|
42
|
+
// index (exit 8)
|
|
43
|
+
IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE",
|
|
44
|
+
IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE",
|
|
45
|
+
IndexFailed: "CRUXY_E_INDEX_FAILED",
|
|
46
|
+
// skill (exit 9)
|
|
47
|
+
SkillInvalid: "CRUXY_E_SKILL_INVALID",
|
|
48
|
+
SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND",
|
|
49
|
+
// approval (exit 10)
|
|
50
|
+
ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
54
|
+
* branch on *why* cruxy failed. Documented in the README error table.
|
|
55
|
+
*/
|
|
56
|
+
const EXIT_CODES = {
|
|
57
|
+
[ErrorCode.Internal]: 1,
|
|
58
|
+
[ErrorCode.Usage]: 2,
|
|
59
|
+
[ErrorCode.ConfigKeyUnknown]: 2,
|
|
60
|
+
[ErrorCode.ProviderUnsupported]: 2,
|
|
61
|
+
[ErrorCode.GitProtectedBranch]: 2,
|
|
62
|
+
[ErrorCode.ConfigParse]: 3,
|
|
63
|
+
[ErrorCode.ConfigInvalid]: 3,
|
|
64
|
+
[ErrorCode.AuthMissingKey]: 4,
|
|
65
|
+
[ErrorCode.AuthInvalid]: 4,
|
|
66
|
+
[ErrorCode.ForgeAuth]: 4,
|
|
67
|
+
[ErrorCode.GatewayUnreachable]: 5,
|
|
68
|
+
[ErrorCode.GitPushFailed]: 5,
|
|
69
|
+
[ErrorCode.Api]: 6,
|
|
70
|
+
[ErrorCode.ApiRateLimit]: 6,
|
|
71
|
+
[ErrorCode.ApiOverloaded]: 6,
|
|
72
|
+
[ErrorCode.BudgetExhausted]: 6,
|
|
73
|
+
[ErrorCode.ForgeApi]: 6,
|
|
74
|
+
[ErrorCode.FileNotFound]: 7,
|
|
75
|
+
[ErrorCode.PermissionDenied]: 7,
|
|
76
|
+
[ErrorCode.PathEscape]: 7,
|
|
77
|
+
[ErrorCode.IndexEmbedderUnavailable]: 8,
|
|
78
|
+
[ErrorCode.IndexStoreUnavailable]: 8,
|
|
79
|
+
[ErrorCode.IndexFailed]: 8,
|
|
80
|
+
[ErrorCode.SkillInvalid]: 9,
|
|
81
|
+
[ErrorCode.SkillNotFound]: 9,
|
|
82
|
+
[ErrorCode.ApprovalRequired]: 10,
|
|
83
|
+
};
|
|
84
|
+
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
85
|
+
export function exitCodeFor(code) {
|
|
86
|
+
return EXIT_CODES[code] ?? 1;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Every user-facing failure is (or becomes) one of these. Extends `Error` so it
|
|
90
|
+
* flows through normal throw/catch; `.message` mirrors `title` so logs and
|
|
91
|
+
* `instanceof Error` consumers stay useful.
|
|
92
|
+
*/
|
|
93
|
+
export class CruxyError extends Error {
|
|
94
|
+
code;
|
|
95
|
+
title;
|
|
96
|
+
/** Human-readable reason (part 2). Narrows the inherited `Error.cause`. */
|
|
97
|
+
cause;
|
|
98
|
+
nextSteps;
|
|
99
|
+
exitCode;
|
|
100
|
+
meta;
|
|
101
|
+
/** The wrapped lower-level error — rendered only under `--verbose`. */
|
|
102
|
+
underlying;
|
|
103
|
+
constructor(init) {
|
|
104
|
+
super(init.title);
|
|
105
|
+
this.name = "CruxyError";
|
|
106
|
+
this.code = init.code;
|
|
107
|
+
this.title = init.title;
|
|
108
|
+
this.cause = init.cause;
|
|
109
|
+
this.nextSteps = init.nextSteps ?? [];
|
|
110
|
+
this.meta = init.meta;
|
|
111
|
+
this.underlying = init.underlying;
|
|
112
|
+
this.exitCode = init.exitCode ?? exitCodeFor(init.code);
|
|
113
|
+
}
|
|
114
|
+
/** Type guard — true for any CruxyError (across realms, via the brand). */
|
|
115
|
+
static is(err) {
|
|
116
|
+
return err instanceof CruxyError;
|
|
117
|
+
}
|
|
118
|
+
}
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
}
|