@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
@@ -1834,3 +1834,4 @@ function DocsAgentSelect({ settings, setSettings }: { settings: any; setSettings
1834
1834
  </div>
1835
1835
  );
1836
1836
  }
1837
+
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
4
  import { useSidebarResize } from '@/hooks/useSidebarResize';
5
5
 
6
6
  const PluginsPanel = lazy(() => import('./PluginsPanel'));
@@ -25,6 +25,7 @@ interface Skill {
25
25
  hasUpdate: boolean;
26
26
  installedProjects: string[];
27
27
  deletedRemotely: boolean;
28
+ source?: 'registry' | 'local';
28
29
  }
29
30
 
30
31
  interface ProjectInfo {
@@ -238,6 +239,26 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
238
239
  };
239
240
 
240
241
  const [syncProgress, setSyncProgress] = useState('');
242
+ const [uploading, setUploading] = useState(false);
243
+ const skillUploadRef = useRef<HTMLInputElement | null>(null);
244
+
245
+ async function uploadSkillFile(file: File) {
246
+ setUploading(true);
247
+ try {
248
+ const fd = new FormData();
249
+ fd.append('file', file);
250
+ const r = await fetch('/api/skills/install-local', { method: 'POST', body: fd });
251
+ const j = await r.json();
252
+ if (!r.ok || j.ok === false) {
253
+ alert(`Upload failed: ${j.error || `HTTP ${r.status}`}`);
254
+ return;
255
+ }
256
+ await fetchSkills();
257
+ } catch (e) {
258
+ alert(`Upload failed: ${e instanceof Error ? e.message : String(e)}`);
259
+ } finally { setUploading(false); }
260
+ }
261
+
241
262
  const sync = async () => {
242
263
  setSyncing(true);
243
264
  setSyncProgress('');
@@ -390,6 +411,26 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
390
411
  </div>
391
412
  </div>
392
413
  <span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
414
+ <input
415
+ ref={skillUploadRef}
416
+ type="file"
417
+ accept=".md,.zip"
418
+ className="hidden"
419
+ onChange={(e) => {
420
+ const f = e.target.files?.[0];
421
+ e.target.value = '';
422
+ if (f) void uploadSkillFile(f);
423
+ }}
424
+ />
425
+ <button
426
+ type="button"
427
+ onClick={() => skillUploadRef.current?.click()}
428
+ disabled={uploading}
429
+ title="Upload a local skill — SKILL.md or a .zip bundle. Falls outside the forge-skills registry."
430
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
431
+ >
432
+ {uploading ? 'Uploading…' : '+ Upload'}
433
+ </button>
393
434
  <button
394
435
  onClick={sync}
395
436
  disabled={syncing}
@@ -62,6 +62,10 @@ export function createClaudeAdapter(config: AgentConfig): AgentAdapter {
62
62
  args.push('--resume', opts.conversationId);
63
63
  }
64
64
 
65
+ if (opts.appendSystemPrompt && opts.appendSystemPrompt.trim()) {
66
+ args.push('--append-system-prompt', opts.appendSystemPrompt);
67
+ }
68
+
65
69
  if (opts.extraFlags) {
66
70
  args.push(...opts.extraFlags);
67
71
  }
@@ -42,6 +42,12 @@ export interface AgentSpawnOptions {
42
42
  skipPermissions?: boolean;
43
43
  outputFormat?: 'stream-json' | 'json' | 'text';
44
44
  extraFlags?: string[];
45
+ /**
46
+ * Extra text appended to the agent's system prompt for this run.
47
+ * Maps to `--append-system-prompt` on Claude Code. Adapters that
48
+ * don't support the concept ignore it.
49
+ */
50
+ appendSystemPrompt?: string;
45
51
  }
46
52
 
47
53
  export interface AgentSpawnResult {
@@ -27,11 +27,10 @@ import { renderMemoryContext } from './temper';
27
27
  import { getMemoryStore } from './memory-store';
28
28
  import { buildMemoryTools } from './memory-tools';
29
29
  import {
30
- listPlugins,
31
- getPlugin,
32
- getInstalledPlugin,
33
- getConnectorsForPlugin,
34
- } from '../plugins/registry';
30
+ listInstalledConnectors,
31
+ getConnector,
32
+ getConnectorEntries,
33
+ } from '../connectors/registry';
35
34
  import type {
36
35
  ContentBlock,
37
36
  Message,
@@ -224,18 +223,10 @@ function detectMentionedConnectors(
224
223
 
225
224
  function buildConnectorTools(): LlmTool[] {
226
225
  const out: LlmTool[] = [];
227
- const sources = listPlugins().filter(s => s.category === 'connector');
228
- for (const s of sources) {
229
- const def = getPlugin(s.id);
230
- if (!def) continue;
231
- // We deliberately DO NOT require getInstalledPlugin(s.id) here — the LLM
232
- // should always see all connector tools the user has defined. Install
233
- // status only matters at dispatch time: browser tools that lack a
234
- // configured base_url will surface a clear error inside the tool_result
235
- // (better than vanishing from the tool list and having the LLM make up
236
- // an excuse like "I can't see your Teams"). http/shell tools don't need
237
- // install at all.
238
- for (const entry of getConnectorsForPlugin(def)) {
226
+ for (const inst of listInstalledConnectors()) {
227
+ if (!inst.enabled) continue;
228
+ const def = inst.definition;
229
+ for (const entry of getConnectorEntries(def)) {
239
230
  for (const [toolName, tool] of Object.entries(entry.tools || {})) {
240
231
  // Executable if it has a script (browser protocol) OR a non-browser
241
232
  // protocol that runs server-side (http / shell).
@@ -249,8 +240,8 @@ function buildConnectorTools(): LlmTool[] {
249
240
  if ((pdef as any)?.required) required.push(pname);
250
241
  }
251
242
  out.push({
252
- name: `${s.id}.${toolName}`,
253
- description: (tool.description || `${s.name} · ${toolName}`).trim(),
243
+ name: `${def.id}.${toolName}`,
244
+ description: (tool.description || `${def.name} · ${toolName}`).trim(),
254
245
  input_schema: {
255
246
  type: 'object',
256
247
  properties,
@@ -361,9 +352,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
361
352
  // restrict the tool list so the LLM can't wander to unrelated connectors.
362
353
  // Strong (slash-prefix) signals also emit a directive in the system prompt
363
354
  // so the model treats it as a command, not a hint.
364
- const allPluginIds = [...new Set(connectorTools.map((t) => t.name.split('.')[0]!))];
365
- const pluginCatalog = allPluginIds.map((id) => {
366
- const def = getPlugin(id);
355
+ const allConnectorIds = [...new Set(connectorTools.map((t) => t.name.split('.')[0]!))];
356
+ const pluginCatalog = allConnectorIds.map((id) => {
357
+ const def = getConnector(id);
367
358
  return { id, name: def?.name };
368
359
  });
369
360
  const mentioned = detectMentionedConnectors(args.userText, pluginCatalog);
@@ -15,7 +15,7 @@
15
15
  * is_error so the LLM can react.
16
16
  */
17
17
 
18
- import type { HttpRequestSpec, ConnectorTool } from '../../plugins/types';
18
+ import type { HttpRequestSpec, ConnectorTool } from '../../connectors/types';
19
19
  import { expandAllTokens } from '../../plugins/templates';
20
20
 
21
21
  export interface HttpProtocolArgs {
@@ -15,7 +15,7 @@
15
15
  * automatic command allow-list in v1.
16
16
  */
17
17
 
18
- import type { ConnectorTool } from '../../plugins/types';
18
+ import type { ConnectorTool } from '../../connectors/types';
19
19
  import { expandAllTokens } from '../../plugins/templates';
20
20
  import { spawn } from 'node:child_process';
21
21
 
@@ -13,12 +13,12 @@ import { bridgeRpc } from './bridge-client';
13
13
  import { runHttp } from './protocols/http';
14
14
  import { runShell } from './protocols/shell';
15
15
  import {
16
- getPlugin,
17
- getInstalledPlugin,
18
- getConnectorsForPlugin,
19
- } from '../plugins/registry';
16
+ getConnector,
17
+ getInstalledConnector,
18
+ getConnectorEntries,
19
+ } from '../connectors/registry';
20
20
  import { expandSettingsTokens } from '../plugins/templates';
21
- import type { Connector, ConnectorTool } from '../plugins/types';
21
+ import type { ConnectorEntry, ConnectorTool } from '../connectors/types';
22
22
 
23
23
  export interface ToolCall {
24
24
  id: string;
@@ -55,26 +55,26 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
55
55
 
56
56
  // ─── Connector dispatch ──────────────────────────────────
57
57
 
58
- /** Look up a connector tool by `<plugin_id>.<tool_name>` form. */
58
+ /** Look up a connector tool by `<connector_id>.<tool_name>` form. */
59
59
  function findConnectorTool(qualified: string): {
60
- pluginId: string;
60
+ connectorId: string;
61
61
  toolName: string;
62
- entry: Connector;
62
+ entry: ConnectorEntry;
63
63
  tool: ConnectorTool;
64
64
  settings: Record<string, any>;
65
65
  } | null {
66
66
  const dot = qualified.indexOf('.');
67
67
  if (dot < 1) return null;
68
- const pluginId = qualified.slice(0, dot);
68
+ const connectorId = qualified.slice(0, dot);
69
69
  const toolName = qualified.slice(dot + 1);
70
- const def = getPlugin(pluginId);
71
- if (!def || def.category !== 'connector') return null;
72
- const entries = getConnectorsForPlugin(def);
70
+ const def = getConnector(connectorId);
71
+ if (!def) return null;
72
+ const entries = getConnectorEntries(def);
73
73
  for (const entry of entries) {
74
74
  const tool = entry.tools?.[toolName];
75
75
  if (tool) {
76
- const inst = getInstalledPlugin(pluginId);
77
- return { pluginId, toolName, entry, tool, settings: inst?.config || {} };
76
+ const inst = getInstalledConnector(connectorId);
77
+ return { connectorId, toolName, entry, tool, settings: (inst?.config as Record<string, any>) || {} };
78
78
  }
79
79
  }
80
80
  return null;
@@ -86,8 +86,8 @@ function findConnectorTool(qualified: string): {
86
86
  * the extension's runner finishes those at execution time.
87
87
  */
88
88
  function buildConnectorPayload(
89
- def: { id: string; name: string; mode?: string; runner?: 'main' | 'isolated'; host_match?: string; login_redirect?: string },
90
- entry: Connector,
89
+ def: { id: string; name: string; runner?: 'main' | 'isolated'; host_match?: string; login_redirect?: string },
90
+ entry: ConnectorEntry,
91
91
  settings: Record<string, any>,
92
92
  ) {
93
93
  const expand = (s: string | undefined) => (s ? expandSettingsTokens(s, settings) : s);
@@ -117,9 +117,9 @@ function buildConnectorPayload(
117
117
  };
118
118
 
119
119
  return {
120
- plugin_id: def.id,
120
+ plugin_id: def.id, // legacy wire-name; extension reads this
121
121
  name: def.name,
122
- mode: def.mode || 'browser-side',
122
+ mode: 'browser-side',
123
123
  installed: true,
124
124
  host_match: hostMatch,
125
125
  login_redirect: loginRedirect,
@@ -157,7 +157,7 @@ export async function dispatchTool(
157
157
  return { content: `unknown tool: ${call.name}`, is_error: true };
158
158
  }
159
159
 
160
- const def = getPlugin(located.pluginId)!;
160
+ const def = getConnector(located.connectorId)!;
161
161
  const protocol = located.tool.protocol || 'browser';
162
162
  const argInput = (call.input ?? {}) as Record<string, any>;
163
163
 
@@ -173,7 +173,7 @@ export async function dispatchTool(
173
173
  // the runner logic (tab acquire, navigate, executeScript).
174
174
  const connector = buildConnectorPayload(def, located.entry, located.settings);
175
175
  const result = (await bridgeRpc('connector.run', {
176
- pluginId: located.pluginId,
176
+ pluginId: located.connectorId, // wire-name kept for extension
177
177
  toolName: located.toolName,
178
178
  input: argInput,
179
179
  connector,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * One-shot migration: lift connector rows out of plugin-configs.json
3
+ * into connector-configs.json.
4
+ *
5
+ * Pre-v0.9 Forge stored connector configs under plugin-configs.json
6
+ * with `category: connector` on the plugin def. From v0.9 connectors
7
+ * are an independent subsystem; configs live in connector-configs.json
8
+ * and manifests come from the remote forge-connectors registry (no
9
+ * built-in fallback).
10
+ *
11
+ * Migration moves the encrypted config blob (PAT, base_url, etc.)
12
+ * keyed by KNOWN_CONNECTOR_IDS — the set of connector ids that used
13
+ * to be built in. Manifests are NOT written here; they arrive on the
14
+ * next syncRegistry() call. If the user is offline at upgrade time,
15
+ * the marketplace will be empty until they reconnect — config rows
16
+ * are preserved so re-install restores everything.
17
+ *
18
+ * Idempotent: once a connector id has been moved, its row is gone
19
+ * from plugin-configs.json and subsequent calls are no-ops.
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { getDataDir } from '../dirs';
25
+
26
+ /**
27
+ * IDs that used to ship as built-in connectors in lib/builtin-plugins/.
28
+ * Add to this list when promoting a new built-in connector into the
29
+ * marketplace, but only for the upgrade window — once your user base
30
+ * has restarted with the new code, the row is gone and the entry
31
+ * becomes inert.
32
+ */
33
+ const KNOWN_CONNECTOR_IDS = ['mantis', 'gitlab', 'teams', 'pmdb', 'github-api'];
34
+
35
+ function pluginConfigsPath(): string {
36
+ return join(getDataDir(), 'plugin-configs.json');
37
+ }
38
+
39
+ function connectorConfigsPath(): string {
40
+ return join(getDataDir(), 'connector-configs.json');
41
+ }
42
+
43
+ function ensureDir(p: string): void {
44
+ if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
45
+ }
46
+
47
+ interface ConnectorConfigRow {
48
+ config: Record<string, unknown>;
49
+ /**
50
+ * Version recorded at migration time. We don't know what version
51
+ * the user was running before — write '0.0.0' so the next sync,
52
+ * which sees the registry version > 0.0.0, flags an update and
53
+ * pulls the manifest. The marketplace UI shows it as "Update
54
+ * available" briefly until that completes.
55
+ */
56
+ installed_version: string;
57
+ enabled?: boolean;
58
+ }
59
+
60
+ export function migrateConnectorConfigs(): { migrated: number; skipped: number } {
61
+ const oldPath = pluginConfigsPath();
62
+ if (!existsSync(oldPath)) return { migrated: 0, skipped: 0 };
63
+
64
+ let oldStore: Record<string, any>;
65
+ try { oldStore = JSON.parse(readFileSync(oldPath, 'utf-8')); }
66
+ catch { return { migrated: 0, skipped: 0 }; }
67
+
68
+ const newPath = connectorConfigsPath();
69
+ let newStore: Record<string, ConnectorConfigRow> = {};
70
+ if (existsSync(newPath)) {
71
+ try { newStore = JSON.parse(readFileSync(newPath, 'utf-8')); }
72
+ catch {}
73
+ }
74
+
75
+ let migrated = 0;
76
+ let skipped = 0;
77
+ const toRemoveFromOld: string[] = [];
78
+
79
+ for (const id of KNOWN_CONNECTOR_IDS) {
80
+ const oldRow = oldStore[id];
81
+ if (!oldRow) {
82
+ skipped += 1;
83
+ continue;
84
+ }
85
+ if (!newStore[id]) {
86
+ newStore[id] = {
87
+ config: oldRow.config || {},
88
+ installed_version: '0.0.0',
89
+ enabled: oldRow.enabled !== false,
90
+ };
91
+ migrated += 1;
92
+ }
93
+ toRemoveFromOld.push(id);
94
+ }
95
+
96
+ if (toRemoveFromOld.length > 0) {
97
+ ensureDir(getDataDir());
98
+ writeFileSync(newPath, JSON.stringify(newStore, null, 2), { mode: 0o600 });
99
+
100
+ for (const id of toRemoveFromOld) delete oldStore[id];
101
+ writeFileSync(oldPath, JSON.stringify(oldStore, null, 2), { mode: 0o600 });
102
+
103
+ console.log(
104
+ `[connectors] migration: moved ${migrated} row(s) from plugin-configs.json. ` +
105
+ 'Manifests will arrive on the next registry sync.',
106
+ );
107
+ }
108
+
109
+ return { migrated, skipped };
110
+ }