@cruxy/cli 0.1.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/agent/approval.d.ts +41 -0
  4. package/dist/agent/approval.js +179 -0
  5. package/dist/agent/index.d.ts +4 -0
  6. package/dist/agent/index.js +4 -0
  7. package/dist/agent/loop.d.ts +53 -0
  8. package/dist/agent/loop.js +148 -0
  9. package/dist/agent/prompts.d.ts +53 -0
  10. package/dist/agent/prompts.js +99 -0
  11. package/dist/agent/session.d.ts +107 -0
  12. package/dist/agent/session.js +236 -0
  13. package/dist/cli/commands/config.d.ts +2 -0
  14. package/dist/cli/commands/config.js +59 -0
  15. package/dist/cli/commands/run.d.ts +2 -0
  16. package/dist/cli/commands/run.js +85 -0
  17. package/dist/cli/program.d.ts +2 -0
  18. package/dist/cli/program.js +36 -0
  19. package/dist/cli/repl.d.ts +15 -0
  20. package/dist/cli/repl.js +114 -0
  21. package/dist/cli/stream-print.d.ts +14 -0
  22. package/dist/cli/stream-print.js +26 -0
  23. package/dist/config/index.d.ts +4 -0
  24. package/dist/config/index.js +4 -0
  25. package/dist/config/manager.d.ts +34 -0
  26. package/dist/config/manager.js +151 -0
  27. package/dist/config/paths.d.ts +9 -0
  28. package/dist/config/paths.js +31 -0
  29. package/dist/config/project.d.ts +10 -0
  30. package/dist/config/project.js +36 -0
  31. package/dist/config/schema.d.ts +303 -0
  32. package/dist/config/schema.js +100 -0
  33. package/dist/constants.d.ts +11 -0
  34. package/dist/constants.js +31 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +13 -0
  37. package/dist/tools/file/apply-patch.d.ts +94 -0
  38. package/dist/tools/file/apply-patch.js +195 -0
  39. package/dist/tools/file/edit-file.d.ts +14 -0
  40. package/dist/tools/file/edit-file.js +81 -0
  41. package/dist/tools/file/glob.d.ts +10 -0
  42. package/dist/tools/file/glob.js +52 -0
  43. package/dist/tools/file/grep-files.d.ts +32 -0
  44. package/dist/tools/file/grep-files.js +113 -0
  45. package/dist/tools/file/index.d.ts +7 -0
  46. package/dist/tools/file/index.js +7 -0
  47. package/dist/tools/file/paths.d.ts +24 -0
  48. package/dist/tools/file/paths.js +65 -0
  49. package/dist/tools/file/read-file.d.ts +8 -0
  50. package/dist/tools/file/read-file.js +52 -0
  51. package/dist/tools/file/write-file.d.ts +10 -0
  52. package/dist/tools/file/write-file.js +56 -0
  53. package/dist/tools/git-status.d.ts +8 -0
  54. package/dist/tools/git-status.js +26 -0
  55. package/dist/tools/index.d.ts +5 -0
  56. package/dist/tools/index.js +5 -0
  57. package/dist/tools/list-files.d.ts +7 -0
  58. package/dist/tools/list-files.js +27 -0
  59. package/dist/tools/registry.d.ts +23 -0
  60. package/dist/tools/registry.js +63 -0
  61. package/dist/tools/shell/index.d.ts +1 -0
  62. package/dist/tools/shell/index.js +1 -0
  63. package/dist/tools/shell/run-command.d.ts +10 -0
  64. package/dist/tools/shell/run-command.js +100 -0
  65. package/dist/tools/types.d.ts +113 -0
  66. package/dist/tools/types.js +1 -0
  67. package/dist/utils/git.d.ts +17 -0
  68. package/dist/utils/git.js +43 -0
  69. package/dist/utils/logger.d.ts +16 -0
  70. package/dist/utils/logger.js +42 -0
  71. package/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cruxy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # @cruxy/cli
2
+
3
+ An agentic coding CLI. **Phase C.0 — CLI scaffolding + config.**
4
+
5
+ This is the foundation pass: a working command-line skeleton with a layered
6
+ configuration system. The agent loop, tools, indexing, and everything else in
7
+ the C-series slot into the directory structure laid out here.
8
+
9
+ ## Requirements
10
+
11
+ - Node.js >= 20
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ npm install
17
+ npm run build # compile TypeScript -> dist/
18
+ npm link # optional: expose the `cruxy` command globally
19
+ ```
20
+
21
+ During development you can skip the build step:
22
+
23
+ ```bash
24
+ npm run dev -- --help
25
+ npm run dev -- config path
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ cruxy # entrypoint (interactive REPL stub — lands in C.3)
32
+ cruxy run "fix the bug" # one-shot prompt (agent loop stub — lands in C.4)
33
+ cruxy config init # write a default global config
34
+ cruxy config list # print the fully-resolved config
35
+ cruxy config get model.temperature
36
+ cruxy config set model.model claude-opus-4-8
37
+ cruxy config path # show where config files live
38
+ cruxy --help
39
+ ```
40
+
41
+ Global flags: `--config <path>`, `--log-level <level>`, `--verbose`, `--version`.
42
+
43
+ ## Configuration
44
+
45
+ Config is resolved in layers, where later sources override earlier ones:
46
+
47
+ 1. Built-in schema defaults
48
+ 2. Global config — `~/.cruxy/config.json`
49
+ 3. Project config — `cruxy.config.json` or `.cruxy/config.json` (discovered by
50
+ walking up from the current directory), or an explicit `--config <path>`
51
+ 4. Environment variables — `CRUXY_MODEL`, `CRUXY_PROVIDER`, `CRUXY_LOG_LEVEL`
52
+
53
+ The merged result is validated against a [zod](https://zod.dev) schema
54
+ (`src/config/schema.ts`); invalid configs are rejected with a readable error.
55
+
56
+ **API keys are never stored in config files.** They are read from the
57
+ environment only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `CRUXY_API_KEY`).
58
+ Copy `.env.example` to `.env` to set them locally.
59
+
60
+ ## Project structure
61
+
62
+ ```
63
+ src/
64
+ index.ts # bin entry (shebang) + top-level error handling
65
+ constants.ts # app name/version, config file names
66
+ cli/
67
+ program.ts # commander program + global options
68
+ commands/
69
+ run.ts # `cruxy run` (agent loop stub)
70
+ config.ts # `cruxy config` subcommands
71
+ config/
72
+ schema.ts # zod config schema + types
73
+ paths.ts # global/project config path resolution
74
+ manager.ts # layered load/merge/validate, get/set/init
75
+ index.ts # barrel
76
+ utils/
77
+ logger.ts # leveled logger
78
+ ```
79
+
80
+ Folders for `agent/`, `tools/`, and `providers/` are introduced as their phases
81
+ land, so this layout grows without restructuring.
82
+
83
+ ## Roadmap (C-series)
84
+
85
+ | Phase | Description |
86
+ | -------- | -------------------------------------------------------------------------------------------------------------------------- |
87
+ | C.0 | CLI scaffolding + config |
88
+ | C.1–C.10 | Provider client, tool system, agent loop, file tools, **permissions** ← you are here, terminal UI, shell, git, IDE plugins |
89
+ | C.11 | Codebase indexing (vector store) |
90
+ | C.12 | Per-language LSP integration |
91
+ | C.13 | Test execution + iteration loop |
92
+ | C.14 | Subagent orchestration |
93
+ | C.15 | PR generation |
94
+ | C.16 | Sandbox / Docker execution |
95
+ | C.17 | Web-search subtool |
96
+ | C.18 | Custom skills (SKILL.md system) |
97
+ | C.19 | Hooks + slash commands |
98
+ | C.20 | Headless / CI mode |
99
+ | C.21 | Multi-repo agents |
100
+ | C.22 | Telemetry + cost tracking |
101
+ | C.23 | Streaming UI improvements |
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,41 @@
1
+ import type { ApproveAction } from "../tools/index.js";
2
+ import { logger as defaultLogger } from "../utils/logger.js";
3
+ type Logger = typeof defaultLogger;
4
+ /** Reads a single keypress from the user. Returns "" on EOF. */
5
+ export type KeypressReader = () => Promise<string>;
6
+ export interface ApproverConfig {
7
+ /** "prompt" asks interactively; "auto" approves everything unattended. */
8
+ mode: "prompt" | "auto";
9
+ /** Project root, used to render action paths relative for the prompt. */
10
+ cwd: string;
11
+ /** Whether stdin is an interactive TTY. */
12
+ isInteractive: boolean;
13
+ /** Explicit unattended opt-in (e.g. --yes / --dangerously-approve). */
14
+ autoApprove?: boolean;
15
+ /** Keypress source (injectable for tests). Defaults to a raw-mode stdin read. */
16
+ readKey?: KeypressReader;
17
+ /** Where the prompt text is written (injectable). Defaults to stderr. */
18
+ write?: (text: string) => void;
19
+ /** Logger for unattended / denial diagnostics. */
20
+ logger?: Logger;
21
+ }
22
+ /**
23
+ * Decides whether a side-effecting tool action may proceed. The single gate every
24
+ * mutating tool funnels through (write/edit today, run_command later).
25
+ *
26
+ * Interactive: prints the action (plus a change preview when the tool supplied
27
+ * one) and reads one key — `y` allow once, `a` allow-all for this run, anything
28
+ * else (including EOF) denies. **Fails closed.**
29
+ * Non-interactive: denies by default unless an explicit opt-in (`mode:"auto"` or
30
+ * `autoApprove`) is set, in which case it auto-approves and warns it's unattended.
31
+ */
32
+ export declare class Approver {
33
+ /** Session allow-all — scoped to this instance, never persisted. */
34
+ private allowAll;
35
+ private warnedUnattended;
36
+ private readonly cfg;
37
+ constructor(cfg: ApproverConfig);
38
+ approve(action: ApproveAction): Promise<boolean>;
39
+ }
40
+ export declare function createApprover(cfg: ApproverConfig): Approver;
41
+ export {};
@@ -0,0 +1,179 @@
1
+ import path from "node:path";
2
+ import pc from "picocolors";
3
+ import { logger as defaultLogger } from "../utils/logger.js";
4
+ /** Cap on rendered preview lines before collapsing the rest into "...N more". */
5
+ const PREVIEW_MAX_LINES = 40;
6
+ /** Human-readable verb + detail for a pending action. */
7
+ function renderAction(cwd, action) {
8
+ switch (action.kind) {
9
+ case "write":
10
+ return `write ${path.relative(cwd, action.path ?? "") || action.path}`;
11
+ case "edit":
12
+ return `edit ${path.relative(cwd, action.path ?? "") || action.path}`;
13
+ case "shell":
14
+ return `run ${action.command ?? ""}`;
15
+ case "patch": {
16
+ const n = action.preview?.type === "patch" ? action.preview.files.length : 0;
17
+ return `apply patch (${n} file${n === 1 ? "" : "s"})`;
18
+ }
19
+ }
20
+ }
21
+ /**
22
+ * A minimal unified-style diff for one hunk: the removed block (red `-`) then the
23
+ * added block (green `+`). The `-`/`+` prefixes keep it readable without color.
24
+ * Shared by the `edit` and `patch` previews (the C.6 renderer).
25
+ */
26
+ function diffLines(oldStr, newStr) {
27
+ const removed = oldStr.split("\n").map((l) => pc.red(`- ${l}`));
28
+ const added = newStr.split("\n").map((l) => pc.green(`+ ${l}`));
29
+ return [...removed, ...added];
30
+ }
31
+ /**
32
+ * Render every file in an `apply_patch` preview as a combined diff: a per-file
33
+ * header (op + path) followed by its hunk diffs (update), created content
34
+ * (create), or nothing (delete).
35
+ */
36
+ function renderPatchFiles(files) {
37
+ const out = [];
38
+ for (const file of files) {
39
+ if (file.op === "delete") {
40
+ out.push(pc.red(`delete ${file.path}`));
41
+ }
42
+ else if (file.op === "create") {
43
+ out.push(pc.green(`create ${file.path}`));
44
+ out.push(...file.lines.map((l) => pc.green(`+ ${l}`)));
45
+ if (file.omittedLines > 0) {
46
+ out.push(pc.dim(` ...${file.omittedLines} more lines`));
47
+ }
48
+ }
49
+ else {
50
+ out.push(pc.yellow(`update ${file.path}`));
51
+ for (const hunk of file.hunks) {
52
+ out.push(...diffLines(hunk.oldStr, hunk.newStr));
53
+ }
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /**
59
+ * Render the exact-change preview for a write/edit action as indented lines, so
60
+ * the user sees what they're approving. Caps the output at `PREVIEW_MAX_LINES`
61
+ * and collapses the overflow into a "...N more" notice. Returns "" when there's
62
+ * nothing to preview. Colors are decorative — the `-`/`+`/header prefixes keep
63
+ * it readable without them.
64
+ */
65
+ function renderPreview(preview) {
66
+ if (!preview)
67
+ return "";
68
+ let lines;
69
+ if (preview.type === "edit") {
70
+ lines = diffLines(preview.oldStr, preview.newStr);
71
+ }
72
+ else if (preview.type === "patch") {
73
+ lines = renderPatchFiles(preview.files);
74
+ }
75
+ else {
76
+ const header = preview.exists
77
+ ? pc.yellow("OVERWRITE existing")
78
+ : pc.green("create");
79
+ const body = preview.lines.map((l) => ` ${l}`);
80
+ if (preview.omittedLines > 0) {
81
+ body.push(pc.dim(` ...${preview.omittedLines} more lines`));
82
+ }
83
+ lines = [header, ...body];
84
+ }
85
+ // Cap the rendered block; collapse the rest into a count.
86
+ if (lines.length > PREVIEW_MAX_LINES) {
87
+ const hidden = lines.length - PREVIEW_MAX_LINES;
88
+ lines = [...lines.slice(0, PREVIEW_MAX_LINES), pc.dim(`...${hidden} more`)];
89
+ }
90
+ return lines.map((l) => ` ${l}\n`).join("");
91
+ }
92
+ /**
93
+ * Decides whether a side-effecting tool action may proceed. The single gate every
94
+ * mutating tool funnels through (write/edit today, run_command later).
95
+ *
96
+ * Interactive: prints the action (plus a change preview when the tool supplied
97
+ * one) and reads one key — `y` allow once, `a` allow-all for this run, anything
98
+ * else (including EOF) denies. **Fails closed.**
99
+ * Non-interactive: denies by default unless an explicit opt-in (`mode:"auto"` or
100
+ * `autoApprove`) is set, in which case it auto-approves and warns it's unattended.
101
+ */
102
+ export class Approver {
103
+ /** Session allow-all — scoped to this instance, never persisted. */
104
+ allowAll = false;
105
+ warnedUnattended = false;
106
+ cfg;
107
+ constructor(cfg) {
108
+ this.cfg = {
109
+ ...cfg,
110
+ readKey: cfg.readKey ?? readKeyFromStdin,
111
+ write: cfg.write ?? ((t) => process.stderr.write(t)),
112
+ logger: cfg.logger ?? defaultLogger,
113
+ };
114
+ }
115
+ async approve(action) {
116
+ const log = this.cfg.logger ?? defaultLogger;
117
+ if (this.allowAll)
118
+ return true;
119
+ // Explicit unattended opt-in — approve everywhere, warn once.
120
+ if (this.cfg.mode === "auto" || this.cfg.autoApprove) {
121
+ if (!this.warnedUnattended) {
122
+ log.warn("running unattended — auto-approving all tool actions");
123
+ this.warnedUnattended = true;
124
+ }
125
+ return true;
126
+ }
127
+ // No way to ask → fail closed.
128
+ if (!this.cfg.isInteractive) {
129
+ log.warn(`denied (${renderAction(this.cfg.cwd, action)}): non-interactive — pass --yes or set approval.mode=auto`);
130
+ return false;
131
+ }
132
+ const write = this.cfg.write ?? ((t) => process.stderr.write(t));
133
+ write(`${pc.yellow("?")} cruxy wants to ${renderAction(this.cfg.cwd, action)}\n` +
134
+ renderPreview(action.preview) +
135
+ ` ${pc.dim("[y] allow once · [n] deny · [a] allow all:")} `);
136
+ const key = (await (this.cfg.readKey ?? readKeyFromStdin)()).toLowerCase();
137
+ write("\n");
138
+ if (key === "y")
139
+ return true;
140
+ if (key === "a") {
141
+ this.allowAll = true;
142
+ return true;
143
+ }
144
+ // n, EOF (""), Ctrl-C, or any other key → deny.
145
+ return false;
146
+ }
147
+ }
148
+ export function createApprover(cfg) {
149
+ return new Approver(cfg);
150
+ }
151
+ /**
152
+ * Read a single keypress from stdin in raw mode. Resolves to the first character
153
+ * of the chunk, or "" on EOF. Always restores cooked mode and pauses stdin.
154
+ */
155
+ function readKeyFromStdin() {
156
+ const stdin = process.stdin;
157
+ return new Promise((resolve) => {
158
+ const cleanup = () => {
159
+ stdin.removeListener("data", onData);
160
+ stdin.removeListener("end", onEnd);
161
+ if (stdin.isTTY)
162
+ stdin.setRawMode(false);
163
+ stdin.pause();
164
+ };
165
+ const onData = (buf) => {
166
+ cleanup();
167
+ resolve(buf.toString("utf8").slice(0, 1));
168
+ };
169
+ const onEnd = () => {
170
+ cleanup();
171
+ resolve("");
172
+ };
173
+ if (stdin.isTTY)
174
+ stdin.setRawMode(true);
175
+ stdin.resume();
176
+ stdin.once("data", onData);
177
+ stdin.once("end", onEnd);
178
+ });
179
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./loop.js";
2
+ export * from "./session.js";
3
+ export * from "./prompts.js";
4
+ export * from "./approval.js";
@@ -0,0 +1,4 @@
1
+ export * from "./loop.js";
2
+ export * from "./session.js";
3
+ export * from "./prompts.js";
4
+ export * from "./approval.js";
@@ -0,0 +1,53 @@
1
+ import type { Message, Provider, Usage } from "@cruxy/sdk";
2
+ import type { CruxyConfig } from "../config/index.js";
3
+ import type { ToolContext } from "../tools/index.js";
4
+ import { ToolRegistry } from "../tools/index.js";
5
+ export interface RunAgentArgs {
6
+ /**
7
+ * The full running conversation. The caller owns history and must append the
8
+ * user turn before calling; `runAgent` does not fabricate the initial array.
9
+ */
10
+ messages: Message[];
11
+ /** A constructed provider to stream from. */
12
+ provider: Provider;
13
+ /** The tool catalogue advertised to the model and dispatched against. */
14
+ registry: ToolRegistry;
15
+ /** Resolved CLI configuration (turn ceiling, etc.). */
16
+ config: CruxyConfig;
17
+ /** Ambient capabilities handed to each tool. */
18
+ ctx: ToolContext;
19
+ /**
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).
24
+ */
25
+ onText?: (delta: string) => void;
26
+ /** Git context (branch + dirty) for the system prompt's Environment section. */
27
+ git?: {
28
+ branch: string;
29
+ dirty: boolean;
30
+ } | null;
31
+ /** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
32
+ projectInstructions?: string | null;
33
+ }
34
+ export interface AgentResult {
35
+ /** The full conversation, including assistant tool calls and tool results. */
36
+ messages: Message[];
37
+ /** Number of model turns consumed. */
38
+ iterations: number;
39
+ /** Why the loop ended. */
40
+ stop: "completed" | "max_iterations";
41
+ /** Accumulated token usage (stashed for cost tracking in C.22). */
42
+ usage: Usage;
43
+ }
44
+ /**
45
+ * Drive the model/tool loop over an existing conversation. Streams each turn,
46
+ * renders assistant text, reassembles tool calls, executes them, feeds the
47
+ * results back, and repeats until the model stops calling tools or the turn
48
+ * ceiling is hit.
49
+ *
50
+ * Requires a tool-capable provider — it throws up front otherwise rather than
51
+ * silently running tool-less.
52
+ */
53
+ export declare function runAgent(args: RunAgentArgs): Promise<AgentResult>;
@@ -0,0 +1,148 @@
1
+ import { buildSystemPrompt } from "./prompts.js";
2
+ /**
3
+ * Drive the model/tool loop over an existing conversation. Streams each turn,
4
+ * renders assistant text, reassembles tool calls, executes them, feeds the
5
+ * results back, and repeats until the model stops calling tools or the turn
6
+ * ceiling is hit.
7
+ *
8
+ * Requires a tool-capable provider — it throws up front otherwise rather than
9
+ * silently running tool-less.
10
+ */
11
+ export async function runAgent(args) {
12
+ const { provider, registry, config, ctx } = args;
13
+ if (!provider.supportsTools) {
14
+ throw new Error(`provider ${config.model.provider} does not support tool use; cruxy run requires a tool-capable provider`);
15
+ }
16
+ const { logger } = ctx;
17
+ // Work on a copy so we never mutate the caller's array as a side effect; the
18
+ // extended history is returned for the caller to adopt.
19
+ const messages = [...args.messages];
20
+ const usage = { input_tokens: 0, output_tokens: 0 };
21
+ const maxIterations = config.agent.maxIterations;
22
+ // The tool catalogue and environment are stable across the loop, so build the
23
+ // system prompt once. The builder degrades gracefully when git is null.
24
+ const system = buildSystemPrompt({
25
+ cwd: ctx.cwd,
26
+ platform: process.platform,
27
+ date: new Date().toISOString().slice(0, 10),
28
+ model: `${config.model.provider}/${config.model.model}`,
29
+ tools: registry
30
+ .list()
31
+ .map((tool) => ({ name: tool.name, description: tool.description })),
32
+ autoApprove: config.approval.mode === "auto",
33
+ git: args.git ?? null,
34
+ projectInstructions: args.projectInstructions ?? null,
35
+ });
36
+ let iterations = 0;
37
+ for (let i = 0; i < maxIterations; i++) {
38
+ iterations = i + 1;
39
+ const tools = registry.toToolSpecs();
40
+ // ── Consume one model turn ──────────────────────────────────────────────
41
+ let turnText = "";
42
+ const pending = new Map();
43
+ const toolUses = [];
44
+ for await (const ev of provider.stream({
45
+ system,
46
+ messages,
47
+ tools,
48
+ })) {
49
+ switch (ev.type) {
50
+ case "text_delta":
51
+ turnText += ev.text;
52
+ args.onText?.(ev.text);
53
+ break;
54
+ case "tool_use_start":
55
+ pending.set(ev.index, { id: ev.id, name: ev.name });
56
+ break;
57
+ case "tool_use_stop": {
58
+ // tool_use_stop carries the fully-assembled, parsed input; `pending`
59
+ // is only a fallback for id/name if a start arrived without a stop.
60
+ const started = pending.get(ev.index);
61
+ pending.delete(ev.index);
62
+ toolUses.push({
63
+ type: "tool_use",
64
+ id: ev.id || started?.id || "",
65
+ name: ev.name || started?.name || "",
66
+ input: ev.input,
67
+ });
68
+ break;
69
+ }
70
+ case "usage":
71
+ usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
72
+ usage.output_tokens += ev.usage.output_tokens;
73
+ break;
74
+ case "message_stop":
75
+ // Turn complete; the stream ends after this.
76
+ break;
77
+ case "error":
78
+ throw ev.error;
79
+ default:
80
+ break;
81
+ }
82
+ }
83
+ // ── Record the assistant turn ───────────────────────────────────────────
84
+ 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* sink — no
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");
92
+ else
93
+ logger.print(turnText);
94
+ }
95
+ const assistantBlocks = [];
96
+ if (turnText)
97
+ assistantBlocks.push({ type: "text", text: turnText });
98
+ assistantBlocks.push(...toolUses);
99
+ messages.push({ role: "assistant", content: assistantBlocks });
100
+ // No tool calls → the model is done.
101
+ if (toolUses.length === 0) {
102
+ return { messages, iterations, stop: "completed", usage };
103
+ }
104
+ // ── Execute each tool call, collecting one tool_result per call ──────────
105
+ const toolResults = [];
106
+ for (const call of toolUses) {
107
+ toolResults.push(await runToolCall(call, registry, ctx));
108
+ }
109
+ messages.push({ role: "user", content: toolResults });
110
+ }
111
+ logger.warn(`reached maxIterations (${maxIterations}) without completing`);
112
+ return { messages, iterations, stop: "max_iterations", usage };
113
+ }
114
+ /**
115
+ * Dispatch a single reassembled tool call to its tool and shape the outcome as
116
+ * a `tool_result` block. Unknown tools and invalid arguments become `is_error`
117
+ * results rather than thrown exceptions, so the model can read the error and
118
+ * self-correct on the next turn.
119
+ */
120
+ async function runToolCall(call, registry, ctx) {
121
+ const tool = registry.get(call.name);
122
+ if (!tool) {
123
+ return {
124
+ type: "tool_result",
125
+ tool_use_id: call.id,
126
+ content: `unknown tool "${call.name}"`,
127
+ is_error: true,
128
+ };
129
+ }
130
+ const parsed = tool.parameters.safeParse(call.input);
131
+ if (!parsed.success) {
132
+ return {
133
+ type: "tool_result",
134
+ tool_use_id: call.id,
135
+ content: `invalid arguments for "${call.name}": ${parsed.error.message}`,
136
+ is_error: true,
137
+ };
138
+ }
139
+ const result = await tool.execute(parsed.data, ctx);
140
+ return result.ok
141
+ ? { type: "tool_result", tool_use_id: call.id, content: result.output }
142
+ : {
143
+ type: "tool_result",
144
+ tool_use_id: call.id,
145
+ content: result.error,
146
+ is_error: true,
147
+ };
148
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * cruxy-code agent prompts.
3
+ *
4
+ * These are ORIGINAL prompts written for cruxy — not copied from any other
5
+ * tool. The system prompt is assembled at runtime from a static core plus a
6
+ * dynamic environment block, so the model always knows where it is, what it
7
+ * can do, and how it's expected to behave.
8
+ */
9
+ export interface ToolSummary {
10
+ name: string;
11
+ description: string;
12
+ }
13
+ export interface PromptContext {
14
+ /** Absolute working directory the agent is rooted in. */
15
+ cwd: string;
16
+ /** node `process.platform`, e.g. "linux", "darwin", "win32". */
17
+ platform: string;
18
+ /** Provider/model string, for the model's self-awareness. */
19
+ model: string;
20
+ /** Current ISO date (so the model isn't guessing). */
21
+ date: string;
22
+ /** Tools available this session (from the tool registry). */
23
+ tools: ToolSummary[];
24
+ /** Optional git context, when the cwd is a repo. */
25
+ git?: {
26
+ branch: string;
27
+ dirty: boolean;
28
+ } | null;
29
+ /** When true, the agent may act without per-step confirmation. */
30
+ autoApprove: boolean;
31
+ /** Optional extra instructions (e.g. from a project CRUXY.md). */
32
+ projectInstructions?: string | null;
33
+ }
34
+ /** Assemble the full system prompt for a session. */
35
+ export declare function buildSystemPrompt(ctx: PromptContext): string;
36
+ /**
37
+ * Compact reminder injected after tool results when the loop has run long, to
38
+ * keep the model anchored to the original objective and the verify step.
39
+ */
40
+ export declare const PROGRESS_REMINDER = "Reminder: stay focused on the original task. Before declaring done, verify your change actually works (build/tests/lint), then summarize what changed.";
41
+ /**
42
+ * System prompt for the side conversation that compacts an over-long history
43
+ * (see Session.compact). It runs as a standalone, tool-less completion over a
44
+ * rendered transcript — the goal is a synopsis dense enough that the main loop
45
+ * can continue without the verbatim prefix.
46
+ */
47
+ export declare const SUMMARY_SYSTEM = "You are compacting a coding assistant's conversation to fit within its context window. Summarize the conversation so far into a compact synopsis that preserves: decisions made and their rationale, concrete file paths and identifiers touched, the current state of the work, and any open or pending tasks. Be specific and terse \u2014 omit pleasantries and restated instructions. Output only the synopsis.";
48
+ /**
49
+ * Marker embedded in the synthetic messages that replace a compacted prefix, so
50
+ * they're recognizable in the history (and fold cleanly into a later
51
+ * re-summarization rather than being mistaken for live conversation).
52
+ */
53
+ export declare const COMPACTION_MARKER = "[conversation compacted]";