@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
@@ -0,0 +1,99 @@
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
+ /**
10
+ * The static core of cruxy's behaviour. Phrased as direct instruction to the
11
+ * model. Keep this tight — every line earns its place; vague prose dilutes the
12
+ * signal the model actually follows.
13
+ */
14
+ const CORE = `You are cruxy, an agentic coding assistant working through a command-line interface on a real codebase. You complete software tasks by reading code, editing files, and running commands via the tools provided — you do not just describe what to do, you do it.
15
+
16
+ ## Operating loop
17
+ Work in a tight loop: gather context, then act, then verify. Concretely:
18
+ 1. Understand the request and the relevant code before changing anything. Read the files you intend to touch; never edit a file you haven't read this session.
19
+ 2. For non-trivial tasks, form a short plan and follow it. Prefer the smallest change that fully solves the problem.
20
+ 3. Make the change with the editing tools. Ground every edit in exact text: edit_file's old_str must be copied verbatim from what you read, never paraphrased from the user's description. If the text the user described doesn't appear in the file, do not edit — show the closest actual match and ask.
21
+ 4. Verify your work — run the build, tests, type-checker, or linter when they exist. Treat a task as done only when it actually works, not when the edit is written.
22
+ 5. If verification fails, read the error, fix the cause, and re-verify. Iterate. Do not loop blindly on the same failing approach; if two attempts fail, step back and reconsider.
23
+
24
+ ## Editing discipline
25
+ - Match the surrounding code's style, naming, and patterns. The goal is a change that looks like it was always there.
26
+ - Make minimal, targeted edits. Do not reformat unrelated code or "drive-by" refactor.
27
+ - Never leave placeholder stubs, TODOs, or "// implement later" in place of real work unless the user asked for a scaffold.
28
+ - Do not invent APIs, functions, libraries, or file paths. If you're unsure something exists, check before relying on it.
29
+ - Don't add comments that merely restate the code. Add a comment only where intent is genuinely non-obvious.
30
+ - Prefer existing utilities and dependencies over adding new ones. Flag any new dependency before introducing it.
31
+ - Prefer the dedicated file tools — read_file, write_file, edit_file — over shelling out to cat or sed to read or edit files.
32
+ - For changes spanning multiple files or multiple hunks, prefer apply_patch — one reviewed approval, applied atomically (all-or-nothing) — over a series of edit_file calls. A single tiny edit can still use edit_file.
33
+ - To find where something is defined or used, search file contents with grep_files, not run_command with grep or find.
34
+
35
+ ## Running commands
36
+ - Explain a command's effect before running it when the effect isn't obvious.
37
+ - Be careful with anything destructive or irreversible (deleting files, force-pushing, dropping data, mass find-replace, rm -rf). ${"${APPROVAL_CLAUSE}"}
38
+ - Never run commands that exfiltrate secrets, weaken security, or touch systems outside the working tree without an explicit instruction to do so.
39
+ - Read command output. Don't claim success you haven't observed. Never describe an edit a tool didn't actually perform; if an action was denied or made no change, say so plainly.
40
+
41
+ ## Communication
42
+ - Be concise and direct. Skip preamble and filler. Lead with the result, not a recap of the request.
43
+ - When you finish, briefly state what you changed and how you verified it. Reference file paths and the commands you ran.
44
+ - Surface real uncertainty and trade-offs honestly; don't paper over them. If a request is ambiguous in a way that changes the outcome, ask one focused question rather than guessing.
45
+ - Don't claim a task is complete if it isn't. "Mostly working" is not done.
46
+
47
+ ## Boundaries
48
+ - Stay within the working directory and the task at hand.
49
+ - If a request would require destructive or out-of-scope action, surface that and confirm intent before proceeding.
50
+ - You operate on the user's machine and their code — act with the care you'd want from a careful colleague.`;
51
+ function renderEnvironment(ctx) {
52
+ const lines = [
53
+ `- Working directory: ${ctx.cwd}`,
54
+ `- Platform: ${ctx.platform}`,
55
+ `- Date: ${ctx.date}`,
56
+ `- Model: ${ctx.model}`,
57
+ ];
58
+ if (ctx.git) {
59
+ lines.push(`- Git: branch ${ctx.git.branch}${ctx.git.dirty ? " (uncommitted changes present)" : " (clean)"}`);
60
+ }
61
+ return `## Environment\n${lines.join("\n")}`;
62
+ }
63
+ function renderTools(tools) {
64
+ if (tools.length === 0) {
65
+ return "## Tools\nNo tools are available this session; respond in text only.";
66
+ }
67
+ const list = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
68
+ return `## Tools\nYou have these tools available. Use them — don't ask the user to run things you can run yourself:\n${list}`;
69
+ }
70
+ /** Assemble the full system prompt for a session. */
71
+ export function buildSystemPrompt(ctx) {
72
+ const approval = ctx.autoApprove
73
+ ? "Auto-approve is on for this session, so you may proceed without asking — but still pause and confirm before genuinely destructive or irreversible actions."
74
+ : "Confirm with the user before taking any destructive or irreversible action.";
75
+ const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
76
+ const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
77
+ if (ctx.projectInstructions?.trim()) {
78
+ 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
+ }
80
+ return sections.join("\n\n");
81
+ }
82
+ /**
83
+ * Compact reminder injected after tool results when the loop has run long, to
84
+ * keep the model anchored to the original objective and the verify step.
85
+ */
86
+ export 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.`;
87
+ /**
88
+ * System prompt for the side conversation that compacts an over-long history
89
+ * (see Session.compact). It runs as a standalone, tool-less completion over a
90
+ * rendered transcript — the goal is a synopsis dense enough that the main loop
91
+ * can continue without the verbatim prefix.
92
+ */
93
+ export 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 — omit pleasantries and restated instructions. Output only the synopsis.`;
94
+ /**
95
+ * Marker embedded in the synthetic messages that replace a compacted prefix, so
96
+ * they're recognizable in the history (and fold cleanly into a later
97
+ * re-summarization rather than being mistaken for live conversation).
98
+ */
99
+ export const COMPACTION_MARKER = "[conversation compacted]";
@@ -0,0 +1,107 @@
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 type { ToolRegistry } from "../tools/index.js";
5
+ import { type AgentResult } from "./loop.js";
6
+ export interface SessionArgs {
7
+ /** A constructed provider to stream from. */
8
+ provider: Provider;
9
+ /** The tool catalogue advertised to the model and dispatched against. */
10
+ registry: ToolRegistry;
11
+ /** Resolved CLI configuration. */
12
+ config: CruxyConfig;
13
+ /** Ambient capabilities handed to each tool. */
14
+ ctx: ToolContext;
15
+ /** Git context (branch + dirty), loaded at startup, for the system prompt. */
16
+ git?: {
17
+ branch: string;
18
+ dirty: boolean;
19
+ } | null;
20
+ /** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
21
+ projectInstructions?: string | null;
22
+ }
23
+ /**
24
+ * Estimate the token footprint of a message list with a cheap chars/4 heuristic
25
+ * — no tokenizer dependency. Good enough to decide *when* to compact; exact
26
+ * counts are deferred to a later phase. Counts only textual payload (block
27
+ * structure and role labels are negligible and ignored).
28
+ */
29
+ export declare function estimateTokens(messages: Message[]): number;
30
+ /**
31
+ * Owns the state of one multi-turn conversation: the running message history and
32
+ * the usage accumulated across turns. Each `send` continues from the prior
33
+ * history (tool_use/tool_result blocks included) rather than starting cold.
34
+ *
35
+ * This is also the home for context compaction: before each turn the running
36
+ * history is measured and, if it crosses the configured threshold, its older
37
+ * prefix is summarized away so the conversation stays within the model's window.
38
+ * The system prompt lives in `runAgent`, never in `messages`, so compaction
39
+ * cannot touch it.
40
+ */
41
+ export declare class Session {
42
+ /** The full running conversation, replaced with the extended history each turn. */
43
+ messages: Message[];
44
+ /** Token usage summed across every `send` (and every compaction) in this session. */
45
+ readonly usage: Usage;
46
+ private readonly args;
47
+ /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
48
+ private projectInstructions;
49
+ constructor(args: SessionArgs);
50
+ /**
51
+ * Run one user turn: append the prompt, compact if the history has grown past
52
+ * the threshold, drive the agent loop over the full history, adopt the
53
+ * extended history, and accumulate usage. Returns the turn's `AgentResult`.
54
+ *
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.
57
+ */
58
+ send(userPrompt: string, onText?: (delta: string) => void): Promise<AgentResult>;
59
+ /**
60
+ * Re-read project instructions (CRUXY.md / AGENTS.md) from the working
61
+ * directory so edits take effect without restarting. Returns the new text, or
62
+ * `null` if none is present. Backs the `/reload` command.
63
+ */
64
+ reloadProjectInstructions(): string | null;
65
+ /** Drop the conversation history but keep the session (for `/clear`). */
66
+ clear(): void;
67
+ /**
68
+ * Compact only when the estimated history exceeds
69
+ * `compactThreshold * maxTokens`. On success logs a one-line notice and
70
+ * returns the number of older messages folded into the summary; otherwise
71
+ * returns `null` (under threshold, nothing safe to cut, or summary failed).
72
+ */
73
+ maybeCompact(): Promise<number | null>;
74
+ /**
75
+ * Force compaction regardless of the threshold (backs `/compact`). Returns the
76
+ * number of older messages summarized, or `null` if there was nothing safe to
77
+ * cut or the summary call failed.
78
+ */
79
+ compact(): Promise<number | null>;
80
+ /**
81
+ * Find a clean cut, summarize the older prefix into a synthetic user/assistant
82
+ * pair, and splice it in front of the kept-recent messages. Best-effort: a
83
+ * failed summary call leaves the history untouched and returns `null` (fail
84
+ * open — losing compaction is degraded, not unsafe).
85
+ */
86
+ private runCompaction;
87
+ /**
88
+ * Choose the boundary between the summarized prefix and the kept-recent tail.
89
+ *
90
+ * Tool-call integrity is the constraint: a `tool_use` (assistant) and its
91
+ * matching `tool_result` (the next user message) must never straddle the cut,
92
+ * or the next provider call breaks. A real user *prompt* (`role:"user"` with
93
+ * string content) only occurs at a completed turn boundary, where every prior
94
+ * tool exchange is already resolved — so the kept region must begin there.
95
+ *
96
+ * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
97
+ * such prompt: this keeps at least the recent floor and lands clean. Returns
98
+ * the cut index, or `null` if no safe boundary leaves a non-empty prefix
99
+ * (e.g. a single long in-progress turn — nothing safe to compact).
100
+ */
101
+ private findCut;
102
+ /**
103
+ * Summarize a prefix via a standalone, tool-less provider call over a rendered
104
+ * transcript. Throws on a stream error or empty output so callers fail open.
105
+ */
106
+ private summarize;
107
+ }
@@ -0,0 +1,236 @@
1
+ import { loadProjectInstructions } from "../config/index.js";
2
+ import { runAgent } from "./loop.js";
3
+ import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
4
+ /**
5
+ * Estimate the token footprint of a message list with a cheap chars/4 heuristic
6
+ * — no tokenizer dependency. Good enough to decide *when* to compact; exact
7
+ * counts are deferred to a later phase. Counts only textual payload (block
8
+ * structure and role labels are negligible and ignored).
9
+ */
10
+ export function estimateTokens(messages) {
11
+ let chars = 0;
12
+ for (const msg of messages) {
13
+ if (typeof msg.content === "string") {
14
+ chars += msg.content.length;
15
+ continue;
16
+ }
17
+ for (const block of msg.content) {
18
+ switch (block.type) {
19
+ case "text":
20
+ chars += block.text.length;
21
+ break;
22
+ case "tool_use":
23
+ chars += block.name.length + JSON.stringify(block.input).length;
24
+ break;
25
+ case "tool_result":
26
+ chars += block.content.length;
27
+ break;
28
+ }
29
+ }
30
+ }
31
+ return Math.ceil(chars / 4);
32
+ }
33
+ /**
34
+ * Owns the state of one multi-turn conversation: the running message history and
35
+ * the usage accumulated across turns. Each `send` continues from the prior
36
+ * history (tool_use/tool_result blocks included) rather than starting cold.
37
+ *
38
+ * This is also the home for context compaction: before each turn the running
39
+ * history is measured and, if it crosses the configured threshold, its older
40
+ * prefix is summarized away so the conversation stays within the model's window.
41
+ * The system prompt lives in `runAgent`, never in `messages`, so compaction
42
+ * cannot touch it.
43
+ */
44
+ export class Session {
45
+ /** The full running conversation, replaced with the extended history each turn. */
46
+ messages = [];
47
+ /** Token usage summed across every `send` (and every compaction) in this session. */
48
+ usage = { input_tokens: 0, output_tokens: 0 };
49
+ args;
50
+ /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
51
+ projectInstructions;
52
+ constructor(args) {
53
+ this.args = args;
54
+ this.projectInstructions = args.projectInstructions ?? null;
55
+ }
56
+ /**
57
+ * Run one user turn: append the prompt, compact if the history has grown past
58
+ * the threshold, drive the agent loop over the full history, adopt the
59
+ * extended history, and accumulate usage. Returns the turn's `AgentResult`.
60
+ *
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.
63
+ */
64
+ async send(userPrompt, onText) {
65
+ this.messages.push({ role: "user", content: userPrompt });
66
+ // Compact *before* the agent call so the turn runs against a bounded history.
67
+ 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
+ });
75
+ this.messages = result.messages;
76
+ this.usage.input_tokens += result.usage.input_tokens;
77
+ this.usage.output_tokens += result.usage.output_tokens;
78
+ return result;
79
+ }
80
+ /**
81
+ * Re-read project instructions (CRUXY.md / AGENTS.md) from the working
82
+ * directory so edits take effect without restarting. Returns the new text, or
83
+ * `null` if none is present. Backs the `/reload` command.
84
+ */
85
+ reloadProjectInstructions() {
86
+ this.projectInstructions = loadProjectInstructions(this.args.ctx.cwd);
87
+ return this.projectInstructions;
88
+ }
89
+ /** Drop the conversation history but keep the session (for `/clear`). */
90
+ clear() {
91
+ this.messages = [];
92
+ }
93
+ /**
94
+ * Compact only when the estimated history exceeds
95
+ * `compactThreshold * maxTokens`. On success logs a one-line notice and
96
+ * returns the number of older messages folded into the summary; otherwise
97
+ * returns `null` (under threshold, nothing safe to cut, or summary failed).
98
+ */
99
+ async maybeCompact() {
100
+ const { maxTokens, compactThreshold } = this.args.config.context;
101
+ if (estimateTokens(this.messages) <= compactThreshold * maxTokens) {
102
+ return null;
103
+ }
104
+ const n = await this.runCompaction();
105
+ if (n) {
106
+ this.args.ctx.logger.info(`compacted ${n} older message${n === 1 ? "" : "s"} to stay within context`);
107
+ }
108
+ return n;
109
+ }
110
+ /**
111
+ * Force compaction regardless of the threshold (backs `/compact`). Returns the
112
+ * number of older messages summarized, or `null` if there was nothing safe to
113
+ * cut or the summary call failed.
114
+ */
115
+ async compact() {
116
+ return this.runCompaction();
117
+ }
118
+ /**
119
+ * Find a clean cut, summarize the older prefix into a synthetic user/assistant
120
+ * pair, and splice it in front of the kept-recent messages. Best-effort: a
121
+ * failed summary call leaves the history untouched and returns `null` (fail
122
+ * open — losing compaction is degraded, not unsafe).
123
+ */
124
+ async runCompaction() {
125
+ const cut = this.findCut();
126
+ if (cut === null)
127
+ return null;
128
+ const prefix = this.messages.slice(0, cut);
129
+ const kept = this.messages.slice(cut);
130
+ let synopsis;
131
+ try {
132
+ const summary = await this.summarize(prefix);
133
+ synopsis = summary.text;
134
+ this.usage.input_tokens += summary.usage.input_tokens;
135
+ this.usage.output_tokens += summary.usage.output_tokens;
136
+ }
137
+ catch (err) {
138
+ this.args.ctx.logger.warn(`compaction skipped: summary failed (${err.message})`);
139
+ return null;
140
+ }
141
+ // A user/assistant pair, not a lone message: the kept region begins with a
142
+ // user prompt, so a single synthetic user message would put two user turns
143
+ // back-to-back and break provider alternation.
144
+ const summaryMessages = [
145
+ {
146
+ role: "user",
147
+ content: `${COMPACTION_MARKER} The earlier conversation was compacted to conserve context; a summary follows.`,
148
+ },
149
+ {
150
+ role: "assistant",
151
+ content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
152
+ },
153
+ ];
154
+ this.messages = [...summaryMessages, ...kept];
155
+ return prefix.length;
156
+ }
157
+ /**
158
+ * Choose the boundary between the summarized prefix and the kept-recent tail.
159
+ *
160
+ * Tool-call integrity is the constraint: a `tool_use` (assistant) and its
161
+ * matching `tool_result` (the next user message) must never straddle the cut,
162
+ * or the next provider call breaks. A real user *prompt* (`role:"user"` with
163
+ * string content) only occurs at a completed turn boundary, where every prior
164
+ * tool exchange is already resolved — so the kept region must begin there.
165
+ *
166
+ * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
167
+ * such prompt: this keeps at least the recent floor and lands clean. Returns
168
+ * the cut index, or `null` if no safe boundary leaves a non-empty prefix
169
+ * (e.g. a single long in-progress turn — nothing safe to compact).
170
+ */
171
+ findCut() {
172
+ const { keepRecentMessages } = this.args.config.context;
173
+ const start = this.messages.length - keepRecentMessages;
174
+ for (let i = start; i >= 1; i--) {
175
+ const msg = this.messages[i];
176
+ if (msg.role === "user" && typeof msg.content === "string")
177
+ return i;
178
+ }
179
+ return null;
180
+ }
181
+ /**
182
+ * Summarize a prefix via a standalone, tool-less provider call over a rendered
183
+ * transcript. Throws on a stream error or empty output so callers fail open.
184
+ */
185
+ async summarize(prefix) {
186
+ const transcript = renderTranscript(prefix);
187
+ const usage = { input_tokens: 0, output_tokens: 0 };
188
+ let text = "";
189
+ for await (const ev of this.args.provider.stream({
190
+ system: SUMMARY_SYSTEM,
191
+ messages: [{ role: "user", content: transcript }],
192
+ })) {
193
+ switch (ev.type) {
194
+ case "text_delta":
195
+ text += ev.text;
196
+ break;
197
+ case "usage":
198
+ usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
199
+ usage.output_tokens += ev.usage.output_tokens;
200
+ break;
201
+ case "error":
202
+ throw ev.error;
203
+ default:
204
+ break;
205
+ }
206
+ }
207
+ if (!text.trim())
208
+ throw new Error("summary was empty");
209
+ return { text: text.trim(), usage };
210
+ }
211
+ }
212
+ /** Render a message list to a compact plain-text transcript for summarization. */
213
+ function renderTranscript(messages) {
214
+ const parts = [];
215
+ for (const msg of messages) {
216
+ const role = msg.role === "user" ? "User" : "Assistant";
217
+ parts.push(`${role}: ${renderContent(msg.content)}`);
218
+ }
219
+ return parts.join("\n\n");
220
+ }
221
+ function renderContent(content) {
222
+ if (typeof content === "string")
223
+ return content;
224
+ return content
225
+ .map((block) => {
226
+ switch (block.type) {
227
+ case "text":
228
+ return block.text;
229
+ case "tool_use":
230
+ return `[called ${block.name}(${JSON.stringify(block.input)})]`;
231
+ case "tool_result":
232
+ return `[tool result${block.is_error ? " (error)" : ""}: ${block.content}]`;
233
+ }
234
+ })
235
+ .join("\n");
236
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function configCommand(): Command;
@@ -0,0 +1,59 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
5
+ export function configCommand() {
6
+ const cmd = new Command("config").description("manage cruxy configuration");
7
+ cmd
8
+ .command("list")
9
+ .alias("ls")
10
+ .description("print the fully-resolved configuration")
11
+ .action(() => {
12
+ const { config } = loadConfig();
13
+ logger.print(JSON.stringify(config, null, 2));
14
+ });
15
+ cmd
16
+ .command("get <key>")
17
+ .description("read a single value (dot-path, e.g. model.temperature)")
18
+ .action((key) => {
19
+ const { config } = loadConfig();
20
+ const value = getPath(config, key);
21
+ if (value === undefined) {
22
+ logger.error(`no such config key: ${pc.bold(key)}`);
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ logger.print(typeof value === "object"
27
+ ? JSON.stringify(value, null, 2)
28
+ : String(value));
29
+ });
30
+ cmd
31
+ .command("set <key> <value>")
32
+ .description("write a value to the config (dot-path)")
33
+ .option("-p, --project", "write to the project config instead of global")
34
+ .action((key, value, opts) => {
35
+ const file = opts.project
36
+ ? (findProjectConfig() ?? "cruxy.config.json")
37
+ : globalConfigPath();
38
+ const written = setValue(key, value, file);
39
+ logger.print(`${pc.green("set")} ${pc.bold(key)} = ${value} ${pc.dim(`(${written})`)}`);
40
+ });
41
+ cmd
42
+ .command("path")
43
+ .description("show config file locations")
44
+ .action(() => {
45
+ const project = findProjectConfig();
46
+ logger.print(`${pc.bold("global:")} ${globalConfigPath()}`);
47
+ logger.print(`${pc.bold("project:")} ${project ?? pc.dim("(none found)")}`);
48
+ });
49
+ cmd
50
+ .command("init")
51
+ .description("write a default global config file")
52
+ .action(() => {
53
+ const { path, created } = initConfig(globalConfigPath());
54
+ logger.print(created
55
+ ? `${pc.green("created")} ${path}`
56
+ : `${pc.yellow("exists")} ${path} ${pc.dim("(left unchanged)")}`);
57
+ });
58
+ return cmd;
59
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function runCommand(): Command;
@@ -0,0 +1,85 @@
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, loadProjectInstructions, } from "../../config/index.js";
6
+ import { buildDefaultRegistry } from "../../tools/index.js";
7
+ import { Session, createApprover } from "../../agent/index.js";
8
+ import { runInteractive } from "../repl.js";
9
+ import { createStreamPrinter } from "../stream-print.js";
10
+ import { getGitInfo } from "../../utils/git.js";
11
+ export function runCommand() {
12
+ return new Command("run")
13
+ .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
14
+ .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
15
+ .option("-y, --yes", "auto-approve all tool actions (run unattended)")
16
+ .option("--dangerously-approve", "alias for --yes: approve every action without prompting")
17
+ .action(async (promptParts, opts) => {
18
+ const prompt = promptParts.join(" ").trim();
19
+ const interactive = prompt === "";
20
+ // No prompt and stdin isn't a terminal: there's no way to read input and
21
+ // nothing to do — fail fast instead of hanging on a line that never comes.
22
+ if (interactive && !process.stdin.isTTY) {
23
+ logger.error('cruxy run needs a prompt when stdin is not a terminal — try: cruxy run "<task>"');
24
+ return;
25
+ }
26
+ const { config, sources } = loadConfig();
27
+ const apiKey = resolveApiKey(config.model.provider);
28
+ logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
29
+ logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
30
+ if (!apiKey) {
31
+ logger.warn(`no API key for provider ${pc.bold(config.model.provider)} — set it in the environment before running.`);
32
+ return;
33
+ }
34
+ const provider = createProvider({
35
+ provider: config.model.provider,
36
+ apiKey,
37
+ model: config.model.model,
38
+ maxTokens: config.model.maxTokens,
39
+ temperature: config.model.temperature,
40
+ gatewayUrl: config.cruxy.gatewayUrl,
41
+ });
42
+ const registry = buildDefaultRegistry();
43
+ const approver = createApprover({
44
+ mode: config.approval.mode,
45
+ cwd: process.cwd(),
46
+ isInteractive: Boolean(process.stdin.isTTY),
47
+ autoApprove: Boolean(opts.yes || opts.dangerouslyApprove),
48
+ logger,
49
+ });
50
+ const ctx = {
51
+ cwd: process.cwd(),
52
+ config,
53
+ logger,
54
+ approve: (action) => approver.approve(action),
55
+ };
56
+ const projectInstructions = loadProjectInstructions(process.cwd());
57
+ const git = getGitInfo(process.cwd());
58
+ const session = new Session({
59
+ provider,
60
+ registry,
61
+ config,
62
+ ctx,
63
+ git,
64
+ projectInstructions,
65
+ });
66
+ if (interactive) {
67
+ await runInteractive(session);
68
+ return;
69
+ }
70
+ // One-shot: a single turn, then exit. Preserves scripting/pipe use.
71
+ // Assistant text streams to stdout delta by delta (same as the REPL),
72
+ // through a printer that trims the model's leading blank lines; the agent
73
+ // loop terminates the line.
74
+ logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
75
+ try {
76
+ const print = createStreamPrinter((text) => process.stdout.write(text));
77
+ const result = await session.send(prompt, print);
78
+ logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
79
+ `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
80
+ }
81
+ catch (err) {
82
+ logger.error(err.message);
83
+ }
84
+ });
85
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function buildProgram(): Command;
@@ -0,0 +1,36 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
4
+ import { logger } from "../utils/logger.js";
5
+ import { runCommand } from "./commands/run.js";
6
+ import { configCommand } from "./commands/config.js";
7
+ export function buildProgram() {
8
+ const program = new Command();
9
+ program
10
+ .name(APP_NAME)
11
+ .description(`cruxy-code — ${APP_DESCRIPTION}`)
12
+ .version(APP_VERSION, "-v, --version", "print the cruxy version")
13
+ .option("-c, --config <path>", "use a specific config file")
14
+ .option("--log-level <level>", "debug | info | warn | error | silent")
15
+ .option("--verbose", "shorthand for --log-level debug")
16
+ .showHelpAfterError("(run `cruxy --help` for usage)");
17
+ // Apply global options as early as possible.
18
+ program.hook("preAction", (thisCommand) => {
19
+ const opts = thisCommand.opts();
20
+ if (opts.verbose)
21
+ logger.setLevel("debug");
22
+ else if (opts.logLevel)
23
+ logger.setLevel(opts.logLevel);
24
+ });
25
+ program.addCommand(runCommand());
26
+ program.addCommand(configCommand());
27
+ // Default action: no subcommand -> interactive entrypoint (stub for now).
28
+ program.action(() => {
29
+ logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
30
+ logger.print(pc.dim("an agentic coding CLI\n"));
31
+ logger.print("The interactive REPL lands in C.3 (terminal UI).");
32
+ logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
33
+ logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
34
+ });
35
+ return program;
36
+ }
@@ -0,0 +1,15 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import type { Session } from "../agent/index.js";
3
+ /** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
4
+ export interface ReplIO {
5
+ input: Readable;
6
+ output: Writable;
7
+ }
8
+ /**
9
+ * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
10
+ * commands or run a turn, repeat. Assistant text streams to stdout from within
11
+ * `session.send` (via the logger); this loop only owns input and control.
12
+ *
13
+ * `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
14
+ */
15
+ export declare function runInteractive(session: Session, io?: ReplIO): Promise<void>;