Spaces:
Running
Running
Install page v2 (plain title, ready-note, visible code panel) + public-bucket lock gate; OpenClaw onboard-then-chat; Settings agents as rows + manual update path
Browse files- server/src/index.js +2 -0
- server/src/runner.js +8 -0
- server/src/visibility.js +60 -18
- web/src/App.tsx +1 -1
- web/src/components/Locked.tsx +49 -15
- web/src/components/SettingsView.tsx +15 -6
- web/src/styles.css +17 -8
server/src/index.js
CHANGED
|
@@ -159,6 +159,8 @@ app.get('/api/info', (_req, res) => res.json({
|
|
| 159 |
spaceHost: process.env.SPACE_HOST || null,
|
| 160 |
tmux: USE_TMUX,
|
| 161 |
locked: isPublic(),
|
|
|
|
|
|
|
| 162 |
canRelaunch: !!(process.env.SPACE_ID && hfToken()),
|
| 163 |
// While public, /api/info stays reachable (the Locked page needs it) — don't
|
| 164 |
// advertise which credentials exist to the whole internet.
|
|
|
|
| 159 |
spaceHost: process.env.SPACE_HOST || null,
|
| 160 |
tmux: USE_TMUX,
|
| 161 |
locked: isPublic(),
|
| 162 |
+
lockReason: visibility().reason,
|
| 163 |
+
lockBucket: visibility().bucket,
|
| 164 |
canRelaunch: !!(process.env.SPACE_ID && hfToken()),
|
| 165 |
// While public, /api/info stays reachable (the Locked page needs it) — don't
|
| 166 |
// advertise which credentials exist to the whole internet.
|
server/src/runner.js
CHANGED
|
@@ -208,6 +208,14 @@ function commandFor(session) {
|
|
| 208 |
return `if ${hasTranscript}; then exec claude --resume ${session.sessionUuid}; else ${fresh}; fi`;
|
| 209 |
}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
// Codex: resume this agent's pinned conversation (captured from its rollout
|
| 212 |
// file after launch — see scheduleCodexCapture). Existence-checked like
|
| 213 |
// Claude, so a purged rollout starts fresh honestly and a crash ends the
|
|
|
|
| 208 |
return `if ${hasTranscript}; then exec claude --resume ${session.sessionUuid}; else ${fresh}; fi`;
|
| 209 |
}
|
| 210 |
|
| 211 |
+
// OpenClaw: first run needs its onboarding wizard (keys, workspace); once the
|
| 212 |
+
// config exists, go straight to the TUI. Decided by config-file existence —
|
| 213 |
+
// same honest pattern as the Claude transcript check.
|
| 214 |
+
if (cli.id === 'openclaw') {
|
| 215 |
+
const cfg = '"${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}"';
|
| 216 |
+
return `if [ -s ${cfg} ]; then exec openclaw chat; else openclaw onboard && exec openclaw chat; fi`;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
// Codex: resume this agent's pinned conversation (captured from its rollout
|
| 220 |
// file after launch — see scheduleCodexCapture). Existence-checked like
|
| 221 |
// Claude, so a purged rollout starts fresh honestly and a crash ends the
|
server/src/visibility.js
CHANGED
|
@@ -1,36 +1,78 @@
|
|
| 1 |
// Self-visibility check. This app has no authentication, so it must only run a
|
| 2 |
-
// usable terminal backend when the Space
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
-
// token). No token required.
|
| 6 |
//
|
| 7 |
-
//
|
| 8 |
-
//
|
| 9 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const SPACE_ID = process.env.SPACE_ID || null;
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
async function check() {
|
| 15 |
// No SPACE_ID → local dev or non-Space host: never lock.
|
| 16 |
if (!SPACE_ID) {
|
| 17 |
-
state = { spaceId: null, public: false, known: true, checkedAt: Date.now() };
|
| 18 |
return state;
|
| 19 |
}
|
| 20 |
try {
|
| 21 |
-
const r = await fetch(`https://huggingface.co/api/spaces/${SPACE_ID}`, {
|
| 22 |
-
headers: { 'user-agent': 'agent-manager' },
|
| 23 |
-
});
|
| 24 |
if (r.ok) {
|
| 25 |
const j = await r.json();
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
|
|
|
| 31 |
} catch {
|
| 32 |
-
// Network blip: keep the last known verdict rather than flapping.
|
| 33 |
-
// state is not-public, so a private Space is never wrongly locked.
|
| 34 |
state = { ...state, checkedAt: Date.now() };
|
| 35 |
}
|
| 36 |
return state;
|
|
|
|
| 1 |
// Self-visibility check. This app has no authentication, so it must only run a
|
| 2 |
+
// usable terminal backend when BOTH the Space and its mounted bucket(s) are
|
| 3 |
+
// PRIVATE — a public bucket exposes everything agents saved (including login
|
| 4 |
+
// tokens), which is exactly as bad as a public Space.
|
|
|
|
| 5 |
//
|
| 6 |
+
// Space check: unauthenticated GET of a *public* space returns 200 with
|
| 7 |
+
// `private:false`; a *private* space returns 401/404. No token required.
|
| 8 |
+
// Bucket check: same pattern via /api/buckets/{id}. The bucket ids are
|
| 9 |
+
// discovered once from the Space's own metadata (runtime.volumes), which for a
|
| 10 |
+
// private Space requires the HF_TOKEN the relaunch feature already uses —
|
| 11 |
+
// without a token we can't discover volumes and skip the bucket gate.
|
| 12 |
+
//
|
| 13 |
+
// When locked, the server blocks every working API (see index.js) and the UI
|
| 14 |
+
// shows the setup/warning page. Re-checked periodically so fixing visibility
|
| 15 |
+
// unlocks within a minute — no rebuild needed.
|
| 16 |
|
| 17 |
const SPACE_ID = process.env.SPACE_ID || null;
|
| 18 |
+
const hfToken = () => process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || process.env.HF_API_TOKEN || null;
|
| 19 |
+
|
| 20 |
+
let state = { spaceId: SPACE_ID, public: false, known: false, checkedAt: 0, reason: null, bucket: null, buckets: [] };
|
| 21 |
+
let volumes = null; // bucket ids mounted on this Space; discovered once (fixed until restart)
|
| 22 |
+
|
| 23 |
+
const HEADERS = { 'user-agent': 'agent-manager' };
|
| 24 |
+
|
| 25 |
+
async function discoverBuckets() {
|
| 26 |
+
if (volumes !== null) return volumes;
|
| 27 |
+
const token = hfToken();
|
| 28 |
+
if (!token) { volumes = []; return volumes; } // can't inspect a private Space without a token
|
| 29 |
+
try {
|
| 30 |
+
const r = await fetch(`https://huggingface.co/api/spaces/${SPACE_ID}`, {
|
| 31 |
+
headers: { ...HEADERS, authorization: `Bearer ${token}` },
|
| 32 |
+
});
|
| 33 |
+
if (!r.ok) return null; // transient — retry next cycle
|
| 34 |
+
const j = await r.json();
|
| 35 |
+
volumes = ((j.runtime && j.runtime.volumes) || [])
|
| 36 |
+
.filter((v) => v && v.type === 'bucket' && v.source)
|
| 37 |
+
.map((v) => v.source);
|
| 38 |
+
} catch { return null; }
|
| 39 |
+
return volumes;
|
| 40 |
+
}
|
| 41 |
|
| 42 |
async function check() {
|
| 43 |
// No SPACE_ID → local dev or non-Space host: never lock.
|
| 44 |
if (!SPACE_ID) {
|
| 45 |
+
state = { spaceId: null, public: false, known: true, checkedAt: Date.now(), reason: null, bucket: null, buckets: [] };
|
| 46 |
return state;
|
| 47 |
}
|
| 48 |
try {
|
| 49 |
+
const r = await fetch(`https://huggingface.co/api/spaces/${SPACE_ID}`, { headers: HEADERS });
|
|
|
|
|
|
|
| 50 |
if (r.ok) {
|
| 51 |
const j = await r.json();
|
| 52 |
+
if (j.private === false) {
|
| 53 |
+
state = { spaceId: SPACE_ID, public: true, known: true, checkedAt: Date.now(), reason: 'public-space', bucket: null, buckets: state.buckets };
|
| 54 |
+
return state;
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
// Not publicly visible (401/404) → the Space is private. Now the bucket(s).
|
| 58 |
+
const buckets = await discoverBuckets();
|
| 59 |
+
if (buckets === null) { state = { ...state, checkedAt: Date.now() }; return state; } // keep last verdict
|
| 60 |
+
for (const id of buckets) {
|
| 61 |
+
try {
|
| 62 |
+
const b = await fetch(`https://huggingface.co/api/buckets/${id}`, { headers: HEADERS });
|
| 63 |
+
if (b.ok) {
|
| 64 |
+
const bj = await b.json();
|
| 65 |
+
if (bj.private === false) {
|
| 66 |
+
state = { spaceId: SPACE_ID, public: true, known: true, checkedAt: Date.now(), reason: 'public-bucket', bucket: id, buckets };
|
| 67 |
+
return state;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
// 401/404 → bucket is private → safe.
|
| 71 |
+
} catch { state = { ...state, checkedAt: Date.now() }; return state; } // blip: keep last verdict
|
| 72 |
}
|
| 73 |
+
state = { spaceId: SPACE_ID, public: false, known: true, checkedAt: Date.now(), reason: null, bucket: null, buckets };
|
| 74 |
} catch {
|
| 75 |
+
// Network blip: keep the last known verdict rather than flapping.
|
|
|
|
| 76 |
state = { ...state, checkedAt: Date.now() };
|
| 77 |
}
|
| 78 |
return state;
|
web/src/App.tsx
CHANGED
|
@@ -324,7 +324,7 @@ export default function App() {
|
|
| 324 |
);
|
| 325 |
};
|
| 326 |
|
| 327 |
-
if (info?.locked) return <Locked spaceId={info.spaceId} />;
|
| 328 |
|
| 329 |
if (settingsOpen) {
|
| 330 |
return (
|
|
|
|
| 324 |
);
|
| 325 |
};
|
| 326 |
|
| 327 |
+
if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
|
| 328 |
|
| 329 |
if (settingsOpen) {
|
| 330 |
return (
|
web/src/components/Locked.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
-
import {
|
| 3 |
import Logo from './Logo';
|
| 4 |
|
| 5 |
// A frozen, slightly dimmed replica of the real sidebar so the install page
|
|
@@ -73,9 +73,14 @@ function MockSidebar() {
|
|
| 73 |
);
|
| 74 |
}
|
| 75 |
|
| 76 |
-
// Shown when the server
|
| 77 |
-
//
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
const id = spaceId || 'owner/space-name';
|
| 80 |
const cmd = `from huggingface_hub import HfApi, Volume, create_bucket
|
| 81 |
|
|
@@ -102,20 +107,44 @@ api.duplicate_repo(
|
|
| 102 |
navigator.clipboard?.writeText(cmd).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {});
|
| 103 |
};
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
return (
|
| 106 |
<div className="app locked-app">
|
| 107 |
<MockSidebar />
|
| 108 |
<div className="main locked-main">
|
| 109 |
<div className="install">
|
| 110 |
-
<div
|
| 111 |
-
<
|
| 112 |
-
<
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
dispatch agents from anywhere, they keep working when you close the tab.
|
| 117 |
-
</p>
|
| 118 |
-
</div>
|
| 119 |
</div>
|
| 120 |
<p className="locked-sub">
|
| 121 |
This copy is <b>public</b>, so its terminals are disabled: the app has no login of its
|
|
@@ -140,10 +169,15 @@ api.duplicate_repo(
|
|
| 140 |
agents' work, logins and history survive rebuilds and sleep.
|
| 141 |
</p>
|
| 142 |
<img src="/install/mount-bucket.png" alt="The Mount a bucket dialog: private bucket, mount path /data, read & write" />
|
|
|
|
| 143 |
</div>
|
| 144 |
|
| 145 |
-
<details className="install-code">
|
| 146 |
-
<summary
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
<div className="locked-cmd">
|
| 148 |
<pre><code>{cmd}</code></pre>
|
| 149 |
<button className="locked-copy" onClick={copy} aria-label="Copy setup code" title={copied ? 'Copied' : 'Copy'}>
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
+
import { SlidersGlyph, SunGlyph, PulseGlyph } from './icons';
|
| 3 |
import Logo from './Logo';
|
| 4 |
|
| 5 |
// A frozen, slightly dimmed replica of the real sidebar so the install page
|
|
|
|
| 73 |
);
|
| 74 |
}
|
| 75 |
|
| 76 |
+
// Shown when the server locks itself: either the Space is public (visitors get
|
| 77 |
+
// the install guide) or the owner's bucket is public (they get a warning — a
|
| 78 |
+
// public bucket exposes everything the agents saved, credentials included).
|
| 79 |
+
export default function Locked({ spaceId, reason, bucket }: {
|
| 80 |
+
spaceId?: string | null;
|
| 81 |
+
reason?: string | null;
|
| 82 |
+
bucket?: string | null;
|
| 83 |
+
}) {
|
| 84 |
const id = spaceId || 'owner/space-name';
|
| 85 |
const cmd = `from huggingface_hub import HfApi, Volume, create_bucket
|
| 86 |
|
|
|
|
| 107 |
navigator.clipboard?.writeText(cmd).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {});
|
| 108 |
};
|
| 109 |
|
| 110 |
+
if (reason === 'public-bucket') {
|
| 111 |
+
return (
|
| 112 |
+
<div className="app locked-app">
|
| 113 |
+
<MockSidebar />
|
| 114 |
+
<div className="main locked-main">
|
| 115 |
+
<div className="install">
|
| 116 |
+
<h1>Your storage bucket is public</h1>
|
| 117 |
+
<p className="locked-lead">
|
| 118 |
+
The bucket mounted at <span className="mono">/data</span>
|
| 119 |
+
{bucket ? <> (<span className="mono">{bucket}</span>)</> : null} is <b>public</b> —
|
| 120 |
+
everything your agents saved is readable by anyone, including credentials and
|
| 121 |
+
shell history. The terminals stay disabled until it's private.
|
| 122 |
+
</p>
|
| 123 |
+
<div className="step">
|
| 124 |
+
<div className="step-head"><span className="step-n mono">!</span><h3>Make the bucket private</h3></div>
|
| 125 |
+
<p className="locked-sub">
|
| 126 |
+
Open <b>{bucket ? <a href={`https://huggingface.co/buckets/${bucket}/settings`} target="_blank" rel="noreferrer">the bucket's settings</a> : 'the bucket’s settings on Hugging Face'}</b> and
|
| 127 |
+
switch its visibility to <b>Private</b>. This page unlocks automatically within a minute —
|
| 128 |
+
and consider rotating any credentials that were stored while it was public.
|
| 129 |
+
</p>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
return (
|
| 138 |
<div className="app locked-app">
|
| 139 |
<MockSidebar />
|
| 140 |
<div className="main locked-main">
|
| 141 |
<div className="install">
|
| 142 |
+
<div>
|
| 143 |
+
<h1>Set up the Agent Manager in two steps</h1>
|
| 144 |
+
<p className="locked-lead">
|
| 145 |
+
A private cloud workspace for Claude Code, Codex, Gemini CLI and friends —
|
| 146 |
+
dispatch agents from anywhere, they keep working when you close the tab.
|
| 147 |
+
</p>
|
|
|
|
|
|
|
|
|
|
| 148 |
</div>
|
| 149 |
<p className="locked-sub">
|
| 150 |
This copy is <b>public</b>, so its terminals are disabled: the app has no login of its
|
|
|
|
| 169 |
agents' work, logins and history survive rebuilds and sleep.
|
| 170 |
</p>
|
| 171 |
<img src="/install/mount-bucket.png" alt="The Mount a bucket dialog: private bucket, mount path /data, read & write" />
|
| 172 |
+
<p className="install-done">That's it — the Space restarts, unlocks itself, and you're ready to go. Open it and spawn your first agent.</p>
|
| 173 |
</div>
|
| 174 |
|
| 175 |
+
<details className="install-code step">
|
| 176 |
+
<summary className="step-head">
|
| 177 |
+
<span className="step-n mono">>_</span>
|
| 178 |
+
<h3>Prefer code? Both steps in one script</h3>
|
| 179 |
+
<span className="install-code-caret">▸</span>
|
| 180 |
+
</summary>
|
| 181 |
<div className="locked-cmd">
|
| 182 |
<pre><code>{cmd}</code></pre>
|
| 183 |
<button className="locked-copy" onClick={copy} aria-label="Copy setup code" title={copied ? 'Copied' : 'Copy'}>
|
web/src/components/SettingsView.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import * as api from '../api';
|
|
| 4 |
import SkillsEditor from './SkillsEditor';
|
| 5 |
import UsagePanel from './UsagePanel';
|
| 6 |
import { SunGlyph, MoonGlyph, RefreshGlyph } from './icons';
|
|
|
|
| 7 |
|
| 8 |
type Page = 'general' | 'usage' | 'skills';
|
| 9 |
const PAGES: { id: Page; label: string }[] = [
|
|
@@ -74,20 +75,22 @@ export default function SettingsView({
|
|
| 74 |
|
| 75 |
<h3>Agents</h3>
|
| 76 |
<div className="s-help">A coloured dot means the agent is configured and ready. Log in once inside a session (or set a key as a Space secret) — credentials persist on the bucket across restarts and all sessions.</div>
|
| 77 |
-
<div className="agent-
|
| 78 |
{clis.filter((c) => c.id !== 'shell' && c.id !== 'files').map((c) => {
|
| 79 |
const ready = c.available && c.ready;
|
| 80 |
return (
|
| 81 |
-
<div key={c.id} className="agent-
|
| 82 |
<span
|
| 83 |
className="status"
|
| 84 |
style={ready
|
| 85 |
? { background: c.color }
|
| 86 |
: { background: 'var(--muted)', opacity: 0.4 }}
|
| 87 |
/>
|
| 88 |
-
<
|
| 89 |
-
|
| 90 |
-
<span className="
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
);
|
| 93 |
})}
|
|
@@ -98,8 +101,14 @@ export default function SettingsView({
|
|
| 98 |
<div className="s-label">Update CLIs</div>
|
| 99 |
<div className="s-help">
|
| 100 |
Factory-reboots the Space to reinstall every CLI at its latest version and relaunch. Sessions survive on the bucket.
|
| 101 |
-
{!info?.canRelaunch && ' Needs a write-scoped HF_TOKEN set as a Space secret.'}
|
| 102 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
{!info?.canRelaunch ? (
|
| 105 |
<button className="btn-ghost" disabled title="Add a write-scoped HF_TOKEN secret to the Space to enable"><RefreshGlyph /> Relaunch & update</button>
|
|
|
|
| 4 |
import SkillsEditor from './SkillsEditor';
|
| 5 |
import UsagePanel from './UsagePanel';
|
| 6 |
import { SunGlyph, MoonGlyph, RefreshGlyph } from './icons';
|
| 7 |
+
import Logo from './Logo';
|
| 8 |
|
| 9 |
type Page = 'general' | 'usage' | 'skills';
|
| 10 |
const PAGES: { id: Page; label: string }[] = [
|
|
|
|
| 75 |
|
| 76 |
<h3>Agents</h3>
|
| 77 |
<div className="s-help">A coloured dot means the agent is configured and ready. Log in once inside a session (or set a key as a Space secret) — credentials persist on the bucket across restarts and all sessions.</div>
|
| 78 |
+
<div className="agent-rows">
|
| 79 |
{clis.filter((c) => c.id !== 'shell' && c.id !== 'files').map((c) => {
|
| 80 |
const ready = c.available && c.ready;
|
| 81 |
return (
|
| 82 |
+
<div key={c.id} className="agent-row">
|
| 83 |
<span
|
| 84 |
className="status"
|
| 85 |
style={ready
|
| 86 |
? { background: c.color }
|
| 87 |
: { background: 'var(--muted)', opacity: 0.4 }}
|
| 88 |
/>
|
| 89 |
+
<Logo cli={c.id} size={14} tint={c.color} />
|
| 90 |
+
<span className="ar-name">{c.label}</span>
|
| 91 |
+
<span className="spacer" />
|
| 92 |
+
{c.available && c.version && <span className="ar-ver mono">v{c.version}</span>}
|
| 93 |
+
<span className={`ar-state${ready ? ' ok' : ''}`}>{!c.available ? 'unavailable' : ready ? 'ready' : 'needs setup'}</span>
|
| 94 |
</div>
|
| 95 |
);
|
| 96 |
})}
|
|
|
|
| 101 |
<div className="s-label">Update CLIs</div>
|
| 102 |
<div className="s-help">
|
| 103 |
Factory-reboots the Space to reinstall every CLI at its latest version and relaunch. Sessions survive on the bucket.
|
|
|
|
| 104 |
</div>
|
| 105 |
+
{!info?.canRelaunch && (
|
| 106 |
+
<div className="s-help" style={{ marginTop: 6 }}>
|
| 107 |
+
This button needs a write-scoped <span className="mono">HF_TOKEN</span> Space secret. Without one,
|
| 108 |
+
update manually: open your Space's <b>Settings</b> tab on Hugging Face and press{' '}
|
| 109 |
+
<b>Factory reboot</b> — same effect.
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
</div>
|
| 113 |
{!info?.canRelaunch ? (
|
| 114 |
<button className="btn-ghost" disabled title="Add a write-scoped HF_TOKEN secret to the Space to enable"><RefreshGlyph /> Relaunch & update</button>
|
web/src/styles.css
CHANGED
|
@@ -342,9 +342,14 @@ body {
|
|
| 342 |
.step-head h3 { margin: 0; font-size: 14px; }
|
| 343 |
.step-n { flex: none; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--accent); border-radius: 50%; font-size: 11.5px; font-weight: 600; }
|
| 344 |
.step img { width: 100%; max-width: 480px; border: 1px solid var(--border); border-radius: var(--r-lg); display: block; }
|
| 345 |
-
.install-
|
| 346 |
-
|
| 347 |
-
.install-code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
.install-relock { border-top: 1px solid var(--border); padding-top: 14px; }
|
| 349 |
.locked-cmd { position: relative; margin-top: 8px; }
|
| 350 |
.locked-cmd pre { margin: 0; overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 42px 14px 14px; font-size: 12px; line-height: 1.5; }
|
|
@@ -452,11 +457,15 @@ body {
|
|
| 452 |
.s-label { font-weight: 600; font-size: 14px; }
|
| 453 |
.s-help { color: var(--muted); font-size: 12.5px; line-height: 1.5; margin: 2px 0 0; }
|
| 454 |
.s-muted { color: var(--muted); }
|
| 455 |
-
|
| 456 |
-
.agent-
|
| 457 |
-
.agent-
|
| 458 |
-
.agent-
|
| 459 |
-
.agent-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
.kv { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; margin-top: 8px; }
|
| 461 |
.kv > div { display: flex; justify-content: space-between; gap: 16px; padding: 11px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
| 462 |
.kv > div:last-child { border-bottom: none; }
|
|
|
|
| 342 |
.step-head h3 { margin: 0; font-size: 14px; }
|
| 343 |
.step-n { flex: none; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--accent); border-radius: 50%; font-size: 11.5px; font-weight: 600; }
|
| 344 |
.step img { width: 100%; max-width: 480px; border: 1px solid var(--border); border-radius: var(--r-lg); display: block; }
|
| 345 |
+
.install-done { margin: 2px 0 0; font-size: 13px; line-height: 1.5; color: var(--go); font-weight: 500; }
|
| 346 |
+
/* the code path: same panel as the steps, collapsed to its title row */
|
| 347 |
+
.install-code summary { cursor: pointer; list-style: none; }
|
| 348 |
+
.install-code summary::-webkit-details-marker { display: none; }
|
| 349 |
+
.install-code summary .step-n { border-radius: var(--r-sm); font-size: 9px; letter-spacing: 0.02em; }
|
| 350 |
+
.install-code .install-code-caret { margin-left: auto; color: var(--muted); font-size: 11px; transition: transform 0.15s ease-out; display: inline-block; }
|
| 351 |
+
.install-code[open] .install-code-caret { transform: rotate(90deg); }
|
| 352 |
+
.install-code[open] summary { margin-bottom: 4px; }
|
| 353 |
.install-relock { border-top: 1px solid var(--border); padding-top: 14px; }
|
| 354 |
.locked-cmd { position: relative; margin-top: 8px; }
|
| 355 |
.locked-cmd pre { margin: 0; overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 42px 14px 14px; font-size: 12px; line-height: 1.5; }
|
|
|
|
| 457 |
.s-label { font-weight: 600; font-size: 14px; }
|
| 458 |
.s-help { color: var(--muted); font-size: 12.5px; line-height: 1.5; margin: 2px 0 0; }
|
| 459 |
.s-muted { color: var(--muted); }
|
| 460 |
+
/* agents: one hairline row per CLI — dot · logo · name · version · state */
|
| 461 |
+
.agent-rows { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); margin-top: 8px; overflow: hidden; }
|
| 462 |
+
.agent-row { display: flex; align-items: center; gap: 10px; padding: 9px 14px; font-size: 13px; }
|
| 463 |
+
.agent-row + .agent-row { border-top: 1px solid var(--border); }
|
| 464 |
+
.agent-row .spacer { flex: 1; }
|
| 465 |
+
.ar-name { white-space: nowrap; font-weight: 500; }
|
| 466 |
+
.ar-ver { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
| 467 |
+
.ar-state { flex: none; width: 88px; text-align: right; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
|
| 468 |
+
.ar-state.ok { color: var(--go); }
|
| 469 |
.kv { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; margin-top: 8px; }
|
| 470 |
.kv > div { display: flex; justify-content: space-between; gap: 16px; padding: 11px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
| 471 |
.kv > div:last-child { border-bottom: none; }
|