@cruxy/cli 0.4.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 (58) hide show
  1. package/README.md +37 -13
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.js +0 -1
  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/pr.d.ts +8 -0
  20. package/dist/cli/commands/pr.js +87 -0
  21. package/dist/cli/commands/run.js +10 -10
  22. package/dist/cli/program.js +2 -0
  23. package/dist/cli/repl.js +1 -1
  24. package/dist/config/schema.d.ts +38 -9
  25. package/dist/config/schema.js +13 -4
  26. package/dist/errors/constructors.d.ts +25 -0
  27. package/dist/errors/constructors.js +83 -0
  28. package/dist/errors/types.d.ts +5 -0
  29. package/dist/errors/types.js +11 -0
  30. package/dist/tools/create-pull-request.d.ts +24 -0
  31. package/dist/tools/create-pull-request.js +83 -0
  32. package/dist/tools/file/apply-patch.js +3 -3
  33. package/dist/tools/file/edit-file.js +6 -3
  34. package/dist/tools/file/write-file.js +6 -3
  35. package/dist/tools/index.d.ts +1 -0
  36. package/dist/tools/index.js +1 -0
  37. package/dist/tools/registry.js +2 -0
  38. package/dist/tools/shell/run-command.js +11 -3
  39. package/dist/tools/types.d.ts +25 -6
  40. package/dist/vcs/auth.d.ts +22 -0
  41. package/dist/vcs/auth.js +29 -0
  42. package/dist/vcs/generate.d.ts +72 -0
  43. package/dist/vcs/generate.js +265 -0
  44. package/dist/vcs/git.d.ts +52 -0
  45. package/dist/vcs/git.js +152 -0
  46. package/dist/vcs/github.d.ts +44 -0
  47. package/dist/vcs/github.js +145 -0
  48. package/dist/vcs/guidance.d.ts +20 -0
  49. package/dist/vcs/guidance.js +76 -0
  50. package/dist/vcs/index.d.ts +7 -0
  51. package/dist/vcs/index.js +7 -0
  52. package/dist/vcs/service.d.ts +53 -0
  53. package/dist/vcs/service.js +79 -0
  54. package/dist/vcs/types.d.ts +57 -0
  55. package/dist/vcs/types.js +6 -0
  56. package/package.json +1 -1
  57. package/dist/agent/approval.d.ts +0 -41
  58. 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 {};
@@ -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
+ }
@@ -4,8 +4,9 @@ import { createProvider } from "@cruxy/sdk";
4
4
  import { logger } from "../../utils/logger.js";
5
5
  import { loadConfig, resolveApiKey, loadProjectInstructions, } from "../../config/index.js";
6
6
  import { authMissingKey, usageError } from "../../errors/index.js";
7
+ import { ApprovalService } from "../../approval/index.js";
7
8
  import { buildDefaultRegistry } from "../../tools/index.js";
8
- import { Session, createApprover } from "../../agent/index.js";
9
+ import { Session } from "../../agent/index.js";
9
10
  import { runInteractive } from "../repl.js";
10
11
  import { createStreamPrinter } from "../stream-print.js";
11
12
  import { getGitInfo } from "../../utils/git.js";
@@ -13,9 +14,7 @@ export function runCommand() {
13
14
  return new Command("run")
14
15
  .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
15
16
  .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
16
- .option("-y, --yes", "auto-approve all tool actions (run unattended)")
17
- .option("--dangerously-approve", "alias for --yes: approve every action without prompting")
18
- .action(async (promptParts, opts) => {
17
+ .action(async (promptParts) => {
19
18
  const prompt = promptParts.join(" ").trim();
20
19
  const interactive = prompt === "";
21
20
  // No prompt and stdin isn't a terminal: there's no way to read input and
@@ -39,18 +38,19 @@ export function runCommand() {
39
38
  gatewayUrl: config.cruxy.gatewayUrl,
40
39
  });
41
40
  const registry = buildDefaultRegistry();
42
- const approver = createApprover({
43
- 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({
44
46
  cwd: process.cwd(),
45
- isInteractive: Boolean(process.stdin.isTTY),
46
- autoApprove: Boolean(opts.yes || opts.dangerouslyApprove),
47
- logger,
47
+ interactive: Boolean(process.stdin.isTTY),
48
48
  });
49
49
  const ctx = {
50
50
  cwd: process.cwd(),
51
51
  config,
52
52
  logger,
53
- approve: (action) => approver.approve(action),
53
+ requestApproval: (action) => approval.requestApproval(action),
54
54
  };
55
55
  const projectInstructions = loadProjectInstructions(process.cwd());
56
56
  const git = getGitInfo(process.cwd());
@@ -7,6 +7,7 @@ import { runCommand } from "./commands/run.js";
7
7
  import { configCommand } from "./commands/config.js";
8
8
  import { indexCommand } from "./commands/index.js";
9
9
  import { skillsCommand } from "./commands/skills.js";
10
+ import { prCommand } from "./commands/pr.js";
10
11
  export function buildProgram() {
11
12
  const program = new Command();
12
13
  program
@@ -28,6 +29,7 @@ export function buildProgram() {
28
29
  program.addCommand(configCommand());
29
30
  program.addCommand(indexCommand());
30
31
  program.addCommand(skillsCommand());
32
+ program.addCommand(prCommand());
31
33
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
32
34
  // means an unknown command (Commander runs the default action with it as an
33
35
  // operand rather than erroring), so reject it as a usage error.
package/dist/cli/repl.js CHANGED
@@ -18,7 +18,7 @@ const defaultIO = () => ({
18
18
  /**
19
19
  * Read one line using a readline interface that is created and then **closed
20
20
  * before this resolves**. This is the crux of the stdin coordination: while a
21
- * turn runs (`session.send`), approvals grab stdin in raw mode via the Approver
21
+ * turn runs (`session.send`), approvals grab stdin in raw mode via the approval prompt
22
22
  * — so no readline interface may be live at that moment. Creating a fresh
23
23
  * interface per line, and closing it the instant we have input, guarantees the
24
24
  * two never contend.
@@ -52,10 +52,20 @@ export declare const ToolsConfigSchema: z.ZodObject<{
52
52
  }>;
53
53
  export declare const GitConfigSchema: z.ZodObject<{
54
54
  autoCommit: z.ZodDefault<z.ZodBoolean>;
55
+ /** Branches cruxy never commits/pushes to directly (PR flow branches off
56
+ * first). `main` and `master` are always protected; these add to them. */
57
+ protectedBranches: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
58
+ /** Default base branch for generated pull requests; falls back to the
59
+ * repo's default branch, then `main`, when unset. */
60
+ defaultBase: z.ZodOptional<z.ZodString>;
55
61
  }, "strict", z.ZodTypeAny, {
56
62
  autoCommit: boolean;
63
+ protectedBranches: string[];
64
+ defaultBase?: string | undefined;
57
65
  }, {
58
66
  autoCommit?: boolean | undefined;
67
+ protectedBranches?: string[] | undefined;
68
+ defaultBase?: string | undefined;
59
69
  }>;
60
70
  /** Execution bounds for the `run_command` shell tool (distinct from the
61
71
  * `tools.shell` enable flag above). */
@@ -89,13 +99,18 @@ export declare const ContextConfigSchema: z.ZodObject<{
89
99
  compactThreshold?: number | undefined;
90
100
  keepRecentMessages?: number | undefined;
91
101
  }>;
92
- /** How tool-action approval is resolved. */
102
+ /**
103
+ * How tool-action approval is resolved. Only `prompt` exists: ask interactively
104
+ * and **deny by default** when non-interactive. There is deliberately no
105
+ * auto-approve / skip mode — the policy seam for a future CI mode lives in
106
+ * `src/approval` (see U.3), not behind a footgun flag.
107
+ */
93
108
  export declare const ApprovalConfigSchema: z.ZodObject<{
94
- mode: z.ZodDefault<z.ZodEnum<["prompt", "auto"]>>;
109
+ mode: z.ZodDefault<z.ZodEnum<["prompt"]>>;
95
110
  }, "strict", z.ZodTypeAny, {
96
- mode: "auto" | "prompt";
111
+ mode: "prompt";
97
112
  }, {
98
- mode?: "auto" | "prompt" | undefined;
113
+ mode?: "prompt" | undefined;
99
114
  }>;
100
115
  /**
101
116
  * Codebase semantic index (C.17): the local embedding index that backs the
@@ -249,10 +264,20 @@ export declare const CruxyConfigSchema: z.ZodObject<{
249
264
  }>>;
250
265
  git: z.ZodDefault<z.ZodObject<{
251
266
  autoCommit: z.ZodDefault<z.ZodBoolean>;
267
+ /** Branches cruxy never commits/pushes to directly (PR flow branches off
268
+ * first). `main` and `master` are always protected; these add to them. */
269
+ protectedBranches: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
270
+ /** Default base branch for generated pull requests; falls back to the
271
+ * repo's default branch, then `main`, when unset. */
272
+ defaultBase: z.ZodOptional<z.ZodString>;
252
273
  }, "strict", z.ZodTypeAny, {
253
274
  autoCommit: boolean;
275
+ protectedBranches: string[];
276
+ defaultBase?: string | undefined;
254
277
  }, {
255
278
  autoCommit?: boolean | undefined;
279
+ protectedBranches?: string[] | undefined;
280
+ defaultBase?: string | undefined;
256
281
  }>>;
257
282
  shell: z.ZodDefault<z.ZodObject<{
258
283
  /** Kill the command (and its process tree) after this many ms. */
@@ -284,11 +309,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
284
309
  keepRecentMessages?: number | undefined;
285
310
  }>>;
286
311
  approval: z.ZodDefault<z.ZodObject<{
287
- mode: z.ZodDefault<z.ZodEnum<["prompt", "auto"]>>;
312
+ mode: z.ZodDefault<z.ZodEnum<["prompt"]>>;
288
313
  }, "strict", z.ZodTypeAny, {
289
- mode: "auto" | "prompt";
314
+ mode: "prompt";
290
315
  }, {
291
- mode?: "auto" | "prompt" | undefined;
316
+ mode?: "prompt" | undefined;
292
317
  }>>;
293
318
  index: z.ZodDefault<z.ZodObject<{
294
319
  /** Master switch for `search_codebase` and `cruxy index`. */
@@ -411,6 +436,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
411
436
  };
412
437
  git: {
413
438
  autoCommit: boolean;
439
+ protectedBranches: string[];
440
+ defaultBase?: string | undefined;
414
441
  };
415
442
  context: {
416
443
  maxTokens: number;
@@ -418,7 +445,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
418
445
  keepRecentMessages: number;
419
446
  };
420
447
  approval: {
421
- mode: "auto" | "prompt";
448
+ mode: "prompt";
422
449
  };
423
450
  index: {
424
451
  search: {
@@ -466,6 +493,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
466
493
  } | undefined;
467
494
  git?: {
468
495
  autoCommit?: boolean | undefined;
496
+ protectedBranches?: string[] | undefined;
497
+ defaultBase?: string | undefined;
469
498
  } | undefined;
470
499
  context?: {
471
500
  maxTokens?: number | undefined;
@@ -473,7 +502,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
473
502
  keepRecentMessages?: number | undefined;
474
503
  } | undefined;
475
504
  approval?: {
476
- mode?: "auto" | "prompt" | undefined;
505
+ mode?: "prompt" | undefined;
477
506
  } | undefined;
478
507
  index?: {
479
508
  search?: {
@@ -40,6 +40,12 @@ export const ToolsConfigSchema = z
40
40
  export const GitConfigSchema = z
41
41
  .object({
42
42
  autoCommit: z.boolean().default(false),
43
+ /** Branches cruxy never commits/pushes to directly (PR flow branches off
44
+ * first). `main` and `master` are always protected; these add to them. */
45
+ protectedBranches: z.array(z.string()).default([]),
46
+ /** Default base branch for generated pull requests; falls back to the
47
+ * repo's default branch, then `main`, when unset. */
48
+ defaultBase: z.string().optional(),
43
49
  })
44
50
  .strict();
45
51
  /** Execution bounds for the `run_command` shell tool (distinct from the
@@ -64,12 +70,15 @@ export const ContextConfigSchema = z
64
70
  keepRecentMessages: z.number().int().positive().default(6),
65
71
  })
66
72
  .strict();
67
- /** How tool-action approval is resolved. */
73
+ /**
74
+ * How tool-action approval is resolved. Only `prompt` exists: ask interactively
75
+ * and **deny by default** when non-interactive. There is deliberately no
76
+ * auto-approve / skip mode — the policy seam for a future CI mode lives in
77
+ * `src/approval` (see U.3), not behind a footgun flag.
78
+ */
68
79
  export const ApprovalConfigSchema = z
69
80
  .object({
70
- // "prompt": ask interactively (deny when non-interactive). "auto": approve
71
- // every action unattended (CI / explicit opt-in).
72
- mode: z.enum(["prompt", "auto"]).default("prompt"),
81
+ mode: z.enum(["prompt"]).default("prompt"),
73
82
  })
74
83
  .strict();
75
84
  /**