ape-claw 0.1.6 → 0.1.8

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,206 @@
1
+ /**
2
+ * Routes:
3
+ * GET /api/openclaw/env
4
+ * POST /api/openclaw/env
5
+ *
6
+ * Local-only helper for Forge to read/update OpenClaw env keys.
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { collectBody } from "../middleware/body-limit.mjs";
12
+ import { openClawEnvFileCandidates } from "../../lib/openclaw-paths.mjs";
13
+
14
+ const ALLOWED_KEYS = [
15
+ "OPENAI_API_KEY",
16
+ "ANTHROPIC_API_KEY",
17
+ "PERPLEXITY_API_KEY",
18
+ "GROQ_API_KEY",
19
+ "TOGETHER_API_KEY",
20
+ "OLLAMA_HOST",
21
+ "OLLAMA_BASE_URL",
22
+ ];
23
+ const BLOCKED_KEYS = new Set([
24
+ "PATH", "HOME", "SHELL", "PWD", "USER", "LOGNAME",
25
+ "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH",
26
+ "FORGE_LLM_API_URL", "FORGE_LLM_API_KEY", "FORGE_LLM_MODEL",
27
+ ]);
28
+
29
+ function isLocalRequest(req) {
30
+ const ip = String(req.socket?.remoteAddress || "");
31
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
32
+ }
33
+
34
+ function resolveEnvPath() {
35
+ const candidates = openClawEnvFileCandidates();
36
+ return candidates.find((p) => fs.existsSync(p)) || candidates[0];
37
+ }
38
+
39
+ function parseEnv(raw) {
40
+ const out = {};
41
+ for (const line of String(raw || "").split(/\r?\n/)) {
42
+ const s = line.trim();
43
+ if (!s || s.startsWith("#")) continue;
44
+ const eq = s.indexOf("=");
45
+ if (eq <= 0) continue;
46
+ const key = s.slice(0, eq).trim();
47
+ let val = s.slice(eq + 1).trim();
48
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
49
+ val = val.slice(1, -1);
50
+ }
51
+ if (key) out[key] = val;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function quoteEnvValue(value) {
57
+ const s = String(value ?? "");
58
+ if (!s) return '""';
59
+ if (/[\s#"'`\\]/.test(s)) {
60
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
61
+ }
62
+ return s;
63
+ }
64
+
65
+ function isValidEnvKey(key) {
66
+ const k = String(key || "").trim();
67
+ if (!k) return false;
68
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) return false;
69
+ if (BLOCKED_KEYS.has(k)) return false;
70
+ return true;
71
+ }
72
+
73
+ function renderEnv(map, managedKeys, previousRaw = "") {
74
+ const oldLines = String(previousRaw || "").split(/\r?\n/);
75
+ const seen = new Set();
76
+ const nextLines = oldLines.map((line) => {
77
+ const eq = line.indexOf("=");
78
+ if (eq <= 0) return line;
79
+ const key = line.slice(0, eq).trim();
80
+ if (!managedKeys.has(key)) return line;
81
+ seen.add(key);
82
+ if (!(key in map)) return line;
83
+ const val = String(map[key] ?? "");
84
+ if (!val) return `# ${key}=`;
85
+ return `${key}=${quoteEnvValue(val)}`;
86
+ });
87
+
88
+ for (const key of managedKeys) {
89
+ if (seen.has(key)) continue;
90
+ const val = String(map[key] ?? "");
91
+ if (!val) continue;
92
+ nextLines.push(`${key}=${quoteEnvValue(val)}`);
93
+ }
94
+ return `${nextLines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
95
+ }
96
+
97
+ function buildMasked(map) {
98
+ const masked = {};
99
+ for (const key of Object.keys(map || {})) {
100
+ const v = String(map[key] || "");
101
+ if (!v) masked[key] = "";
102
+ else if (key.endsWith("_HOST") || key.endsWith("_URL") || key.endsWith("_MODEL")) masked[key] = v;
103
+ else if (/KEY|TOKEN|SECRET|PASSWORD/i.test(key)) masked[key] = v.length <= 8 ? "*".repeat(v.length) : `${v.slice(0, 4)}...${v.slice(-4)}`;
104
+ else if (v.length <= 8) masked[key] = "*".repeat(v.length);
105
+ else masked[key] = `${v.slice(0, 4)}...${v.slice(-4)}`;
106
+ }
107
+ return masked;
108
+ }
109
+
110
+ export function handleOpenClawEnvGet(req, res) {
111
+ if (!isLocalRequest(req)) {
112
+ res.writeHead(403, { "content-type": "application/json" });
113
+ return res.end(JSON.stringify({ ok: false, error: "local requests only" }));
114
+ }
115
+ const envPath = resolveEnvPath();
116
+ const raw = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
117
+ const parsed = parseEnv(raw);
118
+ const values = {};
119
+ for (const key of ALLOWED_KEYS) values[key] = String(parsed[key] || "");
120
+ const customValues = {};
121
+ for (const [k, v] of Object.entries(parsed)) {
122
+ if (ALLOWED_KEYS.includes(k)) continue;
123
+ if (!isValidEnvKey(k)) continue;
124
+ customValues[k] = String(v || "");
125
+ }
126
+ const masked = buildMasked({ ...values, ...customValues });
127
+ res.writeHead(200, { "content-type": "application/json" });
128
+ return res.end(JSON.stringify({
129
+ ok: true,
130
+ envPath,
131
+ keys: ALLOWED_KEYS,
132
+ values,
133
+ customValues,
134
+ masked,
135
+ }));
136
+ }
137
+
138
+ export async function handleOpenClawEnvSet(req, res) {
139
+ if (!isLocalRequest(req)) {
140
+ res.writeHead(403, { "content-type": "application/json" });
141
+ return res.end(JSON.stringify({ ok: false, error: "local requests only" }));
142
+ }
143
+ const rawBody = await collectBody(req, res);
144
+ if (rawBody === null) return;
145
+ let body;
146
+ try { body = JSON.parse(rawBody); } catch {
147
+ res.writeHead(400, { "content-type": "application/json" });
148
+ return res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
149
+ }
150
+ const updates = body?.updates && typeof body.updates === "object" ? body.updates : null;
151
+ if (!updates) {
152
+ res.writeHead(400, { "content-type": "application/json" });
153
+ return res.end(JSON.stringify({ ok: false, error: "updates object required" }));
154
+ }
155
+
156
+ const warnings = [];
157
+ for (const [k, v] of Object.entries(updates)) {
158
+ const val = String(v ?? "").trim();
159
+ if (!val) continue;
160
+ if (k === "OPENAI_API_KEY" && !val.startsWith("sk-")) {
161
+ warnings.push(`${k} should start with "sk-". The value you provided doesn't look like a valid OpenAI API key.`);
162
+ }
163
+ if (k === "ANTHROPIC_API_KEY" && !val.startsWith("sk-ant-")) {
164
+ warnings.push(`${k} should start with "sk-ant-". The value you provided doesn't look like a valid Anthropic key.`);
165
+ }
166
+ if (k === "GROQ_API_KEY" && !val.startsWith("gsk_")) {
167
+ warnings.push(`${k} should start with "gsk_". The value you provided doesn't look like a valid Groq key.`);
168
+ }
169
+ if (k === "PERPLEXITY_API_KEY" && !val.startsWith("pplx-")) {
170
+ warnings.push(`${k} should start with "pplx-". The value you provided doesn't look like a valid Perplexity key.`);
171
+ }
172
+ if (/API_KEY$/i.test(k) && val.length < 20) {
173
+ warnings.push(`${k} looks too short to be a real API key (${val.length} chars).`);
174
+ }
175
+ }
176
+ if (warnings.length) {
177
+ res.writeHead(400, { "content-type": "application/json" });
178
+ return res.end(JSON.stringify({ ok: false, error: "Invalid API key format", warnings }));
179
+ }
180
+
181
+ const envPath = resolveEnvPath();
182
+ const currentRaw = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
183
+ const current = parseEnv(currentRaw);
184
+ const next = { ...current };
185
+ const applied = {};
186
+ const managedKeys = new Set(ALLOWED_KEYS);
187
+ for (const [k, v] of Object.entries(updates)) {
188
+ if (!isValidEnvKey(k)) continue;
189
+ next[k] = String(v ?? "").trim();
190
+ applied[k] = next[k];
191
+ managedKeys.add(k);
192
+ }
193
+ const rendered = renderEnv(next, managedKeys, currentRaw);
194
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
195
+ fs.writeFileSync(envPath, rendered, { mode: 0o600 });
196
+
197
+ res.writeHead(200, { "content-type": "application/json" });
198
+ return res.end(JSON.stringify({
199
+ ok: true,
200
+ envPath,
201
+ applied,
202
+ masked: buildMasked(applied),
203
+ note: "Saved. Forge provider detection refreshes automatically on next requests.",
204
+ }));
205
+ }
206
+
@@ -3,13 +3,72 @@
3
3
  */
4
4
 
5
5
  import fs from "node:fs";
6
- import os from "node:os";
7
6
  import path from "node:path";
8
7
  import { ROOT as PROJECT_ROOT } from "../../lib/paths.mjs";
8
+ import { openClawSkillsDirCandidates as resolveOpenClawSkillsDirCandidates } from "../../lib/openclaw-paths.mjs";
9
9
  import { getStorage } from "../storage/index.mjs";
10
10
  import { requireSkillWriteAuth } from "../middleware/auth.mjs";
11
11
  import { collectBody } from "../middleware/body-limit.mjs";
12
12
 
13
+ function openClawSkillsDirCandidates() {
14
+ return resolveOpenClawSkillsDirCandidates();
15
+ }
16
+
17
+ function getOpenClawSkillsDirForRead() {
18
+ const candidates = openClawSkillsDirCandidates();
19
+ const existing = candidates.find((p) => fs.existsSync(p));
20
+ return existing || candidates[0];
21
+ }
22
+
23
+ function getOpenClawSkillsDirForWrite() {
24
+ const candidates = openClawSkillsDirCandidates();
25
+ return candidates.find((p) => fs.existsSync(p)) || candidates[0];
26
+ }
27
+
28
+ function parseFrontmatter(content) {
29
+ const match = String(content || "").match(/^---\n([\s\S]*?)\n---\s*/);
30
+ if (!match) return { meta: {}, body: String(content || "") };
31
+ const meta = {};
32
+ for (const line of match[1].split("\n")) {
33
+ const idx = line.indexOf(":");
34
+ if (idx <= 0) continue;
35
+ const key = line.slice(0, idx).trim();
36
+ let val = line.slice(idx + 1).trim();
37
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
38
+ val = val.slice(1, -1);
39
+ }
40
+ if (key) meta[key] = val;
41
+ }
42
+ return { meta, body: String(content || "").slice(match[0].length) };
43
+ }
44
+
45
+ function listOpenClawInstalledSkills(limit = 2000) {
46
+ const skillsDir = getOpenClawSkillsDirForRead();
47
+ const out = [];
48
+ if (!skillsDir || !fs.existsSync(skillsDir)) return { skillsDir, skills: out };
49
+ let names = [];
50
+ try { names = fs.readdirSync(skillsDir); } catch { return { skillsDir, skills: out }; }
51
+ for (const name of names) {
52
+ if (out.length >= limit) break;
53
+ const skillMd = path.join(skillsDir, name, "SKILL.md");
54
+ if (!fs.existsSync(skillMd)) continue;
55
+ try {
56
+ const raw = fs.readFileSync(skillMd, "utf8");
57
+ const { meta, body } = parseFrontmatter(raw);
58
+ const desc = String(meta.description || body.split(/\r?\n/).find((l) => l.trim()) || "").trim().slice(0, 300);
59
+ out.push({
60
+ slug: toSlug(name || meta.name || ""),
61
+ name: String(meta.name || name).trim() || name,
62
+ version: safeVersion(meta.version || "") || "",
63
+ description: desc,
64
+ source: "openclaw-installed",
65
+ path: path.join(skillsDir, name),
66
+ });
67
+ } catch {}
68
+ }
69
+ return { skillsDir, skills: out };
70
+ }
71
+
13
72
  function toSlug(input) {
14
73
  return String(input || "").toLowerCase().trim()
15
74
  .replace(/®/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -113,7 +172,8 @@ function upsertUserSkill(store, auth, skillcard, sourceUrl, createdAt) {
113
172
  function installOpenClawSkillCard(skillcard, fallbackSlug = "") {
114
173
  const slugValue = toSlug(skillcard?.slug || fallbackSlug || skillcard?.name || "");
115
174
  if (!slugValue) throw new Error("invalid slug for OpenClaw install");
116
- const skillDir = path.join(os.homedir(), ".openclaw", "skills", slugValue);
175
+ const root = getOpenClawSkillsDirForWrite();
176
+ const skillDir = path.join(root, slugValue);
117
177
  fs.mkdirSync(skillDir, { recursive: true });
118
178
  const doc = String(skillcard?.documentation_md || "").trim();
119
179
  const name = String(skillcard?.name || slugValue).trim();
@@ -135,7 +195,7 @@ function installOpenClawSkillCard(skillcard, fallbackSlug = "") {
135
195
  function uninstallOpenClawSkillBySlug(slug) {
136
196
  const s = toSlug(slug || "");
137
197
  if (!s) return { removed: null, missing: null };
138
- const skillDir = path.join(os.homedir(), ".openclaw", "skills", s);
198
+ const skillDir = path.join(getOpenClawSkillsDirForWrite(), s);
139
199
  const skillMd = path.join(skillDir, "SKILL.md");
140
200
  if (!fs.existsSync(skillMd)) {
141
201
  return { removed: null, missing: { slug: s, reason: "SKILL.md not found" } };
@@ -148,6 +208,23 @@ function uninstallOpenClawSkillBySlug(slug) {
148
208
  return { removed: { slug: s, skillDir }, missing: null };
149
209
  }
150
210
 
211
+ export function handleOpenClawInstalledSkills(req, res, reqUrl) {
212
+ try {
213
+ const limit = Math.min(5000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 2000)));
214
+ const result = listOpenClawInstalledSkills(limit);
215
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
216
+ return res.end(JSON.stringify({
217
+ ok: true,
218
+ skillsDir: result.skillsDir || null,
219
+ total: result.skills.length,
220
+ skills: result.skills,
221
+ }));
222
+ } catch (err) {
223
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
224
+ return res.end(JSON.stringify({ ok: false, error: err.message || "openclaw installed scan failed" }));
225
+ }
226
+ }
227
+
151
228
  export function handleSkillsSearch(req, res, reqUrl) {
152
229
  try {
153
230
  const store = getStorage();
@@ -65,6 +65,9 @@ a{color:var(--neon-cyan);text-decoration:none}
65
65
  .forge-viewport{
66
66
  position:relative;flex:1 1 0%;min-height:0;background:#000;overflow:hidden;
67
67
  }
68
+ .forge-viewport.forge-drop-active{
69
+ box-shadow:inset 0 0 0 2px rgba(99,215,255,.55), inset 0 0 32px rgba(99,215,255,.18);
70
+ }
68
71
  .forge-viewport canvas{display:block;width:100%;height:100%}
69
72
  #forgeLabelLayer{position:absolute;inset:0;pointer-events:none;overflow:hidden}
70
73
  #forgeLabelLayer *{pointer-events:none}
@@ -113,12 +116,13 @@ a{color:var(--neon-cyan);text-decoration:none}
113
116
  display:grid;grid-template-columns:35fr 65fr;gap:0;
114
117
  border-top:1px solid var(--border);
115
118
  max-height:320px;min-height:200px;
119
+ overflow:hidden;
116
120
  }
117
121
  .forge-panel{
118
122
  background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(0,0,0,.3)),var(--surface2);
119
123
  border-right:1px solid var(--border);
120
124
  box-shadow:var(--panel-shadow);
121
- overflow:hidden;display:flex;flex-direction:column;
125
+ overflow:hidden;display:flex;flex-direction:column;min-height:0;
122
126
  }
123
127
  .forge-panel:last-child{border-right:none}
124
128
 
@@ -172,7 +176,7 @@ a{color:var(--neon-cyan);text-decoration:none}
172
176
  .forge-inspector-docs a{color:var(--neon-cyan)}
173
177
 
174
178
  /* ── Chat ───────────────────────────────────────────────── */
175
- .forge-chat{display:flex;flex-direction:column}
179
+ .forge-chat{display:flex;flex-direction:column;min-height:0}
176
180
  .forge-chat-header{
177
181
  padding:10px 16px;font-size:.78rem;font-weight:700;color:var(--text);
178
182
  display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);
@@ -182,6 +186,31 @@ a{color:var(--neon-cyan);text-decoration:none}
182
186
  padding:1px 7px;border-radius:var(--radius);font-size:.6rem;font-weight:600;
183
187
  background:rgba(207,255,4,.08);border:1px solid rgba(207,255,4,.2);color:var(--accent);
184
188
  }
189
+ .forge-gateway-chip{
190
+ margin-left:auto;
191
+ font-family:var(--font-mono);
192
+ font-size:.58rem;
193
+ padding:2px 8px;
194
+ border-radius:999px;
195
+ border:1px solid var(--border);
196
+ color:var(--dim);
197
+ background:rgba(255,255,255,.03);
198
+ }
199
+ .forge-gateway-chip[data-state="ok"]{
200
+ border-color:rgba(0,255,100,.35);color:#9effc8;background:rgba(0,255,100,.08);
201
+ }
202
+ .forge-gateway-chip[data-state="warn"]{
203
+ border-color:rgba(255,178,71,.35);color:#ffcf9a;background:rgba(255,178,71,.08);
204
+ }
205
+ .forge-gateway-chip[data-state="err"]{
206
+ border-color:rgba(255,51,51,.35);color:#ff9e9e;background:rgba(255,51,51,.08);
207
+ }
208
+ .forge-chat-actions{display:flex;align-items:center;gap:6px}
209
+ .forge-chat-action-btn{
210
+ border:1px solid var(--border);background:rgba(0,0,0,.35);color:var(--dim);
211
+ border-radius:6px;font-size:.6rem;padding:3px 8px;cursor:pointer;
212
+ }
213
+ .forge-chat-action-btn:hover{color:var(--text);border-color:var(--neon-cyan)}
185
214
  .forge-chat-messages{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:6px}
186
215
  .chat-empty{
187
216
  display:flex;flex-direction:column;align-items:center;justify-content:center;
@@ -196,6 +225,36 @@ a{color:var(--neon-cyan);text-decoration:none}
196
225
  .forge-chat .chat-msg-name{font-size:.72rem;font-weight:700;color:var(--accent)}
197
226
  .forge-chat .chat-msg-time{font-family:var(--font-mono);font-size:.58rem;color:var(--dim);margin-left:auto}
198
227
  .forge-chat .chat-msg-text{font-size:.75rem;line-height:1.5;color:var(--text);word-break:break-word}
228
+ .forge-chat .chat-msg-text.chat-pending{
229
+ color:#d3ecff;
230
+ }
231
+ .chat-pending-wrap{
232
+ display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap;
233
+ padding:5px 8px;border:1px solid rgba(99,215,255,.26);border-radius:7px;
234
+ background:linear-gradient(180deg,rgba(99,215,255,.08),rgba(99,215,255,.03));
235
+ }
236
+ .chat-pending-label{
237
+ font-family:var(--font-mono);font-size:.64rem;letter-spacing:.02em;color:#bce9ff;
238
+ }
239
+ .chat-pending-dots{display:inline-flex;gap:3px;align-items:center}
240
+ .chat-pending-dots i{
241
+ width:5px;height:5px;border-radius:50%;display:block;background:rgba(99,215,255,.35);
242
+ animation:forgeChatDotPulse 1.2s ease-in-out infinite;
243
+ }
244
+ .chat-pending-dots i:nth-child(2){animation-delay:.15s}
245
+ .chat-pending-dots i:nth-child(3){animation-delay:.3s}
246
+ .chat-pending-stage{
247
+ font-size:.62rem;color:var(--dim);font-family:var(--font-mono);
248
+ }
249
+ .chat-pending-time{
250
+ font-size:.6rem;color:var(--neon-cyan);font-family:var(--font-mono);
251
+ border:1px solid rgba(99,215,255,.24);padding:1px 6px;border-radius:999px;
252
+ background:rgba(99,215,255,.08);
253
+ }
254
+ @keyframes forgeChatDotPulse{
255
+ 0%,100%{transform:translateY(0);opacity:.45}
256
+ 50%{transform:translateY(-2px);opacity:1}
257
+ }
199
258
  .forge-chat .chat-msg-text strong{color:#eefad6;font-weight:700}
200
259
  .forge-chat .chat-msg-text code{
201
260
  font-family:var(--font-mono);font-size:.68rem;
@@ -213,6 +272,14 @@ a{color:var(--neon-cyan);text-decoration:none}
213
272
  display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--border);
214
273
  background:linear-gradient(180deg,rgba(207,255,4,.02),transparent),var(--surface2);
215
274
  }
275
+ .forge-chat-quick{
276
+ display:flex;gap:6px;flex-wrap:wrap;padding:0 12px 8px;
277
+ }
278
+ .forge-chat-quick-btn{
279
+ border:1px solid var(--border);background:rgba(255,255,255,.02);color:var(--dim);
280
+ border-radius:999px;font-size:.58rem;padding:4px 9px;cursor:pointer;
281
+ }
282
+ .forge-chat-quick-btn:hover{color:var(--text);border-color:var(--accent)}
216
283
  .forge-chat-input-bar input{
217
284
  flex:1;padding:7px 12px;border-radius:var(--radius);border:1px solid var(--border);
218
285
  background:#0b1622;color:var(--text);font-family:var(--font-display);font-size:.75rem;
@@ -279,6 +346,9 @@ a{color:var(--neon-cyan);text-decoration:none}
279
346
  .forge-footer{
280
347
  padding:14px 24px;text-align:center;font-size:.62rem;color:var(--dim);
281
348
  border-top:1px solid rgba(207,255,4,.1);background:rgba(0,0,0,.3);
349
+ flex-shrink:0;
350
+ position:relative;
351
+ z-index:2;
282
352
  }
283
353
  .forge-footer .footer-links{
284
354
  display:flex;gap:14px;justify-content:center;margin-top:6px;font-size:.58rem;
@@ -426,6 +496,82 @@ a{color:var(--neon-cyan);text-decoration:none}
426
496
  .forge-page-btn:disabled{opacity:.3;cursor:not-allowed}
427
497
  .forge-page-info{font-size:.58rem;color:var(--dim);font-family:var(--font-mono)}
428
498
 
499
+ /* ── OpenClaw Env Modal ─────────────────────────────────── */
500
+ .forge-env-btn{background:rgba(99,215,255,.08);border-color:rgba(99,215,255,.26);color:var(--neon-cyan)}
501
+ .forge-env-btn:hover{background:rgba(99,215,255,.14)}
502
+ .forge-env-backdrop{
503
+ position:fixed;inset:0;z-index:17;background:rgba(0,0,0,.55);
504
+ opacity:0;pointer-events:none;transition:opacity .2s ease;
505
+ }
506
+ .forge-env-backdrop[data-open="1"]{opacity:1;pointer-events:auto}
507
+ .forge-env-modal{
508
+ position:fixed;top:12%;right:22px;z-index:18;
509
+ width:min(620px,calc(100vw - 44px));max-height:76vh;
510
+ display:flex;flex-direction:column;
511
+ border:1px solid var(--border);border-radius:10px;
512
+ background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(0,0,0,.25)),var(--surface2);
513
+ box-shadow:0 18px 44px rgba(0,0,0,.55);
514
+ transform:translateY(-14px) scale(.98);
515
+ opacity:0;pointer-events:none;transition:all .2s ease;
516
+ }
517
+ .forge-env-modal[data-open="1"]{transform:translateY(0) scale(1);opacity:1;pointer-events:auto}
518
+ .forge-env-header{
519
+ padding:11px 14px;border-bottom:1px solid var(--border);
520
+ display:flex;align-items:center;justify-content:space-between;gap:10px;
521
+ }
522
+ .forge-env-title{font-size:.9rem;font-weight:700;color:var(--text);font-family:var(--font-display)}
523
+ .forge-env-close{
524
+ width:28px;height:28px;border-radius:7px;border:1px solid var(--border);
525
+ background:rgba(0,0,0,.45);color:var(--dim);cursor:pointer;
526
+ }
527
+ .forge-env-close:hover{color:var(--text);border-color:var(--neon-pink)}
528
+ .forge-env-note{
529
+ padding:10px 14px;border-bottom:1px solid var(--border);
530
+ font-size:.67rem;color:var(--dim);line-height:1.55;
531
+ }
532
+ .forge-env-status{
533
+ margin:10px 14px 2px;padding:7px 10px;border-radius:7px;
534
+ border:1px solid var(--border);font-family:var(--font-mono);font-size:.62rem;
535
+ background:rgba(0,0,0,.25);color:var(--dim);
536
+ }
537
+ .forge-env-status[data-state="ok"]{
538
+ border-color:rgba(0,255,100,.28);color:#9effc8;background:rgba(0,255,100,.07);
539
+ }
540
+ .forge-env-status[data-state="warn"]{
541
+ border-color:rgba(255,178,71,.32);color:#ffcf9a;background:rgba(255,178,71,.08);
542
+ }
543
+ .forge-env-status[data-state="err"]{
544
+ border-color:rgba(255,51,51,.3);color:#ff9e9e;background:rgba(255,51,51,.08);
545
+ }
546
+ .forge-env-form{
547
+ padding:10px 14px;overflow:auto;display:flex;flex-direction:column;gap:9px;
548
+ }
549
+ .forge-env-row{display:grid;grid-template-columns:220px 1fr;gap:10px;align-items:center}
550
+ .forge-env-key{font-family:var(--font-mono);font-size:.63rem;color:var(--text)}
551
+ .forge-env-value-wrap{display:flex;align-items:center;gap:8px}
552
+ .forge-env-input{
553
+ width:100%;height:34px;border-radius:7px;border:1px solid var(--border);
554
+ background:rgba(0,0,0,.35);color:var(--text);padding:0 10px;
555
+ font-family:var(--font-mono);font-size:.67rem;
556
+ }
557
+ .forge-env-input:focus{outline:none;border-color:var(--accent)}
558
+ .forge-env-subtitle{
559
+ margin:6px 0 2px;font-family:var(--font-mono);font-size:.62rem;
560
+ color:var(--dim);text-transform:uppercase;letter-spacing:.04em;
561
+ }
562
+ .forge-env-add-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px}
563
+ .forge-env-remove{
564
+ height:34px;padding:0 10px;border-radius:7px;border:1px solid rgba(255,51,51,.3);
565
+ background:rgba(255,51,51,.08);color:#ff9e9e;font-size:.62rem;cursor:pointer;
566
+ }
567
+ .forge-env-remove:hover{background:rgba(255,51,51,.15)}
568
+ .forge-env-footer{
569
+ padding:10px 14px;border-top:1px solid var(--border);
570
+ display:flex;justify-content:flex-end;gap:8px;
571
+ }
572
+ .forge-btn-secondary{border-color:var(--border);color:var(--dim);background:rgba(255,255,255,.03)}
573
+ .forge-btn-secondary:hover{background:rgba(255,255,255,.08);color:var(--text)}
574
+
429
575
  /* ── Toast Notifications ────────────────────────────────── */
430
576
  .forge-toast-container{
431
577
  position:fixed;bottom:24px;right:24px;z-index:100;
@@ -528,10 +674,15 @@ a{color:var(--neon-cyan);text-decoration:none}
528
674
  @media(max-width:979px){
529
675
  .forge-viewport{min-height:50vh}
530
676
  .forge-header{flex-wrap:wrap;gap:8px}
677
+ .forge-env-modal{top:8%;right:12px;width:calc(100vw - 24px)}
678
+ .forge-env-row{grid-template-columns:1fr;gap:5px}
679
+ .forge-chat-header{flex-wrap:wrap}
680
+ .forge-gateway-chip{order:3;margin-left:0}
531
681
  }
532
682
  @media(max-width:639px){
533
683
  .forge-viewport{min-height:40vh}
534
684
  .forge-hud{top:8px;right:8px}
535
685
  .forge-header{padding:10px 14px}
536
686
  .forge-progress-bar{width:80px}
687
+ .forge-env-modal{top:5%;max-height:84vh}
537
688
  }
@@ -55,6 +55,9 @@
55
55
  <button class="forge-hud-btn forge-share-btn" id="forgeShareBtn" type="button" data-ac-tip="Share to X">
56
56
  <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
57
57
  </button>
58
+ <button class="forge-hud-btn forge-env-btn" id="forgeEnvBtn" type="button" data-ac-tip="OpenClaw env">
59
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .17 1.7 1.7 0 0 0-.78 1.45V21a2 2 0 1 1-4 0v-.08a1.7 1.7 0 0 0-.78-1.45 1.7 1.7 0 0 0-1-.17 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.17-1 1.7 1.7 0 0 0-1.45-.78H3a2 2 0 1 1 0-4h.08a1.7 1.7 0 0 0 1.45-.78 1.7 1.7 0 0 0 .17-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.17 1.7 1.7 0 0 0 .78-1.45V3a2 2 0 1 1 4 0v.08a1.7 1.7 0 0 0 .78 1.45 1.7 1.7 0 0 0 1 .17 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 .17 1 1.7 1.7 0 0 0 1.45.78H21a2 2 0 1 1 0 4h-.08a1.7 1.7 0 0 0-1.45.78 1.7 1.7 0 0 0-.17 1z"/></svg>
60
+ </button>
58
61
  </div>
59
62
  <div class="forge-watermark" id="forgeWatermark">
60
63
  <span class="forge-watermark-logo">APECLAW</span>
@@ -94,8 +97,13 @@
94
97
  <div class="forge-chat-header">
95
98
  <span class="icon">&#x1F4AC;</span> Agent Chat
96
99
  <span class="badge" id="forgeChatBadge">0</span>
100
+ <span class="forge-gateway-chip" id="forgeGatewayChip" data-state="loading">Gateway: checking...</span>
101
+ <div class="forge-chat-actions">
102
+ <button class="forge-chat-action-btn" id="forgeGatewayRefresh" type="button">Refresh</button>
103
+ <button class="forge-chat-action-btn" id="forgeGatewayRestart" type="button">Restart</button>
104
+ </div>
97
105
  </div>
98
- <div class="forge-chat-agent-indicator" id="forgeChatAgentIndicator" style="display:none;padding:4px 10px;font-size:11px;opacity:.7;font-family:'JetBrains Mono',monospace;">&#x1F99E; Talking to The Clawllector (OpenClaw agent)</div>
106
+ <div class="forge-chat-agent-indicator" id="forgeChatAgentIndicator" style="display:none;padding:4px 10px;font-size:11px;opacity:.7;font-family:'JetBrains Mono',monospace;">Connected via OpenClaw Gateway (main session)</div>
99
107
  <div class="forge-chat-messages" id="forgeChatMessages">
100
108
  <div class="chat-empty">
101
109
  <div class="chat-empty-icon">&#x1F4AC;</div>
@@ -106,6 +114,11 @@
106
114
  <input type="text" id="forgeChatInput" placeholder="Message your agent..." maxlength="500">
107
115
  <button class="chat-send-btn" id="forgeChatSendBtn" type="button">Send</button>
108
116
  </div>
117
+ <div class="forge-chat-quick" id="forgeChatQuick">
118
+ <button class="forge-chat-quick-btn" data-prompt="Show my installed skills and what each one can do.">Installed skills</button>
119
+ <button class="forge-chat-quick-btn" data-prompt="Check OpenClaw gateway health and summarize any issue.">Gateway health</button>
120
+ <button class="forge-chat-quick-btn" data-prompt="Recommend three high-impact skills I should install next.">Next skills</button>
121
+ </div>
109
122
  <div class="chat-input-meta">
110
123
  <span class="chat-counter" id="forgeChatCounter">0/500</span>
111
124
  </div>
@@ -124,7 +137,7 @@
124
137
  <input type="text" id="forgeAuthAgentId" placeholder="Agent ID" autocomplete="off">
125
138
  <input type="password" id="forgeAuthAgentToken" placeholder="Agent Token" autocomplete="off">
126
139
  </div>
127
- <div class="forge-auth-hint">Required for install/uninstall. Get from <code>ape-claw clawbot list</code></div>
140
+ <div class="forge-auth-hint">Optional for local install/uninstall. Add credentials only if you want global dashboard/telemetry posting. Get from <code>ape-claw clawbot list</code></div>
128
141
  </div>
129
142
  <div class="forge-drawer-search">
130
143
  <input type="search" id="forgeSkillSearch" placeholder="Search Library of Alexandria skills..." autocomplete="off">
@@ -150,6 +163,26 @@
150
163
  <div class="forge-drawer-pagination" id="forgeDrawerPagination"></div>
151
164
  </aside>
152
165
 
166
+ <!-- OpenClaw env modal -->
167
+ <div class="forge-env-backdrop" id="forgeEnvBackdrop"></div>
168
+ <aside class="forge-env-modal" id="forgeEnvModal" data-open="0">
169
+ <div class="forge-env-header">
170
+ <h2 class="forge-env-title">OpenClaw Gateway Env</h2>
171
+ <button class="forge-env-close" id="forgeEnvClose" type="button">&times;</button>
172
+ </div>
173
+ <div class="forge-env-note" id="forgeEnvNote">
174
+ Update model/provider env values used by OpenClaw and Forge takeover mode.
175
+ </div>
176
+ <div class="forge-env-status" id="forgeEnvProviderStatus" data-state="loading">
177
+ Checking active provider...
178
+ </div>
179
+ <div class="forge-env-form" id="forgeEnvForm"></div>
180
+ <div class="forge-env-footer">
181
+ <button class="forge-btn forge-btn-secondary" id="forgeEnvReload" type="button">Reload</button>
182
+ <button class="forge-btn forge-btn-accent" id="forgeEnvSave" type="button">Save & Apply</button>
183
+ </div>
184
+ </aside>
185
+
153
186
  <!-- Toast notifications -->
154
187
  <div class="forge-toast-container" id="forgeToasts"></div>
155
188
 
@@ -193,8 +226,9 @@
193
226
  <div class="forge-offline-step">
194
227
  <span class="forge-offline-step-num">2</span>
195
228
  <div>
196
- <strong>Clone the repo &amp; start the server</strong>
197
- <code>git clone https://github.com/simplefarmer69/ape-claw.git && cd ape-claw && npm install && npm run start:ui</code>
229
+ <strong>Start local Forge (no repo required)</strong>
230
+ <code>npx ape-claw dashboard</code>
231
+ <span class="forge-offline-hint">This fetches/runs the packaged Forge UI server from npm and opens your local dashboard.</span>
198
232
  </div>
199
233
  </div>
200
234
  <div class="forge-offline-step">
@@ -202,14 +236,14 @@
202
236
  <div>
203
237
  <strong>Open the Forge</strong>
204
238
  <code>http://localhost:8787/forge</code>
205
- <span class="forge-offline-hint">Your installed skills appear as 3D attachments on the robot automatically</span>
239
+ <span class="forge-offline-hint">Your installed skills appear as 3D attachments on the robot automatically. Optional dev setup: clone GitHub and run <code>npm run start:ui</code>.</span>
206
240
  </div>
207
241
  </div>
208
242
  </div>
209
243
  <div class="forge-offline-divider"></div>
210
244
  <p class="forge-offline-note">
211
245
  The Forge reads skills installed via <code>npx ape-claw skill install</code>.<br>
212
- Skills are installed to <code>~/.openclaw/skills/</code> and synced to <code>~/.openclaw/workspace/skills/</code> automatically. <strong>Requires <a href="https://openclaw.ai" style="color:var(--accent)">OpenClaw</a>.</strong>
246
+ Skills are installed to your active OpenClaw profile (for example <code>~/.openclaw/skills/</code>) and synced to that profile workspace automatically. <strong>Requires <a href="https://openclaw.ai" style="color:var(--accent)">OpenClaw</a>.</strong>
213
247
  </p>
214
248
  <div class="forge-offline-links">
215
249
  <button class="forge-offline-btn forge-offline-btn-agent" id="forgeTryAgent" type="button">&#x1F99E; Try Our Agent</button>