@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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/agent/approval.d.ts +41 -0
- package/dist/agent/approval.js +179 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +4 -0
- package/dist/agent/loop.d.ts +53 -0
- package/dist/agent/loop.js +148 -0
- package/dist/agent/prompts.d.ts +53 -0
- package/dist/agent/prompts.js +99 -0
- package/dist/agent/session.d.ts +107 -0
- package/dist/agent/session.js +236 -0
- package/dist/cli/commands/config.d.ts +2 -0
- package/dist/cli/commands/config.js +59 -0
- package/dist/cli/commands/run.d.ts +2 -0
- package/dist/cli/commands/run.js +85 -0
- package/dist/cli/program.d.ts +2 -0
- package/dist/cli/program.js +36 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.js +114 -0
- package/dist/cli/stream-print.d.ts +14 -0
- package/dist/cli/stream-print.js +26 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +4 -0
- package/dist/config/manager.d.ts +34 -0
- package/dist/config/manager.js +151 -0
- package/dist/config/paths.d.ts +9 -0
- package/dist/config/paths.js +31 -0
- package/dist/config/project.d.ts +10 -0
- package/dist/config/project.js +36 -0
- package/dist/config/schema.d.ts +303 -0
- package/dist/config/schema.js +100 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/tools/file/apply-patch.d.ts +94 -0
- package/dist/tools/file/apply-patch.js +195 -0
- package/dist/tools/file/edit-file.d.ts +14 -0
- package/dist/tools/file/edit-file.js +81 -0
- package/dist/tools/file/glob.d.ts +10 -0
- package/dist/tools/file/glob.js +52 -0
- package/dist/tools/file/grep-files.d.ts +32 -0
- package/dist/tools/file/grep-files.js +113 -0
- package/dist/tools/file/index.d.ts +7 -0
- package/dist/tools/file/index.js +7 -0
- package/dist/tools/file/paths.d.ts +24 -0
- package/dist/tools/file/paths.js +65 -0
- package/dist/tools/file/read-file.d.ts +8 -0
- package/dist/tools/file/read-file.js +52 -0
- package/dist/tools/file/write-file.d.ts +10 -0
- package/dist/tools/file/write-file.js +56 -0
- package/dist/tools/git-status.d.ts +8 -0
- package/dist/tools/git-status.js +26 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list-files.d.ts +7 -0
- package/dist/tools/list-files.js +27 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +63 -0
- package/dist/tools/shell/index.d.ts +1 -0
- package/dist/tools/shell/index.js +1 -0
- package/dist/tools/shell/run-command.d.ts +10 -0
- package/dist/tools/shell/run-command.js +100 -0
- package/dist/tools/types.d.ts +113 -0
- package/dist/tools/types.js +1 -0
- package/dist/utils/git.d.ts +17 -0
- package/dist/utils/git.js +43 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +42 -0
- 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,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]";
|