@aion0/forge 0.8.1 → 0.8.3

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 (45) hide show
  1. package/RELEASE_NOTES.md +6 -6
  2. package/app/api/connectors/[id]/settings/route.ts +31 -37
  3. package/app/api/connectors/[id]/test/route.ts +260 -0
  4. package/app/api/connectors/install-local/route.ts +211 -0
  5. package/app/api/connectors/marketplace/route.ts +79 -0
  6. package/app/api/connectors/route.ts +41 -46
  7. package/app/api/jobs/route.ts +4 -0
  8. package/app/api/skills/install-local/route.ts +282 -0
  9. package/components/ConnectorsPanel.tsx +526 -211
  10. package/components/SettingsModal.tsx +1 -0
  11. package/components/SkillsPanel.tsx +42 -1
  12. package/lib/agents/claude-adapter.ts +4 -0
  13. package/lib/agents/types.ts +6 -0
  14. package/lib/chat/agent-loop.ts +13 -22
  15. package/lib/chat/protocols/http.ts +1 -1
  16. package/lib/chat/protocols/shell.ts +1 -1
  17. package/lib/chat/tool-dispatcher.ts +20 -20
  18. package/lib/connectors/migration.ts +110 -0
  19. package/lib/connectors/registry.ts +328 -0
  20. package/lib/connectors/sync.ts +305 -0
  21. package/lib/connectors/types.ts +253 -0
  22. package/lib/help-docs/00-overview.md +1 -0
  23. package/lib/help-docs/17-connectors.md +241 -189
  24. package/lib/help-docs/21-build-connector.md +314 -0
  25. package/lib/help-docs/CLAUDE.md +4 -2
  26. package/lib/init.ts +25 -0
  27. package/lib/jobs/dispatcher.ts +28 -8
  28. package/lib/jobs/scheduler.ts +66 -6
  29. package/lib/jobs/store.ts +51 -2
  30. package/lib/jobs/types.ts +32 -0
  31. package/lib/pipeline-scheduler.ts +3 -2
  32. package/lib/pipeline.ts +137 -15
  33. package/lib/plugins/registry.ts +9 -42
  34. package/lib/plugins/types.ts +4 -129
  35. package/lib/settings.ts +7 -0
  36. package/lib/skills.ts +27 -1
  37. package/lib/task-manager.ts +62 -2
  38. package/package.json +4 -1
  39. package/src/core/db/database.ts +4 -0
  40. package/lib/builtin-plugins/github-api.yaml +0 -93
  41. package/lib/builtin-plugins/gitlab.yaml +0 -860
  42. package/lib/builtin-plugins/mantis.probe.js +0 -176
  43. package/lib/builtin-plugins/mantis.yaml +0 -964
  44. package/lib/builtin-plugins/pmdb.yaml +0 -178
  45. package/lib/builtin-plugins/teams.yaml +0 -913
@@ -1,19 +1,40 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ /**
4
+ * Connectors marketplace — lives in SkillsPanel's Connectors tab.
5
+ *
6
+ * Lists everything in the `forge-connectors` registry merged with the
7
+ * user's installed state. Two-pane layout (list + detail) mirroring
8
+ * the Skills tab:
9
+ * - Left: registry entries with status chip + Install/Update/Remove
10
+ * - Right: tools, description, settings form (PAT, base URL, …)
11
+ *
12
+ * Talks to:
13
+ * GET /api/connectors/marketplace
14
+ * POST /api/connectors/marketplace { action, id, refreshInstalled? }
15
+ * GET /api/connectors/<id>/settings
16
+ * POST /api/connectors/<id>/settings
17
+ */
4
18
 
5
- interface ConnectorTool {
19
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
+
21
+ interface MarketEntry {
22
+ id: string;
23
+ name: string;
24
+ version: string;
25
+ icon?: string;
6
26
  description?: string;
7
- parameters?: Record<string, { type: string; label?: string; description?: string; required?: boolean; default?: any }>;
8
- destructive?: boolean;
9
- returns?: string;
27
+ author?: string;
28
+ installed_version?: string;
29
+ update_available?: boolean;
30
+ compatible: boolean;
31
+ source: 'registry' | 'local';
10
32
  }
11
33
 
12
- interface ConnectorEntry {
13
- id: string;
14
- host_permissions?: string[];
15
- tools: Record<string, ConnectorTool>;
16
- settings?: Record<string, FieldSchema>;
34
+ interface MarketState {
35
+ fetched_at?: string;
36
+ base_url?: string;
37
+ entries: MarketEntry[];
17
38
  }
18
39
 
19
40
  interface FieldSchema {
@@ -25,250 +46,544 @@ interface FieldSchema {
25
46
  options?: string[];
26
47
  }
27
48
 
28
- interface ConnectorPayload {
49
+ interface ConnectorTool {
50
+ description?: string;
51
+ destructive?: boolean;
52
+ returns?: string;
53
+ protocol?: string;
54
+ }
55
+
56
+ interface ConnectorEntryShape {
57
+ id: string;
58
+ tools?: Record<string, ConnectorTool>;
59
+ }
60
+
61
+ interface ConnectorDetail {
29
62
  plugin_id: string;
30
63
  name: string;
31
64
  icon: string;
32
65
  version: string;
33
- author: string;
34
- description: string;
35
- mode: string;
66
+ description?: string;
67
+ author?: string;
36
68
  installed: boolean;
37
- entries: ConnectorEntry[];
69
+ entries: ConnectorEntryShape[];
70
+ has_test?: boolean;
71
+ test_description?: string;
72
+ }
73
+
74
+ interface TestResult {
75
+ ok: boolean;
76
+ status?: number;
77
+ message?: string;
78
+ error?: string;
79
+ duration_ms?: number;
38
80
  }
39
81
 
82
+ const SECRET_MASK = '••••••••';
83
+
40
84
  export default function ConnectorsPanel() {
41
- const [connectors, setConnectors] = useState<ConnectorPayload[]>([]);
42
- const [loading, setLoading] = useState(true);
85
+ const [state, setState] = useState<MarketState | null>(null);
86
+ const [busyId, setBusyId] = useState('');
87
+ const [syncing, setSyncing] = useState(false);
88
+ const [error, setError] = useState('');
89
+
43
90
  const [selectedId, setSelectedId] = useState<string | null>(null);
44
- const [detail, setDetail] = useState<ConnectorPayload | null>(null);
45
- const [settingsSchema, setSettingsSchema] = useState<Record<string, FieldSchema>>({});
46
- const [settingsValues, setSettingsValues] = useState<Record<string, any>>({});
91
+ const [detail, setDetail] = useState<ConnectorDetail | null>(null);
92
+ const [schema, setSchema] = useState<Record<string, FieldSchema>>({});
93
+ const [values, setValues] = useState<Record<string, any>>({});
47
94
  const [savedAt, setSavedAt] = useState<number | null>(null);
48
- const [filter, setFilter] = useState<'all' | 'installed'>('all');
49
95
 
50
- const fetchAll = useCallback(async () => {
51
- setLoading(true);
96
+ const [repoUrl, setRepoUrl] = useState<string>('');
97
+ const [repoUrlDirty, setRepoUrlDirty] = useState(false);
98
+
99
+ const [uploading, setUploading] = useState(false);
100
+ const [dragOver, setDragOver] = useState(false);
101
+ const fileInputRef = useRef<HTMLInputElement>(null);
102
+
103
+ const [testing, setTesting] = useState(false);
104
+ const [testResult, setTestResult] = useState<TestResult | null>(null);
105
+
106
+ // ─── Load ─────────────────────────────────────────────────
107
+ const refresh = useCallback(async () => {
52
108
  try {
53
- const res = await fetch('/api/connectors');
54
- const data = await res.json();
55
- setConnectors(data.connectors || []);
109
+ const r = await fetch('/api/connectors/marketplace');
110
+ setState((await r.json()) as MarketState);
56
111
  } catch (e) {
57
- console.error('[ConnectorsPanel] fetch failed', e);
58
- } finally {
59
- setLoading(false);
112
+ setError(e instanceof Error ? e.message : String(e));
60
113
  }
61
114
  }, []);
62
115
 
63
- const selectConnector = useCallback(async (id: string) => {
64
- setSelectedId(id);
116
+ const loadDetail = useCallback(async (id: string) => {
65
117
  setSavedAt(null);
66
118
  try {
67
- const [detailRes, settingsRes] = await Promise.all([
68
- fetch(`/api/connectors?id=${id}`),
69
- fetch(`/api/connectors/${id}/settings`),
119
+ const [d, s] = await Promise.all([
120
+ fetch(`/api/connectors?id=${id}`).then(r => r.ok ? r.json() : null),
121
+ fetch(`/api/connectors/${id}/settings`).then(r => r.ok ? r.json() : null),
70
122
  ]);
71
- const detailData = await detailRes.json();
72
- const settingsData = await settingsRes.json();
73
- setDetail(detailData.connector || null);
74
- setSettingsSchema(settingsData.schema || {});
75
- setSettingsValues(settingsData.settings || {});
76
- } catch (e) {
77
- console.error('[ConnectorsPanel] select failed', e);
123
+ setDetail(d?.connector || null);
124
+ setSchema(s?.schema || {});
125
+ setValues(s?.settings || {});
126
+ } catch {
127
+ setDetail(null);
128
+ setSchema({});
129
+ setValues({});
78
130
  }
79
131
  }, []);
80
132
 
81
- useEffect(() => { fetchAll(); }, [fetchAll]);
133
+ useEffect(() => { refresh(); }, [refresh]);
82
134
 
83
- const handleInstall = async () => {
135
+ // Auto-select first entry on first load (nice default)
136
+ useEffect(() => {
137
+ if (!selectedId && state?.entries?.length) {
138
+ setSelectedId(state.entries[0].id);
139
+ }
140
+ }, [state, selectedId]);
141
+
142
+ useEffect(() => {
143
+ if (selectedId) loadDetail(selectedId);
144
+ setTestResult(null);
145
+ }, [selectedId, loadDetail]);
146
+
147
+ // Keep the repo-url input in sync with whatever's in settings
148
+ useEffect(() => {
149
+ fetch('/api/settings').then(r => r.json()).then(s => {
150
+ if (s?.connectorsRepoUrl != null) setRepoUrl(s.connectorsRepoUrl);
151
+ }).catch(() => {});
152
+ }, []);
153
+
154
+ // ─── Actions ──────────────────────────────────────────────
155
+ async function sync() {
156
+ setSyncing(true); setError('');
157
+ try {
158
+ // Persist URL change first if dirty
159
+ if (repoUrlDirty) {
160
+ await fetch('/api/settings', {
161
+ method: 'PUT',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ connectorsRepoUrl: repoUrl }),
164
+ });
165
+ setRepoUrlDirty(false);
166
+ }
167
+ const r = await fetch('/api/connectors/marketplace', {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify({ action: 'sync', refreshInstalled: true }),
171
+ });
172
+ const j = await r.json();
173
+ if (!j.ok) setError(j.error || 'sync failed');
174
+ await refresh();
175
+ if (selectedId) await loadDetail(selectedId);
176
+ } catch (e) {
177
+ setError(e instanceof Error ? e.message : String(e));
178
+ } finally { setSyncing(false); }
179
+ }
180
+
181
+ async function act(action: 'install' | 'update' | 'uninstall', id: string) {
182
+ if (action === 'uninstall' && !confirm(`Uninstall ${id}? Your saved settings/PAT are kept and restored on re-install.`)) return;
183
+ setBusyId(id); setError('');
184
+ try {
185
+ const r = await fetch('/api/connectors/marketplace', {
186
+ method: 'POST',
187
+ headers: { 'Content-Type': 'application/json' },
188
+ body: JSON.stringify({ action, id }),
189
+ });
190
+ const j = await r.json();
191
+ if (!r.ok || j.ok === false) setError(j.error || `${action} failed`);
192
+ await refresh();
193
+ if (selectedId === id) await loadDetail(id);
194
+ } catch (e) {
195
+ setError(e instanceof Error ? e.message : String(e));
196
+ } finally { setBusyId(''); }
197
+ }
198
+
199
+ async function uploadFile(file: File) {
200
+ setUploading(true); setError('');
201
+ try {
202
+ const fd = new FormData();
203
+ fd.append('file', file);
204
+ const r = await fetch('/api/connectors/install-local', { method: 'POST', body: fd });
205
+ const j = await r.json();
206
+ if (!r.ok || j.ok === false) {
207
+ setError(j.error || `install failed (HTTP ${r.status})`);
208
+ } else {
209
+ await refresh();
210
+ if (j.id) setSelectedId(j.id);
211
+ }
212
+ } catch (e) {
213
+ setError(e instanceof Error ? e.message : String(e));
214
+ } finally { setUploading(false); }
215
+ }
216
+
217
+ function onPickFile(ev: React.ChangeEvent<HTMLInputElement>) {
218
+ const f = ev.target.files?.[0];
219
+ ev.target.value = ''; // allow re-picking the same file
220
+ if (f) uploadFile(f);
221
+ }
222
+
223
+ function onDrop(ev: React.DragEvent) {
224
+ ev.preventDefault();
225
+ setDragOver(false);
226
+ const f = ev.dataTransfer.files?.[0];
227
+ if (f) uploadFile(f);
228
+ }
229
+
230
+ async function runTest() {
84
231
  if (!selectedId) return;
85
- await fetch(`/api/connectors/${selectedId}/settings`, {
86
- method: 'POST',
87
- headers: { 'Content-Type': 'application/json' },
88
- body: JSON.stringify({ settings: settingsValues }),
89
- });
90
- setSavedAt(Date.now());
91
- await fetchAll();
92
- await selectConnector(selectedId);
93
- };
94
-
95
- const handleUninstall = async () => {
232
+ setTesting(true); setTestResult(null);
233
+ try {
234
+ // Save first so the server-side test sees the current values
235
+ // (most common gotcha: user pastes a new PAT, clicks Test, gets 401
236
+ // on the old token still on disk).
237
+ await fetch(`/api/connectors/${selectedId}/settings`, {
238
+ method: 'POST',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ settings: values }),
241
+ });
242
+ const r = await fetch(`/api/connectors/${selectedId}/test`, { method: 'POST' });
243
+ const j = (await r.json()) as TestResult;
244
+ setTestResult(j);
245
+ } catch (e) {
246
+ setTestResult({ ok: false, error: e instanceof Error ? e.message : String(e) });
247
+ } finally { setTesting(false); }
248
+ }
249
+
250
+ async function saveSettings() {
96
251
  if (!selectedId) return;
97
- await fetch('/api/plugins', {
98
- method: 'POST',
99
- headers: { 'Content-Type': 'application/json' },
100
- body: JSON.stringify({ action: 'uninstall', id: selectedId }),
101
- });
102
- await fetchAll();
103
- await selectConnector(selectedId);
104
- };
105
-
106
- const filtered = filter === 'installed' ? connectors.filter(c => c.installed) : connectors;
107
- const allTools = (c: ConnectorPayload): [string, ConnectorTool][] =>
108
- c.entries.flatMap(e => Object.entries(e.tools || {}));
109
-
110
- if (loading) {
111
- return <div className="p-4 text-xs text-[var(--text-secondary)]">Loading connectors…</div>;
252
+ try {
253
+ const r = await fetch(`/api/connectors/${selectedId}/settings`, {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/json' },
256
+ body: JSON.stringify({ settings: values }),
257
+ });
258
+ if (!r.ok) {
259
+ const j = await r.json().catch(() => ({}));
260
+ setError(j.error || 'save failed');
261
+ return;
262
+ }
263
+ const j = await r.json();
264
+ setValues(j.settings || values);
265
+ setSavedAt(Date.now());
266
+ } catch (e) {
267
+ setError(e instanceof Error ? e.message : String(e));
268
+ }
112
269
  }
113
270
 
271
+ const entries = state?.entries || [];
272
+ const installedCount = entries.filter(e => e.installed_version).length;
273
+ const syncedLabel = state?.fetched_at
274
+ ? new Date(state.fetched_at).toLocaleString()
275
+ : 'never';
276
+
277
+ const allTools = useMemo(() => {
278
+ if (!detail) return [] as Array<[string, ConnectorTool]>;
279
+ return detail.entries.flatMap(e => Object.entries(e.tools || {}));
280
+ }, [detail]);
281
+
114
282
  return (
115
- <div className="flex flex-1 min-h-0">
116
- {/* List pane */}
117
- <div className="w-[280px] border-r border-[var(--border)] flex flex-col min-h-0">
118
- <div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between shrink-0">
119
- <span className="text-[10px] text-[var(--text-secondary)]">
120
- {connectors.filter(c => c.installed).length} installed of {connectors.length}
121
- </span>
122
- <div className="flex gap-1">
123
- {(['all', 'installed'] as const).map(f => (
124
- <button key={f}
125
- onClick={() => setFilter(f)}
126
- className={`text-[10px] px-2 py-0.5 rounded transition-colors ${filter === f ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)]'}`}
127
- >{f}</button>
128
- ))}
283
+ <div
284
+ className={`flex flex-1 min-h-0 flex-col relative ${dragOver ? 'ring-2 ring-[var(--accent)]/60' : ''}`}
285
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
286
+ onDragLeave={() => setDragOver(false)}
287
+ onDrop={onDrop}
288
+ >
289
+ {/* Header strip */}
290
+ <div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
291
+ <span className="text-[10px] text-[var(--text-secondary)]">
292
+ {installedCount} installed of {entries.length}
293
+ </span>
294
+ <span className="text-[10px] text-[var(--text-secondary)] flex-1 truncate">
295
+ · Last synced: {syncedLabel}
296
+ </span>
297
+ <input
298
+ value={repoUrl}
299
+ onChange={e => { setRepoUrl(e.target.value); setRepoUrlDirty(true); }}
300
+ placeholder="connectors registry URL"
301
+ className="w-[300px] text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-0.5 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
302
+ />
303
+ <input
304
+ ref={fileInputRef}
305
+ type="file"
306
+ accept=".yaml,.yml,.zip"
307
+ onChange={onPickFile}
308
+ className="hidden"
309
+ />
310
+ <button
311
+ type="button"
312
+ onClick={() => fileInputRef.current?.click()}
313
+ disabled={uploading}
314
+ title="Install a local connector — .yaml manifest or .zip bundle (drag & drop also works)"
315
+ className="text-[10px] px-2.5 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
316
+ >
317
+ {uploading ? 'Installing…' : '+ Upload'}
318
+ </button>
319
+ <button
320
+ onClick={sync}
321
+ disabled={syncing}
322
+ className="text-[10px] px-2.5 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors disabled:opacity-40"
323
+ >
324
+ {syncing ? 'Syncing…' : 'Refresh'}
325
+ </button>
326
+ </div>
327
+
328
+ {dragOver && (
329
+ <div className="absolute inset-0 bg-[var(--accent)]/10 pointer-events-none flex items-center justify-center z-10">
330
+ <div className="px-6 py-3 border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)] rounded text-sm text-[var(--accent)]">
331
+ Drop manifest.yaml or connector.zip to install
129
332
  </div>
130
333
  </div>
131
- <div className="overflow-y-auto flex-1">
132
- {filtered.length === 0 && (
133
- <div className="p-4 text-xs text-[var(--text-secondary)] text-center">No connectors</div>
134
- )}
135
- {filtered.map(c => (
136
- <button key={c.plugin_id}
137
- onClick={() => selectConnector(c.plugin_id)}
138
- className={`w-full text-left px-3 py-2 border-b border-[var(--border)] hover:bg-[var(--bg-tertiary)] transition-colors ${selectedId === c.plugin_id ? 'bg-[var(--bg-tertiary)]' : ''}`}
139
- >
140
- <div className="flex items-center gap-2">
141
- <span className="text-base">{c.icon}</span>
142
- <div className="flex-1 min-w-0">
143
- <div className="flex items-center gap-1.5">
144
- <span className="text-[12px] font-semibold text-[var(--text-primary)] truncate">{c.name}</span>
145
- {c.installed && <span className="text-[8px] px-1 py-0.5 rounded bg-green-500/10 text-green-400">installed</span>}
146
- </div>
147
- <div className="flex items-center gap-1.5 mt-0.5">
148
- <span className="text-[9px] text-[var(--text-secondary)]">v{c.version}</span>
149
- <span className="text-[9px] px-1 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">{c.mode}</span>
150
- </div>
151
- </div>
152
- </div>
153
- {c.description && (
154
- <p className="text-[10px] text-[var(--text-secondary)] mt-1 line-clamp-2">{c.description}</p>
155
- )}
156
- </button>
157
- ))}
334
+ )}
335
+
336
+ {error && (
337
+ <div className="mx-3 mt-2 px-3 py-1.5 text-[11px] text-red-400 border border-red-500/30 bg-red-500/5 rounded">
338
+ {error}
158
339
  </div>
159
- </div>
340
+ )}
160
341
 
161
- {/* Detail pane */}
162
- <div className="flex-1 overflow-y-auto min-h-0">
163
- {!detail ? (
164
- <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">
165
- Select a connector
166
- </div>
167
- ) : (
168
- <div className="p-4 space-y-4">
169
- {/* Header */}
170
- <div className="flex items-start gap-3">
171
- <span className="text-2xl">{detail.icon}</span>
172
- <div className="flex-1 min-w-0">
173
- <div className="flex items-center gap-2">
174
- <h2 className="text-base font-semibold text-[var(--text-primary)]">{detail.name}</h2>
175
- <span className="text-[10px] text-[var(--text-secondary)]">v{detail.version}</span>
176
- <span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">{detail.mode}</span>
177
- {detail.installed && <span className="text-[9px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400">installed</span>}
178
- </div>
179
- {detail.description && (
180
- <p className="text-[11px] text-[var(--text-secondary)] mt-1 whitespace-pre-line">{detail.description}</p>
181
- )}
182
- </div>
183
- {detail.installed && (
184
- <button onClick={handleUninstall}
185
- className="text-[10px] px-2.5 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10"
186
- >Uninstall</button>
187
- )}
342
+ <div className="flex flex-1 min-h-0">
343
+ {/* List pane */}
344
+ <div className="w-[280px] border-r border-[var(--border)] overflow-y-auto">
345
+ {entries.length === 0 ? (
346
+ <div className="p-4 text-xs text-[var(--text-secondary)] text-center">
347
+ {state ? 'Registry is empty — click Refresh.' : 'Loading…'}
188
348
  </div>
189
-
190
- {/* Tools */}
191
- <div>
192
- <div className="flex items-center justify-between mb-1.5">
193
- <h3 className="text-[11px] font-semibold text-[var(--text-primary)]">Tools</h3>
194
- <span className="text-[9px] text-[var(--text-secondary)] italic">executed in browser extension</span>
195
- </div>
196
- <div className="grid gap-1.5">
197
- {allTools(detail).map(([name, tool]) => (
198
- <div key={name} className="px-2.5 py-1.5 rounded bg-[var(--bg-tertiary)]">
199
- <div className="flex items-center gap-2 mb-0.5">
200
- <span className="text-[10px] font-mono font-semibold text-[var(--accent)]">{name}</span>
201
- {tool.destructive && <span className="text-[8px] px-1 py-0.5 rounded bg-red-500/10 text-red-400">destructive</span>}
349
+ ) : (
350
+ entries.map(e => {
351
+ const installed = !!e.installed_version;
352
+ const update = !!e.update_available;
353
+ return (
354
+ <button
355
+ key={e.id}
356
+ onClick={() => setSelectedId(e.id)}
357
+ className={`w-full text-left px-3 py-2 border-b border-[var(--border)] hover:bg-[var(--bg-tertiary)] transition-colors ${selectedId === e.id ? 'bg-[var(--bg-tertiary)]' : ''}`}
358
+ >
359
+ <div className="flex items-center gap-2">
360
+ <span className="text-base">{e.icon || '🔌'}</span>
361
+ <div className="flex-1 min-w-0">
362
+ <div className="flex items-center gap-1.5">
363
+ <span className="text-[12px] font-semibold text-[var(--text-primary)] truncate">{e.name}</span>
364
+ {installed && !update && (
365
+ <span className="text-[8px] px-1 py-0.5 rounded bg-green-500/10 text-green-400">installed</span>
366
+ )}
367
+ {update && (
368
+ <span className="text-[8px] px-1 py-0.5 rounded bg-yellow-500/10 text-yellow-400">update</span>
369
+ )}
370
+ {e.source === 'local' && (
371
+ <span className="text-[8px] px-1 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)]">local</span>
372
+ )}
373
+ </div>
374
+ <div className="flex items-center gap-1.5 mt-0.5">
375
+ <span className="text-[9px] text-[var(--text-secondary)]">
376
+ v{e.version}
377
+ {installed && update && (
378
+ <span className="text-yellow-400"> ← v{e.installed_version}</span>
379
+ )}
380
+ </span>
381
+ </div>
202
382
  </div>
203
- {tool.description && (
204
- <p className="text-[10px] text-[var(--text-secondary)] whitespace-pre-line">{tool.description.trim()}</p>
205
- )}
206
- {tool.returns && (
207
- <p className="text-[9px] text-[var(--text-secondary)] mt-1 font-mono">→ {tool.returns}</p>
208
- )}
209
383
  </div>
210
- ))}
211
- </div>
212
- </div>
384
+ {e.description && (
385
+ <p className="text-[10px] text-[var(--text-secondary)] mt-1 line-clamp-2">{e.description}</p>
386
+ )}
387
+ </button>
388
+ );
389
+ })
390
+ )}
391
+ </div>
213
392
 
214
- {/* Settings */}
215
- {Object.keys(settingsSchema).length > 0 && (
216
- <div>
217
- <h3 className="text-[11px] font-semibold text-[var(--text-primary)] mb-1.5">Settings</h3>
218
- <div className="space-y-2">
219
- {Object.entries(settingsSchema).map(([key, schema]) => (
220
- <div key={key}>
221
- <label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
222
- {schema.label || key} {schema.required && <span className="text-red-400">*</span>}
223
- </label>
224
- {schema.type === 'boolean' ? (
225
- <input type="checkbox"
226
- checked={settingsValues[key] === true || settingsValues[key] === 'true'}
227
- onChange={e => setSettingsValues({ ...settingsValues, [key]: e.target.checked })}
228
- className="accent-[var(--accent)]"
229
- />
230
- ) : schema.type === 'select' ? (
231
- <select
232
- value={settingsValues[key] ?? schema.default ?? ''}
233
- onChange={e => setSettingsValues({ ...settingsValues, [key]: e.target.value })}
234
- className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
235
- >
236
- <option value="">Select…</option>
237
- {(schema.options || []).map(o => <option key={o} value={o}>{o}</option>)}
238
- </select>
239
- ) : (
240
- <input
241
- type={schema.type === 'secret' ? 'password' : schema.type === 'number' ? 'number' : 'text'}
242
- value={settingsValues[key] ?? schema.default ?? ''}
243
- onChange={e => setSettingsValues({ ...settingsValues, [key]: e.target.value })}
244
- placeholder={schema.description || ''}
245
- className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
246
- />
393
+ {/* Detail pane */}
394
+ <div className="flex-1 overflow-y-auto min-h-0">
395
+ {!selectedId ? (
396
+ <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">
397
+ Select a connector
398
+ </div>
399
+ ) : (() => {
400
+ const me = entries.find(e => e.id === selectedId);
401
+ if (!me) return null;
402
+ const installed = !!me.installed_version;
403
+ const update = !!me.update_available;
404
+ return (
405
+ <div className="p-4 space-y-4">
406
+ {/* Header */}
407
+ <div className="flex items-start gap-3">
408
+ <span className="text-2xl">{me.icon || '🔌'}</span>
409
+ <div className="flex-1 min-w-0">
410
+ <div className="flex items-center gap-2 flex-wrap">
411
+ <h2 className="text-base font-semibold text-[var(--text-primary)]">{me.name}</h2>
412
+ <span className="text-[10px] text-[var(--text-secondary)]">v{me.version}</span>
413
+ {installed && !update && (
414
+ <span className="text-[9px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400">installed</span>
415
+ )}
416
+ {update && (
417
+ <span className="text-[9px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400">
418
+ update available · v{me.installed_version} → v{me.version}
419
+ </span>
247
420
  )}
248
- {schema.description && schema.type !== 'string' && (
249
- <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{schema.description}</p>
421
+ {me.source === 'local' && (
422
+ <span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)]">
423
+ local · not in registry
424
+ </span>
250
425
  )}
251
426
  </div>
252
- ))}
253
- <div className="flex items-center gap-2 pt-1">
254
- <button onClick={handleInstall}
255
- className="text-[10px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
256
- >{detail.installed ? 'Save Settings' : 'Install & Save'}</button>
257
- {savedAt && Date.now() - savedAt < 2000 && (
258
- <span className="text-[10px] text-green-400">Saved</span>
427
+ {me.description && (
428
+ <p className="text-[11px] text-[var(--text-secondary)] mt-1 whitespace-pre-line">{me.description}</p>
429
+ )}
430
+ </div>
431
+ <div className="flex items-center gap-1.5 shrink-0">
432
+ {!installed && (
433
+ <button
434
+ onClick={() => act('install', me.id)}
435
+ disabled={busyId === me.id || !me.compatible}
436
+ className="text-[10px] px-2.5 py-1 rounded border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white transition-colors disabled:opacity-40"
437
+ >
438
+ {busyId === me.id ? '…' : 'Install'}
439
+ </button>
440
+ )}
441
+ {installed && update && (
442
+ <button
443
+ onClick={() => act('update', me.id)}
444
+ disabled={busyId === me.id}
445
+ className="text-[10px] px-2.5 py-1 rounded border border-yellow-500/60 text-yellow-400 hover:bg-yellow-500 hover:text-white transition-colors disabled:opacity-40"
446
+ >
447
+ {busyId === me.id ? '…' : 'Update'}
448
+ </button>
449
+ )}
450
+ {installed && (
451
+ <button
452
+ onClick={() => act('uninstall', me.id)}
453
+ disabled={busyId === me.id}
454
+ className="text-[10px] px-2.5 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
455
+ >
456
+ Remove
457
+ </button>
259
458
  )}
260
459
  </div>
261
460
  </div>
262
- </div>
263
- )}
264
461
 
265
- {Object.keys(settingsSchema).length === 0 && !detail.installed && (
266
- <button onClick={handleInstall}
267
- className="text-[10px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
268
- >Install</button>
269
- )}
270
- </div>
271
- )}
462
+ {/* Tools only when manifest is on disk */}
463
+ {installed && detail && (
464
+ <div>
465
+ <div className="flex items-center justify-between mb-1.5">
466
+ <h3 className="text-[11px] font-semibold text-[var(--text-primary)]">
467
+ Tools ({allTools.length})
468
+ </h3>
469
+ <span className="text-[9px] text-[var(--text-secondary)] italic">runtime: browser / http / shell</span>
470
+ </div>
471
+ <div className="grid gap-1.5">
472
+ {allTools.map(([name, tool]) => (
473
+ <div key={name} className="px-2.5 py-1.5 rounded bg-[var(--bg-tertiary)]">
474
+ <div className="flex items-center gap-2 mb-0.5">
475
+ <span className="text-[10px] font-mono font-semibold text-[var(--accent)]">{name}</span>
476
+ {tool.protocol && tool.protocol !== 'browser' && (
477
+ <span className="text-[8px] px-1 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">{tool.protocol}</span>
478
+ )}
479
+ {tool.destructive && (
480
+ <span className="text-[8px] px-1 py-0.5 rounded bg-red-500/10 text-red-400">destructive</span>
481
+ )}
482
+ </div>
483
+ {tool.description && (
484
+ <p className="text-[10px] text-[var(--text-secondary)] whitespace-pre-line">{tool.description.trim()}</p>
485
+ )}
486
+ {tool.returns && (
487
+ <p className="text-[9px] text-[var(--text-secondary)] mt-1 font-mono">→ {tool.returns}</p>
488
+ )}
489
+ </div>
490
+ ))}
491
+ </div>
492
+ </div>
493
+ )}
494
+
495
+ {/* Settings — only when manifest is on disk */}
496
+ {installed && Object.keys(schema).length > 0 && (
497
+ <div>
498
+ <div className="flex items-center justify-between mb-1.5">
499
+ <h3 className="text-[11px] font-semibold text-[var(--text-primary)]">Settings</h3>
500
+ {savedAt && (
501
+ <span className="text-[9px] text-green-400">saved {new Date(savedAt).toLocaleTimeString()}</span>
502
+ )}
503
+ </div>
504
+ <div className="space-y-2">
505
+ {Object.entries(schema).map(([key, sc]) => (
506
+ <div key={key}>
507
+ <label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
508
+ {sc.label || key} {sc.required && <span className="text-red-400">*</span>}
509
+ </label>
510
+ {sc.type === 'boolean' ? (
511
+ <input
512
+ type="checkbox"
513
+ checked={values[key] === true || values[key] === 'true'}
514
+ onChange={e => setValues({ ...values, [key]: e.target.checked })}
515
+ className="accent-[var(--accent)]"
516
+ />
517
+ ) : sc.type === 'select' ? (
518
+ <select
519
+ value={values[key] ?? sc.default ?? ''}
520
+ onChange={e => setValues({ ...values, [key]: e.target.value })}
521
+ className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
522
+ >
523
+ <option value="">Select…</option>
524
+ {(sc.options || []).map(o => <option key={o} value={o}>{o}</option>)}
525
+ </select>
526
+ ) : (
527
+ <input
528
+ type={sc.type === 'secret' || sc.type === 'password' ? 'password' : sc.type === 'number' ? 'number' : 'text'}
529
+ value={values[key] ?? sc.default ?? ''}
530
+ onChange={e => setValues({ ...values, [key]: e.target.value })}
531
+ placeholder={
532
+ (sc.type === 'secret' || sc.type === 'password') && values[key] === SECRET_MASK
533
+ ? 'click to replace'
534
+ : sc.description || ''
535
+ }
536
+ className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)] font-mono"
537
+ />
538
+ )}
539
+ {sc.description && (
540
+ <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{sc.description}</p>
541
+ )}
542
+ </div>
543
+ ))}
544
+ <div className="flex items-center gap-2 flex-wrap">
545
+ <button
546
+ onClick={saveSettings}
547
+ className="text-[10px] px-2.5 py-1 rounded border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white transition-colors"
548
+ >
549
+ Save settings
550
+ </button>
551
+ {detail?.has_test && (
552
+ <button
553
+ onClick={runTest}
554
+ disabled={testing}
555
+ title={detail.test_description || 'Verify credentials reach the service'}
556
+ className="text-[10px] px-2.5 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
557
+ >
558
+ {testing ? 'Testing…' : 'Test'}
559
+ </button>
560
+ )}
561
+ {testResult && (
562
+ <span
563
+ className={`text-[10px] ${
564
+ testResult.ok ? 'text-green-400' : 'text-red-400'
565
+ }`}
566
+ >
567
+ {testResult.ok
568
+ ? `✓ ${testResult.message}${testResult.duration_ms != null ? ` · ${testResult.duration_ms}ms` : ''}`
569
+ : `✗ ${testResult.error || `HTTP ${testResult.status}`}`}
570
+ </span>
571
+ )}
572
+ </div>
573
+ </div>
574
+ </div>
575
+ )}
576
+
577
+ {/* Not-installed hint */}
578
+ {!installed && (
579
+ <p className="text-[11px] text-[var(--text-secondary)] italic">
580
+ Install this connector to see its tools and configure its settings (PAT, host URL, etc.).
581
+ </p>
582
+ )}
583
+ </div>
584
+ );
585
+ })()}
586
+ </div>
272
587
  </div>
273
588
  </div>
274
589
  );