@cruxy/cli 0.4.0 → 0.7.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 (105) hide show
  1. package/README.md +57 -16
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.d.ts +2 -0
  5. package/dist/agent/loop.js +1 -1
  6. package/dist/agent/prompts.d.ts +2 -2
  7. package/dist/agent/prompts.js +9 -3
  8. package/dist/agent/session.d.ts +24 -0
  9. package/dist/agent/session.js +33 -7
  10. package/dist/approval/classify.d.ts +18 -0
  11. package/dist/approval/classify.js +162 -0
  12. package/dist/approval/index.d.ts +5 -0
  13. package/dist/approval/index.js +5 -0
  14. package/dist/approval/policy.d.ts +37 -0
  15. package/dist/approval/policy.js +81 -0
  16. package/dist/approval/prompt.d.ts +33 -0
  17. package/dist/approval/prompt.js +212 -0
  18. package/dist/approval/service.d.ts +36 -0
  19. package/dist/approval/service.js +37 -0
  20. package/dist/approval/types.d.ts +64 -0
  21. package/dist/approval/types.js +1 -0
  22. package/dist/cli/commands/init.d.ts +7 -0
  23. package/dist/cli/commands/init.js +40 -0
  24. package/dist/cli/commands/login.d.ts +8 -0
  25. package/dist/cli/commands/login.js +36 -0
  26. package/dist/cli/commands/pr.d.ts +8 -0
  27. package/dist/cli/commands/pr.js +87 -0
  28. package/dist/cli/commands/run.js +30 -52
  29. package/dist/cli/onboard.d.ts +25 -0
  30. package/dist/cli/onboard.js +54 -0
  31. package/dist/cli/program.js +21 -1
  32. package/dist/cli/repl.js +10 -1
  33. package/dist/cli/session-factory.d.ts +12 -0
  34. package/dist/cli/session-factory.js +88 -0
  35. package/dist/config/credentials.d.ts +10 -0
  36. package/dist/config/credentials.js +69 -0
  37. package/dist/config/index.d.ts +1 -0
  38. package/dist/config/index.js +1 -0
  39. package/dist/config/manager.d.ts +6 -1
  40. package/dist/config/manager.js +11 -1
  41. package/dist/config/schema.d.ts +48 -9
  42. package/dist/config/schema.js +15 -4
  43. package/dist/constants.d.ts +6 -0
  44. package/dist/constants.js +6 -0
  45. package/dist/errors/constructors.d.ts +35 -0
  46. package/dist/errors/constructors.js +129 -2
  47. package/dist/errors/types.d.ts +8 -0
  48. package/dist/errors/types.js +17 -0
  49. package/dist/onboarding/detect.d.ts +26 -0
  50. package/dist/onboarding/detect.js +56 -0
  51. package/dist/onboarding/flow.d.ts +28 -0
  52. package/dist/onboarding/flow.js +100 -0
  53. package/dist/onboarding/index.d.ts +5 -0
  54. package/dist/onboarding/index.js +5 -0
  55. package/dist/onboarding/io.d.ts +8 -0
  56. package/dist/onboarding/io.js +133 -0
  57. package/dist/onboarding/steps.d.ts +17 -0
  58. package/dist/onboarding/steps.js +100 -0
  59. package/dist/onboarding/types.d.ts +81 -0
  60. package/dist/onboarding/types.js +6 -0
  61. package/dist/plan/approve.d.ts +16 -0
  62. package/dist/plan/approve.js +46 -0
  63. package/dist/plan/execute.d.ts +20 -0
  64. package/dist/plan/execute.js +31 -0
  65. package/dist/plan/index.d.ts +7 -0
  66. package/dist/plan/index.js +7 -0
  67. package/dist/plan/policy.d.ts +26 -0
  68. package/dist/plan/policy.js +45 -0
  69. package/dist/plan/render.d.ts +5 -0
  70. package/dist/plan/render.js +47 -0
  71. package/dist/plan/service.d.ts +39 -0
  72. package/dist/plan/service.js +118 -0
  73. package/dist/plan/submit-plan.d.ts +33 -0
  74. package/dist/plan/submit-plan.js +57 -0
  75. package/dist/plan/types.d.ts +60 -0
  76. package/dist/plan/types.js +6 -0
  77. package/dist/tools/create-pull-request.d.ts +24 -0
  78. package/dist/tools/create-pull-request.js +83 -0
  79. package/dist/tools/file/apply-patch.js +3 -3
  80. package/dist/tools/file/edit-file.js +6 -3
  81. package/dist/tools/file/write-file.js +6 -3
  82. package/dist/tools/index.d.ts +1 -0
  83. package/dist/tools/index.js +1 -0
  84. package/dist/tools/registry.js +2 -0
  85. package/dist/tools/shell/run-command.js +11 -3
  86. package/dist/tools/types.d.ts +25 -6
  87. package/dist/vcs/auth.d.ts +22 -0
  88. package/dist/vcs/auth.js +29 -0
  89. package/dist/vcs/generate.d.ts +72 -0
  90. package/dist/vcs/generate.js +265 -0
  91. package/dist/vcs/git.d.ts +52 -0
  92. package/dist/vcs/git.js +152 -0
  93. package/dist/vcs/github.d.ts +44 -0
  94. package/dist/vcs/github.js +145 -0
  95. package/dist/vcs/guidance.d.ts +20 -0
  96. package/dist/vcs/guidance.js +76 -0
  97. package/dist/vcs/index.d.ts +7 -0
  98. package/dist/vcs/index.js +7 -0
  99. package/dist/vcs/service.d.ts +53 -0
  100. package/dist/vcs/service.js +79 -0
  101. package/dist/vcs/types.d.ts +57 -0
  102. package/dist/vcs/types.js +6 -0
  103. package/package.json +1 -1
  104. package/dist/agent/approval.d.ts +0 -41
  105. 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,7 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy init` — set up a project (U.6): ensure a key (acquired only if missing),
4
+ * offer to scaffold a project `CRUXY.md`, and offer a first-win demo run. Like
5
+ * `login`, it's interactive-only.
6
+ */
7
+ export declare function initCommand(): Command;
@@ -0,0 +1,40 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { loadConfig } from "../../config/index.js";
5
+ import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
6
+ import { runFirstWinTask } from "../onboard.js";
7
+ /**
8
+ * `cruxy init` — set up a project (U.6): ensure a key (acquired only if missing),
9
+ * offer to scaffold a project `CRUXY.md`, and offer a first-win demo run. Like
10
+ * `login`, it's interactive-only.
11
+ */
12
+ export function initCommand() {
13
+ return new Command("init")
14
+ .description("set up cruxy in this project (key + CRUXY.md + a first run)")
15
+ .action(async () => {
16
+ if (!process.stdin.isTTY) {
17
+ logger.print(pc.dim("cruxy init is interactive — run it in a terminal, or export your key as an environment variable."));
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const cwd = process.cwd();
22
+ const { config } = loadConfig();
23
+ const result = await runOnboarding({
24
+ provider: config.model.provider,
25
+ mode: "init",
26
+ offerScaffold: true,
27
+ offerFirstWin: true,
28
+ io: defaultOnboardingIO(),
29
+ deps: createDefaultDeps({
30
+ config,
31
+ cwd,
32
+ runTask: (prompt) => runFirstWinTask(config, cwd, prompt),
33
+ }),
34
+ });
35
+ if (!result.completed) {
36
+ logger.print(pc.dim("setup not completed — run `cruxy init` again to resume."));
37
+ process.exitCode = 1;
38
+ }
39
+ });
40
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy login` — set or replace the API key on demand (U.6). Runs the key step
4
+ * of onboarding (always, even if a key already resolves — this is how you
5
+ * re-key / switch), validates it live, and persists it to the credentials store.
6
+ * Non-interactive invocations fail loud rather than hang.
7
+ */
8
+ export declare function loginCommand(): Command;
@@ -0,0 +1,36 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { loadConfig } from "../../config/index.js";
5
+ import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
6
+ /**
7
+ * `cruxy login` — set or replace the API key on demand (U.6). Runs the key step
8
+ * of onboarding (always, even if a key already resolves — this is how you
9
+ * re-key / switch), validates it live, and persists it to the credentials store.
10
+ * Non-interactive invocations fail loud rather than hang.
11
+ */
12
+ export function loginCommand() {
13
+ return new Command("login")
14
+ .description("set or replace your API key (validated, saved to ~/.cruxy)")
15
+ .action(async () => {
16
+ if (!process.stdin.isTTY) {
17
+ logger.print(pc.dim("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const { config } = loadConfig();
22
+ const result = await runOnboarding({
23
+ provider: config.model.provider,
24
+ mode: "login",
25
+ forceKey: true,
26
+ offerFirstWin: false,
27
+ offerScaffold: false,
28
+ io: defaultOnboardingIO(),
29
+ deps: createDefaultDeps({ config, cwd: process.cwd() }),
30
+ });
31
+ if (!result.completed) {
32
+ logger.print(pc.dim("login not completed."));
33
+ process.exitCode = 1;
34
+ }
35
+ });
36
+ }
@@ -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
+ }
@@ -1,20 +1,17 @@
1
1
  import { Command } from "commander";
2
2
  import pc from "picocolors";
3
- import { createProvider } from "@cruxy/sdk";
4
3
  import { logger } from "../../utils/logger.js";
5
- import { loadConfig, resolveApiKey, loadProjectInstructions, } from "../../config/index.js";
4
+ import { loadConfig, resolveApiKey } from "../../config/index.js";
6
5
  import { authMissingKey, usageError } from "../../errors/index.js";
7
- import { buildDefaultRegistry } from "../../tools/index.js";
8
- import { Session, createApprover } from "../../agent/index.js";
9
6
  import { runInteractive } from "../repl.js";
10
7
  import { createStreamPrinter } from "../stream-print.js";
11
- import { getGitInfo } from "../../utils/git.js";
8
+ import { buildAgentSession } from "../session-factory.js";
9
+ import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
12
10
  export function runCommand() {
13
11
  return new Command("run")
14
12
  .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
15
13
  .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")
14
+ .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
18
15
  .action(async (promptParts, opts) => {
19
16
  const prompt = promptParts.join(" ").trim();
20
17
  const interactive = prompt === "";
@@ -24,44 +21,36 @@ export function runCommand() {
24
21
  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
22
  }
26
23
  const { config, sources } = loadConfig();
27
- const apiKey = resolveApiKey(config.model.provider);
24
+ let apiKey = resolveApiKey(config.model.provider);
28
25
  logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
29
26
  logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
27
+ // First-run with no key (and a TTY) → guided onboarding instead of the
28
+ // dead-end auth error. The first-win demo is offered only in the no-prompt
29
+ // (REPL) path; with a real prompt, that prompt IS the first win.
30
30
  if (!apiKey) {
31
- throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
31
+ const onboarding = maybeRunOnboarding(config, {
32
+ ttyInteractive: Boolean(process.stdin.isTTY),
33
+ offerFirstWin: interactive,
34
+ cwd: process.cwd(),
35
+ });
36
+ if (onboarding === null) {
37
+ // Not a first run (non-TTY, or onboarded then key removed) → fail loud.
38
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
39
+ }
40
+ const result = await onboarding;
41
+ if (!result.completed) {
42
+ // Aborted/failed mid-setup — guidance already shown; exit cleanly.
43
+ logger.print(pc.dim("run `cruxy login` to finish setup."));
44
+ return;
45
+ }
46
+ apiKey = result.apiKey ?? resolveApiKey(config.model.provider);
47
+ if (!apiKey) {
48
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
49
+ }
32
50
  }
33
- const provider = createProvider({
34
- provider: config.model.provider,
35
- apiKey,
36
- model: config.model.model,
37
- maxTokens: config.model.maxTokens,
38
- temperature: config.model.temperature,
39
- gatewayUrl: config.cruxy.gatewayUrl,
40
- });
41
- const registry = buildDefaultRegistry();
42
- const approver = createApprover({
43
- mode: config.approval.mode,
44
- cwd: process.cwd(),
45
- isInteractive: Boolean(process.stdin.isTTY),
46
- autoApprove: Boolean(opts.yes || opts.dangerouslyApprove),
47
- logger,
48
- });
49
- const ctx = {
50
- cwd: process.cwd(),
51
- config,
52
- logger,
53
- approve: (action) => approver.approve(action),
54
- };
55
- const projectInstructions = loadProjectInstructions(process.cwd());
56
- const git = getGitInfo(process.cwd());
57
- const session = new Session({
58
- provider,
59
- registry,
60
- config,
61
- ctx,
62
- git,
63
- projectInstructions,
64
- });
51
+ // Plan mode is opt-in: --plan flag overrides the config default.
52
+ const planMode = opts.plan ?? config.agent.planMode;
53
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode);
65
54
  if (interactive) {
66
55
  await runInteractive(session);
67
56
  return;
@@ -80,14 +69,3 @@ export function runCommand() {
80
69
  `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
81
70
  });
82
71
  }
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
- }