@ziamana/bruine 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -11,8 +11,8 @@
11
11
 
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-b4a7ff)](LICENSE)
13
13
  [![Node 22+](https://img.shields.io/badge/node-22%2B-7dcfff)](https://nodejs.org)
14
- ![Windows · macOS · Linux](https://img.shields.io/badge/Windows%20%C2%B7%20macOS%20%C2%B7%20Linux-supported-8fe3a3)
15
- [![Changelog](https://img.shields.io/badge/version-0.1.2-ff9ed2)](CHANGELOG.md)
14
+ ![Linux tested · macOS and Windows in progress](https://img.shields.io/badge/Linux-tested-8fe3a3) ![macOS · Windows](https://img.shields.io/badge/macOS%20%C2%B7%20Windows-in%20progress-f5c97a)
15
+ [![Changelog](https://img.shields.io/badge/version-0.1.3-ff9ed2)](CHANGELOG.md)
16
16
 
17
17
  <a href="docs/media/bruine-film.mp4"><img src="docs/media/hero.webp" alt="The bruine film: the word bruine becomes the logo in the rain, then the effort climbs from low to max and the rain turns into a storm" width="100%"></a>
18
18
 
@@ -144,7 +144,7 @@ training), and the offer can end without notice, so keep another model in reach.
144
144
  | `/model` and `/provider` | Switch model mid-session from the server's own live catalogue |
145
145
  | Skills | Reuses the skills you already wrote for Claude Code, OpenCode, pi or `~/.agents/skills` |
146
146
  | Context and speed | A footer that shows context used, tok/s, prefill and cache hit rate |
147
- | Works on | Windows Terminal, PowerShell, macOS Terminal, iTerm2, Konsole, GNOME Terminal |
147
+ | Works on | Linux (Konsole, GNOME Terminal), tested every day. Written for macOS Terminal, iTerm2, Windows Terminal and PowerShell too, not yet checked by hand there |
148
148
  | herdr | Works with herdr: shows up as `bruine` in `herdr agent list`. |
149
149
 
150
150
  ## The weather
@@ -215,8 +215,11 @@ turn it on in the setup. Network discovery of model servers is opt-in and only t
215
215
  compatible server) with a context of 32k or more. Small models can chat but tend to lose the thread in
216
216
  long agent loops. The setup detects what the server's chat template supports (thinking, effort levels).
217
217
 
218
- **Does it work on Windows?** Yes: Windows Terminal, PowerShell and the classic console; commands run in
219
- PowerShell there. [docs/PLATFORMS.md](docs/PLATFORMS.md) lists what was checked and what still has limits.
218
+ **Does it work on Windows or macOS?** It is written for them (on Windows, commands run in PowerShell) and
219
+ the unit tests run on both in CI, but nobody has used it by hand on a real Windows or macOS machine yet,
220
+ and the terminal tests on Windows still fail. Linux is the one platform used every day. If it breaks on
221
+ yours, [open an issue](https://github.com/ziamana/bruine/issues). [docs/PLATFORMS.md](docs/PLATFORMS.md)
222
+ lists what was checked and what still has limits.
220
223
 
221
224
  **How do I make it ask less?** `/auto` lets a fast model judge the routine actions (risky ones still
222
225
  ask), the approval's `a` (Always) remembers one exact command for the session and says which, and
package/dist/bin.js CHANGED
@@ -688,8 +688,8 @@ async function writeEnvVar(envPath, key, value) {
688
688
  text = "";
689
689
  }
690
690
  const line = `${key}=${value}`;
691
- const re = new RegExp(`^${key}=.*$`, "m");
692
- const next = re.test(text) ? text.replace(re, line) : text === "" || text.endsWith("\n") ? `${text}${line}
691
+ const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=.*$`, "m");
692
+ const next = re.test(text) ? text.replace(re, () => line) : text === "" || text.endsWith("\n") ? `${text}${line}
693
693
  ` : `${text}
694
694
  ${line}
695
695
  `;
@@ -4655,12 +4655,17 @@ function readMcpServers(home, projectDir, env = process.env) {
4655
4655
  return { servers, problems };
4656
4656
  }
4657
4657
  function fingerprint(server) {
4658
- const what = server.transport === "stdio" ? { t: "stdio", command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd ?? "" } : { t: "http", url: server.url, headers: server.headers ?? {} };
4658
+ const may = { alwaysAllow: [...server.alwaysAllow].sort(), readOnly: server.readOnly };
4659
+ const what = server.transport === "stdio" ? { t: "stdio", command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd ?? "", may } : { t: "http", url: server.url, headers: server.headers ?? {}, may };
4659
4660
  return createHash("sha256").update(JSON.stringify(what)).digest("hex").slice(0, 16);
4660
4661
  }
4661
4662
  function describeServer(server) {
4662
- if (server.transport === "stdio") return [server.command, ...server.args ?? []].join(" ");
4663
- return server.url ?? "";
4663
+ const where = server.transport === "stdio" ? [server.command, ...server.args ?? []].join(" ") : server.url ?? "";
4664
+ const may = [
4665
+ ...server.readOnly ? ["marked read-only: usable in Plan mode, never asks"] : [],
4666
+ ...server.alwaysAllow.length > 0 ? [`runs without asking: ${server.alwaysAllow.join(", ")}`] : []
4667
+ ];
4668
+ return may.length === 0 ? where : `${where} (${may.join("; ")})`;
4664
4669
  }
4665
4670
  var PROJECT_MCP_FILE, isRecord;
4666
4671
  var init_config = __esm({
@@ -151,7 +151,8 @@ function readMcpServers(home, projectDir, env = process.env) {
151
151
  return { servers, problems };
152
152
  }
153
153
  function fingerprint(server) {
154
- const what = server.transport === "stdio" ? { t: "stdio", command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd ?? "" } : { t: "http", url: server.url, headers: server.headers ?? {} };
154
+ const may = { alwaysAllow: [...server.alwaysAllow].sort(), readOnly: server.readOnly };
155
+ const what = server.transport === "stdio" ? { t: "stdio", command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd ?? "", may } : { t: "http", url: server.url, headers: server.headers ?? {}, may };
155
156
  return createHash("sha256").update(JSON.stringify(what)).digest("hex").slice(0, 16);
156
157
  }
157
158
  function toDshConfig(server) {
@@ -26,7 +26,8 @@ function configReadPath(home) {
26
26
  }
27
27
 
28
28
  // src/plugins/modes.ts
29
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
29
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
30
+ import { join as join2 } from "path";
30
31
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
31
32
  import { Text } from "@earendil-works/pi-tui";
32
33
 
@@ -190,6 +191,7 @@ var READONLY_COMMANDS = /* @__PURE__ */ new Set([
190
191
  "alias",
191
192
  "ps"
192
193
  ]);
194
+ var PS_ENVIRONMENT = /^ps\s(?:[^\n]*\s)?(?!-)[A-Za-z]*e[A-Za-z]*(?:\s|$)|^ps\b[^\n]*--environment/;
193
195
  var NOT_SIMPLE = /[;&|<>`]|\$\(|\n/;
194
196
  var EXECUTABLE_OPTION = /--(?:pre|ext-diff|textconv)(?=$|[=\s'"\\])/;
195
197
  var FIND_MUTATING = /\s-(exec|ok|fprint|fls|delete)/;
@@ -295,7 +297,13 @@ var SENSITIVE_PATTERNS = [
295
297
  /(^|[/\\])\.bashrc\b|(^|[/\\])\.bash_profile\b/,
296
298
  /(^|[/\\])\.profile\b/,
297
299
  /(^|[/\\])\.zprofile\b/,
298
- /(^|[/\\])\.config[/\\]fish[/\\]/
300
+ /(^|[/\\])\.config[/\\]fish[/\\]/,
301
+ // Cloud, cluster and registry credentials, and the tool's own config (MCP tokens may sit in it).
302
+ /(^|[/\\])\.(aws|gnupg|kube|azure)([/\\]|$)/,
303
+ /(^|[/\\])\.config[/\\](gh|gcloud|glab-cli)([/\\]|$)/,
304
+ /(^|[/\\])\.docker[/\\]config\.json\b/,
305
+ /(^|[/\\])\.(netrc|git-credentials)\b|(^|[/\\])_netrc\b/,
306
+ /(^|[/\\])(bruine|kumo)\.json\b/
299
307
  ];
300
308
  function parseArgs(raw) {
301
309
  if (raw !== null && typeof raw === "object") return Array.isArray(raw) ? {} : raw;
@@ -382,6 +390,7 @@ function isReadonlyBash(command) {
382
390
  const words = command.trim().split(/\s+/);
383
391
  const first = words[0] ?? "";
384
392
  if (first === "find") return !FIND_MUTATING.test(command);
393
+ if (first === "ps") return !PS_ENVIRONMENT.test(command.trim());
385
394
  if (first === "git") return gitReadonly(words, command);
386
395
  return READONLY_COMMANDS.has(first);
387
396
  }
@@ -463,6 +472,43 @@ function isPathInside(target, projectDir) {
463
472
  const rel = mod.relative(root, abs);
464
473
  return rel !== "" && !rel.startsWith("..") && !mod.isAbsolute(rel) ? true : rel === "";
465
474
  }
475
+ var FILE_LOOKUP_TOOLS = /* @__PURE__ */ new Set(["read", "read_image", "glob", "grep"]);
476
+ function lookupTargets(name2, args) {
477
+ const out = [];
478
+ for (const key of ["path", "file_path", "directory"]) if (typeof args[key] === "string") out.push(args[key]);
479
+ if (name2 === "glob" && typeof args.pattern === "string" && /^(?:[/\\~]|[A-Za-z]:)/.test(args.pattern)) out.push(args.pattern);
480
+ return out;
481
+ }
482
+ function lookupLeaks(name2, args, projectDir, readRoots = []) {
483
+ for (const raw of lookupTargets(name2, args)) {
484
+ const target = expandHome(raw.trim());
485
+ if (isSensitiveTarget(target, projectDir)) return true;
486
+ if (!isPathInside(target, projectDir) && !readRoots.some((root) => isPathInside(target, root))) return true;
487
+ }
488
+ return false;
489
+ }
490
+ function isPrivateUrl(raw) {
491
+ let url;
492
+ try {
493
+ url = new URL(raw.trim());
494
+ } catch {
495
+ return false;
496
+ }
497
+ if (url.username !== "" || url.password !== "") return true;
498
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
499
+ if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".lan") || host.endsWith(".home.arpa")) return true;
500
+ if (!host.includes(".") && !host.includes(":")) return true;
501
+ const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
502
+ if (v4 !== null) {
503
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
504
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
505
+ }
506
+ if (host.includes(":")) {
507
+ if (host === "::" || host === "::1" || /^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;
508
+ return host.startsWith("::ffff:");
509
+ }
510
+ return false;
511
+ }
466
512
  function decide(name2, execArgs, ctx) {
467
513
  const isBash = name2 === "bash";
468
514
  const isPwsh = name2 === "pwsh";
@@ -482,11 +528,10 @@ function decide(name2, execArgs, ctx) {
482
528
  if (WRITE_TOOLS.has(name2)) {
483
529
  const target = String(execArgs.path ?? execArgs.file_path ?? "");
484
530
  if (isSensitive(target)) return "ask";
531
+ if (!isPathInside(target, ctx.projectDir)) return "ask";
485
532
  }
486
- if (name2 === "read" || name2 === "read_image") {
487
- const target = String(execArgs.path ?? execArgs.file_path ?? "");
488
- if (isSensitiveTarget(target, ctx.projectDir)) return "ask";
489
- }
533
+ if (FILE_LOOKUP_TOOLS.has(name2) && lookupLeaks(name2, execArgs, ctx.projectDir, ctx.readRoots)) return "ask";
534
+ if (name2 === "web_fetch" && isPrivateUrl(String(execArgs.url ?? ""))) return "ask";
490
535
  if (isShell && bashLeaks(command, ctx.projectDir)) return "ask";
491
536
  if (ctx.sessionAllowed.has(ruleKey(name2, execArgs))) return "allow";
492
537
  if (READ_ONLY_TOOLS.has(name2)) return "allow";
@@ -516,6 +561,83 @@ import path4 from "path";
516
561
  // src/plugins/mcp.ts
517
562
  var BRUINE_MCP_SERVICE = "bruineMcp";
518
563
 
564
+ // src/setup/skills.ts
565
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "fs/promises";
566
+ import { existsSync as existsSync3 } from "fs";
567
+ import { homedir as homedir3 } from "os";
568
+ import path5, { dirname, join } from "path";
569
+ var SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
570
+ function parseSkillFrontmatter(md) {
571
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md);
572
+ if (m === null || m[1] === void 0) return {};
573
+ const lines = m[1].split(/\r?\n/);
574
+ const out = {};
575
+ const unquote = (v) => v.replaceAll(/^["']|["']$/g, "");
576
+ for (let i = 0; i < lines.length; i += 1) {
577
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(lines[i].trimEnd());
578
+ if (kv === null || kv[1] === void 0 || kv[2] === void 0) continue;
579
+ const key = kv[1];
580
+ let value = kv[2].trim();
581
+ const rest = [];
582
+ let j = i + 1;
583
+ while (j < lines.length && (lines[j].trim() === "" || /^\s+\S/.test(lines[j]))) {
584
+ rest.push(lines[j]);
585
+ j += 1;
586
+ }
587
+ while (rest.length > 0 && rest[rest.length - 1].trim() === "") rest.pop();
588
+ const block = /^([>|])[+-]?\d*$/.exec(value);
589
+ if (block !== null) {
590
+ const body = rest.map((l) => l.trim());
591
+ value = block[1] === "|" ? body.join("\n") : body.join(" ").replaceAll(/\s+/g, " ").trim();
592
+ i = j - 1;
593
+ } else if (rest.length > 0 && key !== "name") {
594
+ value = [value, ...rest.map((l) => l.trim())].filter((l) => l !== "").join(" ");
595
+ i = j - 1;
596
+ }
597
+ if (key === "name") out.name = unquote(value);
598
+ if (key === "description") out.description = unquote(value);
599
+ }
600
+ return out;
601
+ }
602
+ async function readSkillsUnder(root) {
603
+ const out = [];
604
+ if (!existsSync3(root)) return out;
605
+ for (const entry of await readdir(root, { withFileTypes: true })) {
606
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
607
+ const skillPath = join(root, entry.name, "SKILL.md");
608
+ if (!existsSync3(skillPath)) continue;
609
+ const fm = parseSkillFrontmatter(await readFile(skillPath, "utf8"));
610
+ if (fm.name === void 0 || !SAFE_NAME.test(fm.name)) continue;
611
+ out.push({ name: fm.name, description: fm.description ?? "", dir: join(root, entry.name) });
612
+ }
613
+ return out;
614
+ }
615
+ async function scanProjectSkills(cwd, pathMod = path5) {
616
+ const out = [];
617
+ const seen = /* @__PURE__ */ new Set();
618
+ for (const rel of [
619
+ [".agents", "skills"],
620
+ [".dsh", "skills"]
621
+ ]) {
622
+ for (const skill of await readSkillsUnder(pathMod.join(cwd, ...rel))) {
623
+ if (seen.has(skill.name)) continue;
624
+ seen.add(skill.name);
625
+ out.push(skill);
626
+ }
627
+ }
628
+ return out.sort((a, b) => a.name.localeCompare(b.name));
629
+ }
630
+ async function readAvailableSkills(homeSkillsDir, cwd) {
631
+ const byName = /* @__PURE__ */ new Map();
632
+ for (const skill of await readSkillsUnder(homeSkillsDir)) {
633
+ byName.set(skill.name, { ...skill, scope: "user" });
634
+ }
635
+ for (const skill of await scanProjectSkills(cwd)) {
636
+ byName.set(skill.name, { ...skill, scope: "project" });
637
+ }
638
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
639
+ }
640
+
519
641
  // src/plugins/modes.ts
520
642
  function mcpPolicy(service, tool) {
521
643
  const server = service?.serverOf(tool);
@@ -539,9 +661,9 @@ var PERMISSION_ORDER = ["ask", "auto", "full"];
539
661
  function readBruineJson(env = process.env) {
540
662
  try {
541
663
  const home = runtimeHome(env);
542
- const path5 = configReadPath(home);
543
- if (!existsSync3(path5)) return {};
544
- return JSON.parse(readFileSync3(path5, "utf8"));
664
+ const path6 = configReadPath(home);
665
+ if (!existsSync4(path6)) return {};
666
+ return JSON.parse(readFileSync3(path6, "utf8"));
545
667
  } catch {
546
668
  return {};
547
669
  }
@@ -778,6 +900,19 @@ ${line}
778
900
  `);
779
901
  }
780
902
  };
903
+ let skillRoots;
904
+ const readRoots = async () => {
905
+ if (skillRoots !== void 0 && Date.now() - skillRoots.at < 3e4) return skillRoots.roots;
906
+ let roots = [];
907
+ try {
908
+ const home = join2(runtimeHome(), "skills");
909
+ roots = [home, ...(await readAvailableSkills(home, process.cwd())).map((skill) => skill.dir)];
910
+ } catch {
911
+ roots = [];
912
+ }
913
+ skillRoots = { at: Date.now(), roots };
914
+ return roots;
915
+ };
781
916
  ctx.on("tools/pre-execute", async (exec, next) => {
782
917
  if (!modes.governs(exec.agent)) return next();
783
918
  const canAsk = repl?.ui !== void 0;
@@ -788,6 +923,7 @@ ${line}
788
923
  plan: modes.plan,
789
924
  sessionAllowed: modes.sessionAllowed,
790
925
  projectDir: process.cwd(),
926
+ readRoots: await readRoots(),
791
927
  mcp: (tool) => mcpPolicy(ctx.get(BRUINE_MCP_SERVICE), tool)
792
928
  });
793
929
  let decision;
@@ -2284,7 +2284,8 @@ async function turnChanges(opts) {
2284
2284
  }
2285
2285
 
2286
2286
  // src/plugins/modes.ts
2287
- import { existsSync as existsSync3, readFileSync as readFileSync6 } from "fs";
2287
+ import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
2288
+ import { join as join4 } from "path";
2288
2289
  import { createUserMessage as createUserMessage2 } from "@deepseek-ai/dsh-llm";
2289
2290
  import { Text as Text2 } from "@earendil-works/pi-tui";
2290
2291
 
@@ -2302,6 +2303,12 @@ import path3 from "path";
2302
2303
  import { mkdirSync, readFileSync as readFileSync5, renameSync, writeFileSync } from "fs";
2303
2304
  import path4 from "path";
2304
2305
 
2306
+ // src/setup/skills.ts
2307
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "fs/promises";
2308
+ import { existsSync as existsSync3 } from "fs";
2309
+ import { homedir as homedir3 } from "os";
2310
+ import path5, { dirname, join as join3 } from "path";
2311
+
2305
2312
  // src/plugins/modes.ts
2306
2313
  var PLAN_ON_TEXT = "Plan mode is on: do not modify files or run modifying commands.";
2307
2314
  var PLAN_OFF_TEXT = "Plan mode is off.";
@@ -2636,22 +2643,16 @@ import {
2636
2643
  // src/ui/footer.ts
2637
2644
  import { truncateToWidth as truncateToWidth2, visibleWidth as visibleWidth5 } from "@earendil-works/pi-tui";
2638
2645
  import { readFileSync as readFileSync7, statSync as statSync2 } from "fs";
2639
- import { homedir as homedir4 } from "os";
2640
- import { isAbsolute as isAbsolute5, join as join3 } from "path";
2646
+ import { homedir as homedir5 } from "os";
2647
+ import { isAbsolute as isAbsolute5, join as join5 } from "path";
2641
2648
 
2642
2649
  // src/ui/place.ts
2643
- import { homedir as homedir3 } from "os";
2650
+ import { homedir as homedir4 } from "os";
2644
2651
  import { posix, win32 } from "path";
2645
2652
 
2646
2653
  // src/ui/jump-latest.ts
2647
2654
  import { stripTerminalSequences, visibleWidth as visibleWidth6 } from "@earendil-works/pi-tui";
2648
2655
 
2649
- // src/setup/skills.ts
2650
- import { cp, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "fs/promises";
2651
- import { existsSync as existsSync4 } from "fs";
2652
- import { homedir as homedir5 } from "os";
2653
- import path5, { dirname, join as join4 } from "path";
2654
-
2655
2656
  // src/ui/shell.ts
2656
2657
  import { Container as Container2 } from "@earendil-works/pi-tui";
2657
2658
 
@@ -2706,7 +2707,7 @@ var LOGO_BESIDE_GAP = " ".repeat(LOGO_GAP);
2706
2707
 
2707
2708
  // src/ui/intro.ts
2708
2709
  import { existsSync as existsSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync2 } from "fs";
2709
- import { join as join5 } from "path";
2710
+ import { join as join6 } from "path";
2710
2711
 
2711
2712
  // src/ui/intro-effects.ts
2712
2713
  var H = LOGO.length;
@@ -2736,7 +2737,7 @@ import { matchesKey as matchesKey3, visibleWidth as visibleWidth12, wrapTextWith
2736
2737
  // src/ui/file-complete.ts
2737
2738
  import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
2738
2739
  import { existsSync as existsSync6, readdirSync } from "fs";
2739
- import { basename, delimiter as delimiter2, join as join6, relative as relative2 } from "path";
2740
+ import { basename, delimiter as delimiter2, join as join7, relative as relative2 } from "path";
2740
2741
 
2741
2742
  // src/image/clipboard.ts
2742
2743
  import { readFileSync as readFileSync10 } from "fs";
@@ -2760,7 +2761,7 @@ import { visibleWidth as visibleWidth14 } from "@earendil-works/pi-tui";
2760
2761
  // src/setup/discover.ts
2761
2762
  import { readFileSync as readFileSync11 } from "fs";
2762
2763
  import { networkInterfaces } from "os";
2763
- import { join as join7 } from "path";
2764
+ import { join as join8 } from "path";
2764
2765
  import { spawnSync } from "child_process";
2765
2766
  import { parse as parseYaml } from "yaml";
2766
2767
 
@@ -2795,7 +2796,7 @@ function settingsEntryAnswer(entry) {
2795
2796
 
2796
2797
  // src/ui/bruine-ui.ts
2797
2798
  import { existsSync as existsSync7, readFileSync as readFileSync12 } from "fs";
2798
- import { join as join9 } from "path";
2799
+ import { join as join10 } from "path";
2799
2800
 
2800
2801
  // src/update.ts
2801
2802
  import { spawn } from "child_process";
@@ -2810,7 +2811,7 @@ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2810
2811
  function readSettingsRoute(dshHome) {
2811
2812
  try {
2812
2813
  const home = dshHome ?? runtimeHome();
2813
- const p = join9(home, "settings.yaml");
2814
+ const p = join10(home, "settings.yaml");
2814
2815
  if (!existsSync7(p)) return void 0;
2815
2816
  const doc = parseBruineSettingsYaml(readFileSync12(p, "utf8"));
2816
2817
  if (doc === void 0) return void 0;
@@ -2840,7 +2841,7 @@ function parseBruineSettingsYaml(text) {
2840
2841
 
2841
2842
  // src/ui/errors.ts
2842
2843
  import { appendFile, mkdir as mkdir4 } from "fs/promises";
2843
- import { join as join10 } from "path";
2844
+ import { join as join11 } from "path";
2844
2845
  import { truncateToWidth as truncateToWidth7, wrapTextWithAnsi as wrapTextWithAnsi4 } from "@earendil-works/pi-tui";
2845
2846
  function firstLine(text) {
2846
2847
  return (text.split(/\r?\n/)[0] ?? text).trim();
@@ -2932,14 +2933,14 @@ async function fetchAvailableModels(baseUrl) {
2932
2933
  }
2933
2934
  function bruineLogPath(env = process.env) {
2934
2935
  const home = runtimeHome(env);
2935
- return join10(home, "logs", "bruine.log");
2936
+ return join11(home, "logs", "bruine.log");
2936
2937
  }
2937
2938
  async function appendErrorLog(failure, extra = "") {
2938
2939
  const path7 = bruineLogPath();
2939
2940
  const line = `${(/* @__PURE__ */ new Date()).toISOString()} [llm-error] code=${String(failure.code ?? "UNKNOWN")}${failure.status === void 0 ? "" : ` status=${String(failure.status)}`} ${String(failure.message ?? "").slice(0, 2e3)}${extra === "" ? "" : ` ${extra}`}
2940
2941
  `;
2941
2942
  try {
2942
- await mkdir4(join10(path7, ".."), { recursive: true });
2943
+ await mkdir4(join11(path7, ".."), { recursive: true });
2943
2944
  await appendFile(path7, line, "utf8");
2944
2945
  } catch {
2945
2946
  }
@@ -2983,7 +2984,7 @@ function retryLine(event) {
2983
2984
 
2984
2985
  // src/plugins/silence.ts
2985
2986
  import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync3 } from "fs";
2986
- import { join as join11 } from "path";
2987
+ import { join as join12 } from "path";
2987
2988
  import { parse as parseYaml3 } from "yaml";
2988
2989
  var BRUINE_SILENCE_SERVICE = "bruineSilence";
2989
2990
 
@@ -7017,6 +7017,7 @@ var BruineUi = class {
7017
7017
 
7018
7018
  // src/plugins/modes.ts
7019
7019
  import { existsSync as existsSync8, readFileSync as readFileSync12 } from "fs";
7020
+ import { join as join10 } from "path";
7020
7021
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
7021
7022
  import { Text as Text3 } from "@earendil-works/pi-tui";
7022
7023
 
@@ -7031,12 +7032,17 @@ import { readFileSync as readFileSync10 } from "fs";
7031
7032
  import path6 from "path";
7032
7033
  var PROJECT_MCP_FILE = ".mcp.json";
7033
7034
  function fingerprint(server) {
7034
- const what = server.transport === "stdio" ? { t: "stdio", command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd ?? "" } : { t: "http", url: server.url, headers: server.headers ?? {} };
7035
+ const may = { alwaysAllow: [...server.alwaysAllow].sort(), readOnly: server.readOnly };
7036
+ const what = server.transport === "stdio" ? { t: "stdio", command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd ?? "", may } : { t: "http", url: server.url, headers: server.headers ?? {}, may };
7035
7037
  return createHash("sha256").update(JSON.stringify(what)).digest("hex").slice(0, 16);
7036
7038
  }
7037
7039
  function describeServer(server) {
7038
- if (server.transport === "stdio") return [server.command, ...server.args ?? []].join(" ");
7039
- return server.url ?? "";
7040
+ const where = server.transport === "stdio" ? [server.command, ...server.args ?? []].join(" ") : server.url ?? "";
7041
+ const may = [
7042
+ ...server.readOnly ? ["marked read-only: usable in Plan mode, never asks"] : [],
7043
+ ...server.alwaysAllow.length > 0 ? [`runs without asking: ${server.alwaysAllow.join(", ")}`] : []
7044
+ ];
7045
+ return may.length === 0 ? where : `${where} (${may.join("; ")})`;
7040
7046
  }
7041
7047
 
7042
7048
  // src/mcp/state.ts
@@ -7383,13 +7389,13 @@ var SPACE_BUNNY_NOTICE = [
7383
7389
 
7384
7390
  // src/setup/zen-route.ts
7385
7391
  import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
7386
- import { join as join11 } from "path";
7392
+ import { join as join12 } from "path";
7387
7393
  import { parseDocument } from "yaml";
7388
7394
 
7389
7395
  // src/setup/simple.ts
7390
7396
  import { chmod as chmod2, mkdir as mkdir4, readFile as readFile5, rename as rename5, writeFile as writeFile5 } from "fs/promises";
7391
7397
  import { existsSync as existsSync10 } from "fs";
7392
- import { dirname as dirname2, join as join10 } from "path";
7398
+ import { dirname as dirname2, join as join11 } from "path";
7393
7399
 
7394
7400
  // src/setup/cloud.ts
7395
7401
  var DEEPSEEK_OFFICIAL = {
@@ -7416,7 +7422,7 @@ async function writeAtomic(path8, content, mode) {
7416
7422
  // src/setup/zen-route.ts
7417
7423
  var ROUTE_PATH = ["llm-pi-ai", "providers", SPACE_BUNNY.routeName];
7418
7424
  async function addSpaceBunnyToHome(home) {
7419
- const file = join11(home, "settings.yaml");
7425
+ const file = join12(home, "settings.yaml");
7420
7426
  const doc = parseDocument(existsSync11(file) ? readFileSync14(file, "utf8") : "");
7421
7427
  if (doc.hasIn([...ROUTE_PATH])) {
7422
7428
  await repairSpaceBunnyRoute(home);
@@ -7427,7 +7433,7 @@ async function addSpaceBunnyToHome(home) {
7427
7433
  return "added";
7428
7434
  }
7429
7435
  async function repairSpaceBunnyRoute(home) {
7430
- const file = join11(home, "settings.yaml");
7436
+ const file = join12(home, "settings.yaml");
7431
7437
  if (!existsSync11(file)) return false;
7432
7438
  let doc;
7433
7439
  try {
@@ -7475,12 +7481,12 @@ async function repairSpaceBunnyRoute(home) {
7475
7481
  // src/plugins/model.ts
7476
7482
  import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
7477
7483
  import { chmod as chmod3, rename as rename6, writeFile as writeFile6 } from "fs/promises";
7478
- import { join as join13 } from "path";
7484
+ import { join as join14 } from "path";
7479
7485
  import { matchesKey as matchesKey6 } from "@earendil-works/pi-tui";
7480
7486
 
7481
7487
  // src/ui/errors.ts
7482
7488
  import { appendFile, mkdir as mkdir5 } from "fs/promises";
7483
- import { join as join12 } from "path";
7489
+ import { join as join13 } from "path";
7484
7490
  import { truncateToWidth as truncateToWidth7, wrapTextWithAnsi as wrapTextWithAnsi3 } from "@earendil-works/pi-tui";
7485
7491
  function formatK2(n) {
7486
7492
  const k = n / 1e3;
@@ -7630,7 +7636,7 @@ function readEnvKeys(dshHome) {
7630
7636
  const out = /* @__PURE__ */ new Set();
7631
7637
  const home = dshHome ?? runtimeHome();
7632
7638
  try {
7633
- const path8 = join13(home, ".env");
7639
+ const path8 = join14(home, ".env");
7634
7640
  if (!existsSync12(path8)) return out;
7635
7641
  for (const line of readFileSync15(path8, "utf8").split(/\r?\n/)) {
7636
7642
  const name4 = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
@@ -8211,7 +8217,7 @@ var QuestionCallComponent = class {
8211
8217
 
8212
8218
  // src/plugins/silence.ts
8213
8219
  import { existsSync as existsSync13, readFileSync as readFileSync16, statSync as statSync3 } from "fs";
8214
- import { join as join14 } from "path";
8220
+ import { join as join15 } from "path";
8215
8221
  import { parse as parseYaml3 } from "yaml";
8216
8222
 
8217
8223
  // src/plugins/render.ts
@@ -8435,7 +8441,7 @@ function answersIn(output) {
8435
8441
  // src/plugins/config-edit.ts
8436
8442
  import { spawn as spawn3 } from "child_process";
8437
8443
  import { existsSync as existsSync14, readFileSync as readFileSync18 } from "fs";
8438
- import { delimiter as delimiter3, join as join15 } from "path";
8444
+ import { delimiter as delimiter3, join as join16 } from "path";
8439
8445
  var TERMINAL_EDITORS = /* @__PURE__ */ new Set([
8440
8446
  "vi",
8441
8447
  "vim",
@@ -8462,7 +8468,7 @@ var TERMINAL_EDITORS = /* @__PURE__ */ new Set([
8462
8468
  ]);
8463
8469
  var GUI_EDITORS = ["kate", "kwrite", "gedit", "xed", "mousepad", "pluma", "code", "codium", "subl", "zed"];
8464
8470
  function configFiles(home, exists2 = existsSync14) {
8465
- return [join15(home, "settings.yaml"), configReadPath(home)].filter((p) => exists2(p));
8471
+ return [join16(home, "settings.yaml"), configReadPath(home)].filter((p) => exists2(p));
8466
8472
  }
8467
8473
  function baseName(command) {
8468
8474
  return command.split(/[\\/]/).pop()?.replace(/\.exe$/i, "") ?? command;
@@ -8476,7 +8482,7 @@ function onPath(command, env, exists2) {
8476
8482
  if (/[\\/]/.test(command)) return exists2(command);
8477
8483
  const dirs = (env.PATH ?? "").split(delimiter3).filter((d) => d !== "");
8478
8484
  const names = process.platform === "win32" ? [command, `${command}.exe`, `${command}.cmd`] : [command];
8479
- return dirs.some((dir) => names.some((name4) => exists2(join15(dir, name4))));
8485
+ return dirs.some((dir) => names.some((name4) => exists2(join16(dir, name4))));
8480
8486
  }
8481
8487
  function editorFromBruineJson(home) {
8482
8488
  try {
@@ -8639,13 +8645,13 @@ async function stopTask(services, agent, sessionId, name4) {
8639
8645
 
8640
8646
  // src/plugins/verify.ts
8641
8647
  import { existsSync as existsSync15, readFileSync as readFileSync19 } from "fs";
8642
- import { join as join16 } from "path";
8648
+ import { join as join17 } from "path";
8643
8649
  function verificationPlan(cwd) {
8644
- const packagePath = join16(cwd, "package.json");
8650
+ const packagePath = join17(cwd, "package.json");
8645
8651
  if (existsSync15(packagePath)) {
8646
8652
  try {
8647
8653
  const doc = JSON.parse(readFileSync19(packagePath, "utf8"));
8648
- const manager = doc.packageManager?.split("@")[0] ?? (existsSync15(join16(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync15(join16(cwd, "yarn.lock")) ? "yarn" : "npm");
8654
+ const manager = doc.packageManager?.split("@")[0] ?? (existsSync15(join17(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync15(join17(cwd, "yarn.lock")) ? "yarn" : "npm");
8649
8655
  const cli = ["pnpm", "yarn", "bun", "npm"].includes(manager) ? manager : "npm";
8650
8656
  const checks = [];
8651
8657
  if (typeof doc.scripts?.typecheck === "string") checks.push({ label: "Typecheck", command: `${cli} run typecheck` });
@@ -8655,8 +8661,8 @@ function verificationPlan(cwd) {
8655
8661
  return [];
8656
8662
  }
8657
8663
  }
8658
- if (existsSync15(join16(cwd, "Cargo.toml"))) return [{ label: "Tests", command: "cargo test" }];
8659
- if (existsSync15(join16(cwd, "go.mod"))) return [{ label: "Tests", command: "go test ./..." }];
8664
+ if (existsSync15(join17(cwd, "Cargo.toml"))) return [{ label: "Tests", command: "cargo test" }];
8665
+ if (existsSync15(join17(cwd, "go.mod"))) return [{ label: "Tests", command: "go test ./..." }];
8660
8666
  return [];
8661
8667
  }
8662
8668
  async function runVerification(cwd, run = (command, dir) => runShell(command, dir)) {
@@ -8675,7 +8681,7 @@ ${formatShell(check.command, result)}`)
8675
8681
  }
8676
8682
 
8677
8683
  // src/plugins/repl.ts
8678
- import { join as join17 } from "path";
8684
+ import { join as join18 } from "path";
8679
8685
 
8680
8686
  // src/image/attach.ts
8681
8687
  function attachFailureNotice(failures) {
@@ -9056,7 +9062,7 @@ async function runRepl(ctx, exit) {
9056
9062
  void sendWithImages(text, images);
9057
9063
  };
9058
9064
  const flush = (session) => sessions.flush(session);
9059
- const skillsDir = join17(runtimeHome(), "skills");
9065
+ const skillsDir = join18(runtimeHome(), "skills");
9060
9066
  let skillNames = [];
9061
9067
  const refreshSkillNames = () => {
9062
9068
  void availableSkillNames(skillsDir, process.cwd()).then((names) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziamana/bruine",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Interactive terminal coding agent that runs your own models, local or OpenAI-compatible",
5
5
  "type": "module",
6
6
  "license": "MIT",