@ayoxx/kundex 0.1.9 → 1.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/package.json +1 -1
- package/src/config.ts +19 -3
- package/src/index.ts +551 -17
- package/src/repl.ts +202 -32
- package/src/sdk.ts +320 -35
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -33,8 +33,24 @@ export function saveConfig(config: Partial<KundexConfig>): KundexConfig {
|
|
|
33
33
|
return merged;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export const KUNDEX_BASE_URL = "https://
|
|
36
|
+
export const KUNDEX_BASE_URL = "https://api.kundex.com.ng";
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the base URL to use for API calls.
|
|
40
|
+
*
|
|
41
|
+
* Priority:
|
|
42
|
+
* 1. KUNDEX_BASE_URL environment variable (useful for local dev)
|
|
43
|
+
* 2. Stored baseUrl from config (~/.kundex/config.json)
|
|
44
|
+
* 3. Hard-coded default: https://api.kundex.com.ng
|
|
45
|
+
*
|
|
46
|
+
* Previously, this function ignored the stored baseUrl completely.
|
|
47
|
+
* That made `kundex config set baseUrl <url>` a no-op, which broke
|
|
48
|
+
* local development and self-hosted deployments.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveBaseUrl(config: KundexConfig): string | null {
|
|
51
|
+
return (
|
|
52
|
+
process.env.KUNDEX_BASE_URL?.trim() ||
|
|
53
|
+
config.baseUrl?.trim() ||
|
|
54
|
+
KUNDEX_BASE_URL
|
|
55
|
+
);
|
|
40
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,41 +1,575 @@
|
|
|
1
|
-
import { loadConfig, resolveBaseUrl } from "./config";
|
|
1
|
+
import { loadConfig, saveConfig, resolveBaseUrl } from "./config";
|
|
2
2
|
import { runLogin } from "./login";
|
|
3
3
|
import { runRepl } from "./repl";
|
|
4
|
+
import { Kundex } from "./sdk";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const VERSION = "0.2.0";
|
|
9
|
+
|
|
10
|
+
// ─── Help text ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
\x1b[1m\x1b[36mkundex\x1b[0m — AI coding agent CLI v${VERSION}
|
|
14
|
+
|
|
15
|
+
\x1b[1mUsage:\x1b[0m
|
|
16
|
+
kundex [command] [options]
|
|
17
|
+
|
|
18
|
+
\x1b[1mAuthentication:\x1b[0m
|
|
19
|
+
login Save your Kundex API key
|
|
20
|
+
logout Remove saved credentials
|
|
21
|
+
|
|
22
|
+
\x1b[1mAgent:\x1b[0m
|
|
23
|
+
(no command) Start the interactive AI coding agent REPL
|
|
24
|
+
run <message> Run a single agent turn (non-interactive)
|
|
25
|
+
|
|
26
|
+
\x1b[1mWorkspaces:\x1b[0m
|
|
27
|
+
workspace list List all your agent workspaces
|
|
28
|
+
workspace new [cwd] Create a new workspace (default: current dir)
|
|
29
|
+
workspace delete <id> Delete a workspace
|
|
30
|
+
|
|
31
|
+
\x1b[1mFiles:\x1b[0m
|
|
32
|
+
files list <wsId> List files in a workspace
|
|
33
|
+
files get <wsId> <path> Print a file's content to stdout
|
|
34
|
+
files push <wsId> <localPath> Upload a local file to a workspace
|
|
35
|
+
files push <wsId> <localPath> <remotePath>
|
|
36
|
+
files pull <wsId> <remotePath> Download a workspace file to stdout (or --out)
|
|
37
|
+
|
|
38
|
+
\x1b[1mModels:\x1b[0m
|
|
39
|
+
models List available models
|
|
40
|
+
|
|
41
|
+
\x1b[1mConfiguration:\x1b[0m
|
|
42
|
+
config show Show current configuration
|
|
43
|
+
config set <key> <val> Set a config value
|
|
44
|
+
Keys: model, baseUrl
|
|
45
|
+
|
|
46
|
+
\x1b[1mOther:\x1b[0m
|
|
47
|
+
help, --help, -h Show this help
|
|
48
|
+
version, --version, -v Show version
|
|
49
|
+
|
|
50
|
+
\x1b[1mExamples:\x1b[0m
|
|
51
|
+
kundex login
|
|
52
|
+
kundex # start REPL in current directory
|
|
53
|
+
kundex run "Fix all TypeScript errors"
|
|
54
|
+
kundex models
|
|
55
|
+
kundex workspace list
|
|
56
|
+
kundex workspace new /path/to/project
|
|
57
|
+
kundex workspace delete 42
|
|
58
|
+
kundex files list 17
|
|
59
|
+
kundex files get 17 src/index.ts
|
|
60
|
+
kundex files push 17 ./README.md
|
|
61
|
+
kundex files pull 17 src/index.ts --out ./downloaded.ts
|
|
62
|
+
kundex config set model openai/gpt-oss-120b
|
|
63
|
+
kundex config set baseUrl https://api.kundex.com.ng
|
|
64
|
+
`.trim();
|
|
65
|
+
|
|
66
|
+
// ─── Colour helpers ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const c = {
|
|
69
|
+
reset: "\x1b[0m",
|
|
70
|
+
bold: "\x1b[1m",
|
|
71
|
+
dim: "\x1b[2m",
|
|
72
|
+
cyan: "\x1b[36m",
|
|
73
|
+
green: "\x1b[32m",
|
|
74
|
+
yellow: "\x1b[33m",
|
|
75
|
+
red: "\x1b[31m",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function ok(msg: string) { console.log(`${c.green}${c.bold} ✓${c.reset} ${msg}`); }
|
|
79
|
+
function errMsg(msg: string) { console.error(`${c.red}${c.bold} ✗${c.reset} ${msg}`); }
|
|
80
|
+
function dim(msg: string) { console.log(`${c.dim} ${msg}${c.reset}`); }
|
|
81
|
+
function header(msg: string) { console.log(`\n${c.bold}${c.cyan} ${msg}${c.reset}`); }
|
|
82
|
+
|
|
83
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const PROVIDER_DISPLAY: Record<string, string> = {
|
|
86
|
+
heavstal: "Heavstal",
|
|
87
|
+
gemini: "Gemini",
|
|
88
|
+
groq: "Groq",
|
|
89
|
+
mistral: "Mistral",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
async function cmdModels(config: ReturnType<typeof loadConfig>, baseUrl: string) {
|
|
93
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
94
|
+
header("Available models");
|
|
95
|
+
try {
|
|
96
|
+
const models = await kundex.models.list();
|
|
97
|
+
if (!models.length) { dim("No models found."); return; }
|
|
98
|
+
const nameWidth = Math.max(...models.map((m) => m.name.length), 4);
|
|
99
|
+
const idWidth = Math.max(...models.map((m) => m.id.length), 2);
|
|
100
|
+
const providerWidth = Math.max(...models.map((m) => (m.provider?.length ?? 0)), 8);
|
|
101
|
+
console.log(`\n ${c.dim}${"Name".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} ${"Provider".padEnd(providerWidth)} Context${c.reset}`);
|
|
102
|
+
console.log(` ${c.dim}${"─".repeat(nameWidth + idWidth + providerWidth + 18)}${c.reset}`);
|
|
103
|
+
|
|
104
|
+
// Group by provider for cleaner output
|
|
105
|
+
const grouped: Record<string, typeof models> = {};
|
|
106
|
+
const order: string[] = [];
|
|
107
|
+
for (const m of models) {
|
|
108
|
+
const p = m.provider ?? "other";
|
|
109
|
+
if (!grouped[p]) { grouped[p] = []; order.push(p); }
|
|
110
|
+
grouped[p].push(m);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const provider of order) {
|
|
114
|
+
for (const m of grouped[provider]) {
|
|
115
|
+
const ctx = m.contextWindow >= 1_000_000
|
|
116
|
+
? `${(m.contextWindow / 1_000_000).toFixed(1)}M`
|
|
117
|
+
: m.contextWindow >= 1000
|
|
118
|
+
? `${(m.contextWindow / 1000).toFixed(0)}k`
|
|
119
|
+
: String(m.contextWindow);
|
|
120
|
+
const providerLabel = (PROVIDER_DISPLAY[m.provider ?? ""] ?? m.provider ?? "").padEnd(providerWidth);
|
|
121
|
+
console.log(
|
|
122
|
+
` ${c.bold}${m.name.padEnd(nameWidth)}${c.reset}` +
|
|
123
|
+
` ${c.dim}${m.id.padEnd(idWidth)}${c.reset}` +
|
|
124
|
+
` ${c.cyan}${providerLabel}${c.reset}` +
|
|
125
|
+
` ${c.dim}${ctx}${c.reset}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log();
|
|
130
|
+
} catch (e) {
|
|
131
|
+
errMsg(`Failed to list models: ${(e as Error).message}`);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function cmdWorkspaceList(config: ReturnType<typeof loadConfig>, baseUrl: string) {
|
|
137
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
138
|
+
header("Workspaces");
|
|
139
|
+
try {
|
|
140
|
+
const sessions = await kundex.agent.listSessions();
|
|
141
|
+
|
|
142
|
+
if (!sessions.length) {
|
|
143
|
+
dim("No workspaces yet. Create one with: kundex workspace new <directory>");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`\n ${c.dim}${"ID".padEnd(6)} ${"Name".padEnd(28)} ${"Directory".padEnd(34)} Model${c.reset}`);
|
|
148
|
+
console.log(` ${c.dim}${"─".repeat(90)}${c.reset}`);
|
|
149
|
+
for (const s of sessions) {
|
|
150
|
+
const cwd = (s.cwd ?? "").length > 32 ? `…${s.cwd.slice(-31)}` : (s.cwd ?? "");
|
|
151
|
+
const name = (s.name ?? `Workspace #${s.id}`).slice(0, 26);
|
|
152
|
+
console.log(` ${c.bold}${String(s.id).padEnd(6)}${c.reset} ${name.padEnd(28)} ${c.dim}${cwd.padEnd(34)} ${s.model}${c.reset}`);
|
|
153
|
+
}
|
|
154
|
+
console.log();
|
|
155
|
+
} catch (e) {
|
|
156
|
+
errMsg(`Failed to list workspaces: ${(e as Error).message}`);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function cmdWorkspaceNew(
|
|
162
|
+
config: ReturnType<typeof loadConfig>,
|
|
163
|
+
baseUrl: string,
|
|
164
|
+
args: string[],
|
|
165
|
+
) {
|
|
166
|
+
const cwd = args[0] ?? process.cwd();
|
|
167
|
+
|
|
168
|
+
// Parse optional flags: --model <model>, --name <name>
|
|
169
|
+
let model = config.defaultModel ?? "openai/gpt-oss-120b";
|
|
170
|
+
let name: string | undefined;
|
|
171
|
+
for (let i = 0; i < args.length; i++) {
|
|
172
|
+
if (args[i] === "--model" && args[i + 1]) { model = args[++i]; }
|
|
173
|
+
else if (args[i] === "--name" && args[i + 1]) { name = args[++i]; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const session = await kundex.agent.createSession({ cwd, model, name });
|
|
180
|
+
ok(`Created workspace #${session.id}`);
|
|
181
|
+
dim(`Name: ${session.name}`);
|
|
182
|
+
dim(`Directory: ${cwd}`);
|
|
183
|
+
dim(`Model: ${session.model}`);
|
|
184
|
+
console.log();
|
|
185
|
+
} catch (e) {
|
|
186
|
+
errMsg(`Failed to create workspace: ${(e as Error).message}`);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function cmdWorkspaceDelete(
|
|
192
|
+
config: ReturnType<typeof loadConfig>,
|
|
193
|
+
baseUrl: string,
|
|
194
|
+
idStr: string,
|
|
195
|
+
) {
|
|
196
|
+
const id = Number(idStr);
|
|
197
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
198
|
+
errMsg("Usage: kundex workspace delete <id>");
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await kundex.agent.deleteSession(id);
|
|
207
|
+
ok(`Deleted workspace #${id}`);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
errMsg(`Failed to delete workspace: ${(e as Error).message}`);
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Files ─────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
async function cmdFilesList(
|
|
217
|
+
config: ReturnType<typeof loadConfig>,
|
|
218
|
+
baseUrl: string,
|
|
219
|
+
wsIdStr: string,
|
|
220
|
+
) {
|
|
221
|
+
const wsId = Number(wsIdStr);
|
|
222
|
+
if (!Number.isFinite(wsId) || wsId <= 0) {
|
|
223
|
+
errMsg("Usage: kundex files list <wsId>");
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
229
|
+
header(`Files in workspace #${wsId}`);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const { files } = await kundex.files.list(wsId);
|
|
233
|
+
if (!files.length) {
|
|
234
|
+
dim("No files yet.");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log(`\n ${c.dim}${"Path".padEnd(44)} ${"Size".padEnd(10)} MIME${c.reset}`);
|
|
239
|
+
console.log(` ${c.dim}${"─".repeat(80)}${c.reset}`);
|
|
240
|
+
for (const f of files) {
|
|
241
|
+
const size = f.size < 1024 ? `${f.size} B`
|
|
242
|
+
: f.size < 1024 * 1024 ? `${(f.size / 1024).toFixed(1)} KB`
|
|
243
|
+
: `${(f.size / 1024 / 1024).toFixed(1)} MB`;
|
|
244
|
+
const displayPath = f.path.length > 42 ? `…${f.path.slice(-41)}` : f.path;
|
|
245
|
+
console.log(` ${displayPath.padEnd(44)} ${size.padEnd(10)} ${c.dim}${f.mimeType}${c.reset}`);
|
|
246
|
+
}
|
|
247
|
+
console.log();
|
|
248
|
+
} catch (e) {
|
|
249
|
+
errMsg(`Failed to list files: ${(e as Error).message}`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function cmdFilesGet(
|
|
255
|
+
config: ReturnType<typeof loadConfig>,
|
|
256
|
+
baseUrl: string,
|
|
257
|
+
wsIdStr: string,
|
|
258
|
+
remotePath: string,
|
|
259
|
+
) {
|
|
260
|
+
const wsId = Number(wsIdStr);
|
|
261
|
+
if (!Number.isFinite(wsId) || wsId <= 0 || !remotePath) {
|
|
262
|
+
errMsg("Usage: kundex files get <wsId> <path>");
|
|
263
|
+
process.exitCode = 1;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const file = await kundex.files.get(wsId, remotePath);
|
|
271
|
+
process.stdout.write(file.content ?? "");
|
|
272
|
+
} catch (e) {
|
|
273
|
+
errMsg(`Failed to get file: ${(e as Error).message}`);
|
|
274
|
+
process.exitCode = 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function cmdFilesPush(
|
|
279
|
+
config: ReturnType<typeof loadConfig>,
|
|
280
|
+
baseUrl: string,
|
|
281
|
+
wsIdStr: string,
|
|
282
|
+
localPath: string,
|
|
283
|
+
remotePath?: string,
|
|
284
|
+
) {
|
|
285
|
+
const wsId = Number(wsIdStr);
|
|
286
|
+
if (!Number.isFinite(wsId) || wsId <= 0 || !localPath) {
|
|
287
|
+
errMsg("Usage: kundex files push <wsId> <localPath> [remotePath]");
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
293
|
+
const destPath = remotePath ?? path.basename(localPath);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const content = await fs.readFile(localPath, "utf-8");
|
|
297
|
+
const name = path.basename(destPath);
|
|
298
|
+
await kundex.files.upsert(wsId, { path: destPath, name, content });
|
|
299
|
+
ok(`Pushed ${localPath} → workspace #${wsId}:${destPath}`);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
errMsg(`Failed to push file: ${(e as Error).message}`);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function cmdFilesPull(
|
|
307
|
+
config: ReturnType<typeof loadConfig>,
|
|
308
|
+
baseUrl: string,
|
|
309
|
+
wsIdStr: string,
|
|
310
|
+
remotePath: string,
|
|
311
|
+
outPath?: string,
|
|
312
|
+
) {
|
|
313
|
+
const wsId = Number(wsIdStr);
|
|
314
|
+
if (!Number.isFinite(wsId) || wsId <= 0 || !remotePath) {
|
|
315
|
+
errMsg("Usage: kundex files pull <wsId> <remotePath> [--out <localPath>]");
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const file = await kundex.files.get(wsId, remotePath);
|
|
324
|
+
const content = file.content ?? "";
|
|
325
|
+
|
|
326
|
+
if (outPath) {
|
|
327
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
328
|
+
await fs.writeFile(outPath, content, "utf-8");
|
|
329
|
+
ok(`Saved ${remotePath} → ${outPath} (${content.length} bytes)`);
|
|
330
|
+
} else {
|
|
331
|
+
process.stdout.write(content);
|
|
332
|
+
}
|
|
333
|
+
} catch (e) {
|
|
334
|
+
errMsg(`Failed to pull file: ${(e as Error).message}`);
|
|
335
|
+
process.exitCode = 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Run (single-turn non-interactive) ─────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async function cmdRun(
|
|
342
|
+
config: ReturnType<typeof loadConfig>,
|
|
343
|
+
baseUrl: string,
|
|
344
|
+
message: string,
|
|
345
|
+
args: string[],
|
|
346
|
+
) {
|
|
347
|
+
if (!message) {
|
|
348
|
+
errMsg("Usage: kundex run <message>");
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Parse optional flags: --model, --workspace, --cwd
|
|
354
|
+
let model = config.defaultModel ?? "openai/gpt-oss-120b";
|
|
355
|
+
let wsId: number | null = null;
|
|
356
|
+
let cwd = process.cwd();
|
|
357
|
+
for (let i = 0; i < args.length; i++) {
|
|
358
|
+
if (args[i] === "--model" && args[i + 1]) model = args[++i];
|
|
359
|
+
else if ((args[i] === "--workspace" || args[i] === "-w") && args[i + 1]) wsId = Number(args[++i]);
|
|
360
|
+
else if (args[i] === "--cwd" && args[i + 1]) cwd = args[++i];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
364
|
+
let sessionId: number;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
if (wsId !== null && Number.isFinite(wsId)) {
|
|
368
|
+
sessionId = wsId;
|
|
369
|
+
} else {
|
|
370
|
+
const session = await kundex.agent.createSession({ cwd, model });
|
|
371
|
+
sessionId = session.id;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
process.stdout.write(`\n`);
|
|
375
|
+
const { executeToolCall } = await import("./tools");
|
|
376
|
+
|
|
377
|
+
let turn = await kundex.agent.streamMessage(
|
|
378
|
+
{ sessionId, message },
|
|
379
|
+
(delta) => process.stdout.write(delta),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Handle tool calls in a loop until the turn is done
|
|
383
|
+
while (!turn.done && turn.toolCalls && turn.toolCalls.length > 0) {
|
|
384
|
+
for (const call of turn.toolCalls) {
|
|
385
|
+
const { result, isError } = await executeToolCall(cwd, call);
|
|
386
|
+
turn = await kundex.agent.submitToolResult(
|
|
387
|
+
{ sessionId, toolCallId: call.id, result, isError },
|
|
388
|
+
(delta) => process.stdout.write(delta),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
process.stdout.write("\n");
|
|
394
|
+
} catch (e) {
|
|
395
|
+
errMsg(`Agent error: ${(e as Error).message}`);
|
|
396
|
+
process.exitCode = 1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ─── Config commands ──────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
function cmdConfigShow(config: ReturnType<typeof loadConfig>, baseUrl: string) {
|
|
403
|
+
header("Current configuration");
|
|
404
|
+
console.log();
|
|
405
|
+
const rows = [
|
|
406
|
+
["API key", config.apiKey ? `${config.apiKey.slice(0, 8)}…` : "(not set)"],
|
|
407
|
+
["Base URL", baseUrl],
|
|
408
|
+
["Model", config.defaultModel ?? "(default)"],
|
|
409
|
+
];
|
|
410
|
+
for (const [k, v] of rows) {
|
|
411
|
+
console.log(` ${c.dim}${k.padEnd(12)}${c.reset} ${c.bold}${v}${c.reset}`);
|
|
412
|
+
}
|
|
413
|
+
console.log();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function cmdConfigSet(key: string, value: string) {
|
|
417
|
+
const allowed = ["model", "baseUrl"];
|
|
418
|
+
if (!allowed.includes(key)) {
|
|
419
|
+
errMsg(`Unknown config key "${key}". Allowed keys: ${allowed.join(", ")}`);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const map: Record<string, keyof ReturnType<typeof loadConfig>> = {
|
|
425
|
+
model: "defaultModel",
|
|
426
|
+
baseUrl: "baseUrl",
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
saveConfig({ [map[key]]: value });
|
|
430
|
+
ok(`Set ${key} = ${value}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function cmdLogout() {
|
|
434
|
+
saveConfig({ apiKey: null, baseUrl: null, defaultModel: null });
|
|
435
|
+
ok("Logged out. Run `kundex login` to authenticate again.");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
4
439
|
|
|
5
440
|
async function main(): Promise<void> {
|
|
6
|
-
const
|
|
441
|
+
const args = process.argv.slice(2);
|
|
442
|
+
const [command, sub, ...rest] = args;
|
|
7
443
|
|
|
8
|
-
|
|
9
|
-
|
|
444
|
+
// Version
|
|
445
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
446
|
+
console.log(`kundex v${VERSION}`);
|
|
10
447
|
return;
|
|
11
448
|
}
|
|
12
449
|
|
|
450
|
+
// Help
|
|
13
451
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
14
|
-
console.log(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
452
|
+
console.log(HELP);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// No command — drop into REPL if logged in, else show help
|
|
457
|
+
if (!command) {
|
|
458
|
+
const config = loadConfig();
|
|
459
|
+
const baseUrl = resolveBaseUrl(config);
|
|
460
|
+
if (config.apiKey && baseUrl) {
|
|
461
|
+
await runRepl(config, config.apiKey, baseUrl);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
console.log(HELP);
|
|
465
|
+
console.log(`\n${c.yellow} Run \`kundex login\` to get started.${c.reset}\n`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Login / logout
|
|
470
|
+
if (command === "login") {
|
|
471
|
+
await runLogin();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (command === "logout") {
|
|
475
|
+
cmdLogout();
|
|
23
476
|
return;
|
|
24
477
|
}
|
|
25
478
|
|
|
479
|
+
// Load config — required for all remaining commands
|
|
26
480
|
const config = loadConfig();
|
|
27
481
|
const baseUrl = resolveBaseUrl(config);
|
|
28
482
|
|
|
29
483
|
if (!config.apiKey || !baseUrl) {
|
|
30
|
-
|
|
484
|
+
errMsg('Not logged in. Run "kundex login" first.');
|
|
31
485
|
process.exitCode = 1;
|
|
32
486
|
return;
|
|
33
487
|
}
|
|
34
488
|
|
|
35
|
-
|
|
489
|
+
// Models
|
|
490
|
+
if (command === "models") {
|
|
491
|
+
await cmdModels(config, baseUrl);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Config
|
|
496
|
+
if (command === "config") {
|
|
497
|
+
if (sub === "show" || !sub) {
|
|
498
|
+
cmdConfigShow(config, baseUrl);
|
|
499
|
+
} else if (sub === "set") {
|
|
500
|
+
const [key, value] = rest;
|
|
501
|
+
if (!key || !value) {
|
|
502
|
+
errMsg("Usage: kundex config set <key> <value>");
|
|
503
|
+
process.exitCode = 1;
|
|
504
|
+
} else {
|
|
505
|
+
cmdConfigSet(key, value);
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
errMsg(`Unknown config subcommand "${sub}". Use: show, set`);
|
|
509
|
+
process.exitCode = 1;
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Workspace
|
|
515
|
+
if (command === "workspace") {
|
|
516
|
+
if (sub === "list" || !sub) {
|
|
517
|
+
await cmdWorkspaceList(config, baseUrl);
|
|
518
|
+
} else if (sub === "new") {
|
|
519
|
+
await cmdWorkspaceNew(config, baseUrl, rest);
|
|
520
|
+
} else if (sub === "delete") {
|
|
521
|
+
await cmdWorkspaceDelete(config, baseUrl, rest[0]);
|
|
522
|
+
} else {
|
|
523
|
+
errMsg(`Unknown workspace subcommand "${sub}". Use: list, new, delete`);
|
|
524
|
+
process.exitCode = 1;
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Files
|
|
530
|
+
if (command === "files") {
|
|
531
|
+
if (sub === "list") {
|
|
532
|
+
await cmdFilesList(config, baseUrl, rest[0]);
|
|
533
|
+
} else if (sub === "get") {
|
|
534
|
+
await cmdFilesGet(config, baseUrl, rest[0], rest[1]);
|
|
535
|
+
} else if (sub === "push") {
|
|
536
|
+
await cmdFilesPush(config, baseUrl, rest[0], rest[1], rest[2]);
|
|
537
|
+
} else if (sub === "pull") {
|
|
538
|
+
// Support --out flag
|
|
539
|
+
let outPath: string | undefined;
|
|
540
|
+
const remaining: string[] = [];
|
|
541
|
+
for (let i = 0; i < rest.length; i++) {
|
|
542
|
+
if (rest[i] === "--out" && rest[i + 1]) { outPath = rest[++i]; }
|
|
543
|
+
else remaining.push(rest[i]);
|
|
544
|
+
}
|
|
545
|
+
await cmdFilesPull(config, baseUrl, remaining[0], remaining[1], outPath);
|
|
546
|
+
} else {
|
|
547
|
+
errMsg(`Unknown files subcommand "${sub}". Use: list, get, push, pull`);
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Run (single-turn agent)
|
|
554
|
+
if (command === "run") {
|
|
555
|
+
// "kundex run <message>" — join remaining args as the message
|
|
556
|
+
const allArgs = [sub, ...rest].filter(Boolean);
|
|
557
|
+
// Find message (everything before the first -- flag or flag-like arg)
|
|
558
|
+
const flagStart = allArgs.findIndex((a) => a.startsWith("--"));
|
|
559
|
+
const messageParts = flagStart >= 0 ? allArgs.slice(0, flagStart) : allArgs;
|
|
560
|
+
const flagArgs = flagStart >= 0 ? allArgs.slice(flagStart) : [];
|
|
561
|
+
const message = messageParts.join(" ").trim();
|
|
562
|
+
|
|
563
|
+
await cmdRun(config, baseUrl, message, flagArgs);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Unknown command — suggest help
|
|
568
|
+
errMsg(`Unknown command "${command}". Run \`kundex help\` to see available commands.`);
|
|
569
|
+
process.exitCode = 1;
|
|
36
570
|
}
|
|
37
571
|
|
|
38
|
-
main().catch((
|
|
39
|
-
console.error(
|
|
572
|
+
main().catch((e) => {
|
|
573
|
+
console.error(`${c.red} Fatal: ${e instanceof Error ? e.message : e}${c.reset}`);
|
|
40
574
|
process.exitCode = 1;
|
|
41
575
|
});
|
package/src/repl.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import readline from "node:readline/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
|
-
import { Kundex, type AgentTurn } from "./sdk";
|
|
4
|
+
import { Kundex, type AgentTurn, type AgentSession } from "./sdk";
|
|
5
5
|
import { executeToolCall } from "./tools";
|
|
6
6
|
import type { KundexConfig } from "./config";
|
|
7
7
|
|
|
@@ -73,16 +73,16 @@ function buildRules(lang: string): LangRule[] {
|
|
|
73
73
|
|
|
74
74
|
/** Apply regex-based ANSI syntax highlighting to a code string. */
|
|
75
75
|
function syntaxHighlight(code: string, lang: string): string {
|
|
76
|
-
const l = lang.toLowerCase()
|
|
77
|
-
const normalised = ["
|
|
78
|
-
: ["
|
|
76
|
+
const l = lang.toLowerCase();
|
|
77
|
+
const normalised = ["tsx","ts"].includes(l) ? "typescript"
|
|
78
|
+
: ["jsx","js","mjs"].includes(l) ? "javascript"
|
|
79
|
+
: ["python","py"].includes(l) ? "python"
|
|
80
|
+
: ["sh","shell","zsh","bash"].includes(l) ? "bash"
|
|
79
81
|
: ["rs"].includes(l) ? "rust"
|
|
80
82
|
: l;
|
|
81
83
|
|
|
82
84
|
const rules = buildRules(normalised in KEYWORDS ? normalised : "javascript");
|
|
83
85
|
|
|
84
|
-
// We work character-by-character with a simple placeholder approach:
|
|
85
|
-
// collect all matches with their positions, sort them, then reconstruct.
|
|
86
86
|
type Span = { start: number; end: number; color: string };
|
|
87
87
|
const spans: Span[] = [];
|
|
88
88
|
|
|
@@ -97,7 +97,6 @@ function syntaxHighlight(code: string, lang: string): string {
|
|
|
97
97
|
|
|
98
98
|
if (spans.length === 0) return code;
|
|
99
99
|
|
|
100
|
-
// Sort by start; discard overlapping spans (first wins)
|
|
101
100
|
spans.sort((a, b) => a.start - b.start);
|
|
102
101
|
const merged: Span[] = [];
|
|
103
102
|
let cursor = 0;
|
|
@@ -144,7 +143,6 @@ function printReply(text: string) {
|
|
|
144
143
|
while ((match = codeBlockRe.exec(text)) !== null) {
|
|
145
144
|
const before = text.slice(last, match.index);
|
|
146
145
|
if (before.trim()) {
|
|
147
|
-
// Wrap plain text at ~100 chars
|
|
148
146
|
before.split("\n").forEach(line => {
|
|
149
147
|
process.stdout.write(` ${line}\n`);
|
|
150
148
|
});
|
|
@@ -166,15 +164,20 @@ function printReply(text: string) {
|
|
|
166
164
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
167
165
|
const HELP = `
|
|
168
166
|
${c.bold}Commands:${c.reset}
|
|
169
|
-
/help
|
|
170
|
-
/model
|
|
171
|
-
/clear
|
|
172
|
-
/
|
|
167
|
+
/help Show this help
|
|
168
|
+
/model Show the model for this session
|
|
169
|
+
/clear Start a fresh agent session
|
|
170
|
+
/workspace list List all your workspaces
|
|
171
|
+
/workspace use <id> Switch to an existing workspace session
|
|
172
|
+
/files List files in the current workspace
|
|
173
|
+
/exit Quit kundex
|
|
173
174
|
|
|
174
175
|
${c.bold}Tips:${c.reset}
|
|
175
176
|
• The agent can read, edit and search files, run commands, and use git.
|
|
176
177
|
• You will be asked to confirm any writes or shell commands before they run.
|
|
178
|
+
• Responses stream in real-time — text appears as the model generates it.
|
|
177
179
|
• Code blocks are syntax-highlighted in the terminal.
|
|
180
|
+
• Press Ctrl+C to abort a running request.
|
|
178
181
|
`;
|
|
179
182
|
|
|
180
183
|
function safeGitStatus(cwd: string): string | undefined {
|
|
@@ -196,11 +199,35 @@ function safeFileTree(cwd: string): string | undefined {
|
|
|
196
199
|
}
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Spinner that writes to stdout with carriage-return to overwrite itself.
|
|
204
|
+
* Call clear() to erase it before writing other output.
|
|
205
|
+
*/
|
|
206
|
+
function createSpinner(label: string) {
|
|
207
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
208
|
+
let frame = 0;
|
|
209
|
+
const timer = setInterval(() => {
|
|
210
|
+
process.stdout.write(`\r${c.dim} ${frames[frame++ % frames.length]} ${label}${c.reset} `);
|
|
211
|
+
}, 80);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
clear() {
|
|
215
|
+
clearInterval(timer);
|
|
216
|
+
process.stdout.write("\r\x1b[2K"); // CR + erase line
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Handle a completed AgentTurn: process any tool calls, stream tool-result turns.
|
|
223
|
+
* All tool results are submitted using the streaming API so progress is visible.
|
|
224
|
+
*/
|
|
199
225
|
async function handleTurn(
|
|
200
226
|
kundex: Kundex,
|
|
201
227
|
cwd: string,
|
|
202
228
|
sessionId: number,
|
|
203
229
|
turn: AgentTurn,
|
|
230
|
+
signal?: AbortSignal,
|
|
204
231
|
): Promise<void> {
|
|
205
232
|
let current = turn;
|
|
206
233
|
|
|
@@ -210,16 +237,39 @@ async function handleTurn(
|
|
|
210
237
|
if (isError) {
|
|
211
238
|
process.stdout.write(`${c.red} ✗ ${call.name}: ${result}${c.reset}\n`);
|
|
212
239
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
240
|
+
|
|
241
|
+
// Show a spinner while waiting for the follow-up AI turn
|
|
242
|
+
const sp = createSpinner("processing tool result…");
|
|
243
|
+
let followUpText = "";
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
current = await kundex.agent.submitToolResult(
|
|
247
|
+
{ sessionId, toolCallId: call.id, result, isError },
|
|
248
|
+
(delta) => {
|
|
249
|
+
if (!followUpText) {
|
|
250
|
+
// First token arrived — clear the spinner
|
|
251
|
+
sp.clear();
|
|
252
|
+
process.stdout.write("\n");
|
|
253
|
+
}
|
|
254
|
+
followUpText += delta;
|
|
255
|
+
process.stdout.write(delta);
|
|
256
|
+
},
|
|
257
|
+
signal,
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
sp.clear();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (followUpText && !followUpText.endsWith("\n")) {
|
|
264
|
+
process.stdout.write("\n");
|
|
265
|
+
}
|
|
219
266
|
}
|
|
220
267
|
}
|
|
221
268
|
|
|
222
|
-
if (current.reply) {
|
|
269
|
+
if (current.reply && !turn.toolCalls?.length) {
|
|
270
|
+
// Reply was already streamed inline; just print the closing rule
|
|
271
|
+
process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
|
|
272
|
+
} else if (current.reply) {
|
|
223
273
|
printReply(current.reply);
|
|
224
274
|
}
|
|
225
275
|
}
|
|
@@ -238,7 +288,7 @@ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═
|
|
|
238
288
|
${c.dim}cwd:${c.reset} ${c.bold}${shortCwd}${c.reset}
|
|
239
289
|
${c.dim}model:${c.reset} ${c.bold}${model}${c.reset}
|
|
240
290
|
|
|
241
|
-
Type ${c.cyan}/help${c.reset} for commands.
|
|
291
|
+
Type ${c.cyan}/help${c.reset} for commands, ${c.cyan}Ctrl+C${c.reset} to stop a running request.
|
|
242
292
|
`);
|
|
243
293
|
}
|
|
244
294
|
|
|
@@ -253,13 +303,33 @@ export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: str
|
|
|
253
303
|
let session = await kundex.agent.createSession({ cwd, model });
|
|
254
304
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
255
305
|
|
|
306
|
+
// AbortController for the current in-progress request.
|
|
307
|
+
// Ctrl+C signals the controller and sends a SIGINT to abort the fetch.
|
|
308
|
+
let currentAbort: AbortController | null = null;
|
|
309
|
+
|
|
310
|
+
const sigintHandler = () => {
|
|
311
|
+
if (currentAbort && !currentAbort.signal.aborted) {
|
|
312
|
+
currentAbort.abort();
|
|
313
|
+
process.stdout.write(`\n${c.yellow} ⚡ Stopped.${c.reset}\n\n`);
|
|
314
|
+
currentAbort = null;
|
|
315
|
+
} else {
|
|
316
|
+
// Second Ctrl+C with no request in flight → exit
|
|
317
|
+
console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
|
|
318
|
+
rl.close();
|
|
319
|
+
process.exit(0);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
process.on("SIGINT", sigintHandler);
|
|
324
|
+
|
|
256
325
|
try {
|
|
257
326
|
for (;;) {
|
|
258
327
|
const raw = await rl.question(`${c.bold}${c.cyan}kundex${c.reset} ${c.dim}▸${c.reset} `);
|
|
259
328
|
const input = raw.trim();
|
|
260
329
|
if (!input) continue;
|
|
261
330
|
|
|
262
|
-
|
|
331
|
+
// ── REPL commands ───────────────────────────────────────────────────────
|
|
332
|
+
if (input === "/exit" || input === "/quit") {
|
|
263
333
|
console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
|
|
264
334
|
break;
|
|
265
335
|
}
|
|
@@ -274,23 +344,123 @@ export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: str
|
|
|
274
344
|
continue;
|
|
275
345
|
}
|
|
276
346
|
|
|
347
|
+
// /workspace list
|
|
348
|
+
if (input === "/workspace list") {
|
|
349
|
+
try {
|
|
350
|
+
const sessions = await kundex.agent.listSessions();
|
|
351
|
+
if (!sessions.length) {
|
|
352
|
+
console.log(`${c.dim} No workspaces yet.${c.reset}`);
|
|
353
|
+
} else {
|
|
354
|
+
console.log(`\n ${c.dim}${"ID".padEnd(6)} ${"Name".padEnd(26)} CWD${c.reset}`);
|
|
355
|
+
console.log(` ${c.dim}${"─".repeat(70)}${c.reset}`);
|
|
356
|
+
for (const s of sessions) {
|
|
357
|
+
const isActive = s.id === session.id ? ` ${c.cyan}← active${c.reset}` : "";
|
|
358
|
+
const name = (s.name ?? `Workspace #${s.id}`).slice(0, 24);
|
|
359
|
+
const cwd2 = (s.cwd ?? "").length > 30 ? `…${s.cwd.slice(-29)}` : s.cwd;
|
|
360
|
+
console.log(` ${c.bold}${String(s.id).padEnd(6)}${c.reset} ${name.padEnd(26)} ${c.dim}${cwd2}${c.reset}${isActive}`);
|
|
361
|
+
}
|
|
362
|
+
console.log();
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
console.error(`${c.red} error: ${(err as Error).message}${c.reset}`);
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// /workspace use <id>
|
|
371
|
+
if (input.startsWith("/workspace use ")) {
|
|
372
|
+
const id = Number(input.slice("/workspace use ".length).trim());
|
|
373
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
374
|
+
console.error(`${c.red} Invalid workspace ID.${c.reset}`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const s = await kundex.agent.getSession(id) as AgentSession;
|
|
379
|
+
session = s as any;
|
|
380
|
+
console.log(`\n${c.green} ✓ Switched to workspace #${id} — ${s.name}${c.reset}\n`);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
console.error(`${c.red} error: ${(err as Error).message}${c.reset}`);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// /files
|
|
388
|
+
if (input === "/files") {
|
|
389
|
+
try {
|
|
390
|
+
const { files } = await kundex.files.list(session.id);
|
|
391
|
+
if (!files.length) {
|
|
392
|
+
console.log(`${c.dim} No files in this workspace yet.${c.reset}`);
|
|
393
|
+
} else {
|
|
394
|
+
console.log(`\n ${c.dim}${"Path".padEnd(40)} Size${c.reset}`);
|
|
395
|
+
console.log(` ${c.dim}${"─".repeat(52)}${c.reset}`);
|
|
396
|
+
for (const f of files) {
|
|
397
|
+
const size = f.size < 1024 ? `${f.size} B`
|
|
398
|
+
: f.size < 1024 * 1024 ? `${(f.size / 1024).toFixed(1)} KB`
|
|
399
|
+
: `${(f.size / 1024 / 1024).toFixed(1)} MB`;
|
|
400
|
+
console.log(` ${f.path.padEnd(40)} ${c.dim}${size}${c.reset}`);
|
|
401
|
+
}
|
|
402
|
+
console.log();
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error(`${c.red} error: ${(err as Error).message}${c.reset}`);
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Agent message ────────────────────────────────────────────────────────
|
|
411
|
+
currentAbort = new AbortController();
|
|
412
|
+
const { signal } = currentAbort;
|
|
413
|
+
const spinner = createSpinner("thinking…");
|
|
414
|
+
let streamedAnyText = false;
|
|
415
|
+
|
|
277
416
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
417
|
+
const turn = await kundex.agent.streamMessage(
|
|
418
|
+
{
|
|
419
|
+
sessionId: session.id,
|
|
420
|
+
message: input,
|
|
421
|
+
context: {
|
|
422
|
+
gitStatus: safeGitStatus(cwd),
|
|
423
|
+
fileTree: safeFileTree(cwd),
|
|
424
|
+
openFiles: [],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
(delta) => {
|
|
428
|
+
if (!streamedAnyText) {
|
|
429
|
+
spinner.clear();
|
|
430
|
+
process.stdout.write("\n");
|
|
431
|
+
streamedAnyText = true;
|
|
432
|
+
}
|
|
433
|
+
process.stdout.write(delta);
|
|
286
434
|
},
|
|
287
|
-
|
|
288
|
-
|
|
435
|
+
signal,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
spinner.clear();
|
|
439
|
+
|
|
440
|
+
// If the response contained only tool calls (no streamed text), let
|
|
441
|
+
// handleTurn print the tool output and follow-up replies.
|
|
442
|
+
if (turn.toolCalls?.length) {
|
|
443
|
+
await handleTurn(kundex, cwd, session.id, turn, signal);
|
|
444
|
+
} else if (streamedAnyText) {
|
|
445
|
+
// Text was streamed inline; just print the closing separator.
|
|
446
|
+
process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
|
|
447
|
+
} else if (turn.reply) {
|
|
448
|
+
printReply(turn.reply);
|
|
449
|
+
}
|
|
289
450
|
} catch (err) {
|
|
290
|
-
|
|
451
|
+
spinner.clear();
|
|
452
|
+
const e = err as Error;
|
|
453
|
+
if (e.name === "AbortError") {
|
|
454
|
+
// Already printed "⚡ Stopped" via sigintHandler
|
|
455
|
+
} else {
|
|
456
|
+
console.error(`\n${c.red} error: ${e.message}${c.reset}\n`);
|
|
457
|
+
}
|
|
458
|
+
} finally {
|
|
459
|
+
currentAbort = null;
|
|
291
460
|
}
|
|
292
461
|
}
|
|
293
462
|
} finally {
|
|
463
|
+
process.off("SIGINT", sigintHandler);
|
|
294
464
|
rl.close();
|
|
295
465
|
}
|
|
296
466
|
}
|
package/src/sdk.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ChatUsage {
|
|
|
21
21
|
export interface ModelInfo {
|
|
22
22
|
id: string;
|
|
23
23
|
name: string;
|
|
24
|
+
/** Which inference provider serves this model (e.g. "heavstal", "groq", "gemini", "mistral"). */
|
|
25
|
+
provider?: string;
|
|
24
26
|
contextWindow: number;
|
|
25
27
|
inputPricePerMTok: number;
|
|
26
28
|
outputPricePerMTok: number;
|
|
@@ -34,6 +36,33 @@ export interface AgentTurn {
|
|
|
34
36
|
usage: ChatUsage | null;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export interface AgentSession {
|
|
40
|
+
id: number;
|
|
41
|
+
cwd: string;
|
|
42
|
+
model: string;
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
language: string;
|
|
46
|
+
wsStatus: string;
|
|
47
|
+
pinned: boolean;
|
|
48
|
+
lastOpenedAt: string | null;
|
|
49
|
+
lastModifiedAt: string | null;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
updatedAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WorkspaceFile {
|
|
55
|
+
id: number;
|
|
56
|
+
sessionId: number;
|
|
57
|
+
path: string;
|
|
58
|
+
name: string;
|
|
59
|
+
mimeType: string;
|
|
60
|
+
size: number;
|
|
61
|
+
content?: string;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
updatedAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
37
66
|
export interface KundexOptions {
|
|
38
67
|
apiKey: string;
|
|
39
68
|
baseUrl: string;
|
|
@@ -49,7 +78,7 @@ export interface KundexOptions {
|
|
|
49
78
|
*/
|
|
50
79
|
export class Kundex {
|
|
51
80
|
private apiKey: string;
|
|
52
|
-
|
|
81
|
+
readonly baseUrl: string;
|
|
53
82
|
|
|
54
83
|
constructor(opts: KundexOptions) {
|
|
55
84
|
if (!opts.apiKey) throw new Error("Kundex: apiKey is required");
|
|
@@ -76,9 +105,37 @@ export class Kundex {
|
|
|
76
105
|
return (await res.json()) as T;
|
|
77
106
|
}
|
|
78
107
|
|
|
108
|
+
private async requestGet<T>(pathname: string): Promise<T> {
|
|
109
|
+
const res = await fetch(`${this.baseUrl}${pathname}`, {
|
|
110
|
+
method: "GET",
|
|
111
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const text = await res.text().catch(() => "");
|
|
116
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (await res.json()) as T;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async requestDelete(pathname: string): Promise<void> {
|
|
123
|
+
const res = await fetch(`${this.baseUrl}${pathname}`, {
|
|
124
|
+
method: "DELETE",
|
|
125
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!res.ok && res.status !== 204) {
|
|
129
|
+
const text = await res.text().catch(() => "");
|
|
130
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
79
134
|
models = {
|
|
80
135
|
list: async (): Promise<ModelInfo[]> => {
|
|
81
|
-
const res = await fetch(`${this.baseUrl}/v1/models
|
|
136
|
+
const res = await fetch(`${this.baseUrl}/v1/models`, {
|
|
137
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
138
|
+
});
|
|
82
139
|
if (!res.ok) throw new Error(`Kundex API error (${res.status})`);
|
|
83
140
|
return (await res.json()) as ModelInfo[];
|
|
84
141
|
},
|
|
@@ -109,6 +166,7 @@ export class Kundex {
|
|
|
109
166
|
tools?: KundexToolDefinition[];
|
|
110
167
|
},
|
|
111
168
|
onDelta: (text: string) => void,
|
|
169
|
+
signal?: AbortSignal,
|
|
112
170
|
): Promise<{ finishReason: string; usage: ChatUsage | null }> => {
|
|
113
171
|
const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
114
172
|
method: "POST",
|
|
@@ -117,6 +175,7 @@ export class Kundex {
|
|
|
117
175
|
authorization: `Bearer ${this.apiKey}`,
|
|
118
176
|
},
|
|
119
177
|
body: JSON.stringify({ ...params, stream: true }),
|
|
178
|
+
signal,
|
|
120
179
|
});
|
|
121
180
|
|
|
122
181
|
if (!res.ok || !res.body) {
|
|
@@ -130,25 +189,32 @@ export class Kundex {
|
|
|
130
189
|
let finishReason = "stop";
|
|
131
190
|
let usage: ChatUsage | null = null;
|
|
132
191
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
try {
|
|
193
|
+
for (;;) {
|
|
194
|
+
const { done, value } = await reader.read();
|
|
195
|
+
if (done) break;
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const lines = buffer.split("\n");
|
|
198
|
+
buffer = lines.pop() ?? "";
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const trimmed = line.trim();
|
|
202
|
+
if (trimmed.startsWith(":")) continue; // heartbeat comment
|
|
203
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
204
|
+
const payload = trimmed.slice(5).trim();
|
|
205
|
+
if (!payload || payload === "[DONE]") continue;
|
|
206
|
+
try {
|
|
207
|
+
const json = JSON.parse(payload);
|
|
208
|
+
if (json.delta) onDelta(json.delta);
|
|
209
|
+
if (json.done) {
|
|
210
|
+
finishReason = json.finishReason ?? finishReason;
|
|
211
|
+
usage = json.usage ?? usage;
|
|
212
|
+
}
|
|
213
|
+
} catch { /* ignore bad JSON */ }
|
|
150
214
|
}
|
|
151
215
|
}
|
|
216
|
+
} finally {
|
|
217
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
152
218
|
}
|
|
153
219
|
|
|
154
220
|
return { finishReason, usage };
|
|
@@ -156,23 +222,242 @@ export class Kundex {
|
|
|
156
222
|
};
|
|
157
223
|
|
|
158
224
|
agent = {
|
|
159
|
-
createSession: (params: {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
225
|
+
createSession: (params: {
|
|
226
|
+
cwd: string;
|
|
227
|
+
model?: string;
|
|
228
|
+
name?: string;
|
|
229
|
+
description?: string;
|
|
230
|
+
language?: string;
|
|
231
|
+
}) =>
|
|
232
|
+
this.request<AgentSession>("/v1/agent/session", params),
|
|
233
|
+
|
|
234
|
+
listSessions: () =>
|
|
235
|
+
this.requestGet<AgentSession[]>("/v1/agent/sessions"),
|
|
236
|
+
|
|
237
|
+
getSession: (id: number) =>
|
|
238
|
+
this.requestGet<AgentSession>(`/v1/agent/sessions/${id}`),
|
|
239
|
+
|
|
240
|
+
deleteSession: (id: number) =>
|
|
241
|
+
this.requestDelete(`/v1/agent/sessions/${id}`),
|
|
242
|
+
|
|
243
|
+
getMessages: (id: number) =>
|
|
244
|
+
this.requestGet<{ sessionId: number; messages: unknown[] }>(`/v1/agent/sessions/${id}/messages`),
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Send a message to the agent and stream the response.
|
|
248
|
+
*
|
|
249
|
+
* The server sends SSE events — this method handles the typed format
|
|
250
|
+
* emitted by the backend:
|
|
251
|
+
* { type: "delta", delta: string }
|
|
252
|
+
* { type: "done", reply?, toolCalls?, done, usage? }
|
|
253
|
+
* { type: "error", error: string }
|
|
254
|
+
*
|
|
255
|
+
* Invokes onDelta as text tokens arrive so the caller can print them
|
|
256
|
+
* incrementally.
|
|
257
|
+
*
|
|
258
|
+
* Returns the completed AgentTurn including any tool calls.
|
|
259
|
+
*/
|
|
260
|
+
streamMessage: async (
|
|
261
|
+
params: {
|
|
262
|
+
sessionId: number;
|
|
263
|
+
message: string;
|
|
264
|
+
context?: { fileTree?: string; gitStatus?: string; openFiles?: string[] };
|
|
265
|
+
},
|
|
266
|
+
onDelta: (text: string) => void,
|
|
267
|
+
signal?: AbortSignal,
|
|
268
|
+
): Promise<AgentTurn> => {
|
|
269
|
+
const endpoint = "/v1/agent/message";
|
|
270
|
+
const res = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: {
|
|
273
|
+
"content-type": "application/json",
|
|
274
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
275
|
+
},
|
|
276
|
+
body: JSON.stringify(params),
|
|
277
|
+
signal,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (!res.ok) {
|
|
281
|
+
const text = await res.text().catch(() => "");
|
|
282
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const reader = res.body?.getReader();
|
|
286
|
+
if (!reader) throw new Error("No response body from server");
|
|
287
|
+
|
|
288
|
+
const decoder = new TextDecoder();
|
|
289
|
+
let buffer = "";
|
|
290
|
+
let reply = "";
|
|
291
|
+
let toolCalls: AgentTurn["toolCalls"] = null;
|
|
292
|
+
let usage: AgentTurn["usage"] = null;
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
for (;;) {
|
|
296
|
+
const { done, value } = await reader.read();
|
|
297
|
+
if (done) break;
|
|
298
|
+
buffer += decoder.decode(value, { stream: true });
|
|
299
|
+
const lines = buffer.split("\n");
|
|
300
|
+
buffer = lines.pop() ?? "";
|
|
301
|
+
|
|
302
|
+
for (const line of lines) {
|
|
303
|
+
const trimmed = line.trim();
|
|
304
|
+
if (trimmed.startsWith(":")) continue; // heartbeat comment
|
|
305
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
306
|
+
const payload = trimmed.slice(5).trim();
|
|
307
|
+
if (!payload || payload === "[DONE]") continue;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const ev = JSON.parse(payload);
|
|
311
|
+
|
|
312
|
+
if (ev.type === "delta" && typeof ev.delta === "string") {
|
|
313
|
+
reply += ev.delta;
|
|
314
|
+
onDelta(ev.delta);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ev.type === "done") {
|
|
318
|
+
return {
|
|
319
|
+
sessionId: params.sessionId,
|
|
320
|
+
reply: ev.reply !== undefined ? (ev.reply ?? reply) : reply,
|
|
321
|
+
toolCalls: ev.toolCalls ?? ev.tool_calls ?? null,
|
|
322
|
+
done: ev.done ?? true,
|
|
323
|
+
usage: ev.usage ?? usage,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (ev.type === "error") {
|
|
327
|
+
throw new Error(ev.error ?? "Agent error");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Legacy format fallback
|
|
331
|
+
if (typeof ev.delta === "string") {
|
|
332
|
+
reply += ev.delta;
|
|
333
|
+
onDelta(ev.delta);
|
|
334
|
+
}
|
|
335
|
+
if (ev.done === true) {
|
|
336
|
+
return {
|
|
337
|
+
sessionId: params.sessionId,
|
|
338
|
+
reply: ev.reply ?? reply,
|
|
339
|
+
toolCalls: ev.tool_calls ?? ev.toolCalls ?? null,
|
|
340
|
+
done: true,
|
|
341
|
+
usage: ev.usage ?? usage,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
} catch (parseErr) {
|
|
345
|
+
if (parseErr instanceof SyntaxError) continue;
|
|
346
|
+
throw parseErr;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Stream ended without a done event; return what we have
|
|
355
|
+
return { sessionId: params.sessionId, reply, toolCalls, done: true, usage };
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
submitToolResult: async (
|
|
359
|
+
params: {
|
|
360
|
+
sessionId: number;
|
|
361
|
+
toolCallId: string;
|
|
362
|
+
result: string;
|
|
363
|
+
isError?: boolean;
|
|
364
|
+
},
|
|
365
|
+
onDelta: (text: string) => void,
|
|
366
|
+
signal?: AbortSignal,
|
|
367
|
+
): Promise<AgentTurn> => {
|
|
368
|
+
const res = await fetch(`${this.baseUrl}/v1/agent/tool-result`, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: {
|
|
371
|
+
"content-type": "application/json",
|
|
372
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
373
|
+
},
|
|
374
|
+
body: JSON.stringify(params),
|
|
375
|
+
signal,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
const text = await res.text().catch(() => "");
|
|
380
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const reader = res.body?.getReader();
|
|
384
|
+
if (!reader) throw new Error("No response body from server");
|
|
385
|
+
|
|
386
|
+
const decoder = new TextDecoder();
|
|
387
|
+
let buffer = "";
|
|
388
|
+
let reply = "";
|
|
389
|
+
let toolCalls: AgentTurn["toolCalls"] = null;
|
|
390
|
+
let usage: AgentTurn["usage"] = null;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
for (;;) {
|
|
394
|
+
const { done, value } = await reader.read();
|
|
395
|
+
if (done) break;
|
|
396
|
+
buffer += decoder.decode(value, { stream: true });
|
|
397
|
+
const lines = buffer.split("\n");
|
|
398
|
+
buffer = lines.pop() ?? "";
|
|
399
|
+
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
const trimmed = line.trim();
|
|
402
|
+
if (trimmed.startsWith(":")) continue;
|
|
403
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
404
|
+
const payload = trimmed.slice(5).trim();
|
|
405
|
+
if (!payload || payload === "[DONE]") continue;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const ev = JSON.parse(payload);
|
|
409
|
+
if (ev.type === "delta" && typeof ev.delta === "string") {
|
|
410
|
+
reply += ev.delta;
|
|
411
|
+
onDelta(ev.delta);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (ev.type === "done") {
|
|
415
|
+
return {
|
|
416
|
+
sessionId: params.sessionId,
|
|
417
|
+
reply: ev.reply !== undefined ? (ev.reply ?? reply) : reply,
|
|
418
|
+
toolCalls: ev.toolCalls ?? ev.tool_calls ?? null,
|
|
419
|
+
done: ev.done ?? true,
|
|
420
|
+
usage: ev.usage ?? usage,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (ev.type === "error") throw new Error(ev.error ?? "Agent error");
|
|
424
|
+
if (typeof ev.delta === "string") { reply += ev.delta; onDelta(ev.delta); }
|
|
425
|
+
if (ev.done === true) {
|
|
426
|
+
return {
|
|
427
|
+
sessionId: params.sessionId,
|
|
428
|
+
reply: ev.reply ?? reply,
|
|
429
|
+
toolCalls: ev.tool_calls ?? ev.toolCalls ?? null,
|
|
430
|
+
done: true,
|
|
431
|
+
usage: ev.usage ?? usage,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
} catch (parseErr) {
|
|
435
|
+
if (parseErr instanceof SyntaxError) continue;
|
|
436
|
+
throw parseErr;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} finally {
|
|
441
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { sessionId: params.sessionId, reply, toolCalls, done: true, usage };
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
files = {
|
|
449
|
+
list: (wsId: number) =>
|
|
450
|
+
this.requestGet<{ files: WorkspaceFile[] }>(`/v1/workspace/${wsId}/files`),
|
|
451
|
+
|
|
452
|
+
get: (wsId: number, path: string) =>
|
|
453
|
+
this.requestGet<WorkspaceFile>(
|
|
454
|
+
`/v1/workspace/${wsId}/files/content?path=${encodeURIComponent(path)}`,
|
|
163
455
|
),
|
|
164
456
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
submitToolResult: (params: {
|
|
172
|
-
sessionId: number;
|
|
173
|
-
toolCallId: string;
|
|
174
|
-
result: string;
|
|
175
|
-
isError?: boolean;
|
|
176
|
-
}) => this.request<AgentTurn>("/v1/agent/tool-result", params),
|
|
457
|
+
upsert: (wsId: number, params: { path: string; name: string; content: string; mimeType?: string }) =>
|
|
458
|
+
this.request<{ file: WorkspaceFile }>(`/v1/workspace/${wsId}/files`, params),
|
|
459
|
+
|
|
460
|
+
delete: (wsId: number, path: string) =>
|
|
461
|
+
this.requestDelete(`/v1/workspace/${wsId}/files?path=${encodeURIComponent(path)}`),
|
|
177
462
|
};
|
|
178
463
|
}
|