croo-cli 0.0.0-stage → 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 +107 -2
- package/dist/cli.js +1694 -0
- package/package.json +50 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1694 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
|
|
6
|
+
// package.json
|
|
7
|
+
var package_default = {
|
|
8
|
+
name: "croo-cli",
|
|
9
|
+
version: "0.1.0",
|
|
10
|
+
description: "Croo: a quick, minimal, open-source terminal AI coding agent. Bring your own API key.",
|
|
11
|
+
type: "module",
|
|
12
|
+
bin: {
|
|
13
|
+
croo: "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
files: [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
engines: {
|
|
19
|
+
node: ">=20"
|
|
20
|
+
},
|
|
21
|
+
scripts: {
|
|
22
|
+
build: "tsup",
|
|
23
|
+
dev: "tsup --watch",
|
|
24
|
+
test: "vitest run",
|
|
25
|
+
typecheck: "tsc --noEmit",
|
|
26
|
+
prepublishOnly: "npm run build"
|
|
27
|
+
},
|
|
28
|
+
keywords: [
|
|
29
|
+
"ai",
|
|
30
|
+
"agent",
|
|
31
|
+
"cli",
|
|
32
|
+
"coding",
|
|
33
|
+
"llm",
|
|
34
|
+
"openai",
|
|
35
|
+
"anthropic",
|
|
36
|
+
"gemini",
|
|
37
|
+
"ollama"
|
|
38
|
+
],
|
|
39
|
+
repository: {
|
|
40
|
+
type: "git",
|
|
41
|
+
url: "git+https://github.com/xandertaylorx/croo.git"
|
|
42
|
+
},
|
|
43
|
+
bugs: {
|
|
44
|
+
url: "https://github.com/xandertaylorx/croo/issues"
|
|
45
|
+
},
|
|
46
|
+
homepage: "https://github.com/xandertaylorx/croo#readme",
|
|
47
|
+
license: "MIT",
|
|
48
|
+
author: "Xander Taylor",
|
|
49
|
+
devDependencies: {
|
|
50
|
+
"@types/node": "^22.20.5",
|
|
51
|
+
tsup: "^9.0.0",
|
|
52
|
+
typescript: "^7.0.2",
|
|
53
|
+
vitest: "^5.0.5"
|
|
54
|
+
},
|
|
55
|
+
publishConfig: {
|
|
56
|
+
access: "public"
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/args.ts
|
|
61
|
+
function parseArgs(argv) {
|
|
62
|
+
const a = { prompt: "", yolo: false, noPet: false, continue: false, help: false, version: false };
|
|
63
|
+
const rest = [];
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const x = argv[i];
|
|
66
|
+
if (x === "--profile" || x === "-p") a.profile = argv[++i];
|
|
67
|
+
else if (x.startsWith("--profile=")) a.profile = x.slice("--profile=".length);
|
|
68
|
+
else if (x === "--yolo") a.yolo = true;
|
|
69
|
+
else if (x === "--no-pet") a.noPet = true;
|
|
70
|
+
else if (x === "--continue" || x === "-c") a.continue = true;
|
|
71
|
+
else if (x === "--help" || x === "-h") a.help = true;
|
|
72
|
+
else if (x === "--version" || x === "-v") a.version = true;
|
|
73
|
+
else if (x.startsWith("-") && x !== "-") throw new Error(`Unknown option: ${x}`);
|
|
74
|
+
else rest.push(x);
|
|
75
|
+
}
|
|
76
|
+
a.prompt = rest.join(" ").trim();
|
|
77
|
+
return a;
|
|
78
|
+
}
|
|
79
|
+
var HELP = `Usage:
|
|
80
|
+
croo start an interactive session in the current directory
|
|
81
|
+
croo "prompt" run a one-shot task and exit
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
-p, --profile <name> use a named profile from the config
|
|
85
|
+
-c, --continue resume the last session in this directory
|
|
86
|
+
--yolo do not ask before writing files or running commands
|
|
87
|
+
--no-pet hide the frog
|
|
88
|
+
-v, --version print version
|
|
89
|
+
-h, --help show this help
|
|
90
|
+
|
|
91
|
+
Config: ~/.config/croo/config.json`;
|
|
92
|
+
|
|
93
|
+
// src/tools/index.ts
|
|
94
|
+
import fs2 from "fs";
|
|
95
|
+
import path2 from "path";
|
|
96
|
+
import { spawn } from "child_process";
|
|
97
|
+
|
|
98
|
+
// src/tools/edit.ts
|
|
99
|
+
function applyEdit(content, oldStr, newStr, replaceAll = false) {
|
|
100
|
+
if (oldStr === "") throw new Error("old_string must not be empty (use write_file to create files).");
|
|
101
|
+
if (oldStr === newStr) throw new Error("old_string and new_string are identical.");
|
|
102
|
+
const first = content.indexOf(oldStr);
|
|
103
|
+
if (first === -1) throw new Error("old_string was not found in the file. It must match exactly, including whitespace.");
|
|
104
|
+
if (replaceAll) return content.split(oldStr).join(newStr);
|
|
105
|
+
if (content.indexOf(oldStr, first + oldStr.length) !== -1) {
|
|
106
|
+
throw new Error("old_string matches more than once. Add surrounding context to make it unique, or set replace_all.");
|
|
107
|
+
}
|
|
108
|
+
return content.slice(0, first) + newStr + content.slice(first + oldStr.length);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/tools/ignore.ts
|
|
112
|
+
import fs from "fs";
|
|
113
|
+
import path from "path";
|
|
114
|
+
function globToRegexSource(glob) {
|
|
115
|
+
let out2 = "";
|
|
116
|
+
for (let i = 0; i < glob.length; i++) {
|
|
117
|
+
const c = glob[i];
|
|
118
|
+
if (c === "*") {
|
|
119
|
+
if (glob[i + 1] === "*") {
|
|
120
|
+
if (glob[i + 2] === "/") {
|
|
121
|
+
out2 += "(?:.*/)?";
|
|
122
|
+
i += 2;
|
|
123
|
+
} else {
|
|
124
|
+
out2 += ".*";
|
|
125
|
+
i += 1;
|
|
126
|
+
}
|
|
127
|
+
} else out2 += "[^/]*";
|
|
128
|
+
} else if (c === "?") out2 += "[^/]";
|
|
129
|
+
else if (c === "{") {
|
|
130
|
+
const end = glob.indexOf("}", i);
|
|
131
|
+
if (end > i) {
|
|
132
|
+
out2 += "(?:" + glob.slice(i + 1, end).split(",").map(globToRegexSource).join("|") + ")";
|
|
133
|
+
i = end;
|
|
134
|
+
} else out2 += "\\{";
|
|
135
|
+
} else out2 += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
136
|
+
}
|
|
137
|
+
return out2;
|
|
138
|
+
}
|
|
139
|
+
function globMatcher(pattern) {
|
|
140
|
+
const re = new RegExp("^" + (pattern.includes("/") ? "" : "(?:.*/)?") + globToRegexSource(pattern.replace(/^\.\//, "")) + "$");
|
|
141
|
+
return (rel) => re.test(rel);
|
|
142
|
+
}
|
|
143
|
+
function parseIgnore(text) {
|
|
144
|
+
const rules = [];
|
|
145
|
+
for (let line of text.split("\n")) {
|
|
146
|
+
line = line.replace(/\r$/, "").replace(/(?<!\\)\s+$/, "");
|
|
147
|
+
if (!line || line.startsWith("#")) continue;
|
|
148
|
+
const negate = line.startsWith("!");
|
|
149
|
+
if (negate) line = line.slice(1);
|
|
150
|
+
const dirOnly = line.endsWith("/");
|
|
151
|
+
if (dirOnly) line = line.slice(0, -1);
|
|
152
|
+
const anchored = line.startsWith("/") || line.includes("/");
|
|
153
|
+
line = line.replace(/^\//, "");
|
|
154
|
+
const re = new RegExp("^" + (anchored ? "" : "(?:.*/)?") + globToRegexSource(line) + "(?:/.*)?$");
|
|
155
|
+
rules.push({ re, negate, dirOnly });
|
|
156
|
+
}
|
|
157
|
+
return rules;
|
|
158
|
+
}
|
|
159
|
+
var Ignore = class {
|
|
160
|
+
layers = [];
|
|
161
|
+
addDir(absDir, root) {
|
|
162
|
+
try {
|
|
163
|
+
const text = fs.readFileSync(path.join(absDir, ".gitignore"), "utf8");
|
|
164
|
+
this.layers.push({ base: path.relative(root, absDir).split(path.sep).join("/"), rules: parseIgnore(text) });
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** rel: posix path relative to root. */
|
|
169
|
+
ignored(rel, isDir) {
|
|
170
|
+
let result = false;
|
|
171
|
+
for (const { base, rules } of this.layers) {
|
|
172
|
+
if (base && !rel.startsWith(base + "/")) continue;
|
|
173
|
+
const sub = base ? rel.slice(base.length + 1) : rel;
|
|
174
|
+
for (const r of rules) {
|
|
175
|
+
if (r.dirOnly && !isDir && !r.re.test(sub.split("/").slice(0, -1).join("/"))) continue;
|
|
176
|
+
if (r.re.test(sub)) result = !r.negate;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
var ALWAYS_SKIP = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
183
|
+
function* walk(root, dir, ig = new Ignore(), limit = { n: 2e4 }) {
|
|
184
|
+
ig.addDir(dir, root);
|
|
185
|
+
let entries;
|
|
186
|
+
try {
|
|
187
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
if (limit.n <= 0) return;
|
|
194
|
+
if (ALWAYS_SKIP.has(e.name)) continue;
|
|
195
|
+
const abs = path.join(dir, e.name);
|
|
196
|
+
const rel = path.relative(root, abs).split(path.sep).join("/");
|
|
197
|
+
if (e.isDirectory()) {
|
|
198
|
+
if (ig.ignored(rel, true)) continue;
|
|
199
|
+
yield* walk(root, abs, ig, limit);
|
|
200
|
+
} else if (e.isFile()) {
|
|
201
|
+
if (ig.ignored(rel, false)) continue;
|
|
202
|
+
limit.n--;
|
|
203
|
+
yield rel;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/tools/index.ts
|
|
209
|
+
var MAX_OUTPUT = 3e4;
|
|
210
|
+
var MAX_FILE_BYTES = 1e6;
|
|
211
|
+
function truncate(s, max = MAX_OUTPUT) {
|
|
212
|
+
if (s.length <= max) return s;
|
|
213
|
+
const half = Math.floor(max / 2);
|
|
214
|
+
return `${s.slice(0, half)}
|
|
215
|
+
\u2026 [${s.length - max} characters truncated] \u2026
|
|
216
|
+
${s.slice(-half)}`;
|
|
217
|
+
}
|
|
218
|
+
function safePath(cwd2, p) {
|
|
219
|
+
if (typeof p !== "string" || !p) throw new Error("path is required.");
|
|
220
|
+
const root = fs2.realpathSync(cwd2);
|
|
221
|
+
const abs = path2.resolve(root, p);
|
|
222
|
+
let probe = abs;
|
|
223
|
+
while (!fs2.existsSync(probe)) {
|
|
224
|
+
const parent = path2.dirname(probe);
|
|
225
|
+
if (parent === probe) break;
|
|
226
|
+
probe = parent;
|
|
227
|
+
}
|
|
228
|
+
const real = path2.join(fs2.realpathSync(probe), path2.relative(probe, abs));
|
|
229
|
+
const rel = path2.relative(root, real);
|
|
230
|
+
if (rel === ".." || rel.startsWith(".." + path2.sep) || path2.isAbsolute(rel)) {
|
|
231
|
+
throw new Error(`Path "${p}" is outside the working directory.`);
|
|
232
|
+
}
|
|
233
|
+
return abs;
|
|
234
|
+
}
|
|
235
|
+
var relPath = (cwd2, abs) => path2.relative(fs2.realpathSync(cwd2), abs) || ".";
|
|
236
|
+
function readText(abs) {
|
|
237
|
+
const stat = fs2.statSync(abs);
|
|
238
|
+
if (!stat.isFile()) throw new Error("Not a file.");
|
|
239
|
+
if (stat.size > MAX_FILE_BYTES * 5) throw new Error("File too large.");
|
|
240
|
+
const buf = fs2.readFileSync(abs);
|
|
241
|
+
if (buf.subarray(0, 8e3).includes(0)) throw new Error("Binary file.");
|
|
242
|
+
return buf.toString("utf8");
|
|
243
|
+
}
|
|
244
|
+
var str = (type = "string", description = "") => ({ type, description });
|
|
245
|
+
var readFile = {
|
|
246
|
+
spec: {
|
|
247
|
+
name: "read_file",
|
|
248
|
+
description: "Read a text file. Returns numbered lines. Use offset/limit for large files.",
|
|
249
|
+
parameters: {
|
|
250
|
+
type: "object",
|
|
251
|
+
properties: { path: str("string", "File path relative to the working directory"), offset: str("integer", "1-based start line"), limit: str("integer", "Max lines (default 2000)") },
|
|
252
|
+
required: ["path"]
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
summarize: (a) => a.path ?? "",
|
|
256
|
+
async run(a, ctx) {
|
|
257
|
+
const text = readText(safePath(ctx.cwd, a.path));
|
|
258
|
+
const lines = text.split("\n");
|
|
259
|
+
const start = Math.max(1, Number(a.offset) || 1);
|
|
260
|
+
const limit = Math.max(1, Number(a.limit) || 2e3);
|
|
261
|
+
const slice = lines.slice(start - 1, start - 1 + limit);
|
|
262
|
+
const out2 = slice.map((l, i) => `${String(start + i).padStart(5)} ${l}`).join("\n");
|
|
263
|
+
const more = start - 1 + limit < lines.length ? `
|
|
264
|
+
\u2026 ${lines.length - (start - 1 + limit)} more lines` : "";
|
|
265
|
+
return truncate(out2 + more) || "(empty file)";
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
var writeFile = {
|
|
269
|
+
spec: {
|
|
270
|
+
name: "write_file",
|
|
271
|
+
description: "Create or overwrite a file with the given content. Parent directories are created.",
|
|
272
|
+
parameters: { type: "object", properties: { path: str("string", "File path"), content: str("string", "Full file content") }, required: ["path", "content"] }
|
|
273
|
+
},
|
|
274
|
+
summarize: (a) => a.path ?? "",
|
|
275
|
+
prepare(a, ctx) {
|
|
276
|
+
const abs = safePath(ctx.cwd, a.path);
|
|
277
|
+
if (typeof a.content !== "string") throw new Error("content is required.");
|
|
278
|
+
const before = fs2.existsSync(abs) ? readText(abs) : "";
|
|
279
|
+
return { kind: "diff", path: relPath(ctx.cwd, abs), before, after: a.content };
|
|
280
|
+
},
|
|
281
|
+
async run(a, ctx) {
|
|
282
|
+
const abs = safePath(ctx.cwd, a.path);
|
|
283
|
+
const existed = fs2.existsSync(abs);
|
|
284
|
+
fs2.mkdirSync(path2.dirname(abs), { recursive: true });
|
|
285
|
+
fs2.writeFileSync(abs, a.content);
|
|
286
|
+
return `${existed ? "Overwrote" : "Created"} ${relPath(ctx.cwd, abs)} (${a.content.split("\n").length} lines)`;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
var editFile = {
|
|
290
|
+
spec: {
|
|
291
|
+
name: "edit_file",
|
|
292
|
+
description: "Replace an exact string in a file. old_string must match exactly once unless replace_all is true.",
|
|
293
|
+
parameters: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: { path: str(), old_string: str(), new_string: str(), replace_all: str("boolean", "Replace every occurrence") },
|
|
296
|
+
required: ["path", "old_string", "new_string"]
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
summarize: (a) => a.path ?? "",
|
|
300
|
+
prepare(a, ctx) {
|
|
301
|
+
const abs = safePath(ctx.cwd, a.path);
|
|
302
|
+
const before = readText(abs);
|
|
303
|
+
const after = applyEdit(before, String(a.old_string ?? ""), String(a.new_string ?? ""), !!a.replace_all);
|
|
304
|
+
return { kind: "diff", path: relPath(ctx.cwd, abs), before, after };
|
|
305
|
+
},
|
|
306
|
+
async run(a, ctx) {
|
|
307
|
+
const abs = safePath(ctx.cwd, a.path);
|
|
308
|
+
fs2.writeFileSync(abs, applyEdit(readText(abs), a.old_string, a.new_string, !!a.replace_all));
|
|
309
|
+
return `Edited ${relPath(ctx.cwd, abs)}`;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
var listFiles = {
|
|
313
|
+
spec: {
|
|
314
|
+
name: "list_files",
|
|
315
|
+
description: 'List files (respects .gitignore). Optional glob pattern like "src/**/*.ts" or "*.md".',
|
|
316
|
+
parameters: { type: "object", properties: { pattern: str("string", "Glob pattern"), path: str("string", "Directory to search (default .)") } }
|
|
317
|
+
},
|
|
318
|
+
summarize: (a) => a.pattern || a.path || ".",
|
|
319
|
+
async run(a, ctx) {
|
|
320
|
+
const dir = safePath(ctx.cwd, a.path || ".");
|
|
321
|
+
const root = fs2.realpathSync(ctx.cwd);
|
|
322
|
+
const match = a.pattern ? globMatcher(a.pattern) : () => true;
|
|
323
|
+
const out2 = [];
|
|
324
|
+
for (const rel of walk(root, dir)) {
|
|
325
|
+
if (match(rel)) out2.push(rel);
|
|
326
|
+
if (out2.length >= 500) {
|
|
327
|
+
out2.push("\u2026 (truncated at 500 files)");
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return out2.join("\n") || "(no files)";
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
var grep = {
|
|
335
|
+
spec: {
|
|
336
|
+
name: "grep",
|
|
337
|
+
description: "Search file contents with a regular expression (respects .gitignore). Returns path:line:text.",
|
|
338
|
+
parameters: {
|
|
339
|
+
type: "object",
|
|
340
|
+
properties: { pattern: str("string", "JavaScript regular expression"), path: str("string", "Directory (default .)"), glob: str("string", "Only files matching this glob"), ignore_case: str("boolean") },
|
|
341
|
+
required: ["pattern"]
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
summarize: (a) => `/${a.pattern ?? ""}/${a.glob ? " " + a.glob : ""}`,
|
|
345
|
+
async run(a, ctx) {
|
|
346
|
+
let re;
|
|
347
|
+
try {
|
|
348
|
+
re = new RegExp(a.pattern, a.ignore_case ? "i" : "");
|
|
349
|
+
} catch (e) {
|
|
350
|
+
throw new Error(`Invalid regex: ${e.message}`);
|
|
351
|
+
}
|
|
352
|
+
const dir = safePath(ctx.cwd, a.path || ".");
|
|
353
|
+
const root = fs2.realpathSync(ctx.cwd);
|
|
354
|
+
const match = a.glob ? globMatcher(a.glob) : () => true;
|
|
355
|
+
const out2 = [];
|
|
356
|
+
for (const rel of walk(root, dir)) {
|
|
357
|
+
if (!match(rel)) continue;
|
|
358
|
+
const abs = path2.join(root, rel);
|
|
359
|
+
let text;
|
|
360
|
+
try {
|
|
361
|
+
if (fs2.statSync(abs).size > MAX_FILE_BYTES) continue;
|
|
362
|
+
const buf = fs2.readFileSync(abs);
|
|
363
|
+
if (buf.subarray(0, 8e3).includes(0)) continue;
|
|
364
|
+
text = buf.toString("utf8");
|
|
365
|
+
} catch {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const lines = text.split("\n");
|
|
369
|
+
for (let i = 0; i < lines.length; i++) {
|
|
370
|
+
if (re.test(lines[i])) out2.push(`${rel}:${i + 1}:${lines[i].slice(0, 300)}`);
|
|
371
|
+
if (out2.length >= 200) return out2.join("\n") + "\n\u2026 (truncated at 200 matches)";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out2.join("\n") || "(no matches)";
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
var bash = {
|
|
378
|
+
spec: {
|
|
379
|
+
name: "bash",
|
|
380
|
+
description: "Run a shell command in the working directory. Default timeout 120s. Output is truncated.",
|
|
381
|
+
parameters: { type: "object", properties: { command: str(), timeout_ms: str("integer", "Timeout in ms (max 600000)") }, required: ["command"] }
|
|
382
|
+
},
|
|
383
|
+
summarize: (a) => String(a.command ?? "").split("\n")[0],
|
|
384
|
+
prepare(a) {
|
|
385
|
+
if (typeof a.command !== "string" || !a.command.trim()) throw new Error("command is required.");
|
|
386
|
+
return { kind: "command", command: a.command };
|
|
387
|
+
},
|
|
388
|
+
run(a, ctx) {
|
|
389
|
+
const timeout = Math.min(Math.max(Number(a.timeout_ms) || 12e4, 1e3), 6e5);
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
const child = spawn(a.command, { shell: true, cwd: ctx.cwd, detached: process.platform !== "win32", env: process.env });
|
|
392
|
+
let out2 = "";
|
|
393
|
+
let note = "";
|
|
394
|
+
const add = (d) => {
|
|
395
|
+
if (out2.length < MAX_OUTPUT * 4) out2 += d.toString();
|
|
396
|
+
};
|
|
397
|
+
child.stdout.on("data", add);
|
|
398
|
+
child.stderr.on("data", add);
|
|
399
|
+
const kill = () => {
|
|
400
|
+
try {
|
|
401
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
402
|
+
else child.kill("SIGKILL");
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const timer = setTimeout(() => {
|
|
407
|
+
note = `
|
|
408
|
+
[timed out after ${timeout / 1e3}s]`;
|
|
409
|
+
kill();
|
|
410
|
+
}, timeout);
|
|
411
|
+
const onAbort = () => {
|
|
412
|
+
note = "\n[cancelled]";
|
|
413
|
+
kill();
|
|
414
|
+
};
|
|
415
|
+
ctx.signal?.addEventListener("abort", onAbort, { once: true });
|
|
416
|
+
child.on("error", (e) => {
|
|
417
|
+
clearTimeout(timer);
|
|
418
|
+
resolve(`Failed to start: ${e.message}`);
|
|
419
|
+
});
|
|
420
|
+
child.on("close", (code) => {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
ctx.signal?.removeEventListener("abort", onAbort);
|
|
423
|
+
resolve((truncate(out2.trimEnd()) + note + (code ? `
|
|
424
|
+
[exit code ${code}]` : "")).trim() || "(no output)");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
var TOOLS = [readFile, writeFile, editFile, listFiles, grep, bash];
|
|
430
|
+
var toolByName = (name) => TOOLS.find((t) => t.spec.name === name);
|
|
431
|
+
|
|
432
|
+
// src/agent.ts
|
|
433
|
+
var Cancelled = class extends Error {
|
|
434
|
+
constructor() {
|
|
435
|
+
super("Cancelled");
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
async function runTurn(o) {
|
|
439
|
+
const { hooks, signal } = o;
|
|
440
|
+
for (let step = 0; step < (o.maxSteps ?? 50); step++) {
|
|
441
|
+
hooks.onThinking();
|
|
442
|
+
let res;
|
|
443
|
+
try {
|
|
444
|
+
res = await o.provider.chat(
|
|
445
|
+
{ system: o.system, messages: o.messages, tools: TOOLS.map((t) => t.spec), signal },
|
|
446
|
+
hooks.onText
|
|
447
|
+
);
|
|
448
|
+
} catch (e) {
|
|
449
|
+
hooks.onTextEnd();
|
|
450
|
+
if (signal.aborted) throw new Cancelled();
|
|
451
|
+
throw e;
|
|
452
|
+
}
|
|
453
|
+
hooks.onTextEnd();
|
|
454
|
+
hooks.onUsage(res.usage);
|
|
455
|
+
o.messages.push({ role: "assistant", content: res.text, toolCalls: res.toolCalls.length ? res.toolCalls : void 0 });
|
|
456
|
+
if (!res.toolCalls.length) return;
|
|
457
|
+
for (let i = 0; i < res.toolCalls.length; i++) {
|
|
458
|
+
if (signal.aborted) {
|
|
459
|
+
for (const c of res.toolCalls.slice(i)) o.messages.push(toolMsg(c, "Cancelled by user.", true));
|
|
460
|
+
throw new Cancelled();
|
|
461
|
+
}
|
|
462
|
+
o.messages.push(await execute(res.toolCalls[i], o));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
throw new Error(`Stopped after ${o.maxSteps ?? 50} steps.`);
|
|
466
|
+
}
|
|
467
|
+
function toolMsg(c, content, isError) {
|
|
468
|
+
return { role: "tool", toolCallId: c.id, name: c.name, content, isError: isError || void 0 };
|
|
469
|
+
}
|
|
470
|
+
async function execute(c, o) {
|
|
471
|
+
const { hooks } = o;
|
|
472
|
+
const tool = toolByName(c.name);
|
|
473
|
+
if (!tool) return toolMsg(c, `Unknown tool "${c.name}".`, true);
|
|
474
|
+
let summary = "";
|
|
475
|
+
try {
|
|
476
|
+
summary = tool.summarize(c.args);
|
|
477
|
+
const ctx = { cwd: o.cwd, signal: o.signal };
|
|
478
|
+
const preview = tool.prepare?.(c.args, ctx);
|
|
479
|
+
if (preview && o.permissions.needsApproval(c.name)) {
|
|
480
|
+
const answer = await hooks.confirm(c.name, preview);
|
|
481
|
+
if (answer === "n") {
|
|
482
|
+
hooks.onToolDone(c.name, summary, false, "denied");
|
|
483
|
+
return toolMsg(c, "The user denied this action. Do not retry it; ask what they would like instead.", true);
|
|
484
|
+
}
|
|
485
|
+
if (answer === "a") o.permissions.allowAlways(c.name);
|
|
486
|
+
}
|
|
487
|
+
hooks.onToolRunning(c.name, summary);
|
|
488
|
+
const result = truncate(await tool.run(c.args, ctx));
|
|
489
|
+
hooks.onToolDone(c.name, summary, true, result);
|
|
490
|
+
return toolMsg(c, result, false);
|
|
491
|
+
} catch (e) {
|
|
492
|
+
const msg = e?.message ?? String(e);
|
|
493
|
+
hooks.onToolDone(c.name, summary, false, msg);
|
|
494
|
+
return toolMsg(c, `Error: ${msg}`, true);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/config.ts
|
|
499
|
+
import fs3 from "fs";
|
|
500
|
+
import os from "os";
|
|
501
|
+
import path3 from "path";
|
|
502
|
+
var DEFAULT_BASE_URLS = {
|
|
503
|
+
openai: "https://api.openai.com/v1",
|
|
504
|
+
anthropic: "https://api.anthropic.com",
|
|
505
|
+
gemini: "https://generativelanguage.googleapis.com"
|
|
506
|
+
};
|
|
507
|
+
var ENV_KEYS = {
|
|
508
|
+
openai: "OPENAI_API_KEY",
|
|
509
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
510
|
+
gemini: "GEMINI_API_KEY"
|
|
511
|
+
};
|
|
512
|
+
function configPath(env = process.env) {
|
|
513
|
+
const base = env.XDG_CONFIG_HOME || path3.join(os.homedir(), ".config");
|
|
514
|
+
return path3.join(base, "croo", "config.json");
|
|
515
|
+
}
|
|
516
|
+
function dataDir(env = process.env) {
|
|
517
|
+
const base = env.XDG_DATA_HOME || path3.join(os.homedir(), ".local", "share");
|
|
518
|
+
return path3.join(base, "croo");
|
|
519
|
+
}
|
|
520
|
+
function defaultConfig() {
|
|
521
|
+
return { activeProfile: "", profiles: {}, pet: true, animations: true };
|
|
522
|
+
}
|
|
523
|
+
function loadConfig(file = configPath()) {
|
|
524
|
+
let raw;
|
|
525
|
+
try {
|
|
526
|
+
raw = fs3.readFileSync(file, "utf8");
|
|
527
|
+
} catch (e) {
|
|
528
|
+
if (e.code === "ENOENT") return null;
|
|
529
|
+
throw e;
|
|
530
|
+
}
|
|
531
|
+
let data;
|
|
532
|
+
try {
|
|
533
|
+
data = JSON.parse(raw);
|
|
534
|
+
} catch {
|
|
535
|
+
throw new Error(`Config at ${file} is not valid JSON.`);
|
|
536
|
+
}
|
|
537
|
+
const cfg = { ...defaultConfig(), ...data };
|
|
538
|
+
if (!cfg.profiles || typeof cfg.profiles !== "object") cfg.profiles = {};
|
|
539
|
+
for (const [name, p] of Object.entries(cfg.profiles)) {
|
|
540
|
+
if (!p || !["openai", "anthropic", "gemini"].includes(p.type) || !p.model) {
|
|
541
|
+
throw new Error(`Profile "${name}" in ${file} needs a valid "type" and "model".`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (!cfg.activeProfile || !cfg.profiles[cfg.activeProfile]) {
|
|
545
|
+
cfg.activeProfile = Object.keys(cfg.profiles)[0] ?? "";
|
|
546
|
+
}
|
|
547
|
+
return cfg;
|
|
548
|
+
}
|
|
549
|
+
function saveConfig(cfg, file = configPath()) {
|
|
550
|
+
fs3.mkdirSync(path3.dirname(file), { recursive: true });
|
|
551
|
+
fs3.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
|
|
552
|
+
fs3.chmodSync(file, 384);
|
|
553
|
+
}
|
|
554
|
+
function resolveApiKey(profile, env = process.env) {
|
|
555
|
+
return profile.apiKey || env.CROO_API_KEY || env[ENV_KEYS[profile.type]] || void 0;
|
|
556
|
+
}
|
|
557
|
+
function maskKey(key) {
|
|
558
|
+
if (!key) return "(none)";
|
|
559
|
+
return key.length <= 8 ? "****" : `${key.slice(0, 3)}\u2026${key.slice(-4)}`;
|
|
560
|
+
}
|
|
561
|
+
function isLocalUrl(url) {
|
|
562
|
+
if (!url) return false;
|
|
563
|
+
try {
|
|
564
|
+
const h = new URL(url).hostname;
|
|
565
|
+
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1" || h === "0.0.0.0";
|
|
566
|
+
} catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/permissions.ts
|
|
572
|
+
var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["read_file", "list_files", "grep"]);
|
|
573
|
+
var Permissions = class {
|
|
574
|
+
constructor(yolo = false) {
|
|
575
|
+
this.yolo = yolo;
|
|
576
|
+
}
|
|
577
|
+
yolo;
|
|
578
|
+
always = /* @__PURE__ */ new Set();
|
|
579
|
+
needsApproval(tool) {
|
|
580
|
+
if (this.yolo) return false;
|
|
581
|
+
if (READ_ONLY_TOOLS.has(tool)) return false;
|
|
582
|
+
return !this.always.has(tool);
|
|
583
|
+
}
|
|
584
|
+
allowAlways(tool) {
|
|
585
|
+
this.always.add(tool);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// src/prompt.ts
|
|
590
|
+
import fs4 from "fs";
|
|
591
|
+
import path4 from "path";
|
|
592
|
+
var INSTRUCTION_FILES = ["CROO.md", "AGENTS.md"];
|
|
593
|
+
var MAX_INSTRUCTIONS = 2e4;
|
|
594
|
+
function loadProjectInstructions(cwd2) {
|
|
595
|
+
for (const name of INSTRUCTION_FILES) {
|
|
596
|
+
try {
|
|
597
|
+
const text = fs4.readFileSync(path4.join(cwd2, name), "utf8").trim();
|
|
598
|
+
if (text) return `# Project instructions (${name})
|
|
599
|
+
${text.slice(0, MAX_INSTRUCTIONS)}`;
|
|
600
|
+
} catch {
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
function buildSystemPrompt(cwd2, now = /* @__PURE__ */ new Date()) {
|
|
606
|
+
const parts = [
|
|
607
|
+
`You are Croo, a small, friendly, open-source coding agent running in the user's terminal. You are working in the directory: ${cwd2}`,
|
|
608
|
+
`Platform: ${process.platform}. Date: ${now.toISOString().slice(0, 10)}.`,
|
|
609
|
+
[
|
|
610
|
+
"Guidelines:",
|
|
611
|
+
"- Use the tools to inspect and change the project. Read files before editing them.",
|
|
612
|
+
"- Prefer edit_file for small changes and write_file for new files. Keep diffs minimal.",
|
|
613
|
+
"- File tools only work inside the working directory. Paths are relative to it.",
|
|
614
|
+
"- Use bash for builds, tests and git. Avoid destructive commands unless the user asked.",
|
|
615
|
+
"- Be concise. Explain what you did in a sentence or two; do not paste whole files back.",
|
|
616
|
+
"- If a tool call is denied by the user, do not retry it; ask how they would like to proceed."
|
|
617
|
+
].join("\n")
|
|
618
|
+
];
|
|
619
|
+
const project = loadProjectInstructions(cwd2);
|
|
620
|
+
if (project) parts.push(project);
|
|
621
|
+
return parts.join("\n\n");
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/providers/sse.ts
|
|
625
|
+
async function* sseData(res) {
|
|
626
|
+
if (!res.body) throw new Error("Response has no body.");
|
|
627
|
+
const reader = res.body.getReader();
|
|
628
|
+
const dec = new TextDecoder();
|
|
629
|
+
let buf = "";
|
|
630
|
+
for (; ; ) {
|
|
631
|
+
const { done, value } = await reader.read();
|
|
632
|
+
if (done) break;
|
|
633
|
+
buf += dec.decode(value, { stream: true });
|
|
634
|
+
let nl;
|
|
635
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
636
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
637
|
+
buf = buf.slice(nl + 1);
|
|
638
|
+
if (line.startsWith("data:")) yield line.slice(5).trim();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const last = buf.trim();
|
|
642
|
+
if (last.startsWith("data:")) yield last.slice(5).trim();
|
|
643
|
+
}
|
|
644
|
+
async function postJson(url, headers, body, signal) {
|
|
645
|
+
const res = await fetch(url, {
|
|
646
|
+
method: "POST",
|
|
647
|
+
headers: { "content-type": "application/json", ...headers },
|
|
648
|
+
body: JSON.stringify(body),
|
|
649
|
+
signal
|
|
650
|
+
});
|
|
651
|
+
if (!res.ok) {
|
|
652
|
+
const text = (await res.text().catch(() => "")).slice(0, 600);
|
|
653
|
+
throw new Error(`API error ${res.status} ${res.statusText}: ${text}`);
|
|
654
|
+
}
|
|
655
|
+
return res;
|
|
656
|
+
}
|
|
657
|
+
function parseArgs2(raw) {
|
|
658
|
+
if (!raw.trim()) return {};
|
|
659
|
+
try {
|
|
660
|
+
const v = JSON.parse(raw);
|
|
661
|
+
return v && typeof v === "object" ? v : {};
|
|
662
|
+
} catch {
|
|
663
|
+
return {};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/providers/anthropic.ts
|
|
668
|
+
function toAnthropicMessages(messages) {
|
|
669
|
+
const out2 = [];
|
|
670
|
+
for (const m of messages) {
|
|
671
|
+
if (m.role === "user") out2.push({ role: "user", content: m.content });
|
|
672
|
+
else if (m.role === "assistant") {
|
|
673
|
+
const blocks = [];
|
|
674
|
+
if (m.content) blocks.push({ type: "text", text: m.content });
|
|
675
|
+
for (const c of m.toolCalls ?? []) blocks.push({ type: "tool_use", id: c.id, name: c.name, input: c.args });
|
|
676
|
+
out2.push({ role: "assistant", content: blocks });
|
|
677
|
+
} else {
|
|
678
|
+
const block = { type: "tool_result", tool_use_id: m.toolCallId, content: m.content, is_error: m.isError || void 0 };
|
|
679
|
+
const last = out2[out2.length - 1];
|
|
680
|
+
if (last?.role === "user" && Array.isArray(last.content)) last.content.push(block);
|
|
681
|
+
else out2.push({ role: "user", content: [block] });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return out2;
|
|
685
|
+
}
|
|
686
|
+
function anthropicProvider(profile, apiKey) {
|
|
687
|
+
const base = (profile.baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\/+$/, "");
|
|
688
|
+
return {
|
|
689
|
+
async chat(req, onText) {
|
|
690
|
+
const body = {
|
|
691
|
+
model: profile.model,
|
|
692
|
+
max_tokens: 8192,
|
|
693
|
+
system: req.system,
|
|
694
|
+
messages: toAnthropicMessages(req.messages),
|
|
695
|
+
stream: true
|
|
696
|
+
};
|
|
697
|
+
if (req.tools.length) body.tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }));
|
|
698
|
+
const res = await postJson(
|
|
699
|
+
`${base}/v1/messages`,
|
|
700
|
+
{ "x-api-key": apiKey ?? "", "anthropic-version": "2023-06-01" },
|
|
701
|
+
body,
|
|
702
|
+
req.signal
|
|
703
|
+
);
|
|
704
|
+
let text = "";
|
|
705
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
706
|
+
const usage = { input: 0, output: 0 };
|
|
707
|
+
for await (const data of sseData(res)) {
|
|
708
|
+
let ev;
|
|
709
|
+
try {
|
|
710
|
+
ev = JSON.parse(data);
|
|
711
|
+
} catch {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (ev.type === "message_start") usage.input = ev.message?.usage?.input_tokens ?? 0;
|
|
715
|
+
else if (ev.type === "message_delta") usage.output = ev.usage?.output_tokens ?? usage.output;
|
|
716
|
+
else if (ev.type === "content_block_start" && ev.content_block?.type === "tool_use") {
|
|
717
|
+
blocks.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, json: "" });
|
|
718
|
+
} else if (ev.type === "content_block_delta") {
|
|
719
|
+
if (ev.delta?.type === "text_delta") {
|
|
720
|
+
text += ev.delta.text;
|
|
721
|
+
onText(ev.delta.text);
|
|
722
|
+
} else if (ev.delta?.type === "input_json_delta") {
|
|
723
|
+
const b = blocks.get(ev.index);
|
|
724
|
+
if (b) b.json += ev.delta.partial_json;
|
|
725
|
+
}
|
|
726
|
+
} else if (ev.type === "error") {
|
|
727
|
+
throw new Error(`API error: ${ev.error?.message ?? data}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const toolCalls = [...blocks.entries()].sort((a, b) => a[0] - b[0]).map(([, b]) => ({ id: b.id, name: b.name, args: parseArgs2(b.json) }));
|
|
731
|
+
return { text, toolCalls, usage };
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/providers/gemini.ts
|
|
737
|
+
function toGeminiContents(messages) {
|
|
738
|
+
const out2 = [];
|
|
739
|
+
for (const m of messages) {
|
|
740
|
+
if (m.role === "user") out2.push({ role: "user", parts: [{ text: m.content }] });
|
|
741
|
+
else if (m.role === "assistant") {
|
|
742
|
+
const parts = [];
|
|
743
|
+
if (m.content) parts.push({ text: m.content });
|
|
744
|
+
for (const c of m.toolCalls ?? []) {
|
|
745
|
+
const part = { functionCall: { name: c.name, args: c.args } };
|
|
746
|
+
if (c.meta) part.thoughtSignature = c.meta;
|
|
747
|
+
parts.push(part);
|
|
748
|
+
}
|
|
749
|
+
out2.push({ role: "model", parts });
|
|
750
|
+
} else {
|
|
751
|
+
const part = { functionResponse: { name: m.name, response: m.isError ? { error: m.content } : { output: m.content } } };
|
|
752
|
+
const last = out2[out2.length - 1];
|
|
753
|
+
if (last?.role === "user" && last.parts[0]?.functionResponse) last.parts.push(part);
|
|
754
|
+
else out2.push({ role: "user", parts: [part] });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return out2;
|
|
758
|
+
}
|
|
759
|
+
function geminiProvider(profile, apiKey) {
|
|
760
|
+
const base = (profile.baseUrl || DEFAULT_BASE_URLS.gemini).replace(/\/+$/, "");
|
|
761
|
+
return {
|
|
762
|
+
async chat(req, onText) {
|
|
763
|
+
const body = {
|
|
764
|
+
systemInstruction: { parts: [{ text: req.system }] },
|
|
765
|
+
contents: toGeminiContents(req.messages)
|
|
766
|
+
};
|
|
767
|
+
if (req.tools.length) {
|
|
768
|
+
body.tools = [{ functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })) }];
|
|
769
|
+
}
|
|
770
|
+
const model = profile.model.replace(/^models\//, "");
|
|
771
|
+
const res = await postJson(`${base}/v1beta/models/${model}:streamGenerateContent?alt=sse`, { "x-goog-api-key": apiKey ?? "" }, body, req.signal);
|
|
772
|
+
let text = "";
|
|
773
|
+
const toolCalls = [];
|
|
774
|
+
const usage = { input: 0, output: 0 };
|
|
775
|
+
for await (const data of sseData(res)) {
|
|
776
|
+
let chunk;
|
|
777
|
+
try {
|
|
778
|
+
chunk = JSON.parse(data);
|
|
779
|
+
} catch {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (chunk.usageMetadata) {
|
|
783
|
+
usage.input = chunk.usageMetadata.promptTokenCount ?? usage.input;
|
|
784
|
+
usage.output = chunk.usageMetadata.candidatesTokenCount ?? usage.output;
|
|
785
|
+
}
|
|
786
|
+
for (const part of chunk.candidates?.[0]?.content?.parts ?? []) {
|
|
787
|
+
if (part.thought) continue;
|
|
788
|
+
if (part.text) {
|
|
789
|
+
text += part.text;
|
|
790
|
+
onText(part.text);
|
|
791
|
+
}
|
|
792
|
+
if (part.functionCall) {
|
|
793
|
+
toolCalls.push({
|
|
794
|
+
id: `call_${toolCalls.length}_${Date.now()}`,
|
|
795
|
+
name: part.functionCall.name,
|
|
796
|
+
args: part.functionCall.args ?? {},
|
|
797
|
+
meta: part.thoughtSignature
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return { text, toolCalls, usage };
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/providers/openai.ts
|
|
808
|
+
function toOpenAIMessages(system, messages) {
|
|
809
|
+
const out2 = [{ role: "system", content: system }];
|
|
810
|
+
for (const m of messages) {
|
|
811
|
+
if (m.role === "user") out2.push({ role: "user", content: m.content });
|
|
812
|
+
else if (m.role === "tool") out2.push({ role: "tool", tool_call_id: m.toolCallId, content: m.content });
|
|
813
|
+
else {
|
|
814
|
+
const msg = { role: "assistant", content: m.content || null };
|
|
815
|
+
if (m.toolCalls?.length) {
|
|
816
|
+
msg.tool_calls = m.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args) } }));
|
|
817
|
+
}
|
|
818
|
+
out2.push(msg);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return out2;
|
|
822
|
+
}
|
|
823
|
+
function openaiProvider(profile, apiKey) {
|
|
824
|
+
const base = (profile.baseUrl || DEFAULT_BASE_URLS.openai).replace(/\/+$/, "");
|
|
825
|
+
return {
|
|
826
|
+
async chat(req, onText) {
|
|
827
|
+
const body = {
|
|
828
|
+
model: profile.model,
|
|
829
|
+
messages: toOpenAIMessages(req.system, req.messages),
|
|
830
|
+
stream: true,
|
|
831
|
+
stream_options: { include_usage: true }
|
|
832
|
+
};
|
|
833
|
+
if (req.tools.length) {
|
|
834
|
+
body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
|
|
835
|
+
}
|
|
836
|
+
const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
837
|
+
const res = await postJson(`${base}/chat/completions`, headers, body, req.signal);
|
|
838
|
+
let text = "";
|
|
839
|
+
const calls = /* @__PURE__ */ new Map();
|
|
840
|
+
const usage = { input: 0, output: 0 };
|
|
841
|
+
for await (const data of sseData(res)) {
|
|
842
|
+
if (data === "[DONE]") break;
|
|
843
|
+
let chunk;
|
|
844
|
+
try {
|
|
845
|
+
chunk = JSON.parse(data);
|
|
846
|
+
} catch {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (chunk.usage) {
|
|
850
|
+
usage.input = chunk.usage.prompt_tokens ?? usage.input;
|
|
851
|
+
usage.output = chunk.usage.completion_tokens ?? usage.output;
|
|
852
|
+
}
|
|
853
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
854
|
+
if (!delta) continue;
|
|
855
|
+
if (delta.content) {
|
|
856
|
+
text += delta.content;
|
|
857
|
+
onText(delta.content);
|
|
858
|
+
}
|
|
859
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
860
|
+
const i = tc.index ?? 0;
|
|
861
|
+
const cur = calls.get(i) ?? { id: "", name: "", args: "" };
|
|
862
|
+
if (tc.id) cur.id = tc.id;
|
|
863
|
+
if (tc.function?.name) cur.name += tc.function.name;
|
|
864
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
865
|
+
calls.set(i, cur);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([i, c]) => ({ id: c.id || `call_${i}_${Date.now()}`, name: c.name, args: parseArgs2(c.args) }));
|
|
869
|
+
return { text, toolCalls, usage };
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/providers/index.ts
|
|
875
|
+
function createProvider(profile, env = process.env) {
|
|
876
|
+
const key = resolveApiKey(profile, env);
|
|
877
|
+
if (!key && !(profile.type === "openai" && isLocalUrl(profile.baseUrl))) {
|
|
878
|
+
throw new Error(`No API key for this profile. Set one in the config or via CROO_API_KEY / ${profile.type.toUpperCase()}_API_KEY.`);
|
|
879
|
+
}
|
|
880
|
+
if (profile.type === "anthropic") return anthropicProvider(profile, key);
|
|
881
|
+
if (profile.type === "gemini") return geminiProvider(profile, key);
|
|
882
|
+
return openaiProvider(profile, key);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/session.ts
|
|
886
|
+
import fs5 from "fs";
|
|
887
|
+
import path5 from "path";
|
|
888
|
+
var sessionsDir = (dir = dataDir()) => path5.join(dir, "sessions");
|
|
889
|
+
function newSession(cwd2, profile) {
|
|
890
|
+
const id = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-") + "-" + Math.random().toString(36).slice(2, 6);
|
|
891
|
+
return { id, cwd: cwd2, profile, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), messages: [] };
|
|
892
|
+
}
|
|
893
|
+
function saveSession(s, dir = dataDir()) {
|
|
894
|
+
if (!s.messages.length) return;
|
|
895
|
+
s.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
896
|
+
fs5.mkdirSync(sessionsDir(dir), { recursive: true, mode: 448 });
|
|
897
|
+
fs5.writeFileSync(path5.join(sessionsDir(dir), `${s.id}.json`), JSON.stringify(s), { mode: 384 });
|
|
898
|
+
}
|
|
899
|
+
function loadLastSession(cwd2, dir = dataDir()) {
|
|
900
|
+
let files;
|
|
901
|
+
try {
|
|
902
|
+
files = fs5.readdirSync(sessionsDir(dir)).filter((f) => f.endsWith(".json"));
|
|
903
|
+
} catch {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
const sessions = [];
|
|
907
|
+
for (const f of files) {
|
|
908
|
+
try {
|
|
909
|
+
const s = JSON.parse(fs5.readFileSync(path5.join(sessionsDir(dir), f), "utf8"));
|
|
910
|
+
if (s.cwd === cwd2 && Array.isArray(s.messages)) sessions.push(s);
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
915
|
+
return sessions[0] ?? null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/ui/pet.ts
|
|
919
|
+
var W = 11;
|
|
920
|
+
var H = 8;
|
|
921
|
+
function frog(eyes, mouth, offset) {
|
|
922
|
+
const rows = [
|
|
923
|
+
".###...###.",
|
|
924
|
+
"####...####",
|
|
925
|
+
"###########",
|
|
926
|
+
"###########",
|
|
927
|
+
".#########.",
|
|
928
|
+
".##.....##."
|
|
929
|
+
].map((r) => r.split(""));
|
|
930
|
+
const carve = (r, ...cols) => cols.forEach((c) => rows[r][c] = ".");
|
|
931
|
+
if (eyes === "center") carve(1, 2, 8);
|
|
932
|
+
if (eyes === "left") carve(1, 1, 7);
|
|
933
|
+
if (eyes === "right") carve(1, 3, 9);
|
|
934
|
+
if (eyes === "wide") carve(1, 2, 8), carve(0, 2, 8);
|
|
935
|
+
if (mouth === "smile") carve(3, 2, 8), carve(4, 3, 4, 5, 6, 7);
|
|
936
|
+
if (mouth === "flat") carve(3, 3, 4, 5, 6, 7);
|
|
937
|
+
if (mouth === "open") carve(3, 2, 3, 4, 5, 6, 7, 8), carve(4, 3, 4, 5, 6, 7);
|
|
938
|
+
if (mouth === "frown") carve(3, 4, 5, 6), carve(4, 3, 7);
|
|
939
|
+
const grid = Array.from({ length: H }, () => Array(W).fill("."));
|
|
940
|
+
rows.forEach((row, i) => {
|
|
941
|
+
const y = i + offset;
|
|
942
|
+
if (y >= 0 && y < H) grid[y] = row;
|
|
943
|
+
});
|
|
944
|
+
const lines = [];
|
|
945
|
+
for (let y = 0; y < H; y += 2) {
|
|
946
|
+
let line = "";
|
|
947
|
+
for (let x = 0; x < W; x++) {
|
|
948
|
+
const top = grid[y][x] === "#";
|
|
949
|
+
const bot = grid[y + 1][x] === "#";
|
|
950
|
+
line += top && bot ? "\u2588" : top ? "\u2580" : bot ? "\u2584" : " ";
|
|
951
|
+
}
|
|
952
|
+
lines.push(line);
|
|
953
|
+
}
|
|
954
|
+
return lines;
|
|
955
|
+
}
|
|
956
|
+
var frogPet = {
|
|
957
|
+
name: "frog",
|
|
958
|
+
frames: {
|
|
959
|
+
// A long open-eyed pause, then a blink (the animator holds frame 0 for a few seconds).
|
|
960
|
+
idle: [frog("center", "smile", 1), frog("closed", "smile", 1)],
|
|
961
|
+
thinking: [frog("left", "flat", 1), frog("center", "flat", 1), frog("right", "flat", 1), frog("center", "flat", 1)],
|
|
962
|
+
working: [frog("center", "smile", 2), frog("center", "smile", 0)],
|
|
963
|
+
happy: [frog("closed", "open", 1), frog("closed", "open", 0), frog("closed", "open", 1)],
|
|
964
|
+
oops: [frog("wide", "frown", 1), frog("wide", "frown", 2)]
|
|
965
|
+
},
|
|
966
|
+
mini: {
|
|
967
|
+
idle: ["(o.o)", "(-.-)"],
|
|
968
|
+
thinking: ["(o.o )", "( o.o)"],
|
|
969
|
+
working: ["\\(o.o)/", " (o.o) "],
|
|
970
|
+
happy: ["\\(^.^)/"],
|
|
971
|
+
oops: ["(x.x)"]
|
|
972
|
+
},
|
|
973
|
+
sayings: [
|
|
974
|
+
"ribbit. ready when you are.",
|
|
975
|
+
"croo! what are we building?",
|
|
976
|
+
"hop in, I will read the code.",
|
|
977
|
+
"small diffs, big wins.",
|
|
978
|
+
"lily pad cleared. let us code."
|
|
979
|
+
]
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
// src/ui/theme.ts
|
|
983
|
+
var palette = {
|
|
984
|
+
accent: [94, 214, 126],
|
|
985
|
+
muted: [128, 134, 130],
|
|
986
|
+
error: [240, 98, 98],
|
|
987
|
+
warn: [236, 201, 75],
|
|
988
|
+
text: [220, 224, 220]
|
|
989
|
+
};
|
|
990
|
+
function detectColorLevel(env = process.env, isTTY2 = !!process.stdout.isTTY) {
|
|
991
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return 0;
|
|
992
|
+
const force = env.FORCE_COLOR;
|
|
993
|
+
if (force === "0") return 0;
|
|
994
|
+
if (!isTTY2 && !force) return 0;
|
|
995
|
+
if (env.TERM === "dumb") return 0;
|
|
996
|
+
if (force === "1") return 1;
|
|
997
|
+
if (force === "2") return 2;
|
|
998
|
+
if (force === "3") return 3;
|
|
999
|
+
const ct = (env.COLORTERM || "").toLowerCase();
|
|
1000
|
+
if (ct === "truecolor" || ct === "24bit") return 3;
|
|
1001
|
+
if ((env.TERM || "").includes("256color") || env.TERM_PROGRAM === "Apple_Terminal") return 2;
|
|
1002
|
+
return 1;
|
|
1003
|
+
}
|
|
1004
|
+
function to256([r, g, b]) {
|
|
1005
|
+
if (r === g && g === b) {
|
|
1006
|
+
if (r < 8) return 16;
|
|
1007
|
+
if (r > 248) return 231;
|
|
1008
|
+
return 232 + Math.round((r - 8) / 247 * 24);
|
|
1009
|
+
}
|
|
1010
|
+
const c = (v) => Math.round(v / 255 * 5);
|
|
1011
|
+
return 16 + 36 * c(r) + 6 * c(g) + c(b);
|
|
1012
|
+
}
|
|
1013
|
+
function to16([r, g, b]) {
|
|
1014
|
+
const max = Math.max(r, g, b);
|
|
1015
|
+
if (max < 60) return 30;
|
|
1016
|
+
const bright = max > 200 ? 60 : 0;
|
|
1017
|
+
const bit = (v) => v > max * 0.6 ? 1 : 0;
|
|
1018
|
+
return 30 + bright + bit(r) + bit(g) * 2 + bit(b) * 4;
|
|
1019
|
+
}
|
|
1020
|
+
function createTheme(level) {
|
|
1021
|
+
const wrap = (open, close) => (s) => level === 0 ? s : `\x1B[${open}m${s}\x1B[${close}m`;
|
|
1022
|
+
const fg = (rgb) => {
|
|
1023
|
+
if (level === 0) return (s) => s;
|
|
1024
|
+
const open = level === 3 ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : level === 2 ? `38;5;${to256(rgb)}` : `${to16(rgb)}`;
|
|
1025
|
+
return (s) => `\x1B[${open}m${s}\x1B[39m`;
|
|
1026
|
+
};
|
|
1027
|
+
return {
|
|
1028
|
+
level,
|
|
1029
|
+
enabled: level > 0,
|
|
1030
|
+
accent: fg(palette.accent),
|
|
1031
|
+
muted: fg(palette.muted),
|
|
1032
|
+
error: fg(palette.error),
|
|
1033
|
+
warn: fg(palette.warn),
|
|
1034
|
+
text: fg(palette.text),
|
|
1035
|
+
bold: wrap("1", "22"),
|
|
1036
|
+
dim: wrap("2", "22"),
|
|
1037
|
+
italic: wrap("3", "23")
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
var theme = createTheme(detectColorLevel());
|
|
1041
|
+
var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
1042
|
+
var stripAnsi = (s) => s.replace(ANSI_RE, "");
|
|
1043
|
+
var visibleLength = (s) => [...stripAnsi(s)].length;
|
|
1044
|
+
|
|
1045
|
+
// src/ui/markdown.ts
|
|
1046
|
+
var MarkdownStream = class {
|
|
1047
|
+
constructor(theme2, write) {
|
|
1048
|
+
this.theme = theme2;
|
|
1049
|
+
this.write = write;
|
|
1050
|
+
}
|
|
1051
|
+
theme;
|
|
1052
|
+
write;
|
|
1053
|
+
buf = "";
|
|
1054
|
+
inCode = false;
|
|
1055
|
+
push(delta) {
|
|
1056
|
+
if (!this.theme.enabled) return this.write(delta);
|
|
1057
|
+
this.buf += delta;
|
|
1058
|
+
let nl;
|
|
1059
|
+
while ((nl = this.buf.indexOf("\n")) >= 0) {
|
|
1060
|
+
const line = this.buf.slice(0, nl);
|
|
1061
|
+
this.buf = this.buf.slice(nl + 1);
|
|
1062
|
+
this.write(this.renderLine(line) + "\n");
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
flush() {
|
|
1066
|
+
if (this.buf) {
|
|
1067
|
+
this.write(this.theme.enabled ? this.renderLine(this.buf) : this.buf);
|
|
1068
|
+
this.buf = "";
|
|
1069
|
+
}
|
|
1070
|
+
this.inCode = false;
|
|
1071
|
+
}
|
|
1072
|
+
/** Whether the last emitted char was mid-line (so callers know to add a newline). */
|
|
1073
|
+
get pending() {
|
|
1074
|
+
return this.buf.length > 0;
|
|
1075
|
+
}
|
|
1076
|
+
renderLine(line) {
|
|
1077
|
+
const t = this.theme;
|
|
1078
|
+
const fence = line.match(/^\s*```\s*([\w+-]*)\s*$/);
|
|
1079
|
+
if (fence) {
|
|
1080
|
+
this.inCode = !this.inCode;
|
|
1081
|
+
return this.inCode && fence[1] ? t.muted(` \u258E ${fence[1]}`) : "";
|
|
1082
|
+
}
|
|
1083
|
+
if (this.inCode) return `${t.muted(" \u258E")} ${t.text(line)}`;
|
|
1084
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
1085
|
+
if (h) return t.bold(t.accent(this.inline(h[2])));
|
|
1086
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return t.muted("\u2500".repeat(24));
|
|
1087
|
+
const q = line.match(/^>\s?(.*)$/);
|
|
1088
|
+
if (q) return `${t.muted("\u258E")} ${t.italic(this.inline(q[1]))}`;
|
|
1089
|
+
const li = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
|
1090
|
+
if (li) {
|
|
1091
|
+
const bullet = /\d/.test(li[2]) ? t.accent(li[2]) : t.accent("\u2022");
|
|
1092
|
+
return `${li[1]}${bullet} ${this.inline(li[3])}`;
|
|
1093
|
+
}
|
|
1094
|
+
return this.inline(line);
|
|
1095
|
+
}
|
|
1096
|
+
inline(s) {
|
|
1097
|
+
const t = this.theme;
|
|
1098
|
+
return s.split(/(`[^`]+`)/g).map((part) => {
|
|
1099
|
+
if (part.length > 1 && part.startsWith("`") && part.endsWith("`")) return t.accent(part.slice(1, -1));
|
|
1100
|
+
return part.replace(/\*\*([^*]+)\*\*/g, (_, x) => t.bold(x)).replace(/(^|[^*\w])\*([^*\s][^*]*)\*(?!\*)/g, (_, p, x) => p + t.italic(x));
|
|
1101
|
+
}).join("");
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// src/ui/term.ts
|
|
1106
|
+
var out = process.stdout;
|
|
1107
|
+
var isTTY = !!out.isTTY;
|
|
1108
|
+
var columns = () => out.isTTY && out.columns ? out.columns : 80;
|
|
1109
|
+
var cursorHidden = false;
|
|
1110
|
+
var handlersInstalled = false;
|
|
1111
|
+
function restoreCursor() {
|
|
1112
|
+
if (cursorHidden && out.isTTY) out.write("\x1B[?25h");
|
|
1113
|
+
cursorHidden = false;
|
|
1114
|
+
}
|
|
1115
|
+
function hideCursor() {
|
|
1116
|
+
if (!out.isTTY) return;
|
|
1117
|
+
if (!handlersInstalled) {
|
|
1118
|
+
handlersInstalled = true;
|
|
1119
|
+
process.on("exit", restoreCursor);
|
|
1120
|
+
process.on("uncaughtException", (e) => {
|
|
1121
|
+
restoreCursor();
|
|
1122
|
+
console.error(theme.error(String(e instanceof Error ? e.stack || e.message : e)));
|
|
1123
|
+
process.exit(1);
|
|
1124
|
+
});
|
|
1125
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) {
|
|
1126
|
+
process.on(sig, () => {
|
|
1127
|
+
restoreCursor();
|
|
1128
|
+
process.exit(1);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
cursorHidden = true;
|
|
1133
|
+
out.write("\x1B[?25l");
|
|
1134
|
+
}
|
|
1135
|
+
var showCursor = restoreCursor;
|
|
1136
|
+
function shortenPath(p, home = process.env.HOME || "") {
|
|
1137
|
+
return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
1138
|
+
}
|
|
1139
|
+
function truncateMiddle(s, max) {
|
|
1140
|
+
if (s.length <= max) return s;
|
|
1141
|
+
const keep = Math.max(4, max - 1);
|
|
1142
|
+
const head = Math.ceil(keep / 2);
|
|
1143
|
+
return s.slice(0, head) + "\u2026" + s.slice(s.length - (keep - head));
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/ui/spinner.ts
|
|
1147
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1148
|
+
var THINKING_VERBS = ["Thinking", "Hatching a plan", "Pondering", "Ribbiting", "Mulling it over", "Hopping to conclusions"];
|
|
1149
|
+
var TOOL_VERBS = {
|
|
1150
|
+
read_file: "Reading",
|
|
1151
|
+
list_files: "Looking around",
|
|
1152
|
+
grep: "Searching",
|
|
1153
|
+
write_file: "Writing",
|
|
1154
|
+
edit_file: "Editing",
|
|
1155
|
+
bash: "Running"
|
|
1156
|
+
};
|
|
1157
|
+
var Spinner = class {
|
|
1158
|
+
constructor(o) {
|
|
1159
|
+
this.o = o;
|
|
1160
|
+
}
|
|
1161
|
+
o;
|
|
1162
|
+
timer;
|
|
1163
|
+
startedAt = 0;
|
|
1164
|
+
tick = 0;
|
|
1165
|
+
verbs = THINKING_VERBS;
|
|
1166
|
+
state = "thinking";
|
|
1167
|
+
detail = "";
|
|
1168
|
+
tokens = 0;
|
|
1169
|
+
get active() {
|
|
1170
|
+
return !!this.timer;
|
|
1171
|
+
}
|
|
1172
|
+
start(state, verbs, detail = "") {
|
|
1173
|
+
this.stop();
|
|
1174
|
+
if (!this.o.enabled) return;
|
|
1175
|
+
this.state = state;
|
|
1176
|
+
this.verbs = verbs;
|
|
1177
|
+
this.detail = detail;
|
|
1178
|
+
this.startedAt = Date.now();
|
|
1179
|
+
this.tick = 0;
|
|
1180
|
+
hideCursor();
|
|
1181
|
+
this.render();
|
|
1182
|
+
this.timer = setInterval(() => this.render(), 120);
|
|
1183
|
+
}
|
|
1184
|
+
stop() {
|
|
1185
|
+
if (!this.timer) return;
|
|
1186
|
+
clearInterval(this.timer);
|
|
1187
|
+
this.timer = void 0;
|
|
1188
|
+
process.stdout.write("\r\x1B[2K");
|
|
1189
|
+
showCursor();
|
|
1190
|
+
}
|
|
1191
|
+
render() {
|
|
1192
|
+
const { theme: t, pet, showPet } = this.o;
|
|
1193
|
+
const secs = Math.floor((Date.now() - this.startedAt) / 1e3);
|
|
1194
|
+
const verb = this.verbs[Math.floor(this.tick / 25) % this.verbs.length];
|
|
1195
|
+
const mini = pet.mini[this.state];
|
|
1196
|
+
const glyph = showPet ? t.accent(mini[Math.floor(this.tick / 4) % mini.length]) + " " : "";
|
|
1197
|
+
const stats = [`${secs}s`, this.tokens ? `~${this.tokens} tokens` : ""].filter(Boolean).join(" \xB7 ");
|
|
1198
|
+
const label = `${verb}\u2026${this.detail ? " " + this.detail : ""}`;
|
|
1199
|
+
const head = `${t.accent(FRAMES[this.tick % FRAMES.length])} ${glyph}`;
|
|
1200
|
+
const room = Math.max(10, columns() - 12 - stats.length - (showPet ? 9 : 0));
|
|
1201
|
+
process.stdout.write(`\r\x1B[2K${head}${truncateMiddle(label, room)} ${t.muted(stats)}`);
|
|
1202
|
+
this.tick++;
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
// src/tools/diff.ts
|
|
1207
|
+
var MAX_MIDDLE = 2e3;
|
|
1208
|
+
function diffLines(before, after) {
|
|
1209
|
+
const a = before === "" ? [] : before.split("\n");
|
|
1210
|
+
const b = after === "" ? [] : after.split("\n");
|
|
1211
|
+
let head = 0;
|
|
1212
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head++;
|
|
1213
|
+
let tail = 0;
|
|
1214
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
|
|
1215
|
+
const am = a.slice(head, a.length - tail);
|
|
1216
|
+
const bm = b.slice(head, b.length - tail);
|
|
1217
|
+
const ops = [];
|
|
1218
|
+
let oldNo = 1;
|
|
1219
|
+
let newNo = 1;
|
|
1220
|
+
for (let i = 0; i < head; i++) ops.push({ type: " ", text: a[i], oldNo: oldNo++, newNo: newNo++ });
|
|
1221
|
+
if (am.length > MAX_MIDDLE || bm.length > MAX_MIDDLE) {
|
|
1222
|
+
for (const t of am) ops.push({ type: "-", text: t, oldNo: oldNo++ });
|
|
1223
|
+
for (const t of bm) ops.push({ type: "+", text: t, newNo: newNo++ });
|
|
1224
|
+
} else {
|
|
1225
|
+
const n = am.length;
|
|
1226
|
+
const m = bm.length;
|
|
1227
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
1228
|
+
for (let i2 = n - 1; i2 >= 0; i2--)
|
|
1229
|
+
for (let j2 = m - 1; j2 >= 0; j2--)
|
|
1230
|
+
dp[i2][j2] = am[i2] === bm[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
1231
|
+
let i = 0;
|
|
1232
|
+
let j = 0;
|
|
1233
|
+
while (i < n || j < m) {
|
|
1234
|
+
if (i < n && j < m && am[i] === bm[j]) {
|
|
1235
|
+
ops.push({ type: " ", text: am[i], oldNo: oldNo++, newNo: newNo++ });
|
|
1236
|
+
i++;
|
|
1237
|
+
j++;
|
|
1238
|
+
} else if (i < n && (j >= m || dp[i + 1][j] >= dp[i][j + 1])) {
|
|
1239
|
+
ops.push({ type: "-", text: am[i++], oldNo: oldNo++ });
|
|
1240
|
+
} else {
|
|
1241
|
+
ops.push({ type: "+", text: bm[j++], newNo: newNo++ });
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
for (let i = a.length - tail; i < a.length; i++) ops.push({ type: " ", text: a[i], oldNo: oldNo++, newNo: newNo++ });
|
|
1246
|
+
return ops;
|
|
1247
|
+
}
|
|
1248
|
+
function collapseContext(lines, context = 2) {
|
|
1249
|
+
const keep = new Array(lines.length).fill(false);
|
|
1250
|
+
lines.forEach((l, i) => {
|
|
1251
|
+
if (l.type === " ") return;
|
|
1252
|
+
for (let k = Math.max(0, i - context); k <= Math.min(lines.length - 1, i + context); k++) keep[k] = true;
|
|
1253
|
+
});
|
|
1254
|
+
const out2 = [];
|
|
1255
|
+
let gap = false;
|
|
1256
|
+
lines.forEach((l, i) => {
|
|
1257
|
+
if (keep[i]) {
|
|
1258
|
+
out2.push(l);
|
|
1259
|
+
gap = false;
|
|
1260
|
+
} else if (!gap) {
|
|
1261
|
+
out2.push(null);
|
|
1262
|
+
gap = true;
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
return out2;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/ui/render.ts
|
|
1269
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1270
|
+
var fmtTokens = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : String(n);
|
|
1271
|
+
var Ui = class {
|
|
1272
|
+
constructor(o) {
|
|
1273
|
+
this.o = o;
|
|
1274
|
+
this.animate = isTTY && o.animations;
|
|
1275
|
+
this.spinner = new Spinner({ theme: o.theme, enabled: this.animate, pet: o.pet, showPet: o.showPet });
|
|
1276
|
+
this.md = new MarkdownStream(o.theme, (s) => {
|
|
1277
|
+
process.stdout.write(s);
|
|
1278
|
+
this.midLine = !s.endsWith("\n");
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
o;
|
|
1282
|
+
spinner;
|
|
1283
|
+
md;
|
|
1284
|
+
midLine = false;
|
|
1285
|
+
// streamed text without trailing newline
|
|
1286
|
+
streamed = false;
|
|
1287
|
+
animate;
|
|
1288
|
+
get t() {
|
|
1289
|
+
return this.o.theme;
|
|
1290
|
+
}
|
|
1291
|
+
// ---------- banner ----------
|
|
1292
|
+
async banner(info) {
|
|
1293
|
+
const { t, o } = this;
|
|
1294
|
+
const cols = columns();
|
|
1295
|
+
const hint = t.muted("/help for commands");
|
|
1296
|
+
if (!o.showPet || cols < 60) {
|
|
1297
|
+
console.log(`${t.accent(t.bold("Croo"))} ${t.muted("v" + info.version)} ${t.muted(`${info.model} \xB7 ${info.profile}`)}`);
|
|
1298
|
+
console.log(t.muted(truncateMiddle(shortenPath(info.cwd), cols - 2)));
|
|
1299
|
+
console.log(hint + "\n");
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const say = o.pet.sayings[Math.floor(Math.random() * o.pet.sayings.length)];
|
|
1303
|
+
const right = [
|
|
1304
|
+
t.muted(`\u201C${say}\u201D`),
|
|
1305
|
+
`${t.accent(t.bold("Croo"))} ${t.muted("v" + info.version)}`,
|
|
1306
|
+
t.muted(`${info.model} \xB7 ${info.profile}`),
|
|
1307
|
+
t.muted(truncateMiddle(shortenPath(info.cwd), cols - 20))
|
|
1308
|
+
];
|
|
1309
|
+
const compose = (frame) => frame.map((l, i) => `${t.accent(l)} ${right[i] ?? ""}`);
|
|
1310
|
+
const frames = o.pet.frames.idle;
|
|
1311
|
+
let lines = compose(frames[0]);
|
|
1312
|
+
console.log(lines.join("\n"));
|
|
1313
|
+
console.log(" ".repeat(14) + hint + "\n");
|
|
1314
|
+
if (!this.animate) return;
|
|
1315
|
+
const redraw = (frame) => {
|
|
1316
|
+
const up = lines.length + 2;
|
|
1317
|
+
process.stdout.write(`\x1B[${up}A` + compose(frame).map((l) => `\r\x1B[2K${l}
|
|
1318
|
+
`).join("") + `\x1B[2B`);
|
|
1319
|
+
};
|
|
1320
|
+
hideCursor();
|
|
1321
|
+
try {
|
|
1322
|
+
await sleep(550);
|
|
1323
|
+
redraw(frames[1]);
|
|
1324
|
+
await sleep(140);
|
|
1325
|
+
redraw(frames[0]);
|
|
1326
|
+
} finally {
|
|
1327
|
+
showCursor();
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
// ---------- streaming ----------
|
|
1331
|
+
think(verbs = THINKING_VERBS) {
|
|
1332
|
+
this.spinner.start("thinking", verbs);
|
|
1333
|
+
}
|
|
1334
|
+
work(tool, detail) {
|
|
1335
|
+
this.spinner.start("working", [TOOL_VERBS[tool] ?? "Working"], detail);
|
|
1336
|
+
}
|
|
1337
|
+
idle() {
|
|
1338
|
+
this.spinner.stop();
|
|
1339
|
+
}
|
|
1340
|
+
text(delta) {
|
|
1341
|
+
this.spinner.stop();
|
|
1342
|
+
if (!this.streamed) {
|
|
1343
|
+
this.streamed = true;
|
|
1344
|
+
if (this.animate || this.t.enabled) process.stdout.write("\n");
|
|
1345
|
+
}
|
|
1346
|
+
this.spinner.tokens += Math.ceil(delta.length / 4);
|
|
1347
|
+
this.md.push(delta);
|
|
1348
|
+
}
|
|
1349
|
+
endText() {
|
|
1350
|
+
this.spinner.stop();
|
|
1351
|
+
this.md.flush();
|
|
1352
|
+
if (this.midLine) process.stdout.write("\n");
|
|
1353
|
+
this.midLine = false;
|
|
1354
|
+
this.streamed = false;
|
|
1355
|
+
}
|
|
1356
|
+
/** Reset per-turn counters. */
|
|
1357
|
+
beginTurn() {
|
|
1358
|
+
this.spinner.tokens = 0;
|
|
1359
|
+
this.streamed = false;
|
|
1360
|
+
}
|
|
1361
|
+
// ---------- messages ----------
|
|
1362
|
+
line(s = "") {
|
|
1363
|
+
this.spinner.stop();
|
|
1364
|
+
if (this.midLine) process.stdout.write("\n"), this.midLine = false;
|
|
1365
|
+
console.log(s);
|
|
1366
|
+
}
|
|
1367
|
+
info(s) {
|
|
1368
|
+
this.line(this.t.muted(s));
|
|
1369
|
+
}
|
|
1370
|
+
warn(s) {
|
|
1371
|
+
this.line(this.t.warn(s));
|
|
1372
|
+
}
|
|
1373
|
+
error(s) {
|
|
1374
|
+
const mini = this.o.showPet ? this.o.pet.mini.oops[0] + " " : "";
|
|
1375
|
+
this.line(this.t.error(`${mini}${s}`));
|
|
1376
|
+
}
|
|
1377
|
+
done(seconds) {
|
|
1378
|
+
if (!this.animate && !this.t.enabled) return;
|
|
1379
|
+
const mini = this.o.showPet ? this.o.pet.mini.happy[0] + " " : "";
|
|
1380
|
+
this.line(this.t.muted(`${mini}done in ${seconds.toFixed(1)}s`));
|
|
1381
|
+
}
|
|
1382
|
+
/** Full-size pet reaction, used for startup problems. Plain text when narrow or piped. */
|
|
1383
|
+
petSays(state, message) {
|
|
1384
|
+
const { t, o } = this;
|
|
1385
|
+
if (!o.showPet || !this.animate || columns() < 60) return console.error(t.error(message));
|
|
1386
|
+
const frame = o.pet.frames[state][0];
|
|
1387
|
+
const color = state === "oops" ? t.error : t.accent;
|
|
1388
|
+
frame.forEach((l, i) => console.error(`${color(l)} ${i === 1 ? state === "oops" ? t.error(message) : message : ""}`));
|
|
1389
|
+
}
|
|
1390
|
+
toolCard(name, summary, ok, result) {
|
|
1391
|
+
const { t } = this;
|
|
1392
|
+
const icon = { read_file: "\u25C7", list_files: "\u2315", grep: "\u2315", write_file: "\u270E", edit_file: "\u270E", bash: "$" }[name] ?? "\u25C6";
|
|
1393
|
+
const status = ok ? t.accent("\u2713") : t.error("\u2717");
|
|
1394
|
+
const room = Math.max(20, columns() - 6 - name.length);
|
|
1395
|
+
const first = result.split("\n")[0];
|
|
1396
|
+
const tail = first && first !== "(no output)" ? t.muted(` \xB7 ${truncateMiddle(first, 40)}`) : "";
|
|
1397
|
+
const head = ` ${t.accent(icon)} ${t.bold(name)} ${t.muted(truncateMiddle(summary.replace(/\s+/g, " "), room - 45))}`;
|
|
1398
|
+
this.line(`${head} ${status}${ok ? "" : tail}`);
|
|
1399
|
+
}
|
|
1400
|
+
// ---------- permission prompt ----------
|
|
1401
|
+
async confirm(name, preview) {
|
|
1402
|
+
const { t } = this;
|
|
1403
|
+
const w = Math.min(columns() - 2, 100);
|
|
1404
|
+
const title = preview.kind === "diff" ? `${name} ${preview.path}` : `${name}`;
|
|
1405
|
+
const top = `\u256D\u2500 ${title} `;
|
|
1406
|
+
this.line("\n" + t.warn(top + "\u2500".repeat(Math.max(0, w - visibleLength(top)))));
|
|
1407
|
+
const bar = t.warn("\u2502");
|
|
1408
|
+
if (preview.kind === "command") {
|
|
1409
|
+
for (const l of preview.command.split("\n").slice(0, 20)) this.line(`${bar} ${t.bold(truncateMiddle(l, w - 4))}`);
|
|
1410
|
+
} else {
|
|
1411
|
+
this.printDiff(preview.before, preview.after, bar, w - 4);
|
|
1412
|
+
}
|
|
1413
|
+
const answer = await this.o.ask(`${t.warn("\u2570")} Allow? ${t.muted("[y]es / [n]o / [a]lways")} `);
|
|
1414
|
+
const c = answer.trim().toLowerCase()[0];
|
|
1415
|
+
return c === "y" ? "y" : c === "a" ? "a" : "n";
|
|
1416
|
+
}
|
|
1417
|
+
printDiff(before, after, bar, width) {
|
|
1418
|
+
const { t } = this;
|
|
1419
|
+
const rows = collapseContext(diffLines(before, after), 2);
|
|
1420
|
+
const numW = String(Math.max(before.split("\n").length, after.split("\n").length)).length;
|
|
1421
|
+
const MAX = 60;
|
|
1422
|
+
let shown = 0;
|
|
1423
|
+
for (const r of rows) {
|
|
1424
|
+
if (shown >= MAX) {
|
|
1425
|
+
this.line(`${bar} ${t.muted(`\u2026 ${rows.length - shown} more lines`)}`);
|
|
1426
|
+
break;
|
|
1427
|
+
}
|
|
1428
|
+
shown++;
|
|
1429
|
+
if (r === null) {
|
|
1430
|
+
this.line(`${bar} ${t.muted("\u2026")}`);
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
const no = String(r.type === "-" ? r.oldNo : r.newNo).padStart(numW);
|
|
1434
|
+
const body = truncateMiddle(r.text, Math.max(10, width - numW - 4));
|
|
1435
|
+
if (r.type === "+") this.line(`${bar} ${t.accent(`${no} + ${body}`)}`);
|
|
1436
|
+
else if (r.type === "-") this.line(`${bar} ${t.error(`${no} - ${body}`)}`);
|
|
1437
|
+
else this.line(`${bar} ${t.muted(`${no} ${body}`)}`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
// ---------- status ----------
|
|
1441
|
+
statusLine(s) {
|
|
1442
|
+
const { t } = this;
|
|
1443
|
+
const tokens = s.input + s.output > 0 ? ` \xB7 ${fmtTokens(s.input)} in / ${fmtTokens(s.output)} out` : "";
|
|
1444
|
+
const cost = s.cost !== void 0 ? ` \xB7 ~$${s.cost.toFixed(s.cost < 0.1 ? 3 : 2)}` : "";
|
|
1445
|
+
const text = `${s.model} \xB7 ${shortenPath(s.cwd)}${tokens}${cost}`;
|
|
1446
|
+
this.line(t.muted(" " + truncateMiddle(text, columns() - 3)));
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
// src/cli.ts
|
|
1451
|
+
var VERSION = package_default.version;
|
|
1452
|
+
var cwd = process.cwd();
|
|
1453
|
+
var rl;
|
|
1454
|
+
var cancelAsk;
|
|
1455
|
+
var getRl = () => rl ??= readline.createInterface({ input: process.stdin, output: process.stdout, terminal: !!process.stdin.isTTY });
|
|
1456
|
+
var ask = (question, opts) => {
|
|
1457
|
+
if (!process.stdin.isTTY) return Promise.resolve("");
|
|
1458
|
+
return new Promise((resolve) => {
|
|
1459
|
+
const r = getRl();
|
|
1460
|
+
let muted = false;
|
|
1461
|
+
const original = r._writeToOutput;
|
|
1462
|
+
if (opts?.secret && typeof original === "function") {
|
|
1463
|
+
r._writeToOutput = (s) => {
|
|
1464
|
+
if (!muted) original.call(r, s);
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
const finish = (answer) => {
|
|
1468
|
+
r._writeToOutput = original;
|
|
1469
|
+
cancelAsk = void 0;
|
|
1470
|
+
if (opts?.secret) process.stdout.write("\n");
|
|
1471
|
+
resolve(answer);
|
|
1472
|
+
};
|
|
1473
|
+
cancelAsk = () => finish("");
|
|
1474
|
+
r.question(question, (a) => finish(a));
|
|
1475
|
+
muted = !!opts?.secret;
|
|
1476
|
+
});
|
|
1477
|
+
};
|
|
1478
|
+
var DEFAULT_MODELS = { openai: "gpt-4o", anthropic: "claude-sonnet-4-5", gemini: "gemini-2.5-pro" };
|
|
1479
|
+
async function setupProfile(a, cfg) {
|
|
1480
|
+
console.log(theme.bold("\nSet up a provider") + theme.muted(" (stored in " + configPath() + ", mode 600)\n"));
|
|
1481
|
+
console.log(" 1) OpenAI-compatible (OpenAI, OpenRouter, Groq, Ollama, LM Studio, ...)\n 2) Anthropic\n 3) Google Gemini\n");
|
|
1482
|
+
const choice = (await a("Provider [1]: ")).trim() || "1";
|
|
1483
|
+
const type = choice === "2" ? "anthropic" : choice === "3" ? "gemini" : "openai";
|
|
1484
|
+
const profile = { type, model: "" };
|
|
1485
|
+
if (type === "openai") {
|
|
1486
|
+
const url = (await a(`Base URL [${DEFAULT_BASE_URLS.openai}] (Ollama: http://localhost:11434/v1): `)).trim();
|
|
1487
|
+
if (url) profile.baseUrl = url;
|
|
1488
|
+
}
|
|
1489
|
+
const key = (await a("API key (leave blank to use an env var, or for local models): ", { secret: true })).trim();
|
|
1490
|
+
if (key) profile.apiKey = key;
|
|
1491
|
+
profile.model = (await a(`Model [${DEFAULT_MODELS[type]}]: `)).trim() || DEFAULT_MODELS[type];
|
|
1492
|
+
let base = type === "openai" && profile.baseUrl ? new URL(profile.baseUrl).hostname.replace(/^api\./, "").split(".")[0] : type;
|
|
1493
|
+
let name = base;
|
|
1494
|
+
for (let i = 2; cfg.profiles[name]; i++) name = `${base}${i}`;
|
|
1495
|
+
const next = { ...cfg, profiles: { ...cfg.profiles, [name]: profile }, activeProfile: name };
|
|
1496
|
+
saveConfig(next);
|
|
1497
|
+
console.log(theme.accent(`
|
|
1498
|
+
Saved profile "${name}".
|
|
1499
|
+
`));
|
|
1500
|
+
return next;
|
|
1501
|
+
}
|
|
1502
|
+
async function main() {
|
|
1503
|
+
let args;
|
|
1504
|
+
try {
|
|
1505
|
+
args = parseArgs(process.argv.slice(2));
|
|
1506
|
+
} catch (e) {
|
|
1507
|
+
console.error(e.message + "\n\n" + HELP);
|
|
1508
|
+
process.exit(2);
|
|
1509
|
+
}
|
|
1510
|
+
if (args.help) return console.log(HELP);
|
|
1511
|
+
if (args.version) return console.log(VERSION);
|
|
1512
|
+
let cfg = loadConfig();
|
|
1513
|
+
if (!cfg || !Object.keys(cfg.profiles).length) {
|
|
1514
|
+
if (!process.stdin.isTTY) {
|
|
1515
|
+
console.error(`No config found. Run \`croo\` in a terminal to set one up, or create ${configPath()}.`);
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
}
|
|
1518
|
+
console.log(theme.accent(theme.bold("Welcome to Croo.")) + " No config yet, so let us set one up.");
|
|
1519
|
+
cfg = await setupProfile(ask, cfg ?? defaultConfig());
|
|
1520
|
+
}
|
|
1521
|
+
let profileName = args.profile ?? cfg.activeProfile;
|
|
1522
|
+
if (!cfg.profiles[profileName]) {
|
|
1523
|
+
console.error(`Unknown profile "${profileName}". Available: ${Object.keys(cfg.profiles).join(", ")}`);
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
const ui = new Ui({ theme, pet: frogPet, showPet: cfg.pet && !args.noPet, animations: cfg.animations, ask });
|
|
1527
|
+
const permissions = new Permissions(args.yolo);
|
|
1528
|
+
const interactive = !args.prompt;
|
|
1529
|
+
let provider;
|
|
1530
|
+
const useProfile = (name) => {
|
|
1531
|
+
provider = createProvider(cfg.profiles[name]);
|
|
1532
|
+
profileName = name;
|
|
1533
|
+
};
|
|
1534
|
+
try {
|
|
1535
|
+
useProfile(profileName);
|
|
1536
|
+
} catch (e) {
|
|
1537
|
+
ui.petSays("oops", e.message);
|
|
1538
|
+
process.exit(1);
|
|
1539
|
+
}
|
|
1540
|
+
let session = args.continue && loadLastSession(cwd) || newSession(cwd, profileName);
|
|
1541
|
+
const messages = session.messages;
|
|
1542
|
+
if (args.continue && messages.length) ui.info(`Resumed session with ${messages.length} messages.`);
|
|
1543
|
+
const system = buildSystemPrompt(cwd);
|
|
1544
|
+
const totals = { input: 0, output: 0 };
|
|
1545
|
+
let current;
|
|
1546
|
+
let lastInterrupt = 0;
|
|
1547
|
+
async function runPrompt(text) {
|
|
1548
|
+
const before = messages.length;
|
|
1549
|
+
messages.push({ role: "user", content: text });
|
|
1550
|
+
const ac = current = new AbortController();
|
|
1551
|
+
const t0 = Date.now();
|
|
1552
|
+
ui.beginTurn();
|
|
1553
|
+
try {
|
|
1554
|
+
await runTurn({
|
|
1555
|
+
provider,
|
|
1556
|
+
system,
|
|
1557
|
+
messages,
|
|
1558
|
+
cwd,
|
|
1559
|
+
permissions,
|
|
1560
|
+
signal: ac.signal,
|
|
1561
|
+
hooks: {
|
|
1562
|
+
onThinking: () => ui.think(),
|
|
1563
|
+
onText: (d) => ui.text(d),
|
|
1564
|
+
onTextEnd: () => ui.endText(),
|
|
1565
|
+
onToolRunning: (n, s) => ui.work(n, s),
|
|
1566
|
+
onToolDone: (n, s, ok, r) => ui.toolCard(n, s, ok, r),
|
|
1567
|
+
confirm: (n, p) => ui.confirm(n, p),
|
|
1568
|
+
onUsage: (u) => (totals.input += u.input, totals.output += u.output)
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
ui.done((Date.now() - t0) / 1e3);
|
|
1572
|
+
return true;
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
if (e instanceof Cancelled) ui.warn("Cancelled.");
|
|
1575
|
+
else {
|
|
1576
|
+
messages.length = before;
|
|
1577
|
+
ui.error(e?.message ?? String(e));
|
|
1578
|
+
}
|
|
1579
|
+
return false;
|
|
1580
|
+
} finally {
|
|
1581
|
+
ui.idle();
|
|
1582
|
+
current = void 0;
|
|
1583
|
+
session.profile = profileName;
|
|
1584
|
+
saveSession(session);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
function interrupt() {
|
|
1588
|
+
const now = Date.now();
|
|
1589
|
+
if (current && !current.signal.aborted) {
|
|
1590
|
+
current.abort();
|
|
1591
|
+
cancelAsk?.();
|
|
1592
|
+
lastInterrupt = now;
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (now - lastInterrupt < 2e3 || !interactive) {
|
|
1596
|
+
ui.idle();
|
|
1597
|
+
process.stdout.write("\n");
|
|
1598
|
+
process.exit(130);
|
|
1599
|
+
}
|
|
1600
|
+
lastInterrupt = now;
|
|
1601
|
+
ui.info("\n(press Ctrl+C again to exit)");
|
|
1602
|
+
if (rl) {
|
|
1603
|
+
rl.write(null, { ctrl: true, name: "u" });
|
|
1604
|
+
rl.prompt();
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
process.on("SIGINT", interrupt);
|
|
1608
|
+
if (!interactive) {
|
|
1609
|
+
const ok = await runPrompt(args.prompt);
|
|
1610
|
+
rl?.close();
|
|
1611
|
+
process.exit(ok ? 0 : 1);
|
|
1612
|
+
}
|
|
1613
|
+
const profile = () => cfg.profiles[profileName];
|
|
1614
|
+
const showBanner = () => ui.banner({ version: VERSION, profile: profileName, model: profile().model, cwd });
|
|
1615
|
+
await showBanner();
|
|
1616
|
+
const reader = getRl();
|
|
1617
|
+
reader.setPrompt(theme.accent("\u203A "));
|
|
1618
|
+
reader.on("SIGINT", interrupt);
|
|
1619
|
+
const status = () => {
|
|
1620
|
+
const p = profile();
|
|
1621
|
+
const cost = p.inputPerMTok !== void 0 || p.outputPerMTok !== void 0 ? (totals.input * (p.inputPerMTok ?? 0) + totals.output * (p.outputPerMTok ?? 0)) / 1e6 : void 0;
|
|
1622
|
+
ui.statusLine({ model: p.model, cwd, input: totals.input, output: totals.output, cost });
|
|
1623
|
+
};
|
|
1624
|
+
let closed = false;
|
|
1625
|
+
reader.on("close", () => closed = true);
|
|
1626
|
+
const next = () => {
|
|
1627
|
+
if (closed) return;
|
|
1628
|
+
status();
|
|
1629
|
+
reader.prompt();
|
|
1630
|
+
};
|
|
1631
|
+
const commands = {
|
|
1632
|
+
help: () => ui.line(
|
|
1633
|
+
["/help show this help", "/model list profiles; /model <name> switches; /model add adds one", "/clear start a fresh conversation", "/config show config location and profiles", "/exit quit (Ctrl+C twice also works)"].map((l) => theme.muted(" " + l)).join("\n")
|
|
1634
|
+
),
|
|
1635
|
+
exit: () => {
|
|
1636
|
+
reader.close();
|
|
1637
|
+
},
|
|
1638
|
+
clear: async () => {
|
|
1639
|
+
messages.length = 0;
|
|
1640
|
+
session = newSession(cwd, profileName);
|
|
1641
|
+
totals.input = totals.output = 0;
|
|
1642
|
+
if (process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[H");
|
|
1643
|
+
await showBanner();
|
|
1644
|
+
},
|
|
1645
|
+
config: () => {
|
|
1646
|
+
ui.line(theme.muted(` config: ${configPath()}`));
|
|
1647
|
+
for (const [n, p] of Object.entries(cfg.profiles)) {
|
|
1648
|
+
ui.line(` ${n === profileName ? theme.accent("\u25CF") : " "} ${n} ${theme.muted(`${p.type} \xB7 ${p.model}${p.baseUrl ? " \xB7 " + p.baseUrl : ""} \xB7 key ${maskKey(p.apiKey)}`)}`);
|
|
1649
|
+
}
|
|
1650
|
+
ui.line(theme.muted(` pet: ${cfg.pet} animations: ${cfg.animations}`));
|
|
1651
|
+
},
|
|
1652
|
+
model: async (arg) => {
|
|
1653
|
+
if (arg === "add") {
|
|
1654
|
+
cfg = await setupProfile(ask, cfg);
|
|
1655
|
+
arg = cfg.activeProfile;
|
|
1656
|
+
}
|
|
1657
|
+
if (!arg) {
|
|
1658
|
+
for (const [n, p] of Object.entries(cfg.profiles)) ui.line(` ${n === profileName ? theme.accent("\u25CF") : " "} ${n} ${theme.muted(`${p.type} \xB7 ${p.model}`)}`);
|
|
1659
|
+
return ui.info(" switch with /model <name>");
|
|
1660
|
+
}
|
|
1661
|
+
if (!cfg.profiles[arg]) return ui.error(`Unknown profile "${arg}".`);
|
|
1662
|
+
try {
|
|
1663
|
+
useProfile(arg);
|
|
1664
|
+
cfg.activeProfile = arg;
|
|
1665
|
+
saveConfig(cfg);
|
|
1666
|
+
ui.info(` now using ${arg} (${profile().model})`);
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
ui.error(e.message);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
next();
|
|
1673
|
+
for await (const raw of reader) {
|
|
1674
|
+
const line = raw.trim();
|
|
1675
|
+
if (line.startsWith("/")) {
|
|
1676
|
+
const [cmd, ...rest] = line.slice(1).split(/\s+/);
|
|
1677
|
+
const fn = commands[cmd];
|
|
1678
|
+
if (fn) await fn(rest.join(" "));
|
|
1679
|
+
else ui.error(`Unknown command /${cmd}. Try /help.`);
|
|
1680
|
+
} else if (line) {
|
|
1681
|
+
await runPrompt(line);
|
|
1682
|
+
}
|
|
1683
|
+
next();
|
|
1684
|
+
}
|
|
1685
|
+
saveSession(session);
|
|
1686
|
+
process.exit(0);
|
|
1687
|
+
}
|
|
1688
|
+
main().catch((e) => {
|
|
1689
|
+
console.error(theme.error(e?.message ?? String(e)));
|
|
1690
|
+
process.exit(1);
|
|
1691
|
+
});
|
|
1692
|
+
export {
|
|
1693
|
+
setupProfile
|
|
1694
|
+
};
|