blun-king-cli 4.1.1 → 5.0.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/api.js +965 -0
- package/blun-cli.js +763 -0
- package/blunking-api.js +7 -0
- package/bot.js +188 -0
- package/browser-controller.js +76 -0
- package/chat-memory.js +103 -0
- package/file-helper.js +63 -0
- package/fuzzy-match.js +78 -0
- package/identities.js +106 -0
- package/installer.js +160 -0
- package/job-manager.js +146 -0
- package/local-data.js +71 -0
- package/message-builder.js +28 -0
- package/noisy-evals.js +38 -0
- package/package.json +17 -4
- package/palace-memory.js +246 -0
- package/reference-inspector.js +228 -0
- package/runtime.js +555 -0
- package/task-executor.js +104 -0
- package/tests/browser-controller.test.js +42 -0
- package/tests/cli.test.js +93 -0
- package/tests/file-helper.test.js +18 -0
- package/tests/installer.test.js +39 -0
- package/tests/job-manager.test.js +99 -0
- package/tests/merge-compat.test.js +77 -0
- package/tests/messages.test.js +23 -0
- package/tests/noisy-evals.test.js +12 -0
- package/tests/noisy-intent-corpus.test.js +45 -0
- package/tests/reference-inspector.test.js +36 -0
- package/tests/runtime.test.js +119 -0
- package/tests/task-executor.test.js +40 -0
- package/tests/tools.test.js +23 -0
- package/tests/user-profile.test.js +66 -0
- package/tests/website-builder.test.js +66 -0
- package/tmp-build-smoke/nicrazy-landing/index.html +53 -0
- package/tmp-build-smoke/nicrazy-landing/style.css +110 -0
- package/tmp-shot-smoke/website-shot-1776006760424.png +0 -0
- package/tmp-shot-smoke/website-shot-1776007850007.png +0 -0
- package/tmp-shot-smoke/website-shot-1776007886209.png +0 -0
- package/tmp-shot-smoke/website-shot-1776007903766.png +0 -0
- package/tmp-shot-smoke/website-shot-1776008737117.png +0 -0
- package/tmp-shot-smoke/website-shot-1776008988859.png +0 -0
- package/tmp-smoke/nicrazy-landing/index.html +66 -0
- package/tmp-smoke/nicrazy-landing/style.css +104 -0
- package/tools.js +177 -0
- package/user-profile.js +395 -0
- package/website-builder.js +394 -0
- package/website-shot-1776010648230.png +0 -0
- package/website_builder.txt +38 -0
- package/bin/blun.js +0 -3196
- package/setup.js +0 -30
package/blun-cli.js
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const readline = require("readline");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const { spawn } = require("child_process");
|
|
7
|
+
const { listIdentities, getIdentity } = require("./identities");
|
|
8
|
+
const { installSource, EXTENSIONS_DIR } = require("./installer");
|
|
9
|
+
|
|
10
|
+
const HOME = process.env.BLUN_HOME || path.join(os.homedir(), ".blun");
|
|
11
|
+
const CONFIG_FILE = path.join(HOME, "config.json");
|
|
12
|
+
if (!fs.existsSync(HOME)) fs.mkdirSync(HOME, { recursive: true });
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONFIG = {
|
|
15
|
+
apiUrl: process.env.BLUN_API_URL || "http://127.0.0.1:3200",
|
|
16
|
+
token: process.env.BLUN_API_TOKEN || "",
|
|
17
|
+
streamLevel: "normal"
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function loadConfig() {
|
|
21
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
22
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
23
|
+
return { ...DEFAULT_CONFIG };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ...DEFAULT_CONFIG };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function apiRequest(cfg, method, endpoint, body) {
|
|
34
|
+
const headers = { "Content-Type": "application/json" };
|
|
35
|
+
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
|
|
36
|
+
|
|
37
|
+
const resp = await fetch(`${cfg.apiUrl}${endpoint}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers,
|
|
40
|
+
body: body ? JSON.stringify(body) : undefined
|
|
41
|
+
});
|
|
42
|
+
const text = await resp.text();
|
|
43
|
+
let data = {};
|
|
44
|
+
try {
|
|
45
|
+
data = JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
data = { raw: text };
|
|
48
|
+
}
|
|
49
|
+
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function apiStreamRequest(cfg, endpoint, body, handlers = {}) {
|
|
54
|
+
const headers = { "Content-Type": "application/json", Accept: "text/event-stream" };
|
|
55
|
+
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
|
|
56
|
+
|
|
57
|
+
const resp = await fetch(`${cfg.apiUrl}${endpoint}`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers,
|
|
60
|
+
body: JSON.stringify(body || {})
|
|
61
|
+
});
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
const text = await resp.text();
|
|
64
|
+
throw new Error(text || `HTTP ${resp.status}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const decoder = new TextDecoder();
|
|
68
|
+
const reader = resp.body.getReader();
|
|
69
|
+
let buffer = "";
|
|
70
|
+
let currentEvent = "message";
|
|
71
|
+
|
|
72
|
+
while (true) {
|
|
73
|
+
const chunk = await reader.read();
|
|
74
|
+
if (chunk.done) break;
|
|
75
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
76
|
+
|
|
77
|
+
while (buffer.includes("\n\n")) {
|
|
78
|
+
const index = buffer.indexOf("\n\n");
|
|
79
|
+
const raw = buffer.slice(0, index);
|
|
80
|
+
buffer = buffer.slice(index + 2);
|
|
81
|
+
if (!raw.trim()) continue;
|
|
82
|
+
|
|
83
|
+
let event = currentEvent;
|
|
84
|
+
const dataLines = [];
|
|
85
|
+
for (const line of raw.split("\n")) {
|
|
86
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
87
|
+
if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!dataLines.length) continue;
|
|
91
|
+
const payload = JSON.parse(dataLines.join("\n"));
|
|
92
|
+
currentEvent = "message";
|
|
93
|
+
|
|
94
|
+
if (event === "meta" && handlers.onMeta) handlers.onMeta(payload);
|
|
95
|
+
if (event === "token" && handlers.onToken) handlers.onToken(payload);
|
|
96
|
+
if (event === "done" && handlers.onDone) handlers.onDone(payload);
|
|
97
|
+
if (event === "error" && handlers.onError) handlers.onError(payload);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function line(char = "-", len = 48) {
|
|
103
|
+
return char.repeat(len);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isPlaceholderContent(content) {
|
|
107
|
+
return typeof content === "string" && content.length === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function saveReturnedFiles(workdir, files) {
|
|
111
|
+
if (!Array.isArray(files)) return [];
|
|
112
|
+
const written = [];
|
|
113
|
+
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
if (!file || !file.path || typeof file.content !== "string") continue;
|
|
116
|
+
if (isPlaceholderContent(file.content)) continue;
|
|
117
|
+
const target = path.resolve(workdir, file.path);
|
|
118
|
+
if (!target.startsWith(path.resolve(workdir))) continue;
|
|
119
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
120
|
+
fs.writeFileSync(target, file.content, "utf8");
|
|
121
|
+
written.push(target);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return written;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatAnswerBlock(prefix, text) {
|
|
128
|
+
return String(text || "")
|
|
129
|
+
.split("\n")
|
|
130
|
+
.map((entry) => `${prefix}${entry}`)
|
|
131
|
+
.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseLearnCommand(input) {
|
|
135
|
+
const raw = String(input || "").replace(/^\/learn\s+/i, "").trim();
|
|
136
|
+
if (!raw) return { category: "general", content: "" };
|
|
137
|
+
const alias = raw.match(/^alias\s+(.+?)=(.+)$/i);
|
|
138
|
+
if (alias) {
|
|
139
|
+
return { category: "system", content: `${alias[1].trim()} = ${alias[2].trim()}` };
|
|
140
|
+
}
|
|
141
|
+
const prefer = raw.match(/^prefer\s+(.+)$/i);
|
|
142
|
+
if (prefer) {
|
|
143
|
+
return { category: "system", content: `Ich bevorzuge ${prefer[1].trim()}.` };
|
|
144
|
+
}
|
|
145
|
+
const forbid = raw.match(/^forbid\s+(.+)$/i);
|
|
146
|
+
if (forbid) {
|
|
147
|
+
return { category: "system", content: `Mach niemals ${forbid[1].trim()}.` };
|
|
148
|
+
}
|
|
149
|
+
const workflow = raw.match(/^workflow\s+(.+?)=(.+)$/i);
|
|
150
|
+
if (workflow) {
|
|
151
|
+
return { category: "system", content: `Wenn ich ${workflow[1].trim()} sage, meine ich ${workflow[2].trim()}.` };
|
|
152
|
+
}
|
|
153
|
+
const match = raw.match(/^([a-z0-9_-]+)\s*:\s*([\s\S]+)$/i);
|
|
154
|
+
if (match) {
|
|
155
|
+
return {
|
|
156
|
+
category: match[1].toLowerCase(),
|
|
157
|
+
content: match[2].trim()
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { category: "general", content: raw };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseResumeCommand(input) {
|
|
164
|
+
const match = String(input || "").match(/^\/resume\s+([a-zA-Z0-9._-]+)/);
|
|
165
|
+
return match ? match[1] : "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseWatchCommand(input) {
|
|
169
|
+
const match = String(input || "").match(/^\/watch\s+([a-zA-Z0-9._-]+)/);
|
|
170
|
+
return match ? match[1] : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatFlags(flags) {
|
|
174
|
+
if (!Array.isArray(flags) || flags.length === 0) return "clean";
|
|
175
|
+
return flags.join(", ");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatRoutingSummary(data) {
|
|
179
|
+
const confidence = data.routing?.confidence ?? data.confidence;
|
|
180
|
+
const pct = typeof confidence === "number" ? `${Math.round(confidence * 100)}%` : "-";
|
|
181
|
+
return `routing: ${data.task_type || data.type || "-"} confidence: ${pct}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatLearnSummary(data) {
|
|
185
|
+
const learned = data.learned || {};
|
|
186
|
+
const types = learned.types || {};
|
|
187
|
+
return [
|
|
188
|
+
"",
|
|
189
|
+
" Learn",
|
|
190
|
+
` ${line()}`,
|
|
191
|
+
` user: ${data.user_id || "-"}`,
|
|
192
|
+
` category: ${data.category || "general"}`,
|
|
193
|
+
` aliases: ${learned.aliasesLearned || 0} snippets: ${learned.snippetsLearned || 0}`,
|
|
194
|
+
` types: pref=${types.preferences || 0} fact=${types.facts || 0} workflow=${types.workflows || 0} guard=${types.guardrails || 0}`,
|
|
195
|
+
` file: ${data.file || "-"}`,
|
|
196
|
+
""
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildQueueSnapshot({ mode, selectedIdentity, currentJob, queue }) {
|
|
201
|
+
const lines = [
|
|
202
|
+
"",
|
|
203
|
+
" Queue",
|
|
204
|
+
` ${line()}`,
|
|
205
|
+
` mode: ${mode} identity: ${selectedIdentity}`,
|
|
206
|
+
` running: ${currentJob ? `#${currentJob.id} ${currentJob.message}` : "idle"}`,
|
|
207
|
+
` pending: ${Array.isArray(queue) ? queue.length : 0}`
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
for (const job of queue || []) {
|
|
211
|
+
lines.push(` - #${job.id} ${job.message}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
lines.push("");
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildTaskboard({ mode, selectedIdentity, currentJob, queue, jobs = [], lastArtifacts = [] }) {
|
|
219
|
+
const lines = [
|
|
220
|
+
"",
|
|
221
|
+
" Taskboard",
|
|
222
|
+
` ${line()}`,
|
|
223
|
+
` mode: ${mode}`,
|
|
224
|
+
` identity: ${selectedIdentity}`,
|
|
225
|
+
` running: ${currentJob ? `#${currentJob.id} ${currentJob.message}` : "idle"}`,
|
|
226
|
+
` pending: ${queue.length}`,
|
|
227
|
+
` remote jobs: ${jobs.length}`
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
if (queue.length) {
|
|
231
|
+
lines.push(" pending queue:");
|
|
232
|
+
for (const job of queue.slice(0, 5)) lines.push(` - #${job.id} ${job.message}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (jobs.length) {
|
|
236
|
+
lines.push(" recent remote jobs:");
|
|
237
|
+
for (const job of jobs.slice(0, 5)) {
|
|
238
|
+
const summary = job.summary || {};
|
|
239
|
+
const detail = [
|
|
240
|
+
summary.taskType || "",
|
|
241
|
+
typeof summary.fileCount === "number" && summary.fileCount ? `${summary.fileCount} file` : "",
|
|
242
|
+
typeof summary.referenceCount === "number" && summary.referenceCount ? `${summary.referenceCount} ref` : ""
|
|
243
|
+
].filter(Boolean).join(" | ");
|
|
244
|
+
lines.push(` - ${job.id} ${job.status} ${job.endpoint}${detail ? ` ${detail}` : ""}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (lastArtifacts.length) {
|
|
249
|
+
lines.push(" recent artifacts:");
|
|
250
|
+
for (const artifact of lastArtifacts.slice(0, 5)) lines.push(` - ${artifact}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
lines.push("");
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function formatJobResultLines(data, job, workdir, locallyWritten) {
|
|
258
|
+
const header = ` BLUN King ${job.label} ${data.task_type}/${data.role} ${data.identity || "auto"}`;
|
|
259
|
+
const lines = ["", header, ` ${line()}`, ` ${formatRoutingSummary(data)}`];
|
|
260
|
+
|
|
261
|
+
if (data.quality) {
|
|
262
|
+
lines.push(` quality: ${data.quality.score ?? "-"} flags: ${formatFlags(data.quality.flags)}${data.quality.retried ? " retried" : ""}${data.quality.blocked ? " blocked" : ""}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const step of data.steps || []) {
|
|
266
|
+
lines.push(` [step ${step.step}] ${step.action}${step.detail ? ` -> ${step.detail}` : ""}${step.files ? ` -> ${step.files.join(", ")}` : ""}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (Array.isArray(data.references) && data.references.length) {
|
|
270
|
+
for (const ref of data.references) {
|
|
271
|
+
if (ref.error) lines.push(` [reference] ${ref.url} -> ${ref.error}`);
|
|
272
|
+
else lines.push(` [reference] ${ref.url} -> ${ref.title || "no title"}${ref.screenshotPath ? ` (${ref.screenshotPath})` : ""}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (data.artifact?.filename) {
|
|
277
|
+
lines.push(` artifact: ${data.artifact.filename}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (Array.isArray(data.files) && data.files.length) {
|
|
281
|
+
const fileList = data.files.map((file) => file.path).filter(Boolean);
|
|
282
|
+
if (fileList.length) lines.push(` files: ${fileList.join(", ")}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (locallyWritten.length) {
|
|
286
|
+
lines.push(` local: ${locallyWritten.map((entry) => path.relative(workdir, entry)).join(", ")}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (data.answer) {
|
|
290
|
+
lines.push(formatAnswerBlock(" ", data.answer));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (data.status || typeof data.verified === "boolean") {
|
|
294
|
+
lines.push(` status: ${data.status || "completed"} verified: ${data.verified ? "yes" : "no"}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (data.tokens) {
|
|
298
|
+
lines.push(` tokens: ${data.tokens.total ?? "-"} total`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
lines.push("");
|
|
302
|
+
return lines;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatRemoteJobLines(job) {
|
|
306
|
+
const summary = job.summary || {};
|
|
307
|
+
const lines = [
|
|
308
|
+
"",
|
|
309
|
+
` Job ${job.id}`,
|
|
310
|
+
` ${line()}`,
|
|
311
|
+
` endpoint: ${job.endpoint}`,
|
|
312
|
+
` status: ${job.status}`,
|
|
313
|
+
` session: ${job.sessionId}`
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
if (job.resumedFrom) lines.push(` resumed_from: ${job.resumedFrom}`);
|
|
317
|
+
if (summary.taskType) lines.push(` task: ${summary.taskType}`);
|
|
318
|
+
if (typeof summary.durationMs === "number") lines.push(` duration_ms: ${summary.durationMs}`);
|
|
319
|
+
if (summary.fileCount) lines.push(` files: ${summary.fileCount}`);
|
|
320
|
+
if (summary.referenceCount) lines.push(` references: ${summary.referenceCount}`);
|
|
321
|
+
if (summary.artifact) lines.push(` artifact: ${summary.artifact}`);
|
|
322
|
+
if (job.error) lines.push(` error: ${job.error}`);
|
|
323
|
+
if (job.result?.answer) lines.push(formatAnswerBlock(" ", job.result.answer));
|
|
324
|
+
lines.push("");
|
|
325
|
+
return lines;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function runLocalShell(command, workdir) {
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const child = spawn(command, {
|
|
331
|
+
cwd: workdir,
|
|
332
|
+
shell: true,
|
|
333
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
let stdout = "";
|
|
337
|
+
let stderr = "";
|
|
338
|
+
|
|
339
|
+
child.stdout.on("data", (chunk) => {
|
|
340
|
+
stdout += chunk.toString();
|
|
341
|
+
});
|
|
342
|
+
child.stderr.on("data", (chunk) => {
|
|
343
|
+
stderr += chunk.toString();
|
|
344
|
+
});
|
|
345
|
+
child.on("error", reject);
|
|
346
|
+
child.on("close", (code) => {
|
|
347
|
+
if (code === 0) resolve({ code, stdout, stderr });
|
|
348
|
+
else reject(new Error(stderr.trim() || `Command failed with exit code ${code}`));
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function main() {
|
|
354
|
+
const cfg = loadConfig();
|
|
355
|
+
const workdir = process.cwd();
|
|
356
|
+
const sessionId = `cli_${Date.now()}`;
|
|
357
|
+
const queue = [];
|
|
358
|
+
let processing = false;
|
|
359
|
+
let currentJob = null;
|
|
360
|
+
let mode = "agent";
|
|
361
|
+
let selectedIdentity = "auto";
|
|
362
|
+
let cliClosed = false;
|
|
363
|
+
let nextJobId = 1;
|
|
364
|
+
let nextBtwId = 1;
|
|
365
|
+
const lastArtifacts = [];
|
|
366
|
+
|
|
367
|
+
const rl = readline.createInterface({
|
|
368
|
+
input: process.stdin,
|
|
369
|
+
output: process.stdout,
|
|
370
|
+
prompt: ""
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
function promptLabel() {
|
|
374
|
+
return ` You [${mode}/${selectedIdentity}]> `;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function renderPrompt() {
|
|
378
|
+
if (cliClosed || rl.closed || !process.stdin.isTTY) return;
|
|
379
|
+
rl.setPrompt(promptLabel());
|
|
380
|
+
rl.prompt(true);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function writeLines(lines) {
|
|
384
|
+
const normalizedLines = Array.isArray(lines) ? lines : [lines];
|
|
385
|
+
if (process.stdout.isTTY) {
|
|
386
|
+
readline.clearLine(process.stdout, 0);
|
|
387
|
+
readline.cursorTo(process.stdout, 0);
|
|
388
|
+
}
|
|
389
|
+
process.stdout.write(`${normalizedLines.filter(Boolean).join("\n")}\n`);
|
|
390
|
+
if (!cliClosed && !rl.closed) {
|
|
391
|
+
renderPrompt();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function showHelp() {
|
|
396
|
+
writeLines([
|
|
397
|
+
"",
|
|
398
|
+
" Commands:",
|
|
399
|
+
" /chat use chat endpoint",
|
|
400
|
+
" /agent use agent endpoint",
|
|
401
|
+
" /status show runtime status",
|
|
402
|
+
" /health show health",
|
|
403
|
+
" /artifacts list artifacts",
|
|
404
|
+
" /identities list available identities",
|
|
405
|
+
" /identity <name|auto> set a fixed identity or auto",
|
|
406
|
+
" /mode show current mode, identity and queue state",
|
|
407
|
+
" /queue show running job and pending queue",
|
|
408
|
+
" /cancel-pending drop all pending jobs but keep the running one",
|
|
409
|
+
" /bg <message> submit a background job to the API",
|
|
410
|
+
" /jobs list background jobs for this session",
|
|
411
|
+
" /watch <jobId> poll a background job until it finishes",
|
|
412
|
+
" /resume <jobId> restart an old background job and queue it again",
|
|
413
|
+
" /board show a compact taskboard",
|
|
414
|
+
" /learn [cat:] <text> learn aliases/preferences/workflows/guardrails",
|
|
415
|
+
" /btw <message> send an immediate side message without waiting for queue",
|
|
416
|
+
" /install <source> install plugin/skill/package locally",
|
|
417
|
+
" /browser-open <url> open a page in the local browser session",
|
|
418
|
+
" /browser-click <sel> click a selector in the local browser session",
|
|
419
|
+
" /browser-type <sel> <text> type into a selector",
|
|
420
|
+
" /browser-press <key> press a key in the local browser session",
|
|
421
|
+
" /browser-screenshot [path] save a screenshot from the local browser session",
|
|
422
|
+
" /browser-status show current browser snapshot",
|
|
423
|
+
" /browser-close close the local browser session",
|
|
424
|
+
" /local <command> run a local shell command",
|
|
425
|
+
" /ssh <user@host cmd> run a local SSH deployment command",
|
|
426
|
+
" /exit quit",
|
|
427
|
+
""
|
|
428
|
+
]);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function processQueue() {
|
|
432
|
+
if (processing) return;
|
|
433
|
+
processing = true;
|
|
434
|
+
|
|
435
|
+
while (queue.length) {
|
|
436
|
+
const job = queue.shift();
|
|
437
|
+
currentJob = job;
|
|
438
|
+
try {
|
|
439
|
+
writeLines([` [running #${job.id}] ${job.message} pending:${queue.length}`]);
|
|
440
|
+
if (job.endpoint === "/chat" && cfg.streamLevel !== "off") {
|
|
441
|
+
let finalPayload = null;
|
|
442
|
+
let headerPrinted = false;
|
|
443
|
+
process.stdout.write("\n");
|
|
444
|
+
await apiStreamRequest(cfg, "/chat/stream", {
|
|
445
|
+
message: job.message,
|
|
446
|
+
session_id: sessionId,
|
|
447
|
+
workdir,
|
|
448
|
+
identity: selectedIdentity
|
|
449
|
+
}, {
|
|
450
|
+
onMeta(payload) {
|
|
451
|
+
if (headerPrinted) return;
|
|
452
|
+
headerPrinted = true;
|
|
453
|
+
writeLines([
|
|
454
|
+
"",
|
|
455
|
+
` BLUN King ${job.label} ${payload.task_type}/${payload.role} ${payload.identity || "auto"}`,
|
|
456
|
+
` ${line()}`,
|
|
457
|
+
` routing: ${payload.routing?.task?.type || payload.task_type || "-"} confidence: ${typeof payload.routing?.confidence === "number" ? `${Math.round(payload.routing.confidence * 100)}%` : "-"}`
|
|
458
|
+
]);
|
|
459
|
+
},
|
|
460
|
+
onToken(payload) {
|
|
461
|
+
const text = String(payload.text || "");
|
|
462
|
+
if (!text) return;
|
|
463
|
+
if (process.stdout.isTTY) {
|
|
464
|
+
readline.clearLine(process.stdout, 0);
|
|
465
|
+
readline.cursorTo(process.stdout, 0);
|
|
466
|
+
}
|
|
467
|
+
process.stdout.write(` ${text}`);
|
|
468
|
+
if (!cliClosed && !rl.closed) renderPrompt();
|
|
469
|
+
},
|
|
470
|
+
onDone(payload) {
|
|
471
|
+
finalPayload = payload;
|
|
472
|
+
},
|
|
473
|
+
onError(payload) {
|
|
474
|
+
throw new Error(payload.error || "Stream failed");
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
process.stdout.write("\n");
|
|
478
|
+
if (finalPayload) {
|
|
479
|
+
writeLines([
|
|
480
|
+
` quality: ${finalPayload.quality?.score ?? "-"} flags: ${formatFlags(finalPayload.quality?.reasons || finalPayload.quality?.flags || [])}${finalPayload.quality?.retried ? " retried" : ""}`,
|
|
481
|
+
""
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
const data = await apiRequest(cfg, "POST", job.endpoint, {
|
|
486
|
+
message: job.message,
|
|
487
|
+
goal: job.message,
|
|
488
|
+
session_id: sessionId,
|
|
489
|
+
workdir,
|
|
490
|
+
identity: selectedIdentity
|
|
491
|
+
});
|
|
492
|
+
const locallyWritten = saveReturnedFiles(workdir, data.files);
|
|
493
|
+
for (const file of locallyWritten) {
|
|
494
|
+
lastArtifacts.unshift(path.relative(workdir, file));
|
|
495
|
+
}
|
|
496
|
+
writeLines(formatJobResultLines(data, job, workdir, locallyWritten));
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
writeLines([` ERROR [#${job.id}]: ${error.message}`]);
|
|
500
|
+
} finally {
|
|
501
|
+
currentJob = null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
processing = false;
|
|
506
|
+
renderPrompt();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const health = await fetch(`${cfg.apiUrl}/health`).then((resp) => resp.json());
|
|
511
|
+
console.log(`Connected to BLUN King API (${health.model})`);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
console.error(`API connection failed: ${error.message}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
console.log("");
|
|
517
|
+
console.log(" BLUN KING CLI v5.0.0");
|
|
518
|
+
console.log(` API: ${cfg.apiUrl}`);
|
|
519
|
+
console.log(` Dir: ${workdir}`);
|
|
520
|
+
console.log(" Chat input stays available while jobs run.");
|
|
521
|
+
console.log(" Normal input goes into pending. /btw uses an immediate side lane.");
|
|
522
|
+
console.log("");
|
|
523
|
+
renderPrompt();
|
|
524
|
+
|
|
525
|
+
rl.on("close", () => {
|
|
526
|
+
cliClosed = true;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
rl.on("line", (input) => {
|
|
530
|
+
const message = input.trim();
|
|
531
|
+
if (!message) return renderPrompt();
|
|
532
|
+
|
|
533
|
+
if (message === "/exit" || message === "/quit") process.exit(0);
|
|
534
|
+
if (message === "/help") return showHelp();
|
|
535
|
+
if (message === "/chat") {
|
|
536
|
+
mode = "chat";
|
|
537
|
+
return writeLines([" Mode switched to chat."]);
|
|
538
|
+
}
|
|
539
|
+
if (message === "/agent") {
|
|
540
|
+
mode = "agent";
|
|
541
|
+
return writeLines([" Mode switched to agent."]);
|
|
542
|
+
}
|
|
543
|
+
if (message === "/identities") {
|
|
544
|
+
const lines = ["", " Available identities:"];
|
|
545
|
+
for (const identity of listIdentities()) {
|
|
546
|
+
lines.push(` - ${identity.id}: ${identity.name}`);
|
|
547
|
+
}
|
|
548
|
+
lines.push("");
|
|
549
|
+
return writeLines(lines);
|
|
550
|
+
}
|
|
551
|
+
if (message.startsWith("/identity")) {
|
|
552
|
+
const nextIdentity = message.split(/\s+/)[1] || "auto";
|
|
553
|
+
if (nextIdentity !== "auto" && getIdentity(nextIdentity).id !== nextIdentity) {
|
|
554
|
+
return writeLines([` Unknown identity: ${nextIdentity}`]);
|
|
555
|
+
}
|
|
556
|
+
selectedIdentity = nextIdentity;
|
|
557
|
+
return writeLines([` Identity set to ${selectedIdentity}.`]);
|
|
558
|
+
}
|
|
559
|
+
if (message === "/mode" || message === "/queue") {
|
|
560
|
+
return writeLines(buildQueueSnapshot({ mode, selectedIdentity, currentJob, queue }));
|
|
561
|
+
}
|
|
562
|
+
if (message === "/board") {
|
|
563
|
+
apiRequest(cfg, "GET", `/jobs?session_id=${encodeURIComponent(sessionId)}`)
|
|
564
|
+
.then((data) => writeLines(buildTaskboard({ mode, selectedIdentity, currentJob, queue, jobs: data.jobs || [], lastArtifacts })))
|
|
565
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
566
|
+
return renderPrompt();
|
|
567
|
+
}
|
|
568
|
+
if (message === "/cancel-pending") {
|
|
569
|
+
const dropped = queue.length;
|
|
570
|
+
queue.splice(0, queue.length);
|
|
571
|
+
return writeLines([` Pending queue cleared. Dropped: ${dropped}`]);
|
|
572
|
+
}
|
|
573
|
+
if (message === "/jobs") {
|
|
574
|
+
apiRequest(cfg, "GET", `/jobs?session_id=${encodeURIComponent(sessionId)}`)
|
|
575
|
+
.then((data) => {
|
|
576
|
+
const lines = ["", " Jobs", ` ${line()}`];
|
|
577
|
+
for (const job of data.jobs || []) {
|
|
578
|
+
const summary = job.summary || {};
|
|
579
|
+
lines.push(` - ${job.id} ${job.status} ${job.endpoint}${summary.taskType ? ` ${summary.taskType}` : ""}${summary.durationMs ? ` ${summary.durationMs}ms` : ""}`);
|
|
580
|
+
}
|
|
581
|
+
lines.push("");
|
|
582
|
+
writeLines(lines);
|
|
583
|
+
})
|
|
584
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
585
|
+
return renderPrompt();
|
|
586
|
+
}
|
|
587
|
+
if (message.startsWith("/watch ")) {
|
|
588
|
+
const jobId = parseWatchCommand(message);
|
|
589
|
+
if (!jobId) return writeLines([" Usage: /watch <jobId>"]);
|
|
590
|
+
(async () => {
|
|
591
|
+
let seenLogCount = 0;
|
|
592
|
+
for (;;) {
|
|
593
|
+
const job = await apiRequest(cfg, "GET", `/jobs/${jobId}`);
|
|
594
|
+
const logData = await apiRequest(cfg, "GET", `/jobs/${jobId}/logs`);
|
|
595
|
+
const newLogs = (logData.logs || []).slice(seenLogCount);
|
|
596
|
+
for (const entry of newLogs) {
|
|
597
|
+
writeLines([` [job ${jobId}] ${entry.event}${entry.payload?.error ? ` -> ${entry.payload.error}` : ""}`]);
|
|
598
|
+
}
|
|
599
|
+
seenLogCount = (logData.logs || []).length;
|
|
600
|
+
if (job.status === "completed" || job.status === "failed") {
|
|
601
|
+
writeLines(formatRemoteJobLines(job));
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
605
|
+
}
|
|
606
|
+
})().catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
607
|
+
return renderPrompt();
|
|
608
|
+
}
|
|
609
|
+
if (message.startsWith("/resume ")) {
|
|
610
|
+
const jobId = parseResumeCommand(message);
|
|
611
|
+
if (!jobId) return writeLines([" Usage: /resume <jobId>"]);
|
|
612
|
+
apiRequest(cfg, "POST", `/jobs/${jobId}/resume`, {})
|
|
613
|
+
.then((data) => writeLines([` resumed job: ${data.job_id} (from ${data.resumed_from})`]))
|
|
614
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
615
|
+
return renderPrompt();
|
|
616
|
+
}
|
|
617
|
+
if (message.startsWith("/bg ")) {
|
|
618
|
+
const payloadMessage = message.slice(4).trim();
|
|
619
|
+
apiRequest(cfg, "POST", mode === "agent" ? "/agent" : "/chat", {
|
|
620
|
+
message: payloadMessage,
|
|
621
|
+
goal: payloadMessage,
|
|
622
|
+
session_id: sessionId,
|
|
623
|
+
workdir,
|
|
624
|
+
identity: selectedIdentity,
|
|
625
|
+
background: true
|
|
626
|
+
})
|
|
627
|
+
.then((data) => writeLines([` background job queued: ${data.job_id}`]))
|
|
628
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
629
|
+
return renderPrompt();
|
|
630
|
+
}
|
|
631
|
+
if (message.startsWith("/learn ")) {
|
|
632
|
+
const payload = parseLearnCommand(message);
|
|
633
|
+
apiRequest(cfg, "POST", "/learn", {
|
|
634
|
+
session_id: sessionId,
|
|
635
|
+
user_id: sessionId,
|
|
636
|
+
title: `cli-${Date.now()}`,
|
|
637
|
+
category: payload.category,
|
|
638
|
+
content: payload.content
|
|
639
|
+
})
|
|
640
|
+
.then((data) => writeLines(formatLearnSummary(data)))
|
|
641
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
642
|
+
return renderPrompt();
|
|
643
|
+
}
|
|
644
|
+
if (message.startsWith("/btw ")) {
|
|
645
|
+
const btwMessage = message.slice(5).trim();
|
|
646
|
+
const btwId = nextBtwId++;
|
|
647
|
+
writeLines([` [btw #${btwId}] sent immediately`]);
|
|
648
|
+
apiRequest(cfg, "POST", mode === "agent" ? "/agent" : "/chat", {
|
|
649
|
+
message: btwMessage,
|
|
650
|
+
goal: btwMessage,
|
|
651
|
+
session_id: sessionId,
|
|
652
|
+
workdir,
|
|
653
|
+
identity: selectedIdentity
|
|
654
|
+
})
|
|
655
|
+
.then((data) => writeLines(formatJobResultLines(data, { id: `btw-${btwId}`, label: `BTW#${btwId}` }, workdir, saveReturnedFiles(workdir, data.files))))
|
|
656
|
+
.catch((error) => writeLines([` ERROR [BTW#${btwId}]: ${error.message}`]));
|
|
657
|
+
return renderPrompt();
|
|
658
|
+
}
|
|
659
|
+
if (message.startsWith("/install ")) {
|
|
660
|
+
installSource(message.slice(9).trim())
|
|
661
|
+
.then((result) => writeLines([` installed ${result.kind}: ${result.target}`, ` extensions: ${EXTENSIONS_DIR}`]))
|
|
662
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
663
|
+
return renderPrompt();
|
|
664
|
+
}
|
|
665
|
+
if (message.startsWith("/browser-open ")) {
|
|
666
|
+
apiRequest(cfg, "POST", "/browser/open", { url: message.slice(14).trim() })
|
|
667
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
668
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
669
|
+
return renderPrompt();
|
|
670
|
+
}
|
|
671
|
+
if (message.startsWith("/browser-click ")) {
|
|
672
|
+
apiRequest(cfg, "POST", "/browser/click", { selector: message.slice(15).trim() })
|
|
673
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
674
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
675
|
+
return renderPrompt();
|
|
676
|
+
}
|
|
677
|
+
if (message.startsWith("/browser-type ")) {
|
|
678
|
+
const rest = message.slice(14).trim();
|
|
679
|
+
const firstSpace = rest.indexOf(" ");
|
|
680
|
+
const selector = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
|
|
681
|
+
const text = firstSpace === -1 ? "" : rest.slice(firstSpace + 1);
|
|
682
|
+
apiRequest(cfg, "POST", "/browser/type", { selector, text })
|
|
683
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
684
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
685
|
+
return renderPrompt();
|
|
686
|
+
}
|
|
687
|
+
if (message.startsWith("/browser-press ")) {
|
|
688
|
+
apiRequest(cfg, "POST", "/browser/press", { key: message.slice(15).trim() })
|
|
689
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
690
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
691
|
+
return renderPrompt();
|
|
692
|
+
}
|
|
693
|
+
if (message.startsWith("/browser-screenshot")) {
|
|
694
|
+
const target = message.slice("/browser-screenshot".length).trim();
|
|
695
|
+
apiRequest(cfg, "POST", "/browser/screenshot", target ? { path: target } : {})
|
|
696
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
697
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
698
|
+
return renderPrompt();
|
|
699
|
+
}
|
|
700
|
+
if (message === "/browser-status") {
|
|
701
|
+
apiRequest(cfg, "GET", "/browser/status")
|
|
702
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
703
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
704
|
+
return renderPrompt();
|
|
705
|
+
}
|
|
706
|
+
if (message === "/browser-close") {
|
|
707
|
+
apiRequest(cfg, "POST", "/browser/close", {})
|
|
708
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
709
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
710
|
+
return renderPrompt();
|
|
711
|
+
}
|
|
712
|
+
if (message === "/status") {
|
|
713
|
+
apiRequest(cfg, "GET", "/runtime/status")
|
|
714
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
715
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
716
|
+
return renderPrompt();
|
|
717
|
+
}
|
|
718
|
+
if (message === "/health") {
|
|
719
|
+
fetch(`${cfg.apiUrl}/health`)
|
|
720
|
+
.then((resp) => resp.json())
|
|
721
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
722
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
723
|
+
return renderPrompt();
|
|
724
|
+
}
|
|
725
|
+
if (message === "/artifacts") {
|
|
726
|
+
apiRequest(cfg, "GET", "/artifacts")
|
|
727
|
+
.then((data) => writeLines(["", JSON.stringify(data, null, 2), ""]))
|
|
728
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
729
|
+
return renderPrompt();
|
|
730
|
+
}
|
|
731
|
+
if (message.startsWith("/local ")) {
|
|
732
|
+
runLocalShell(message.slice(7), workdir)
|
|
733
|
+
.then((result) => writeLines(["", result.stdout || "(no output)", ""]))
|
|
734
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
735
|
+
return renderPrompt();
|
|
736
|
+
}
|
|
737
|
+
if (message.startsWith("/ssh ")) {
|
|
738
|
+
runLocalShell(`ssh ${message.slice(5)}`, workdir)
|
|
739
|
+
.then((result) => writeLines(["", result.stdout || "(no output)", ""]))
|
|
740
|
+
.catch((error) => writeLines([` ERROR: ${error.message}`]));
|
|
741
|
+
return renderPrompt();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const endpoint = mode === "agent" ? "/agent" : "/chat";
|
|
745
|
+
const job = { id: nextJobId++, label: `#${nextJobId - 1}`, message, endpoint };
|
|
746
|
+
queue.push(job);
|
|
747
|
+
writeLines([` [pending #${job.id}] ${message} queue:${queue.length}`]);
|
|
748
|
+
processQueue();
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (require.main === module) {
|
|
753
|
+
main();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
module.exports = {
|
|
757
|
+
saveReturnedFiles,
|
|
758
|
+
parseLearnCommand,
|
|
759
|
+
parseResumeCommand,
|
|
760
|
+
parseWatchCommand,
|
|
761
|
+
buildQueueSnapshot,
|
|
762
|
+
buildTaskboard
|
|
763
|
+
};
|