@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.
- package/README.md +39 -1
- package/dist/agent/loop.js +2 -1
- package/dist/cli/commands/config.js +2 -3
- package/dist/cli/commands/index.js +5 -2
- package/dist/cli/commands/run.js +21 -13
- package/dist/cli/commands/skills.d.ts +8 -0
- package/dist/cli/commands/skills.js +51 -0
- package/dist/cli/program.js +26 -4
- package/dist/cli/repl.js +14 -1
- package/dist/config/manager.js +5 -4
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +13 -0
- package/dist/errors/boundary.d.ts +43 -0
- package/dist/errors/boundary.js +73 -0
- package/dist/errors/constructors.d.ts +27 -0
- package/dist/errors/constructors.js +246 -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 +74 -0
- package/dist/errors/types.js +107 -0
- package/dist/index.js +8 -5
- package/dist/indexing/embedder.js +3 -3
- package/dist/indexing/service.js +4 -1
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/loader.d.ts +43 -0
- package/dist/skills/loader.js +0 -0
- package/dist/skills/parser.d.ts +31 -0
- package/dist/skills/parser.js +98 -0
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +92 -0
- package/dist/skills/types.d.ts +94 -0
- package/dist/skills/types.js +21 -0
- package/dist/tools/file/paths.d.ts +4 -2
- package/dist/tools/file/paths.js +5 -3
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/list-skills.d.ts +9 -0
- package/dist/tools/list-skills.js +34 -0
- package/dist/tools/load-skill.d.ts +21 -0
- package/dist/tools/load-skill.js +49 -0
- package/dist/tools/registry.js +4 -0
- package/package.json +3 -2
- package/skills/git-commit/SKILL.md +60 -0
- 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
|
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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();
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
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
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
99
|
+
printReplError(err);
|
|
87
100
|
}
|
|
88
101
|
continue;
|
|
89
102
|
}
|
package/dist/config/manager.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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");
|
package/dist/constants.d.ts
CHANGED
|
@@ -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;
|