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.
- package/lib/claude-hook-installer.js +24 -76
- package/lib/cli-sessions.js +80 -45
- package/lib/project-connection.js +9 -1
- package/lib/project-sessions.js +132 -55
- package/lib/project.js +1 -0
- package/lib/public/css/filebrowser.css +2 -2
- package/lib/public/css/input.css +12 -0
- package/lib/public/css/menus.css +23 -0
- package/lib/public/css/sidebar.css +0 -85
- package/lib/public/css/tui-attention.css +41 -0
- package/lib/public/index.html +2 -1
- package/lib/public/modules/app-messages.js +4 -1
- package/lib/public/modules/input.js +43 -5
- package/lib/public/modules/session-tui-view.js +224 -10
- package/lib/public/modules/sidebar-mobile.js +2 -1
- package/lib/public/modules/sidebar-sessions.js +11 -83
- package/lib/public/modules/terminal-toolbar.js +129 -0
- package/lib/public/modules/terminal.js +57 -99
- package/lib/safe-bash-commands.js +171 -0
- package/lib/sdk-bridge.js +6 -51
- package/lib/sessions.js +1 -1
- package/lib/terminal-manager.js +44 -0
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -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:
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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"
|
|
309
|
+
getWs().send(JSON.stringify({ type: "new_session", vendor: "claude" }));
|
|
368
310
|
}
|
|
369
311
|
});
|
|
370
|
-
|
|
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">▲</button>' +
|
|
18
|
+
'<button class="term-key term-key-arrow" data-key="down">▼</button>' +
|
|
19
|
+
'<button class="term-key term-key-arrow" data-key="left">◀</button>' +
|
|
20
|
+
'<button class="term-key term-key-arrow" data-key="right">▶</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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|