@chrysb/alphaclaw 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.
Files changed (53) hide show
  1. package/bin/alphaclaw.js +338 -0
  2. package/lib/public/icons/chevron-down.svg +9 -0
  3. package/lib/public/js/app.js +325 -0
  4. package/lib/public/js/components/badge.js +16 -0
  5. package/lib/public/js/components/channels.js +36 -0
  6. package/lib/public/js/components/credentials-modal.js +336 -0
  7. package/lib/public/js/components/device-pairings.js +72 -0
  8. package/lib/public/js/components/envars.js +354 -0
  9. package/lib/public/js/components/gateway.js +163 -0
  10. package/lib/public/js/components/google.js +223 -0
  11. package/lib/public/js/components/icons.js +23 -0
  12. package/lib/public/js/components/models.js +461 -0
  13. package/lib/public/js/components/pairings.js +74 -0
  14. package/lib/public/js/components/scope-picker.js +106 -0
  15. package/lib/public/js/components/toast.js +31 -0
  16. package/lib/public/js/components/welcome.js +541 -0
  17. package/lib/public/js/hooks/usePolling.js +29 -0
  18. package/lib/public/js/lib/api.js +196 -0
  19. package/lib/public/js/lib/model-config.js +88 -0
  20. package/lib/public/login.html +90 -0
  21. package/lib/public/setup.html +33 -0
  22. package/lib/scripts/systemctl +56 -0
  23. package/lib/server/auth-profiles.js +101 -0
  24. package/lib/server/commands.js +84 -0
  25. package/lib/server/constants.js +282 -0
  26. package/lib/server/env.js +78 -0
  27. package/lib/server/gateway.js +262 -0
  28. package/lib/server/helpers.js +192 -0
  29. package/lib/server/login-throttle.js +86 -0
  30. package/lib/server/onboarding/cron.js +51 -0
  31. package/lib/server/onboarding/github.js +49 -0
  32. package/lib/server/onboarding/index.js +127 -0
  33. package/lib/server/onboarding/openclaw.js +171 -0
  34. package/lib/server/onboarding/validation.js +107 -0
  35. package/lib/server/onboarding/workspace.js +52 -0
  36. package/lib/server/openclaw-version.js +179 -0
  37. package/lib/server/routes/auth.js +80 -0
  38. package/lib/server/routes/codex.js +204 -0
  39. package/lib/server/routes/google.js +390 -0
  40. package/lib/server/routes/models.js +68 -0
  41. package/lib/server/routes/onboarding.js +116 -0
  42. package/lib/server/routes/pages.js +21 -0
  43. package/lib/server/routes/pairings.js +134 -0
  44. package/lib/server/routes/proxy.js +29 -0
  45. package/lib/server/routes/system.js +213 -0
  46. package/lib/server.js +161 -0
  47. package/lib/setup/core-prompts/AGENTS.md +22 -0
  48. package/lib/setup/core-prompts/TOOLS.md +18 -0
  49. package/lib/setup/env.template +19 -0
  50. package/lib/setup/gitignore +12 -0
  51. package/lib/setup/hourly-git-sync.sh +86 -0
  52. package/lib/setup/skills/control-ui/SKILL.md +70 -0
  53. package/package.json +34 -0
@@ -0,0 +1,461 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import {
5
+ fetchEnvVars,
6
+ saveEnvVars,
7
+ fetchModels,
8
+ fetchModelStatus,
9
+ setPrimaryModel,
10
+ fetchCodexStatus,
11
+ disconnectCodex,
12
+ exchangeCodexOAuth,
13
+ } from "../lib/api.js";
14
+ import { showToast } from "./toast.js";
15
+ import { Badge } from "./badge.js";
16
+ import {
17
+ getModelProvider,
18
+ getAuthProviderFromModelProvider,
19
+ getFeaturedModels,
20
+ kProviderAuthFields,
21
+ kProviderLabels,
22
+ kProviderOrder,
23
+ } from "../lib/model-config.js";
24
+
25
+ const html = htm.bind(h);
26
+
27
+ const getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || "";
28
+ const kAiCredentialKeys = Object.values(kProviderAuthFields)
29
+ .flat()
30
+ .map((field) => field.key)
31
+ .filter((key, idx, arr) => arr.indexOf(key) === idx);
32
+ let kModelsTabCache = null;
33
+
34
+ export const Models = () => {
35
+ const [envVars, setEnvVars] = useState(() => kModelsTabCache?.envVars || []);
36
+ const [models, setModels] = useState(() => kModelsTabCache?.models || []);
37
+ const [selectedModel, setSelectedModel] = useState(() => kModelsTabCache?.selectedModel || "");
38
+ const [showAllModels, setShowAllModels] = useState(() => kModelsTabCache?.showAllModels || false);
39
+ const [savingChanges, setSavingChanges] = useState(false);
40
+ const [codexStatus, setCodexStatus] = useState(() => kModelsTabCache?.codexStatus || { connected: false });
41
+ const [codexManualInput, setCodexManualInput] = useState("");
42
+ const [codexExchanging, setCodexExchanging] = useState(false);
43
+ const [codexAuthStarted, setCodexAuthStarted] = useState(false);
44
+ const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);
45
+ const [modelsLoading, setModelsLoading] = useState(() => !kModelsTabCache);
46
+ const [modelsError, setModelsError] = useState(() => kModelsTabCache?.modelsError || "");
47
+ const [ready, setReady] = useState(() => !!kModelsTabCache);
48
+ const [savedModel, setSavedModel] = useState(() => kModelsTabCache?.savedModel || "");
49
+ const [modelDirty, setModelDirty] = useState(false);
50
+ const [savedAiValues, setSavedAiValues] = useState(() => kModelsTabCache?.savedAiValues || {});
51
+ const codexPopupPollRef = useRef(null);
52
+
53
+ const refresh = async () => {
54
+ if (!ready) setModelsLoading(true);
55
+ setModelsError("");
56
+ try {
57
+ const [env, modelCatalog, modelStatus, codex] = await Promise.all([
58
+ fetchEnvVars(),
59
+ fetchModels(),
60
+ fetchModelStatus(),
61
+ fetchCodexStatus(),
62
+ ]);
63
+ setEnvVars(env.vars || []);
64
+ const catalogModels = Array.isArray(modelCatalog.models) ? modelCatalog.models : [];
65
+ setModels(catalogModels);
66
+ const currentModel = modelStatus.modelKey || "";
67
+ setSelectedModel(currentModel);
68
+ setCodexStatus(codex || { connected: false });
69
+ setSavedModel(currentModel);
70
+ setModelDirty(false);
71
+ const nextSavedAiValues = Object.fromEntries(
72
+ kAiCredentialKeys.map((key) => [key, getKeyVal(env.vars || [], key)]),
73
+ );
74
+ setSavedAiValues(nextSavedAiValues);
75
+ const nextModelsError = catalogModels.length ? "" : "No models found";
76
+ setModelsError(nextModelsError);
77
+ kModelsTabCache = {
78
+ envVars: env.vars || [],
79
+ models: catalogModels,
80
+ selectedModel: currentModel,
81
+ savedModel: currentModel,
82
+ savedAiValues: nextSavedAiValues,
83
+ codexStatus: codex || { connected: false },
84
+ showAllModels,
85
+ modelsError: nextModelsError,
86
+ };
87
+ } catch (err) {
88
+ setModelsError("Failed to load model settings");
89
+ showToast(`Failed to load model settings: ${err.message}`, "red");
90
+ } finally {
91
+ setReady(true);
92
+ setModelsLoading(false);
93
+ }
94
+ };
95
+
96
+ const refreshCodexConnection = async () => {
97
+ try {
98
+ const codex = await fetchCodexStatus();
99
+ setCodexStatus(codex || { connected: false });
100
+ if (codex?.connected) {
101
+ setCodexAuthStarted(false);
102
+ setCodexAuthWaiting(false);
103
+ }
104
+ kModelsTabCache = { ...(kModelsTabCache || {}), codexStatus: codex || { connected: false } };
105
+ } catch {
106
+ setCodexStatus({ connected: false });
107
+ kModelsTabCache = { ...(kModelsTabCache || {}), codexStatus: { connected: false } };
108
+ }
109
+ };
110
+
111
+ useEffect(() => {
112
+ refresh();
113
+ }, []);
114
+
115
+ useEffect(() => () => {
116
+ if (codexPopupPollRef.current) {
117
+ clearInterval(codexPopupPollRef.current);
118
+ codexPopupPollRef.current = null;
119
+ }
120
+ }, []);
121
+
122
+ useEffect(() => {
123
+ const onMessage = async (e) => {
124
+ if (e.data?.codex === "success") {
125
+ showToast("Codex connected", "green");
126
+ await refreshCodexConnection();
127
+ } else if (e.data?.codex === "error") {
128
+ showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "red");
129
+ }
130
+ };
131
+ window.addEventListener("message", onMessage);
132
+ return () => window.removeEventListener("message", onMessage);
133
+ }, []);
134
+
135
+ const setEnvValue = (key, value) => {
136
+ setEnvVars((prev) => {
137
+ const next = prev.map((v) => (v.key === key ? { ...v, value } : v));
138
+ kModelsTabCache = { ...(kModelsTabCache || {}), envVars: next };
139
+ return next;
140
+ });
141
+ };
142
+
143
+ const saveChanges = async () => {
144
+ if (savingChanges) return;
145
+ if (!modelDirty && !aiCredentialsDirty) return;
146
+ if (modelDirty && !hasSelectedProviderAuth) {
147
+ showToast("Add credentials for the selected model provider before saving model changes", "red");
148
+ return;
149
+ }
150
+ setSavingChanges(true);
151
+ try {
152
+ const targetModel = selectedModel;
153
+
154
+ if (aiCredentialsDirty) {
155
+ const payload = envVars
156
+ .filter((v) => v.editable)
157
+ .map((v) => ({ key: v.key, value: v.value }));
158
+ const envResult = await saveEnvVars(payload);
159
+ if (!envResult.ok) throw new Error(envResult.error || "Failed to save env vars");
160
+ }
161
+
162
+ if (modelDirty && targetModel) {
163
+ const modelResult = await setPrimaryModel(targetModel);
164
+ if (!modelResult.ok) throw new Error(modelResult.error || "Failed to set primary model");
165
+ const status = await fetchModelStatus();
166
+ if (status?.ok === false) {
167
+ throw new Error(status.error || "Failed to verify primary model");
168
+ }
169
+ const activeModel = status?.modelKey || "";
170
+ if (activeModel && activeModel !== targetModel) {
171
+ throw new Error(`Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`);
172
+ }
173
+ setSavedModel(targetModel);
174
+ setModelDirty(false);
175
+ kModelsTabCache = { ...(kModelsTabCache || {}), selectedModel: targetModel, savedModel: targetModel };
176
+ }
177
+
178
+ showToast("Changes saved", "green");
179
+ await refresh();
180
+ } catch (err) {
181
+ showToast(err.message || "Failed to save changes", "red");
182
+ } finally {
183
+ setSavingChanges(false);
184
+ }
185
+ };
186
+
187
+ const startCodexAuth = () => {
188
+ if (codexStatus.connected) return;
189
+ setCodexAuthStarted(true);
190
+ setCodexAuthWaiting(true);
191
+ const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780");
192
+ if (!popup || popup.closed) {
193
+ setCodexAuthWaiting(false);
194
+ window.location.href = "/auth/codex/start";
195
+ return;
196
+ }
197
+ if (codexPopupPollRef.current) {
198
+ clearInterval(codexPopupPollRef.current);
199
+ }
200
+ codexPopupPollRef.current = setInterval(() => {
201
+ if (popup.closed) {
202
+ clearInterval(codexPopupPollRef.current);
203
+ codexPopupPollRef.current = null;
204
+ setCodexAuthWaiting(false);
205
+ }
206
+ }, 500);
207
+ };
208
+
209
+ const completeCodexAuth = async () => {
210
+ if (!codexManualInput.trim() || codexExchanging) return;
211
+ setCodexExchanging(true);
212
+ try {
213
+ const result = await exchangeCodexOAuth(codexManualInput.trim());
214
+ if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed");
215
+ setCodexManualInput("");
216
+ showToast("Codex connected", "green");
217
+ setCodexAuthStarted(false);
218
+ setCodexAuthWaiting(false);
219
+ await refreshCodexConnection();
220
+ } catch (err) {
221
+ showToast(err.message || "Codex OAuth exchange failed", "red");
222
+ } finally {
223
+ setCodexExchanging(false);
224
+ }
225
+ };
226
+
227
+ const handleCodexDisconnect = async () => {
228
+ const result = await disconnectCodex();
229
+ if (!result.ok) {
230
+ showToast(result.error || "Failed to disconnect Codex", "red");
231
+ return;
232
+ }
233
+ showToast("Codex disconnected", "green");
234
+ setCodexAuthStarted(false);
235
+ setCodexAuthWaiting(false);
236
+ setCodexManualInput("");
237
+ await refreshCodexConnection();
238
+ };
239
+
240
+ const selectedModelProvider = getModelProvider(selectedModel);
241
+ const selectedAuthProvider = getAuthProviderFromModelProvider(selectedModelProvider);
242
+ const featuredModels = getFeaturedModels(models);
243
+ const baseModelOptions = showAllModels
244
+ ? models
245
+ : featuredModels.length > 0
246
+ ? featuredModels
247
+ : models;
248
+ const selectedModelOption = models.find((model) => model.key === selectedModel);
249
+ const modelOptions =
250
+ selectedModelOption &&
251
+ !baseModelOptions.some((model) => model.key === selectedModelOption.key)
252
+ ? [...baseModelOptions, selectedModelOption]
253
+ : baseModelOptions;
254
+ const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length;
255
+ const primaryProvider = kProviderOrder.includes(selectedAuthProvider)
256
+ ? selectedAuthProvider
257
+ : kProviderOrder[0];
258
+ const otherProviders = kProviderOrder.filter((provider) => provider !== primaryProvider);
259
+ const aiCredentialsDirty = kAiCredentialKeys.some(
260
+ (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""),
261
+ );
262
+ const hasSelectedProviderAuth =
263
+ selectedModelProvider === "anthropic"
264
+ ? !!(getKeyVal(envVars, "ANTHROPIC_API_KEY") || getKeyVal(envVars, "ANTHROPIC_TOKEN"))
265
+ : selectedModelProvider === "openai"
266
+ ? !!getKeyVal(envVars, "OPENAI_API_KEY")
267
+ : selectedModelProvider === "openai-codex"
268
+ ? !!(codexStatus.connected || getKeyVal(envVars, "OPENAI_API_KEY"))
269
+ : selectedModelProvider === "google"
270
+ ? !!getKeyVal(envVars, "GEMINI_API_KEY")
271
+ : false;
272
+ const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));
273
+
274
+ const renderCredentialField = (field) => html`
275
+ <div class="space-y-1">
276
+ <label class="text-xs font-medium text-gray-400">${field.label}</label>
277
+ <input
278
+ type="password"
279
+ placeholder=${field.placeholder || ""}
280
+ value=${getKeyVal(envVars, field.key)}
281
+ onInput=${(e) => setEnvValue(field.key, e.target.value)}
282
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
283
+ />
284
+ <p class="text-xs text-gray-600">${field.hint}</p>
285
+ </div>
286
+ `;
287
+
288
+ const renderProviderContent = (provider) => {
289
+ const fields = kProviderAuthFields[provider] || [];
290
+ const hasCodex = provider === "openai";
291
+ return html`
292
+ ${fields.map((field) => renderCredentialField(field))}
293
+ ${hasCodex &&
294
+ html`
295
+ <div class="border border-border rounded-lg p-3 space-y-2">
296
+ <div class="flex items-center justify-between">
297
+ <span class="text-xs text-gray-400">Codex OAuth</span>
298
+ ${codexStatus.connected
299
+ ? html`<${Badge} tone="success">Connected</${Badge}>`
300
+ : html`<${Badge} tone="warning">Not connected</${Badge}>`}
301
+ </div>
302
+ ${codexStatus.connected
303
+ ? html`
304
+ <div class="flex gap-2">
305
+ <button
306
+ onclick=${startCodexAuth}
307
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
308
+ >
309
+ Reconnect Codex
310
+ </button>
311
+ <button
312
+ onclick=${handleCodexDisconnect}
313
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
314
+ >
315
+ Disconnect
316
+ </button>
317
+ </div>
318
+ `
319
+ : !codexAuthStarted
320
+ ? html`
321
+ <button
322
+ onclick=${startCodexAuth}
323
+ class="text-xs font-medium px-3 py-1.5 rounded-lg bg-white text-black hover:opacity-85"
324
+ >
325
+ Connect Codex OAuth
326
+ </button>
327
+ `
328
+ : html`
329
+ <div class="flex items-center justify-between gap-2">
330
+ <p class="text-xs text-gray-500">
331
+ ${codexAuthWaiting
332
+ ? "Complete login in the popup, then paste the redirect URL."
333
+ : "Paste the redirect URL from your browser to finish connecting."}
334
+ </p>
335
+ <button
336
+ onclick=${startCodexAuth}
337
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500 shrink-0"
338
+ >
339
+ Restart
340
+ </button>
341
+ </div>
342
+ `}
343
+ ${!codexStatus.connected && codexAuthStarted
344
+ ? html`
345
+ <p class="text-xs text-gray-500">
346
+ After login, copy the full redirect URL (starts with
347
+ <code class="text-xs bg-black/30 px-1 rounded">http://localhost:1455/auth/callback</code>)
348
+ and paste it here.
349
+ </p>
350
+ <input
351
+ type="text"
352
+ value=${codexManualInput}
353
+ onInput=${(e) => setCodexManualInput(e.target.value)}
354
+ placeholder="http://localhost:1455/auth/callback?code=...&state=..."
355
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
356
+ />
357
+ <button
358
+ onclick=${completeCodexAuth}
359
+ disabled=${!codexManualInput.trim() || codexExchanging}
360
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ${!codexManualInput.trim() || codexExchanging ? "bg-gray-700 text-gray-400 cursor-not-allowed" : "bg-white text-black hover:opacity-85"}"
361
+ >
362
+ ${codexExchanging ? "Completing..." : "Complete Codex OAuth"}
363
+ </button>
364
+ `
365
+ : null}
366
+ </div>
367
+ `}
368
+ `;
369
+ };
370
+
371
+ if (!ready) {
372
+ return html`
373
+ <div class="bg-surface border border-border rounded-xl p-4">
374
+ <div class="flex items-center gap-2 text-sm text-gray-400">
375
+ <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
376
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
377
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
378
+ </svg>
379
+ Loading model settings...
380
+ </div>
381
+ </div>
382
+ `;
383
+ }
384
+
385
+ return html`
386
+ <div class="space-y-4">
387
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
388
+ <h2 class="font-semibold text-sm">Primary Agent Model</h2>
389
+ <select
390
+ value=${selectedModel}
391
+ onInput=${(e) => {
392
+ const next = e.target.value;
393
+ setSelectedModel(next);
394
+ setModelDirty(next !== savedModel);
395
+ kModelsTabCache = { ...(kModelsTabCache || {}), selectedModel: next };
396
+ }}
397
+ class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
398
+ >
399
+ <option value="">Select a model</option>
400
+ ${modelOptions.map(
401
+ (model) => html`<option value=${model.key}>${model.label || model.key}</option>`,
402
+ )}
403
+ </select>
404
+ <p class="text-xs text-gray-600">
405
+ ${modelsLoading ? "Loading model catalog..." : modelsError ? modelsError : ""}
406
+ </p>
407
+ ${canToggleFullCatalog
408
+ ? html`
409
+ <div>
410
+ <button
411
+ type="button"
412
+ onclick=${() =>
413
+ setShowAllModels((prev) => {
414
+ const next = !prev;
415
+ kModelsTabCache = { ...(kModelsTabCache || {}), showAllModels: next };
416
+ return next;
417
+ })}
418
+ class="text-xs text-gray-500 hover:text-gray-300"
419
+ >
420
+ ${showAllModels ? "Show recommended models" : "Show full model catalog"}
421
+ </button>
422
+ </div>
423
+ `
424
+ : null}
425
+ <div class="pt-2 border-t border-border space-y-3">
426
+ ${renderProviderContent(primaryProvider)}
427
+ </div>
428
+ </div>
429
+
430
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
431
+ <h2 class="font-semibold text-sm">Other Providers</h2>
432
+ ${otherProviders.map(
433
+ (provider) => html`
434
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-3">
435
+ <h3 class="text-xs font-semibold text-gray-300">${kProviderLabels[provider] || provider}</h3>
436
+ ${renderProviderContent(provider)}
437
+ </div>
438
+ `,
439
+ )}
440
+ </div>
441
+
442
+ <button
443
+ onclick=${saveChanges}
444
+ disabled=${!canSaveChanges}
445
+ class="w-full text-sm font-medium px-4 py-2.5 rounded-xl transition-all ${canSaveChanges
446
+ ? "bg-white text-black hover:opacity-85"
447
+ : "bg-gray-800 text-gray-500 cursor-not-allowed"}"
448
+ >
449
+ ${savingChanges ? "Saving..." : "Save changes"}
450
+ </button>
451
+ ${modelDirty && !hasSelectedProviderAuth
452
+ ? html`
453
+ <p class="text-xs text-yellow-500">
454
+ Set credentials for the selected provider before saving this model change.
455
+ </p>
456
+ `
457
+ : null}
458
+
459
+ </div>
460
+ `;
461
+ };
@@ -0,0 +1,74 @@
1
+ import { h } from 'https://esm.sh/preact';
2
+ import { useState } from 'https://esm.sh/preact/hooks';
3
+ import htm from 'https://esm.sh/htm';
4
+ const html = htm.bind(h);
5
+
6
+ const PairingRow = ({ p, onApprove, onReject }) => {
7
+ const [busy, setBusy] = useState(null);
8
+
9
+ const handle = async (action) => {
10
+ setBusy(action);
11
+ try {
12
+ if (action === "approve") await onApprove(p.id, p.channel);
13
+ else await onReject(p.id, p.channel);
14
+ } catch {
15
+ setBusy(null);
16
+ }
17
+ };
18
+
19
+ const label = (p.channel || 'unknown').charAt(0).toUpperCase() + (p.channel || '').slice(1);
20
+
21
+ if (busy === "approve") {
22
+ return html`
23
+ <div class="bg-black/30 rounded-lg p-3 mb-2 flex items-center gap-2">
24
+ <span class="text-green-400 text-sm">Approved</span>
25
+ <span class="text-gray-500 text-xs">${label} · ${p.code || p.id || '?'}</span>
26
+ </div>`;
27
+ }
28
+ if (busy === "reject") {
29
+ return html`
30
+ <div class="bg-black/30 rounded-lg p-3 mb-2 flex items-center gap-2">
31
+ <span class="text-gray-400 text-sm">Rejected</span>
32
+ <span class="text-gray-500 text-xs">${label} · ${p.code || p.id || '?'}</span>
33
+ </div>`;
34
+ }
35
+
36
+ return html`
37
+ <div class="bg-black/30 rounded-lg p-3 mb-2">
38
+ <div class="font-medium text-sm mb-2">${label} · <code class="text-gray-400">${p.code || p.id || '?'}</code></div>
39
+ <div class="flex gap-2">
40
+ <button onclick=${() => handle("approve")} class="bg-green-500 text-black text-xs font-medium px-3 py-1.5 rounded-lg hover:opacity-85">Approve</button>
41
+ <button onclick=${() => handle("reject")} class="bg-gray-800 text-gray-300 text-xs px-3 py-1.5 rounded-lg hover:bg-gray-700">Reject</button>
42
+ </div>
43
+ </div>`;
44
+ };
45
+
46
+ const ALL_CHANNELS = ['telegram', 'discord'];
47
+
48
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
49
+
50
+ export function Pairings({ pending, channels, visible, onApprove, onReject }) {
51
+ if (!visible) return null;
52
+
53
+ const unpaired = ALL_CHANNELS
54
+ .filter((ch) => channels?.[ch] && channels[ch].status !== 'paired')
55
+ .map(capitalize);
56
+
57
+ const channelList = unpaired.length <= 2
58
+ ? unpaired.join(' or ')
59
+ : unpaired.slice(0, -1).join(', ') + ', or ' + unpaired[unpaired.length - 1];
60
+
61
+ return html`
62
+ <div class="bg-surface border border-border rounded-xl p-4">
63
+ <h2 class="font-semibold mb-3">Pending Pairings</h2>
64
+ ${pending.length > 0
65
+ ? html`<div>
66
+ ${pending.map(p => html`<${PairingRow} key=${p.id} p=${p} onApprove=${onApprove} onReject=${onReject} />`)}
67
+ </div>`
68
+ : html`<div class="text-center py-4 space-y-2">
69
+ <div class="text-3xl">💬</div>
70
+ <p class="text-gray-300 text-sm">Send a message to your bot on ${channelList}</p>
71
+ <p class="text-gray-600 text-xs">The pairing request will appear here — it may take a few moments</p>
72
+ </div>`}
73
+ </div>`;
74
+ }
@@ -0,0 +1,106 @@
1
+ import { h } from 'https://esm.sh/preact';
2
+ import { useState } from 'https://esm.sh/preact/hooks';
3
+ import htm from 'https://esm.sh/htm';
4
+ const html = htm.bind(h);
5
+
6
+ export const SERVICES = [
7
+ { key: 'gmail', icon: '📧', label: 'Gmail', defaultRead: true, defaultWrite: false },
8
+ { key: 'calendar', icon: '📅', label: 'Calendar', defaultRead: true, defaultWrite: true },
9
+ { key: 'drive', icon: '📁', label: 'Drive', defaultRead: true, defaultWrite: false },
10
+ { key: 'sheets', icon: '📊', label: 'Sheets', defaultRead: true, defaultWrite: false },
11
+ { key: 'docs', icon: '📝', label: 'Docs', defaultRead: true, defaultWrite: false },
12
+ { key: 'tasks', icon: '✅', label: 'Tasks', defaultRead: false, defaultWrite: false },
13
+ { key: 'contacts', icon: '👤', label: 'Contacts', defaultRead: false, defaultWrite: false },
14
+ { key: 'meet', icon: '🎥', label: 'Meet', defaultRead: false, defaultWrite: false },
15
+ ];
16
+
17
+ const API_ENABLE_URLS = {
18
+ gmail: 'gmail.googleapis.com',
19
+ calendar: 'calendar-json.googleapis.com',
20
+ tasks: 'tasks.googleapis.com',
21
+ drive: 'drive.googleapis.com',
22
+ contacts: 'people.googleapis.com',
23
+ sheets: 'sheets.googleapis.com',
24
+ docs: 'docs.googleapis.com',
25
+ meet: 'meet.googleapis.com',
26
+ };
27
+
28
+ function getApiEnableUrl(svc) {
29
+ return `https://console.developers.google.com/apis/api/${API_ENABLE_URLS[svc] || ''}/overview`;
30
+ }
31
+
32
+ export function ScopePicker({ scopes, onToggle, apiStatus, loading }) {
33
+ const [showAll, setShowAll] = useState(false);
34
+ const status = apiStatus || {};
35
+ const kVisibleCount = 5;
36
+ const hasMore = SERVICES.length > kVisibleCount;
37
+ const visibleServices = showAll ? SERVICES : SERVICES.slice(0, kVisibleCount);
38
+
39
+ return html`<div class="space-y-2">
40
+ ${visibleServices.map(s => {
41
+ const readOn = scopes.includes(`${s.key}:read`);
42
+ const writeOn = scopes.includes(`${s.key}:write`);
43
+ const api = status[s.key];
44
+ let apiIndicator = null;
45
+ if (loading && !api && (readOn || writeOn)) {
46
+ apiIndicator = html`<span class="text-gray-500 text-xs flex items-center gap-1"><span class="inline-block w-3 h-3 border-2 border-gray-500 border-t-transparent rounded-full animate-spin"></span></span>`;
47
+ } else if (api) {
48
+ if (api.status === 'ok') {
49
+ apiIndicator = html`<a href=${api.enableUrl || getApiEnableUrl(s.key)} target="_blank" class="text-green-500 hover:text-green-300 text-xs">✓ API</a>`;
50
+ } else if (api.status === 'not_enabled') {
51
+ apiIndicator = html`<a href=${api.enableUrl} target="_blank" class="text-red-400 hover:text-red-300 text-xs underline">Enable API</a>`;
52
+ } else if (api.status === 'error') {
53
+ apiIndicator = html`<a href=${api.enableUrl || getApiEnableUrl(s.key)} target="_blank" class="text-yellow-500 hover:text-yellow-300 text-xs underline">Enable API</a>`;
54
+ }
55
+ }
56
+
57
+ return html`
58
+ <div class="flex items-center justify-between bg-black/30 rounded-lg px-3 py-2">
59
+ <span class="text-sm">${s.icon} ${s.label}</span>
60
+ <div class="flex items-center gap-2">
61
+ ${apiIndicator}
62
+ <button onclick=${() => onToggle(`${s.key}:read`)} class="scope-btn ${readOn ? 'active' : ''} text-xs px-2 py-0.5 rounded">Read</button>
63
+ <button onclick=${() => onToggle(`${s.key}:write`)} class="scope-btn ${writeOn ? 'active' : ''} text-xs px-2 py-0.5 rounded">Write</button>
64
+ </div>
65
+ </div>`;
66
+ })}
67
+ ${hasMore ? html`
68
+ <button
69
+ type="button"
70
+ onclick=${() => setShowAll((prev) => !prev)}
71
+ class="text-xs text-gray-500 hover:text-gray-300"
72
+ >
73
+ ${showAll ? 'Show fewer services' : `Show more services (${SERVICES.length - kVisibleCount})`}
74
+ </button>
75
+ ` : null}
76
+ </div>`;
77
+ }
78
+
79
+ // Returns new scopes array after toggling, with read/write dependency logic
80
+ export function toggleScopeLogic(scopes, scope) {
81
+ const isActive = scopes.includes(scope);
82
+ let next = isActive ? scopes.filter(s => s !== scope) : [...scopes, scope];
83
+
84
+ if (scope.endsWith(':write') && !isActive) {
85
+ // enabling write → also enable read
86
+ const readScope = scope.replace(':write', ':read');
87
+ if (!next.includes(readScope)) next.push(readScope);
88
+ }
89
+ if (scope.endsWith(':read') && isActive) {
90
+ // disabling read → also disable write
91
+ const writeScope = scope.replace(':read', ':write');
92
+ next = next.filter(s => s !== writeScope);
93
+ }
94
+
95
+ return next;
96
+ }
97
+
98
+ // Get default scopes from SERVICES
99
+ export function getDefaultScopes() {
100
+ const scopes = [];
101
+ for (const s of SERVICES) {
102
+ if (s.defaultRead) scopes.push(`${s.key}:read`);
103
+ if (s.defaultWrite) scopes.push(`${s.key}:write`);
104
+ }
105
+ return scopes;
106
+ }
@@ -0,0 +1,31 @@
1
+ import { h } from 'https://esm.sh/preact';
2
+ import { useState, useEffect } from 'https://esm.sh/preact/hooks';
3
+ import htm from 'https://esm.sh/htm';
4
+ const html = htm.bind(h);
5
+
6
+ let toastId = 0;
7
+ let addToastFn = null;
8
+
9
+ export function showToast(text, color) {
10
+ if (addToastFn) addToastFn({ id: ++toastId, text, color });
11
+ }
12
+
13
+ export function ToastContainer() {
14
+ const [toasts, setToasts] = useState([]);
15
+
16
+ useEffect(() => {
17
+ addToastFn = (t) => {
18
+ setToasts(prev => [...prev, t]);
19
+ setTimeout(() => setToasts(prev => prev.filter(x => x.id !== t.id)), 4000);
20
+ };
21
+ return () => { addToastFn = null; };
22
+ }, []);
23
+
24
+ return html`<div class="fixed top-4 right-4 z-50 space-y-2">
25
+ ${toasts.map(t => html`
26
+ <div key=${t.id} class="bg-${t.color}-500/20 border border-${t.color}-500/30 text-${t.color}-400 px-4 py-2 rounded-lg text-sm">
27
+ ${t.text}
28
+ </div>
29
+ `)}
30
+ </div>`;
31
+ }