clay-server 2.40.0-beta.2 → 2.40.0-beta.4

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.
@@ -292,96 +292,24 @@ function appendSessionCloseButton(el, session) {
292
292
  el.appendChild(closeBtn);
293
293
  }
294
294
 
295
- // Dropdown anchored to the Claude split-button chevron. Renders two
296
- // explicit choices (TUI / GUI) and dismisses on click-outside, Escape,
297
- // or window blur. Kept local to this module since it's the only caller.
298
- var _claudeModeMenu = null;
299
- function closeClaudeModeMenu() {
300
- if (_claudeModeMenu && _claudeModeMenu.parentNode) {
301
- _claudeModeMenu.parentNode.removeChild(_claudeModeMenu);
302
- }
303
- _claudeModeMenu = null;
304
- }
305
- function openClaudeModeMenu(x, y) {
306
- closeClaudeModeMenu();
307
- var menu = document.createElement("div");
308
- menu.className = "claude-mode-menu";
309
- // CLI sessions (created by the `claude` CLI outside Clay) are now
310
- // auto-adopted into the regular session list on server start, so no
311
- // separate "Import CLI" entry is needed. Users see all sessions as one
312
- // list and click renders per their claudeOpenMode pref.
313
- var items = [
314
- { label: "Start as TUI", hint: "Real claude terminal", action: "new", mode: "tui" },
315
- { label: "Start as GUI", hint: "Clay chat UI", action: "new", mode: "gui" },
316
- ];
317
- for (var i = 0; i < items.length; i++) {
318
- (function (it) {
319
- var b = document.createElement("button");
320
- b.type = "button";
321
- b.className = "claude-mode-menu-item";
322
- b.innerHTML = '<span class="claude-mode-menu-label">' + it.label + '</span>' +
323
- '<span class="claude-mode-menu-hint">' + it.hint + '</span>';
324
- b.addEventListener("click", function (e) {
325
- e.stopPropagation();
326
- closeClaudeModeMenu();
327
- if (getWs() && store.get('connected')) {
328
- getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: it.mode }));
329
- }
330
- });
331
- menu.appendChild(b);
332
- })(items[i]);
333
- }
334
- document.body.appendChild(menu);
335
- // Clamp to viewport.
336
- var rect = menu.getBoundingClientRect();
337
- var px = x, py = y;
338
- if (px + rect.width > window.innerWidth - 4) px = window.innerWidth - rect.width - 4;
339
- if (py + rect.height > window.innerHeight - 4) py = window.innerHeight - rect.height - 4;
340
- menu.style.left = px + "px";
341
- menu.style.top = py + "px";
342
- _claudeModeMenu = menu;
343
- }
344
- document.addEventListener("click", function () { closeClaudeModeMenu(); });
345
- document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeClaudeModeMenu(); });
346
- window.addEventListener("blur", function () { closeClaudeModeMenu(); });
347
- window.addEventListener("resize", function () { closeClaudeModeMenu(); });
348
-
349
295
  function renderSessionTopActions() {
350
296
  var wrap = document.createElement("div");
351
297
  wrap.className = "session-top-actions";
352
298
 
353
- // Claude: split button. Default click starts a TUI session (the path
354
- // most users want post 2026-06-15 Agent SDK billing split). The small
355
- // chevron on the right opens a dropdown that lets the user explicitly
356
- // pick TUI or GUI for this one session, bypassing the saved pref.
357
- var claudeWrap = document.createElement("div");
358
- claudeWrap.className = "session-top-action session-top-action-split";
359
-
360
- var claudeMain = document.createElement("button");
361
- claudeMain.className = "session-top-action-main";
362
- claudeMain.type = "button";
363
- claudeMain.title = "New Claude session (TUI)";
364
- claudeMain.innerHTML = '<img src="/claude-code-avatar.png" class="session-top-action-icon" alt=""><span>Claude</span>';
365
- claudeMain.addEventListener("click", function () {
299
+ // Claude: single button. TUI vs GUI is the user's claudeOpenMode pref
300
+ // (set in user settings), so we send no explicit mode and let the server
301
+ // apply it - no per-click chevron/menu needed.
302
+ var claudeBtn = document.createElement("button");
303
+ claudeBtn.className = "session-top-action";
304
+ claudeBtn.type = "button";
305
+ claudeBtn.title = "New Claude session";
306
+ claudeBtn.innerHTML = '<img src="/claude-code-avatar.png" class="session-top-action-icon" alt=""><span>Claude</span>';
307
+ claudeBtn.addEventListener("click", function () {
366
308
  if (getWs() && store.get('connected')) {
367
- getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: "tui" }));
309
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "claude" }));
368
310
  }
369
311
  });
370
- claudeWrap.appendChild(claudeMain);
371
-
372
- var claudeChevron = document.createElement("button");
373
- claudeChevron.className = "session-top-action-chevron";
374
- claudeChevron.type = "button";
375
- claudeChevron.title = "Choose session mode";
376
- claudeChevron.innerHTML = iconHtml("chevron-down");
377
- claudeChevron.addEventListener("click", function (e) {
378
- e.stopPropagation();
379
- var rect = claudeWrap.getBoundingClientRect();
380
- openClaudeModeMenu(rect.left, rect.bottom + 4);
381
- });
382
- claudeWrap.appendChild(claudeChevron);
383
-
384
- wrap.appendChild(claudeWrap);
312
+ wrap.appendChild(claudeBtn);
385
313
 
386
314
  // Codex: always GUI (no TUI adapter for Codex).
387
315
  var codexBtn = document.createElement("button");
@@ -0,0 +1,129 @@
1
+ // terminal-toolbar.js
2
+ //
3
+ // Reusable mobile control-key bar (Tab / Ctrl / Esc / arrows / Alt / pipe /
4
+ // slash / tilde) for any xterm-backed terminal. Soft keyboards lack these
5
+ // keys, so both the bottom-panel shell (terminal.js) and the embedded TUI
6
+ // session view (session-tui-view.js) mount this bar on touch devices.
7
+ //
8
+ // The caller owns the toolbar element and how bytes reach its terminal; this
9
+ // module owns the key sequences, the sticky Ctrl/Alt modifiers, and applying
10
+ // Ctrl to a soft-keyboard letter via xterm's custom key handler.
11
+
12
+ export var TERMINAL_TOOLBAR_HTML =
13
+ '<button class="term-key" data-key="tab">Tab</button>' +
14
+ '<button class="term-key term-key-toggle" data-key="ctrl">Ctrl</button>' +
15
+ '<button class="term-key" data-key="esc">Esc</button>' +
16
+ '<span class="term-key-spacer"></span>' +
17
+ '<button class="term-key term-key-arrow" data-key="up">&#9650;</button>' +
18
+ '<button class="term-key term-key-arrow" data-key="down">&#9660;</button>' +
19
+ '<button class="term-key term-key-arrow" data-key="left">&#9664;</button>' +
20
+ '<button class="term-key term-key-arrow" data-key="right">&#9654;</button>' +
21
+ '<span class="term-key-spacer"></span>' +
22
+ '<button class="term-key term-key-toggle" data-key="alt">Alt</button>' +
23
+ '<button class="term-key" data-key="pipe">|</button>' +
24
+ '<button class="term-key" data-key="slash">/</button>' +
25
+ '<button class="term-key" data-key="tilde">~</button>';
26
+
27
+ var KEY_MAP = {
28
+ tab: "\t",
29
+ esc: "\x1b",
30
+ up: "\x1b[A",
31
+ down: "\x1b[B",
32
+ right: "\x1b[C",
33
+ left: "\x1b[D",
34
+ pipe: "|",
35
+ slash: "/",
36
+ tilde: "~",
37
+ };
38
+
39
+ // Wire a toolbar element to a terminal.
40
+ // opts.toolbar : the container element holding the .term-key buttons.
41
+ // opts.send : function(data) that writes the bytes to the live PTY.
42
+ // Returns { bindXterm(xterm), reset() }:
43
+ // bindXterm - (re)attach the Ctrl-letter handler to the active xterm; call
44
+ // whenever the active terminal changes.
45
+ // reset - clear sticky Ctrl/Alt state (call when hiding the bar).
46
+ export function createKeyToolbar(opts) {
47
+ var toolbar = opts && opts.toolbar;
48
+ var send = opts && opts.send;
49
+ if (!toolbar || typeof send !== "function") return { bindXterm: function () {}, reset: function () {} };
50
+
51
+ var ctrlActive = false;
52
+ var altActive = false;
53
+
54
+ function clearModifier(key) {
55
+ var btn = toolbar.querySelector("[data-key='" + key + "']");
56
+ if (btn) btn.classList.remove("active");
57
+ }
58
+ function reset() {
59
+ ctrlActive = false;
60
+ altActive = false;
61
+ clearModifier("ctrl");
62
+ clearModifier("alt");
63
+ }
64
+
65
+ // Bind the click handler once.
66
+ if (!toolbar._keyToolbarBound) {
67
+ toolbar._keyToolbarBound = true;
68
+ // Keep focus on the terminal so the soft keyboard doesn't dismiss.
69
+ toolbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
70
+ toolbar.addEventListener("click", function (e) {
71
+ var btn = e.target.closest(".term-key");
72
+ if (!btn) return;
73
+ var key = btn.dataset.key;
74
+ if (!key) return;
75
+
76
+ if (key === "ctrl") {
77
+ ctrlActive = !ctrlActive;
78
+ btn.classList.toggle("active", ctrlActive);
79
+ return;
80
+ }
81
+ if (key === "alt") {
82
+ altActive = !altActive;
83
+ btn.classList.toggle("active", altActive);
84
+ return;
85
+ }
86
+
87
+ var seq = KEY_MAP[key];
88
+ if (!seq) return;
89
+ if (altActive) {
90
+ seq = "\x1b" + seq;
91
+ altActive = false;
92
+ clearModifier("alt");
93
+ }
94
+ send(seq);
95
+ if (ctrlActive) {
96
+ ctrlActive = false;
97
+ clearModifier("ctrl");
98
+ }
99
+ });
100
+ }
101
+
102
+ return {
103
+ bindXterm: function (xterm) {
104
+ if (!xterm || typeof xterm.attachCustomKeyEventHandler !== "function") return;
105
+ xterm.attachCustomKeyEventHandler(function (ev) {
106
+ if (ctrlActive && ev.type === "keydown" && ev.key && ev.key.length === 1) {
107
+ var charCode = ev.key.toUpperCase().charCodeAt(0);
108
+ if (charCode >= 65 && charCode <= 90) {
109
+ send(String.fromCharCode(charCode - 64));
110
+ ctrlActive = false;
111
+ clearModifier("ctrl");
112
+ return false;
113
+ }
114
+ }
115
+ return true;
116
+ });
117
+ },
118
+ // If Ctrl is armed, disarm it and return true. Lets a separate input
119
+ // surface (e.g. the mobile TUI entry bar, where soft-keyboard letters
120
+ // don't reach xterm) apply the Ctrl modifier to the next typed letter.
121
+ takeCtrl: function () {
122
+ if (!ctrlActive) return false;
123
+ ctrlActive = false;
124
+ clearModifier("ctrl");
125
+ return true;
126
+ },
127
+ reset: reset,
128
+ };
129
+ }
@@ -4,13 +4,13 @@ import { closeFileViewer } from './filebrowser.js';
4
4
  import { copyToClipboard } from './utils.js';
5
5
  import { getTerminalTheme } from './theme.js';
6
6
  import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
7
+ import { createKeyToolbar } from './terminal-toolbar.js';
7
8
 
8
9
  var ctx;
9
10
  var tabs = new Map(); // termId -> { id, title, exited, xterm, fitAddon, bodyEl }
10
11
  var activeTabId = null;
11
12
  var isOpen = false;
12
- var ctrlActive = false;
13
- var altActive = false;
13
+ var keyToolbar = null;
14
14
  var isTouchDevice = "ontouchstart" in window;
15
15
  var viewportHandler = null;
16
16
  var resizeObserver = null;
@@ -30,7 +30,6 @@ function disposeTab(tab) {
30
30
  tab.bodyEl = null;
31
31
  }
32
32
  }
33
- var toolbarBound = false;
34
33
  var termCtxMenu = null;
35
34
 
36
35
  // --- Multi-line link provider ---
@@ -242,15 +241,8 @@ export function closeTerminal() {
242
241
 
243
242
  // Hide toolbar
244
243
  var toolbar = document.getElementById("terminal-toolbar");
245
- if (toolbar) {
246
- toolbar.classList.add("hidden");
247
- var ctrlBtn = toolbar.querySelector("[data-key='ctrl']");
248
- if (ctrlBtn) ctrlBtn.classList.remove("active");
249
- var altBtn = toolbar.querySelector("[data-key='alt']");
250
- if (altBtn) altBtn.classList.remove("active");
251
- }
252
- ctrlActive = false;
253
- altActive = false;
244
+ if (toolbar) toolbar.classList.add("hidden");
245
+ if (keyToolbar) keyToolbar.reset();
254
246
 
255
247
  // Mobile: restore tab bar
256
248
  var mTabBar = document.getElementById("mobile-tab-bar");
@@ -327,7 +319,17 @@ function activateTab(termId) {
327
319
  var toolbar = document.getElementById("terminal-toolbar");
328
320
  if (toolbar && isTouchDevice) {
329
321
  toolbar.classList.remove("hidden");
330
- initToolbar(toolbar);
322
+ if (!keyToolbar) {
323
+ keyToolbar = createKeyToolbar({
324
+ toolbar: toolbar,
325
+ send: function (data) {
326
+ if (activeTabId && ctx.ws && ctx.connected) {
327
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: data }));
328
+ }
329
+ },
330
+ });
331
+ }
332
+ if (tab.xterm) keyToolbar.bindXterm(tab.xterm);
331
333
  }
332
334
 
333
335
  // Mobile viewport handling
@@ -440,6 +442,29 @@ function createXtermForTab(tab) {
440
442
  tab.bodyEl = bodyEl;
441
443
  }
442
444
 
445
+ // Rebuild a tab's WebGL glyph atlas, then force a full redraw.
446
+ //
447
+ // The WebGL renderer caches rasterized glyphs in a texture atlas keyed on
448
+ // the font (family + cell metrics). When the font changes or cell size
449
+ // shifts on resize, the atlas keeps glyphs rasterized from the old state,
450
+ // so text renders garbled until a full repaint rebuilds it. That repaint
451
+ // is exactly what "select all" triggers, which is why selecting everything
452
+ // appears to "fix" the corruption. clearTextureAtlas() forces the rebuild
453
+ // so glyphs re-rasterize at the new font/metrics immediately. Deferred one
454
+ // frame so any preceding fit() has settled. No-op on tabs that fell back to
455
+ // the DOM renderer. Mirrors the resize handler in session-tui-view.js.
456
+ function rebuildTabGlyphAtlas(tab) {
457
+ if (!tab) return;
458
+ requestAnimationFrame(function () {
459
+ if (tab._webglAddon && typeof tab._webglAddon.clearTextureAtlas === "function") {
460
+ try { tab._webglAddon.clearTextureAtlas(); } catch (e) {}
461
+ }
462
+ if (tab.xterm) {
463
+ try { tab.xterm.refresh(0, tab.xterm.rows - 1); } catch (e) {}
464
+ }
465
+ });
466
+ }
467
+
443
468
  // --- Fit active terminal ---
444
469
  var fitRafId = null;
445
470
 
@@ -462,6 +487,9 @@ function fitTerminal() {
462
487
  }));
463
488
  }
464
489
  } catch (e) {}
490
+ // Cell metrics shift on resize; rebuild the WebGL glyph atlas so glyphs
491
+ // don't render against stale cell dimensions.
492
+ rebuildTabGlyphAtlas(tab);
465
493
  });
466
494
  }
467
495
 
@@ -818,7 +846,23 @@ function applyFontToAllTabs(family, size) {
818
846
  if (family) tab.xterm.options.fontFamily = family;
819
847
  if (size) tab.xterm.options.fontSize = size;
820
848
  if (tab.fitAddon) tab.fitAddon.fit();
849
+ // A font-size change shifts cell metrics, so fit() recomputes
850
+ // cols/rows. Notify the PTY of the new size - otherwise the running
851
+ // program keeps drawing to the old dimensions and the output looks
852
+ // corrupted. (Font-family-only changes leave cols/rows unchanged,
853
+ // making this a harmless no-op.) Mirrors fitTerminal()'s resize msg.
854
+ if (ctx.ws && ctx.connected) {
855
+ ctx.ws.send(JSON.stringify({
856
+ type: "term_resize",
857
+ id: tab.id,
858
+ cols: tab.xterm.cols,
859
+ rows: tab.xterm.rows,
860
+ }));
861
+ }
821
862
  } catch (e) {}
863
+ // Changing the font invalidates the WebGL glyph atlas; rebuild it so
864
+ // the new font renders cleanly instead of stale, garbled cached glyphs.
865
+ rebuildTabGlyphAtlas(tab);
822
866
  }
823
867
  }
824
868
  onTerminalFontChange(applyFontToAllTabs);
@@ -920,89 +964,3 @@ function showTermCtxMenu(e, tab) {
920
964
  document.addEventListener("click", closeTermCtxMenu, { once: true });
921
965
  }, 0);
922
966
  }
923
-
924
- // --- Mobile toolbar ---
925
- var KEY_MAP = {
926
- tab: "\t",
927
- esc: "\x1b",
928
- up: "\x1b[A",
929
- down: "\x1b[B",
930
- right: "\x1b[C",
931
- left: "\x1b[D",
932
- pipe: "|",
933
- slash: "/",
934
- tilde: "~",
935
- };
936
-
937
- function initToolbar(toolbar) {
938
- if (!toolbarBound) {
939
- toolbarBound = true;
940
-
941
- toolbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
942
-
943
- toolbar.addEventListener("click", function (e) {
944
- var btn = e.target.closest(".term-key");
945
- if (!btn) return;
946
-
947
- var tab = activeTabId ? tabs.get(activeTabId) : null;
948
- if (!tab || !tab.xterm) return;
949
-
950
- var key = btn.dataset.key;
951
- if (!key) return;
952
-
953
- if (key === "ctrl") {
954
- ctrlActive = !ctrlActive;
955
- btn.classList.toggle("active", ctrlActive);
956
- return;
957
- }
958
-
959
- if (key === "alt") {
960
- altActive = !altActive;
961
- btn.classList.toggle("active", altActive);
962
- return;
963
- }
964
-
965
- var seq = KEY_MAP[key];
966
- if (!seq) return;
967
-
968
- // Alt prefix: send ESC before the character
969
- if (altActive) {
970
- seq = "\x1b" + seq;
971
- altActive = false;
972
- var altBtn = toolbar.querySelector("[data-key='alt']");
973
- if (altBtn) altBtn.classList.remove("active");
974
- }
975
-
976
- if (ctx.ws && ctx.connected) {
977
- ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: seq }));
978
- }
979
-
980
- if (ctrlActive) {
981
- ctrlActive = false;
982
- var ctrlBtn = toolbar.querySelector("[data-key='ctrl']");
983
- if (ctrlBtn) ctrlBtn.classList.remove("active");
984
- }
985
- });
986
- }
987
-
988
- // Attach Ctrl handler to active terminal
989
- var tab = activeTabId ? tabs.get(activeTabId) : null;
990
- if (tab && tab.xterm) {
991
- tab.xterm.attachCustomKeyEventHandler(function (ev) {
992
- if (ctrlActive && ev.type === "keydown" && ev.key.length === 1) {
993
- var charCode = ev.key.toUpperCase().charCodeAt(0);
994
- if (charCode >= 65 && charCode <= 90) {
995
- var ctrlChar = String.fromCharCode(charCode - 64);
996
- if (ctx.ws && ctx.connected) {
997
- ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: ctrlChar }));
998
- }
999
- ctrlActive = false;
1000
- var ctrlBtn = document.querySelector("#terminal-toolbar [data-key='ctrl']");
1001
- if (ctrlBtn) ctrlBtn.classList.remove("active");
1002
- return false;
1003
- }
1004
- }
1005
- return true;
1006
- });
1007
- }
1008
- }
@@ -0,0 +1,171 @@
1
+ // safe-bash-commands.js
2
+ //
3
+ // Single source of truth for the Bash commands Clay auto-approves without
4
+ // prompting. BOTH auto-approval paths derive from here so they can never
5
+ // drift apart again:
6
+ // - sdk-bridge.js `checkToolWhitelist` -> SDK / GUI sessions (uses
7
+ // isSafeBashSegment on each operator-split segment).
8
+ // - claude-hook-installer.js `CLAY_MANAGED_ALLOW` -> TUI sessions, via the
9
+ // `Bash(...)` patterns buildClayBashAllowPatterns() emits into
10
+ // ~/.claude/settings.json `permissions.allow`.
11
+ //
12
+ // Policy:
13
+ // - STANDALONE: read-only / inspection / navigation / text commands that
14
+ // stay safe regardless of arguments. Auto-approved as a whole word.
15
+ // - SUBCOMMAND: binaries that are NOT blanket-safe (git, npm, ...) but
16
+ // whose listed read-only subcommands are. Write subcommands (git push,
17
+ // npm install, ...) are intentionally absent so they still prompt.
18
+ // - EXACT: full command strings safe verbatim (version probes). Language
19
+ // runtimes (node, python, ruby, go, ...) live here as `--version` only:
20
+ // `node script.js` executes arbitrary code, so it is NOT auto-approved.
21
+ //
22
+ // Safety note: a few STANDALONE entries (env, command, xargs, time, tee,
23
+ // find, sed, awk) can execute or write given hostile arguments
24
+ // (`env rm -rf /`, `find . -exec ...`, `tee /etc/x`). They are kept to match
25
+ // Clay's long-standing read-only-convenience posture; this is the one place
26
+ // to tighten if that tradeoff ever changes.
27
+
28
+ var STANDALONE = [
29
+ // Navigation
30
+ "cd", "pushd", "popd",
31
+ // File / dir inspection
32
+ "ls", "cat", "head", "tail", "wc", "file", "stat", "find", "tree", "du", "df",
33
+ "readlink", "realpath", "basename", "dirname",
34
+ // Search
35
+ "grep", "rg", "ag", "ack", "fgrep", "egrep",
36
+ // Lookup
37
+ "which", "type", "whereis", "command", "hash",
38
+ // Environment / system info
39
+ "echo", "printf", "env", "printenv", "pwd", "whoami", "id", "groups",
40
+ "date", "uname", "hostname", "uptime", "arch", "nproc", "free",
41
+ "lsb_release", "sw_vers", "locale", "timedatectl",
42
+ // Text processing (stdin / stdout)
43
+ "jq", "yq", "sort", "uniq", "cut", "tr", "awk", "sed", "paste", "column",
44
+ "fold", "rev", "tac", "nl", "expand", "unexpand", "fmt", "pr", "csplit",
45
+ "comm", "join",
46
+ // Comparison / hashing
47
+ "diff", "cmp", "md5sum", "sha256sum", "sha1sum", "shasum", "cksum", "sum",
48
+ "b2sum", "base64", "xxd", "od", "hexdump",
49
+ // Misc read-only
50
+ "test", "true", "false", "seq", "yes", "sleep", "tee", "xargs", "time",
51
+ "man", "help", "info", "apropos", "cal", "bc", "expr", "factor",
52
+ // ACL / attribute inspection
53
+ "getfacl", "getfattr", "namei",
54
+ // Process / network introspection
55
+ "lsof", "ps", "top", "htop", "pgrep", "netstat", "ss", "ifconfig", "ip",
56
+ "dig", "nslookup", "host", "ping", "traceroute", "curl", "wget", "http",
57
+ ];
58
+
59
+ // binary -> read-only subcommands. Each generates `Bash(bin sub)` and
60
+ // `Bash(bin sub *)`. Multi-word keys (e.g. "config --get") are supported.
61
+ var SUBCOMMAND = {
62
+ git: [
63
+ "status", "log", "diff", "show", "branch", "tag", "remote",
64
+ "rev-parse", "ls-files", "blame", "describe", "config --get",
65
+ ],
66
+ npm: ["list", "ls", "view", "outdated", "config get"],
67
+ yarn: ["list"],
68
+ pnpm: ["list"],
69
+ };
70
+
71
+ // Full command strings safe verbatim. `*`-suffixed args are allowed after
72
+ // them (e.g. `node --version` plus any trailing flags) but the prefix must
73
+ // match exactly so `node server.js` does not slip through.
74
+ var EXACT = [
75
+ "node --version", "npm --version", "python --version",
76
+ "python3 --version", "go version", "ruby --version",
77
+ ];
78
+
79
+ var STANDALONE_SET = {};
80
+ for (var i = 0; i < STANDALONE.length; i++) STANDALONE_SET[STANDALONE[i]] = true;
81
+
82
+ // Strip leading env assignments (FOO=bar cmd) and an optional sudo prefix so
83
+ // the real command word is what we test. Returns the cleaned segment.
84
+ function stripLeadingNoise(seg) {
85
+ var out = seg.replace(/^(?:\w+=\S*\s+)*/, "");
86
+ if (/^sudo(?:\s|$)/.test(out)) {
87
+ out = out.replace(/^sudo\s+(?:-\S+\s+)*/, "");
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // True if a single shell segment (already split on &&, ||, ;, |) is safe to
93
+ // auto-approve. Empty segments (trailing operators) are treated as safe.
94
+ function isSafeBashSegment(seg) {
95
+ seg = (seg || "").trim();
96
+ if (!seg) return true;
97
+ var cleaned = stripLeadingNoise(seg);
98
+ var firstWord = cleaned.split(/\s+/)[0];
99
+ if (!firstWord) return false;
100
+ if (STANDALONE_SET[firstWord]) return true;
101
+ if (SUBCOMMAND[firstWord]) {
102
+ var subs = SUBCOMMAND[firstWord];
103
+ for (var i = 0; i < subs.length; i++) {
104
+ var prefix = firstWord + " " + subs[i];
105
+ if (cleaned === prefix || cleaned.indexOf(prefix + " ") === 0) return true;
106
+ }
107
+ return false;
108
+ }
109
+ for (var j = 0; j < EXACT.length; j++) {
110
+ if (cleaned === EXACT[j] || cleaned.indexOf(EXACT[j] + " ") === 0) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Emit the `Bash(...)` permission patterns for ~/.claude/settings.json. Each
116
+ // standalone command gets a bare form (zero-arg) and a space-wildcard form
117
+ // (claude 2.x prefix matching); subcommands and exact commands likewise.
118
+ function buildClayBashAllowPatterns() {
119
+ var patterns = [];
120
+ for (var i = 0; i < STANDALONE.length; i++) {
121
+ patterns.push("Bash(" + STANDALONE[i] + ")");
122
+ patterns.push("Bash(" + STANDALONE[i] + " *)");
123
+ }
124
+ var bins = Object.keys(SUBCOMMAND);
125
+ for (var b = 0; b < bins.length; b++) {
126
+ var subs = SUBCOMMAND[bins[b]];
127
+ for (var s = 0; s < subs.length; s++) {
128
+ var base = bins[b] + " " + subs[s];
129
+ patterns.push("Bash(" + base + ")");
130
+ patterns.push("Bash(" + base + " *)");
131
+ }
132
+ }
133
+ for (var e = 0; e < EXACT.length; e++) {
134
+ patterns.push("Bash(" + EXACT[e] + ")");
135
+ patterns.push("Bash(" + EXACT[e] + " *)");
136
+ }
137
+ return patterns;
138
+ }
139
+
140
+ var GENERATED_SET = {};
141
+ (function () {
142
+ var p = buildClayBashAllowPatterns();
143
+ for (var i = 0; i < p.length; i++) GENERATED_SET[p[i]] = true;
144
+ })();
145
+
146
+ // Recognize a `permissions.allow` entry that Clay owns, so the installer can
147
+ // strip stale variants on upgrade (including shapes a previous version wrote)
148
+ // WITHOUT clobbering user-authored patterns. Rules:
149
+ // - Standalone commands: own every shape of `Bash(ls ...)` (any args), since
150
+ // a user pattern for a command we manage is redundant with our broad one.
151
+ // - Subcommand binaries (git, npm, ...) and exact commands: only strip the
152
+ // precise patterns we generate, so a user's `Bash(git push)` survives.
153
+ // Legacy colon shapes are handled separately by the installer.
154
+ function isClayManagedBashPattern(p) {
155
+ if (typeof p !== "string") return false;
156
+ if (p.indexOf("Bash(") !== 0 || p.charAt(p.length - 1) !== ")") return false;
157
+ if (GENERATED_SET[p]) return true;
158
+ var inner = p.slice(5, -1).trim();
159
+ if (!inner) return false;
160
+ var word = inner.split(/[\s:]/)[0];
161
+ return STANDALONE_SET[word] === true;
162
+ }
163
+
164
+ module.exports = {
165
+ STANDALONE: STANDALONE,
166
+ SUBCOMMAND: SUBCOMMAND,
167
+ EXACT: EXACT,
168
+ isSafeBashSegment: isSafeBashSegment,
169
+ buildClayBashAllowPatterns: buildClayBashAllowPatterns,
170
+ isClayManagedBashPattern: isClayManagedBashPattern,
171
+ };