bravecode-cli 0.1.8 → 0.1.10
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/dist/cli.mjs +2073 -39919
- package/dist/cli.mjs.map +4 -4
- package/package.json +2 -2
- package/scripts/build.js +19 -3
- package/scripts/smoke-tool-calling.ts +181 -0
- package/scripts/test-snapshot.ts +99 -0
- package/dist/cli.cjs +0 -41849
- package/dist/cli.cjs.map +0 -7
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bravecode-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Agentic AI vibe coding CLI with free model support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"bravecode": "./dist/cli.
|
|
8
|
+
"bravecode": "./dist/cli.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/**/*",
|
package/scripts/build.js
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { build } from "esbuild";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
|
|
4
|
+
// Keep the user-facing version in src/version.ts in sync with package.json.
|
|
5
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
|
6
|
+
const versionTs = readFileSync("src/version.ts", "utf-8");
|
|
7
|
+
const declared = versionTs.match(/APP_VERSION\s*=\s*"([^"]+)"/)?.[1];
|
|
8
|
+
if (declared !== pkg.version) {
|
|
9
|
+
console.error(
|
|
10
|
+
`Version mismatch: package.json is ${pkg.version} but src/version.ts declares ${declared}. Update src/version.ts.`
|
|
11
|
+
);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
await build({
|
|
4
16
|
entryPoints: ["src/cli.ts"],
|
|
5
17
|
bundle: true,
|
|
6
18
|
platform: "node",
|
|
7
19
|
target: "node18",
|
|
8
|
-
outfile: "dist/cli.
|
|
9
|
-
format: "
|
|
20
|
+
outfile: "dist/cli.mjs",
|
|
21
|
+
format: "esm",
|
|
10
22
|
banner: {
|
|
11
|
-
js:
|
|
23
|
+
js: [
|
|
24
|
+
"#!/usr/bin/env node",
|
|
25
|
+
"import { createRequire as __braveCreateRequire } from 'module';",
|
|
26
|
+
"const require = __braveCreateRequire(import.meta.url);",
|
|
27
|
+
].join("\n"),
|
|
12
28
|
},
|
|
13
29
|
sourcemap: true,
|
|
14
30
|
minify: false,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end smoke test for tool-calling wiring.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a local HTTP server that mimics the Codero endpoint's wire format
|
|
5
|
+
* (JSON lines of {"delta": "..."} / {"done": true}) and drives the full agent
|
|
6
|
+
* loop against it via BRAVECODE_BASE_URL. No external network calls are made.
|
|
7
|
+
*
|
|
8
|
+
* Run with: npx tsx scripts/smoke-tool-calling.ts
|
|
9
|
+
*/
|
|
10
|
+
import { createServer, type Server } from "http";
|
|
11
|
+
import assert from "assert";
|
|
12
|
+
import { LLMProviderManager } from "../src/provider";
|
|
13
|
+
import { ToolRegistry } from "../src/core/tool";
|
|
14
|
+
import { Agent, defaultAgents } from "../src/core/agent";
|
|
15
|
+
|
|
16
|
+
type Frag = string; // one JSON line of the mock stream
|
|
17
|
+
|
|
18
|
+
interface MockTurn {
|
|
19
|
+
frags: (parsedRequest: any) => Frag[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function delta(text: string): Frag {
|
|
23
|
+
return JSON.stringify({ delta: text });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function done(): Frag {
|
|
27
|
+
return JSON.stringify({ done: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const requests: any[] = [];
|
|
31
|
+
|
|
32
|
+
// Scenario: turn 1 emits a tool call split across stream chunks (tests the
|
|
33
|
+
// partial-tag buffering); turn 2 answers using the tool result; turn 3 is a
|
|
34
|
+
// plain-text reply (no tool call); turn 4 emits an unparseable tool_call block
|
|
35
|
+
// (must pass through as text, not crash).
|
|
36
|
+
const turns: MockTurn[] = [
|
|
37
|
+
{
|
|
38
|
+
frags: () => [
|
|
39
|
+
delta("Let me run that command.\n"),
|
|
40
|
+
delta("<tool"),
|
|
41
|
+
delta(
|
|
42
|
+
`_call>\n{"name": "bash", "arguments": {"command": "echo wiring-ok"}}\n</tool_call>`
|
|
43
|
+
),
|
|
44
|
+
delta("\n"),
|
|
45
|
+
done(),
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
frags: (req) => {
|
|
50
|
+
const last = req.messages[req.messages.length - 1];
|
|
51
|
+
assert.strictEqual(last.role, "user", "tool result must arrive as a user message");
|
|
52
|
+
assert.ok(
|
|
53
|
+
last.content.includes("[Tool result for bash]") && last.content.includes("wiring-ok"),
|
|
54
|
+
`tool result not fed back correctly, got: ${JSON.stringify(last.content)}`
|
|
55
|
+
);
|
|
56
|
+
const assistantCall = (req.messages as any[]).find(
|
|
57
|
+
(m) => m.role === "assistant" && m.content.includes("<tool_call>")
|
|
58
|
+
);
|
|
59
|
+
assert.ok(
|
|
60
|
+
assistantCall && assistantCall.content.includes('"name":"bash"'),
|
|
61
|
+
"assistant tool call must be preserved in conversation history"
|
|
62
|
+
);
|
|
63
|
+
return [delta("The command output was: wiring-ok"), done()];
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
frags: () => [delta("hi there"), done()],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
frags: () => [delta("Attempting: <tool_call>\n{not valid json}\n</tool_call>"), done()],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
// Parent delegates to the plan subagent via the task tool.
|
|
74
|
+
frags: () => [
|
|
75
|
+
delta(
|
|
76
|
+
`<tool_call>\n{"name": "task", "arguments": {"agentId": "plan", "prompt": "analyze the code", "description": "architecture review"}}\n</tool_call>`
|
|
77
|
+
),
|
|
78
|
+
done(),
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
// Subagent (non-streaming path) answers without tools.
|
|
83
|
+
frags: () => [delta("plan says: looks good"), done()],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
// Parent incorporates the subagent result.
|
|
87
|
+
frags: (req) => {
|
|
88
|
+
const last = req.messages[req.messages.length - 1];
|
|
89
|
+
assert.ok(
|
|
90
|
+
last.role === "user" && last.content.includes("[Tool result for task]") && last.content.includes("plan says: looks good"),
|
|
91
|
+
`subagent result not fed back to parent, got: ${JSON.stringify(last.content)}`
|
|
92
|
+
);
|
|
93
|
+
return [delta("Delegation complete: looks good"), done()];
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const server: Server = createServer((req, res) => {
|
|
99
|
+
let body = "";
|
|
100
|
+
req.on("data", (c) => (body += c));
|
|
101
|
+
req.on("end", () => {
|
|
102
|
+
const parsed = JSON.parse(body);
|
|
103
|
+
const turn = turns[requests.length];
|
|
104
|
+
requests.push(parsed);
|
|
105
|
+
res.setHeader("Content-Type", "application/x-ndjson");
|
|
106
|
+
if (!turn) {
|
|
107
|
+
res.write(JSON.stringify({ delta: "unexpected extra request" }) + "\n");
|
|
108
|
+
res.write(done() + "\n");
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const frag of turn.frags(parsed)) {
|
|
113
|
+
res.write(frag + "\n");
|
|
114
|
+
}
|
|
115
|
+
res.end();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function main() {
|
|
120
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
121
|
+
const port = (server.address() as any).port;
|
|
122
|
+
process.env.BRAVECODE_BASE_URL = `http://127.0.0.1:${port}/api/chat`;
|
|
123
|
+
|
|
124
|
+
const provider = new LLMProviderManager();
|
|
125
|
+
const tools = new ToolRegistry();
|
|
126
|
+
|
|
127
|
+
// --- Scenario 1: tool call -> execute -> result fed back -> final answer ---
|
|
128
|
+
const agent = new Agent(provider, tools, defaultAgents[0]);
|
|
129
|
+
let streamed = "";
|
|
130
|
+
const final = await agent.run("Run the echo command", (c) => (streamed += c));
|
|
131
|
+
|
|
132
|
+
assert.strictEqual(requests.length, 2, `expected 2 requests, got ${requests.length}`);
|
|
133
|
+
const req1 = requests[0];
|
|
134
|
+
assert.ok(
|
|
135
|
+
req1.system.includes("<tool_call>") && req1.system.includes("bash:"),
|
|
136
|
+
"system prompt must contain the tool protocol instructions and tool list"
|
|
137
|
+
);
|
|
138
|
+
assert.ok(
|
|
139
|
+
req1.messages.every((m: any) => ["user", "assistant"].includes(m.role)),
|
|
140
|
+
"codero payload must only contain user/assistant roles"
|
|
141
|
+
);
|
|
142
|
+
assert.ok(
|
|
143
|
+
streamed.includes("[bash result]: wiring-ok"),
|
|
144
|
+
"streamed output must include the bash tool result"
|
|
145
|
+
);
|
|
146
|
+
assert.ok(
|
|
147
|
+
final.includes("wiring-ok"),
|
|
148
|
+
`final answer should reference tool output, got: ${final}`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// --- Scenario 2: plain text response, no tool call -> single-turn return ---
|
|
152
|
+
const final2 = await agent.run("just say hi");
|
|
153
|
+
assert.strictEqual(requests.length, 3, "plain text reply must not loop");
|
|
154
|
+
assert.strictEqual(final2, "hi there");
|
|
155
|
+
|
|
156
|
+
// --- Scenario 3: malformed tool_call block passes through as text ---
|
|
157
|
+
const final3 = await agent.run("do the broken thing");
|
|
158
|
+
assert.strictEqual(requests.length, 4);
|
|
159
|
+
assert.ok(
|
|
160
|
+
final3.includes("{not valid json}"),
|
|
161
|
+
"unparseable tool_call block must pass through as text"
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// --- Scenario 4: task tool delegates to a subagent and returns its result ---
|
|
165
|
+
const final4 = await agent.run("delegate an architecture review to the plan agent");
|
|
166
|
+
assert.strictEqual(requests.length, 7, `expected 7 requests (3 agent turns + subagent), got ${requests.length}`);
|
|
167
|
+
assert.ok(
|
|
168
|
+
final4.includes("looks good"),
|
|
169
|
+
`parent should incorporate subagent result, got: ${final4}`
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
console.log("\nAll tool-calling smoke tests passed.");
|
|
173
|
+
server.close();
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((err) => {
|
|
178
|
+
console.error("\nSMOKE TEST FAILED:", err.message);
|
|
179
|
+
server.close();
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for SnapshotManager: targeted staging and safe revert.
|
|
3
|
+
* Each test runs against a throwaway git repo in a temp directory.
|
|
4
|
+
*
|
|
5
|
+
* Run with: npx tsx scripts/test-snapshot.ts
|
|
6
|
+
*/
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { execFileSync } from "child_process";
|
|
11
|
+
import assert from "assert";
|
|
12
|
+
import { SnapshotManager } from "../src/core/snapshot";
|
|
13
|
+
|
|
14
|
+
function git(dir: string, args: string[]): string {
|
|
15
|
+
return execFileSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeRepo(): string {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "bravecode-snap-test-"));
|
|
20
|
+
git(dir, ["init", "-q"]);
|
|
21
|
+
git(dir, ["config", "user.email", "test@test.local"]);
|
|
22
|
+
git(dir, ["config", "user.name", "Test"]);
|
|
23
|
+
writeFileSync(join(dir, "a.txt"), "user v1\n");
|
|
24
|
+
git(dir, ["add", "-A"]);
|
|
25
|
+
git(dir, ["commit", "-q", "-m", "user init"]);
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function status(dir: string): string {
|
|
30
|
+
return git(dir, ["status", "--porcelain"]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
let dir = makeRepo();
|
|
35
|
+
try {
|
|
36
|
+
// --- Test 1: snapshot stages only the named file ---
|
|
37
|
+
const sm1 = new SnapshotManager(dir);
|
|
38
|
+
writeFileSync(join(dir, "b.txt"), "agent draft v1\n"); // agent's new file
|
|
39
|
+
writeFileSync(join(dir, "a.txt"), "user v2 uncommitted\n"); // user's concurrent edit
|
|
40
|
+
|
|
41
|
+
const snap = await sm1.createSnapshot("before edit", [join(dir, "b.txt")]);
|
|
42
|
+
|
|
43
|
+
assert.ok(snap.commitHash !== "no-changes" && !snap.commitHash.startsWith("fallback-"), "snapshot should create a commit");
|
|
44
|
+
const committed = git(dir, ["show", "--name-only", "--format=", "HEAD"]);
|
|
45
|
+
assert.ok(committed.includes("b.txt"), "snapshot commit should include b.txt");
|
|
46
|
+
assert.ok(!committed.includes("a.txt"), "snapshot commit must NOT include a.txt");
|
|
47
|
+
assert.ok(status(dir).includes("M a.txt"), "user's uncommitted a.txt change must remain uncommitted");
|
|
48
|
+
assert.deepStrictEqual(snap.files, ["b.txt"], "snapshot should record the changed file");
|
|
49
|
+
console.log("Test 1 passed: targeted staging commits only the named file");
|
|
50
|
+
|
|
51
|
+
// --- Test 2: revert restores the captured file state, touches nothing else ---
|
|
52
|
+
writeFileSync(join(dir, "b.txt"), "agent draft v2\n"); // agent's post-snapshot change
|
|
53
|
+
|
|
54
|
+
const result = await sm1.revert();
|
|
55
|
+
assert.ok(result.success, `revert should succeed: ${result.message}`);
|
|
56
|
+
assert.strictEqual(readFileSync(join(dir, "b.txt"), "utf-8"), "agent draft v1\n", "b.txt restored to captured state");
|
|
57
|
+
assert.strictEqual(readFileSync(join(dir, "a.txt"), "utf-8"), "user v2 uncommitted\n", "user's a.txt change untouched");
|
|
58
|
+
assert.ok(status(dir).includes("M a.txt"), "user's a.txt change still uncommitted after revert");
|
|
59
|
+
const logAfter = git(dir, ["log", "--oneline"]);
|
|
60
|
+
assert.ok(logAfter.includes("user init"), "history preserved (no reset)");
|
|
61
|
+
console.log("Test 2 passed: revert restores only the snapshot's file");
|
|
62
|
+
|
|
63
|
+
// --- Test 3: revert refuses when the user has committed on top ---
|
|
64
|
+
const sm2 = new SnapshotManager(dir);
|
|
65
|
+
writeFileSync(join(dir, "c.txt"), "agent new file\n");
|
|
66
|
+
await sm2.createSnapshot("before write", [join(dir, "c.txt")]);
|
|
67
|
+
git(dir, ["add", "-A"]);
|
|
68
|
+
git(dir, ["commit", "-q", "-m", "user follow-up commit"]); // HEAD moves past snapshot
|
|
69
|
+
|
|
70
|
+
const result3 = await sm2.revert();
|
|
71
|
+
assert.ok(!result3.success, "revert must refuse when HEAD moved past the snapshot");
|
|
72
|
+
assert.ok(result3.message.includes("Cannot revert"), `unexpected message: ${result3.message}`);
|
|
73
|
+
assert.strictEqual(git(dir, ["rev-parse", "HEAD"]), git(dir, ["rev-parse", "HEAD"]), "HEAD unchanged");
|
|
74
|
+
console.log("Test 3 passed: revert refuses when HEAD has moved");
|
|
75
|
+
|
|
76
|
+
// --- Test 4: snapshot of a nonexistent file is a no-op, not a crash ---
|
|
77
|
+
const sm3 = new SnapshotManager(dir);
|
|
78
|
+
const snap4 = await sm3.createSnapshot("before write", [join(dir, "does-not-exist.txt")]);
|
|
79
|
+
assert.strictEqual(snap4.commitHash, "no-changes", "missing file should yield no-changes snapshot");
|
|
80
|
+
console.log("Test 4 passed: nonexistent target file handled gracefully");
|
|
81
|
+
|
|
82
|
+
// --- Test 5: revert with no snapshots reports failure ---
|
|
83
|
+
dir = makeRepo();
|
|
84
|
+
const sm4 = new SnapshotManager(dir);
|
|
85
|
+
const result5 = await sm4.revert();
|
|
86
|
+
assert.ok(!result5.success, "revert without snapshots should fail gracefully");
|
|
87
|
+
console.log("Test 5 passed: revert without snapshots fails gracefully");
|
|
88
|
+
|
|
89
|
+
console.log("\nAll snapshot tests passed.");
|
|
90
|
+
process.exit(0);
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
console.error("\nSNAPSHOT TEST FAILED:", err.message);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
main();
|