@cryer/star-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js ADDED
@@ -0,0 +1,2232 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.tsx
4
+ import { Command } from "commander";
5
+
6
+ // src/agent/loop.ts
7
+ import { tool as aiTool } from "ai";
8
+
9
+ // src/context/compaction.ts
10
+ import { generateText } from "ai";
11
+
12
+ // src/context/tokens.ts
13
+ var CHARS_PER_TOKEN = 4;
14
+ var MESSAGE_OVERHEAD = 4;
15
+ function contentCharLength(message) {
16
+ if (message.role === "tool") {
17
+ let chars2 = 0;
18
+ for (const part of message.content) {
19
+ chars2 += JSON.stringify(part.result ?? null).length;
20
+ }
21
+ return chars2;
22
+ }
23
+ if (typeof message.content === "string") {
24
+ return message.content.length;
25
+ }
26
+ let chars = 0;
27
+ for (const part of message.content) {
28
+ if (part.type === "text" || part.type === "reasoning") {
29
+ chars += part.text.length;
30
+ } else if (part.type === "tool-call") {
31
+ chars += part.toolName.length + JSON.stringify(part.args ?? null).length;
32
+ }
33
+ }
34
+ return chars;
35
+ }
36
+ function estimateMessageTokens(message) {
37
+ return Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD;
38
+ }
39
+ function estimateTokens(messages) {
40
+ return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
41
+ }
42
+
43
+ // src/context/compaction.ts
44
+ var MIN_KEPT_MESSAGES = 4;
45
+ function placeholderMessage(droppedCount) {
46
+ return {
47
+ role: "user",
48
+ content: `[context compacted: ${droppedCount} earlier messages dropped]`
49
+ };
50
+ }
51
+ var SUMMARY_SYSTEM_PROMPT = [
52
+ "You are summarizing an AI coding agent's conversation for context compaction.",
53
+ "Write a concise summary that preserves:",
54
+ "- the user's goals and requests",
55
+ "- decisions made and their rationale",
56
+ "- files and code touched (paths, key changes)",
57
+ "- tool results that matter (errors, key outputs)",
58
+ "- outstanding TODOs and next steps",
59
+ "Output only the summary, no preamble."
60
+ ].join("\n");
61
+ function serializeMessage(message) {
62
+ if (typeof message.content === "string") {
63
+ return `${message.role}: ${message.content}`;
64
+ }
65
+ const parts = message.content.map((part) => {
66
+ if (part.type === "text") return part.text;
67
+ if (part.type === "tool-call")
68
+ return `[tool-call ${part.toolName}] ${JSON.stringify(part.args)}`;
69
+ if (part.type === "tool-result")
70
+ return `[tool-result ${part.toolName}] ${JSON.stringify(part.result)}`;
71
+ return `[${part.type}]`;
72
+ });
73
+ return `${message.role}: ${parts.join("\n")}`;
74
+ }
75
+ async function summarizeMessages(messages, model) {
76
+ const transcript = messages.map(serializeMessage).join("\n");
77
+ const { text } = await generateText({
78
+ model,
79
+ system: SUMMARY_SYSTEM_PROMPT,
80
+ prompt: `Summarize this conversation so far:
81
+
82
+ ${transcript}`
83
+ });
84
+ return text.trim();
85
+ }
86
+ function compactMessages(messages, maxTokens) {
87
+ if (estimateTokens(messages) <= maxTokens) {
88
+ return { messages, compacted: false, droppedCount: 0 };
89
+ }
90
+ const first = messages[0];
91
+ const hasSystem = first?.role === "system";
92
+ const head = hasSystem && first ? [first] : [];
93
+ const rest = hasSystem ? messages.slice(1) : messages.slice();
94
+ const turns = [];
95
+ for (const message of rest) {
96
+ const current = turns[turns.length - 1];
97
+ if (message.role === "user" || !current) {
98
+ turns.push([message]);
99
+ } else {
100
+ current.push(message);
101
+ }
102
+ }
103
+ let droppedCount = 0;
104
+ let turnIndex = 0;
105
+ while (turnIndex < turns.length) {
106
+ const turn = turns[turnIndex];
107
+ if (rest.length - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
108
+ break;
109
+ }
110
+ droppedCount += turn.length;
111
+ turnIndex += 1;
112
+ const candidate = [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)];
113
+ if (estimateTokens(candidate) <= maxTokens) {
114
+ break;
115
+ }
116
+ }
117
+ if (droppedCount === 0) {
118
+ return { messages, compacted: false, droppedCount: 0 };
119
+ }
120
+ return {
121
+ messages: [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)],
122
+ compacted: true,
123
+ droppedCount
124
+ };
125
+ }
126
+
127
+ // src/permissions/gate.ts
128
+ import path from "path";
129
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
130
+ var DANGEROUS_COMMAND_PATTERNS = [
131
+ /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
132
+ /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
133
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
134
+ /\bmkfs\b/i,
135
+ /dd\s+[^|;]*\bif=/i,
136
+ />\s*\/dev\/sd[a-z]/i,
137
+ /\bshutdown\b/i,
138
+ /\breboot\b/i,
139
+ /\bpoweroff\b/i
140
+ ];
141
+ function getStringArg(args, key) {
142
+ if (typeof args === "object" && args !== null) {
143
+ const value = args[key];
144
+ if (typeof value === "string") {
145
+ return value;
146
+ }
147
+ }
148
+ return void 0;
149
+ }
150
+ function normalizeAbsolute(p, cwd) {
151
+ const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
152
+ return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
153
+ }
154
+ function isOutsideCwd(p, cwd) {
155
+ const abs = normalizeAbsolute(p, cwd);
156
+ const base = normalizeAbsolute(cwd, cwd);
157
+ return abs !== base && !abs.startsWith(`${base}/`);
158
+ }
159
+ function isSensitivePath(p) {
160
+ const base = p.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
161
+ if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
162
+ return false;
163
+ }
164
+ return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
165
+ }
166
+ function checkPermission(mode, req, ctx) {
167
+ const command = getStringArg(req.args, "command");
168
+ const filePath = getStringArg(req.args, "path");
169
+ if (req.toolName === "bash" && command !== void 0 && DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command))) {
170
+ return "deny";
171
+ }
172
+ if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
173
+ if ((req.toolName === "write_file" || req.toolName === "edit_file") && isSensitivePath(filePath)) {
174
+ return "deny";
175
+ }
176
+ if (isOutsideCwd(filePath, ctx.cwd)) {
177
+ if (mode === "ask" && req.level === "read") {
178
+ return "ask";
179
+ }
180
+ return "deny";
181
+ }
182
+ }
183
+ if (mode === "auto") {
184
+ return "allow";
185
+ }
186
+ if (mode === "readonly") {
187
+ return req.level === "read" ? "allow" : "deny";
188
+ }
189
+ return req.level === "read" ? "allow" : "ask";
190
+ }
191
+
192
+ // src/agent/loop.ts
193
+ var AgentLoop = class {
194
+ messages = [];
195
+ opts;
196
+ confirmHandler;
197
+ constructor(opts) {
198
+ this.opts = opts;
199
+ if (opts.system) {
200
+ this.messages.push({ role: "system", content: opts.system });
201
+ }
202
+ }
203
+ getMessages() {
204
+ return this.messages;
205
+ }
206
+ async loadMessages(messages) {
207
+ this.messages = messages;
208
+ }
209
+ async persist(message) {
210
+ await this.opts.sessionStore?.append(message);
211
+ }
212
+ async *stream(input, signal) {
213
+ const userMessage = { role: "user", content: input };
214
+ this.messages.push(userMessage);
215
+ await this.persist(userMessage);
216
+ const { config, registry, cwd } = this.opts;
217
+ const aiTools = this.buildAiTools();
218
+ for (let step = 0; step < config.maxSteps; step++) {
219
+ const compacted = compactMessages([...this.messages], config.contextMaxTokens);
220
+ if (compacted.compacted) {
221
+ this.messages = await this.applyCompactionSummary(compacted);
222
+ }
223
+ let text = "";
224
+ const toolCalls = [];
225
+ let failed = false;
226
+ try {
227
+ for await (const event of this.streamOnce(aiTools, signal)) {
228
+ if (event.type === "text-delta") {
229
+ text += event.text;
230
+ yield event;
231
+ } else if (event.type === "tool-call") {
232
+ toolCalls.push({ id: event.id, name: event.name, args: event.args });
233
+ yield event;
234
+ } else if (event.type === "error") {
235
+ failed = true;
236
+ yield event;
237
+ } else {
238
+ yield event;
239
+ }
240
+ }
241
+ } catch (error) {
242
+ yield { type: "error", error: error instanceof Error ? error : new Error(String(error)) };
243
+ return;
244
+ }
245
+ if (failed) return;
246
+ const assistantMessage = {
247
+ role: "assistant",
248
+ content: [
249
+ ...text ? [{ type: "text", text }] : [],
250
+ ...toolCalls.map((call) => ({
251
+ type: "tool-call",
252
+ toolCallId: call.id,
253
+ toolName: call.name,
254
+ args: call.args
255
+ }))
256
+ ]
257
+ };
258
+ this.messages.push(assistantMessage);
259
+ await this.persist(assistantMessage);
260
+ if (toolCalls.length === 0) return;
261
+ for (const call of toolCalls) {
262
+ const result = await this.executeTool(call, signal);
263
+ const toolMessage = {
264
+ role: "tool",
265
+ content: [
266
+ {
267
+ type: "tool-result",
268
+ toolCallId: call.id,
269
+ toolName: call.name,
270
+ result: result.content
271
+ }
272
+ ]
273
+ };
274
+ this.messages.push(toolMessage);
275
+ await this.persist(toolMessage);
276
+ yield {
277
+ type: "tool-result",
278
+ id: call.id,
279
+ name: call.name,
280
+ content: result.content,
281
+ isError: result.isError
282
+ };
283
+ }
284
+ }
285
+ yield {
286
+ type: "error",
287
+ error: new Error(`Max steps (${config.maxSteps}) reached, stopping.`)
288
+ };
289
+ }
290
+ async applyCompactionSummary(compacted) {
291
+ const { config, model } = this.opts;
292
+ if (config.contextCompaction !== "summary" || !model) {
293
+ return compacted.messages;
294
+ }
295
+ const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
296
+ const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
297
+ try {
298
+ const summary = await summarizeMessages(dropped, model);
299
+ const messages = compacted.messages.slice();
300
+ messages[headCount] = {
301
+ role: "user",
302
+ content: `[earlier conversation summarized]
303
+ ${summary}`
304
+ };
305
+ return messages;
306
+ } catch {
307
+ return compacted.messages;
308
+ }
309
+ }
310
+ async *streamOnce(aiTools, signal) {
311
+ const { streamChat } = await import("./stream-FJQZGETE.js");
312
+ yield* streamChat({
313
+ model: this.opts.model,
314
+ messages: this.messages,
315
+ tools: aiTools,
316
+ abortSignal: signal
317
+ });
318
+ }
319
+ buildAiTools() {
320
+ const tools = {};
321
+ for (const t of this.opts.registry.list()) {
322
+ tools[t.name] = aiTool({
323
+ description: t.description,
324
+ parameters: t.parameters
325
+ });
326
+ }
327
+ return tools;
328
+ }
329
+ async executeTool(call, signal) {
330
+ const { registry, config, cwd } = this.opts;
331
+ const tool = registry.get(call.name);
332
+ if (!tool) {
333
+ return { content: `Unknown tool: ${call.name}`, isError: true };
334
+ }
335
+ const decision = checkPermission(
336
+ config.permissionMode,
337
+ { toolName: call.name, args: call.args, level: tool.permission },
338
+ { cwd }
339
+ );
340
+ if (decision === "deny") {
341
+ return { content: `Permission denied for tool "${call.name}".`, isError: true };
342
+ }
343
+ if (decision === "ask") {
344
+ const approved = this.confirmHandler ? await this.confirmHandler({
345
+ toolName: call.name,
346
+ args: call.args,
347
+ level: tool.permission
348
+ }) : false;
349
+ if (!approved) {
350
+ return { content: `User rejected tool "${call.name}".`, isError: true };
351
+ }
352
+ }
353
+ const parsed = tool.parameters.safeParse(call.args);
354
+ if (!parsed.success) {
355
+ return { content: `Invalid arguments: ${parsed.error.message}`, isError: true };
356
+ }
357
+ try {
358
+ return await tool.execute(parsed.data, { cwd, abortSignal: signal });
359
+ } catch (error) {
360
+ if (signal.aborted) {
361
+ return { content: "Tool execution aborted.", isError: true };
362
+ }
363
+ return {
364
+ content: `Tool error: ${error instanceof Error ? error.message : String(error)}`,
365
+ isError: true
366
+ };
367
+ }
368
+ }
369
+ };
370
+
371
+ // src/cli/repl.tsx
372
+ import { Box as Box7, render, useApp, useInput as useInput3 } from "ink";
373
+ import { useCallback, useEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
374
+
375
+ // src/llm/provider.ts
376
+ import { createAnthropic } from "@ai-sdk/anthropic";
377
+ import { createOpenAI } from "@ai-sdk/openai";
378
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
379
+
380
+ // src/llm/registry.ts
381
+ function listModels(config) {
382
+ return config.models;
383
+ }
384
+ function resolveModelConfig(config, modelName) {
385
+ const name = modelName ?? config.defaultModel;
386
+ const model = config.models.find((m) => m.name === name);
387
+ if (!model) {
388
+ const available = config.models.map((m) => m.name).join(", ") || "(none)";
389
+ throw new Error(`Model "${name}" not found. Available models: ${available}`);
390
+ }
391
+ return model;
392
+ }
393
+
394
+ // src/llm/provider.ts
395
+ function resolveApiKey(provider) {
396
+ const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
397
+ const apiKey = fromEnv ?? provider.apiKey;
398
+ if (!apiKey) {
399
+ const hint = provider.apiKeyEnv ? `set ${provider.apiKeyEnv} or configure apiKey` : "configure apiKey";
400
+ throw new Error(`Missing API key for provider "${provider.name}": ${hint}`);
401
+ }
402
+ return apiKey;
403
+ }
404
+ function createModel(config, modelName) {
405
+ const modelConfig = resolveModelConfig(config, modelName);
406
+ const provider = config.providers.find((p) => p.name === modelConfig.provider);
407
+ if (!provider) {
408
+ const available = config.providers.map((p) => p.name).join(", ") || "(none)";
409
+ throw new Error(
410
+ `Provider "${modelConfig.provider}" for model "${modelConfig.name}" not found. Available providers: ${available}`
411
+ );
412
+ }
413
+ const apiKey = resolveApiKey(provider);
414
+ switch (provider.protocol) {
415
+ case "anthropic":
416
+ return createAnthropic({
417
+ baseURL: provider.baseURL,
418
+ apiKey,
419
+ headers: provider.headers
420
+ })(modelConfig.model);
421
+ case "openai-compatible":
422
+ return createOpenAICompatible({
423
+ name: provider.name,
424
+ baseURL: provider.baseURL,
425
+ apiKey,
426
+ headers: provider.headers
427
+ })(modelConfig.model);
428
+ case "openai-responses":
429
+ return createOpenAI({
430
+ baseURL: provider.baseURL,
431
+ apiKey,
432
+ headers: provider.headers
433
+ }).responses(modelConfig.model);
434
+ }
435
+ }
436
+
437
+ // src/session/store.ts
438
+ import fs from "fs/promises";
439
+ import path3 from "path";
440
+
441
+ // src/config/paths.ts
442
+ import os from "os";
443
+ import path2 from "path";
444
+ function starHome() {
445
+ return process.env.STAR_HOME ?? path2.join(os.homedir(), ".star-cli");
446
+ }
447
+ function globalConfigPath() {
448
+ return path2.join(starHome(), "config.toml");
449
+ }
450
+ function sessionsDir() {
451
+ return path2.join(starHome(), "sessions");
452
+ }
453
+ function projectConfigPath(cwd) {
454
+ return path2.join(cwd, ".star", "config.toml");
455
+ }
456
+
457
+ // src/session/store.ts
458
+ function generateId(now) {
459
+ const pad = (n) => String(n).padStart(2, "0");
460
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
461
+ const suffix = Math.random().toString(16).slice(2, 8).padEnd(6, "0");
462
+ return `${stamp}-${suffix}`;
463
+ }
464
+ function messageText(message) {
465
+ const content = message.content;
466
+ if (typeof content === "string") return content;
467
+ if (Array.isArray(content)) {
468
+ return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
469
+ }
470
+ return "";
471
+ }
472
+ var SessionStore = class _SessionStore {
473
+ id;
474
+ dir;
475
+ pendingMeta = null;
476
+ initialized = false;
477
+ constructor(id) {
478
+ this.id = id;
479
+ this.dir = path3.join(sessionsDir(), id);
480
+ }
481
+ metaPath() {
482
+ return path3.join(this.dir, "meta.json");
483
+ }
484
+ messagesPath() {
485
+ return path3.join(this.dir, "messages.jsonl");
486
+ }
487
+ static async create(cwd, model) {
488
+ const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
489
+ store.pendingMeta = { cwd, model, createdAt: Date.now() };
490
+ return store;
491
+ }
492
+ static async open(id) {
493
+ const store = new _SessionStore(id);
494
+ try {
495
+ const raw = await fs.readFile(store.metaPath(), "utf8");
496
+ const meta = JSON.parse(raw);
497
+ if (meta.id !== id) return null;
498
+ store.initialized = true;
499
+ return store;
500
+ } catch {
501
+ return null;
502
+ }
503
+ }
504
+ static async list(cwd) {
505
+ let entries;
506
+ try {
507
+ entries = await fs.readdir(sessionsDir());
508
+ } catch {
509
+ return [];
510
+ }
511
+ const metas = [];
512
+ for (const entry of entries) {
513
+ try {
514
+ const raw = await fs.readFile(path3.join(sessionsDir(), entry, "meta.json"), "utf8");
515
+ const meta = JSON.parse(raw);
516
+ if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
517
+ } catch {
518
+ }
519
+ }
520
+ return metas.sort((a, b) => b.updatedAt - a.updatedAt);
521
+ }
522
+ async ensureInitialized() {
523
+ if (this.initialized) return;
524
+ const pending = this.pendingMeta;
525
+ if (!pending) {
526
+ this.initialized = true;
527
+ return;
528
+ }
529
+ await fs.mkdir(this.dir, { recursive: true });
530
+ const meta = {
531
+ id: this.id,
532
+ title: "",
533
+ model: pending.model,
534
+ cwd: pending.cwd,
535
+ createdAt: pending.createdAt,
536
+ updatedAt: pending.createdAt
537
+ };
538
+ await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
539
+ this.initialized = true;
540
+ }
541
+ async append(message) {
542
+ await this.ensureInitialized();
543
+ await fs.appendFile(this.messagesPath(), `${JSON.stringify(message)}
544
+ `);
545
+ const meta = await this.meta();
546
+ meta.updatedAt = Date.now();
547
+ if (!meta.title && message.role === "user") {
548
+ meta.title = messageText(message).slice(0, 60);
549
+ }
550
+ await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
551
+ }
552
+ async messages() {
553
+ let raw;
554
+ try {
555
+ raw = await fs.readFile(this.messagesPath(), "utf8");
556
+ } catch {
557
+ return [];
558
+ }
559
+ const messages = [];
560
+ for (const line of raw.split("\n")) {
561
+ const trimmed = line.trim();
562
+ if (!trimmed) continue;
563
+ try {
564
+ messages.push(JSON.parse(trimmed));
565
+ } catch {
566
+ }
567
+ }
568
+ return messages;
569
+ }
570
+ async meta() {
571
+ await this.ensureInitialized();
572
+ const raw = await fs.readFile(this.metaPath(), "utf8");
573
+ return JSON.parse(raw);
574
+ }
575
+ async setTitle(title) {
576
+ await this.ensureInitialized();
577
+ const meta = await this.meta();
578
+ meta.title = title;
579
+ meta.updatedAt = Date.now();
580
+ await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
581
+ }
582
+ async addUsage(delta) {
583
+ await this.ensureInitialized();
584
+ const meta = await this.meta();
585
+ const usage = meta.usage ?? {
586
+ requests: 0,
587
+ promptTokens: 0,
588
+ completionTokens: 0,
589
+ totalTokens: 0
590
+ };
591
+ usage.requests += 1;
592
+ usage.promptTokens += delta.promptTokens;
593
+ usage.completionTokens += delta.completionTokens;
594
+ usage.totalTokens += delta.totalTokens;
595
+ meta.usage = usage;
596
+ await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
597
+ }
598
+ };
599
+
600
+ // src/session/resume.ts
601
+ async function resumeSession(id) {
602
+ const store = await SessionStore.open(id);
603
+ if (!store) return null;
604
+ const [meta, messages] = await Promise.all([store.meta(), store.messages()]);
605
+ return { meta, messages };
606
+ }
607
+ function relativeTime(timestamp) {
608
+ const diff = Date.now() - timestamp;
609
+ const minutes = Math.floor(diff / 6e4);
610
+ if (minutes < 1) return "\u521A\u521A";
611
+ if (minutes < 60) return `${minutes} \u5206\u949F\u524D`;
612
+ const hours = Math.floor(minutes / 60);
613
+ if (hours < 24) return `${hours} \u5C0F\u65F6\u524D`;
614
+ const days = Math.floor(hours / 24);
615
+ if (days < 30) return `${days} \u5929\u524D`;
616
+ const months = Math.floor(days / 30);
617
+ if (months < 12) return `${months} \u4E2A\u6708\u524D`;
618
+ return `${Math.floor(months / 12)} \u5E74\u524D`;
619
+ }
620
+ function formatSessionList(metas) {
621
+ return metas.map((meta) => {
622
+ const title = meta.title || "(\u65E0\u6807\u9898)";
623
+ return `${meta.id} ${title} (\u66F4\u65B0\u4E8E ${relativeTime(meta.updatedAt)})`;
624
+ }).join("\n");
625
+ }
626
+
627
+ // src/tools/bash.ts
628
+ import { spawn } from "child_process";
629
+ import { existsSync } from "fs";
630
+ import path4 from "path";
631
+ import { z } from "zod";
632
+ var MAX_OUTPUT = 3e4;
633
+ var DEFAULT_TIMEOUT = 120;
634
+ var MAX_TIMEOUT = 600;
635
+ function findOnPath(exe, exclude) {
636
+ for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
637
+ if (!dir || exclude?.(dir)) {
638
+ continue;
639
+ }
640
+ const full = path4.join(dir, exe);
641
+ if (existsSync(full)) {
642
+ return full;
643
+ }
644
+ }
645
+ return null;
646
+ }
647
+ function resolveShell() {
648
+ if (process.platform !== "win32") {
649
+ return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
650
+ }
651
+ const isWslStub = (dir) => /\\(system32|windowsapps)\\?$/i.test(dir.trim());
652
+ const fromPath = findOnPath("bash.exe", isWslStub);
653
+ if (fromPath) {
654
+ return { shell: fromPath, wrap: (c) => ["-c", c], label: "bash" };
655
+ }
656
+ const gitExe = findOnPath("git.exe");
657
+ if (gitExe) {
658
+ const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
659
+ if (existsSync(sibling)) {
660
+ return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
661
+ }
662
+ }
663
+ for (const candidate of [
664
+ "C:\\Program Files\\Git\\bin\\bash.exe",
665
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
666
+ "D:\\Git\\bin\\bash.exe"
667
+ ]) {
668
+ if (existsSync(candidate)) {
669
+ return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
670
+ }
671
+ }
672
+ const comspec = process.env.ComSpec ?? "cmd.exe";
673
+ return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
674
+ }
675
+ var schema = z.object({
676
+ command: z.string().describe("Shell command to execute"),
677
+ timeout: z.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
678
+ description: z.string().optional().describe("Short description of what the command does")
679
+ });
680
+ function killTree(child) {
681
+ if (process.platform === "win32" && child.pid) {
682
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
683
+ windowsHide: true,
684
+ stdio: "ignore"
685
+ }).unref();
686
+ return;
687
+ }
688
+ child.kill("SIGKILL");
689
+ }
690
+ function truncateMiddle(s) {
691
+ if (s.length <= MAX_OUTPUT) {
692
+ return s;
693
+ }
694
+ const half = Math.floor(MAX_OUTPUT / 2);
695
+ const head = s.slice(0, half);
696
+ const tail = s.slice(-half);
697
+ return `${head}
698
+ ... [${s.length - MAX_OUTPUT} characters truncated] ...
699
+ ${tail}`;
700
+ }
701
+ var bashTool = {
702
+ name: "bash",
703
+ description: "Execute a shell command (Git Bash on Windows when available, otherwise cmd; sh elsewhere). Stdout and stderr are merged. Output is truncated to 30000 characters.",
704
+ permission: "exec",
705
+ parameters: schema,
706
+ execute(args, ctx) {
707
+ const timeoutSeconds = Math.min(args.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
708
+ const spec = resolveShell();
709
+ return new Promise((resolve) => {
710
+ const child = spawn(spec.shell, spec.wrap(args.command), {
711
+ cwd: ctx.cwd,
712
+ windowsHide: true
713
+ });
714
+ let output = "";
715
+ let settled = false;
716
+ const finish = (result) => {
717
+ if (settled) {
718
+ return;
719
+ }
720
+ settled = true;
721
+ clearTimeout(timer);
722
+ ctx.abortSignal?.removeEventListener("abort", onAbort);
723
+ resolve(result);
724
+ };
725
+ const onAbort = () => {
726
+ killTree(child);
727
+ finish({ content: `${truncateMiddle(output)}
728
+ Command aborted`, isError: true });
729
+ };
730
+ const timer = setTimeout(() => {
731
+ killTree(child);
732
+ finish({
733
+ content: `${truncateMiddle(output)}
734
+ Command timed out after ${timeoutSeconds}s`,
735
+ isError: true
736
+ });
737
+ }, timeoutSeconds * 1e3);
738
+ child.stdout.on("data", (d) => {
739
+ output += d.toString("utf8");
740
+ });
741
+ child.stderr.on("data", (d) => {
742
+ output += d.toString("utf8");
743
+ });
744
+ child.on("error", (err) => {
745
+ finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
746
+ });
747
+ ctx.abortSignal?.addEventListener("abort", onAbort);
748
+ child.on("close", (code) => {
749
+ const trimmed = output.replace(/\s+$/, "");
750
+ if (code === 0) {
751
+ finish({ content: truncateMiddle(trimmed) || "(no output)" });
752
+ return;
753
+ }
754
+ const body = truncateMiddle(trimmed);
755
+ finish({
756
+ content: `${body}${body ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
757
+ isError: true
758
+ });
759
+ });
760
+ });
761
+ }
762
+ };
763
+
764
+ // src/tools/fs/edit.ts
765
+ import { readFile, writeFile } from "fs/promises";
766
+ import path5 from "path";
767
+ import { z as z2 } from "zod";
768
+ var schema2 = z2.object({
769
+ path: z2.string().describe("File path, absolute or relative to the working directory"),
770
+ old_string: z2.string().min(1).describe("Exact text to replace"),
771
+ new_string: z2.string().describe("Replacement text"),
772
+ replace_all: z2.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
773
+ });
774
+ var editFileTool = {
775
+ name: "edit_file",
776
+ description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set.",
777
+ permission: "write",
778
+ parameters: schema2,
779
+ async execute(args, ctx) {
780
+ const filePath = path5.resolve(ctx.cwd, args.path);
781
+ let content;
782
+ try {
783
+ content = await readFile(filePath, "utf8");
784
+ } catch (err) {
785
+ return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
786
+ }
787
+ let count = 0;
788
+ let idx = content.indexOf(args.old_string);
789
+ while (idx !== -1) {
790
+ count += 1;
791
+ idx = content.indexOf(args.old_string, idx + args.old_string.length);
792
+ }
793
+ if (count === 0) {
794
+ return { content: `old_string not found in ${args.path}`, isError: true };
795
+ }
796
+ if (count > 1 && !args.replace_all) {
797
+ return {
798
+ content: `old_string matches ${count} locations in ${args.path}; provide more surrounding context or set replace_all`,
799
+ isError: true
800
+ };
801
+ }
802
+ const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
803
+ await writeFile(filePath, updated, "utf8");
804
+ return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
805
+ }
806
+ };
807
+
808
+ // src/tools/fs/glob.ts
809
+ import path7 from "path";
810
+ import { z as z3 } from "zod";
811
+
812
+ // src/tools/fs/util.ts
813
+ import { readdir, stat } from "fs/promises";
814
+ import path6 from "path";
815
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
816
+ async function walkFiles(root, skipDirs = SKIP_DIRS) {
817
+ const out = [];
818
+ async function walk(dir, relBase) {
819
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
820
+ if (!entries) {
821
+ return;
822
+ }
823
+ for (const entry of entries) {
824
+ const abs = path6.join(dir, entry.name);
825
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
826
+ if (entry.isDirectory()) {
827
+ if (!skipDirs.has(entry.name)) {
828
+ await walk(abs, rel);
829
+ }
830
+ } else if (entry.isFile()) {
831
+ try {
832
+ const st = await stat(abs);
833
+ out.push({ abs, rel, mtimeMs: st.mtimeMs });
834
+ } catch {
835
+ }
836
+ }
837
+ }
838
+ }
839
+ await walk(root, "");
840
+ return out;
841
+ }
842
+ var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
843
+ function globToRegExp(pattern) {
844
+ let re = "";
845
+ let i = 0;
846
+ while (i < pattern.length) {
847
+ const c = pattern.charAt(i);
848
+ if (c === "*") {
849
+ if (pattern.charAt(i + 1) === "*") {
850
+ i += 2;
851
+ if (pattern.charAt(i) === "/") {
852
+ i += 1;
853
+ re += "(?:[^/]+/)*";
854
+ } else {
855
+ re += ".*";
856
+ }
857
+ } else {
858
+ re += "[^/]*";
859
+ i += 1;
860
+ }
861
+ } else if (c === "?") {
862
+ re += "[^/]";
863
+ i += 1;
864
+ } else {
865
+ re += c.replace(REGEX_SPECIALS, "\\$&");
866
+ i += 1;
867
+ }
868
+ }
869
+ return new RegExp(`^${re}$`);
870
+ }
871
+ function matchesGlob(pattern, relPath) {
872
+ const normalized = pattern.replace(/\\/g, "/");
873
+ if (!normalized.includes("/")) {
874
+ const base = relPath.split("/").pop() ?? relPath;
875
+ return globToRegExp(normalized).test(base);
876
+ }
877
+ return globToRegExp(normalized).test(relPath);
878
+ }
879
+ function isSensitivePath2(filePath) {
880
+ const base = path6.basename(filePath);
881
+ if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
882
+ return false;
883
+ }
884
+ if (base === ".env" || base.startsWith(".env.")) {
885
+ return true;
886
+ }
887
+ if (base === "id_rsa" || base.endsWith(".pem")) {
888
+ return true;
889
+ }
890
+ return false;
891
+ }
892
+
893
+ // src/tools/fs/glob.ts
894
+ var MAX_RESULTS = 100;
895
+ var schema3 = z3.object({
896
+ pattern: z3.string().describe(
897
+ "Glob pattern, e.g. 'src/**/*.ts'. Supports **, * and ?. A bare pattern like '*.ts' matches basenames recursively."
898
+ ),
899
+ path: z3.string().optional().describe("Directory to search, defaults to the working directory")
900
+ });
901
+ var globTool = {
902
+ name: "glob",
903
+ description: "Find files by glob pattern. Skips node_modules, .git and dist. Returns paths sorted by modification time, newest first, up to 100 results.",
904
+ permission: "read",
905
+ parameters: schema3,
906
+ async execute(args, ctx) {
907
+ const root = path7.resolve(ctx.cwd, args.path ?? ".");
908
+ const files = await walkFiles(root);
909
+ const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
910
+ if (matched.length === 0) {
911
+ return { content: `No files matched pattern: ${args.pattern}` };
912
+ }
913
+ return { content: matched.map((f) => f.rel).join("\n") };
914
+ }
915
+ };
916
+
917
+ // src/tools/fs/grep.ts
918
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
919
+ import path8 from "path";
920
+ import { z as z4 } from "zod";
921
+ var MAX_MATCHES = 250;
922
+ var schema4 = z4.object({
923
+ pattern: z4.string().describe("Regular expression to search for"),
924
+ path: z4.string().optional().describe("File or directory to search, defaults to the working directory"),
925
+ glob: z4.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
926
+ ignoreCase: z4.boolean().optional().describe("Case-insensitive matching")
927
+ });
928
+ var grepTool = {
929
+ name: "grep",
930
+ description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, node_modules and .git.",
931
+ permission: "read",
932
+ parameters: schema4,
933
+ async execute(args, ctx) {
934
+ let re;
935
+ try {
936
+ re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
937
+ } catch (err) {
938
+ return { content: `Invalid regular expression: ${err.message}`, isError: true };
939
+ }
940
+ const root = path8.resolve(ctx.cwd, args.path ?? ".");
941
+ let files;
942
+ try {
943
+ const st = await stat2(root);
944
+ if (st.isFile()) {
945
+ files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
946
+ } else {
947
+ files = await walkFiles(root);
948
+ }
949
+ } catch {
950
+ return { content: `Path not found: ${args.path ?? "."}`, isError: true };
951
+ }
952
+ if (args.glob) {
953
+ const glob = args.glob;
954
+ files = files.filter((f) => matchesGlob(glob, f.rel));
955
+ }
956
+ const out = [];
957
+ let truncated = false;
958
+ outer: for (const file of files) {
959
+ let buf;
960
+ try {
961
+ buf = await readFile2(file.abs);
962
+ } catch {
963
+ continue;
964
+ }
965
+ if (buf.includes(0)) {
966
+ continue;
967
+ }
968
+ const lines = buf.toString("utf8").split("\n");
969
+ for (let i = 0; i < lines.length; i++) {
970
+ const line = lines[i] ?? "";
971
+ if (re.test(line)) {
972
+ out.push(`${file.rel}:${i + 1}:${line}`);
973
+ if (out.length >= MAX_MATCHES) {
974
+ truncated = true;
975
+ break outer;
976
+ }
977
+ }
978
+ }
979
+ }
980
+ if (out.length === 0) {
981
+ return { content: `No matches for pattern: ${args.pattern}` };
982
+ }
983
+ if (truncated) {
984
+ out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
985
+ }
986
+ return { content: out.join("\n") };
987
+ }
988
+ };
989
+
990
+ // src/tools/fs/read.ts
991
+ import { readFile as readFile3, stat as stat3 } from "fs/promises";
992
+ import path9 from "path";
993
+ import { z as z5 } from "zod";
994
+ var DEFAULT_LIMIT = 2e3;
995
+ var MAX_CHARS = 100 * 1024;
996
+ var schema5 = z5.object({
997
+ path: z5.string().describe("File path, absolute or relative to the working directory"),
998
+ offset: z5.number().int().positive().optional().describe("1-based line number to start reading from"),
999
+ limit: z5.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1000
+ });
1001
+ var readFileTool = {
1002
+ name: "read_file",
1003
+ description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default.",
1004
+ permission: "read",
1005
+ parameters: schema5,
1006
+ async execute(args, ctx) {
1007
+ const filePath = path9.resolve(ctx.cwd, args.path);
1008
+ if (isSensitivePath2(filePath)) {
1009
+ return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1010
+ }
1011
+ const st = await stat3(filePath).catch(() => null);
1012
+ if (!st) {
1013
+ return { content: `File not found: ${args.path}`, isError: true };
1014
+ }
1015
+ if (st.isDirectory()) {
1016
+ return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1017
+ }
1018
+ const raw = await readFile3(filePath, "utf8");
1019
+ const lines = raw.split("\n");
1020
+ const start = args.offset ?? 1;
1021
+ const limit = args.limit ?? DEFAULT_LIMIT;
1022
+ const end = Math.min(lines.length, start - 1 + limit);
1023
+ const out = [];
1024
+ let size = 0;
1025
+ let truncatedBySize = false;
1026
+ for (let i = start - 1; i < end; i++) {
1027
+ const line = lines[i];
1028
+ if (line === void 0) {
1029
+ break;
1030
+ }
1031
+ const numbered = `${i + 1} ${line}`;
1032
+ if (size + numbered.length > MAX_CHARS) {
1033
+ truncatedBySize = true;
1034
+ break;
1035
+ }
1036
+ out.push(numbered);
1037
+ size += numbered.length;
1038
+ }
1039
+ const lastShown = start - 1 + out.length;
1040
+ if (truncatedBySize) {
1041
+ out.push(
1042
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${lines.length})`
1043
+ );
1044
+ } else if (end < lines.length) {
1045
+ out.push(`... (truncated: showing lines ${start}-${end} of ${lines.length})`);
1046
+ }
1047
+ return { content: out.join("\n") };
1048
+ }
1049
+ };
1050
+
1051
+ // src/tools/fs/write.ts
1052
+ import { mkdir, writeFile as writeFile2 } from "fs/promises";
1053
+ import path10 from "path";
1054
+ import { z as z6 } from "zod";
1055
+ var schema6 = z6.object({
1056
+ path: z6.string().describe("File path, absolute or relative to the working directory"),
1057
+ content: z6.string().describe("Full content to write to the file")
1058
+ });
1059
+ var writeFileTool = {
1060
+ name: "write_file",
1061
+ description: "Write content to a file, creating parent directories as needed. Overwrites existing files.",
1062
+ permission: "write",
1063
+ parameters: schema6,
1064
+ async execute(args, ctx) {
1065
+ const filePath = path10.resolve(ctx.cwd, args.path);
1066
+ try {
1067
+ await mkdir(path10.dirname(filePath), { recursive: true });
1068
+ await writeFile2(filePath, args.content, "utf8");
1069
+ } catch (err) {
1070
+ return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1071
+ }
1072
+ return { content: `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}` };
1073
+ }
1074
+ };
1075
+
1076
+ // src/tools/todo.ts
1077
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1078
+ import path11 from "path";
1079
+ import { z as z7 } from "zod";
1080
+ var todoItemSchema = z7.object({
1081
+ id: z7.number().int(),
1082
+ title: z7.string(),
1083
+ status: z7.enum(["pending", "in_progress", "done"])
1084
+ });
1085
+ var writeSchema = z7.object({
1086
+ todos: z7.array(todoItemSchema).describe("The full todo list, replacing the current one")
1087
+ });
1088
+ var readSchema = z7.object({});
1089
+ function fileFor(cwd) {
1090
+ return path11.join(cwd, ".star", "todos.json");
1091
+ }
1092
+ var TodoStore = class {
1093
+ items = /* @__PURE__ */ new Map();
1094
+ list() {
1095
+ return [...this.items.values()];
1096
+ }
1097
+ replace(items) {
1098
+ this.items.clear();
1099
+ for (const item of items) {
1100
+ this.items.set(item.id, item);
1101
+ }
1102
+ }
1103
+ async load(cwd) {
1104
+ let raw;
1105
+ try {
1106
+ raw = await readFile4(fileFor(cwd), "utf8");
1107
+ } catch {
1108
+ return;
1109
+ }
1110
+ try {
1111
+ const parsed = JSON.parse(raw);
1112
+ if (Array.isArray(parsed)) {
1113
+ this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
1114
+ }
1115
+ } catch {
1116
+ }
1117
+ }
1118
+ async save(cwd) {
1119
+ await mkdir2(path11.join(cwd, ".star"), { recursive: true });
1120
+ await writeFile3(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1121
+ `);
1122
+ }
1123
+ };
1124
+ var SYMBOLS = {
1125
+ pending: "[ ]",
1126
+ in_progress: "[~]",
1127
+ done: "[x]"
1128
+ };
1129
+ function formatTodos(items) {
1130
+ if (items.length === 0) {
1131
+ return "No todos.";
1132
+ }
1133
+ const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
1134
+ const width = Math.max(...lines.map((l) => l.length));
1135
+ return lines.map((line, i) => {
1136
+ const status = items[i]?.status;
1137
+ return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
1138
+ }).join("\n");
1139
+ }
1140
+ var defaultStore = new TodoStore();
1141
+ function createTodoTools(store = defaultStore) {
1142
+ const todoWrite = {
1143
+ name: "todo_write",
1144
+ description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
1145
+ permission: "read",
1146
+ parameters: writeSchema,
1147
+ async execute(args, ctx) {
1148
+ const parsed = writeSchema.safeParse(args);
1149
+ if (!parsed.success) {
1150
+ return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
1151
+ }
1152
+ await store.load(ctx.cwd);
1153
+ store.replace(parsed.data.todos);
1154
+ await store.save(ctx.cwd);
1155
+ return { content: formatTodos(store.list()) };
1156
+ }
1157
+ };
1158
+ const todoRead = {
1159
+ name: "todo_read",
1160
+ description: "Read the current todo list.",
1161
+ permission: "read",
1162
+ parameters: readSchema,
1163
+ async execute(_args, ctx) {
1164
+ await store.load(ctx.cwd);
1165
+ return { content: formatTodos(store.list()) };
1166
+ }
1167
+ };
1168
+ return [todoWrite, todoRead];
1169
+ }
1170
+
1171
+ // src/tools/web/fetch.ts
1172
+ import { z as z8 } from "zod";
1173
+ var DEFAULT_MAX_CHARS = 2e4;
1174
+ var MAX_BYTES = 2 * 1024 * 1024;
1175
+ var TIMEOUT_MS = 3e4;
1176
+ var schema7 = z8.object({
1177
+ url: z8.string().describe("The http(s) URL to fetch"),
1178
+ maxChars: z8.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
1179
+ });
1180
+ function decodeEntities(s) {
1181
+ return s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCodePoint(Number.parseInt(n, 16))).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
1182
+ switch (m) {
1183
+ case "&quot;":
1184
+ return '"';
1185
+ case "&lt;":
1186
+ return "<";
1187
+ case "&gt;":
1188
+ return ">";
1189
+ case "&nbsp;":
1190
+ return " ";
1191
+ default:
1192
+ return m;
1193
+ }
1194
+ }).replace(/&amp;/g, "&");
1195
+ }
1196
+ function htmlToText(html) {
1197
+ const noBlocks = html.replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
1198
+ const withBreaks = noBlocks.replace(
1199
+ /<br\s*\/?>|<\/(p|div|li|tr|h[1-6]|section|article|header|footer|blockquote|pre)>/gi,
1200
+ "\n"
1201
+ );
1202
+ const stripped = decodeEntities(withBreaks.replace(/<[^>]+>/g, " "));
1203
+ return stripped.split("\n").map((line) => line.replace(/\s+/g, " ").trim()).join("\n").replace(/\n{3,}/g, "\n\n").trim();
1204
+ }
1205
+ async function readBody(res, maxChars) {
1206
+ if (!res.body) {
1207
+ return "";
1208
+ }
1209
+ const reader = res.body.getReader();
1210
+ const decoder = new TextDecoder("utf-8");
1211
+ let bytes = 0;
1212
+ let text = "";
1213
+ try {
1214
+ for (; ; ) {
1215
+ const { done, value } = await reader.read();
1216
+ if (done) {
1217
+ break;
1218
+ }
1219
+ bytes += value.byteLength;
1220
+ text += decoder.decode(value, { stream: true });
1221
+ if (bytes >= MAX_BYTES || text.length >= maxChars * 2) {
1222
+ break;
1223
+ }
1224
+ }
1225
+ text += decoder.decode();
1226
+ } finally {
1227
+ reader.cancel().catch(() => {
1228
+ });
1229
+ }
1230
+ return text;
1231
+ }
1232
+ function truncate(s, maxChars) {
1233
+ if (s.length <= maxChars) {
1234
+ return s;
1235
+ }
1236
+ return `${s.slice(0, maxChars)}
1237
+ [truncated, showing first ${maxChars} of ${s.length} chars]`;
1238
+ }
1239
+ var webFetchTool = {
1240
+ name: "web_fetch",
1241
+ description: "Fetch a URL over http(s) and return its contents as readable text. HTML pages are converted to plain text (tags, scripts and styles removed). Output is truncated to maxChars (default 20000).",
1242
+ permission: "read",
1243
+ parameters: schema7,
1244
+ async execute(args, ctx) {
1245
+ let url;
1246
+ try {
1247
+ url = new URL(args.url);
1248
+ } catch {
1249
+ return { content: `ERROR: invalid URL: ${args.url}`, isError: true };
1250
+ }
1251
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1252
+ return {
1253
+ content: `ERROR: only http(s) URLs are supported, got: ${url.protocol}`,
1254
+ isError: true
1255
+ };
1256
+ }
1257
+ const maxChars = args.maxChars ?? DEFAULT_MAX_CHARS;
1258
+ const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS);
1259
+ const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
1260
+ let res;
1261
+ try {
1262
+ res = await fetch(url, { signal, redirect: "follow" });
1263
+ } catch (err) {
1264
+ if (ctx.abortSignal?.aborted) {
1265
+ return { content: "ERROR: request aborted", isError: true };
1266
+ }
1267
+ if (timeoutSignal.aborted) {
1268
+ return { content: `ERROR: request timed out after ${TIMEOUT_MS / 1e3}s`, isError: true };
1269
+ }
1270
+ const message = err instanceof Error ? err.message : String(err);
1271
+ return { content: `ERROR: failed to fetch ${args.url}: ${message}`, isError: true };
1272
+ }
1273
+ if (!res.ok) {
1274
+ await res.body?.cancel().catch(() => {
1275
+ });
1276
+ return {
1277
+ content: `ERROR: HTTP ${res.status} ${res.statusText} for ${args.url}`,
1278
+ isError: true
1279
+ };
1280
+ }
1281
+ const [rawType = ""] = (res.headers.get("content-type") ?? "text/plain").split(";");
1282
+ const contentType = rawType.trim().toLowerCase();
1283
+ const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
1284
+ const isText = contentType.startsWith("text/") || contentType === "application/json" || contentType === "application/xml" || contentType.endsWith("+json") || contentType.endsWith("+xml");
1285
+ if (!isHtml && !isText) {
1286
+ await res.body?.cancel().catch(() => {
1287
+ });
1288
+ return {
1289
+ content: `ERROR: unsupported content type: ${contentType} (${args.url})`,
1290
+ isError: true
1291
+ };
1292
+ }
1293
+ let body;
1294
+ try {
1295
+ body = await readBody(res, maxChars);
1296
+ } catch (err) {
1297
+ const message = err instanceof Error ? err.message : String(err);
1298
+ return { content: `ERROR: failed to read response body: ${message}`, isError: true };
1299
+ }
1300
+ const text = isHtml ? htmlToText(body) : body;
1301
+ return { content: truncate(text, maxChars) };
1302
+ }
1303
+ };
1304
+
1305
+ // src/tools/registry.ts
1306
+ var ToolRegistry = class {
1307
+ tools = /* @__PURE__ */ new Map();
1308
+ constructor() {
1309
+ for (const tool of [
1310
+ readFileTool,
1311
+ writeFileTool,
1312
+ editFileTool,
1313
+ globTool,
1314
+ grepTool,
1315
+ bashTool,
1316
+ webFetchTool,
1317
+ ...createTodoTools()
1318
+ ]) {
1319
+ this.register(tool);
1320
+ }
1321
+ }
1322
+ register(tool) {
1323
+ this.tools.set(tool.name, tool);
1324
+ }
1325
+ get(name) {
1326
+ return this.tools.get(name);
1327
+ }
1328
+ list() {
1329
+ return [...this.tools.values()];
1330
+ }
1331
+ names() {
1332
+ return [...this.tools.keys()];
1333
+ }
1334
+ };
1335
+
1336
+ // src/tools/index.ts
1337
+ function createDefaultRegistry() {
1338
+ const registry = new ToolRegistry();
1339
+ for (const tool of createTodoTools()) {
1340
+ registry.register(tool);
1341
+ }
1342
+ return registry;
1343
+ }
1344
+
1345
+ // src/cli/commands/builtin.ts
1346
+ function registerBuiltinCommands(registry) {
1347
+ registry.register({
1348
+ name: "help",
1349
+ description: "List available commands",
1350
+ usage: "/help",
1351
+ run(_args, ctx) {
1352
+ const lines = registry.list().map((cmd) => {
1353
+ const usage = cmd.usage ?? `/${cmd.name}`;
1354
+ return `${usage} - ${cmd.description}`;
1355
+ });
1356
+ ctx.addSystemMessage(`Available commands:
1357
+ ${lines.join("\n")}`);
1358
+ }
1359
+ });
1360
+ registry.register({
1361
+ name: "clear",
1362
+ description: "Clear message history",
1363
+ usage: "/clear",
1364
+ run(_args, ctx) {
1365
+ ctx.clearMessages();
1366
+ }
1367
+ });
1368
+ registry.register({
1369
+ name: "exit",
1370
+ description: "Exit the application",
1371
+ usage: "/exit",
1372
+ run(_args, ctx) {
1373
+ ctx.exit();
1374
+ }
1375
+ });
1376
+ registry.register({
1377
+ name: "q",
1378
+ description: "Exit the application (alias of /exit)",
1379
+ usage: "/q",
1380
+ run(_args, ctx) {
1381
+ ctx.exit();
1382
+ }
1383
+ });
1384
+ registry.register({
1385
+ name: "model",
1386
+ description: "List available models or switch the current model",
1387
+ usage: "/model [name]",
1388
+ async run(args, ctx) {
1389
+ if (!args) {
1390
+ ctx.addSystemMessage(ctx.listModels());
1391
+ } else {
1392
+ ctx.addSystemMessage(await ctx.switchModel(args));
1393
+ }
1394
+ }
1395
+ });
1396
+ registry.register({
1397
+ name: "resume",
1398
+ description: "List sessions or resume a session by id",
1399
+ usage: "/resume [sessionId]",
1400
+ async run(args, ctx) {
1401
+ if (!args) {
1402
+ ctx.addSystemMessage(await ctx.listSessions());
1403
+ } else {
1404
+ ctx.addSystemMessage(await ctx.resumeSession(args));
1405
+ }
1406
+ }
1407
+ });
1408
+ registry.register({
1409
+ name: "todo",
1410
+ description: "Show the current todo list",
1411
+ usage: "/todo",
1412
+ async run(_args, ctx) {
1413
+ ctx.addSystemMessage(await ctx.showTodos());
1414
+ }
1415
+ });
1416
+ registry.register({
1417
+ name: "cost",
1418
+ description: "Show API token usage for this session",
1419
+ usage: "/cost",
1420
+ run(_args, ctx) {
1421
+ ctx.addSystemMessage(ctx.showUsage());
1422
+ }
1423
+ });
1424
+ registry.register({
1425
+ name: "config",
1426
+ description: "Show the current configuration",
1427
+ usage: "/config",
1428
+ run(_args, ctx) {
1429
+ ctx.addSystemMessage(ctx.describeConfig());
1430
+ }
1431
+ });
1432
+ }
1433
+
1434
+ // src/cli/commands/registry.ts
1435
+ var CommandRegistry = class {
1436
+ commands = /* @__PURE__ */ new Map();
1437
+ register(command) {
1438
+ this.commands.set(command.name, command);
1439
+ }
1440
+ list() {
1441
+ return [...this.commands.values()].sort((a, b) => a.name.localeCompare(b.name));
1442
+ }
1443
+ get(name) {
1444
+ return this.commands.get(name);
1445
+ }
1446
+ complete(prefix) {
1447
+ const stripped = prefix.startsWith("/") ? prefix.slice(1) : prefix;
1448
+ return this.list().filter((cmd) => cmd.name.startsWith(stripped));
1449
+ }
1450
+ };
1451
+ function parseSlashCommand(input) {
1452
+ if (!input.startsWith("/")) return null;
1453
+ const body = input.slice(1).trim();
1454
+ if (body.length === 0) return null;
1455
+ const spaceIndex = body.search(/\s/);
1456
+ if (spaceIndex === -1) return { name: body, args: "" };
1457
+ return {
1458
+ name: body.slice(0, spaceIndex),
1459
+ args: body.slice(spaceIndex).trim()
1460
+ };
1461
+ }
1462
+
1463
+ // src/cli/components/InputBox.tsx
1464
+ import { Box, Text, useInput } from "ink";
1465
+ import { useRef, useState } from "react";
1466
+ import { jsx, jsxs } from "react/jsx-runtime";
1467
+ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1468
+ const [value, setValue] = useState("");
1469
+ const [cursor, setCursor] = useState(0);
1470
+ const [history, setHistory] = useState([]);
1471
+ const historyIndexRef = useRef(null);
1472
+ const draftRef = useRef("");
1473
+ const edit = (next, nextCursor) => {
1474
+ setValue(next);
1475
+ setCursor(Math.max(0, Math.min(nextCursor, next.length)));
1476
+ };
1477
+ useInput((input, key) => {
1478
+ if (key.ctrl && input === "c") {
1479
+ if (isStreaming || disabled) {
1480
+ onInterrupt();
1481
+ } else {
1482
+ edit("", 0);
1483
+ historyIndexRef.current = null;
1484
+ }
1485
+ return;
1486
+ }
1487
+ if (key.ctrl && input === "d") {
1488
+ onExit();
1489
+ return;
1490
+ }
1491
+ if (isStreaming || disabled) return;
1492
+ if (key.return) {
1493
+ const text = value.trim();
1494
+ if (text.length > 0) {
1495
+ setHistory((prev) => [...prev, text]);
1496
+ onSubmit(text);
1497
+ }
1498
+ edit("", 0);
1499
+ historyIndexRef.current = null;
1500
+ draftRef.current = "";
1501
+ return;
1502
+ }
1503
+ if (key.upArrow) {
1504
+ if (history.length === 0) return;
1505
+ if (historyIndexRef.current === null) {
1506
+ draftRef.current = value;
1507
+ historyIndexRef.current = history.length - 1;
1508
+ } else if (historyIndexRef.current > 0) {
1509
+ historyIndexRef.current -= 1;
1510
+ }
1511
+ const entry = history[historyIndexRef.current] ?? "";
1512
+ edit(entry, entry.length);
1513
+ return;
1514
+ }
1515
+ if (key.downArrow) {
1516
+ if (historyIndexRef.current === null) return;
1517
+ if (historyIndexRef.current < history.length - 1) {
1518
+ historyIndexRef.current += 1;
1519
+ const entry = history[historyIndexRef.current] ?? "";
1520
+ edit(entry, entry.length);
1521
+ } else {
1522
+ historyIndexRef.current = null;
1523
+ edit(draftRef.current, draftRef.current.length);
1524
+ }
1525
+ return;
1526
+ }
1527
+ if (key.leftArrow) {
1528
+ setCursor((prev) => Math.max(0, prev - 1));
1529
+ return;
1530
+ }
1531
+ if (key.rightArrow) {
1532
+ setCursor((prev) => Math.min(value.length, prev + 1));
1533
+ return;
1534
+ }
1535
+ if (key.ctrl && input === "a") {
1536
+ setCursor(0);
1537
+ return;
1538
+ }
1539
+ if (key.ctrl && input === "e") {
1540
+ setCursor(value.length);
1541
+ return;
1542
+ }
1543
+ if (key.ctrl && input === "u") {
1544
+ edit(value.slice(cursor), 0);
1545
+ return;
1546
+ }
1547
+ if (key.ctrl && input === "k") {
1548
+ edit(value.slice(0, cursor), cursor);
1549
+ return;
1550
+ }
1551
+ if (key.ctrl && input === "w") {
1552
+ const trimmed = value.slice(0, cursor).replace(/\s+$/, "");
1553
+ const wordStart = trimmed.search(/\S+$/);
1554
+ const next = value.slice(0, wordStart === -1 ? 0 : wordStart) + value.slice(cursor);
1555
+ edit(next, wordStart === -1 ? 0 : wordStart);
1556
+ return;
1557
+ }
1558
+ if (key.backspace || key.delete) {
1559
+ if (cursor === 0) return;
1560
+ edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
1561
+ return;
1562
+ }
1563
+ if (input && !key.ctrl && !key.meta) {
1564
+ edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
1565
+ }
1566
+ });
1567
+ const before = value.slice(0, cursor);
1568
+ const at = value[cursor] ?? " ";
1569
+ const after = value.slice(cursor + 1);
1570
+ return /* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
1571
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "> " }),
1572
+ /* @__PURE__ */ jsx(Text, { children: before }),
1573
+ /* @__PURE__ */ jsx(Text, { inverse: true, children: at }),
1574
+ /* @__PURE__ */ jsx(Text, { children: after })
1575
+ ] });
1576
+ }
1577
+
1578
+ // src/cli/components/MessageList.tsx
1579
+ import { Box as Box2, Static, Text as Text2 } from "ink";
1580
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1581
+ var roleStyles = {
1582
+ user: { label: "you", color: "cyan" },
1583
+ assistant: { label: "star", color: "green" },
1584
+ system: { label: "system", color: "yellow" },
1585
+ tool: { label: "tool", color: "magenta" }
1586
+ };
1587
+ function MessageList({ messages }) {
1588
+ return /* @__PURE__ */ jsx2(Static, { items: messages, children: (message) => {
1589
+ const style = roleStyles[message.role];
1590
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: 1, children: [
1591
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: style.color, children: style.label }),
1592
+ /* @__PURE__ */ jsx2(Text2, { color: style.color, children: message.text })
1593
+ ] }, message.id);
1594
+ } });
1595
+ }
1596
+
1597
+ // src/cli/components/PermissionPrompt.tsx
1598
+ import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
1599
+
1600
+ // src/cli/format.ts
1601
+ function summarizeArgs(args, maxLength = 120) {
1602
+ let json;
1603
+ try {
1604
+ json = JSON.stringify(args) ?? String(args);
1605
+ } catch {
1606
+ json = String(args);
1607
+ }
1608
+ return json.length > maxLength ? `${json.slice(0, maxLength)}...` : json;
1609
+ }
1610
+ function previewLines(text, maxLines = 10) {
1611
+ const lines = text.split("\n");
1612
+ if (lines.length <= maxLines) return { text, truncated: false };
1613
+ return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
1614
+ }
1615
+ function coreMessageText(message) {
1616
+ const content = message.content;
1617
+ if (typeof content === "string") return content;
1618
+ if (Array.isArray(content)) {
1619
+ return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
1620
+ }
1621
+ return "";
1622
+ }
1623
+ function buildDisplayMessages(messages) {
1624
+ const display = [];
1625
+ let collapsed = 0;
1626
+ for (const message of messages) {
1627
+ if (message.role === "user" || message.role === "assistant") {
1628
+ const text = coreMessageText(message);
1629
+ if (text) {
1630
+ display.push({ id: display.length, role: message.role, text });
1631
+ } else {
1632
+ collapsed++;
1633
+ }
1634
+ } else if (message.role === "tool") {
1635
+ collapsed++;
1636
+ }
1637
+ }
1638
+ if (collapsed > 0) {
1639
+ display.push({
1640
+ id: display.length,
1641
+ role: "system",
1642
+ text: `\u5DF2\u6062\u590D ${collapsed} \u6761\u5386\u53F2\u6D88\u606F`
1643
+ });
1644
+ }
1645
+ return display;
1646
+ }
1647
+
1648
+ // src/cli/components/PermissionPrompt.tsx
1649
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1650
+ function PermissionPrompt({ request, onDecision }) {
1651
+ useInput2((input) => {
1652
+ const ch = input.toLowerCase();
1653
+ if (ch === "y") onDecision("yes");
1654
+ else if (ch === "n") onDecision("no");
1655
+ else if (ch === "a") onDecision("always");
1656
+ });
1657
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
1658
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, color: "yellow", children: [
1659
+ "Permission required: ",
1660
+ request.toolName,
1661
+ " (",
1662
+ request.level,
1663
+ ")"
1664
+ ] }),
1665
+ /* @__PURE__ */ jsx3(Text3, { children: summarizeArgs(request.args) }),
1666
+ /* @__PURE__ */ jsx3(Text3, { children: "[y] allow [n] deny [a] always" })
1667
+ ] });
1668
+ }
1669
+
1670
+ // src/cli/components/StatusBar.tsx
1671
+ import { Box as Box4, Text as Text4 } from "ink";
1672
+ import { jsxs as jsxs4 } from "react/jsx-runtime";
1673
+ function StatusBar({ model, permissionMode, tokens }) {
1674
+ return /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", children: [
1675
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1676
+ "model: ",
1677
+ model
1678
+ ] }),
1679
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1680
+ tokens,
1681
+ " tokens"
1682
+ ] }),
1683
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1684
+ "mode: ",
1685
+ permissionMode
1686
+ ] })
1687
+ ] });
1688
+ }
1689
+
1690
+ // src/cli/components/StreamingMessage.tsx
1691
+ import { Box as Box5, Text as Text5 } from "ink";
1692
+ import { jsx as jsx4, jsxs as jsxs5 } from "react/jsx-runtime";
1693
+ function StreamingMessage({ text }) {
1694
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginBottom: 1, children: [
1695
+ /* @__PURE__ */ jsx4(Text5, { bold: true, color: "green", children: "star" }),
1696
+ /* @__PURE__ */ jsx4(Text5, { color: "green", children: text })
1697
+ ] });
1698
+ }
1699
+
1700
+ // src/cli/components/ToolCallCard.tsx
1701
+ import { Box as Box6, Text as Text6 } from "ink";
1702
+ import { jsx as jsx5, jsxs as jsxs6 } from "react/jsx-runtime";
1703
+ function ToolCallCard({ card }) {
1704
+ const color = card.isError ? "red" : "magenta";
1705
+ const preview = card.result !== void 0 ? previewLines(card.result, 10) : null;
1706
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginBottom: 1, children: [
1707
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color, children: [
1708
+ "tool: ",
1709
+ card.name,
1710
+ card.isError ? " (error)" : ""
1711
+ ] }),
1712
+ /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: card.argsSummary }),
1713
+ preview && /* @__PURE__ */ jsxs6(Text6, { color, children: [
1714
+ preview.text,
1715
+ preview.truncated ? "\n... (truncated)" : ""
1716
+ ] })
1717
+ ] });
1718
+ }
1719
+ function formatToolCard(card) {
1720
+ const header = `${card.name} ${card.argsSummary}${card.isError ? " [error]" : ""}`;
1721
+ if (card.result === void 0) return header;
1722
+ const preview = previewLines(card.result, 10);
1723
+ return `${header}
1724
+ ${preview.text}${preview.truncated ? "\n... (truncated)" : ""}`;
1725
+ }
1726
+
1727
+ // src/cli/repl.tsx
1728
+ import { jsx as jsx6, jsxs as jsxs7 } from "react/jsx-runtime";
1729
+ var FLUSH_INTERVAL_MS = 30;
1730
+ function emptyUsage() {
1731
+ return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1732
+ }
1733
+ function formatUsage(usage) {
1734
+ return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
1735
+ }
1736
+ function Repl({
1737
+ backend,
1738
+ model,
1739
+ permissionMode,
1740
+ config,
1741
+ cwd,
1742
+ sessionStore,
1743
+ initialMessages,
1744
+ initialUsage
1745
+ }) {
1746
+ const { exit } = useApp();
1747
+ const [initialDisplay] = useState2(() => buildDisplayMessages(initialMessages ?? []));
1748
+ const [messages, setMessages] = useState2(initialDisplay);
1749
+ const [epoch, setEpoch] = useState2(0);
1750
+ const [streamingText, setStreamingText] = useState2(null);
1751
+ const [isStreaming, setIsStreaming] = useState2(false);
1752
+ const [usageVersion, setUsageVersion] = useState2(0);
1753
+ const [modelName, setModelName] = useState2(model);
1754
+ const [pending, setPending] = useState2(null);
1755
+ const [cardsVersion, setCardsVersion] = useState2(0);
1756
+ const backendRef = useRef2(backend);
1757
+ const nextIdRef = useRef2(initialDisplay.length);
1758
+ const abortRef = useRef2(null);
1759
+ const streamedRef = useRef2("");
1760
+ const flushTimerRef = useRef2(null);
1761
+ const toolCardsRef = useRef2(/* @__PURE__ */ new Map());
1762
+ const pendingRef = useRef2(null);
1763
+ const alwaysAllowedRef = useRef2(/* @__PURE__ */ new Set());
1764
+ const modelNameRef = useRef2(model);
1765
+ const usageRef = useRef2(initialUsage ? { ...initialUsage } : emptyUsage());
1766
+ const pushMessage = useCallback((role, text) => {
1767
+ setMessages((prev) => [...prev, { id: nextIdRef.current++, role, text }]);
1768
+ }, []);
1769
+ const setPendingPermission = useCallback((p) => {
1770
+ pendingRef.current = p;
1771
+ setPending(p);
1772
+ }, []);
1773
+ const attachConfirmHandler = useCallback(
1774
+ (target) => {
1775
+ target.confirmHandler = (req) => {
1776
+ if (alwaysAllowedRef.current.has(req.toolName)) {
1777
+ return Promise.resolve(true);
1778
+ }
1779
+ return new Promise((resolve) => {
1780
+ setPendingPermission({ request: req, resolve });
1781
+ });
1782
+ };
1783
+ },
1784
+ [setPendingPermission]
1785
+ );
1786
+ useEffect(() => {
1787
+ attachConfirmHandler(backendRef.current);
1788
+ return () => {
1789
+ if (flushTimerRef.current !== null) clearInterval(flushTimerRef.current);
1790
+ abortRef.current?.abort();
1791
+ pendingRef.current?.resolve(false);
1792
+ };
1793
+ }, [attachConfirmHandler]);
1794
+ const interrupt = useCallback(() => {
1795
+ abortRef.current?.abort();
1796
+ const p = pendingRef.current;
1797
+ if (p) {
1798
+ setPendingPermission(null);
1799
+ p.resolve(false);
1800
+ }
1801
+ }, [setPendingPermission]);
1802
+ useInput3((_input, key) => {
1803
+ if (key.escape) interrupt();
1804
+ });
1805
+ const handleDecision = useCallback(
1806
+ (decision) => {
1807
+ const p = pendingRef.current;
1808
+ if (!p) return;
1809
+ if (decision === "always") {
1810
+ alwaysAllowedRef.current.add(p.request.toolName);
1811
+ }
1812
+ setPendingPermission(null);
1813
+ p.resolve(decision !== "no");
1814
+ },
1815
+ [setPendingPermission]
1816
+ );
1817
+ const switchModel = useCallback(
1818
+ async (name) => {
1819
+ try {
1820
+ const newModel = createModel(config, name);
1821
+ const loop = new AgentLoop({
1822
+ model: newModel,
1823
+ registry: createDefaultRegistry(),
1824
+ config,
1825
+ cwd,
1826
+ sessionStore
1827
+ });
1828
+ const prev = backendRef.current;
1829
+ if (prev instanceof AgentLoop) {
1830
+ await loop.loadMessages([...prev.getMessages()]);
1831
+ }
1832
+ attachConfirmHandler(loop);
1833
+ backendRef.current = loop;
1834
+ modelNameRef.current = name;
1835
+ setModelName(name);
1836
+ return `Switched to model "${name}".`;
1837
+ } catch (error) {
1838
+ return `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`;
1839
+ }
1840
+ },
1841
+ [config, cwd, sessionStore, attachConfirmHandler]
1842
+ );
1843
+ const resume = useCallback(async (id) => {
1844
+ const current = backendRef.current;
1845
+ if (!(current instanceof AgentLoop)) {
1846
+ return "Current backend does not support resuming sessions.";
1847
+ }
1848
+ const resumed = await resumeSession(id);
1849
+ if (!resumed) {
1850
+ return `Session not found: ${id}`;
1851
+ }
1852
+ await current.loadMessages(resumed.messages);
1853
+ const display = buildDisplayMessages(resumed.messages);
1854
+ nextIdRef.current = display.length;
1855
+ setMessages(display);
1856
+ setEpoch((e) => e + 1);
1857
+ usageRef.current = resumed.meta.usage ? { ...resumed.meta.usage } : emptyUsage();
1858
+ setUsageVersion((v) => v + 1);
1859
+ return `Resumed session ${id} (${resumed.messages.length} messages).`;
1860
+ }, []);
1861
+ const registry = useMemo(() => {
1862
+ const ctx = {
1863
+ addSystemMessage: (text) => pushMessage("system", text),
1864
+ clearMessages: () => {
1865
+ setMessages([]);
1866
+ setEpoch((e) => e + 1);
1867
+ },
1868
+ exit: () => exit(),
1869
+ listModels: () => {
1870
+ const models = listModels(config);
1871
+ if (models.length === 0) return "No models configured.";
1872
+ const lines = models.map(
1873
+ (m) => `${m.name === modelNameRef.current ? "*" : " "} ${m.name} (${m.provider}/${m.model})`
1874
+ );
1875
+ return `Models (* = current):
1876
+ ${lines.join("\n")}`;
1877
+ },
1878
+ switchModel,
1879
+ listSessions: async () => {
1880
+ const metas = await SessionStore.list(cwd);
1881
+ return metas.length === 0 ? "No sessions found for this directory." : formatSessionList(metas);
1882
+ },
1883
+ resumeSession: resume,
1884
+ showTodos: async () => {
1885
+ const store = new TodoStore();
1886
+ await store.load(cwd);
1887
+ return formatTodos(store.list());
1888
+ },
1889
+ showUsage: () => formatUsage(usageRef.current),
1890
+ describeConfig: () => [
1891
+ `defaultModel: ${config.defaultModel || "(none)"}`,
1892
+ `permissionMode: ${config.permissionMode}`,
1893
+ `providers (${config.providers.length}): ${config.providers.map((p) => p.name).join(", ") || "(none)"}`,
1894
+ `models (${config.models.length}): ${config.models.map((m) => m.name).join(", ") || "(none)"}`,
1895
+ `maxSteps: ${config.maxSteps}`,
1896
+ `contextMaxTokens: ${config.contextMaxTokens}`
1897
+ ].join("\n")
1898
+ };
1899
+ const reg = new CommandRegistry();
1900
+ registerBuiltinCommands(reg);
1901
+ return Object.assign(reg, { ctx });
1902
+ }, [pushMessage, exit, config, cwd, switchModel, resume]);
1903
+ const runStream = useCallback(
1904
+ async (input) => {
1905
+ pushMessage("user", input);
1906
+ const controller = new AbortController();
1907
+ abortRef.current = controller;
1908
+ streamedRef.current = "";
1909
+ setStreamingText("");
1910
+ setIsStreaming(true);
1911
+ flushTimerRef.current = setInterval(() => {
1912
+ setStreamingText(streamedRef.current);
1913
+ }, FLUSH_INTERVAL_MS);
1914
+ try {
1915
+ for await (const event of backendRef.current.stream(input, controller.signal)) {
1916
+ if (event.type === "text-delta") {
1917
+ streamedRef.current += event.text;
1918
+ } else if (event.type === "tool-call") {
1919
+ toolCardsRef.current.set(event.id, {
1920
+ id: event.id,
1921
+ name: event.name,
1922
+ argsSummary: summarizeArgs(event.args)
1923
+ });
1924
+ setCardsVersion((v) => v + 1);
1925
+ } else if (event.type === "tool-result") {
1926
+ const card = toolCardsRef.current.get(event.id);
1927
+ if (card) {
1928
+ card.result = event.content;
1929
+ card.isError = event.isError ?? false;
1930
+ setCardsVersion((v) => v + 1);
1931
+ }
1932
+ } else if (event.type === "finish") {
1933
+ if (event.usage) {
1934
+ const usage = usageRef.current;
1935
+ usage.requests += 1;
1936
+ usage.promptTokens += event.usage.promptTokens;
1937
+ usage.completionTokens += event.usage.completionTokens;
1938
+ usage.totalTokens += event.usage.totalTokens;
1939
+ setUsageVersion((v) => v + 1);
1940
+ sessionStore?.addUsage(event.usage).catch(() => {
1941
+ });
1942
+ }
1943
+ } else if (event.type === "error") {
1944
+ pushMessage("system", `Error: ${event.error.message}`);
1945
+ }
1946
+ }
1947
+ } catch (error) {
1948
+ if (!(error instanceof DOMException && error.name === "AbortError")) {
1949
+ pushMessage("system", `Error: ${error instanceof Error ? error.message : String(error)}`);
1950
+ }
1951
+ } finally {
1952
+ if (flushTimerRef.current !== null) {
1953
+ clearInterval(flushTimerRef.current);
1954
+ flushTimerRef.current = null;
1955
+ }
1956
+ abortRef.current = null;
1957
+ setIsStreaming(false);
1958
+ const finalText = streamedRef.current;
1959
+ streamedRef.current = "";
1960
+ setStreamingText(null);
1961
+ if (finalText.length > 0) {
1962
+ pushMessage(
1963
+ "assistant",
1964
+ controller.signal.aborted ? `${finalText} [interrupted]` : finalText
1965
+ );
1966
+ }
1967
+ for (const card of toolCardsRef.current.values()) {
1968
+ pushMessage("tool", formatToolCard(card));
1969
+ }
1970
+ toolCardsRef.current.clear();
1971
+ setCardsVersion((v) => v + 1);
1972
+ const p = pendingRef.current;
1973
+ if (p) {
1974
+ setPendingPermission(null);
1975
+ p.resolve(false);
1976
+ }
1977
+ }
1978
+ },
1979
+ [pushMessage, setPendingPermission, sessionStore]
1980
+ );
1981
+ const handleSubmit = useCallback(
1982
+ (text) => {
1983
+ if (text.startsWith("/")) {
1984
+ const parsed = parseSlashCommand(text);
1985
+ if (!parsed) return;
1986
+ const command = registry.get(parsed.name);
1987
+ if (!command) {
1988
+ pushMessage("system", `Unknown command: /${parsed.name} (try /help)`);
1989
+ return;
1990
+ }
1991
+ void command.run(parsed.args, registry.ctx);
1992
+ return;
1993
+ }
1994
+ void runStream(text);
1995
+ },
1996
+ [registry, pushMessage, runStream]
1997
+ );
1998
+ const cards = [...toolCardsRef.current.values()];
1999
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
2000
+ /* @__PURE__ */ jsx6(MessageList, { messages }, epoch),
2001
+ cards.length > 0 && /* @__PURE__ */ jsx6(Box7, { flexDirection: "column", children: cards.map((card) => /* @__PURE__ */ jsx6(ToolCallCard, { card }, card.id)) }, cardsVersion),
2002
+ streamingText !== null && /* @__PURE__ */ jsx6(StreamingMessage, { text: streamingText }),
2003
+ pending && /* @__PURE__ */ jsx6(PermissionPrompt, { request: pending.request, onDecision: handleDecision }),
2004
+ /* @__PURE__ */ jsx6(
2005
+ InputBox,
2006
+ {
2007
+ isStreaming,
2008
+ disabled: pending !== null,
2009
+ onSubmit: handleSubmit,
2010
+ onInterrupt: interrupt,
2011
+ onExit: exit
2012
+ }
2013
+ ),
2014
+ /* @__PURE__ */ jsx6(
2015
+ StatusBar,
2016
+ {
2017
+ model: modelName,
2018
+ permissionMode,
2019
+ tokens: usageRef.current.totalTokens
2020
+ },
2021
+ usageVersion
2022
+ )
2023
+ ] });
2024
+ }
2025
+ function renderRepl(backend, opts) {
2026
+ return render(
2027
+ /* @__PURE__ */ jsx6(
2028
+ Repl,
2029
+ {
2030
+ backend,
2031
+ model: opts.model,
2032
+ permissionMode: opts.permissionMode,
2033
+ config: opts.config,
2034
+ cwd: opts.cwd,
2035
+ sessionStore: opts.sessionStore,
2036
+ initialMessages: opts.initialMessages,
2037
+ initialUsage: opts.initialUsage
2038
+ }
2039
+ )
2040
+ );
2041
+ }
2042
+
2043
+ // src/config/loader.ts
2044
+ import fs2 from "fs";
2045
+ import { parse } from "smol-toml";
2046
+
2047
+ // src/config/schema.ts
2048
+ import { z as z9 } from "zod";
2049
+ var ProviderConfigSchema = z9.object({
2050
+ name: z9.string(),
2051
+ protocol: z9.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
2052
+ baseURL: z9.string(),
2053
+ apiKeyEnv: z9.string().optional(),
2054
+ apiKey: z9.string().optional(),
2055
+ headers: z9.record(z9.string()).optional()
2056
+ });
2057
+ var ModelConfigSchema = z9.object({
2058
+ name: z9.string(),
2059
+ provider: z9.string(),
2060
+ model: z9.string(),
2061
+ maxTokens: z9.number().int().positive().optional()
2062
+ });
2063
+ var ConfigSchema = z9.object({
2064
+ defaultModel: z9.string().default(""),
2065
+ permissionMode: z9.enum(["auto", "ask", "readonly"]).default("ask"),
2066
+ providers: z9.array(ProviderConfigSchema).default([]),
2067
+ models: z9.array(ModelConfigSchema).default([]),
2068
+ maxSteps: z9.number().int().positive().default(50),
2069
+ contextMaxTokens: z9.number().int().positive().default(1e5),
2070
+ contextCompaction: z9.enum(["summary", "truncate"]).default("summary")
2071
+ });
2072
+
2073
+ // src/config/loader.ts
2074
+ var PartialConfigSchema = ConfigSchema.partial();
2075
+ function parseTomlFile(content, filePath) {
2076
+ let raw;
2077
+ try {
2078
+ raw = parse(content);
2079
+ } catch (error) {
2080
+ const message = error instanceof Error ? error.message : String(error);
2081
+ throw new Error(`Failed to parse TOML in ${filePath}: ${message}`);
2082
+ }
2083
+ const result = PartialConfigSchema.safeParse(raw);
2084
+ if (!result.success) {
2085
+ throw new Error(`Invalid config in ${filePath}: ${result.error.message}`);
2086
+ }
2087
+ return result.data;
2088
+ }
2089
+ function mergeConfig(base, override) {
2090
+ return { ...base, ...override };
2091
+ }
2092
+ function applyOverrides(config, overrides) {
2093
+ if (!overrides) return config;
2094
+ const merged = { ...config };
2095
+ if (overrides.model !== void 0) merged.defaultModel = overrides.model;
2096
+ if (overrides.permissionMode !== void 0) merged.permissionMode = overrides.permissionMode;
2097
+ return merged;
2098
+ }
2099
+ function readTomlFileSync(filePath) {
2100
+ if (!fs2.existsSync(filePath)) return {};
2101
+ const content = fs2.readFileSync(filePath, "utf8");
2102
+ return parseTomlFile(content, filePath);
2103
+ }
2104
+ function loadConfigSync(cwd, overrides) {
2105
+ const global = readTomlFileSync(globalConfigPath());
2106
+ const project = readTomlFileSync(projectConfigPath(cwd));
2107
+ const merged = applyOverrides(mergeConfig(global, project), overrides);
2108
+ return ConfigSchema.parse(merged);
2109
+ }
2110
+
2111
+ // src/main.tsx
2112
+ var SYSTEM_PROMPT = `You are Star CLI, an AI coding agent running in the user's terminal.
2113
+ You help with software engineering tasks: reading, writing and editing code, running shell commands, and managing todos.
2114
+ Be concise and direct. Use tools when they help accomplish the task.
2115
+ The working directory is the user's project root; never touch files outside it without explicit instruction.`;
2116
+ async function createLoop(config, modelName, cwd, sessionStore) {
2117
+ const model = createModel(config, modelName);
2118
+ const registry = createDefaultRegistry();
2119
+ return new AgentLoop({
2120
+ model,
2121
+ registry,
2122
+ config,
2123
+ cwd,
2124
+ system: SYSTEM_PROMPT,
2125
+ sessionStore
2126
+ });
2127
+ }
2128
+ async function printMode(loop, prompt) {
2129
+ const controller = new AbortController();
2130
+ process.on("SIGINT", () => controller.abort());
2131
+ let requests = 0;
2132
+ let promptTokens = 0;
2133
+ let completionTokens = 0;
2134
+ let totalTokens = 0;
2135
+ let exitCode = 0;
2136
+ for await (const event of loop.stream(prompt, controller.signal)) {
2137
+ switch (event.type) {
2138
+ case "text-delta":
2139
+ process.stdout.write(event.text);
2140
+ break;
2141
+ case "tool-call":
2142
+ process.stderr.write(`
2143
+ [tool] ${event.name} ${JSON.stringify(event.args)}
2144
+ `);
2145
+ break;
2146
+ case "tool-result": {
2147
+ const preview = event.content.length > 500 ? `${event.content.slice(0, 500)}... (truncated)` : event.content;
2148
+ process.stderr.write(`[result] ${event.isError ? "ERROR: " : ""}${preview}
2149
+ `);
2150
+ break;
2151
+ }
2152
+ case "finish":
2153
+ if (event.usage) {
2154
+ requests += 1;
2155
+ promptTokens += event.usage.promptTokens;
2156
+ completionTokens += event.usage.completionTokens;
2157
+ totalTokens += event.usage.totalTokens;
2158
+ }
2159
+ break;
2160
+ case "error":
2161
+ process.stderr.write(`
2162
+ [error] ${event.error.message}
2163
+ `);
2164
+ exitCode = 1;
2165
+ break;
2166
+ }
2167
+ if (exitCode !== 0) break;
2168
+ }
2169
+ if (requests > 0) {
2170
+ process.stderr.write(
2171
+ `[usage] ${requests} requests, ${promptTokens} prompt + ${completionTokens} completion = ${totalTokens} tokens
2172
+ `
2173
+ );
2174
+ }
2175
+ process.stdout.write("\n");
2176
+ return exitCode;
2177
+ }
2178
+ var program = new Command();
2179
+ program.name("star").description("Star CLI \u2014 an AI agent command-line interface").version("0.1.0").option("-m, --model <model>", "model to use").option("--permission-mode <mode>", "permission mode: auto | ask | readonly").option("-p, --print <prompt>", "non-interactive print mode").option("-r, --resume <sessionId>", "resume a previous session").action(async (opts) => {
2180
+ const cwd = process.cwd();
2181
+ let config;
2182
+ try {
2183
+ config = loadConfigSync(cwd, { model: opts.model, permissionMode: opts.permissionMode });
2184
+ } catch (error) {
2185
+ console.error(error instanceof Error ? error.message : String(error));
2186
+ process.exit(1);
2187
+ }
2188
+ const modelName = opts.model ?? config.defaultModel;
2189
+ if (!modelName) {
2190
+ console.error(
2191
+ "No model configured. Add one to ~/.star-cli/config.toml or pass --model.\nSee README.md for configuration examples."
2192
+ );
2193
+ process.exit(1);
2194
+ }
2195
+ let sessionStore = null;
2196
+ let resumed = null;
2197
+ if (opts.resume) {
2198
+ resumed = await resumeSession(opts.resume);
2199
+ if (!resumed) {
2200
+ console.error(`Session not found: ${opts.resume}`);
2201
+ process.exit(1);
2202
+ }
2203
+ sessionStore = await SessionStore.open(opts.resume);
2204
+ } else if (!opts.print) {
2205
+ sessionStore = await SessionStore.create(cwd, modelName);
2206
+ }
2207
+ let loop;
2208
+ try {
2209
+ loop = await createLoop(config, modelName, cwd, sessionStore);
2210
+ } catch (error) {
2211
+ console.error(error instanceof Error ? error.message : String(error));
2212
+ process.exit(1);
2213
+ }
2214
+ if (resumed) {
2215
+ await loop.loadMessages(resumed.messages);
2216
+ }
2217
+ if (opts.print) {
2218
+ process.exitCode = await printMode(loop, opts.print);
2219
+ return;
2220
+ }
2221
+ renderRepl(loop, {
2222
+ model: modelName,
2223
+ permissionMode: config.permissionMode,
2224
+ config,
2225
+ cwd,
2226
+ sessionStore,
2227
+ initialMessages: resumed?.messages,
2228
+ initialUsage: resumed?.meta.usage
2229
+ });
2230
+ });
2231
+ program.parse(process.argv);
2232
+ //# sourceMappingURL=main.js.map