@ninemind/agentgem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1465 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
5
+ <title>agentgem — Gem Builder</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com">
7
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ /* ── Lapidary Ledger — warm paper, terracotta seal, emerald = certified ── */
11
+ :root{
12
+ --paper:#f4efe3;--paper-2:#efe8d8;--card:#fbf8f1;
13
+ --ink:#211c15;--ink-2:#4a4133;--muted:#857a64;
14
+ --line:#ddd2bb;--line-2:#cfc3a8;
15
+ --accent:#9a3324;--accent-ink:#7d2a1e;--accent-soft:rgba(154,51,36,.08);
16
+ --gem:#1f6b4f;--gem-soft:rgba(31,107,79,.10);--gold:#b08436;
17
+ --r:8px;--shadow:0 1px 0 rgba(255,255,255,.55) inset,0 10px 28px -14px rgba(33,28,21,.34);
18
+ --display:"Fraunces",Georgia,serif;--ui:"Hanken Grotesk",system-ui,sans-serif;--mono:"JetBrains Mono",ui-monospace,monospace;
19
+ }
20
+ *{box-sizing:border-box}
21
+ body{
22
+ margin:0;font-family:var(--ui);font-size:14px;line-height:1.5;color:var(--ink);
23
+ background:radial-gradient(1200px 600px at 88% -12%,rgba(176,132,54,.07),transparent 60%),radial-gradient(900px 500px at -6% 112%,rgba(31,107,79,.06),transparent 55%),var(--paper);
24
+ background-attachment:fixed;-webkit-font-smoothing:antialiased;
25
+ display:flex;flex-direction:column;height:100vh;overflow:hidden;
26
+ }
27
+ body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.5;mix-blend-mode:multiply;
28
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E")}
29
+ /* ── header ── */
30
+ header{position:relative;z-index:1;padding:14px 24px;border-bottom:1px solid var(--line);display:flex;gap:14px;align-items:center;
31
+ background:linear-gradient(180deg,rgba(251,248,241,.7),rgba(251,248,241,0));backdrop-filter:blur(2px)}
32
+ header .mark{width:28px;height:28px;flex:none;filter:drop-shadow(0 2px 4px rgba(33,28,21,.18))}
33
+ header h1{font-family:var(--display);font-optical-sizing:auto;font-weight:600;font-size:22px;margin:0;letter-spacing:-.01em;line-height:1}
34
+ header .tag{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);border-left:1px solid var(--line-2);padding-left:11px}
35
+ header .muted{color:var(--muted);font-size:12px}
36
+ header code{font-family:var(--mono);font-size:11px;color:var(--accent-ink);background:var(--accent-soft);padding:1px 5px;border-radius:4px}
37
+ .spacer{flex:1}
38
+ .testbed-chip{display:flex;align-items:center;gap:9px;background:var(--card);border:1px solid var(--line);border-radius:99px;padding:6px 8px 6px 13px;box-shadow:var(--shadow);font-size:12.5px}
39
+ .testbed-chip .path{font-family:var(--mono);color:var(--ink-2);font-size:12px}
40
+ .testbed-chip .path b{color:var(--ink)}
41
+ .testbed-chip button{font:600 11px/1 var(--ui);padding:5px 10px;border-radius:99px}
42
+ main{position:relative;z-index:1;display:grid;grid-template-columns:1.04fr .96fr;gap:0;flex:1;min-height:0}
43
+ /* stage rail */
44
+ .rail{position:relative;z-index:1;flex:none;display:flex;align-items:stretch;padding:11px 26px 13px;border-bottom:1px solid var(--line);background:var(--paper-2);overflow:hidden}
45
+ .rail::after{content:"";position:absolute;left:62px;right:62px;top:calc(50% - 7px);height:2px;background:var(--line-2);z-index:0}
46
+ .station{position:relative;z-index:1;flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;cursor:pointer;background:transparent;border:0;font:inherit;color:inherit;padding:0}
47
+ .station .node{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;background:var(--card);border:1.5px solid var(--line-2);transition:.25s ease;transform:rotate(45deg);box-shadow:0 4px 10px -6px rgba(33,28,21,.4)}
48
+ .station .node svg{transform:rotate(-45deg);width:15px;height:15px;color:var(--muted);transition:.25s}
49
+ .station .label{font-family:var(--display);font-weight:600;font-size:12.5px;letter-spacing:.01em}
50
+ .station .sub{font-family:var(--mono);font-size:9px;color:var(--muted);letter-spacing:.03em;min-height:11px}
51
+ .station.done .node{background:var(--gem);border-color:var(--gem)} .station.done .node svg{color:#fff}
52
+ .station.active .node{background:var(--accent);border-color:var(--accent);transform:rotate(45deg) scale(1.1);box-shadow:0 0 0 5px var(--accent-soft),0 8px 18px -8px rgba(154,51,36,.7)}
53
+ .station.active .node svg{color:#fff} .station.active .label{color:var(--accent-ink)}
54
+ .station.todo .label,.station.todo .sub{color:var(--muted)}
55
+ .station:hover .node{transform:rotate(45deg) translateY(-2px)} .station.active:hover .node{transform:rotate(45deg) scale(1.1) translateY(-2px)}
56
+ .pane{padding:18px 22px;overflow:auto}.pane.left{border-right:1px solid var(--line)}
57
+ .pane::-webkit-scrollbar{width:10px}.pane::-webkit-scrollbar-thumb{background:var(--line-2);border-radius:99px;border:3px solid var(--paper)}
58
+ /* ── groups & rows ── */
59
+ .group{margin-bottom:18px}
60
+ .group h2{font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 8px;display:flex;align-items:center;gap:9px}
61
+ .group h2::after{content:"";flex:1;height:1px;background:var(--line)}
62
+ label.row{display:flex;gap:10px;align-items:flex-start;padding:8px 10px;cursor:pointer;border:1px solid transparent;border-radius:8px;transition:background .14s,border-color .14s;position:relative}
63
+ label.row:hover{background:var(--card);border-color:var(--line)}
64
+ label.row:has(input:checked){background:var(--card);border-color:var(--line)}
65
+ label.row:has(input:checked)::before{content:"";position:absolute;left:0;top:7px;bottom:7px;width:3px;border-radius:99px;background:var(--accent)}
66
+ label.row input[type=checkbox]{margin-top:2px;width:16px;height:16px;accent-color:var(--accent);cursor:pointer;flex:none}
67
+ label.row > span{flex:1;font-weight:600;font-size:13.5px}
68
+ label.row .d{color:var(--muted);font-size:12px;font-weight:400}
69
+ label.row .src,.src{font-family:var(--mono);font-size:10px;color:var(--accent);border:1px solid var(--line);border-radius:5px;padding:2px 6px;letter-spacing:.03em;cursor:pointer}
70
+ label.row .src:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
71
+ label.row .view{flex:none;font-family:var(--mono);font-size:10px;color:var(--muted);border:1px solid var(--line);background:transparent;border-radius:5px;padding:3px 7px;cursor:pointer;opacity:0;transition:.15s}
72
+ label.row:hover .view{opacity:1}
73
+ label.row .view:hover{color:#fff;background:var(--accent);border-color:var(--accent)}
74
+ /* ── controls ── */
75
+ select{padding:7px 10px;border:1px solid var(--line);border-radius:var(--r);font:600 13px/1 var(--ui);background:var(--card);color:var(--ink);max-width:48%;cursor:pointer}
76
+ .bar{display:flex;gap:8px;align-items:center;margin-bottom:12px}
77
+ .bar .chk{display:flex;gap:5px;align-items:center;cursor:pointer;font-size:13px;color:var(--ink)}
78
+ .bar .chk input{margin:0;accent-color:var(--accent)}
79
+ .bar strong{font-family:var(--display);font-weight:600}
80
+ input[type=text]{flex:1;padding:8px 11px;border:1px solid var(--line);border-radius:var(--r);font:inherit;background:var(--card);color:var(--ink)}
81
+ input[type=text]::placeholder{color:var(--muted)}
82
+ button{font:600 13px/1 var(--ui);padding:8px 13px;border:1px solid var(--accent);background:var(--accent);color:#fbf8f1;border-radius:var(--r);cursor:pointer;transition:.16s;box-shadow:0 6px 16px -11px rgba(154,51,36,.9)}
83
+ button:hover{transform:translateY(-1px);box-shadow:0 10px 22px -11px rgba(154,51,36,.95)}
84
+ button:active{transform:translateY(0)}
85
+ button.ghost{background:transparent;color:var(--accent);box-shadow:none}
86
+ button.ghost:hover{background:var(--accent-soft);transform:none}
87
+ :focus-visible{outline:2.5px solid var(--accent);outline-offset:2px;border-radius:6px}
88
+ pre{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:12px;font:12px/1.6 var(--mono);white-space:pre-wrap;word-break:break-word;color:var(--ink)}
89
+ .note{color:var(--muted);font-size:11.5px;margin-top:10px;line-height:1.55}
90
+ .note code{font-family:var(--mono);font-size:11px;color:var(--accent-ink)}
91
+ /* ── modal ── */
92
+ .modal-bg{position:fixed;inset:0;background:rgba(33,28,21,.5);display:flex;align-items:center;justify-content:center;z-index:10;padding:24px;backdrop-filter:blur(2px)}
93
+ .modal-bg[hidden]{display:none}
94
+ .modal{background:var(--card);border:1px solid var(--line-2);border-radius:12px;max-width:840px;width:100%;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 24px 60px -20px rgba(33,28,21,.55)}
95
+ .modal-h{display:flex;gap:8px;align-items:center;padding:13px 16px;border-bottom:1px solid var(--line);background:var(--paper-2);border-radius:12px 12px 0 0}
96
+ .modal-h .t{font-family:var(--display);font-weight:600;font-size:16px}
97
+ .modal-body{margin:0;border:0;border-radius:0 0 12px 12px;overflow:auto;flex:1;background:var(--card)}
98
+ .fm{display:block;background:var(--accent-soft);border:1px solid var(--line);border-radius:6px;padding:9px;margin-bottom:11px}
99
+ .fmfence{color:var(--muted)} .fmk{color:var(--accent);font-weight:600} .fmc{color:var(--muted)} .fmv{color:var(--ink)}
100
+ .mh{color:var(--accent-ink);font-weight:700;font-family:var(--display)} .mq{color:var(--muted);font-style:italic} .mfence{color:var(--muted)}
101
+ .mcode{color:var(--gem)} .mic{color:var(--gem)} .mb{font-weight:700} .mli{color:var(--accent);font-weight:600} .mlink{color:#1a5fb4}
102
+ .redacted{color:#fff;background:var(--accent);border-radius:3px;padding:0 4px;font-weight:600}
103
+ /* ── segmented control ── */
104
+ .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;margin-left:auto;background:var(--card)}
105
+ .exportwrap{position:relative;display:inline-block}
106
+ .exportmenu{position:absolute;right:0;top:calc(100% + 4px);min-width:190px;padding:4px;background:var(--card);border:1px solid var(--line-2);border-radius:8px;box-shadow:0 14px 34px -12px rgba(33,28,21,.5);z-index:30;display:flex;flex-direction:column}
107
+ .exportmenu[hidden]{display:none}
108
+ .exportmenu .menuitem{display:block;width:100%;text-align:left;border:0;background:transparent;padding:8px 10px;border-radius:6px;cursor:pointer;font:inherit;color:inherit}
109
+ .exportmenu .menuitem:hover{background:var(--line)}
110
+ .seg button{border:0;border-radius:0;background:transparent;color:var(--muted);padding:6px 11px;font:600 12px/1 var(--ui);box-shadow:none}
111
+ .seg button:hover{background:var(--accent-soft);color:var(--accent);transform:none}
112
+ .seg button.on{background:var(--accent);color:#fff}
113
+ /* ── right pane: the certificate ── */
114
+ .pane.right{background:linear-gradient(180deg,rgba(251,248,241,.4),transparent 30%)}
115
+ .pane.right > .bar:first-child strong{font-size:17px}
116
+ #preview{border:1px solid var(--line-2);border-radius:14px;background:linear-gradient(180deg,var(--card),var(--paper));box-shadow:var(--shadow);padding:18px 20px;position:relative;overflow:hidden}
117
+ #preview::before,#preview::after{content:"";position:absolute;width:46px;height:46px;border:1px solid var(--line-2);opacity:.6;pointer-events:none}
118
+ #preview::before{top:9px;left:9px;border-right:0;border-bottom:0;border-radius:7px 0 0 0}
119
+ #preview::after{bottom:9px;right:9px;border-left:0;border-top:0;border-radius:0 0 7px 0}
120
+ .psummary{display:flex;flex-direction:column;gap:15px}
121
+ .phead{font-family:var(--display);font-weight:600;font-size:16px}
122
+ /* certificate composition */
123
+ .cert-h{display:flex;flex-direction:column;align-items:center;text-align:center;padding:4px 0 14px;border-bottom:1px solid var(--line);margin-bottom:14px}
124
+ .cert-h .gemmark{width:52px;height:52px;margin-bottom:8px;filter:drop-shadow(0 3px 6px rgba(33,28,21,.22))}
125
+ .cert-h .kicker{font-family:var(--mono);font-size:9.5px;letter-spacing:.26em;text-transform:uppercase;color:var(--muted)}
126
+ .cert-h .gname{font-family:var(--display);font-weight:600;font-size:25px;letter-spacing:-.015em;margin:2px 0 1px}
127
+ .cert-h .from{font-family:var(--mono);font-size:10.5px;color:var(--muted)}
128
+ .gemmark .glint{animation:shimmer 3.6s ease-in-out infinite}
129
+ @keyframes shimmer{0%,100%{opacity:0}50%{opacity:.5}}
130
+ @media (prefers-reduced-motion:reduce){.gemmark .glint{animation:none}}
131
+ .seal{position:absolute;top:16px;right:18px;z-index:1;width:72px;height:72px;display:grid;place-items:center;text-align:center;border:2px solid var(--gem);border-radius:50%;color:var(--gem);transform:rotate(-11deg);background:var(--gem-soft);font-family:var(--display);font-weight:700;font-size:10px;letter-spacing:.1em;text-transform:uppercase;line-height:1.25;animation:stampin .5s cubic-bezier(.2,.8,.2,1) both}
132
+ .seal.draft{border-color:var(--line-2);color:var(--muted);background:transparent}
133
+ .seal small{display:block;font-family:var(--mono);font-weight:500;font-size:7px;letter-spacing:.04em;margin-top:2px}
134
+ @keyframes stampin{0%{opacity:0;transform:rotate(-11deg) scale(1.5)}60%{opacity:1;transform:rotate(-11deg) scale(.94)}100%{transform:rotate(-11deg) scale(1)}}
135
+ @media (prefers-reduced-motion:reduce){.seal{animation:none}}
136
+ .tally{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);border-radius:8px;overflow:hidden;margin-bottom:14px;background:var(--card)}
137
+ .tally .t{padding:11px 6px;text-align:center;border-right:1px solid var(--line)}
138
+ .tally .t:last-child{border-right:0}
139
+ .tally .t .n{font-family:var(--display);font-weight:600;font-size:22px;line-height:1}
140
+ .tally .t .l{font-family:var(--mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-top:4px}
141
+ .crow{display:flex;gap:10px;align-items:baseline;padding:8px 0;border-bottom:1px dashed var(--line)}
142
+ .crow .k{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);width:84px;flex:none}
143
+ .crow .v{flex:1;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
144
+ .chip{display:inline-flex;align-items:center;gap:5px;font-family:var(--mono);font-size:10.5px;padding:3px 8px;border-radius:99px;border:1px solid var(--line);color:var(--ink-2)}
145
+ .chip.secret{color:var(--accent);border-color:rgba(154,51,36,.3);background:var(--accent-soft)}
146
+ .chip.pass{color:var(--gem);border-color:rgba(31,107,79,.3);background:var(--gem-soft)}
147
+ .pgroup h3{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted);margin:0 0 6px}
148
+ .prow{display:flex;gap:8px;align-items:center;width:100%;text-align:left;border:0;background:transparent;padding:6px 8px;border-radius:7px;cursor:pointer;font:inherit;color:var(--ink)}
149
+ .prow:hover{background:var(--accent-soft)}
150
+ .prow .pn{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:48%}
151
+ .prow .pm{margin-left:auto;color:var(--muted);font-size:12px;white-space:nowrap;font-family:var(--mono)}
152
+ .redtag{color:var(--accent);font-size:11px}
153
+ pre.json{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:12px;font:12px/1.6 var(--mono);white-space:pre-wrap;word-break:break-word;margin:0}
154
+ /* ── filters ── */
155
+ #filterPanel{border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:12px;background:var(--accent-soft)}
156
+ #filterPanel[hidden]{display:none}
157
+ #filterPanel .bar{margin-bottom:8px}#filterPanel .bar:last-child{margin-bottom:0}
158
+ .flabel{width:54px;flex:none;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase}
159
+ #filtersToggle.on{background:var(--accent);color:#fff}
160
+ /* ── project chips ── */
161
+ .pchip{display:inline-flex;align-items:center;gap:3px;background:var(--card);border:1px solid var(--line);border-radius:99px;padding:3px 5px 3px 11px;font-size:12.5px}
162
+ .pchip .premove{border:0;background:transparent;color:var(--muted);padding:0 4px;cursor:pointer;font-size:13px;box-shadow:none}
163
+ .pchip .premove:hover{color:var(--accent);transform:none;background:transparent}
164
+ .wstab{font-family:var(--mono);font-size:11px}
165
+ /* ── entrance motion ── */
166
+ @keyframes rise{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:translateY(0)}}
167
+ #inventory .group{opacity:0;animation:rise .45s cubic-bezier(.2,.7,.2,1) forwards}
168
+ #inventory .group:nth-child(1){animation-delay:.04s}#inventory .group:nth-child(2){animation-delay:.09s}
169
+ #inventory .group:nth-child(3){animation-delay:.14s}#inventory .group:nth-child(4){animation-delay:.19s}
170
+ #inventory .group:nth-child(n+5){animation-delay:.24s}
171
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
172
+ .testdrive{margin-top:18px;border:1px solid var(--line);border-radius:var(--r);background:var(--card);overflow:hidden;box-shadow:var(--shadow)}
173
+ .testdrive .hd{display:flex;align-items:center;gap:9px;padding:11px 14px;border-bottom:1px solid var(--line);background:var(--paper-2);font-family:var(--display);font-weight:600}
174
+ .testdrive .pill{margin-left:auto;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--gem);background:var(--gem-soft);padding:3px 8px;border-radius:99px}
175
+ .testdrive .cmd{display:flex;align-items:center;gap:10px;padding:12px 14px;font-family:var(--mono);font-size:12.5px}
176
+ .testdrive .cmd .dollar{color:var(--accent);font-weight:600}.testdrive .cmd code{flex:1}
177
+ .testdrive .ft{padding:0 14px 12px;color:var(--muted);font-size:11.5px}
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <header>
182
+ <svg class="mark" viewBox="0 0 40 40" fill="none" aria-hidden="true">
183
+ <path d="M20 2 L36 14 L20 38 L4 14 Z" fill="#9a3324"/>
184
+ <path d="M20 2 L36 14 L20 16 Z" fill="#bb4a38"/>
185
+ <path d="M20 2 L4 14 L20 16 Z" fill="#7d2a1e"/>
186
+ <path d="M4 14 L20 16 L20 38 Z" fill="#8b2e21"/>
187
+ <path d="M36 14 L20 16 L20 38 Z" fill="#a8392a"/>
188
+ <path d="M4 14 L36 14 L20 16 Z" fill="#c8543f"/>
189
+ </svg>
190
+ <h1>agentgem</h1>
191
+ <span class="tag">Gem Builder</span>
192
+ <span class="spacer" style="flex:1"></span>
193
+ <span id="testbedChip" class="testbed-chip"></span>
194
+ </header>
195
+ <nav class="rail" id="rail">
196
+ <button class="station" data-stage="testbed"><span class="node"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v18M3 12h18"/></svg></span><span class="label">Testbed</span><span class="sub" data-sub="testbed"></span></button>
197
+ <button class="station" data-stage="package"><span class="node"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5"/></svg></span><span class="label">Package</span><span class="sub" data-sub="package"></span></button>
198
+ <button class="station" data-stage="workspace"><span class="node"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h18v12H3z"/><path d="M3 7l3-4h12l3 4"/></svg></span><span class="label">Workspace</span><span class="sub" data-sub="workspace"></span></button>
199
+ <button class="station" data-stage="target"><span class="node"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg></span><span class="label">Target</span><span class="sub" data-sub="target"></span></button>
200
+ <button class="station" data-stage="deploy"><span class="node"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg></span><span class="label">Deploy</span><span class="sub" data-sub="deploy"></span></button>
201
+ </nav>
202
+ <main>
203
+ <section class="pane left">
204
+ <div class="bar" id="nameBar" style="display:none"><input id="name" type="text" placeholder="gem name" value="gem" style="flex:1" /></div>
205
+ <div class="bar" id="wsBar" style="display:none">
206
+ <select id="wsSelect" title="open a saved workspace" style="flex:1"><option value="">— no workspace —</option></select>
207
+ <button id="wsNew" class="ghost" title="save the current selection as a new workspace">New workspace…</button>
208
+ <button id="wsDelete" class="ghost" title="delete the open workspace" hidden>Delete</button>
209
+ </div>
210
+ <div class="bar" id="wsTargets" hidden></div>
211
+ <div id="wsTree"></div>
212
+ <div id="runPanel" style="display:none;margin-top:8px">
213
+ <div class="bar">
214
+ <strong style="flex:1">Run eve</strong>
215
+ <button id="runLocal" class="ghost">▶ Run locally</button>
216
+ <button id="runVercel" class="ghost">▲ Deploy to Vercel</button>
217
+ <button id="runStop" class="ghost" style="display:none">■ Stop</button>
218
+ <span class="d" id="runState" style="margin-left:8px"></span>
219
+ </div>
220
+ <div id="runUrl" style="margin:4px 0"></div>
221
+ <pre id="runLog" style="max-height:200px;overflow:auto;background:#111;color:#ddd;padding:8px;font-size:12px"></pre>
222
+ </div>
223
+ <div id="acPanel" style="display:none;margin-top:8px">
224
+ <div class="bar">
225
+ <strong style="flex:1">Deploy to AWS (AgentCore)</strong>
226
+ <button id="acDeploy" class="btn gem ghost">▲ Deploy</button>
227
+ <span class="d" id="acState" style="margin-left:8px"></span>
228
+ </div>
229
+ <div id="acUrl" style="margin:4px 0"></div>
230
+ <pre id="acLog" style="max-height:200px;overflow:auto;background:#111;color:#ddd;padding:8px;font-size:12px"></pre>
231
+ </div>
232
+ <div class="bar"><input id="search" type="text" placeholder="search names, descriptions, contents…" style="flex:1" /><button id="filtersToggle" class="ghost">Filters ▾</button></div>
233
+ <div id="filterPanel" hidden>
234
+ <div class="bar"><span class="flabel d">Source</span><select id="srcFilter" style="flex:1"><option value="">All sources</option></select></div>
235
+ <div class="bar" id="agentBar"></div>
236
+ <div class="bar" id="typeBar"></div>
237
+ </div>
238
+ <div class="bar" id="selbar"><strong style="flex:1;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)">Selection</strong><button id="all" class="ghost">Select all shown</button><button id="none" class="ghost">Clear</button></div>
239
+ <div class="bar"><button id="importBtn" class="ghost">⬇ Import from machine…</button><span class="d" id="importStatus" style="margin-left:8px"></span></div>
240
+ <div id="inventory">Loading…</div>
241
+ <div id="testdrive" class="testdrive" hidden>
242
+ <div class="hd">▶ Test-drive this agent <span class="pill" id="tdPill">Claude Code</span></div>
243
+ <div class="cmd"><span class="dollar">$</span> <code id="tdCmd"></code> <button id="tdCopy" class="ghost">Copy</button></div>
244
+ <div class="ft">Runs your real harness in the testbed. Imported secrets are live there; the packaged gem stays redacted.</div>
245
+ </div>
246
+ </section>
247
+ <section class="pane right">
248
+ <div class="bar"><strong style="flex:1">Gem (live)</strong><select id="target" title="materialize target" style="margin-left:auto"><option value="claude">Claude</option><option value="codex">Codex</option><option value="agents">Agents</option><option value="hermes">Hermes</option><option value="eve">Eve</option><option value="flue">Flue</option><option value="openai-sandbox">OpenAI Sandbox</option><option value="agentcore">AgentCore</option></select><span class="seg" id="preview-modes"><button type="button" data-pmode="summary">Summary</button><button type="button" data-pmode="json">JSON</button><button type="button" data-pmode="materialize">Materialize</button><button type="button" data-pmode="managed">Managed Agents</button></span><span class="exportwrap"><button id="exportBtn">Export ▾</button><div id="exportMenu" class="exportmenu" hidden><button id="dltar" class="menuitem" title="download the gem archive as a .tar.gz">⬇ Download .tar.gz</button><button id="save" class="menuitem" title="write the gem archive (manifest + lock + files) to a folder">💾 Save to folder…</button><button id="dl" class="menuitem">⬇ Download JSON</button><button id="copy" class="menuitem">⧉ Copy JSON</button></div></span><span class="d" id="archiveStatus" style="margin-left:6px"></span></div>
249
+ <div id="preview"></div>
250
+ <p class="note">MCP server &amp; hook config secrets are shown as <code>&lt;redacted&gt;</code> and never exported. Skill, CLAUDE.md, rules bodies &amp; hook commands are bundled as written — don't keep secrets in them.</p>
251
+ <div id="checksPanel" class="group" style="margin-top:14px">
252
+ <div class="bar"><strong style="flex:1">Checks</strong><button id="suggestChecks" class="ghost">Suggest checks</button></div>
253
+ <div id="checksList"></div>
254
+ </div>
255
+ </section>
256
+ </main>
257
+ <div id="modal" class="modal-bg" hidden>
258
+ <div class="modal">
259
+ <div class="modal-h"><strong class="t" id="modal-title"></strong><span class="src" id="modal-sub"></span><span class="seg" id="modal-modes"><button type="button" data-mode="md">Markdown</button><button type="button" data-mode="raw">Raw</button></span><button id="modal-x" class="ghost" style="margin-left:8px">✕ Close</button></div>
260
+ <pre class="modal-body" id="modal-body"></pre>
261
+ </div>
262
+ </div>
263
+ <div id="importModal" class="modal-bg" hidden>
264
+ <div class="modal">
265
+ <div class="modal-h"><strong class="t">Import from machine</strong><span class="src">~/.claude · ~/.agents · ~/.codex · ~/.hermes</span><button id="importClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
266
+ <div class="modal-body" style="padding:14px"><div id="importInventory">Loading…</div></div>
267
+ <div class="modal-h" style="border-top:1px solid var(--line);border-bottom:0">
268
+ <span class="d">Writes real secrets into this testbed so it runs — don't commit <code>.mcp.json</code>/<code>settings.json</code>.</span>
269
+ <button id="importApply" style="margin-left:auto">Add to testbed</button>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ <div id="recentModal" class="modal-bg" hidden>
274
+ <div class="modal">
275
+ <div class="modal-h"><strong class="t">Open a testbed</strong><button id="recentClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
276
+ <div class="modal-body" style="padding:14px">
277
+ <div id="tbCwd" hidden style="padding:12px;border:1px solid var(--line);border-radius:8px;margin-bottom:14px">
278
+ <div style="margin-bottom:6px">This folder looks like a <span id="tbCwdFlavor"></span> project</div>
279
+ <div class="d" id="tbCwdPath" style="margin-bottom:8px;word-break:break-all"></div>
280
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
281
+ <label class="d">name</label>
282
+ <input id="tbName" style="flex:1;min-width:120px" />
283
+ <span id="tbFlavor" style="display:flex;gap:4px"></span>
284
+ <button id="tbUse" style="margin-left:auto">Use this ▸</button>
285
+ </div>
286
+ </div>
287
+ <div class="src" style="margin-bottom:6px">Recent <span class="d">(testbeds you've opened here)</span></div>
288
+ <div id="recentList">Loading…</div>
289
+ <div class="src" style="margin:14px 0 6px">Discovered <span class="d">(projects from your Claude / Codex history)</span></div>
290
+ <div id="discoveredList">Loading…</div>
291
+ </div>
292
+ <div class="modal-h" style="border-top:1px solid var(--line);border-bottom:0">
293
+ <span class="d">Not this folder? Pick a recent one above, or browse.</span>
294
+ <button id="recentBrowse" style="margin-left:auto">Browse folder…</button>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ <script>
299
+ let inv = { skills: [], mcpServers: [], instructions: [], projects: [] };
300
+ let projects = []; // chosen project root paths (mirrors inv.projects[].root after each load)
301
+ let activeTestbed = localStorage.getItem("agentgem.testbed") || null;
302
+ const FLAVORS = {
303
+ claude: { label: "Claude Code", run: "claude", importSupported: true },
304
+ codex: { label: "Codex", run: "codex", importSupported: true },
305
+ hermes: { label: "Hermes", run: "hermes", importSupported: true },
306
+ };
307
+ let activeFlavor = localStorage.getItem("agentgem.testbedFlavor") || "claude";
308
+ function setFlavor(id){ activeFlavor = (id in FLAVORS) ? id : "claude"; localStorage.setItem("agentgem.testbedFlavor", activeFlavor); }
309
+ function setTestbed(path){ activeTestbed = path || null; if(path) localStorage.setItem("agentgem.testbed", path); else localStorage.removeItem("agentgem.testbed"); renderTestbedChip(); }
310
+ // The gem defaults to its project's name. Track manual edits so we never clobber a name the user typed.
311
+ let nameEdited = false;
312
+ function syncGemName(){
313
+ if(nameEdited) return;
314
+ const el = document.getElementById("name");
315
+ if(el) el.value = activeTestbed ? activeTestbed.replace(/^.*\//, "") : "gem";
316
+ }
317
+ function renderTestbedChip(){
318
+ const el = document.getElementById("testbedChip");
319
+ if(!activeTestbed){ el.innerHTML = `<button id="tbNew">Create / open testbed…</button>`; document.getElementById("tbNew").onclick = openOrCreateTestbed; return; }
320
+ const short = activeTestbed.replace(/^.*\//, "");
321
+ const flavorLabel = (FLAVORS[activeFlavor] || FLAVORS.claude).label;
322
+ el.innerHTML = `<span class="path">📁 <b>${esc(short)}</b> <span style="color:var(--muted);font-size:11px">${esc(flavorLabel)}</span></span><button id="tbSwap" class="ghost">Switch</button>`;
323
+ document.getElementById("tbSwap").onclick = openOrCreateTestbed;
324
+ }
325
+ // Front door: confirm the cwd suggestion up top, with recents + Browse below.
326
+ let candidate = null; // { path, flavor } currently shown in the top block
327
+
328
+ async function openOrCreateTestbed(){
329
+ document.getElementById("recentModal").hidden = false;
330
+ try {
331
+ const s = await (await fetch("/api/testbed/suggestion")).json();
332
+ if (s.looksLikeProject) renderCandidate(s.cwd, s.flavor, s.name);
333
+ else renderCandidate(null);
334
+ } catch { renderCandidate(null); }
335
+ await renderRecents();
336
+ renderDiscovered();
337
+ }
338
+
339
+ // Fill (or hide) the top "use this folder" block. flavor null -> toggle starts unset.
340
+ function renderCandidate(path, flavor, name){
341
+ const box = document.getElementById("tbCwd");
342
+ if(!path){ box.hidden = true; candidate = null; return; }
343
+ box.hidden = false;
344
+ candidate = { path, flavor: flavor || null };
345
+ document.getElementById("tbCwdPath").textContent = path;
346
+ document.getElementById("tbName").value = name || path.replace(/^.*\//, "");
347
+ renderFlavorToggle();
348
+ }
349
+
350
+ function renderFlavorToggle(){
351
+ document.getElementById("tbCwdFlavor").textContent = candidate.flavor ? (FLAVORS[candidate.flavor]||{}).label || candidate.flavor : "—";
352
+ const el = document.getElementById("tbFlavor");
353
+ el.innerHTML = Object.entries(FLAVORS).map(([id,f])=>
354
+ `<button type="button" class="ghost" data-f="${id}" aria-pressed="${candidate.flavor===id}"${candidate.flavor===id?' style="font-weight:700;text-decoration:underline"':''}>${esc(f.label)}</button>`
355
+ ).join("");
356
+ el.querySelectorAll("button").forEach(b=>{ b.onclick = ()=>{ candidate.flavor = b.dataset.f; renderFlavorToggle(); }; });
357
+ document.getElementById("tbUse").disabled = !candidate.flavor;
358
+ }
359
+
360
+ async function confirmCandidate(){
361
+ if(!candidate || !candidate.flavor) return;
362
+ const name = document.getElementById("tbName").value.trim() || candidate.path.replace(/^.*\//, "");
363
+ document.getElementById("recentModal").hidden = true;
364
+ await useTestbed(candidate.path, candidate.flavor, name);
365
+ }
366
+
367
+ let recentPaths = new Set(); // paths already in agentgem recents — dedup the Discovered list against these
368
+ async function renderRecents(){
369
+ const list = document.getElementById("recentList");
370
+ list.innerHTML = "Loading…";
371
+ let recents = [];
372
+ try { recents = (await (await fetch("/api/testbed/recents")).json()).recents || []; } catch {}
373
+ recentPaths = new Set(recents.map(r=>r.path));
374
+ if(!recents.length){
375
+ list.innerHTML = `<p class="note">No recent testbeds yet — confirm the folder above, or <b>Browse folder…</b>.</p>`;
376
+ return;
377
+ }
378
+ list.innerHTML = recents.map((p,i)=>{
379
+ const short = esc(p.name || p.path.replace(/^.*\//, "") || p.path);
380
+ const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
381
+ const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
382
+ const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
383
+ return `<label class="row recent" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span></label>`;
384
+ }).join("");
385
+ list.querySelectorAll("label.recent").forEach(row=>{
386
+ row.onclick = ()=>{ const p = recents[+row.dataset.i]; if(!p.exists) return; document.getElementById("recentModal").hidden = true; useTestbed(p.path, p.flavor, p.name); };
387
+ });
388
+ }
389
+
390
+ // Cross-repo projects from Claude/Codex session history, minus anything already in Recent.
391
+ async function renderDiscovered(){
392
+ const list = document.getElementById("discoveredList");
393
+ list.innerHTML = "Loading…";
394
+ let projects = [];
395
+ try { projects = (await (await fetch("/api/testbed/projects")).json()).projects || []; } catch {}
396
+ projects = projects.filter(p=>!recentPaths.has(p.path));
397
+ if(!projects.length){
398
+ list.innerHTML = `<p class="note">No projects found in your Claude / Codex history.</p>`;
399
+ return;
400
+ }
401
+ list.innerHTML = projects.map((p,i)=>{
402
+ const short = esc(p.path.replace(/^.*\//, "") || p.path);
403
+ const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
404
+ const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
405
+ const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
406
+ return `<label class="row disc" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span></label>`;
407
+ }).join("");
408
+ list.querySelectorAll("label.disc").forEach(row=>{
409
+ row.onclick = ()=>{ const p = projects[+row.dataset.i]; if(!p.exists) return; document.getElementById("recentModal").hidden = true; useTestbed(p.path, p.flavor, p.path.replace(/^.*\//, "")); };
410
+ });
411
+ }
412
+
413
+ // Adopt a known project+flavor: scaffold is idempotent (writeIfAbsent) and records a recent.
414
+ async function useTestbed(path, flavor, name){
415
+ setFlavor(flavor);
416
+ name = name || path.replace(/^.*\//, "");
417
+ await fetch("/api/testbed/scaffold", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ root: path, name, flavor }) });
418
+ setTestbed(path);
419
+ nameEdited = false; // picking a project is explicit — default the gem name to it
420
+ document.getElementById("name").value = name;
421
+ load();
422
+ }
423
+
424
+ // Browse routes the picked folder back through the same confirm block (no prompts).
425
+ async function browseForTestbed(){
426
+ const pick = await (await fetch("/api/pick-folder")).json();
427
+ if(!pick.path) return;
428
+ const flavor = (await (await fetch(`/api/testbed/detect?root=${encodeURIComponent(pick.path)}`)).json()).flavor;
429
+ renderCandidate(pick.path, flavor, pick.path.replace(/^.*\//, ""));
430
+ }
431
+ const sel = { skills: new Set(), mcpServers: new Set(), includeInstructions: false, hooks: new Set(), projects: {} };
432
+ function esc(s){return String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]))}
433
+ // Map a fine-grained source to the coding agent it belongs to.
434
+ const AGENT_LABEL = { claude: "Claude", codex: "Codex", agents: "Agents", hermes: "Hermes", project: "Project" };
435
+ function agentOf(source){ return source === "project" ? "project" : source === "agent" ? "agents" : source === "codex" ? "codex" : source === "hermes" ? "hermes" : "claude"; }
436
+ // Instruction artifacts carry no source; map by filename to an agent.
437
+ function instrAgentOf(name){ return name === "SOUL.md" ? "hermes" : name.startsWith("codex:") ? "codex" : "claude"; }
438
+ // Artifact type per row kind (global + project kinds collapse to one type).
439
+ const TYPE_LABEL = { skill: "Skills", mcp: "MCP", instructions: "Instructions", hook: "Hooks" };
440
+ function typeOfKind(kind){
441
+ if (kind === "skills" || kind === "projectSkills") return "skill";
442
+ if (kind === "mcpServers" || kind === "projectMcpServers") return "mcp";
443
+ if (kind === "instructions" || kind === "projectInstructions") return "instructions";
444
+ if (kind === "hooks" || kind === "projectHooks") return "hook";
445
+ return "";
446
+ }
447
+ function group(title, items, kind, projectRoot) {
448
+ if (!items.length && kind !== "instructions") return "";
449
+ const pa = projectRoot ? ` data-project="${esc(projectRoot)}"` : "";
450
+ const ty = typeOfKind(kind);
451
+ const rows = items.map(it => {
452
+ const meta = it.description || it.transport || "";
453
+ const src = it.source ? ` <span class="src" data-src="${esc(it.source)}" title="filter by ${esc(it.source)}">${esc(it.source)}</span>` : "";
454
+ return `<label class="row" data-source="${esc(it.source || "")}" data-agent="${agentOf(it.source || "")}" data-type="${ty}"${pa}><input type="checkbox" data-kind="${kind}" data-name="${esc(it.name)}"${pa}> <span>${esc(it.name)}${src} ${meta ? `<span class="d">— ${esc(meta)}</span>` : ""}</span><button type="button" class="view" data-kind="${kind}" data-name="${esc(it.name)}"${pa} title="view content">view</button></label>`;
455
+ }).join("");
456
+ return `<div class="group" data-group="${kind}"><h2>${title} (${items.length})</h2>${rows}</div>`;
457
+ }
458
+ async function load() {
459
+ renderTestbedChip();
460
+ if (!activeTestbed) {
461
+ document.getElementById("inventory").innerHTML =
462
+ `<div class="group"><h2>No testbed</h2><p class="note">Create or open a testbed project to author and test-drive an agent, then package it into a gem. Use <b>Create / open testbed…</b> in the top bar.</p></div>`;
463
+ return;
464
+ }
465
+ projects = [activeTestbed];
466
+ const qs = `?projects=${encodeURIComponent(JSON.stringify(projects))}`;
467
+ inv = await (await fetch("/api/inventory" + qs)).json();
468
+ inv.projects = inv.projects || [];
469
+ // Render ONLY the active testbed's project groups (global groups are reached via Import).
470
+ const proj = inv.projects.find(p => p.root === activeTestbed) || { root: activeTestbed, name: activeTestbed.replace(/^.*\//, ""), skills: [], mcpServers: [], instructions: [], hooks: [] };
471
+ let html = group("Skills", proj.skills, "projectSkills", proj.root)
472
+ + group("MCP servers", proj.mcpServers, "projectMcpServers", proj.root)
473
+ + group("Hooks", proj.hooks, "projectHooks", proj.root);
474
+ if (proj.instructions.length) {
475
+ const pil = esc(proj.instructions.map(x => x.name).join(", "));
476
+ html += `<div class="group"><h2>Instructions</h2><label class="row" data-source="project" data-agent="project" data-type="instructions" data-project="${esc(proj.root)}"><input type="checkbox" data-kind="projectInstructions" data-project="${esc(proj.root)}"> <span><span class="src">project</span> ${pil}</span><button type="button" class="view" data-kind="projectInstructions" data-name="" data-project="${esc(proj.root)}" title="view content">view</button></label></div>`;
477
+ }
478
+ document.getElementById("inventory").innerHTML = html;
479
+ document.querySelectorAll("#inventory label.row").forEach(row => {
480
+ const cb = row.querySelector("input[type=checkbox]");
481
+ row._hay = hayForRow(cb && cb.dataset.kind, cb && cb.dataset.name, cb && cb.dataset.project).toLowerCase();
482
+ });
483
+ populateSources(); populateAgents(); populateTypes();
484
+ document.querySelectorAll('#inventory input[type=checkbox]').forEach(cb => cb.addEventListener("change", onToggle));
485
+ restoreChecks();
486
+ filterRows(); // apply any active filters to the freshly rendered rows
487
+ refresh();
488
+ renderTestDrive();
489
+ renderRail();
490
+ }
491
+ function hayForRow(kind, name, projRoot) {
492
+ if (kind === "skills") { const a = inv.skills.find(s => s.name === name); return a ? `${a.name} ${a.description || ""} ${a.source || ""} ${a.content || ""}` : ""; }
493
+ if (kind === "mcpServers") { const a = inv.mcpServers.find(s => s.name === name); return a ? `${a.name} ${a.transport || ""} ${a.source || ""} ${JSON.stringify(a.config || {})}` : ""; }
494
+ if (kind === "instructions") return `instructions ${inv.instructions.map(i => `${i.name} ${i.content}`).join(" ")}`;
495
+ if (kind === "projectSkills") { const a = projOf(projRoot)?.skills.find(s => s.name === name); return a ? `${a.name} ${a.description || ""} project ${a.content || ""}` : ""; }
496
+ if (kind === "projectMcpServers") { const a = projOf(projRoot)?.mcpServers.find(s => s.name === name); return a ? `${a.name} ${a.transport || ""} project ${JSON.stringify(a.config || {})}` : ""; }
497
+ if (kind === "projectInstructions") { const p = projOf(projRoot); return `project instructions ${(p ? p.instructions : []).map(i => `${i.name} ${i.content}`).join(" ")}`; }
498
+ if (kind === "hooks") { const a = inv.hooks.find(h => h.name === name); return a ? `${a.name} ${a.event} ${a.matcher || ""} ${a.source || ""} ${JSON.stringify(a.config || {})}` : ""; }
499
+ if (kind === "projectHooks") { const a = projOf(projRoot)?.hooks.find(h => h.name === name); return a ? `${a.name} ${a.event} ${a.matcher || ""} project ${JSON.stringify(a.config || {})}` : ""; }
500
+ return "";
501
+ }
502
+ function restoreChecks() {
503
+ document.querySelectorAll('#inventory input[type=checkbox]').forEach(cb => {
504
+ const { kind, name, project } = cb.dataset;
505
+ const ps = project ? sel.projects[project] : null;
506
+ if (kind === "instructions") cb.checked = sel.includeInstructions;
507
+ else if (kind === "projectInstructions") cb.checked = !!(ps && ps.includeInstructions);
508
+ else if (kind === "projectSkills") cb.checked = !!(ps && ps.skills.has(name));
509
+ else if (kind === "projectMcpServers") cb.checked = !!(ps && ps.mcpServers.has(name));
510
+ else if (kind === "projectHooks") cb.checked = !!(ps && ps.hooks.has(name));
511
+ else if (sel[kind]) cb.checked = sel[kind].has(name);
512
+ });
513
+ }
514
+ function populateAgents() {
515
+ const present = new Set();
516
+ inv.skills.forEach(s => present.add(agentOf(s.source)));
517
+ inv.mcpServers.forEach(m => present.add(agentOf(m.source)));
518
+ inv.instructions.forEach(i => present.add(instrAgentOf(i.name)));
519
+ inv.hooks.forEach(h => present.add(agentOf(h.source)));
520
+ if ((inv.projects || []).some(p => p.skills.length || p.mcpServers.length || p.instructions.length || p.hooks.length)) present.add("project");
521
+ const types = ["claude", "codex", "agents", "hermes", "project"].filter(a => present.has(a));
522
+ const el = document.getElementById("agentBar");
523
+ if (types.length < 2) { el.style.display = "none"; el.innerHTML = ""; return; } // nothing to choose between
524
+ el.style.display = "";
525
+ el.innerHTML = `<span class="d">Agent:</span>` + types.map(a =>
526
+ `<label class="chk"><input type="checkbox" class="agentChk" value="${a}" checked> ${AGENT_LABEL[a]}</label>`).join("");
527
+ el.querySelectorAll(".agentChk").forEach(cb => cb.addEventListener("change", filterRows));
528
+ }
529
+ function populateTypes() {
530
+ const projs = inv.projects || [];
531
+ const present = new Set();
532
+ if (inv.skills.length || projs.some(p => p.skills.length)) present.add("skill");
533
+ if (inv.mcpServers.length || projs.some(p => p.mcpServers.length)) present.add("mcp");
534
+ if (inv.instructions.length || projs.some(p => p.instructions.length)) present.add("instructions");
535
+ if (inv.hooks.length || projs.some(p => p.hooks.length)) present.add("hook");
536
+ const types = ["skill", "mcp", "instructions", "hook"].filter(t => present.has(t));
537
+ const el = document.getElementById("typeBar");
538
+ if (types.length < 2) { el.style.display = "none"; el.innerHTML = ""; return; }
539
+ el.style.display = "";
540
+ el.innerHTML = `<span class="d">Type:</span>` + types.map(t =>
541
+ `<label class="chk"><input type="checkbox" class="typeChk" value="${t}" checked> ${TYPE_LABEL[t]}</label>`).join("");
542
+ el.querySelectorAll(".typeChk").forEach(cb => cb.addEventListener("change", filterRows));
543
+ }
544
+ function populateSources() {
545
+ const counts = {};
546
+ const all = [...inv.skills, ...inv.mcpServers, ...inv.hooks, ...(inv.projects || []).flatMap(p => [...p.skills, ...p.mcpServers, ...p.hooks])];
547
+ all.forEach(it => { if (it.source) counts[it.source] = (counts[it.source] || 0) + 1; });
548
+ const rank = s => s === "standalone" ? 0 : s === "user" ? 1 : s === "agent" ? 2 : s === "project" ? 4 : 3;
549
+ const order = Object.keys(counts).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
550
+ document.getElementById("srcFilter").innerHTML =
551
+ `<option value="">All sources</option>` + order.map(s => `<option value="${esc(s)}">${esc(s)} (${counts[s]})</option>`).join("");
552
+ }
553
+ function onToggle(e){
554
+ const { kind, name, project } = e.target.dataset;
555
+ if (kind === "instructions") sel.includeInstructions = e.target.checked;
556
+ else if (kind === "projectInstructions") projSel(project).includeInstructions = e.target.checked;
557
+ else if (kind === "projectSkills") { const s = projSel(project).skills; e.target.checked ? s.add(name) : s.delete(name); }
558
+ else if (kind === "projectMcpServers") { const s = projSel(project).mcpServers; e.target.checked ? s.add(name) : s.delete(name); }
559
+ else if (kind === "projectHooks") { const s = projSel(project).hooks; e.target.checked ? s.add(name) : s.delete(name); }
560
+ else { e.target.checked ? sel[kind].add(name) : sel[kind].delete(name); }
561
+ refresh();
562
+ }
563
+ let t; function refresh(){ clearTimeout(t); t = setTimeout(build, 120); }
564
+ function filterRows() {
565
+ const q = (document.getElementById("search").value || "").trim().toLowerCase();
566
+ const tokens = q ? q.split(/\s+/) : [];
567
+ const src = document.getElementById("srcFilter").value;
568
+ const agentBoxes = [...document.querySelectorAll(".agentChk")];
569
+ const agents = new Set(agentBoxes.filter(c => c.checked).map(c => c.value));
570
+ const agentFilterActive = agentBoxes.length > 0 && agentBoxes.some(c => !c.checked);
571
+ const typeBoxes = [...document.querySelectorAll(".typeChk")];
572
+ const types = new Set(typeBoxes.filter(c => c.checked).map(c => c.value));
573
+ const typeFilterActive = typeBoxes.length > 0 && typeBoxes.some(c => !c.checked);
574
+ document.querySelectorAll("#inventory label.row").forEach(row => {
575
+ const hay = row._hay || "";
576
+ const matchQ = tokens.every(t => hay.includes(t)); // order-independent AND; [] => true
577
+ const matchSrc = !src || (row.getAttribute("data-source") || "") === src;
578
+ const rowAgents = (row.getAttribute("data-agent") || "").split(" ").filter(Boolean);
579
+ const matchAgent = agentBoxes.length === 0 || rowAgents.length === 0 || rowAgents.some(a => agents.has(a));
580
+ const matchType = typeBoxes.length === 0 || types.has(row.getAttribute("data-type"));
581
+ row.style.display = matchQ && matchSrc && matchAgent && matchType ? "" : "none";
582
+ });
583
+ const active = q || src || agentFilterActive || typeFilterActive;
584
+ document.querySelectorAll("#inventory .group").forEach(g => {
585
+ const all = g.querySelectorAll("label.row");
586
+ const shown = [...all].filter(r => r.style.display !== "none").length;
587
+ const h2 = g.querySelector("h2");
588
+ if (h2 && !h2.dataset.base) h2.dataset.base = h2.textContent.replace(/\s*—\s*showing.*$/, "");
589
+ if (h2) h2.textContent = active ? `${h2.dataset.base} — showing ${shown}` : h2.dataset.base;
590
+ });
591
+ updateFilterBadge();
592
+ }
593
+ let currentChecks = [];
594
+ // Assemble the selection request body, pruning stale names to what's currently in the inventory.
595
+ // (A stale checkbox after a project removal/reload would make buildGem throw -> opaque 500.)
596
+ function buildSelectionBody(){
597
+ const has = (arr, n) => arr.some(x => x.name === n);
598
+ const skills = [...sel.skills].filter(n => has(inv.skills, n));
599
+ const mcpServers = [...sel.mcpServers].filter(n => has(inv.mcpServers, n));
600
+ const hooks = [...sel.hooks].filter(n => has(inv.hooks, n));
601
+ const projectsSel = {};
602
+ for (const p of inv.projects || []) {
603
+ const ps = sel.projects[p.root];
604
+ if (!ps) continue;
605
+ const o = {};
606
+ const s = [...ps.skills].filter(n => has(p.skills, n));
607
+ const m = [...ps.mcpServers].filter(n => has(p.mcpServers, n));
608
+ const hk = [...ps.hooks].filter(n => has(p.hooks, n));
609
+ if (s.length) o.skills = s;
610
+ if (m.length) o.mcpServers = m;
611
+ if (ps.includeInstructions && p.instructions.length) o.includeInstructions = true;
612
+ if (hk.length) o.hooks = hk;
613
+ if (Object.keys(o).length) projectsSel[p.root] = o;
614
+ }
615
+ const selection = { skills, mcpServers, includeInstructions: sel.includeInstructions };
616
+ if (hooks.length) selection.hooks = hooks;
617
+ if (Object.keys(projectsSel).length) selection.projects = projectsSel;
618
+ const reqBody = { selection, name: document.getElementById("name").value || "gem" };
619
+ if (projects.length) reqBody.projects = projects;
620
+ return reqBody;
621
+ }
622
+ async function build(){
623
+ const reqBody = buildSelectionBody();
624
+ reqBody.checks = currentChecks;
625
+ const gem = await (await fetch("/api/gem", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(reqBody) })).json();
626
+ window.__gem = gem;
627
+ renderPreview();
628
+ }
629
+ // Each check renders as an editable JSON textarea (operator refines task/assertions/threshold).
630
+ function renderChecks(){
631
+ const el = document.getElementById("checksList");
632
+ if (!currentChecks.length){ el.innerHTML = `<p class="d">No checks. Click "Suggest checks" to scaffold a behavioral + security draft, then edit.</p>`; return; }
633
+ el.innerHTML = currentChecks.map((c, i) =>
634
+ `<div class="group"><h2>${esc(c.kind)} · ${esc(c.name)}</h2><textarea data-ci="${i}" style="width:100%;min-height:120px;font:12px/1.5 ui-monospace,monospace;border:1px solid var(--line);border-radius:6px;padding:8px">${esc(JSON.stringify(c, null, 2))}</textarea></div>`).join("");
635
+ }
636
+ document.getElementById("checksList").addEventListener("change", e => {
637
+ const ta = e.target.closest("textarea[data-ci]"); if (!ta) return;
638
+ try { currentChecks[+ta.dataset.ci] = JSON.parse(ta.value); ta.style.borderColor = ""; build(); }
639
+ catch { ta.style.borderColor = "var(--accent)"; } // invalid JSON: flag it, keep last good value
640
+ });
641
+ document.getElementById("suggestChecks").addEventListener("click", async () => {
642
+ const r = await (await fetch("/api/scaffold-checks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(buildSelectionBody()) })).json();
643
+ currentChecks = Array.isArray(r.checks) ? r.checks : [];
644
+ renderChecks(); build();
645
+ });
646
+ let previewMode = "summary";
647
+ function fmtSize(n){ return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`; }
648
+ function artSize(a){ return (a.type === "mcp_server" || a.type === "hook") ? JSON.stringify(a.config || {}).length : (a.content || "").length; }
649
+ const GEM_MARK_SVG = `<svg class="gemmark" viewBox="0 0 64 64" fill="none" aria-hidden="true"><path d="M32 4 58 24 32 60 6 24 Z" fill="#9a3324"/><path d="M32 4 58 24 32 26 Z" fill="#bb4a38"/><path d="M32 4 6 24 32 26 Z" fill="#7d2a1e"/><path d="M6 24 32 26 32 60 Z" fill="#8b2e21"/><path d="M58 24 32 26 32 60 Z" fill="#a8392a"/><path d="M6 24 58 24 32 26 Z" fill="#c8543f"/><path class="glint" d="M32 4 58 24 32 26 Z" fill="#ffd9a0"/></svg>`;
650
+ // Render the live gem as a "certificate of authenticity": faceted mark, name, artifact tally,
651
+ // the secret/check trust rows, a seal, then the per-artifact list (clickable to view).
652
+ function summaryHtml(p){
653
+ const arts = p.artifacts || [];
654
+ if (!arts.length) return `<p class="d">Empty gem — select artifacts on the left to add them.</p>`;
655
+ const total = arts.reduce((s, a) => s + artSize(a), 0);
656
+ const count = (t) => arts.filter(a => a.type === t).length;
657
+ const secrets = p.requiredSecrets || [];
658
+ const checks = p.checks || [];
659
+ const seal = checks.length
660
+ ? `<div class="seal" title="${checks.length} check${checks.length === 1 ? "" : "s"} declared">Checks<small>${checks.length} declared</small></div>`
661
+ : `<div class="seal draft" title="no checks declared">Draft<small>no checks</small></div>`;
662
+ let h = `<div class="psummary">${seal}`;
663
+ h += `<div class="cert-h">${GEM_MARK_SVG}<span class="kicker">Certificate of Authenticity</span><span class="gname">${esc(p.name)}</span><span class="from">${esc(p.createdFrom || "")} · ${fmtSize(total)}</span></div>`;
664
+ h += `<div class="tally">`
665
+ + `<div class="t"><div class="n">${count("skill")}</div><div class="l">Skills</div></div>`
666
+ + `<div class="t"><div class="n">${count("mcp_server")}</div><div class="l">MCP</div></div>`
667
+ + `<div class="t"><div class="n">${count("hook")}</div><div class="l">Hooks</div></div>`
668
+ + `<div class="t"><div class="n">${count("instructions")}</div><div class="l">Instr</div></div></div>`;
669
+ if (secrets.length) h += `<div class="crow"><span class="k">Secrets</span><span class="v">`
670
+ + secrets.map(s => `<span class="chip secret" title="${esc(s.artifact)} · ${esc(s.location)}">🔑 ${esc(s.name)}</span>`).join("") + `</span></div>`;
671
+ h += `<div class="crow"><span class="k">Checks</span><span class="v">`
672
+ + (checks.length ? checks.map(c => `<span class="chip pass">✓ ${esc(c.kind)} · ${esc(c.name)}</span>`).join("") : `<span class="d">none — add via the Checks panel below</span>`)
673
+ + `</span></div>`;
674
+ const groups = [["Skills", "skill"], ["MCP servers", "mcp_server"], ["Instructions", "instructions"], ["Hooks", "hook"]];
675
+ for (const [label, type] of groups) {
676
+ const items = arts.filter(a => a.type === type);
677
+ if (!items.length) continue;
678
+ h += `<div class="pgroup"><h3>${label} (${items.length})</h3>` + items.map(a => {
679
+ const i = arts.indexOf(a);
680
+ const src = a.source ? `<span class="src">${esc(a.source)}</span>` : "";
681
+ const meta = a.type === "mcp_server" ? `${esc(a.transport || "")} · <span class="redtag">redacted</span>` : a.type === "hook" ? esc(a.event || "hook") : fmtSize(artSize(a));
682
+ return `<button type="button" class="prow" data-i="${i}"><span class="pn">${esc(a.name)}</span> ${src} <span class="pm">${meta}</span></button>`;
683
+ }).join("") + `</div>`;
684
+ }
685
+ return h + `</div>`;
686
+ }
687
+ function renderPreview(){
688
+ const el = document.getElementById("preview");
689
+ if (typeof renderRail === "function") renderRail();
690
+ if (previewMode === "materialize") { renderMaterialize(); document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode)); return; }
691
+ if (previewMode === "managed") { renderPublish(); document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode)); return; }
692
+ const p = window.__gem;
693
+ if (!p) { el.textContent = ""; }
694
+ else if (p.error || previewMode === "json") {
695
+ el.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "json";
696
+ pre.textContent = JSON.stringify(p, null, 2); el.appendChild(pre);
697
+ } else {
698
+ el.innerHTML = summaryHtml(p);
699
+ }
700
+ document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode));
701
+ }
702
+ async function renderMaterialize(){
703
+ const el = document.getElementById("preview");
704
+ const reqBody = buildSelectionBody();
705
+ reqBody.target = document.getElementById("target").value;
706
+ const m = await (await fetch("/api/materialize", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(reqBody) })).json();
707
+ window.__materialize = m;
708
+ if (m.error) { el.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "json"; pre.textContent = JSON.stringify(m, null, 2); el.appendChild(pre); return; }
709
+ const paths = Object.keys(m.files || {});
710
+ const compat = Object.entries(m.compatibility || {}).map(([t, c]) => `${t} ${c.skipped ? c.skipped + " skipped" : "✓"}`).join(" · ");
711
+ let h = `<div class="psummary"><div class="phead"><strong>${esc(m.target)}</strong> <span class="d">· ${paths.length} file${paths.length === 1 ? "" : "s"}</span></div>`;
712
+ h += `<div class="pgroup"><h3>Files</h3>` + (paths.length ? paths.map(p => `<button type="button" class="prow" data-mpath="${esc(p)}"><span class="pn">${esc(p)}</span></button>`).join("") : `<p class="d">No files — select artifacts on the left.</p>`) + `</div>`;
713
+ if ((m.skipped || []).length) h += `<div class="pgroup"><h3>Skipped (${m.skipped.length})</h3>` + m.skipped.map(s => `<div class="prow"><span class="pn">${esc(s.artifact)}</span> <span class="pm">${esc(s.reason)}</span></div>`).join("") + `</div>`;
714
+ h += `<p class="note">Compatibility: ${esc(compat)}</p></div>`;
715
+ el.innerHTML = h;
716
+ }
717
+ // Managed Agents publish: offline preview of the agent payload + a gated Publish action.
718
+ let __publishReady = null, __publishRequestId = null, __publishFingerprint = null, __deployTargets = null;
719
+ // The default deploy backend for each materialize target/harness. Selecting a harness in the
720
+ // "Gem (live)" dropdown sets the Deploy backend to its match; the user can still override it.
721
+ const BACKEND_FOR_TARGET = { claude: "claude-managed", agentcore: "agentcore-managed", eve: "eve-vercel", flue: "flue-cloudflare" };
722
+ let deployBackend = null; // last chosen/derived backend; null until first computed
723
+ async function getDeployTargets(){ if (!__deployTargets) { const d = await (await fetch("/api/deploy-targets")).json(); __deployTargets = d.targets || []; } return __deployTargets; }
724
+ async function renderPublish(){
725
+ const el = document.getElementById("preview");
726
+ const targets = await getDeployTargets();
727
+ const harness = document.getElementById("target")?.value;
728
+ const selectedTarget = deployBackend || BACKEND_FOR_TARGET[harness] || (targets[0] ? targets[0].id : "claude-managed");
729
+ // Managed backends (DEPLOY_REGISTRY) + self-host deploys for the materialize targets.
730
+ const targetOpts = targets.map(t => `<option value="${esc(t.id)}" ${t.id === selectedTarget ? "selected" : ""}>${esc(t.label || t.id)}</option>`).join("")
731
+ + `<option value="eve-vercel" ${selectedTarget === "eve-vercel" ? "selected" : ""}>Eve → Vercel (self-host)</option>`
732
+ + `<option value="flue-cloudflare" ${selectedTarget === "flue-cloudflare" ? "selected" : ""}>Flue → Cloudflare (self-host)</option>`;
733
+ const selHtml = `<div class="pgroup" style="display:flex;align-items:center;gap:8px"><label for="deployTarget" style="white-space:nowrap">Backend:</label><select id="deployTarget">${targetOpts}</select></div>`;
734
+ if (selectedTarget === "eve-vercel") { await renderEveDeploy(el, selHtml); return; }
735
+ if (selectedTarget === "flue-cloudflare") { await renderFlueDeploy(el, selHtml); return; }
736
+ const body = { ...buildSelectionBody(), target: selectedTarget };
737
+ const r = await (await fetch("/api/publish-preview", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })).json();
738
+ window.__publish = r;
739
+ if (r.error) { el.innerHTML = selHtml; const pre = document.createElement("pre"); pre.className = "json"; pre.textContent = JSON.stringify(r, null, 2); el.appendChild(pre); attachDeployTargetListener(); return; }
740
+ const sect = (title, rows) => rows.length ? `<div class="pgroup"><h3>${title} (${rows.length})</h3>${rows.join("")}</div>` : "";
741
+ const row = (a, b) => `<div class="prow"><span class="pn">${esc(a)}</span> <span class="pm">${esc(b)}</span></div>`;
742
+ if (r.kind === "agentcore-harness") {
743
+ const agentcoreRec = await fetchDeployRecord("agentcore");
744
+ let h = `<div class="psummary">${selHtml}<div class="phead"><strong>${esc(r.request.harnessName)}</strong> <span class="d">· AgentCore Harness</span></div>`;
745
+ h += `<div class="pgroup"><h3>CreateHarness request</h3><pre class="json">${esc(JSON.stringify(r.request, null, 2))}</pre></div>`;
746
+ h += sect("Skipped", r.skipped.map(s => row(s.artifact, s.reason)));
747
+ h += sect("Add to a vault after publish", r.vaultSecrets.map(s => row(s.name, `${s.artifact} · ${s.location}`)));
748
+ __publishReady = null;
749
+ const ready = await (await fetch(`/api/publish-ready?target=agentcore-managed`)).json();
750
+ h += `<div class="pgroup"><button id="publishBtn" ${ready.ready ? "" : "disabled"}>${ready.ready ? "Create AgentCore Harness" : "Publish (set AWS creds + AGENTCORE_EXECUTION_ROLE_ARN)"}</button> <span class="d" id="publishStatus"></span></div>`;
751
+ if (agentcoreRec) h += `<div class="pgroup"><button id="agentcoreUndeployBtn">✕ Undeploy AgentCore Harness</button> <span class="d" id="undeployStatus-agentcore"></span></div>`;
752
+ h += `<p class="note">Creates a Bedrock AgentCore harness via CreateHarness. Local skills are skipped (the API takes git/s3 skill sources); MCP secret headers reference AgentCore Identity token-vault ARNs. Secrets are never sent.</p></div>`;
753
+ el.innerHTML = h; attachDeployTargetListener();
754
+ document.getElementById("agentcoreUndeployBtn")?.addEventListener("click", () => undeploy("agentcore"));
755
+ return;
756
+ }
757
+ // managed-agent branch (claude-managed)
758
+ const managedRec = await fetchDeployRecord("claude-managed");
759
+ const p = r.payload;
760
+ let h = `<div class="psummary">${selHtml}<div class="phead"><strong>${esc(p.name)}</strong> <span class="d">· Managed Agent · ${esc(p.model)}</span></div>`;
761
+ h += `<div class="pgroup"><h3>System prompt</h3>${row("instructions only", fmtSize((p.system || "").length))}</div>`;
762
+ h += sect("Skills to register", r.skillsToRegister.map(n => row(n, "Agent Skill")));
763
+ h += sect("MCP servers", p.mcp_servers.map(m => row(m.name, m.url)));
764
+ h += sect("Add to a vault after publish", r.vaultSecrets.map(s => row(s.name, `${s.artifact} · ${s.location}`)));
765
+ h += sect("Skipped", r.skipped.map(s => row(s.artifact, s.reason)));
766
+ __publishReady = null;
767
+ const ready = await (await fetch(`/api/publish-ready?target=${encodeURIComponent(selectedTarget)}`)).json();
768
+ if (ready.ready) {
769
+ h += `<div class="pgroup"><button id="publishBtn">Publish to Managed Agents</button> <span class="d" id="publishStatus"></span></div>`;
770
+ } else {
771
+ h += credEntryHtml("ANTHROPIC_API_KEY");
772
+ }
773
+ if (managedRec) h += `<div class="pgroup"><button id="managedUndeployBtn">✕ Undeploy Managed Agent</button> <span class="d" id="undeployStatus-claude-managed"></span></div>`;
774
+ h += `<p class="note">Publishing creates an agent and a limited-network cloud sandbox in your Anthropic org. Each skill is registered as an on-demand Agent Skill (Skills API), plus http/sse MCP servers (URL only) and instructions as the system prompt. Secrets are never sent; add MCP credentials to a vault before starting sessions.</p></div>`;
775
+ el.innerHTML = h;
776
+ attachDeployTargetListener();
777
+ if (!ready.ready) wireCredEntry("ANTHROPIC_API_KEY", () => renderPublish());
778
+ document.getElementById("managedUndeployBtn")?.addEventListener("click", () => undeploy("claude-managed"));
779
+ }
780
+ function attachDeployTargetListener(){
781
+ const sel = document.getElementById("deployTarget");
782
+ if (sel && !sel.__deployListened) { sel.__deployListened = true; sel.addEventListener("change", () => { deployBackend = sel.value; __publishReady = null; renderPublish(); }); }
783
+ }
784
+ async function doPublish(){
785
+ const selectedTarget = document.getElementById("deployTarget")?.value || "claude-managed";
786
+ if (selectedTarget === "agentcore-managed") {
787
+ if (!confirm("Create an AgentCore harness in your AWS account via CreateHarness?")) return;
788
+ } else {
789
+ if (!confirm("Create a Managed Agent, cloud sandbox, and uploaded skills in your Anthropic org?")) return;
790
+ }
791
+ const btn = document.getElementById("publishBtn"), st = document.getElementById("publishStatus");
792
+ btn.disabled = true; st.textContent = "Publishing…";
793
+ try {
794
+ const body = { ...buildSelectionBody(), target: selectedTarget }, fingerprint = JSON.stringify(body);
795
+ if (__publishFingerprint !== fingerprint) {
796
+ __publishFingerprint = fingerprint;
797
+ __publishRequestId = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`;
798
+ }
799
+ const r = await (await fetch("/api/publish", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...body, requestId: __publishRequestId, wsName: wsCurrent || undefined }) })).json();
800
+ if (r.error) { st.textContent = "Failed: " + (r.error.message || JSON.stringify(r.error)); btn.disabled = false; return; }
801
+ if (r.kind === "agentcore-harness") {
802
+ st.innerHTML = `✓ Created AgentCore harness <code>${esc(r.harnessId || "")}</code>`;
803
+ } else {
804
+ st.innerHTML = `✓ Created agent <code>${esc(r.agentId)}</code> (v${esc(r.version)}) + sandbox <code>${esc(r.environmentId)}</code> · ${r.registeredSkills.length} skill${r.registeredSkills.length === 1 ? "" : "s"} registered`;
805
+ }
806
+ // Re-render so the Undeploy button appears now that a deploy record exists (when a workspace is active).
807
+ if (wsCurrent) setTimeout(() => renderPublish(), 1500);
808
+ } catch (e) { st.textContent = "Failed: " + e.message; btn.disabled = false; }
809
+ }
810
+ async function undeploy(target){
811
+ if (!wsCurrent) return;
812
+ if (!confirm(`Undeploy ${target} for "${wsCurrent}"? This removes the live cloud resource.`)) return;
813
+ const st = document.getElementById("undeployStatus-" + target);
814
+ if (st) st.textContent = "Undeploying…";
815
+ try {
816
+ const resp = await fetch("/api/undeploy", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ name: wsCurrent, target }) });
817
+ const r = await resp.json();
818
+ if (!resp.ok || r.error) {
819
+ const msg = r.error?.message || r.error || (resp.status + " " + resp.statusText) || "undeploy error";
820
+ if (st) st.textContent = "Failed: " + msg;
821
+ return;
822
+ }
823
+ await renderPublish();
824
+ } catch (e) { if (st) st.textContent = "Failed: " + (e && e.message ? e.message : "undeploy error"); }
825
+ }
826
+ async function fetchDeployRecord(backend){
827
+ if (!wsCurrent) return null;
828
+ try { const r = await (await fetch(`/api/deploy-record?name=${encodeURIComponent(wsCurrent)}&backend=${encodeURIComponent(backend)}`)).json(); return r.record; }
829
+ catch { return null; }
830
+ }
831
+ // ── Self-host deploy: Eve → Vercel (reuses the workspace run/deploy backend) ──
832
+ // Eve deploys a saved workspace's eve project (deployVercel re-renders it from the
833
+ // workspace gem), so this panel needs a workspace open and VERCEL_TOKEN on the server.
834
+ let __evePoll = null;
835
+ const eveRunning = s => ["installing", "building", "running", "deploying"].includes(s);
836
+ // Where to get each server credential the deploy/publish backends gate on.
837
+ const CRED_LINKS = {
838
+ ANTHROPIC_API_KEY: "https://console.anthropic.com/settings/keys",
839
+ VERCEL_TOKEN: "https://vercel.com/account/tokens",
840
+ CLOUDFLARE_API_TOKEN: "https://dash.cloudflare.com/profile/api-tokens",
841
+ };
842
+ // A reusable "paste a server credential" block, shown when a backend is gated on a missing key.
843
+ // Saving sets it in the running server + persists to ~/.agentgem/.env, then re-renders the panel.
844
+ function credEntryHtml(key){
845
+ const link = CRED_LINKS[key] || "";
846
+ return `<div class="pgroup" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">`
847
+ + `<span class="d">Needs <code>${esc(key)}</code>.</span>`
848
+ + (link ? ` <a href="${esc(link)}" target="_blank" rel="noopener">Get a token ↗</a>` : "")
849
+ + `<input id="credInput" type="password" placeholder="paste ${esc(key)}" style="flex:1;min-width:160px" />`
850
+ + `<button id="credSave">Save</button> <span class="d" id="credStatus"></span></div>`
851
+ + `<p class="note">Stored unencrypted in <code>~/.agentgem/.env</code> on this machine — a server credential, kept out of any Gem.</p>`;
852
+ }
853
+ function wireCredEntry(key, onSaved){
854
+ const btn = document.getElementById("credSave"); if (!btn) return;
855
+ const save = async () => {
856
+ const v = document.getElementById("credInput").value.trim();
857
+ if (!v) return;
858
+ const st = document.getElementById("credStatus"); st.textContent = "Saving…";
859
+ try {
860
+ const r = await fetch("/api/credential", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key, value: v }) });
861
+ if (!r.ok) { st.textContent = "Failed"; return; }
862
+ onSaved();
863
+ } catch { st.textContent = "Failed"; }
864
+ };
865
+ btn.onclick = save;
866
+ document.getElementById("credInput")?.addEventListener("keydown", (e) => { if (e.key === "Enter") save(); });
867
+ }
868
+ async function renderEveDeploy(el, selHtml){
869
+ if (!wsCurrent){
870
+ el.innerHTML = selHtml + `<div class="pgroup"><p class="note">Eve deploys a saved <b>workspace</b>'s eve project. Open or create a workspace first (the <b>Workspace</b> stage), then deploy it here.</p></div>`;
871
+ attachDeployTargetListener(); return;
872
+ }
873
+ const [ready, deployRec] = await Promise.all([
874
+ (await fetch(`/api/run-ready?name=${encodeURIComponent(wsCurrent)}&target=eve`)).json(),
875
+ fetchDeployRecord("eve"),
876
+ ]);
877
+ let h = `<div class="psummary">${selHtml}<div class="phead"><strong>${esc(wsCurrent)}</strong> <span class="d">· Eve → Vercel (self-host)</span></div>`;
878
+ if (ready.vercel) {
879
+ h += `<div class="pgroup" style="display:flex;gap:8px;align-items:center"><label for="eveAuthMode" class="d">Auth</label><select id="eveAuthMode"><option value="placeholder">Placeholder (secure — wire your own provider)</option><option value="public">Public — anyone can reach the agent (demo)</option></select></div>`;
880
+ h += `<div class="pgroup"><button id="eveDeployBtn">▲ Deploy to Vercel</button> <span class="d" id="eveDeployState"></span></div>`;
881
+ h += `<div id="eveDeployUrl" style="margin:4px 0"></div>`;
882
+ h += `<pre id="eveDeployLog" class="json" style="max-height:220px;overflow:auto" hidden></pre>`;
883
+ } else {
884
+ h += credEntryHtml("VERCEL_TOKEN");
885
+ }
886
+ // Undeploy is available whenever a deploy exists — independent of the deploy-credential gate.
887
+ if (deployRec) h += `<div class="pgroup"><button id="eveUndeployBtn">✕ Undeploy from Vercel</button> <span class="d" id="undeployStatus-eve"></span></div>`;
888
+ h += `<p class="note">Re-renders the workspace's eve project from the gem and deploys it from source via the pinned Vercel CLI (<code>vercel deploy</code>, which builds on Vercel). If your token has one team, the scope is detected automatically.</p></div>`;
889
+ el.innerHTML = h;
890
+ attachDeployTargetListener();
891
+ document.getElementById("eveUndeployBtn")?.addEventListener("click", () => undeploy("eve"));
892
+ if (ready.vercel) {
893
+ const cur = await (await fetch(`/api/run-status?name=${encodeURIComponent(wsCurrent)}&target=eve`)).json();
894
+ if (cur && cur.mode === "vercel" && (cur.state !== "idle" || cur.url)) { eveRenderRun(cur); if (eveRunning(cur.state)) evePollRun(wsCurrent); }
895
+ document.getElementById("eveDeployBtn")?.addEventListener("click", eveDeploy);
896
+ } else {
897
+ wireCredEntry("VERCEL_TOKEN", () => renderPublish());
898
+ }
899
+ }
900
+ async function eveDeploy(){
901
+ if (!wsCurrent) return;
902
+ const btn = document.getElementById("eveDeployBtn"), st = document.getElementById("eveDeployState");
903
+ if (btn) btn.disabled = true; if (st) st.textContent = "Deploying…";
904
+ const eveAuth = document.getElementById("eveAuthMode")?.value || "placeholder";
905
+ try {
906
+ const s = await (await fetch("/api/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: wsCurrent, target: "eve", mode: "vercel", eveAuth }) })).json();
907
+ eveRenderRun(s); evePollRun(wsCurrent);
908
+ } catch (e) {
909
+ if (st) st.textContent = "Failed: " + (e && e.message ? e.message : "deploy request error");
910
+ if (btn) btn.disabled = false;
911
+ }
912
+ }
913
+ function eveRenderRun(s){
914
+ const st = document.getElementById("eveDeployState"), url = document.getElementById("eveDeployUrl"), log = document.getElementById("eveDeployLog"), btn = document.getElementById("eveDeployBtn");
915
+ if (!st) return;
916
+ st.textContent = s.state || "";
917
+ if (url) url.innerHTML = s.url ? `<a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.url)}</a>` : "";
918
+ if (log && s.logTail && s.logTail.length){ log.hidden = false; log.textContent = s.logTail.join("\n"); log.scrollTop = log.scrollHeight; }
919
+ if (btn) btn.disabled = eveRunning(s.state);
920
+ }
921
+ function evePollRun(ws){
922
+ if (__evePoll) clearInterval(__evePoll);
923
+ __evePoll = setInterval(async () => {
924
+ const s = await (await fetch(`/api/run-status?name=${encodeURIComponent(ws)}&target=eve`)).json();
925
+ eveRenderRun(s);
926
+ if (!eveRunning(s.state)) { clearInterval(__evePoll); __evePoll = null; }
927
+ }, 1500);
928
+ }
929
+ // ── Self-host deploy: Flue → Cloudflare (npm install -> flue build -> wrangler deploy) ──
930
+ let __fluePoll = null;
931
+ async function renderFlueDeploy(el, selHtml){
932
+ if (!wsCurrent){
933
+ el.innerHTML = selHtml + `<div class="pgroup"><p class="note">Flue deploys a saved <b>workspace</b>'s flue project to Cloudflare Workers. Open or create a workspace first (the <b>Workspace</b> stage), then deploy it here.</p></div>`;
934
+ attachDeployTargetListener(); return;
935
+ }
936
+ const [ready, deployRec] = await Promise.all([
937
+ (await fetch(`/api/run-ready?name=${encodeURIComponent(wsCurrent)}&target=flue`)).json(),
938
+ fetchDeployRecord("flue"),
939
+ ]);
940
+ let h = `<div class="psummary">${selHtml}<div class="phead"><strong>${esc(wsCurrent)}</strong> <span class="d">· Flue → Cloudflare (self-host)</span></div>`;
941
+ if (ready.cloudflare) {
942
+ h += `<div class="pgroup"><button id="flueDeployBtn">▲ Deploy to Cloudflare</button> <span class="d" id="flueDeployState"></span></div>`;
943
+ h += `<div id="flueDeployUrl" style="margin:4px 0"></div>`;
944
+ h += `<pre id="flueDeployLog" class="json" style="max-height:220px;overflow:auto" hidden></pre>`;
945
+ } else {
946
+ h += credEntryHtml("CLOUDFLARE_API_TOKEN");
947
+ }
948
+ // Undeploy is available whenever a deploy exists — independent of the deploy-credential gate.
949
+ if (deployRec) h += `<div class="pgroup"><button id="flueUndeployBtn">✕ Undeploy from Cloudflare</button> <span class="d" id="undeployStatus-flue"></span></div>`;
950
+ h += `<p class="note">Runs <code>npm install</code> → <code>flue build --target cloudflare</code> → <code>wrangler deploy</code> on the workspace's flue project (re-rendered from the gem).</p></div>`;
951
+ el.innerHTML = h;
952
+ attachDeployTargetListener();
953
+ document.getElementById("flueUndeployBtn")?.addEventListener("click", () => undeploy("flue"));
954
+ if (ready.cloudflare) {
955
+ const cur = await (await fetch(`/api/run-status?name=${encodeURIComponent(wsCurrent)}&target=flue`)).json();
956
+ if (cur && cur.mode === "cloudflare" && (cur.state !== "idle" || cur.url)) { flueRenderRun(cur); if (eveRunning(cur.state)) fluePollRun(wsCurrent); }
957
+ document.getElementById("flueDeployBtn")?.addEventListener("click", flueDeploy);
958
+ } else {
959
+ wireCredEntry("CLOUDFLARE_API_TOKEN", () => renderPublish());
960
+ }
961
+ }
962
+ async function flueDeploy(){
963
+ if (!wsCurrent) return;
964
+ const btn = document.getElementById("flueDeployBtn"), st = document.getElementById("flueDeployState");
965
+ if (btn) btn.disabled = true; if (st) st.textContent = "Deploying…";
966
+ try {
967
+ const s = await (await fetch("/api/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: wsCurrent, target: "flue", mode: "cloudflare" }) })).json();
968
+ flueRenderRun(s); fluePollRun(wsCurrent);
969
+ } catch (e) {
970
+ if (st) st.textContent = "Failed: " + (e && e.message ? e.message : "deploy request error");
971
+ if (btn) btn.disabled = false;
972
+ }
973
+ }
974
+ function flueRenderRun(s){
975
+ const st = document.getElementById("flueDeployState"), url = document.getElementById("flueDeployUrl"), log = document.getElementById("flueDeployLog"), btn = document.getElementById("flueDeployBtn");
976
+ if (!st) return;
977
+ st.textContent = s.state || "";
978
+ if (url) url.innerHTML = s.url ? `<a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.url)}</a>` : "";
979
+ if (log && s.logTail && s.logTail.length){ log.hidden = false; log.textContent = s.logTail.join("\n"); log.scrollTop = log.scrollHeight; }
980
+ if (btn) btn.disabled = eveRunning(s.state);
981
+ }
982
+ function fluePollRun(ws){
983
+ if (__fluePoll) clearInterval(__fluePoll);
984
+ __fluePoll = setInterval(async () => {
985
+ const s = await (await fetch(`/api/run-status?name=${encodeURIComponent(ws)}&target=flue`)).json();
986
+ flueRenderRun(s);
987
+ if (!eveRunning(s.state)) { clearInterval(__fluePoll); __fluePoll = null; }
988
+ }, 1500);
989
+ }
990
+ function openMaterializedFile(path){
991
+ const body = (window.__materialize && window.__materialize.files || {})[path] || "";
992
+ modalState.kind = (path.endsWith(".json") || path.endsWith(".toml")) ? "mcpServers" : "skills";
993
+ modalState.body = body;
994
+ document.getElementById("modal-title").textContent = path;
995
+ document.getElementById("modal-sub").textContent = (window.__materialize && window.__materialize.target) || "";
996
+ renderModalBody();
997
+ document.getElementById("modal").hidden = false;
998
+ }
999
+ function openArtifact(art){
1000
+ const isJson = art.type === "mcp_server" || art.type === "hook";
1001
+ modalState.kind = isJson ? "mcpServers" : "skills";
1002
+ modalState.body = isJson ? JSON.stringify(art.config, null, 2) : (art.content || "");
1003
+ document.getElementById("modal-title").textContent = art.name;
1004
+ document.getElementById("modal-sub").textContent = (art.source || "") + (art.transport ? ` · ${art.transport}` : art.event ? ` · ${art.event}` : "");
1005
+ renderModalBody();
1006
+ document.getElementById("modal").hidden = false;
1007
+ }
1008
+ document.getElementById("preview-modes").addEventListener("click", e => {
1009
+ const b = e.target.closest("button[data-pmode]"); if (!b) return;
1010
+ previewMode = b.dataset.pmode; renderPreview();
1011
+ });
1012
+ // Re-render the active preview panel whenever the target changes (materialize/managed are
1013
+ // target-specific; summary/json are target-agnostic so this is a harmless no-op for them).
1014
+ // Selecting a harness also defaults the Deploy backend to its match (when one exists).
1015
+ document.getElementById("target").addEventListener("change", (e) => {
1016
+ const mapped = BACKEND_FOR_TARGET[e.target.value];
1017
+ if (mapped) deployBackend = mapped;
1018
+ renderPreview();
1019
+ });
1020
+ document.getElementById("preview").addEventListener("click", e => {
1021
+ if (e.target.id === "publishBtn") { doPublish(); return; }
1022
+ const mp = e.target.closest("[data-mpath]");
1023
+ if (mp) { openMaterializedFile(mp.dataset.mpath); return; }
1024
+ const row = e.target.closest(".prow"); if (!row) return;
1025
+ const art = (window.__gem && window.__gem.artifacts) ? window.__gem.artifacts[+row.dataset.i] : null;
1026
+ if (art) openArtifact(art);
1027
+ });
1028
+ document.getElementById("all").addEventListener("click", () => {
1029
+ // Select only rows currently visible under the active filters.
1030
+ document.querySelectorAll('#inventory label.row').forEach(row => {
1031
+ if (row.style.display === "none") return;
1032
+ const cb = row.querySelector('input[type=checkbox]');
1033
+ if (cb && !cb.checked) { cb.checked = true; cb.dispatchEvent(new Event("change")); }
1034
+ });
1035
+ });
1036
+ document.getElementById("none").addEventListener("click", () => {
1037
+ document.querySelectorAll('#inventory input[type=checkbox]').forEach(cb => {
1038
+ if (cb.checked) { cb.checked = false; cb.dispatchEvent(new Event("change")); }
1039
+ });
1040
+ });
1041
+ document.getElementById("name").addEventListener("input", () => { nameEdited = true; refresh(); });
1042
+ document.getElementById("dl").addEventListener("click", () => {
1043
+ const blob = new Blob([JSON.stringify(window.__gem || {}, null, 2)], { type: "application/json" });
1044
+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = (document.getElementById("name").value || "gem") + ".json"; a.click();
1045
+ });
1046
+ document.getElementById("copy").addEventListener("click", () => navigator.clipboard.writeText(JSON.stringify(window.__gem || {}, null, 2)));
1047
+ // Save the gem archive (manifest + lock + body files) to a folder via the native picker.
1048
+ document.getElementById("save").addEventListener("click", async () => {
1049
+ const status = document.getElementById("archiveStatus");
1050
+ status.textContent = "Choose a folder…";
1051
+ const picked = await (await fetch("/api/pick-folder")).json();
1052
+ if (!picked.path) { status.textContent = ""; return; }
1053
+ status.textContent = "Saving…";
1054
+ const r = await (await fetch("/api/archive", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), outDir: picked.path }) })).json();
1055
+ const n = Object.keys(r.files || {}).length, sk = (r.skipped || []).length;
1056
+ status.textContent = `Saved ${n} files to ${r.path}` + (sk ? ` · ${sk} skipped` : "");
1057
+ });
1058
+ // Download the gem archive as a single .tar.gz (the transport/shipping form).
1059
+ document.getElementById("dltar").addEventListener("click", async () => {
1060
+ const status = document.getElementById("archiveStatus");
1061
+ status.textContent = "Building .tar.gz…";
1062
+ const r = await (await fetch("/api/archive", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), tar: true }) })).json();
1063
+ if (!r.tarGz) { status.textContent = "No archive — select artifacts on the left."; return; }
1064
+ const bytes = Uint8Array.from(atob(r.tarGz), c => c.charCodeAt(0));
1065
+ const blob = new Blob([bytes], { type: "application/gzip" });
1066
+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = (document.getElementById("name").value || "gem") + ".gem.tar.gz"; a.click();
1067
+ status.textContent = `Downloaded ${a.download}`;
1068
+ });
1069
+ // Export ▾ dropdown: toggle, close on outside click / Escape, and close after any item runs.
1070
+ (function(){
1071
+ const btn = document.getElementById("exportBtn"), menu = document.getElementById("exportMenu");
1072
+ const close = () => { menu.hidden = true; };
1073
+ btn.addEventListener("click", e => { e.stopPropagation(); menu.hidden = !menu.hidden; });
1074
+ menu.querySelectorAll(".menuitem").forEach(mi => mi.addEventListener("click", close));
1075
+ document.addEventListener("click", e => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close(); });
1076
+ document.addEventListener("keydown", e => { if (e.key === "Escape") close(); });
1077
+ })();
1078
+ document.getElementById("srcFilter").addEventListener("change", filterRows);
1079
+ document.getElementById("inventory").addEventListener("click", e => {
1080
+ // View an item's content in a modal (must not toggle the row's checkbox).
1081
+ const v = e.target.closest(".view");
1082
+ if (v) { e.preventDefault(); e.stopPropagation(); openItem(v.dataset.kind, v.dataset.name, v.dataset.project); return; }
1083
+ // Click a source chip to scope the source filter to it (click again to clear).
1084
+ const chip = e.target.closest(".src");
1085
+ if (!chip) return;
1086
+ e.preventDefault(); e.stopPropagation();
1087
+ const el = document.getElementById("srcFilter");
1088
+ const val = chip.getAttribute("data-src") || "";
1089
+ el.value = el.value === val ? "" : val;
1090
+ filterRows();
1091
+ });
1092
+ // --- Content viewer modal with Raw / Markdown forms. ALL highlighters escape the text
1093
+ // first and only inject their own <span> tags, so untrusted skill/rules bodies can never
1094
+ // become live markup. "Raw" uses textContent; the bytes are always faithful in both modes. ---
1095
+ const modalState = { kind: "", body: "", mode: "md" };
1096
+ function hlFrontmatter(raw) {
1097
+ return raw.split("\n").map(line => {
1098
+ if (/^---\s*$/.test(line)) return `<span class="fmfence">${esc(line)}</span>`;
1099
+ const m = line.match(/^(\s*)([A-Za-z0-9_-]+)(:)(.*)$/);
1100
+ if (m) return `${esc(m[1])}<span class="fmk">${esc(m[2])}</span><span class="fmc">${esc(m[3])}</span><span class="fmv">${esc(m[4])}</span>`;
1101
+ return esc(line);
1102
+ }).join("\n");
1103
+ }
1104
+ function hlMarkdown(raw) {
1105
+ const out = []; let inFence = false;
1106
+ for (const line of raw.split("\n")) {
1107
+ if (/^\s*```/.test(line)) { inFence = !inFence; out.push(`<span class="mfence">${esc(line)}</span>`); continue; }
1108
+ if (inFence) { out.push(`<span class="mcode">${esc(line)}</span>`); continue; }
1109
+ if (/^#{1,6}\s/.test(line)) { out.push(`<span class="mh">${esc(line)}</span>`); continue; }
1110
+ if (/^\s*>/.test(line)) { out.push(`<span class="mq">${esc(line)}</span>`); continue; }
1111
+ let s = esc(line);
1112
+ s = s.replace(/`([^`]+)`/g, '<span class="mic">`$1`</span>');
1113
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<span class="mb">**$1**</span>');
1114
+ s = s.replace(/^(\s*)([-*+]|\d+\.)(\s)/, '$1<span class="mli">$2</span>$3');
1115
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="mlink">[$1]($2)</span>');
1116
+ out.push(s);
1117
+ }
1118
+ return out.join("\n");
1119
+ }
1120
+ function highlight(kind, body) {
1121
+ if (kind === "mcpServers") return esc(body).replace(/&lt;redacted&gt;/g, '<span class="redacted">&lt;redacted&gt;</span>');
1122
+ const m = body.match(/^(---\n[\s\S]*?\n---)\n?([\s\S]*)$/);
1123
+ return m ? `<div class="fm">${hlFrontmatter(m[1])}</div>${hlMarkdown(m[2])}` : hlMarkdown(body);
1124
+ }
1125
+ function renderModalBody() {
1126
+ const el = document.getElementById("modal-body");
1127
+ if (modalState.mode === "raw") el.textContent = modalState.body;
1128
+ else el.innerHTML = highlight(modalState.kind, modalState.body);
1129
+ document.querySelectorAll("#modal-modes button").forEach(b => b.classList.toggle("on", b.dataset.mode === modalState.mode));
1130
+ }
1131
+ function openItem(kind, name, projRoot) {
1132
+ let title = name, sub = "", body = "";
1133
+ if (kind === "skills" || kind === "projectSkills") {
1134
+ const a = (kind === "skills" ? inv.skills : (projOf(projRoot)?.skills || [])).find(s => s.name === name);
1135
+ if (!a) return;
1136
+ sub = a.source || ""; body = a.content || "";
1137
+ } else if (kind === "mcpServers" || kind === "projectMcpServers") {
1138
+ const a = (kind === "mcpServers" ? inv.mcpServers : (projOf(projRoot)?.mcpServers || [])).find(s => s.name === name);
1139
+ if (!a) return;
1140
+ sub = `${a.source || ""} · ${a.transport}`; body = JSON.stringify(a.config, null, 2);
1141
+ } else if (kind === "instructions" || kind === "projectInstructions") {
1142
+ const list = kind === "instructions" ? inv.instructions : (projOf(projRoot)?.instructions || []);
1143
+ title = kind === "instructions" ? "Instructions" : "Project instructions";
1144
+ sub = list.map(i => i.name).join(", ");
1145
+ body = list.map(i => `### ${i.name}\n\n${i.content}`).join("\n\n──────────\n\n");
1146
+ } else if (kind === "hooks" || kind === "projectHooks") {
1147
+ const a = (kind === "hooks" ? inv.hooks : (projOf(projRoot)?.hooks || [])).find(h => h.name === name);
1148
+ if (!a) return;
1149
+ sub = `${a.source || ""} · ${a.event}`; body = JSON.stringify(a.config, null, 2);
1150
+ } else return;
1151
+ // highlight() only special-cases JSON configs (mcp + hooks); everything else gets markdown.
1152
+ modalState.kind = (kind === "mcpServers" || kind === "projectMcpServers" || kind === "hooks" || kind === "projectHooks") ? "mcpServers" : "skills";
1153
+ modalState.body = body;
1154
+ document.getElementById("modal-title").textContent = title;
1155
+ document.getElementById("modal-sub").textContent = sub;
1156
+ renderModalBody();
1157
+ document.getElementById("modal").hidden = false;
1158
+ }
1159
+ function closeModal(){ document.getElementById("modal").hidden = true; }
1160
+ document.getElementById("modal-modes").addEventListener("click", e => {
1161
+ const b = e.target.closest("button[data-mode]"); if (!b) return;
1162
+ modalState.mode = b.dataset.mode; renderModalBody(); // persists across opens
1163
+ });
1164
+ document.getElementById("modal-x").addEventListener("click", closeModal);
1165
+ document.getElementById("modal").addEventListener("click", e => { if (e.target.id === "modal") closeModal(); });
1166
+ document.addEventListener("keydown", e => { if (e.key === "Escape") closeModal(); });
1167
+
1168
+ // --- Projects: OS-native folder picker; multiple roots, each labeled by name. ---
1169
+ function projOf(root) { return (inv.projects || []).find(p => p.root === root); }
1170
+ function projSel(root) { return (sel.projects[root] ??= { skills: new Set(), mcpServers: new Set(), includeInstructions: false, hooks: new Set() }); }
1171
+ // (Project add/remove UI removed: the active testbed — set via the header chip — is the single
1172
+ // project source now. `projects` is kept in sync with [activeTestbed] inside load().)
1173
+ document.getElementById("search").addEventListener("input", filterRows);
1174
+ // Collapsible filters: default hidden; the button shows a "• N" badge for active constraints.
1175
+ let filterPanelOpen = false;
1176
+ function updateFilterBadge() {
1177
+ const srcOn = !!document.getElementById("srcFilter").value;
1178
+ const agentOn = [...document.querySelectorAll(".agentChk")].some(c => !c.checked);
1179
+ const typeOn = [...document.querySelectorAll(".typeChk")].some(c => !c.checked);
1180
+ const n = (srcOn ? 1 : 0) + (agentOn ? 1 : 0) + (typeOn ? 1 : 0);
1181
+ const btn = document.getElementById("filtersToggle");
1182
+ btn.textContent = `Filters${n ? ` • ${n}` : ""} ${filterPanelOpen ? "▲" : "▾"}`;
1183
+ btn.classList.toggle("on", n > 0);
1184
+ }
1185
+ document.getElementById("filtersToggle").addEventListener("click", () => {
1186
+ filterPanelOpen = !filterPanelOpen;
1187
+ document.getElementById("filterPanel").hidden = !filterPanelOpen;
1188
+ updateFilterBadge();
1189
+ });
1190
+ renderChecks();
1191
+ load();
1192
+ let wsCurrent = "";
1193
+ let wsAny = false; // any saved workspaces exist — keeps the workspace bar reachable to open them
1194
+ async function wsRefresh(){
1195
+ const r = await (await fetch("/api/workspaces")).json();
1196
+ wsAny = (r.workspaces || []).length > 0;
1197
+ const sel = document.getElementById("wsSelect");
1198
+ sel.innerHTML = `<option value="">— no workspace —</option>` + (r.workspaces || [])
1199
+ .map(w => `<option value="${esc(w.name)}"${w.name === wsCurrent ? " selected" : ""}>${esc(w.name)} · ${w.artifactCounts.skill + w.artifactCounts.mcp_server + w.artifactCounts.instructions + w.artifactCounts.hook} artifacts</option>`).join("");
1200
+ document.getElementById("wsDelete").hidden = !wsCurrent;
1201
+ if (typeof renderRail === "function") renderRail();
1202
+ }
1203
+ async function wsOpen(name){
1204
+ wsCurrent = name;
1205
+ const targets = document.getElementById("wsTargets"), tree = document.getElementById("wsTree");
1206
+ if (!name){ targets.hidden = true; targets.innerHTML = ""; tree.innerHTML = ""; document.getElementById("wsDelete").hidden = true; runShowFor("", ""); return; }
1207
+ let d;
1208
+ try {
1209
+ const res = await fetch("/api/workspace?name=" + encodeURIComponent(name));
1210
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1211
+ d = await res.json();
1212
+ } catch {
1213
+ tree.innerHTML = '<p class="d">Could not load workspace (it may have been deleted or is corrupted).</p>';
1214
+ return;
1215
+ }
1216
+ targets.hidden = false;
1217
+ targets.innerHTML = `<strong style="flex:1">Target layout</strong>` + Object.keys(d.compatibility)
1218
+ .map(t => `<button type="button" class="ghost wstab" data-t="${esc(t)}">${esc(t)}${(d.renderedTargets || []).includes(t) ? " ●" : ""}</button>`).join("");
1219
+ tree.innerHTML = `<p class="d">Pick a target to render its project layout.</p>`;
1220
+ document.getElementById("wsDelete").hidden = false;
1221
+ refreshAcPanel();
1222
+ }
1223
+ async function wsRender(target){
1224
+ const tree = document.getElementById("wsTree");
1225
+ tree.innerHTML = `<p class="d">Rendering ${esc(target)}…</p>`;
1226
+ let r;
1227
+ try {
1228
+ const res = await fetch("/api/workspace/render", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: wsCurrent, target }) });
1229
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1230
+ r = await res.json();
1231
+ } catch {
1232
+ tree.innerHTML = '<p class="d">Could not load workspace (it may have been deleted or is corrupted).</p>';
1233
+ return;
1234
+ }
1235
+ window.__wsFiles = r.files || {};
1236
+ const paths = Object.keys(window.__wsFiles).sort();
1237
+ const skipSuffix = (r.skipped && r.skipped.length) ? ` · ${r.skipped.length} unsupported` : "";
1238
+ if (paths.length === 0) {
1239
+ tree.innerHTML = `<p class="d">0 files — ${r.skipped ? r.skipped.length : 0} artifact(s) unsupported on ${esc(target)}.</p>`;
1240
+ } else {
1241
+ tree.innerHTML = `<div class="pgroup"><h3>${esc(target)} · ${paths.length} files${skipSuffix} <span class="d">${esc(r.path || "")}</span></h3>`
1242
+ + paths.map(p => `<button type="button" class="prow" data-wspath="${esc(p)}"><span class="pn">${esc(p)}</span></button>`).join("") + `</div>`;
1243
+ }
1244
+ await wsRefresh();
1245
+ runShowFor(wsCurrent, target);
1246
+ }
1247
+ document.getElementById("wsSelect").addEventListener("change", e => wsOpen(e.target.value));
1248
+ document.getElementById("wsNew").addEventListener("click", async () => {
1249
+ const name = prompt("Workspace name (letters, digits, . _ - only):", document.getElementById("name").value || "gem");
1250
+ if (!name) return;
1251
+ const res = await fetch("/api/workspaces", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), name }) });
1252
+ if (!res.ok){ alert("Could not create workspace (name taken or invalid)."); return; }
1253
+ wsCurrent = name; // mark selected before refresh so the option shows
1254
+ await wsRefresh(); await wsOpen(name); document.getElementById("wsSelect").value = name;
1255
+ });
1256
+ document.getElementById("wsDelete").addEventListener("click", async () => {
1257
+ if (!wsCurrent || !confirm(`Delete workspace "${wsCurrent}"? This removes its folder and rendered targets.`)) return;
1258
+ await fetch("/api/workspace/delete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: wsCurrent }) });
1259
+ await wsOpen(""); await wsRefresh();
1260
+ });
1261
+ document.getElementById("wsTargets").addEventListener("click", e => { const b = e.target.closest(".wstab"); if (b) wsRender(b.dataset.t); });
1262
+ document.getElementById("wsTree").addEventListener("click", e => {
1263
+ const b = e.target.closest("[data-wspath]"); if (!b) return;
1264
+ const p = b.dataset.wspath, body = (window.__wsFiles || {})[p] || "";
1265
+ modalState.kind = "skills";
1266
+ modalState.body = body;
1267
+ document.getElementById("modal-title").textContent = p;
1268
+ document.getElementById("modal-sub").textContent = wsCurrent;
1269
+ renderModalBody();
1270
+ document.getElementById("modal").hidden = false;
1271
+ });
1272
+ wsRefresh();
1273
+ const importSel = { skills: new Set(), mcpServers: new Set(), hooks: new Set(), includeInstructions: false };
1274
+ async function openImport(){
1275
+ if(!activeTestbed){ alert("Create or open a testbed first."); return; }
1276
+ if(!FLAVORS[activeFlavor].importSupported){ alert(`Import into ${FLAVORS[activeFlavor].label} testbeds isn't supported yet — hand-edit the project, then it'll be picked up.`); return; }
1277
+ importSel.skills.clear(); importSel.mcpServers.clear(); importSel.hooks.clear(); importSel.includeInstructions = false;
1278
+ document.getElementById("importModal").hidden = false;
1279
+ const gi = await (await fetch("/api/inventory")).json();
1280
+ const grp = (title, items, kind) => items.length ? `<div class="group"><h2>${title}</h2>` + items.map(it =>
1281
+ `<label class="row"><input type="checkbox" data-ikind="${kind}" data-name="${esc(it.name)}"> <span>${esc(it.name)}${it.source?` <span class="src">${esc(it.source)}</span>`:""}${(it.description||it.transport)?` <span class="d">— ${esc(it.description||it.transport)}</span>`:""}</span></label>`).join("") + `</div>` : "";
1282
+ let h = grp("Skills", gi.skills, "skills") + grp("MCP servers", gi.mcpServers, "mcpServers") + grp("Hooks", gi.hooks, "hooks");
1283
+ if(gi.instructions.length) h += `<div class="group"><h2>Instructions</h2><label class="row"><input type="checkbox" data-ikind="instructions"> <span>${esc(gi.instructions.map(i=>i.name).join(", "))}</span></label></div>`;
1284
+ document.getElementById("importInventory").innerHTML = h;
1285
+ document.querySelectorAll('#importInventory input[type=checkbox]').forEach(cb => cb.addEventListener("change", e => {
1286
+ const k = e.target.dataset.ikind, n = e.target.dataset.name;
1287
+ if(k === "instructions"){ importSel.includeInstructions = e.target.checked; return; }
1288
+ const set = importSel[k]; if(e.target.checked) set.add(n); else set.delete(n);
1289
+ }));
1290
+ }
1291
+ async function applyImport(){
1292
+ const selection = { skills:[...importSel.skills], mcpServers:[...importSel.mcpServers], hooks:[...importSel.hooks], includeInstructions: importSel.includeInstructions };
1293
+ const r = await (await fetch("/api/testbed/import", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ root: activeTestbed, selection, flavor: activeFlavor }) })).json();
1294
+ document.getElementById("importModal").hidden = true;
1295
+ const st = document.getElementById("importStatus");
1296
+ if (!r || r.error || !Array.isArray(r.written)) {
1297
+ st.textContent = "Import failed" + (r && r.error ? `: ${r.error.message || r.error}` : "");
1298
+ } else {
1299
+ st.textContent = `Imported ${r.written.length}${r.skipped.length ? ` · skipped ${r.skipped.length}` : ""}`;
1300
+ // Auto-select what was just imported — it was deliberately added, so default it into the gem
1301
+ // (restoreChecks() in load() reflects these into the checkboxes). Without this, imported
1302
+ // artifacts render unchecked and never reach the gem/materialize output.
1303
+ if (activeTestbed) {
1304
+ const ps = projSel(activeTestbed);
1305
+ for (const w of r.written) {
1306
+ if (w.type === "skill") ps.skills.add(w.name);
1307
+ else if (w.type === "mcp_server") ps.mcpServers.add(w.name);
1308
+ else if (w.type === "hook") ps.hooks.add(w.name);
1309
+ else if (w.type === "instructions") ps.includeInstructions = true;
1310
+ }
1311
+ }
1312
+ }
1313
+ load();
1314
+ }
1315
+ document.getElementById("importBtn").onclick = openImport;
1316
+ document.getElementById("importClose").onclick = () => document.getElementById("importModal").hidden = true;
1317
+ document.getElementById("recentClose").onclick = () => document.getElementById("recentModal").hidden = true;
1318
+ document.getElementById("recentBrowse").onclick = browseForTestbed;
1319
+ document.getElementById("tbUse").onclick = confirmCandidate;
1320
+ document.getElementById("importApply").onclick = applyImport;
1321
+ let __runWs = null, __runPoll = null;
1322
+ async function runRefreshReady(name){
1323
+ const r = await (await fetch(`/api/run-ready?name=${encodeURIComponent(name)}&target=eve`)).json();
1324
+ const lb = document.getElementById("runLocal"), vb = document.getElementById("runVercel");
1325
+ lb.disabled = !r.local; lb.title = r.local ? "" : "needs Node 24+";
1326
+ vb.disabled = !r.vercel; vb.title = r.vercel ? "" : "set VERCEL_TOKEN on the server";
1327
+ }
1328
+ function runRenderState(s){
1329
+ document.getElementById("runState").textContent = s.state || "idle";
1330
+ document.getElementById("runStop").style.display = s.state === "running" ? "" : "none";
1331
+ document.getElementById("runUrl").innerHTML = s.url ? `<a href="${esc(s.url)}" target="_blank">${esc(s.url)}</a>` : "";
1332
+ document.getElementById("runLog").textContent = (s.logTail || []).join("\n");
1333
+ }
1334
+ function runStartPolling(name){
1335
+ if (__runPoll) clearInterval(__runPoll);
1336
+ __runPoll = setInterval(async () => {
1337
+ const s = await (await fetch(`/api/run-status?name=${encodeURIComponent(name)}&target=eve`)).json();
1338
+ runRenderState(s);
1339
+ if (!["installing","building","running","deploying"].includes(s.state)) { clearInterval(__runPoll); __runPoll = null; }
1340
+ }, 1500);
1341
+ }
1342
+ // Show the Run panel whenever a workspace's eve target is in view; call this from wsRender.
1343
+ function runShowFor(name, target){
1344
+ __runWs = name;
1345
+ document.getElementById("runPanel").style.display = target === "eve" ? "" : "none";
1346
+ if (target === "eve") runRefreshReady(name);
1347
+ }
1348
+ document.getElementById("runLocal").addEventListener("click", async () => {
1349
+ const s = await (await fetch("/api/run", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ name: __runWs, target:"eve", mode:"local" }) })).json();
1350
+ runRenderState(s); runStartPolling(__runWs);
1351
+ });
1352
+ document.getElementById("runVercel").addEventListener("click", async () => {
1353
+ const s = await (await fetch("/api/run", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ name: __runWs, target:"eve", mode:"vercel" }) })).json();
1354
+ runRenderState(s); runStartPolling(__runWs);
1355
+ });
1356
+ document.getElementById("runStop").addEventListener("click", async () => {
1357
+ await fetch("/api/run/stop", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ name: __runWs, target:"eve" }) });
1358
+ runStartPolling(__runWs);
1359
+ });
1360
+ // ── AgentCore deploy panel ────────────────────────────────────────────
1361
+ let __acPoll = null;
1362
+ function renderAcState(s){
1363
+ document.getElementById("acState").textContent = s.state || "";
1364
+ const urlEl = document.getElementById("acUrl");
1365
+ urlEl.innerHTML = s.url ? `<code>${esc(s.url)}</code>` : "";
1366
+ document.getElementById("acLog").textContent = (s.logTail || []).join("\n");
1367
+ }
1368
+ // Show the AgentCore deploy panel only when a workspace is open and the target is agentcore.
1369
+ async function refreshAcPanel(){
1370
+ const panel = document.getElementById("acPanel");
1371
+ const ws = document.getElementById("wsSelect").value;
1372
+ const show = !!ws && document.getElementById("target").value === "agentcore";
1373
+ panel.style.display = show ? "" : "none";
1374
+ if (!show) return;
1375
+ const ready = await (await fetch("/api/agentcore/deploy-ready")).json();
1376
+ const btn = document.getElementById("acDeploy");
1377
+ btn.disabled = !(ready.cli && ready.awsCreds);
1378
+ btn.title = ready.cli && ready.awsCreds ? "" : `needs: ${!ready.cli ? "agentcore CLI " : ""}${!ready.awsCreds ? "AWS creds" : ""}`.trim();
1379
+ }
1380
+ document.getElementById("acDeploy").addEventListener("click", async () => {
1381
+ const name = document.getElementById("wsSelect").value; if (!name) return;
1382
+ const btn = document.getElementById("acDeploy"); btn.disabled = true;
1383
+ renderAcState({ state: "deploying", logTail: ["starting agentcore deploy…"] });
1384
+ const s = await (await fetch("/api/agentcore/deploy", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name }) })).json();
1385
+ renderAcState(s); btn.disabled = false;
1386
+ clearInterval(__acPoll);
1387
+ if (s.state === "deploying") __acPoll = setInterval(async () => {
1388
+ const st = await (await fetch(`/api/agentcore/deploy-status?name=${encodeURIComponent(name)}`)).json();
1389
+ renderAcState(st); if (st.state !== "deploying") clearInterval(__acPoll);
1390
+ }, 2000);
1391
+ });
1392
+ document.getElementById("target").addEventListener("change", refreshAcPanel);
1393
+ document.getElementById("wsSelect").addEventListener("change", refreshAcPanel);
1394
+ function renderTestDrive(){
1395
+ const card = document.getElementById("testdrive");
1396
+ if(!activeTestbed){ card.hidden = true; return; }
1397
+ card.hidden = false;
1398
+ const fl = FLAVORS[activeFlavor] || FLAVORS.claude;
1399
+ document.getElementById("tdCmd").textContent = `cd ${activeTestbed} && ${fl.run}`;
1400
+ document.getElementById("tdPill").textContent = fl.label;
1401
+ const ib = document.getElementById("importBtn");
1402
+ if (ib) { const ok = (FLAVORS[activeFlavor] || FLAVORS.claude).importSupported; ib.disabled = !ok; ib.title = ok ? "" : `Not supported for ${(FLAVORS[activeFlavor] || FLAVORS.claude).label} testbeds`; }
1403
+ }
1404
+ document.getElementById("tdCopy").onclick = () => {
1405
+ navigator.clipboard?.writeText(`cd ${activeTestbed} && ${(FLAVORS[activeFlavor] || FLAVORS.claude).run}`);
1406
+ const b = document.getElementById("tdCopy"); b.textContent = "Copied ✓"; setTimeout(()=>b.textContent="Copy", 1400);
1407
+ };
1408
+
1409
+ // ── Lifecycle stage rail ──────────────────────────────────────────────
1410
+ // Reflects observable state across the Testbed→Package→Workspace→Target→Deploy flow.
1411
+ // Stations before the current one are "done", the current one "active", the rest "todo".
1412
+ // Clicking a station performs the natural navigation for that stage.
1413
+ const RAIL_STAGES = ["testbed", "package", "workspace", "target", "deploy"];
1414
+ function railState(){
1415
+ const wsVal = document.getElementById("wsSelect")?.value || "";
1416
+ const gemArts = (window.__gem && window.__gem.artifacts || []).length;
1417
+ const targetRendered = /●/.test(document.getElementById("wsTargets")?.textContent || "") || previewMode === "materialize";
1418
+ const runState = (document.getElementById("runState")?.textContent || "").toLowerCase();
1419
+ const deployed = /running|deploy|live|ready/.test(runState) || !!(document.getElementById("runUrl")?.querySelector("a"));
1420
+ let idx = 0; // testbed (setup)
1421
+ if (activeTestbed) idx = 1; // package (have a testbed)
1422
+ if (activeTestbed && wsVal) idx = 2; // workspace (packaged + open)
1423
+ if (idx >= 2 && targetRendered) idx = 3; // target (rendered)
1424
+ if (idx >= 3 && deployed) idx = 4; // deploy
1425
+ const subs = {
1426
+ testbed: activeTestbed ? activeTestbed.replace(/^.*\//, "") : "none",
1427
+ package: gemArts ? `${gemArts} artifact${gemArts === 1 ? "" : "s"}` : "select & cut",
1428
+ workspace: wsVal || "gem home",
1429
+ target: (targetRendered ? document.getElementById("target")?.value : "") || "eve · flue · …",
1430
+ deploy: deployed ? (runState || "live") : "ship",
1431
+ };
1432
+ return { idx, subs };
1433
+ }
1434
+ function renderRail(){
1435
+ const { idx, subs } = railState();
1436
+ document.querySelectorAll("#rail .station").forEach((st, i) => {
1437
+ st.classList.remove("done", "active", "todo");
1438
+ st.classList.add(i < idx ? "done" : i === idx ? "active" : "todo");
1439
+ const sub = st.querySelector(".sub"); if (sub) sub.textContent = subs[st.dataset.stage] || "";
1440
+ });
1441
+ // Reveal the gem identity + workspace controls only once there's something to package
1442
+ // (artifacts selected or a workspace open) — they're downstream of pure testbed authoring.
1443
+ const gemArts = (window.__gem && window.__gem.artifacts || []).length;
1444
+ const wsVal = document.getElementById("wsSelect")?.value || "";
1445
+ const showPkg = gemArts > 0 || !!wsVal; // gem identity: only while packaging / a ws is open
1446
+ const showWs = showPkg || wsAny; // workspace controls: also whenever saved workspaces exist
1447
+ if (showPkg) syncGemName(); // keep the name current when the bar appears
1448
+ const nameBar = document.getElementById("nameBar"); if (nameBar) nameBar.style.display = showPkg ? "" : "none";
1449
+ const wsBar = document.getElementById("wsBar"); if (wsBar) wsBar.style.display = showWs ? "" : "none";
1450
+ }
1451
+ document.getElementById("rail").addEventListener("click", e => {
1452
+ const st = e.target.closest(".station"); if (!st) return;
1453
+ const stage = st.dataset.stage;
1454
+ if (stage === "testbed") { if (!activeTestbed) openOrCreateTestbed(); else document.querySelector(".pane.left")?.scrollTo({ top: 0, behavior: "smooth" }); }
1455
+ else if (stage === "package") { previewMode = "summary"; renderPreview(); }
1456
+ else if (stage === "workspace") { document.getElementById("wsSelect")?.scrollIntoView({ behavior: "smooth", block: "center" }); document.getElementById("wsSelect")?.focus(); }
1457
+ else if (stage === "target") { previewMode = "materialize"; renderPreview(); }
1458
+ else if (stage === "deploy") { previewMode = "managed"; renderPreview(); document.getElementById("runPanel")?.scrollIntoView({ behavior: "smooth", block: "center" }); }
1459
+ renderRail();
1460
+ });
1461
+ document.getElementById("wsSelect").addEventListener("change", renderRail);
1462
+ renderRail();
1463
+ </script>
1464
+ </body>
1465
+ </html>