@cryer/star-cli 0.1.0 → 0.1.1

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 CHANGED
@@ -126,6 +126,42 @@ function compactMessages(messages, maxTokens) {
126
126
 
127
127
  // src/permissions/gate.ts
128
128
  import path from "path";
129
+
130
+ // src/permissions/allow.ts
131
+ function parseAllowRule(rule) {
132
+ const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
133
+ if (!match) return null;
134
+ return { toolName: match[1], pattern: match[2] };
135
+ }
136
+ function globMatch(pattern, value) {
137
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
138
+ return new RegExp(`^${escaped}$`).test(value);
139
+ }
140
+ function requestTarget(req) {
141
+ if (typeof req.args !== "object" || req.args === null) return void 0;
142
+ const args = req.args;
143
+ const key = req.toolName === "bash" ? "command" : "path";
144
+ const value = args[key];
145
+ return typeof value === "string" ? value : void 0;
146
+ }
147
+ function matchAllowRule(rule, req) {
148
+ if (rule.toolName !== req.toolName) return false;
149
+ if (rule.pattern === void 0) return true;
150
+ const target = requestTarget(req);
151
+ return target !== void 0 && globMatch(rule.pattern, target);
152
+ }
153
+ function isAllowedByRules(rules, req) {
154
+ return rules.some((raw) => {
155
+ const rule = parseAllowRule(raw);
156
+ return rule !== null && matchAllowRule(rule, req);
157
+ });
158
+ }
159
+ function buildAllowRule(req) {
160
+ const target = requestTarget(req);
161
+ return target !== void 0 ? `${req.toolName}(${target})` : req.toolName;
162
+ }
163
+
164
+ // src/permissions/gate.ts
129
165
  var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
130
166
  var DANGEROUS_COMMAND_PATTERNS = [
131
167
  /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
@@ -163,7 +199,7 @@ function isSensitivePath(p) {
163
199
  }
164
200
  return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
165
201
  }
166
- function checkPermission(mode, req, ctx) {
202
+ function checkPermission(mode, req, ctx, allowRules = []) {
167
203
  const command = getStringArg(req.args, "command");
168
204
  const filePath = getStringArg(req.args, "path");
169
205
  if (req.toolName === "bash" && command !== void 0 && DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command))) {
@@ -186,6 +222,9 @@ function checkPermission(mode, req, ctx) {
186
222
  if (mode === "readonly") {
187
223
  return req.level === "read" ? "allow" : "deny";
188
224
  }
225
+ if (isAllowedByRules(allowRules, req)) {
226
+ return "allow";
227
+ }
189
228
  return req.level === "read" ? "allow" : "ask";
190
229
  }
191
230
 
@@ -209,10 +248,12 @@ var AgentLoop = class {
209
248
  async persist(message) {
210
249
  await this.opts.sessionStore?.append(message);
211
250
  }
212
- async *stream(input, signal) {
251
+ async *stream(input, signal, opts) {
213
252
  const userMessage = { role: "user", content: input };
214
253
  this.messages.push(userMessage);
215
- await this.persist(userMessage);
254
+ await this.persist(
255
+ opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
256
+ );
216
257
  const { config, registry, cwd } = this.opts;
217
258
  const aiTools = this.buildAiTools();
218
259
  for (let step = 0; step < config.maxSteps; step++) {
@@ -335,7 +376,8 @@ ${summary}`
335
376
  const decision = checkPermission(
336
377
  config.permissionMode,
337
378
  { toolName: call.name, args: call.args, level: tool.permission },
338
- { cwd }
379
+ { cwd },
380
+ config.permissions.allow
339
381
  );
340
382
  if (decision === "deny") {
341
383
  return { content: `Permission denied for tool "${call.name}".`, isError: true };
@@ -368,10 +410,215 @@ ${summary}`
368
410
  }
369
411
  };
370
412
 
413
+ // src/cli/mentions.ts
414
+ import { readFile, stat as stat2 } from "fs/promises";
415
+ import path3 from "path";
416
+
417
+ // src/tools/fs/util.ts
418
+ import { readdir, stat } from "fs/promises";
419
+ import path2 from "path";
420
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
421
+ async function walkFiles(root, skipDirs = SKIP_DIRS) {
422
+ const out = [];
423
+ async function walk(dir, relBase) {
424
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
425
+ if (!entries) {
426
+ return;
427
+ }
428
+ for (const entry of entries) {
429
+ const abs = path2.join(dir, entry.name);
430
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
431
+ if (entry.isDirectory()) {
432
+ if (!skipDirs.has(entry.name)) {
433
+ await walk(abs, rel);
434
+ }
435
+ } else if (entry.isFile()) {
436
+ try {
437
+ const st = await stat(abs);
438
+ out.push({ abs, rel, mtimeMs: st.mtimeMs });
439
+ } catch {
440
+ }
441
+ }
442
+ }
443
+ }
444
+ await walk(root, "");
445
+ return out;
446
+ }
447
+ var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
448
+ function globToRegExp(pattern) {
449
+ let re = "";
450
+ let i = 0;
451
+ while (i < pattern.length) {
452
+ const c = pattern.charAt(i);
453
+ if (c === "*") {
454
+ if (pattern.charAt(i + 1) === "*") {
455
+ i += 2;
456
+ if (pattern.charAt(i) === "/") {
457
+ i += 1;
458
+ re += "(?:[^/]+/)*";
459
+ } else {
460
+ re += ".*";
461
+ }
462
+ } else {
463
+ re += "[^/]*";
464
+ i += 1;
465
+ }
466
+ } else if (c === "?") {
467
+ re += "[^/]";
468
+ i += 1;
469
+ } else {
470
+ re += c.replace(REGEX_SPECIALS, "\\$&");
471
+ i += 1;
472
+ }
473
+ }
474
+ return new RegExp(`^${re}$`);
475
+ }
476
+ function matchesGlob(pattern, relPath) {
477
+ const normalized = pattern.replace(/\\/g, "/");
478
+ if (!normalized.includes("/")) {
479
+ const base = relPath.split("/").pop() ?? relPath;
480
+ return globToRegExp(normalized).test(base);
481
+ }
482
+ return globToRegExp(normalized).test(relPath);
483
+ }
484
+ function isSensitivePath2(filePath) {
485
+ const base = path2.basename(filePath);
486
+ if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
487
+ return false;
488
+ }
489
+ if (base === ".env" || base.startsWith(".env.")) {
490
+ return true;
491
+ }
492
+ if (base === "id_rsa" || base.endsWith(".pem")) {
493
+ return true;
494
+ }
495
+ return false;
496
+ }
497
+
498
+ // src/cli/mentions.ts
499
+ var MAX_MENTION_BYTES = 100 * 1024;
500
+ var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\]*|[A-Za-z0-9._\-/\\]+)/g;
501
+ function parseMentions(text) {
502
+ const mentions = [];
503
+ const seen = /* @__PURE__ */ new Set();
504
+ const ranges = [];
505
+ for (const match of text.matchAll(MENTION_RE)) {
506
+ const mention = match[1];
507
+ const full = match[0];
508
+ if (mention === void 0 || full === void 0) continue;
509
+ ranges.push([match.index, match.index + full.length]);
510
+ if (!seen.has(mention)) {
511
+ seen.add(mention);
512
+ mentions.push(mention);
513
+ }
514
+ }
515
+ if (ranges.length === 0) {
516
+ return { cleanText: text, mentions };
517
+ }
518
+ let clean = "";
519
+ let last = 0;
520
+ for (const [start, end] of ranges) {
521
+ clean += text.slice(last, start);
522
+ last = end;
523
+ }
524
+ clean += text.slice(last);
525
+ const cleanText = clean.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
526
+ return { cleanText, mentions };
527
+ }
528
+ function isBinaryContent(buf) {
529
+ const probe = buf.subarray(0, 8e3);
530
+ return probe.includes(0);
531
+ }
532
+ async function resolveMentions(text, cwd) {
533
+ const { cleanText, mentions } = parseMentions(text);
534
+ if (mentions.length === 0) {
535
+ return { input: text, attached: [], skipped: [] };
536
+ }
537
+ const blocks = [];
538
+ const attached = [];
539
+ const skipped = [];
540
+ for (const mention of mentions) {
541
+ const abs = path3.resolve(cwd, mention);
542
+ if (isSensitivePath2(abs)) {
543
+ skipped.push({ path: mention, reason: "sensitive file" });
544
+ continue;
545
+ }
546
+ const st = await stat2(abs).catch(() => null);
547
+ if (!st) {
548
+ skipped.push({ path: mention, reason: "file not found" });
549
+ continue;
550
+ }
551
+ if (st.isDirectory()) {
552
+ skipped.push({ path: mention, reason: "is a directory" });
553
+ continue;
554
+ }
555
+ if (st.size > MAX_MENTION_BYTES) {
556
+ skipped.push({ path: mention, reason: `exceeds ${MAX_MENTION_BYTES / 1024}KB limit` });
557
+ continue;
558
+ }
559
+ const buf = await readFile(abs).catch(() => null);
560
+ if (!buf) {
561
+ skipped.push({ path: mention, reason: "unreadable" });
562
+ continue;
563
+ }
564
+ if (isBinaryContent(buf)) {
565
+ skipped.push({ path: mention, reason: "binary file" });
566
+ continue;
567
+ }
568
+ attached.push(mention);
569
+ blocks.push(`--- @${mention} ---
570
+ ${buf.toString("utf8")}
571
+ --- end ---`);
572
+ }
573
+ const input = [cleanText, ...blocks].filter((s) => s.length > 0).join("\n\n") || text;
574
+ return { input, attached, skipped };
575
+ }
576
+
371
577
  // src/cli/repl.tsx
372
578
  import { Box as Box7, render, useApp, useInput as useInput3 } from "ink";
373
579
  import { useCallback, useEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
374
580
 
581
+ // src/config/save.ts
582
+ import fs from "fs";
583
+ import path5 from "path";
584
+ import { parse, stringify } from "smol-toml";
585
+
586
+ // src/config/paths.ts
587
+ import os from "os";
588
+ import path4 from "path";
589
+ function starHome() {
590
+ return process.env.STAR_HOME ?? path4.join(os.homedir(), ".star-cli");
591
+ }
592
+ function globalConfigPath() {
593
+ return path4.join(starHome(), "config.toml");
594
+ }
595
+ function sessionsDir() {
596
+ return path4.join(starHome(), "sessions");
597
+ }
598
+ function projectConfigPath(cwd) {
599
+ return path4.join(cwd, ".star", "config.toml");
600
+ }
601
+
602
+ // src/config/save.ts
603
+ async function addAllowRule(rule) {
604
+ const filePath = globalConfigPath();
605
+ let raw = {};
606
+ try {
607
+ const content = await fs.promises.readFile(filePath, "utf8");
608
+ raw = parse(content);
609
+ } catch (error) {
610
+ if (error.code !== "ENOENT") throw error;
611
+ }
612
+ const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
613
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
614
+ if (allow.includes(rule)) return false;
615
+ permissions.allow = [...allow, rule];
616
+ raw.permissions = permissions;
617
+ await fs.promises.mkdir(path5.dirname(filePath), { recursive: true });
618
+ await fs.promises.writeFile(filePath, stringify(raw), "utf8");
619
+ return true;
620
+ }
621
+
375
622
  // src/llm/provider.ts
376
623
  import { createAnthropic } from "@ai-sdk/anthropic";
377
624
  import { createOpenAI } from "@ai-sdk/openai";
@@ -435,26 +682,8 @@ function createModel(config, modelName) {
435
682
  }
436
683
 
437
684
  // 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
685
+ import fs2 from "fs/promises";
686
+ import path6 from "path";
458
687
  function generateId(now) {
459
688
  const pad = (n) => String(n).padStart(2, "0");
460
689
  const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
@@ -476,13 +705,13 @@ var SessionStore = class _SessionStore {
476
705
  initialized = false;
477
706
  constructor(id) {
478
707
  this.id = id;
479
- this.dir = path3.join(sessionsDir(), id);
708
+ this.dir = path6.join(sessionsDir(), id);
480
709
  }
481
710
  metaPath() {
482
- return path3.join(this.dir, "meta.json");
711
+ return path6.join(this.dir, "meta.json");
483
712
  }
484
713
  messagesPath() {
485
- return path3.join(this.dir, "messages.jsonl");
714
+ return path6.join(this.dir, "messages.jsonl");
486
715
  }
487
716
  static async create(cwd, model) {
488
717
  const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
@@ -492,7 +721,7 @@ var SessionStore = class _SessionStore {
492
721
  static async open(id) {
493
722
  const store = new _SessionStore(id);
494
723
  try {
495
- const raw = await fs.readFile(store.metaPath(), "utf8");
724
+ const raw = await fs2.readFile(store.metaPath(), "utf8");
496
725
  const meta = JSON.parse(raw);
497
726
  if (meta.id !== id) return null;
498
727
  store.initialized = true;
@@ -504,14 +733,14 @@ var SessionStore = class _SessionStore {
504
733
  static async list(cwd) {
505
734
  let entries;
506
735
  try {
507
- entries = await fs.readdir(sessionsDir());
736
+ entries = await fs2.readdir(sessionsDir());
508
737
  } catch {
509
738
  return [];
510
739
  }
511
740
  const metas = [];
512
741
  for (const entry of entries) {
513
742
  try {
514
- const raw = await fs.readFile(path3.join(sessionsDir(), entry, "meta.json"), "utf8");
743
+ const raw = await fs2.readFile(path6.join(sessionsDir(), entry, "meta.json"), "utf8");
515
744
  const meta = JSON.parse(raw);
516
745
  if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
517
746
  } catch {
@@ -526,7 +755,7 @@ var SessionStore = class _SessionStore {
526
755
  this.initialized = true;
527
756
  return;
528
757
  }
529
- await fs.mkdir(this.dir, { recursive: true });
758
+ await fs2.mkdir(this.dir, { recursive: true });
530
759
  const meta = {
531
760
  id: this.id,
532
761
  title: "",
@@ -535,24 +764,33 @@ var SessionStore = class _SessionStore {
535
764
  createdAt: pending.createdAt,
536
765
  updatedAt: pending.createdAt
537
766
  };
538
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
767
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
539
768
  this.initialized = true;
540
769
  }
541
770
  async append(message) {
542
771
  await this.ensureInitialized();
543
- await fs.appendFile(this.messagesPath(), `${JSON.stringify(message)}
772
+ await fs2.appendFile(this.messagesPath(), `${JSON.stringify(message)}
544
773
  `);
545
774
  const meta = await this.meta();
546
775
  meta.updatedAt = Date.now();
547
776
  if (!meta.title && message.role === "user") {
548
777
  meta.title = messageText(message).slice(0, 60);
549
778
  }
550
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
779
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
780
+ }
781
+ async replaceMessages(messages) {
782
+ await this.ensureInitialized();
783
+ const content = messages.map((message) => JSON.stringify(message)).join("\n");
784
+ await fs2.writeFile(this.messagesPath(), content ? `${content}
785
+ ` : "");
786
+ const meta = await this.meta();
787
+ meta.updatedAt = Date.now();
788
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
551
789
  }
552
790
  async messages() {
553
791
  let raw;
554
792
  try {
555
- raw = await fs.readFile(this.messagesPath(), "utf8");
793
+ raw = await fs2.readFile(this.messagesPath(), "utf8");
556
794
  } catch {
557
795
  return [];
558
796
  }
@@ -569,7 +807,7 @@ var SessionStore = class _SessionStore {
569
807
  }
570
808
  async meta() {
571
809
  await this.ensureInitialized();
572
- const raw = await fs.readFile(this.metaPath(), "utf8");
810
+ const raw = await fs2.readFile(this.metaPath(), "utf8");
573
811
  return JSON.parse(raw);
574
812
  }
575
813
  async setTitle(title) {
@@ -577,7 +815,7 @@ var SessionStore = class _SessionStore {
577
815
  const meta = await this.meta();
578
816
  meta.title = title;
579
817
  meta.updatedAt = Date.now();
580
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
818
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
581
819
  }
582
820
  async addUsage(delta) {
583
821
  await this.ensureInitialized();
@@ -593,7 +831,7 @@ var SessionStore = class _SessionStore {
593
831
  usage.completionTokens += delta.completionTokens;
594
832
  usage.totalTokens += delta.totalTokens;
595
833
  meta.usage = usage;
596
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
834
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
597
835
  }
598
836
  };
599
837
 
@@ -627,17 +865,17 @@ function formatSessionList(metas) {
627
865
  // src/tools/bash.ts
628
866
  import { spawn } from "child_process";
629
867
  import { existsSync } from "fs";
630
- import path4 from "path";
868
+ import path7 from "path";
631
869
  import { z } from "zod";
632
870
  var MAX_OUTPUT = 3e4;
633
871
  var DEFAULT_TIMEOUT = 120;
634
872
  var MAX_TIMEOUT = 600;
635
873
  function findOnPath(exe, exclude) {
636
- for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
874
+ for (const dir of (process.env.PATH ?? "").split(path7.delimiter)) {
637
875
  if (!dir || exclude?.(dir)) {
638
876
  continue;
639
877
  }
640
- const full = path4.join(dir, exe);
878
+ const full = path7.join(dir, exe);
641
879
  if (existsSync(full)) {
642
880
  return full;
643
881
  }
@@ -655,7 +893,7 @@ function resolveShell() {
655
893
  }
656
894
  const gitExe = findOnPath("git.exe");
657
895
  if (gitExe) {
658
- const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
896
+ const sibling = path7.join(path7.dirname(path7.dirname(gitExe)), "bin", "bash.exe");
659
897
  if (existsSync(sibling)) {
660
898
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
661
899
  }
@@ -762,9 +1000,46 @@ Command timed out after ${timeoutSeconds}s`,
762
1000
  };
763
1001
 
764
1002
  // src/tools/fs/edit.ts
765
- import { readFile, writeFile } from "fs/promises";
766
- import path5 from "path";
1003
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1004
+ import path9 from "path";
767
1005
  import { z as z2 } from "zod";
1006
+
1007
+ // src/tools/fs/snapshots.ts
1008
+ import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
1009
+ import path8 from "path";
1010
+ var MAX_SNAPSHOTS = 50;
1011
+ var stack = [];
1012
+ async function captureSnapshot(filePath, toolName) {
1013
+ let content = null;
1014
+ let existed = false;
1015
+ try {
1016
+ content = await readFile2(filePath, "utf8");
1017
+ existed = true;
1018
+ } catch {
1019
+ }
1020
+ return { path: filePath, existed, content, toolName, timestamp: Date.now() };
1021
+ }
1022
+ function pushSnapshot(snapshot) {
1023
+ stack.push(snapshot);
1024
+ if (stack.length > MAX_SNAPSHOTS) {
1025
+ stack.splice(0, stack.length - MAX_SNAPSHOTS);
1026
+ }
1027
+ }
1028
+ async function undoLastSnapshot() {
1029
+ const snapshot = stack.pop();
1030
+ if (!snapshot) {
1031
+ return "Nothing to undo.";
1032
+ }
1033
+ if (snapshot.existed && snapshot.content !== null) {
1034
+ await mkdir(path8.dirname(snapshot.path), { recursive: true });
1035
+ await writeFile(snapshot.path, snapshot.content, "utf8");
1036
+ return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
1037
+ }
1038
+ await rm(snapshot.path, { force: true });
1039
+ return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
1040
+ }
1041
+
1042
+ // src/tools/fs/edit.ts
768
1043
  var schema2 = z2.object({
769
1044
  path: z2.string().describe("File path, absolute or relative to the working directory"),
770
1045
  old_string: z2.string().min(1).describe("Exact text to replace"),
@@ -777,10 +1052,10 @@ var editFileTool = {
777
1052
  permission: "write",
778
1053
  parameters: schema2,
779
1054
  async execute(args, ctx) {
780
- const filePath = path5.resolve(ctx.cwd, args.path);
1055
+ const filePath = path9.resolve(ctx.cwd, args.path);
781
1056
  let content;
782
1057
  try {
783
- content = await readFile(filePath, "utf8");
1058
+ content = await readFile3(filePath, "utf8");
784
1059
  } catch (err) {
785
1060
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
786
1061
  }
@@ -800,97 +1075,25 @@ var editFileTool = {
800
1075
  };
801
1076
  }
802
1077
  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");
1078
+ try {
1079
+ await writeFile2(filePath, updated, "utf8");
1080
+ } catch (err) {
1081
+ return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1082
+ }
1083
+ pushSnapshot({
1084
+ path: filePath,
1085
+ existed: true,
1086
+ content,
1087
+ toolName: "edit_file",
1088
+ timestamp: Date.now()
1089
+ });
804
1090
  return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
805
1091
  }
806
1092
  };
807
1093
 
808
1094
  // src/tools/fs/glob.ts
809
- import path7 from "path";
1095
+ import path10 from "path";
810
1096
  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
1097
  var MAX_RESULTS = 100;
895
1098
  var schema3 = z3.object({
896
1099
  pattern: z3.string().describe(
@@ -904,7 +1107,7 @@ var globTool = {
904
1107
  permission: "read",
905
1108
  parameters: schema3,
906
1109
  async execute(args, ctx) {
907
- const root = path7.resolve(ctx.cwd, args.path ?? ".");
1110
+ const root = path10.resolve(ctx.cwd, args.path ?? ".");
908
1111
  const files = await walkFiles(root);
909
1112
  const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
910
1113
  if (matched.length === 0) {
@@ -915,8 +1118,8 @@ var globTool = {
915
1118
  };
916
1119
 
917
1120
  // src/tools/fs/grep.ts
918
- import { readFile as readFile2, stat as stat2 } from "fs/promises";
919
- import path8 from "path";
1121
+ import { readFile as readFile4, stat as stat3 } from "fs/promises";
1122
+ import path11 from "path";
920
1123
  import { z as z4 } from "zod";
921
1124
  var MAX_MATCHES = 250;
922
1125
  var schema4 = z4.object({
@@ -937,12 +1140,12 @@ var grepTool = {
937
1140
  } catch (err) {
938
1141
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
939
1142
  }
940
- const root = path8.resolve(ctx.cwd, args.path ?? ".");
1143
+ const root = path11.resolve(ctx.cwd, args.path ?? ".");
941
1144
  let files;
942
1145
  try {
943
- const st = await stat2(root);
1146
+ const st = await stat3(root);
944
1147
  if (st.isFile()) {
945
- files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
1148
+ files = [{ abs: root, rel: path11.basename(root), mtimeMs: st.mtimeMs }];
946
1149
  } else {
947
1150
  files = await walkFiles(root);
948
1151
  }
@@ -958,7 +1161,7 @@ var grepTool = {
958
1161
  outer: for (const file of files) {
959
1162
  let buf;
960
1163
  try {
961
- buf = await readFile2(file.abs);
1164
+ buf = await readFile4(file.abs);
962
1165
  } catch {
963
1166
  continue;
964
1167
  }
@@ -988,8 +1191,8 @@ var grepTool = {
988
1191
  };
989
1192
 
990
1193
  // src/tools/fs/read.ts
991
- import { readFile as readFile3, stat as stat3 } from "fs/promises";
992
- import path9 from "path";
1194
+ import { readFile as readFile5, stat as stat4 } from "fs/promises";
1195
+ import path12 from "path";
993
1196
  import { z as z5 } from "zod";
994
1197
  var DEFAULT_LIMIT = 2e3;
995
1198
  var MAX_CHARS = 100 * 1024;
@@ -1004,18 +1207,18 @@ var readFileTool = {
1004
1207
  permission: "read",
1005
1208
  parameters: schema5,
1006
1209
  async execute(args, ctx) {
1007
- const filePath = path9.resolve(ctx.cwd, args.path);
1210
+ const filePath = path12.resolve(ctx.cwd, args.path);
1008
1211
  if (isSensitivePath2(filePath)) {
1009
1212
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1010
1213
  }
1011
- const st = await stat3(filePath).catch(() => null);
1214
+ const st = await stat4(filePath).catch(() => null);
1012
1215
  if (!st) {
1013
1216
  return { content: `File not found: ${args.path}`, isError: true };
1014
1217
  }
1015
1218
  if (st.isDirectory()) {
1016
1219
  return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1017
1220
  }
1018
- const raw = await readFile3(filePath, "utf8");
1221
+ const raw = await readFile5(filePath, "utf8");
1019
1222
  const lines = raw.split("\n");
1020
1223
  const start = args.offset ?? 1;
1021
1224
  const limit = args.limit ?? DEFAULT_LIMIT;
@@ -1049,8 +1252,8 @@ var readFileTool = {
1049
1252
  };
1050
1253
 
1051
1254
  // src/tools/fs/write.ts
1052
- import { mkdir, writeFile as writeFile2 } from "fs/promises";
1053
- import path10 from "path";
1255
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1256
+ import path13 from "path";
1054
1257
  import { z as z6 } from "zod";
1055
1258
  var schema6 = z6.object({
1056
1259
  path: z6.string().describe("File path, absolute or relative to the working directory"),
@@ -1062,20 +1265,22 @@ var writeFileTool = {
1062
1265
  permission: "write",
1063
1266
  parameters: schema6,
1064
1267
  async execute(args, ctx) {
1065
- const filePath = path10.resolve(ctx.cwd, args.path);
1268
+ const filePath = path13.resolve(ctx.cwd, args.path);
1269
+ const snapshot = await captureSnapshot(filePath, "write_file");
1066
1270
  try {
1067
- await mkdir(path10.dirname(filePath), { recursive: true });
1068
- await writeFile2(filePath, args.content, "utf8");
1271
+ await mkdir2(path13.dirname(filePath), { recursive: true });
1272
+ await writeFile3(filePath, args.content, "utf8");
1069
1273
  } catch (err) {
1070
1274
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1071
1275
  }
1276
+ pushSnapshot(snapshot);
1072
1277
  return { content: `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}` };
1073
1278
  }
1074
1279
  };
1075
1280
 
1076
1281
  // src/tools/todo.ts
1077
- import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1078
- import path11 from "path";
1282
+ import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
1283
+ import path14 from "path";
1079
1284
  import { z as z7 } from "zod";
1080
1285
  var todoItemSchema = z7.object({
1081
1286
  id: z7.number().int(),
@@ -1087,7 +1292,7 @@ var writeSchema = z7.object({
1087
1292
  });
1088
1293
  var readSchema = z7.object({});
1089
1294
  function fileFor(cwd) {
1090
- return path11.join(cwd, ".star", "todos.json");
1295
+ return path14.join(cwd, ".star", "todos.json");
1091
1296
  }
1092
1297
  var TodoStore = class {
1093
1298
  items = /* @__PURE__ */ new Map();
@@ -1103,7 +1308,7 @@ var TodoStore = class {
1103
1308
  async load(cwd) {
1104
1309
  let raw;
1105
1310
  try {
1106
- raw = await readFile4(fileFor(cwd), "utf8");
1311
+ raw = await readFile6(fileFor(cwd), "utf8");
1107
1312
  } catch {
1108
1313
  return;
1109
1314
  }
@@ -1116,8 +1321,8 @@ var TodoStore = class {
1116
1321
  }
1117
1322
  }
1118
1323
  async save(cwd) {
1119
- await mkdir2(path11.join(cwd, ".star"), { recursive: true });
1120
- await writeFile3(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1324
+ await mkdir3(path14.join(cwd, ".star"), { recursive: true });
1325
+ await writeFile4(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1121
1326
  `);
1122
1327
  }
1123
1328
  };
@@ -1302,6 +1507,121 @@ var webFetchTool = {
1302
1507
  }
1303
1508
  };
1304
1509
 
1510
+ // src/tools/web/search.ts
1511
+ import { z as z9 } from "zod";
1512
+ var TIMEOUT_MS2 = 15e3;
1513
+ var DEFAULT_MAX_RESULTS = 5;
1514
+ var MAX_RESULTS_LIMIT = 10;
1515
+ var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
1516
+ var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
1517
+ var schema8 = z9.object({
1518
+ query: z9.string().min(1).describe("The search query"),
1519
+ maxResults: z9.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
1520
+ `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
1521
+ )
1522
+ });
1523
+ function stripTags(s) {
1524
+ return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
1525
+ }
1526
+ function normalizeUrl(href) {
1527
+ const decoded = decodeEntities(href);
1528
+ const uddg = decoded.match(/[?&]uddg=([^&]+)/);
1529
+ if (uddg?.[1]) {
1530
+ try {
1531
+ return decodeURIComponent(uddg[1]);
1532
+ } catch {
1533
+ return decoded;
1534
+ }
1535
+ }
1536
+ if (decoded.startsWith("//")) {
1537
+ return `https:${decoded}`;
1538
+ }
1539
+ return decoded;
1540
+ }
1541
+ function parseResults(html) {
1542
+ const results = [];
1543
+ const anchorRe = /<a\b[^>]*\bclass\s*=\s*"[^"]*\bresult__a\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi;
1544
+ const anchors = [...html.matchAll(anchorRe)];
1545
+ for (const [i, anchor] of anchors.entries()) {
1546
+ const full = anchor[0];
1547
+ const hrefMatch = full.match(/href\s*=\s*"([^"]*)"/i);
1548
+ if (!hrefMatch?.[1]) {
1549
+ continue;
1550
+ }
1551
+ const url = normalizeUrl(hrefMatch[1]);
1552
+ const title = stripTags(full.replace(/^<a\b[^>]*>/i, "").replace(/<\/a>\s*$/i, ""));
1553
+ const blockStart = (anchor.index ?? 0) + full.length;
1554
+ const blockEnd = i + 1 < anchors.length ? anchors[i + 1]?.index ?? html.length : html.length;
1555
+ const block = html.slice(blockStart, blockEnd);
1556
+ const snippetMatch = block.match(
1557
+ /<(?:a|div)\b[^>]*\bclass\s*=\s*"[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div)>/i
1558
+ );
1559
+ const snippet = snippetMatch?.[1] ? stripTags(snippetMatch[1]) : "";
1560
+ if (title && url) {
1561
+ results.push({ title, url, snippet });
1562
+ }
1563
+ }
1564
+ return results;
1565
+ }
1566
+ var webSearchTool = {
1567
+ name: "web_search",
1568
+ description: "Search the web using DuckDuckGo and return a numbered list of results with title, URL and snippet. No API key required. Use web_fetch to read a result's full page.",
1569
+ permission: "read",
1570
+ parameters: schema8,
1571
+ async execute(args, ctx) {
1572
+ const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
1573
+ const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
1574
+ const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS2);
1575
+ const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
1576
+ let res;
1577
+ try {
1578
+ res = await fetch(url, {
1579
+ signal,
1580
+ redirect: "follow",
1581
+ headers: { "user-agent": USER_AGENT, accept: "text/html" }
1582
+ });
1583
+ } catch (err) {
1584
+ if (ctx.abortSignal?.aborted) {
1585
+ return { content: "ERROR: request aborted", isError: true };
1586
+ }
1587
+ const name = err instanceof Error ? err.name : "";
1588
+ if (timeoutSignal.aborted || name === "TimeoutError") {
1589
+ return { content: `ERROR: search timed out after ${TIMEOUT_MS2 / 1e3}s`, isError: true };
1590
+ }
1591
+ const message = err instanceof Error ? err.message : String(err);
1592
+ return { content: `ERROR: search request failed: ${message}`, isError: true };
1593
+ }
1594
+ if (!res.ok) {
1595
+ await res.body?.cancel().catch(() => {
1596
+ });
1597
+ return {
1598
+ content: `ERROR: HTTP ${res.status} ${res.statusText} from DuckDuckGo`,
1599
+ isError: true
1600
+ };
1601
+ }
1602
+ let html;
1603
+ try {
1604
+ html = await res.text();
1605
+ } catch (err) {
1606
+ const message = err instanceof Error ? err.message : String(err);
1607
+ return { content: `ERROR: failed to read search response: ${message}`, isError: true };
1608
+ }
1609
+ const results = parseResults(html).slice(0, maxResults);
1610
+ if (results.length === 0) {
1611
+ return {
1612
+ content: `ERROR: no results found for "${args.query}" (the response may have been blocked or the page layout changed)`,
1613
+ isError: true
1614
+ };
1615
+ }
1616
+ const lines = results.map(
1617
+ (r, i) => `${i + 1}. ${r.title}
1618
+ ${r.url}${r.snippet ? `
1619
+ ${r.snippet}` : ""}`
1620
+ );
1621
+ return { content: lines.join("\n") };
1622
+ }
1623
+ };
1624
+
1305
1625
  // src/tools/registry.ts
1306
1626
  var ToolRegistry = class {
1307
1627
  tools = /* @__PURE__ */ new Map();
@@ -1314,6 +1634,7 @@ var ToolRegistry = class {
1314
1634
  grepTool,
1315
1635
  bashTool,
1316
1636
  webFetchTool,
1637
+ webSearchTool,
1317
1638
  ...createTodoTools()
1318
1639
  ]) {
1319
1640
  this.register(tool);
@@ -1342,6 +1663,137 @@ function createDefaultRegistry() {
1342
1663
  return registry;
1343
1664
  }
1344
1665
 
1666
+ // src/cli/commands/actions.ts
1667
+ import fs3 from "fs/promises";
1668
+ import path15 from "path";
1669
+ var MIN_COMPACT_MESSAGES = 4;
1670
+ async function compactSession(opts) {
1671
+ const { backend, sessionStore, config, model } = opts;
1672
+ if (!(backend instanceof AgentLoop)) {
1673
+ return { message: "Current backend does not support compaction.", compacted: false };
1674
+ }
1675
+ const messages = [...backend.getMessages()];
1676
+ if (messages.length < MIN_COMPACT_MESSAGES) {
1677
+ return {
1678
+ message: `Nothing to compact: history has only ${messages.length} message(s).`,
1679
+ compacted: false
1680
+ };
1681
+ }
1682
+ const beforeTokens = estimateTokens(messages);
1683
+ const compacted = compactMessages(messages, config.contextMaxTokens);
1684
+ if (!compacted.compacted) {
1685
+ return {
1686
+ message: `Nothing to compact: ~${beforeTokens} estimated tokens, below the limit of ${config.contextMaxTokens}.`,
1687
+ compacted: false
1688
+ };
1689
+ }
1690
+ const next = await applyCompactionSummary(messages, compacted, config, model);
1691
+ await backend.loadMessages(next);
1692
+ await sessionStore?.replaceMessages(next);
1693
+ const afterTokens = estimateTokens(next);
1694
+ return {
1695
+ message: `Compacted context: ${messages.length} -> ${next.length} messages (~${beforeTokens} -> ~${afterTokens} estimated tokens).`,
1696
+ compacted: true,
1697
+ messages: next
1698
+ };
1699
+ }
1700
+ async function applyCompactionSummary(original, compacted, config, model) {
1701
+ if (config.contextCompaction !== "summary" || !model) {
1702
+ return compacted.messages;
1703
+ }
1704
+ const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
1705
+ const dropped = original.slice(headCount, headCount + compacted.droppedCount);
1706
+ try {
1707
+ const summary = await summarizeMessages(dropped, model);
1708
+ const messages = compacted.messages.slice();
1709
+ messages[headCount] = {
1710
+ role: "user",
1711
+ content: `[earlier conversation summarized]
1712
+ ${summary}`
1713
+ };
1714
+ return messages;
1715
+ } catch {
1716
+ return compacted.messages;
1717
+ }
1718
+ }
1719
+ async function exportSession(opts) {
1720
+ const { backend, sessionStore, cwd, arg } = opts;
1721
+ if (!(backend instanceof AgentLoop)) {
1722
+ return "Current backend does not support exporting sessions.";
1723
+ }
1724
+ const messages = [...backend.getMessages()];
1725
+ if (messages.length === 0) {
1726
+ return "Nothing to export: the session has no messages.";
1727
+ }
1728
+ const sessionId = sessionStore?.id ?? `unknown-${Date.now()}`;
1729
+ const target = arg ? path15.resolve(cwd, arg) : path15.resolve(cwd, `star-session-${sessionId}.md`);
1730
+ const existed = await fileExists(target);
1731
+ await fs3.mkdir(path15.dirname(target), { recursive: true });
1732
+ await fs3.writeFile(target, renderSessionMarkdown(sessionId, messages));
1733
+ return existed ? `Exported ${messages.length} messages to ${target} (overwrote existing file).` : `Exported ${messages.length} messages to ${target}.`;
1734
+ }
1735
+ async function fileExists(target) {
1736
+ try {
1737
+ await fs3.access(target);
1738
+ return true;
1739
+ } catch {
1740
+ return false;
1741
+ }
1742
+ }
1743
+ function renderSessionMarkdown(sessionId, messages, exportedAt = /* @__PURE__ */ new Date()) {
1744
+ const lines = [
1745
+ `# Star CLI Session ${sessionId}`,
1746
+ "",
1747
+ `_Exported at ${exportedAt.toISOString()}_`,
1748
+ ""
1749
+ ];
1750
+ for (const message of messages) {
1751
+ lines.push(`## ${roleHeading(message.role)}`, "");
1752
+ lines.push(...renderContent(message), "");
1753
+ }
1754
+ return lines.join("\n");
1755
+ }
1756
+ function roleHeading(role) {
1757
+ return role.charAt(0).toUpperCase() + role.slice(1);
1758
+ }
1759
+ function renderContent(message) {
1760
+ const content = message.content;
1761
+ if (typeof content === "string") return [content];
1762
+ const lines = [];
1763
+ for (const part of content) {
1764
+ if (part.type === "text") {
1765
+ lines.push(part.text, "");
1766
+ } else if (part.type === "tool-call") {
1767
+ lines.push(
1768
+ `**Tool call: ${part.toolName}**`,
1769
+ "",
1770
+ "```json",
1771
+ JSON.stringify(part.args, null, 2),
1772
+ "```",
1773
+ ""
1774
+ );
1775
+ } else if (part.type === "tool-result") {
1776
+ lines.push(
1777
+ `**Tool result: ${part.toolName}**`,
1778
+ "",
1779
+ "```",
1780
+ stringifyPart(part.result),
1781
+ "```",
1782
+ ""
1783
+ );
1784
+ } else if (part.type === "reasoning") {
1785
+ lines.push(part.text, "");
1786
+ } else {
1787
+ lines.push(`[${part.type}]`, "");
1788
+ }
1789
+ }
1790
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1791
+ return lines;
1792
+ }
1793
+ function stringifyPart(value) {
1794
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
1795
+ }
1796
+
1345
1797
  // src/cli/commands/builtin.ts
1346
1798
  function registerBuiltinCommands(registry) {
1347
1799
  registry.register({
@@ -1429,6 +1881,30 @@ ${lines.join("\n")}`);
1429
1881
  ctx.addSystemMessage(ctx.describeConfig());
1430
1882
  }
1431
1883
  });
1884
+ registry.register({
1885
+ name: "compact",
1886
+ description: "Compact the conversation history to free up context",
1887
+ usage: "/compact",
1888
+ async run(_args, ctx) {
1889
+ ctx.addSystemMessage(await ctx.compactContext());
1890
+ }
1891
+ });
1892
+ registry.register({
1893
+ name: "export",
1894
+ description: "Export the current session to a Markdown file",
1895
+ usage: "/export [path]",
1896
+ async run(args, ctx) {
1897
+ ctx.addSystemMessage(await ctx.exportSession(args));
1898
+ }
1899
+ });
1900
+ registry.register({
1901
+ name: "undo",
1902
+ description: "Revert the last file change made by write_file or edit_file",
1903
+ usage: "/undo",
1904
+ async run(_args, ctx) {
1905
+ ctx.addSystemMessage(await ctx.undo());
1906
+ }
1907
+ });
1432
1908
  }
1433
1909
 
1434
1910
  // src/cli/commands/registry.ts
@@ -1463,16 +1939,46 @@ function parseSlashCommand(input) {
1463
1939
  // src/cli/components/InputBox.tsx
1464
1940
  import { Box, Text, useInput } from "ink";
1465
1941
  import { useRef, useState } from "react";
1942
+
1943
+ // src/cli/commands/suggest.ts
1944
+ var MAX_SUGGESTIONS = 5;
1945
+ function filterCommands(input, commands) {
1946
+ if (!input.startsWith("/")) return [];
1947
+ if (/\s/.test(input)) return [];
1948
+ const prefix = input.slice(1).toLowerCase();
1949
+ return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(prefix)).sort((a, b) => a.name.localeCompare(b.name)).slice(0, MAX_SUGGESTIONS);
1950
+ }
1951
+
1952
+ // src/cli/components/InputBox.tsx
1466
1953
  import { jsx, jsxs } from "react/jsx-runtime";
1467
- function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1954
+ function InputBox({
1955
+ isStreaming,
1956
+ disabled,
1957
+ commands,
1958
+ onSubmit,
1959
+ onInterrupt,
1960
+ onExit
1961
+ }) {
1468
1962
  const [value, setValue] = useState("");
1469
1963
  const [cursor, setCursor] = useState(0);
1470
1964
  const [history, setHistory] = useState([]);
1965
+ const [highlight, setHighlight] = useState(0);
1966
+ const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
1471
1967
  const historyIndexRef = useRef(null);
1472
1968
  const draftRef = useRef("");
1473
1969
  const edit = (next, nextCursor) => {
1474
1970
  setValue(next);
1475
1971
  setCursor(Math.max(0, Math.min(nextCursor, next.length)));
1972
+ setHighlight(0);
1973
+ setSuggestionsDismissed(false);
1974
+ };
1975
+ const suggestions = isStreaming || disabled || suggestionsDismissed || !commands ? [] : filterCommands(value, commands);
1976
+ const activeIndex = suggestions.length === 0 ? 0 : Math.min(highlight, suggestions.length - 1);
1977
+ const completeHighlighted = () => {
1978
+ const cmd = suggestions[activeIndex];
1979
+ if (!cmd) return;
1980
+ const text = `/${cmd.name} `;
1981
+ edit(text, text.length);
1476
1982
  };
1477
1983
  useInput((input, key) => {
1478
1984
  if (key.ctrl && input === "c") {
@@ -1489,6 +1995,10 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1489
1995
  return;
1490
1996
  }
1491
1997
  if (isStreaming || disabled) return;
1998
+ if (key.escape) {
1999
+ if (suggestions.length > 0) setSuggestionsDismissed(true);
2000
+ return;
2001
+ }
1492
2002
  if (key.return) {
1493
2003
  const text = value.trim();
1494
2004
  if (text.length > 0) {
@@ -1500,7 +2010,16 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1500
2010
  draftRef.current = "";
1501
2011
  return;
1502
2012
  }
1503
- if (key.upArrow) {
2013
+ if (key.tab) {
2014
+ if (suggestions.length > 0) {
2015
+ completeHighlighted();
2016
+ return;
2017
+ }
2018
+ } else if (key.upArrow) {
2019
+ if (suggestions.length > 0) {
2020
+ setHighlight((prev) => (prev - 1 + suggestions.length) % suggestions.length);
2021
+ return;
2022
+ }
1504
2023
  if (history.length === 0) return;
1505
2024
  if (historyIndexRef.current === null) {
1506
2025
  draftRef.current = value;
@@ -1511,8 +2030,11 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1511
2030
  const entry = history[historyIndexRef.current] ?? "";
1512
2031
  edit(entry, entry.length);
1513
2032
  return;
1514
- }
1515
- if (key.downArrow) {
2033
+ } else if (key.downArrow) {
2034
+ if (suggestions.length > 0) {
2035
+ setHighlight((prev) => (prev + 1) % suggestions.length);
2036
+ return;
2037
+ }
1516
2038
  if (historyIndexRef.current === null) return;
1517
2039
  if (historyIndexRef.current < history.length - 1) {
1518
2040
  historyIndexRef.current += 1;
@@ -1523,13 +2045,15 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1523
2045
  edit(draftRef.current, draftRef.current.length);
1524
2046
  }
1525
2047
  return;
1526
- }
1527
- if (key.leftArrow) {
2048
+ } else if (key.leftArrow) {
1528
2049
  setCursor((prev) => Math.max(0, prev - 1));
1529
2050
  return;
1530
- }
1531
- if (key.rightArrow) {
1532
- setCursor((prev) => Math.min(value.length, prev + 1));
2051
+ } else if (key.rightArrow) {
2052
+ if (suggestions.length > 0 && cursor === value.length) {
2053
+ completeHighlighted();
2054
+ } else {
2055
+ setCursor((prev) => Math.min(value.length, prev + 1));
2056
+ }
1533
2057
  return;
1534
2058
  }
1535
2059
  if (key.ctrl && input === "a") {
@@ -1567,11 +2091,19 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1567
2091
  const before = value.slice(0, cursor);
1568
2092
  const at = value[cursor] ?? " ";
1569
2093
  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 })
2094
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2095
+ /* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
2096
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "> " }),
2097
+ /* @__PURE__ */ jsx(Text, { children: before }),
2098
+ /* @__PURE__ */ jsx(Text, { inverse: true, children: at }),
2099
+ /* @__PURE__ */ jsx(Text, { children: after })
2100
+ ] }),
2101
+ suggestions.length > 0 && /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingLeft: 2, children: suggestions.map(
2102
+ (cmd, index) => index === activeIndex ? /* @__PURE__ */ jsx(Text, { bold: true, inverse: true, children: `/${cmd.name} - ${cmd.description}` }, cmd.name) : /* @__PURE__ */ jsxs(Text, { children: [
2103
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: `/${cmd.name}` }),
2104
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` - ${cmd.description}` })
2105
+ ] }, cmd.name)
2106
+ ) })
1575
2107
  ] });
1576
2108
  }
1577
2109
 
@@ -1589,7 +2121,8 @@ function MessageList({ messages }) {
1589
2121
  const style = roleStyles[message.role];
1590
2122
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: 1, children: [
1591
2123
  /* @__PURE__ */ jsx2(Text2, { bold: true, color: style.color, children: style.label }),
1592
- /* @__PURE__ */ jsx2(Text2, { color: style.color, children: message.text })
2124
+ /* @__PURE__ */ jsx2(Text2, { color: style.color, children: message.text }),
2125
+ message.note && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: message.note })
1593
2126
  ] }, message.id);
1594
2127
  } });
1595
2128
  }
@@ -1663,7 +2196,7 @@ function PermissionPrompt({ request, onDecision }) {
1663
2196
  ")"
1664
2197
  ] }),
1665
2198
  /* @__PURE__ */ jsx3(Text3, { children: summarizeArgs(request.args) }),
1666
- /* @__PURE__ */ jsx3(Text3, { children: "[y] allow [n] deny [a] always" })
2199
+ /* @__PURE__ */ jsx3(Text3, { children: "[y] allow [n] deny [a] always (saved to config)" })
1667
2200
  ] });
1668
2201
  }
1669
2202
 
@@ -1763,8 +2296,8 @@ function Repl({
1763
2296
  const alwaysAllowedRef = useRef2(/* @__PURE__ */ new Set());
1764
2297
  const modelNameRef = useRef2(model);
1765
2298
  const usageRef = useRef2(initialUsage ? { ...initialUsage } : emptyUsage());
1766
- const pushMessage = useCallback((role, text) => {
1767
- setMessages((prev) => [...prev, { id: nextIdRef.current++, role, text }]);
2299
+ const pushMessage = useCallback((role, text, note) => {
2300
+ setMessages((prev) => [...prev, { id: nextIdRef.current++, role, text, note }]);
1768
2301
  }, []);
1769
2302
  const setPendingPermission = useCallback((p) => {
1770
2303
  pendingRef.current = p;
@@ -1773,7 +2306,7 @@ function Repl({
1773
2306
  const attachConfirmHandler = useCallback(
1774
2307
  (target) => {
1775
2308
  target.confirmHandler = (req) => {
1776
- if (alwaysAllowedRef.current.has(req.toolName)) {
2309
+ if (isAllowedByRules([...alwaysAllowedRef.current], req)) {
1777
2310
  return Promise.resolve(true);
1778
2311
  }
1779
2312
  return new Promise((resolve) => {
@@ -1807,12 +2340,21 @@ function Repl({
1807
2340
  const p = pendingRef.current;
1808
2341
  if (!p) return;
1809
2342
  if (decision === "always") {
1810
- alwaysAllowedRef.current.add(p.request.toolName);
2343
+ const rule = buildAllowRule(p.request);
2344
+ alwaysAllowedRef.current.add(rule);
2345
+ void addAllowRule(rule).then((added) => {
2346
+ pushMessage(
2347
+ "system",
2348
+ added ? `Always allow ${rule} \u2014 saved to config` : `Always allow ${rule} (already in config)`
2349
+ );
2350
+ }).catch(() => {
2351
+ pushMessage("system", `Always allow ${rule} for this session (failed to save)`);
2352
+ });
1811
2353
  }
1812
2354
  setPendingPermission(null);
1813
2355
  p.resolve(decision !== "no");
1814
2356
  },
1815
- [setPendingPermission]
2357
+ [setPendingPermission, pushMessage]
1816
2358
  );
1817
2359
  const switchModel = useCallback(
1818
2360
  async (name) => {
@@ -1887,22 +2429,54 @@ ${lines.join("\n")}`;
1887
2429
  return formatTodos(store.list());
1888
2430
  },
1889
2431
  showUsage: () => formatUsage(usageRef.current),
2432
+ compactContext: async () => {
2433
+ let summaryModel = null;
2434
+ try {
2435
+ summaryModel = createModel(config, modelNameRef.current);
2436
+ } catch {
2437
+ summaryModel = null;
2438
+ }
2439
+ const result = await compactSession({
2440
+ backend: backendRef.current,
2441
+ sessionStore,
2442
+ config,
2443
+ model: summaryModel
2444
+ });
2445
+ if (result.compacted && result.messages) {
2446
+ const display = buildDisplayMessages(result.messages);
2447
+ nextIdRef.current = display.length;
2448
+ setMessages(display);
2449
+ setEpoch((e) => e + 1);
2450
+ }
2451
+ return result.message;
2452
+ },
2453
+ exportSession: (arg) => exportSession({ backend: backendRef.current, sessionStore, cwd, arg }),
2454
+ undo: () => undoLastSnapshot(),
1890
2455
  describeConfig: () => [
1891
2456
  `defaultModel: ${config.defaultModel || "(none)"}`,
1892
2457
  `permissionMode: ${config.permissionMode}`,
1893
2458
  `providers (${config.providers.length}): ${config.providers.map((p) => p.name).join(", ") || "(none)"}`,
1894
2459
  `models (${config.models.length}): ${config.models.map((m) => m.name).join(", ") || "(none)"}`,
1895
2460
  `maxSteps: ${config.maxSteps}`,
1896
- `contextMaxTokens: ${config.contextMaxTokens}`
2461
+ `contextMaxTokens: ${config.contextMaxTokens}`,
2462
+ `permissions.allow (${config.permissions.allow.length}): ${config.permissions.allow.join(", ") || "(none)"}`
1897
2463
  ].join("\n")
1898
2464
  };
1899
2465
  const reg = new CommandRegistry();
1900
2466
  registerBuiltinCommands(reg);
1901
2467
  return Object.assign(reg, { ctx });
1902
- }, [pushMessage, exit, config, cwd, switchModel, resume]);
2468
+ }, [pushMessage, exit, config, cwd, switchModel, resume, sessionStore]);
1903
2469
  const runStream = useCallback(
1904
2470
  async (input) => {
1905
- pushMessage("user", input);
2471
+ const resolved = await resolveMentions(input, cwd);
2472
+ pushMessage(
2473
+ "user",
2474
+ input,
2475
+ resolved.attached.length > 0 ? `attached: ${resolved.attached.join(", ")}` : void 0
2476
+ );
2477
+ for (const skip of resolved.skipped) {
2478
+ pushMessage("system", `Skipped @${skip.path}: ${skip.reason}`);
2479
+ }
1906
2480
  const controller = new AbortController();
1907
2481
  abortRef.current = controller;
1908
2482
  streamedRef.current = "";
@@ -1912,7 +2486,9 @@ ${lines.join("\n")}`;
1912
2486
  setStreamingText(streamedRef.current);
1913
2487
  }, FLUSH_INTERVAL_MS);
1914
2488
  try {
1915
- for await (const event of backendRef.current.stream(input, controller.signal)) {
2489
+ for await (const event of backendRef.current.stream(resolved.input, controller.signal, {
2490
+ persistAs: input
2491
+ })) {
1916
2492
  if (event.type === "text-delta") {
1917
2493
  streamedRef.current += event.text;
1918
2494
  } else if (event.type === "tool-call") {
@@ -1976,7 +2552,7 @@ ${lines.join("\n")}`;
1976
2552
  }
1977
2553
  }
1978
2554
  },
1979
- [pushMessage, setPendingPermission, sessionStore]
2555
+ [pushMessage, setPendingPermission, sessionStore, cwd]
1980
2556
  );
1981
2557
  const handleSubmit = useCallback(
1982
2558
  (text) => {
@@ -1995,10 +2571,14 @@ ${lines.join("\n")}`;
1995
2571
  },
1996
2572
  [registry, pushMessage, runStream]
1997
2573
  );
2574
+ const commandHints = useMemo(
2575
+ () => registry.list().map((cmd) => ({ name: cmd.name, description: cmd.description })),
2576
+ [registry]
2577
+ );
1998
2578
  const cards = [...toolCardsRef.current.values()];
1999
2579
  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),
2580
+ /* @__PURE__ */ jsx6(MessageList, { messages }, `messages-${epoch}`),
2581
+ cards.length > 0 && /* @__PURE__ */ jsx6(Box7, { flexDirection: "column", children: cards.map((card) => /* @__PURE__ */ jsx6(ToolCallCard, { card }, card.id)) }, `cards-${cardsVersion}`),
2002
2582
  streamingText !== null && /* @__PURE__ */ jsx6(StreamingMessage, { text: streamingText }),
2003
2583
  pending && /* @__PURE__ */ jsx6(PermissionPrompt, { request: pending.request, onDecision: handleDecision }),
2004
2584
  /* @__PURE__ */ jsx6(
@@ -2006,6 +2586,7 @@ ${lines.join("\n")}`;
2006
2586
  {
2007
2587
  isStreaming,
2008
2588
  disabled: pending !== null,
2589
+ commands: commandHints,
2009
2590
  onSubmit: handleSubmit,
2010
2591
  onInterrupt: interrupt,
2011
2592
  onExit: exit
@@ -2018,7 +2599,7 @@ ${lines.join("\n")}`;
2018
2599
  permissionMode,
2019
2600
  tokens: usageRef.current.totalTokens
2020
2601
  },
2021
- usageVersion
2602
+ `status-${usageVersion}`
2022
2603
  )
2023
2604
  ] });
2024
2605
  }
@@ -2041,33 +2622,37 @@ function renderRepl(backend, opts) {
2041
2622
  }
2042
2623
 
2043
2624
  // src/config/loader.ts
2044
- import fs2 from "fs";
2045
- import { parse } from "smol-toml";
2625
+ import fs4 from "fs";
2626
+ import { parse as parse2 } from "smol-toml";
2046
2627
 
2047
2628
  // 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()
2629
+ import { z as z10 } from "zod";
2630
+ var ProviderConfigSchema = z10.object({
2631
+ name: z10.string(),
2632
+ protocol: z10.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
2633
+ baseURL: z10.string(),
2634
+ apiKeyEnv: z10.string().optional(),
2635
+ apiKey: z10.string().optional(),
2636
+ headers: z10.record(z10.string()).optional()
2056
2637
  });
2057
- var ModelConfigSchema = z9.object({
2058
- name: z9.string(),
2059
- provider: z9.string(),
2060
- model: z9.string(),
2061
- maxTokens: z9.number().int().positive().optional()
2638
+ var ModelConfigSchema = z10.object({
2639
+ name: z10.string(),
2640
+ provider: z10.string(),
2641
+ model: z10.string(),
2642
+ maxTokens: z10.number().int().positive().optional()
2062
2643
  });
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")
2644
+ var PermissionsConfigSchema = z10.object({
2645
+ allow: z10.array(z10.string()).default([])
2646
+ });
2647
+ var ConfigSchema = z10.object({
2648
+ defaultModel: z10.string().default(""),
2649
+ permissionMode: z10.enum(["auto", "ask", "readonly"]).default("ask"),
2650
+ providers: z10.array(ProviderConfigSchema).default([]),
2651
+ models: z10.array(ModelConfigSchema).default([]),
2652
+ maxSteps: z10.number().int().positive().default(50),
2653
+ contextMaxTokens: z10.number().int().positive().default(1e5),
2654
+ contextCompaction: z10.enum(["summary", "truncate"]).default("summary"),
2655
+ permissions: PermissionsConfigSchema.default({ allow: [] })
2071
2656
  });
2072
2657
 
2073
2658
  // src/config/loader.ts
@@ -2075,7 +2660,7 @@ var PartialConfigSchema = ConfigSchema.partial();
2075
2660
  function parseTomlFile(content, filePath) {
2076
2661
  let raw;
2077
2662
  try {
2078
- raw = parse(content);
2663
+ raw = parse2(content);
2079
2664
  } catch (error) {
2080
2665
  const message = error instanceof Error ? error.message : String(error);
2081
2666
  throw new Error(`Failed to parse TOML in ${filePath}: ${message}`);
@@ -2097,8 +2682,8 @@ function applyOverrides(config, overrides) {
2097
2682
  return merged;
2098
2683
  }
2099
2684
  function readTomlFileSync(filePath) {
2100
- if (!fs2.existsSync(filePath)) return {};
2101
- const content = fs2.readFileSync(filePath, "utf8");
2685
+ if (!fs4.existsSync(filePath)) return {};
2686
+ const content = fs4.readFileSync(filePath, "utf8");
2102
2687
  return parseTomlFile(content, filePath);
2103
2688
  }
2104
2689
  function loadConfigSync(cwd, overrides) {
@@ -2125,15 +2710,26 @@ async function createLoop(config, modelName, cwd, sessionStore) {
2125
2710
  sessionStore
2126
2711
  });
2127
2712
  }
2128
- async function printMode(loop, prompt) {
2713
+ async function printMode(loop, prompt, cwd) {
2129
2714
  const controller = new AbortController();
2130
2715
  process.on("SIGINT", () => controller.abort());
2716
+ const resolved = await resolveMentions(prompt, cwd);
2717
+ if (resolved.attached.length > 0) {
2718
+ process.stderr.write(`[attached] ${resolved.attached.join(", ")}
2719
+ `);
2720
+ }
2721
+ for (const skip of resolved.skipped) {
2722
+ process.stderr.write(`[skipped] @${skip.path}: ${skip.reason}
2723
+ `);
2724
+ }
2131
2725
  let requests = 0;
2132
2726
  let promptTokens = 0;
2133
2727
  let completionTokens = 0;
2134
2728
  let totalTokens = 0;
2135
2729
  let exitCode = 0;
2136
- for await (const event of loop.stream(prompt, controller.signal)) {
2730
+ for await (const event of loop.stream(resolved.input, controller.signal, {
2731
+ persistAs: prompt
2732
+ })) {
2137
2733
  switch (event.type) {
2138
2734
  case "text-delta":
2139
2735
  process.stdout.write(event.text);
@@ -2215,7 +2811,7 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
2215
2811
  await loop.loadMessages(resumed.messages);
2216
2812
  }
2217
2813
  if (opts.print) {
2218
- process.exitCode = await printMode(loop, opts.print);
2814
+ process.exitCode = await printMode(loop, opts.print, cwd);
2219
2815
  return;
2220
2816
  }
2221
2817
  renderRepl(loop, {