@aion0/forge 0.6.1 → 0.8.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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `forge worktree` — list / clean leftover worktrees created by Forge pipelines.
3
+ *
4
+ * The mantis-bug-fix-and-mr and gitlab-issue-fix-and-review builtin pipelines
5
+ * stage their work in `<project>/.forge/worktrees/<id>`. The pipeline auto-
6
+ * cleans on success, but failed runs leave the worktree on disk for
7
+ * inspection — those accumulate over time and bloat the project directory
8
+ * (one full project checkout per failed bug). This command sweeps them.
9
+ *
10
+ * Scope: ONLY `.forge/worktrees/*` directories under each project root.
11
+ * User-created git worktrees elsewhere are never touched.
12
+ */
13
+
14
+ import { execSync, spawnSync } from 'node:child_process';
15
+ import { existsSync, readdirSync, statSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ interface Project { name: string; path: string; language?: string; }
19
+
20
+ const isTty = !!process.stdout.isTTY;
21
+ const dim = (s: string) => isTty ? `\x1b[2m${s}\x1b[0m` : s;
22
+ const red = (s: string) => isTty ? `\x1b[31m${s}\x1b[0m` : s;
23
+ const grn = (s: string) => isTty ? `\x1b[32m${s}\x1b[0m` : s;
24
+ const yel = (s: string) => isTty ? `\x1b[33m${s}\x1b[0m` : s;
25
+ const cyan = (s: string) => isTty ? `\x1b[36m${s}\x1b[0m` : s;
26
+
27
+ function getBase(): string {
28
+ const portArgIdx = process.argv.findIndex(a => a === '--port');
29
+ const port = portArgIdx >= 0 ? process.argv[portArgIdx + 1] : undefined;
30
+ return process.env.MW_URL || `http://localhost:${port || '8403'}`;
31
+ }
32
+
33
+ async function fetchProjects(): Promise<Project[]> {
34
+ const base = getBase();
35
+ try {
36
+ const res = await fetch(base + '/api/projects');
37
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
38
+ return await res.json();
39
+ } catch (e) {
40
+ throw new Error(`forge unreachable on ${base}: ${(e as Error).message}`);
41
+ }
42
+ }
43
+
44
+ /** Bytes-on-disk for a directory tree. `du -sk` returns kilobytes; we convert. */
45
+ function dirSizeBytes(p: string): number {
46
+ try {
47
+ const out = execSync(`du -sk "${p}" 2>/dev/null | awk '{print $1}'`, { encoding: 'utf8' }).trim();
48
+ return Number(out || 0) * 1024;
49
+ } catch { return 0; }
50
+ }
51
+
52
+ function fmtSize(bytes: number): string {
53
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)}G`;
54
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)}M`;
55
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}K`;
56
+ return `${bytes}B`;
57
+ }
58
+
59
+ interface Entry {
60
+ project: string;
61
+ projectPath: string;
62
+ worktreeDir: string; // absolute
63
+ name: string; // basename under .forge/worktrees/
64
+ branch: string | null; // git's reported branch, if any
65
+ registered: boolean; // appears in `git worktree list`
66
+ bytes: number;
67
+ }
68
+
69
+ function scanProject(p: Project): Entry[] {
70
+ const root = p.path;
71
+ const wtRoot = join(root, '.forge', 'worktrees');
72
+ if (!existsSync(wtRoot)) return [];
73
+
74
+ // Map of absolute path → branch (from git's view of registered worktrees)
75
+ const registered = new Map<string, string | null>();
76
+ try {
77
+ const porc = execSync('git worktree list --porcelain', { cwd: root, encoding: 'utf8' });
78
+ let cur: { path?: string; branch?: string } = {};
79
+ for (const line of porc.split('\n')) {
80
+ if (line.startsWith('worktree ')) {
81
+ if (cur.path) registered.set(cur.path, cur.branch ?? null);
82
+ cur = { path: line.slice('worktree '.length).trim() };
83
+ } else if (line.startsWith('branch ')) {
84
+ cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '').trim();
85
+ } else if (line === '' && cur.path) {
86
+ registered.set(cur.path, cur.branch ?? null);
87
+ cur = {};
88
+ }
89
+ }
90
+ if (cur.path) registered.set(cur.path, cur.branch ?? null);
91
+ } catch { /* not a git repo; just walk the dir */ }
92
+
93
+ const out: Entry[] = [];
94
+ let names: string[];
95
+ try { names = readdirSync(wtRoot); } catch { return []; }
96
+ for (const name of names) {
97
+ const wt = join(wtRoot, name);
98
+ let isDir = false;
99
+ try { isDir = statSync(wt).isDirectory(); } catch { continue; }
100
+ if (!isDir) continue;
101
+ out.push({
102
+ project: p.name, projectPath: root,
103
+ worktreeDir: wt, name,
104
+ branch: registered.get(wt) ?? null,
105
+ registered: registered.has(wt),
106
+ bytes: dirSizeBytes(wt),
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function removeOne(e: Entry): { ok: boolean; msg: string } {
113
+ // Try git worktree remove first — it untracks + deletes the dir together.
114
+ const r = spawnSync('git', ['worktree', 'remove', '--force', e.worktreeDir],
115
+ { cwd: e.projectPath, encoding: 'utf8' });
116
+ if (r.status === 0) {
117
+ // Best-effort drop the local branch the worktree was pinning.
118
+ if (e.branch) {
119
+ spawnSync('git', ['branch', '-D', e.branch], { cwd: e.projectPath, encoding: 'utf8' });
120
+ }
121
+ return { ok: true, msg: 'worktree remove' };
122
+ }
123
+
124
+ // Stale registration or never-tracked dir → rm -rf + prune + drop branch.
125
+ const rm = spawnSync('rm', ['-rf', e.worktreeDir], { encoding: 'utf8' });
126
+ if (rm.status !== 0) {
127
+ return { ok: false, msg: `rm -rf failed: ${rm.stderr.trim().slice(0, 200)}` };
128
+ }
129
+ spawnSync('git', ['worktree', 'prune'], { cwd: e.projectPath, encoding: 'utf8' });
130
+ if (e.branch) {
131
+ spawnSync('git', ['branch', '-D', e.branch], { cwd: e.projectPath, encoding: 'utf8' });
132
+ }
133
+ return { ok: true, msg: 'rm -rf + prune' };
134
+ }
135
+
136
+ export async function worktreeCommand(args: string[]): Promise<void> {
137
+ const sub = args[0] || 'list';
138
+ const rest = args.slice(1);
139
+ switch (sub) {
140
+ case 'list':
141
+ case 'ls':
142
+ return cmdList(rest);
143
+ case 'clean':
144
+ case 'rm':
145
+ return cmdClean(rest);
146
+ case '--help':
147
+ case '-h':
148
+ default:
149
+ return printHelp();
150
+ }
151
+ }
152
+
153
+ async function cmdList(args: string[]): Promise<void> {
154
+ const projectFilter = args.find(a => !a.startsWith('-'));
155
+ const projects = await fetchProjects();
156
+ const targets = projectFilter ? projects.filter(p => p.name === projectFilter || p.path === projectFilter) : projects;
157
+ if (projectFilter && targets.length === 0) {
158
+ console.error(red(`project not found: ${projectFilter}`));
159
+ process.exit(1);
160
+ }
161
+
162
+ let total = 0, totalBytes = 0;
163
+ for (const p of targets) {
164
+ const entries = scanProject(p);
165
+ if (!entries.length) continue;
166
+ const subBytes = entries.reduce((a, e) => a + e.bytes, 0);
167
+ console.log(`${cyan(p.name)} ${dim(p.path)} ${dim(`(${entries.length}, ${fmtSize(subBytes)})`)}`);
168
+ for (const e of entries) {
169
+ const flag = e.registered ? grn('●') : yel('○');
170
+ const br = e.branch ? dim(e.branch) : dim('(unregistered)');
171
+ console.log(` ${flag} ${e.name.padEnd(30)} ${fmtSize(e.bytes).padStart(7)} ${br}`);
172
+ }
173
+ total += entries.length;
174
+ totalBytes += subBytes;
175
+ }
176
+ if (total === 0) console.log(dim('(no Forge worktrees)'));
177
+ else console.log(`\n${total} worktree(s), ${fmtSize(totalBytes)}`);
178
+ }
179
+
180
+ async function cmdClean(args: string[]): Promise<void> {
181
+ const dryRun = args.includes('--dry-run') || args.includes('-n');
182
+ const yes = args.includes('--yes') || args.includes('-y');
183
+ const projectFilter = args.find(a => !a.startsWith('-'));
184
+
185
+ const projects = await fetchProjects();
186
+ const targets = projectFilter ? projects.filter(p => p.name === projectFilter || p.path === projectFilter) : projects;
187
+ if (projectFilter && targets.length === 0) {
188
+ console.error(red(`project not found: ${projectFilter}`));
189
+ process.exit(1);
190
+ }
191
+
192
+ const all: Entry[] = [];
193
+ for (const p of targets) all.push(...scanProject(p));
194
+ if (all.length === 0) { console.log(dim('(no Forge worktrees to clean)')); return; }
195
+
196
+ const totalBytes = all.reduce((a, e) => a + e.bytes, 0);
197
+ console.log(`Found ${all.length} worktree(s), ${fmtSize(totalBytes)} total:`);
198
+ for (const e of all) console.log(` ${dim(e.project + ':')} ${e.name} ${dim(`(${fmtSize(e.bytes)})`)}`);
199
+
200
+ if (dryRun) { console.log(yel('\n--dry-run — nothing removed.')); return; }
201
+
202
+ if (!yes && isTty) {
203
+ process.stdout.write(`\nRemove these ${all.length} worktree(s)? [y/N] `);
204
+ const ans = await readOneLine();
205
+ if (!/^y(es)?$/i.test(ans.trim())) { console.log('aborted'); return; }
206
+ }
207
+
208
+ let ok = 0, fail = 0;
209
+ for (const e of all) {
210
+ const r = removeOne(e);
211
+ if (r.ok) { console.log(` ${grn('✓')} ${e.project}/${e.name} ${dim(r.msg)}`); ok++; }
212
+ else { console.log(` ${red('✗')} ${e.project}/${e.name} ${red(r.msg)}`); fail++; }
213
+ }
214
+ console.log(`\n${grn(`removed ${ok}`)}${fail ? `, ${red(`${fail} failed`)}` : ''} (${fmtSize(totalBytes)} freed)`);
215
+ }
216
+
217
+ function readOneLine(): Promise<string> {
218
+ return new Promise((resolve) => {
219
+ let buf = '';
220
+ process.stdin.setEncoding('utf8');
221
+ process.stdin.on('data', (chunk) => {
222
+ buf += chunk;
223
+ const i = buf.indexOf('\n');
224
+ if (i >= 0) { process.stdin.pause(); resolve(buf.slice(0, i)); }
225
+ });
226
+ process.stdin.resume();
227
+ });
228
+ }
229
+
230
+ function printHelp(): void {
231
+ console.log(`forge worktree — manage pipeline worktrees under <project>/.forge/worktrees/
232
+
233
+ Usage:
234
+ forge worktree List all (alias of 'list')
235
+ forge worktree list [project] List worktrees, optionally filtered by project
236
+ forge worktree clean [project] Remove all worktrees (prompts unless --yes)
237
+ --dry-run, -n Show what would be removed
238
+ --yes, -y Don't prompt
239
+
240
+ Worktrees are only created by Forge builtin pipelines (mantis-bug-fix-and-mr,
241
+ gitlab-issue-fix-and-review). Successful runs auto-clean; failed runs leave
242
+ the worktree for inspection. This command sweeps the failed leftovers.
243
+
244
+ Only paths under <project>/.forge/worktrees/ are ever touched.`);
245
+ }
@@ -0,0 +1,275 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+
5
+ interface ConnectorTool {
6
+ description?: string;
7
+ parameters?: Record<string, { type: string; label?: string; description?: string; required?: boolean; default?: any }>;
8
+ destructive?: boolean;
9
+ returns?: string;
10
+ }
11
+
12
+ interface ConnectorEntry {
13
+ id: string;
14
+ host_permissions?: string[];
15
+ tools: Record<string, ConnectorTool>;
16
+ settings?: Record<string, FieldSchema>;
17
+ }
18
+
19
+ interface FieldSchema {
20
+ type: string;
21
+ label?: string;
22
+ description?: string;
23
+ required?: boolean;
24
+ default?: any;
25
+ options?: string[];
26
+ }
27
+
28
+ interface ConnectorPayload {
29
+ plugin_id: string;
30
+ name: string;
31
+ icon: string;
32
+ version: string;
33
+ author: string;
34
+ description: string;
35
+ mode: string;
36
+ installed: boolean;
37
+ entries: ConnectorEntry[];
38
+ }
39
+
40
+ export default function ConnectorsPanel() {
41
+ const [connectors, setConnectors] = useState<ConnectorPayload[]>([]);
42
+ const [loading, setLoading] = useState(true);
43
+ 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>>({});
47
+ const [savedAt, setSavedAt] = useState<number | null>(null);
48
+ const [filter, setFilter] = useState<'all' | 'installed'>('all');
49
+
50
+ const fetchAll = useCallback(async () => {
51
+ setLoading(true);
52
+ try {
53
+ const res = await fetch('/api/connectors');
54
+ const data = await res.json();
55
+ setConnectors(data.connectors || []);
56
+ } catch (e) {
57
+ console.error('[ConnectorsPanel] fetch failed', e);
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }, []);
62
+
63
+ const selectConnector = useCallback(async (id: string) => {
64
+ setSelectedId(id);
65
+ setSavedAt(null);
66
+ try {
67
+ const [detailRes, settingsRes] = await Promise.all([
68
+ fetch(`/api/connectors?id=${id}`),
69
+ fetch(`/api/connectors/${id}/settings`),
70
+ ]);
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);
78
+ }
79
+ }, []);
80
+
81
+ useEffect(() => { fetchAll(); }, [fetchAll]);
82
+
83
+ const handleInstall = async () => {
84
+ 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 () => {
96
+ 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>;
112
+ }
113
+
114
+ 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
+ ))}
129
+ </div>
130
+ </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
+ ))}
158
+ </div>
159
+ </div>
160
+
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
+ )}
188
+ </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>}
202
+ </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
+ </div>
210
+ ))}
211
+ </div>
212
+ </div>
213
+
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
+ />
247
+ )}
248
+ {schema.description && schema.type !== 'string' && (
249
+ <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{schema.description}</p>
250
+ )}
251
+ </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>
259
+ )}
260
+ </div>
261
+ </div>
262
+ </div>
263
+ )}
264
+
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
+ )}
272
+ </div>
273
+ </div>
274
+ );
275
+ }