@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.
- package/README.md +37 -13
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.js +0 -1
- package/dist/agent/prompts.d.ts +0 -2
- package/dist/agent/prompts.js +3 -3
- package/dist/approval/classify.d.ts +18 -0
- package/dist/approval/classify.js +162 -0
- package/dist/approval/index.d.ts +5 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/policy.d.ts +37 -0
- package/dist/approval/policy.js +81 -0
- package/dist/approval/prompt.d.ts +33 -0
- package/dist/approval/prompt.js +212 -0
- package/dist/approval/service.d.ts +36 -0
- package/dist/approval/service.js +37 -0
- package/dist/approval/types.d.ts +64 -0
- package/dist/approval/types.js +1 -0
- package/dist/cli/commands/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +10 -10
- package/dist/cli/program.js +2 -0
- package/dist/cli/repl.js +1 -1
- package/dist/config/schema.d.ts +38 -9
- package/dist/config/schema.js +13 -4
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +83 -0
- package/dist/errors/types.d.ts +5 -0
- package/dist/errors/types.js +11 -0
- package/dist/tools/create-pull-request.d.ts +24 -0
- package/dist/tools/create-pull-request.js +83 -0
- package/dist/tools/file/apply-patch.js +3 -3
- package/dist/tools/file/edit-file.js +6 -3
- package/dist/tools/file/write-file.js +6 -3
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/shell/run-command.js +11 -3
- package/dist/tools/types.d.ts +25 -6
- package/dist/vcs/auth.d.ts +22 -0
- package/dist/vcs/auth.js +29 -0
- package/dist/vcs/generate.d.ts +72 -0
- package/dist/vcs/generate.js +265 -0
- package/dist/vcs/git.d.ts +52 -0
- package/dist/vcs/git.js +152 -0
- package/dist/vcs/github.d.ts +44 -0
- package/dist/vcs/github.js +145 -0
- package/dist/vcs/guidance.d.ts +20 -0
- package/dist/vcs/guidance.js +76 -0
- package/dist/vcs/index.d.ts +7 -0
- package/dist/vcs/index.js +7 -0
- package/dist/vcs/service.d.ts +53 -0
- package/dist/vcs/service.js +79 -0
- package/dist/vcs/types.d.ts +57 -0
- package/dist/vcs/types.js +6 -0
- package/package.json +1 -1
- package/dist/agent/approval.d.ts +0 -41
- package/dist/agent/approval.js +0 -179
|
@@ -0,0 +1,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
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
-
.
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
54
54
|
};
|
|
55
55
|
const projectInstructions = loadProjectInstructions(process.cwd());
|
|
56
56
|
const git = getGitInfo(process.cwd());
|
package/dist/cli/program.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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"
|
|
109
|
+
mode: z.ZodDefault<z.ZodEnum<["prompt"]>>;
|
|
95
110
|
}, "strict", z.ZodTypeAny, {
|
|
96
|
-
mode: "
|
|
111
|
+
mode: "prompt";
|
|
97
112
|
}, {
|
|
98
|
-
mode?: "
|
|
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"
|
|
312
|
+
mode: z.ZodDefault<z.ZodEnum<["prompt"]>>;
|
|
288
313
|
}, "strict", z.ZodTypeAny, {
|
|
289
|
-
mode: "
|
|
314
|
+
mode: "prompt";
|
|
290
315
|
}, {
|
|
291
|
-
mode?: "
|
|
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: "
|
|
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?: "
|
|
505
|
+
mode?: "prompt" | undefined;
|
|
477
506
|
} | undefined;
|
|
478
507
|
index?: {
|
|
479
508
|
search?: {
|
package/dist/config/schema.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
/**
|