@cruxy/cli 0.6.0 → 0.8.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 (78) hide show
  1. package/README.md +39 -16
  2. package/dist/agent/loop.d.ts +9 -5
  3. package/dist/agent/loop.js +53 -10
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +6 -0
  6. package/dist/agent/session.d.ts +29 -3
  7. package/dist/agent/session.js +37 -10
  8. package/dist/approval/prompt.d.ts +9 -0
  9. package/dist/approval/prompt.js +2 -77
  10. package/dist/cli/commands/init.d.ts +7 -0
  11. package/dist/cli/commands/init.js +40 -0
  12. package/dist/cli/commands/login.d.ts +8 -0
  13. package/dist/cli/commands/login.js +36 -0
  14. package/dist/cli/commands/run.js +46 -62
  15. package/dist/cli/onboard.d.ts +25 -0
  16. package/dist/cli/onboard.js +59 -0
  17. package/dist/cli/program.js +19 -1
  18. package/dist/cli/repl.d.ts +9 -4
  19. package/dist/cli/repl.js +32 -12
  20. package/dist/cli/session-factory.d.ts +13 -0
  21. package/dist/cli/session-factory.js +109 -0
  22. package/dist/config/credentials.d.ts +10 -0
  23. package/dist/config/credentials.js +69 -0
  24. package/dist/config/index.d.ts +1 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.d.ts +6 -1
  27. package/dist/config/manager.js +11 -1
  28. package/dist/config/schema.d.ts +10 -0
  29. package/dist/config/schema.js +2 -0
  30. package/dist/constants.d.ts +6 -0
  31. package/dist/constants.js +6 -0
  32. package/dist/errors/constructors.d.ts +10 -0
  33. package/dist/errors/constructors.js +46 -2
  34. package/dist/errors/types.d.ts +3 -0
  35. package/dist/errors/types.js +6 -0
  36. package/dist/onboarding/detect.d.ts +26 -0
  37. package/dist/onboarding/detect.js +56 -0
  38. package/dist/onboarding/flow.d.ts +28 -0
  39. package/dist/onboarding/flow.js +100 -0
  40. package/dist/onboarding/index.d.ts +5 -0
  41. package/dist/onboarding/index.js +5 -0
  42. package/dist/onboarding/io.d.ts +8 -0
  43. package/dist/onboarding/io.js +133 -0
  44. package/dist/onboarding/steps.d.ts +17 -0
  45. package/dist/onboarding/steps.js +100 -0
  46. package/dist/onboarding/types.d.ts +81 -0
  47. package/dist/onboarding/types.js +6 -0
  48. package/dist/plan/approve.d.ts +16 -0
  49. package/dist/plan/approve.js +46 -0
  50. package/dist/plan/execute.d.ts +20 -0
  51. package/dist/plan/execute.js +31 -0
  52. package/dist/plan/index.d.ts +7 -0
  53. package/dist/plan/index.js +7 -0
  54. package/dist/plan/policy.d.ts +26 -0
  55. package/dist/plan/policy.js +45 -0
  56. package/dist/plan/render.d.ts +5 -0
  57. package/dist/plan/render.js +47 -0
  58. package/dist/plan/service.d.ts +40 -0
  59. package/dist/plan/service.js +118 -0
  60. package/dist/plan/submit-plan.d.ts +33 -0
  61. package/dist/plan/submit-plan.js +57 -0
  62. package/dist/plan/types.d.ts +60 -0
  63. package/dist/plan/types.js +6 -0
  64. package/dist/render/capabilities.d.ts +12 -0
  65. package/dist/render/capabilities.js +27 -0
  66. package/dist/render/diff.d.ts +19 -0
  67. package/dist/render/diff.js +80 -0
  68. package/dist/render/highlight.d.ts +47 -0
  69. package/dist/render/highlight.js +265 -0
  70. package/dist/render/index.d.ts +14 -0
  71. package/dist/render/index.js +20 -0
  72. package/dist/render/plain-renderer.d.ts +32 -0
  73. package/dist/render/plain-renderer.js +61 -0
  74. package/dist/render/tty-renderer.d.ts +47 -0
  75. package/dist/render/tty-renderer.js +149 -0
  76. package/dist/render/types.d.ts +76 -0
  77. package/dist/render/types.js +1 -0
  78. package/package.json +1 -1
package/README.md CHANGED
@@ -16,14 +16,26 @@ an approval gate before anything touches disk.
16
16
  ```bash
17
17
  npm install -g @cruxy/cli
18
18
 
19
- export CRUXY_API_KEY=cxy_live_... # from app.cruxy.in
19
+ cruxy run "explain this codebase" # first run guides you through setup
20
+ ```
21
+
22
+ On your first interactive run with no key, cruxy walks you through getting one,
23
+ validates it, and saves it to `~/.cruxy` (owner-only) — no env var needed. You
24
+ can also run setup on demand:
20
25
 
21
- cruxy run "explain this codebase" # one-shot
22
- cruxy run # interactive session
26
+ ```bash
27
+ cruxy login # set or replace your API key
28
+ cruxy init # key + a project CRUXY.md + a first run
29
+ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
23
30
  ```
24
31
 
25
32
  ## Features
26
33
 
34
+ - **Onboarding** — a guided first run (`cruxy login` / `cruxy init` on demand):
35
+ get a key, validate it live, and save it to `~/.cruxy/credentials.json`
36
+ (`0600`) — never to `config.json`, the project, or env files. Keys resolve
37
+ env → credentials store. Non-interactive runs stay fail-loud
38
+ (`CRUXY_E_AUTH_MISSING_KEY`), never blocking on input.
27
39
  - **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
28
40
  `grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
29
41
  `list_skills`, `load_skill`, `create_pull_request`.
@@ -46,6 +58,17 @@ cruxy run # interactive session
46
58
  validated, and never auto-executed.
47
59
  - **Agent** — streaming output, multi-turn interactive sessions, context
48
60
  compaction, and awareness of git state and project instructions (`CRUXY.md`).
61
+ - **Streaming render** — flicker-free live output: a single in-place status line
62
+ (spinner while the model thinks / tools run), append-only committed text,
63
+ syntax-highlighted code fences, tool-call notes, and diffs drawn by the same
64
+ renderer as the approval prompt. Degrades cleanly: piped/CI output is plain
65
+ append-only text with zero ANSI (chrome on stderr), `NO_COLOR` drops color,
66
+ and `CRUXY_NO_SPINNER=1` stills the animation.
67
+ - **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
68
+ agent proposes a structured, step-by-step plan; you approve it once, then it
69
+ executes with live per-step status. Approving consents to the _shape_ of the
70
+ work — every action still passes the approval gate, and destructive/ungrantable
71
+ actions always re-confirm even after approval.
49
72
  - **Safety** — a single approval gate with diff previews that fails closed;
50
73
  read-only tools never prompt; file access is confined to the project root;
51
74
  shell commands run bounded.
@@ -101,19 +124,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
101
124
  `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
102
125
  branch on them:
103
126
 
104
- | Exit | Category | Example codes |
105
- | ---- | ---------- | ------------------------------------------------------------------------------------------------------------------ |
106
- | `0` | success | — |
107
- | `1` | internal | `CRUXY_E_INTERNAL` |
108
- | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH` |
109
- | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
110
- | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
111
- | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
112
- | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
113
- | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
114
- | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
115
- | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
116
- | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED` |
127
+ | Exit | Category | Example codes |
128
+ | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `0` | success | — |
130
+ | `1` | internal | `CRUXY_E_INTERNAL` |
131
+ | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT` |
132
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
133
+ | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
134
+ | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
135
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
136
+ | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
137
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
138
+ | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
139
+ | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED` |
117
140
 
118
141
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
119
142
  provider-agnostic, built over `fetch`, with no vendor SDKs.
@@ -1,5 +1,6 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
+ import type { StreamRenderer } from "../render/index.js";
3
4
  import type { ToolContext } from "../tools/index.js";
4
5
  import { ToolRegistry } from "../tools/index.js";
5
6
  export interface RunAgentArgs {
@@ -17,12 +18,13 @@ export interface RunAgentArgs {
17
18
  /** Ambient capabilities handed to each tool. */
18
19
  ctx: ToolContext;
19
20
  /**
20
- * Optional sink for assistant text as it streams: called per text delta, then
21
- * once with a lone "\n" to close each non-empty text segment on its own line.
22
- * When set, `runAgent` streams live and does not buffer-print the turn (the
23
- * caller renders); when omitted, behavior is unchanged (one buffered print).
21
+ * The render seam (U.2): assistant text streams through `renderer.write`
22
+ * delta by delta (each non-empty segment closed via `endSegment`), and
23
+ * tool-call progress is surfaced as transient `status` + committed `note`
24
+ * lines. When omitted, behavior is unchanged (one buffered print per turn,
25
+ * no tool-call chrome). The loop never touches stdout directly.
24
26
  */
25
- onText?: (delta: string) => void;
27
+ renderer?: StreamRenderer;
26
28
  /** Git context (branch + dirty) for the system prompt's Environment section. */
27
29
  git?: {
28
30
  branch: string;
@@ -30,6 +32,8 @@ export interface RunAgentArgs {
30
32
  } | null;
31
33
  /** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
32
34
  projectInstructions?: string | null;
35
+ /** Plan mode's propose phase (C.31): inject the plan-first system directive. */
36
+ planMode?: boolean;
33
37
  }
34
38
  export interface AgentResult {
35
39
  /** The full conversation, including assistant tool calls and tool results. */
@@ -10,10 +10,23 @@ import { buildSystemPrompt } from "./prompts.js";
10
10
  * silently running tool-less.
11
11
  */
12
12
  export async function runAgent(args) {
13
- const { provider, registry, config, ctx } = args;
13
+ const { provider, config, renderer } = args;
14
14
  if (!provider.supportsTools) {
15
15
  throw providerUnsupported(config.model.provider);
16
16
  }
17
+ renderer?.beginTurn();
18
+ try {
19
+ return await driveLoop(args, renderer);
20
+ }
21
+ finally {
22
+ // Always leave the terminal clean: no orphaned status line, no held text —
23
+ // even when a provider error aborts the turn mid-stream.
24
+ renderer?.endTurn();
25
+ }
26
+ }
27
+ /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
28
+ async function driveLoop(args, renderer) {
29
+ const { provider, registry, config, ctx } = args;
17
30
  const { logger } = ctx;
18
31
  // Work on a copy so we never mutate the caller's array as a side effect; the
19
32
  // extended history is returned for the caller to adopt.
@@ -32,6 +45,7 @@ export async function runAgent(args) {
32
45
  .map((tool) => ({ name: tool.name, description: tool.description })),
33
46
  git: args.git ?? null,
34
47
  projectInstructions: args.projectInstructions ?? null,
48
+ planMode: args.planMode ?? false,
35
49
  });
36
50
  let iterations = 0;
37
51
  for (let i = 0; i < maxIterations; i++) {
@@ -41,6 +55,8 @@ export async function runAgent(args) {
41
55
  let turnText = "";
42
56
  const pending = new Map();
43
57
  const toolUses = [];
58
+ // Live progress while waiting on the model; dismissed by the first delta.
59
+ renderer?.status("thinking…");
44
60
  for await (const ev of provider.stream({
45
61
  system,
46
62
  messages,
@@ -49,7 +65,7 @@ export async function runAgent(args) {
49
65
  switch (ev.type) {
50
66
  case "text_delta":
51
67
  turnText += ev.text;
52
- args.onText?.(ev.text);
68
+ renderer?.write(ev.text);
53
69
  break;
54
70
  case "tool_use_start":
55
71
  pending.set(ev.index, { id: ev.id, name: ev.name });
@@ -82,13 +98,13 @@ export async function runAgent(args) {
82
98
  }
83
99
  // ── Record the assistant turn ───────────────────────────────────────────
84
100
  if (turnText) {
85
- // Streaming (onText set): the text already reached the user delta by delta,
86
- // so close the segment with a single newline through the *same* sinkno
87
- // separate buffered print racing the stream so tool output, the next
88
- // turn, or an approval prompt starts on its own line. Otherwise render the
89
- // whole buffered block (no-callback path, unchanged).
90
- if (args.onText)
91
- args.onText("\n");
101
+ // Streaming (renderer set): the text already reached the user delta by
102
+ // delta, so close the segment through the *same* rendererit flushes any
103
+ // held partial line and terminates with one newline, so tool output, the
104
+ // next turn, or an approval prompt starts on its own line. Otherwise
105
+ // render the whole buffered block (no-renderer path, unchanged).
106
+ if (renderer)
107
+ renderer.endSegment();
92
108
  else
93
109
  logger.print(turnText);
94
110
  }
@@ -104,13 +120,40 @@ export async function runAgent(args) {
104
120
  // ── Execute each tool call, collecting one tool_result per call ──────────
105
121
  const toolResults = [];
106
122
  for (const call of toolUses) {
107
- toolResults.push(await runToolCall(call, registry, ctx));
123
+ const label = describeToolCall(call);
124
+ renderer?.status(`${label}…`);
125
+ const result = await runToolCall(call, registry, ctx);
126
+ renderer?.note(`${result.is_error ? "✗" : "✓"} ${label}`);
127
+ toolResults.push(result);
108
128
  }
109
129
  messages.push({ role: "user", content: toolResults });
110
130
  }
111
131
  logger.warn(`reached maxIterations (${maxIterations}) without completing`);
112
132
  return { messages, iterations, stop: "max_iterations", usage };
113
133
  }
134
+ /** Input keys worth surfacing in tool-call chrome, in preference order. */
135
+ const HINT_KEYS = ["path", "file_path", "command", "pattern", "query", "url"];
136
+ /** Longest hint shown before truncation — chrome, not information of record. */
137
+ const HINT_MAX = 60;
138
+ /**
139
+ * A short human label for a tool call ("read_file src/x.ts"): the tool name
140
+ * plus the first recognizable scalar argument, if any. Best-effort — unknown
141
+ * shapes fall back to the bare name.
142
+ */
143
+ function describeToolCall(call) {
144
+ const input = call.input;
145
+ if (typeof input === "object" && input !== null) {
146
+ for (const key of HINT_KEYS) {
147
+ const value = input[key];
148
+ if (typeof value === "string" && value !== "") {
149
+ const flat = value.replace(/\s+/g, " ").trim();
150
+ const hint = flat.length > HINT_MAX ? flat.slice(0, HINT_MAX - 1) + "…" : flat;
151
+ return `${call.name} ${hint}`;
152
+ }
153
+ }
154
+ }
155
+ return call.name;
156
+ }
114
157
  /**
115
158
  * Dispatch a single reassembled tool call to its tool and shape the outcome as
116
159
  * a `tool_result` block. Unknown tools and invalid arguments become `is_error`
@@ -28,6 +28,8 @@ export interface PromptContext {
28
28
  } | null;
29
29
  /** Optional extra instructions (e.g. from a project CRUXY.md). */
30
30
  projectInstructions?: string | null;
31
+ /** Plan mode's propose phase (C.31): inject the plan-first directive. */
32
+ planMode?: boolean;
31
33
  }
32
34
  /** Assemble the full system prompt for a session. */
33
35
  export declare function buildSystemPrompt(ctx: PromptContext): string;
@@ -6,6 +6,10 @@
6
6
  * dynamic environment block, so the model always knows where it is, what it
7
7
  * can do, and how it's expected to behave.
8
8
  */
9
+ /** The plan-mode directive, injected during the propose phase only. */
10
+ const PLAN_MODE_SECTION = `## Plan mode
11
+ You are in plan mode. Do NOT edit files, run commands, or take any side-effecting action yet.
12
+ First investigate with the read-only tools if you need to, then call \`submit_plan\` with an ordered list of steps — each with a title, a one-line rationale, and a kind (read | mutate | destructive). Cover the whole task; keep steps concrete and minimal. After you call \`submit_plan\`, stop and end your turn — the user reviews and approves the plan before you execute it.`;
9
13
  /**
10
14
  * The static core of cruxy's behaviour. Phrased as direct instruction to the
11
15
  * model. Keep this tight — every line earns its place; vague prose dilutes the
@@ -74,6 +78,8 @@ export function buildSystemPrompt(ctx) {
74
78
  const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
75
79
  const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
76
80
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
81
+ if (ctx.planMode)
82
+ sections.push(PLAN_MODE_SECTION);
77
83
  if (ctx.projectInstructions?.trim()) {
78
84
  sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
79
85
  }
@@ -1,8 +1,20 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
+ import type { StreamRenderer } from "../render/index.js";
3
4
  import type { ToolContext } from "../tools/index.js";
4
5
  import type { ToolRegistry } from "../tools/index.js";
5
6
  import { type AgentResult } from "./loop.js";
7
+ /**
8
+ * Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
9
+ * the plan package. When plan mode is on, `send` delegates the whole turn to
10
+ * this instead of a single `runAgent` call. Receives the running history (with
11
+ * the user turn already appended) and returns the extended history to adopt.
12
+ */
13
+ export type PlanRunner = (args: {
14
+ messages: Message[];
15
+ projectInstructions: string | null;
16
+ renderer?: StreamRenderer;
17
+ }) => Promise<AgentResult>;
6
18
  export interface SessionArgs {
7
19
  /** A constructed provider to stream from. */
8
20
  provider: Provider;
@@ -19,6 +31,10 @@ export interface SessionArgs {
19
31
  } | null;
20
32
  /** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
21
33
  projectInstructions?: string | null;
34
+ /** Start in plan mode (C.31). Toggleable at runtime via `setPlanMode`. */
35
+ planMode?: boolean;
36
+ /** The plan-mode turn runner; required for plan mode to actually engage. */
37
+ planRunner?: PlanRunner;
22
38
  }
23
39
  /**
24
40
  * Estimate the token footprint of a message list with a cheap chars/4 heuristic
@@ -46,16 +62,26 @@ export declare class Session {
46
62
  private readonly args;
47
63
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
48
64
  private projectInstructions;
65
+ /** Mutable so `/plan` can toggle plan mode mid-session. */
66
+ private planMode;
49
67
  constructor(args: SessionArgs);
68
+ /** Whether plan mode is currently on. */
69
+ getPlanMode(): boolean;
70
+ /**
71
+ * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
72
+ * the session factory); without one, plan mode stays off.
73
+ */
74
+ setPlanMode(enabled: boolean): void;
50
75
  /**
51
76
  * Run one user turn: append the prompt, compact if the history has grown past
52
77
  * the threshold, drive the agent loop over the full history, adopt the
53
78
  * extended history, and accumulate usage. Returns the turn's `AgentResult`.
54
79
  *
55
- * `onText`, when supplied, receives assistant text deltas as they stream so the
56
- * caller can render them live (see the REPL); history is unaffected.
80
+ * `renderer`, when supplied, receives assistant text deltas and tool-call
81
+ * progress as they stream so the caller sees the turn live (see the REPL);
82
+ * history is unaffected.
57
83
  */
58
- send(userPrompt: string, onText?: (delta: string) => void): Promise<AgentResult>;
84
+ send(userPrompt: string, renderer?: StreamRenderer): Promise<AgentResult>;
59
85
  /**
60
86
  * Re-read project instructions (CRUXY.md / AGENTS.md) from the working
61
87
  * directory so edits take effect without restarting. Returns the new text, or
@@ -49,29 +49,56 @@ export class Session {
49
49
  args;
50
50
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
51
51
  projectInstructions;
52
+ /** Mutable so `/plan` can toggle plan mode mid-session. */
53
+ planMode;
52
54
  constructor(args) {
53
55
  this.args = args;
54
56
  this.projectInstructions = args.projectInstructions ?? null;
57
+ // Plan mode requires a wired runner; without one it stays off (no half-on
58
+ // state where the plan directive is injected but nothing orchestrates it).
59
+ this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
60
+ }
61
+ /** Whether plan mode is currently on. */
62
+ getPlanMode() {
63
+ return this.planMode;
64
+ }
65
+ /**
66
+ * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
67
+ * the session factory); without one, plan mode stays off.
68
+ */
69
+ setPlanMode(enabled) {
70
+ this.planMode = enabled && this.args.planRunner !== undefined;
55
71
  }
56
72
  /**
57
73
  * Run one user turn: append the prompt, compact if the history has grown past
58
74
  * the threshold, drive the agent loop over the full history, adopt the
59
75
  * extended history, and accumulate usage. Returns the turn's `AgentResult`.
60
76
  *
61
- * `onText`, when supplied, receives assistant text deltas as they stream so the
62
- * caller can render them live (see the REPL); history is unaffected.
77
+ * `renderer`, when supplied, receives assistant text deltas and tool-call
78
+ * progress as they stream so the caller sees the turn live (see the REPL);
79
+ * history is unaffected.
63
80
  */
64
- async send(userPrompt, onText) {
81
+ async send(userPrompt, renderer) {
65
82
  this.messages.push({ role: "user", content: userPrompt });
66
83
  // Compact *before* the agent call so the turn runs against a bounded history.
67
84
  await this.maybeCompact();
68
- const result = await runAgent({
69
- messages: this.messages,
70
- ...this.args,
71
- // After the spread so a mid-session `/reload` wins over the initial value.
72
- projectInstructions: this.projectInstructions,
73
- onText,
74
- });
85
+ // Plan mode (C.31) delegates the whole turn to the injected runner: propose a
86
+ // plan, approve/revise, then execute step-by-step. Falls back to the normal
87
+ // single-shot loop when off or unwired, so existing behavior is untouched.
88
+ const result = this.planMode && this.args.planRunner
89
+ ? await this.args.planRunner({
90
+ messages: this.messages,
91
+ projectInstructions: this.projectInstructions,
92
+ renderer,
93
+ })
94
+ : await runAgent({
95
+ messages: this.messages,
96
+ ...this.args,
97
+ // After the spread so a mid-session `/reload` wins over the initial value.
98
+ projectInstructions: this.projectInstructions,
99
+ planMode: false, // the plan directive belongs only to the runner's propose phase
100
+ renderer,
101
+ });
75
102
  this.messages = result.messages;
76
103
  this.usage.input_tokens += result.usage.input_tokens;
77
104
  this.usage.output_tokens += result.usage.output_tokens;
@@ -1,4 +1,13 @@
1
1
  import type { ApprovalRequest } from "./types.js";
2
+ /**
3
+ * The interactive prompt: render a pending action (a real diff for file edits,
4
+ * the exact command + cwd for shell) and read a 4-way choice. Rendering is data
5
+ * → string so it's testable; color is gated on `io.color` (NO_COLOR / non-TTY
6
+ * aware). **Default-deny**: EOF / Ctrl-C / any unrecognized key → reject.
7
+ *
8
+ * Diff/preview rendering is the shared implementation in `render/diff.ts` —
9
+ * the streaming path and this prompt draw the same bytes for the same change.
10
+ */
2
11
  /** The four user choices (plus the implicit default-deny). */
3
12
  export type PromptChoice = {
4
13
  kind: "once";
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
2
  import pc from "picocolors";
3
- /** Cap on rendered preview lines before collapsing the rest. */
4
- const PREVIEW_MAX_LINES = 40;
3
+ import { renderActionPreview } from "../render/diff.js";
5
4
  /**
6
5
  * Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
7
6
  * read a follow-up line (reason / instruction). Anything else — including EOF —
@@ -53,7 +52,7 @@ function detail(request, c) {
53
52
  ` ${c.dim(`in ${request.cwd}`)}`,
54
53
  ].join("\n");
55
54
  }
56
- return renderPreview(request.action.preview, c);
55
+ return renderActionPreview(request.action.preview, c);
57
56
  }
58
57
  /** The choices line, including a short label of what an `a` grant would cover. */
59
58
  function choices(scope, c) {
@@ -71,80 +70,6 @@ function scopeLabel(scope) {
71
70
  return `changes under ${path.basename(scope.root)}/`;
72
71
  return null;
73
72
  }
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
73
  // ── default stdin-backed PromptIO ──────────────────────────────────────────────
149
74
  /** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
150
75
  export function defaultPromptIO(color) {
@@ -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
+ }