@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.
Files changed (76) hide show
  1. package/README.md +46 -1
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.js +2 -2
  5. package/dist/agent/prompts.d.ts +0 -2
  6. package/dist/agent/prompts.js +3 -3
  7. package/dist/approval/classify.d.ts +18 -0
  8. package/dist/approval/classify.js +162 -0
  9. package/dist/approval/index.d.ts +5 -0
  10. package/dist/approval/index.js +5 -0
  11. package/dist/approval/policy.d.ts +37 -0
  12. package/dist/approval/policy.js +81 -0
  13. package/dist/approval/prompt.d.ts +33 -0
  14. package/dist/approval/prompt.js +212 -0
  15. package/dist/approval/service.d.ts +36 -0
  16. package/dist/approval/service.js +37 -0
  17. package/dist/approval/types.d.ts +64 -0
  18. package/dist/approval/types.js +1 -0
  19. package/dist/cli/commands/config.js +2 -3
  20. package/dist/cli/commands/index.js +5 -2
  21. package/dist/cli/commands/pr.d.ts +8 -0
  22. package/dist/cli/commands/pr.js +87 -0
  23. package/dist/cli/commands/run.js +31 -23
  24. package/dist/cli/program.js +26 -4
  25. package/dist/cli/repl.js +15 -2
  26. package/dist/config/manager.js +5 -4
  27. package/dist/config/schema.d.ts +38 -9
  28. package/dist/config/schema.js +13 -4
  29. package/dist/errors/boundary.d.ts +43 -0
  30. package/dist/errors/boundary.js +73 -0
  31. package/dist/errors/constructors.d.ts +52 -0
  32. package/dist/errors/constructors.js +329 -0
  33. package/dist/errors/format.d.ts +31 -0
  34. package/dist/errors/format.js +60 -0
  35. package/dist/errors/index.d.ts +4 -0
  36. package/dist/errors/index.js +4 -0
  37. package/dist/errors/types.d.ts +79 -0
  38. package/dist/errors/types.js +118 -0
  39. package/dist/index.js +8 -5
  40. package/dist/indexing/embedder.js +3 -3
  41. package/dist/indexing/service.js +4 -1
  42. package/dist/skills/loader.d.ts +2 -1
  43. package/dist/skills/loader.js +0 -0
  44. package/dist/skills/parser.d.ts +6 -4
  45. package/dist/skills/parser.js +13 -5
  46. package/dist/tools/create-pull-request.d.ts +24 -0
  47. package/dist/tools/create-pull-request.js +83 -0
  48. package/dist/tools/file/apply-patch.js +3 -3
  49. package/dist/tools/file/edit-file.js +6 -3
  50. package/dist/tools/file/paths.d.ts +4 -2
  51. package/dist/tools/file/paths.js +5 -3
  52. package/dist/tools/file/write-file.js +6 -3
  53. package/dist/tools/index.d.ts +1 -0
  54. package/dist/tools/index.js +1 -0
  55. package/dist/tools/registry.js +2 -0
  56. package/dist/tools/shell/run-command.js +11 -3
  57. package/dist/tools/types.d.ts +25 -6
  58. package/dist/vcs/auth.d.ts +22 -0
  59. package/dist/vcs/auth.js +29 -0
  60. package/dist/vcs/generate.d.ts +72 -0
  61. package/dist/vcs/generate.js +265 -0
  62. package/dist/vcs/git.d.ts +52 -0
  63. package/dist/vcs/git.js +152 -0
  64. package/dist/vcs/github.d.ts +44 -0
  65. package/dist/vcs/github.js +145 -0
  66. package/dist/vcs/guidance.d.ts +20 -0
  67. package/dist/vcs/guidance.js +76 -0
  68. package/dist/vcs/index.d.ts +7 -0
  69. package/dist/vcs/index.js +7 -0
  70. package/dist/vcs/service.d.ts +53 -0
  71. package/dist/vcs/service.js +79 -0
  72. package/dist/vcs/types.d.ts +57 -0
  73. package/dist/vcs/types.js +6 -0
  74. package/package.json +1 -1
  75. package/dist/agent/approval.d.ts +0 -41
  76. package/dist/agent/approval.js +0 -179
@@ -0,0 +1,212 @@
1
+ import path from "node:path";
2
+ import pc from "picocolors";
3
+ /** Cap on rendered preview lines before collapsing the rest. */
4
+ const PREVIEW_MAX_LINES = 40;
5
+ /**
6
+ * Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
7
+ * read a follow-up line (reason / instruction). Anything else — including EOF —
8
+ * is a reject.
9
+ */
10
+ export async function promptForApproval(request, io) {
11
+ io.write(render(request, io.color));
12
+ const key = (await io.readKey()).toLowerCase();
13
+ io.write("\n");
14
+ switch (key) {
15
+ case "y":
16
+ return { kind: "once" };
17
+ case "a":
18
+ return { kind: "session" };
19
+ case "n": {
20
+ io.write(" reason (optional, sent to the agent): ");
21
+ const reason = (await io.readLine()).trim();
22
+ return reason ? { kind: "reject", reason } : { kind: "reject" };
23
+ }
24
+ case "t": {
25
+ io.write(" what should the agent do instead? ");
26
+ const instruction = (await io.readLine()).trim();
27
+ return instruction
28
+ ? { kind: "instruct", instruction }
29
+ : { kind: "reject" };
30
+ }
31
+ default:
32
+ // n/a key, empty, EOF, Ctrl-C → default-deny.
33
+ return { kind: "reject" };
34
+ }
35
+ }
36
+ /** Render the full prompt block: header, detail (diff or command+cwd), choices. */
37
+ export function render(request, color) {
38
+ const c = pc.createColors(color);
39
+ const destructive = request.tier === "destructive";
40
+ const mark = destructive ? c.red(c.bold("!")) : c.yellow("?");
41
+ const label = destructive ? c.red(c.bold(" (destructive)")) : "";
42
+ const lines = [];
43
+ lines.push(`${mark} cruxy wants to ${c.bold(request.summary)}${label}`);
44
+ lines.push(detail(request, c));
45
+ lines.push(choices(request.scope, c));
46
+ return lines.filter((l) => l !== "").join("\n") + " ";
47
+ }
48
+ /** The action detail: a diff for file actions, the command + cwd for shell. */
49
+ function detail(request, c) {
50
+ if (request.action.kind === "shell") {
51
+ return [
52
+ ` ${c.dim("$")} ${request.action.command ?? ""}`,
53
+ ` ${c.dim(`in ${request.cwd}`)}`,
54
+ ].join("\n");
55
+ }
56
+ return renderPreview(request.action.preview, c);
57
+ }
58
+ /** The choices line, including a short label of what an `a` grant would cover. */
59
+ function choices(scope, c) {
60
+ const grant = scopeLabel(scope);
61
+ const a = grant === null
62
+ ? `${c.dim("[a] allow this kind (n/a here)")}`
63
+ : `[a] allow ${c.bold(grant)} this session`;
64
+ return ` ${c.dim("[y] approve once ·")} ${a} ${c.dim("· [n] reject · [t] reject & instruct:")}`;
65
+ }
66
+ /** Short human label for what a session grant would allow, or null if none. */
67
+ function scopeLabel(scope) {
68
+ if (scope.kind === "shell-prefix")
69
+ return `${scope.token} commands`;
70
+ if (scope.kind === "file-subtree")
71
+ return `changes under ${path.basename(scope.root)}/`;
72
+ return null;
73
+ }
74
+ // ── diff rendering (shared with the old C.6 renderer) ──────────────────────────
75
+ function diffLines(oldStr, newStr, c) {
76
+ const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
77
+ const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
78
+ return [...removed, ...added];
79
+ }
80
+ function renderPatchFiles(files, c) {
81
+ const out = [];
82
+ for (const file of files) {
83
+ if (file.op === "delete") {
84
+ out.push(c.red(`delete ${file.path}`));
85
+ }
86
+ else if (file.op === "create") {
87
+ out.push(c.green(`create ${file.path}`));
88
+ out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
89
+ if (file.omittedLines > 0)
90
+ out.push(c.dim(` ...${file.omittedLines} more lines`));
91
+ }
92
+ else {
93
+ out.push(c.yellow(`update ${file.path}`));
94
+ for (const hunk of file.hunks)
95
+ out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ /** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
101
+ function renderPrPreview(preview, c) {
102
+ const out = [];
103
+ out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
104
+ out.push("");
105
+ out.push(c.bold("commit"));
106
+ out.push(` ${preview.commitSubject}`);
107
+ for (const line of bodyLines(preview.commitBody))
108
+ out.push(c.dim(` ${line}`));
109
+ out.push("");
110
+ out.push(`${c.bold("pull request")} ${preview.prTitle}`);
111
+ for (const line of bodyLines(preview.prBody))
112
+ out.push(c.dim(` ${line}`));
113
+ return out;
114
+ }
115
+ /** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
116
+ function bodyLines(body) {
117
+ const lines = body.replace(/\s+$/, "").split("\n");
118
+ return lines.length === 1 && lines[0] === "" ? [] : lines;
119
+ }
120
+ function renderPreview(preview, c) {
121
+ if (!preview)
122
+ return "";
123
+ let lines;
124
+ if (preview.type === "edit") {
125
+ lines = diffLines(preview.oldStr, preview.newStr, c);
126
+ }
127
+ else if (preview.type === "patch") {
128
+ lines = renderPatchFiles(preview.files, c);
129
+ }
130
+ else if (preview.type === "pr") {
131
+ lines = renderPrPreview(preview, c);
132
+ }
133
+ else {
134
+ const header = preview.exists
135
+ ? c.yellow("OVERWRITE existing")
136
+ : c.green("create");
137
+ const body = preview.lines.map((l) => ` ${l}`);
138
+ if (preview.omittedLines > 0)
139
+ body.push(c.dim(` ...${preview.omittedLines} more lines`));
140
+ lines = [header, ...body];
141
+ }
142
+ if (lines.length > PREVIEW_MAX_LINES) {
143
+ const hidden = lines.length - PREVIEW_MAX_LINES;
144
+ lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
145
+ }
146
+ return lines.map((l) => ` ${l}`).join("\n");
147
+ }
148
+ // ── default stdin-backed PromptIO ──────────────────────────────────────────────
149
+ /** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
150
+ export function defaultPromptIO(color) {
151
+ return {
152
+ write: (text) => void process.stderr.write(text),
153
+ readKey: readKeyFromStdin,
154
+ readLine: readLineFromStdin,
155
+ color,
156
+ };
157
+ }
158
+ /** Read a single keypress in raw mode; "" on EOF. Always restores cooked mode. */
159
+ function readKeyFromStdin() {
160
+ const stdin = process.stdin;
161
+ return new Promise((resolve) => {
162
+ const cleanup = () => {
163
+ stdin.removeListener("data", onData);
164
+ stdin.removeListener("end", onEnd);
165
+ if (stdin.isTTY)
166
+ stdin.setRawMode(false);
167
+ stdin.pause();
168
+ };
169
+ const onData = (buf) => {
170
+ cleanup();
171
+ resolve(buf.toString("utf8").slice(0, 1));
172
+ };
173
+ const onEnd = () => {
174
+ cleanup();
175
+ resolve("");
176
+ };
177
+ if (stdin.isTTY)
178
+ stdin.setRawMode(true);
179
+ stdin.resume();
180
+ stdin.once("data", onData);
181
+ stdin.once("end", onEnd);
182
+ });
183
+ }
184
+ /** Read one line in cooked mode; "" on EOF. */
185
+ function readLineFromStdin() {
186
+ const stdin = process.stdin;
187
+ return new Promise((resolve) => {
188
+ let buf = "";
189
+ const cleanup = () => {
190
+ stdin.removeListener("data", onData);
191
+ stdin.removeListener("end", onEnd);
192
+ stdin.pause();
193
+ };
194
+ const onData = (chunk) => {
195
+ buf += chunk.toString("utf8");
196
+ const nl = buf.indexOf("\n");
197
+ if (nl !== -1) {
198
+ cleanup();
199
+ resolve(buf.slice(0, nl).replace(/\r$/, ""));
200
+ }
201
+ };
202
+ const onEnd = () => {
203
+ cleanup();
204
+ resolve(buf.replace(/\r$/, ""));
205
+ };
206
+ if (stdin.isTTY)
207
+ stdin.setRawMode(false);
208
+ stdin.resume();
209
+ stdin.on("data", onData);
210
+ stdin.once("end", onEnd);
211
+ });
212
+ }
@@ -0,0 +1,36 @@
1
+ import type { ApproveAction } from "../tools/types.js";
2
+ import { type PromptIO } from "./prompt.js";
3
+ import type { ApprovalDecision, ApprovalPolicy } from "./types.js";
4
+ /**
5
+ * The approval gate, called by the tool layer **before any side effect**. It
6
+ * classifies the action, lets read-only actions through, **throws
7
+ * CRUXY_E_APPROVAL_REQUIRED when it can't ask** (non-interactive — default-deny,
8
+ * never auto-approve), and otherwise defers to the policy. It owns the
9
+ * per-session allowlist, so a new service ⇒ a fresh session.
10
+ */
11
+ export interface ApprovalServiceOptions {
12
+ /** Resolved project root the actions run in. */
13
+ cwd: string;
14
+ /** Whether cruxy can actually prompt (stdin is a TTY). */
15
+ interactive: boolean;
16
+ /** Override the decision policy (the seam — tests / a future CI policy). */
17
+ policy?: ApprovalPolicy;
18
+ /** Override the prompt I/O (tests script keys/lines). */
19
+ io?: PromptIO;
20
+ }
21
+ export declare class ApprovalService {
22
+ private readonly cwd;
23
+ private readonly interactive;
24
+ private readonly allowlist;
25
+ private readonly policy;
26
+ constructor(opts: ApprovalServiceOptions);
27
+ /**
28
+ * Decide whether `action` may proceed. Read-only ⇒ allow. Non-interactive ⇒
29
+ * throw {@link approvalRequired} (the U.5 error system, exit 10). Otherwise the
30
+ * policy prompts. A returned `{allow:false}` is a clean rejection (fed back to
31
+ * the agent), not an error.
32
+ */
33
+ requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
34
+ /** Clear all session grants (a fresh session). */
35
+ resetSession(): void;
36
+ }
@@ -0,0 +1,37 @@
1
+ import { approvalRequired } from "../errors/index.js";
2
+ import { shouldUseColor } from "../errors/index.js";
3
+ import { classify } from "./classify.js";
4
+ import { InteractivePolicy, SessionAllowlist } from "./policy.js";
5
+ import { defaultPromptIO } from "./prompt.js";
6
+ export class ApprovalService {
7
+ cwd;
8
+ interactive;
9
+ allowlist = new SessionAllowlist();
10
+ policy;
11
+ constructor(opts) {
12
+ this.cwd = opts.cwd;
13
+ this.interactive = opts.interactive;
14
+ this.policy =
15
+ opts.policy ??
16
+ new InteractivePolicy(this.allowlist, opts.io ?? defaultPromptIO(shouldUseColor()));
17
+ }
18
+ /**
19
+ * Decide whether `action` may proceed. Read-only ⇒ allow. Non-interactive ⇒
20
+ * throw {@link approvalRequired} (the U.5 error system, exit 10). Otherwise the
21
+ * policy prompts. A returned `{allow:false}` is a clean rejection (fed back to
22
+ * the agent), not an error.
23
+ */
24
+ async requestApproval(action) {
25
+ const request = classify(action, this.cwd);
26
+ if (request.tier === "read")
27
+ return { allow: true };
28
+ if (!this.interactive) {
29
+ throw approvalRequired(request.summary);
30
+ }
31
+ return this.policy.decide(request);
32
+ }
33
+ /** Clear all session grants (a fresh session). */
34
+ resetSession() {
35
+ this.allowlist.clear();
36
+ }
37
+ }
@@ -0,0 +1,64 @@
1
+ import type { ApproveAction } from "../tools/types.js";
2
+ /**
3
+ * Types for the approval gate (U.3) — the security boundary between the agent
4
+ * and side-effecting actions. The gate is **default-deny**: no response means
5
+ * reject, never auto-approve.
6
+ */
7
+ /**
8
+ * Risk classification for a pending action. Friction is reserved for what's
9
+ * risky, so users don't rubber-stamp.
10
+ * - `read` — read-only, auto-allowed (read tools bypass the gate entirely).
11
+ * - `mutate` — reversible-ish mutation (file write/edit) → approve, normal style.
12
+ * - `destructive` — irreversible / high blast radius (shell, delete) → approve,
13
+ * visually distinct. **Unknown actions classify here.**
14
+ */
15
+ export type RiskTier = "read" | "mutate" | "destructive";
16
+ /**
17
+ * The tight scope a session grant is keyed by. Never blanket.
18
+ * - `shell-prefix` — a command's leading program token (e.g. `git`); only ever
19
+ * matches commands we can *positively* prove are simple (no shell features).
20
+ * - `file-subtree` — an absolute directory (or, under the root-cap, an exact
21
+ * file path); matches targets that resolve inside it.
22
+ * - `none` — nothing safe to grant (e.g. a multi-file patch spanning the root).
23
+ */
24
+ export type Scope = {
25
+ readonly kind: "shell-prefix";
26
+ readonly token: string;
27
+ } | {
28
+ readonly kind: "file-subtree";
29
+ readonly root: string;
30
+ } | {
31
+ readonly kind: "none";
32
+ };
33
+ /** A pending action, classified and ready to show / decide on. */
34
+ export interface ApprovalRequest {
35
+ /** The raw tool action (kind, path/command, preview). */
36
+ readonly action: ApproveAction;
37
+ /** Risk classification. */
38
+ readonly tier: RiskTier;
39
+ /** The tight scope a session grant would use. */
40
+ readonly scope: Scope;
41
+ /** One plain line describing the action (e.g. "run: git status"). */
42
+ readonly summary: string;
43
+ /** Absolute resolved target paths for file actions; `[]` for shell. */
44
+ readonly targets: readonly string[];
45
+ /** Resolved project root the action runs in (shown as the shell cwd). */
46
+ readonly cwd: string;
47
+ }
48
+ /**
49
+ * The gate's verdict. On reject, `feedback` (if any) is fed back to the agent as
50
+ * the tool result so it can adapt — a rejection is data, not an error.
51
+ */
52
+ export type ApprovalDecision = {
53
+ readonly allow: true;
54
+ } | {
55
+ readonly allow: false;
56
+ readonly feedback?: string;
57
+ };
58
+ /**
59
+ * Pluggable decision strategy — the seam. Only `InteractivePolicy` ships in U.3;
60
+ * a future non-interactive/CI policy slots in here without touching call sites.
61
+ */
62
+ export interface ApprovalPolicy {
63
+ decide(request: ApprovalRequest): Promise<ApprovalDecision>;
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import pc from "picocolors";
3
+ import { configKeyUnknown } from "../../errors/index.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
5
6
  export function configCommand() {
@@ -19,9 +20,7 @@ export function configCommand() {
19
20
  const { config } = loadConfig();
20
21
  const value = getPath(config, key);
21
22
  if (value === undefined) {
22
- logger.error(`no such config key: ${pc.bold(key)}`);
23
- process.exitCode = 1;
24
- return;
23
+ throw configKeyUnknown(key);
25
24
  }
26
25
  logger.print(typeof value === "object"
27
26
  ? JSON.stringify(value, null, 2)
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { loadConfig } from "../../config/index.js";
5
+ import { CruxyError, indexFailed } from "../../errors/index.js";
5
6
  import { getIndexService, indexDbPath, resetIndexServices, } from "../../indexing/index.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  /**
@@ -41,8 +42,10 @@ export function indexCommand() {
41
42
  pc.dim(`(${stats.filesSkipped} unchanged, ${stats.filesPurged} purged, ${stats.durationMs}ms)`));
42
43
  }
43
44
  catch (err) {
44
- logger.error(`indexing failed: ${err.message}`);
45
- process.exitCode = 1;
45
+ // Typed index failures (embedder/store unavailable) already carry a
46
+ // code; anything else becomes CRUXY_E_INDEX_FAILED. The boundary formats
47
+ // and sets the exit code.
48
+ throw err instanceof CruxyError ? err : indexFailed(err);
46
49
  }
47
50
  finally {
48
51
  await resetIndexServices();
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
4
+ * branch name, conventional commit, and PR title/body from the diff with the
5
+ * model, then runs branch → commit → push → open-PR behind the one U.3 approval.
6
+ * The forge token comes from the environment / `gh`; it's never prompted or stored.
7
+ */
8
+ export declare function prCommand(): Command;
@@ -0,0 +1,87 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { createProvider } from "@cruxy/sdk";
4
+ import { logger } from "../../utils/logger.js";
5
+ import { loadConfig, resolveApiKey } from "../../config/index.js";
6
+ import { authMissingKey } from "../../errors/index.js";
7
+ import { ApprovalService } from "../../approval/index.js";
8
+ import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
9
+ /**
10
+ * `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
11
+ * branch name, conventional commit, and PR title/body from the diff with the
12
+ * model, then runs branch → commit → push → open-PR behind the one U.3 approval.
13
+ * The forge token comes from the environment / `gh`; it's never prompted or stored.
14
+ */
15
+ export function prCommand() {
16
+ return new Command("pr")
17
+ .description("open a pull request from the current changes (generated + gated)")
18
+ .option("-b, --base <branch>", "base branch to merge into")
19
+ .option("-t, --title <title>", "PR title (conventional-commit subject)")
20
+ .option("--body <body>", "PR body")
21
+ .option("--draft", "open the pull request as a draft")
22
+ .action(async (opts) => {
23
+ const cwd = process.cwd();
24
+ const { config } = loadConfig();
25
+ // The model writes the PR content, so we need a provider key just like
26
+ // `cruxy run`. The forge token is resolved separately (env / gh).
27
+ const apiKey = resolveApiKey(config.model.provider);
28
+ if (!apiKey) {
29
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
30
+ }
31
+ const provider = createProvider({
32
+ provider: config.model.provider,
33
+ apiKey,
34
+ model: config.model.model,
35
+ maxTokens: config.model.maxTokens,
36
+ temperature: config.model.temperature,
37
+ gatewayUrl: config.cruxy.gatewayUrl,
38
+ });
39
+ // resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
40
+ const forge = createForgeProvider(resolveForgeToken());
41
+ const guidance = await loadCommitGuidance(cwd);
42
+ const approval = new ApprovalService({
43
+ cwd,
44
+ interactive: Boolean(process.stdin.isTTY),
45
+ });
46
+ const service = createPrService({
47
+ cwd,
48
+ config,
49
+ forge,
50
+ requestApproval: (action) => approval.requestApproval(action),
51
+ generate: (i) => generateWithLlm(provider, {
52
+ ...i,
53
+ scopes: guidance.scopes,
54
+ skillBody: guidance.skillBody,
55
+ }),
56
+ });
57
+ logger.info(pc.dim("generating pull request content…"));
58
+ const outcome = await service.openPullRequest({
59
+ base: opts.base,
60
+ title: opts.title,
61
+ body: opts.body,
62
+ draft: opts.draft,
63
+ });
64
+ if (!outcome.approved) {
65
+ logger.print(pc.yellow("pull request not opened — approval declined."));
66
+ if (outcome.feedback)
67
+ logger.print(pc.dim(outcome.feedback));
68
+ return;
69
+ }
70
+ const label = outcome.alreadyExists
71
+ ? "a pull request already exists"
72
+ : "opened pull request";
73
+ logger.print(`${pc.green("✓")} ${label} (${outcome.branch} → ${outcome.base})`);
74
+ logger.print(pc.cyan(outcome.url));
75
+ });
76
+ }
77
+ /** Environment variable that holds the API key for a provider. */
78
+ function apiKeyEnvVar(provider) {
79
+ switch (provider) {
80
+ case "anthropic":
81
+ return "ANTHROPIC_API_KEY";
82
+ case "openai":
83
+ return "OPENAI_API_KEY";
84
+ default:
85
+ return "CRUXY_API_KEY";
86
+ }
87
+ }
@@ -3,8 +3,10 @@ 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";
7
+ import { ApprovalService } from "../../approval/index.js";
6
8
  import { buildDefaultRegistry } from "../../tools/index.js";
7
- import { Session, createApprover } from "../../agent/index.js";
9
+ import { Session } from "../../agent/index.js";
8
10
  import { runInteractive } from "../repl.js";
9
11
  import { createStreamPrinter } from "../stream-print.js";
10
12
  import { getGitInfo } from "../../utils/git.js";
@@ -12,24 +14,20 @@ export function runCommand() {
12
14
  return new Command("run")
13
15
  .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
14
16
  .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
15
- .option("-y, --yes", "auto-approve all tool actions (run unattended)")
16
- .option("--dangerously-approve", "alias for --yes: approve every action without prompting")
17
- .action(async (promptParts, opts) => {
17
+ .action(async (promptParts) => {
18
18
  const prompt = promptParts.join(" ").trim();
19
19
  const interactive = prompt === "";
20
20
  // No prompt and stdin isn't a terminal: there's no way to read input and
21
21
  // nothing to do — fail fast instead of hanging on a line that never comes.
22
22
  if (interactive && !process.stdin.isTTY) {
23
- logger.error('cruxy run needs a prompt when stdin is not a terminal try: cruxy run "<task>"');
24
- return;
23
+ 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
24
  }
26
25
  const { config, sources } = loadConfig();
27
26
  const apiKey = resolveApiKey(config.model.provider);
28
27
  logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
29
28
  logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
30
29
  if (!apiKey) {
31
- logger.warn(`no API key for provider ${pc.bold(config.model.provider)} — set it in the environment before running.`);
32
- return;
30
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
33
31
  }
34
32
  const provider = createProvider({
35
33
  provider: config.model.provider,
@@ -40,18 +38,19 @@ export function runCommand() {
40
38
  gatewayUrl: config.cruxy.gatewayUrl,
41
39
  });
42
40
  const registry = buildDefaultRegistry();
43
- const approver = createApprover({
44
- mode: config.approval.mode,
41
+ // The approval gate. Default-deny, risk-tiered, scoped session allowlist;
42
+ // no global skip flag (the policy seam lives in src/approval). When stdin
43
+ // isn't a TTY, a side-effecting action fails with CRUXY_E_APPROVAL_REQUIRED
44
+ // rather than being auto-approved.
45
+ const approval = new ApprovalService({
45
46
  cwd: process.cwd(),
46
- isInteractive: Boolean(process.stdin.isTTY),
47
- autoApprove: Boolean(opts.yes || opts.dangerouslyApprove),
48
- logger,
47
+ interactive: Boolean(process.stdin.isTTY),
49
48
  });
50
49
  const ctx = {
51
50
  cwd: process.cwd(),
52
51
  config,
53
52
  logger,
54
- approve: (action) => approver.approve(action),
53
+ requestApproval: (action) => approval.requestApproval(action),
55
54
  };
56
55
  const projectInstructions = loadProjectInstructions(process.cwd());
57
56
  const git = getGitInfo(process.cwd());
@@ -72,14 +71,23 @@ export function runCommand() {
72
71
  // through a printer that trims the model's leading blank lines; the agent
73
72
  // loop terminates the line.
74
73
  logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
75
- try {
76
- const print = createStreamPrinter((text) => process.stdout.write(text));
77
- const result = await session.send(prompt, print);
78
- logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
79
- `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
80
- }
81
- catch (err) {
82
- logger.error(err.message);
83
- }
74
+ // Provider/network/auth failures propagate to the top-level boundary,
75
+ // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
76
+ // the matching code — a one-shot run must fail non-zero on error.
77
+ const print = createStreamPrinter((text) => process.stdout.write(text));
78
+ const result = await session.send(prompt, print);
79
+ logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
80
+ `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
84
81
  });
85
82
  }
83
+ /** Environment variable that holds the API key for a provider. */
84
+ function apiKeyEnvVar(provider) {
85
+ switch (provider) {
86
+ case "anthropic":
87
+ return "ANTHROPIC_API_KEY";
88
+ case "openai":
89
+ return "OPENAI_API_KEY";
90
+ default:
91
+ return "CRUXY_API_KEY";
92
+ }
93
+ }
@@ -2,10 +2,12 @@ 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";
8
9
  import { skillsCommand } from "./commands/skills.js";
10
+ import { prCommand } from "./commands/pr.js";
9
11
  export function buildProgram() {
10
12
  const program = new Command();
11
13
  program
@@ -14,8 +16,7 @@ export function buildProgram() {
14
16
  .version(APP_VERSION, "-v, --version", "print the cruxy version")
15
17
  .option("-c, --config <path>", "use a specific config file")
16
18
  .option("--log-level <level>", "debug | info | warn | error | silent")
17
- .option("--verbose", "shorthand for --log-level debug")
18
- .showHelpAfterError("(run `cruxy --help` for usage)");
19
+ .option("--verbose", "shorthand for --log-level debug");
19
20
  // Apply global options as early as possible.
20
21
  program.hook("preAction", (thisCommand) => {
21
22
  const opts = thisCommand.opts();
@@ -28,13 +29,34 @@ export function buildProgram() {
28
29
  program.addCommand(configCommand());
29
30
  program.addCommand(indexCommand());
30
31
  program.addCommand(skillsCommand());
31
- // Default action: no subcommand -> interactive entrypoint (stub for now).
32
- program.action(() => {
32
+ program.addCommand(prCommand());
33
+ // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
34
+ // means an unknown command (Commander runs the default action with it as an
35
+ // operand rather than erroring), so reject it as a usage error.
36
+ program.action((_opts, command) => {
37
+ if (command.args.length > 0) {
38
+ throw usageError(`unknown command: ${command.args[0]}`, [
39
+ "run `cruxy --help` to see available commands",
40
+ ]);
41
+ }
33
42
  logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
34
43
  logger.print(pc.dim("an agentic coding CLI\n"));
35
44
  logger.print("The interactive REPL lands in C.3 (terminal UI).");
36
45
  logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
37
46
  logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
38
47
  });
48
+ // Throw CommanderError instead of calling process.exit, and suppress
49
+ // Commander's own "error:" line — so parse errors (unknown command/option,
50
+ // missing argument) at *any* level route through the single error boundary as
51
+ // CRUXY_E_USAGE, sharing one exit code (2) and format. Applied recursively
52
+ // because these settings are not inherited by subcommands.
53
+ routeErrorsToBoundary(program);
39
54
  return program;
40
55
  }
56
+ /** Recursively make a command (and its subcommands) throw on error, silently. */
57
+ function routeErrorsToBoundary(command) {
58
+ command.exitOverride();
59
+ command.configureOutput({ outputError: () => { } });
60
+ for (const sub of command.commands)
61
+ routeErrorsToBoundary(sub);
62
+ }