@aion0/forge 0.8.1 → 0.8.2
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.
- package/RELEASE_NOTES.md +25 -6
- package/app/api/connectors/[id]/settings/route.ts +31 -37
- package/app/api/connectors/[id]/test/route.ts +260 -0
- package/app/api/connectors/install-local/route.ts +211 -0
- package/app/api/connectors/marketplace/route.ts +79 -0
- package/app/api/connectors/route.ts +41 -46
- package/app/api/jobs/route.ts +1 -0
- package/app/api/skills/install-local/route.ts +282 -0
- package/components/ConnectorsPanel.tsx +526 -211
- package/components/SettingsModal.tsx +1 -0
- package/components/SkillsPanel.tsx +42 -1
- package/lib/agents/claude-adapter.ts +4 -0
- package/lib/agents/types.ts +6 -0
- package/lib/chat/agent-loop.ts +13 -22
- package/lib/chat/protocols/http.ts +1 -1
- package/lib/chat/protocols/shell.ts +1 -1
- package/lib/chat/tool-dispatcher.ts +20 -20
- package/lib/connectors/migration.ts +110 -0
- package/lib/connectors/registry.ts +328 -0
- package/lib/connectors/sync.ts +305 -0
- package/lib/connectors/types.ts +253 -0
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/17-connectors.md +241 -189
- package/lib/help-docs/21-build-connector.md +314 -0
- package/lib/help-docs/CLAUDE.md +4 -2
- package/lib/init.ts +25 -0
- package/lib/jobs/dispatcher.ts +28 -8
- package/lib/jobs/scheduler.ts +21 -3
- package/lib/jobs/store.ts +11 -2
- package/lib/jobs/types.ts +12 -0
- package/lib/pipeline-scheduler.ts +3 -2
- package/lib/pipeline.ts +135 -13
- package/lib/plugins/registry.ts +9 -42
- package/lib/plugins/types.ts +4 -129
- package/lib/settings.ts +7 -0
- package/lib/skills.ts +27 -1
- package/lib/task-manager.ts +62 -2
- package/package.json +3 -1
- package/src/core/db/database.ts +4 -0
- package/lib/builtin-plugins/github-api.yaml +0 -93
- package/lib/builtin-plugins/gitlab.yaml +0 -860
- package/lib/builtin-plugins/mantis.probe.js +0 -176
- package/lib/builtin-plugins/mantis.yaml +0 -964
- package/lib/builtin-plugins/pmdb.yaml +0 -178
- package/lib/builtin-plugins/teams.yaml +0 -913
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
27
|
+
author?: string;
|
|
28
|
+
installed_version?: string;
|
|
29
|
+
update_available?: boolean;
|
|
30
|
+
compatible: boolean;
|
|
31
|
+
source: 'registry' | 'local';
|
|
10
32
|
}
|
|
11
33
|
|
|
12
|
-
interface
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
mode: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
author?: string;
|
|
36
68
|
installed: boolean;
|
|
37
|
-
entries:
|
|
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 [
|
|
42
|
-
const [
|
|
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<
|
|
45
|
-
const [
|
|
46
|
-
const [
|
|
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
|
|
51
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
setConnectors(data.connectors || []);
|
|
109
|
+
const r = await fetch('/api/connectors/marketplace');
|
|
110
|
+
setState((await r.json()) as MarketState);
|
|
56
111
|
} catch (e) {
|
|
57
|
-
|
|
58
|
-
} finally {
|
|
59
|
-
setLoading(false);
|
|
112
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
60
113
|
}
|
|
61
114
|
}, []);
|
|
62
115
|
|
|
63
|
-
const
|
|
64
|
-
setSelectedId(id);
|
|
116
|
+
const loadDetail = useCallback(async (id: string) => {
|
|
65
117
|
setSavedAt(null);
|
|
66
118
|
try {
|
|
67
|
-
const [
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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(() => {
|
|
133
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
82
134
|
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
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
|
-
|
|
340
|
+
)}
|
|
160
341
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
{
|
|
249
|
-
<
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
{
|
|
258
|
-
<
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
);
|