@aion0/forge 0.8.0 → 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.
Files changed (50) hide show
  1. package/RELEASE_NOTES.md +25 -101
  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 +1 -0
  8. package/app/api/monitor/route.ts +26 -0
  9. package/app/api/skills/install-local/route.ts +282 -0
  10. package/components/ConnectorsPanel.tsx +526 -211
  11. package/components/MonitorPanel.tsx +43 -0
  12. package/components/SettingsModal.tsx +1 -0
  13. package/components/SkillsPanel.tsx +42 -1
  14. package/lib/agents/claude-adapter.ts +4 -0
  15. package/lib/agents/types.ts +6 -0
  16. package/lib/chat/agent-loop.ts +14 -23
  17. package/lib/chat/local-memory.ts +2 -2
  18. package/lib/chat/memory-store.ts +1 -1
  19. package/lib/chat/protocols/http.ts +2 -2
  20. package/lib/chat/protocols/shell.ts +2 -2
  21. package/lib/chat/session-store.ts +2 -2
  22. package/lib/chat/tool-dispatcher.ts +21 -21
  23. package/lib/connectors/migration.ts +110 -0
  24. package/lib/connectors/registry.ts +328 -0
  25. package/lib/connectors/sync.ts +305 -0
  26. package/lib/connectors/types.ts +253 -0
  27. package/lib/help-docs/00-overview.md +1 -0
  28. package/lib/help-docs/17-connectors.md +241 -189
  29. package/lib/help-docs/21-build-connector.md +314 -0
  30. package/lib/help-docs/CLAUDE.md +4 -2
  31. package/lib/init.ts +25 -0
  32. package/lib/jobs/dispatcher.ts +28 -8
  33. package/lib/jobs/scheduler.ts +21 -3
  34. package/lib/jobs/store.ts +11 -2
  35. package/lib/jobs/types.ts +12 -0
  36. package/lib/pipeline-scheduler.ts +3 -2
  37. package/lib/pipeline.ts +135 -13
  38. package/lib/plugins/registry.ts +9 -42
  39. package/lib/plugins/types.ts +4 -129
  40. package/lib/settings.ts +7 -0
  41. package/lib/skills.ts +27 -1
  42. package/lib/task-manager.ts +62 -2
  43. package/package.json +3 -1
  44. package/src/core/db/database.ts +4 -0
  45. package/lib/builtin-plugins/github-api.yaml +0 -93
  46. package/lib/builtin-plugins/gitlab.yaml +0 -860
  47. package/lib/builtin-plugins/mantis.probe.js +0 -176
  48. package/lib/builtin-plugins/mantis.yaml +0 -964
  49. package/lib/builtin-plugins/pmdb.yaml +0 -178
  50. package/lib/builtin-plugins/teams.yaml +0 -913
package/lib/pipeline.ts CHANGED
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto';
9
9
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import YAML from 'yaml';
12
- import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
12
+ import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides } from './task-manager';
13
13
  import { getProjectInfo } from './projects';
14
14
  import { loadSettings } from './settings';
15
15
  import { getAgent, listAgents } from './agents';
@@ -114,6 +114,13 @@ export interface Pipeline {
114
114
  nodeOrder: string[]; // for UI display
115
115
  createdAt: string;
116
116
  completedAt?: string;
117
+ /**
118
+ * Skill names from the forge-skills registry to make available to
119
+ * every task this pipeline spawns. Composed into the task's
120
+ * --append-system-prompt. Carried in pipeline state so retries +
121
+ * recovery use the same set as the original run.
122
+ */
123
+ skills?: string[];
117
124
  // Conversation mode state
118
125
  conversation?: {
119
126
  config: ConversationConfig;
@@ -687,6 +694,22 @@ nodes:
687
694
  print(out)
688
695
  PY
689
696
  )
697
+ # Force glab to use the Forge-managed GitLab PAT. Forge injects
698
+ # GITLAB_TOKEN from the gitlab connector's config; without
699
+ # rewriting it here, glab can stick to its stale per-host token
700
+ # in ~/.config/glab-cli/config.yml and 401 with "Token was
701
+ # revoked" — even when the env is set. glab auth login --token
702
+ # is idempotent and writes the right host config in 100 ms.
703
+ if [ -n "$GITLAB_TOKEN" ]; then
704
+ REMOTE_HOST_FOR_AUTH=$(git -C "$PROJECT_PATH" config --get remote.origin.url 2>/dev/null \\
705
+ | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
706
+ if [ -n "$REMOTE_HOST_FOR_AUTH" ]; then
707
+ echo "Refreshing glab auth for $REMOTE_HOST_FOR_AUTH from Forge connector token"
708
+ echo "$GITLAB_TOKEN" | glab auth login --hostname "$REMOTE_HOST_FOR_AUTH" --token-stdin >/dev/null 2>&1 || \\
709
+ echo "(glab auth login refresh failed — falling through; will likely 401)" >&2
710
+ fi
711
+ fi
712
+
690
713
  # Bug assignee → MR reviewer. Mantis assignee comes as "Jane Doe (jdoe)";
691
714
  # extract the (username) and pass to glab. If parens absent or empty,
692
715
  # skip the flag — glab errors on --reviewer "" .
@@ -717,15 +740,17 @@ nodes:
717
740
  echo "$GLAB_OUT" | head -50
718
741
  echo "--- end glab output ---"
719
742
 
720
- # Common, very actionable failure: corp GitLab revoked the glab
721
- # token. Detect + give the exact command to fix instead of making
722
- # the user dig through stack-trace-like output.
743
+ # Common, very actionable failure: corp GitLab revoked the
744
+ # token. Forge already pushes the connector PAT here via
745
+ # GITLAB_TOKEN + a glab auth login --token-stdin refresh
746
+ # earlier in this step, so the only way to land here is if the
747
+ # connector PAT itself is wrong/revoked. Point the user at
748
+ # Settings, not at glab auth login.
723
749
  if echo "$GLAB_OUT" | grep -qE '401|invalid_token|Token was revoked|unauthorized'; then
724
- HOST=$(echo "$PROJECT_PATH" | sed -E 's#^.+@?##; s#:.*##' || true)
725
750
  REMOTE_HOST=$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
726
- echo "ERROR: glab token revoked / expired for \${REMOTE_HOST:-this GitLab server}." >&2
727
- echo "Fix: glab auth login --hostname \${REMOTE_HOST:-<gitlab-host>}" >&2
728
- echo "Then retry this node from the pipeline UI." >&2
751
+ echo "ERROR: GitLab rejected the token for \${REMOTE_HOST:-this GitLab server}." >&2
752
+ echo "Fix: open Forge Settings → Skills → Connectors tab → GitLab, paste a fresh Personal Access Token, click Save + Test." >&2
753
+ echo "Then retry this node from the pipeline UI — Forge will push the new token to glab automatically." >&2
729
754
  fi
730
755
 
731
756
  MR_URL=$(echo "$GLAB_OUT" | grep -oE 'https://[^[:space:]]+/-/merge_requests/[0-9]+' | head -1)
@@ -1273,7 +1298,25 @@ function releaseProjectLock(projectPath: string, pipelineId: string) {
1273
1298
 
1274
1299
  // ─── Pipeline Execution ───────────────────────────────────
1275
1300
 
1276
- export function startPipeline(workflowName: string, input: Record<string, string>): Pipeline {
1301
+ /**
1302
+ * Render the skills list into an --append-system-prompt block. Empty
1303
+ * input → empty string (caller passes undefined to skip the flag).
1304
+ */
1305
+ function renderSkillsAppendPrompt(skills: string[] | undefined): string {
1306
+ if (!skills || skills.length === 0) return '';
1307
+ const lines = skills.map((s) => ` /${s}`).join('\n');
1308
+ return [
1309
+ 'For this task, the following Forge skills are available and should be used as appropriate:',
1310
+ lines,
1311
+ "Read each skill's SKILL.md before invoking. Prefer invoking these skills (with /<name>) over reimplementing their workflows inline.",
1312
+ ].join('\n');
1313
+ }
1314
+
1315
+ export function startPipeline(
1316
+ workflowName: string,
1317
+ input: Record<string, string>,
1318
+ opts: { skills?: string[] } = {},
1319
+ ): Pipeline {
1277
1320
  const workflow = getWorkflow(workflowName);
1278
1321
  if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
1279
1322
 
@@ -1303,6 +1346,7 @@ export function startPipeline(workflowName: string, input: Record<string, string
1303
1346
  nodes,
1304
1347
  nodeOrder,
1305
1348
  createdAt: new Date().toISOString(),
1349
+ skills: opts.skills && opts.skills.length ? [...opts.skills] : undefined,
1306
1350
  };
1307
1351
 
1308
1352
  savePipeline(pipeline);
@@ -1407,6 +1451,10 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
1407
1451
  conversationId: '', // fresh session — no resume for conversation mode
1408
1452
  });
1409
1453
  pipelineTaskIds.add(task.id);
1454
+ {
1455
+ const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
1456
+ if (skillsAppend) taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
1457
+ }
1410
1458
 
1411
1459
  // Add pending message
1412
1460
  const names = agentNames || resolveAgentNames(config.agents);
@@ -1803,10 +1851,60 @@ function recoverStuckPipelines() {
1803
1851
  }
1804
1852
  }
1805
1853
 
1854
+ /**
1855
+ * One-shot at startup: cancel tasks that pipeline nodes think are
1856
+ * still running. When forge dies (crash, restart, kill -9) the task
1857
+ * row in SQLite is left as `running` even though the child process
1858
+ * is long gone, so the existing periodic recoverStuckPipelines just
1859
+ * sees `task.status === 'running'` and waits forever. This pass
1860
+ * marks those tasks as cancelled, letting the next recovery tick
1861
+ * transition the node to `failed` — at which point the user can
1862
+ * hit Retry from the pipeline UI.
1863
+ *
1864
+ * Safe because by definition any forge child task from before the
1865
+ * boot is dead: when the parent went down, the OS reaped its
1866
+ * subtree. No race with legitimately-running tasks — the very first
1867
+ * forge worker boot is the only moment this needs to fire.
1868
+ */
1869
+ let reapedOrphans = false;
1870
+ function reapOrphanedPipelineTasks() {
1871
+ if (reapedOrphans) return;
1872
+ reapedOrphans = true;
1873
+ try {
1874
+ const pipelines = listPipelines().filter(p => p.status === 'running');
1875
+ let reaped = 0;
1876
+ for (const pipeline of pipelines) {
1877
+ for (const node of Object.values(pipeline.nodes)) {
1878
+ if (node.status === 'running' && node.taskId) {
1879
+ const t = getTask(node.taskId);
1880
+ if (t && t.status === 'running') {
1881
+ try {
1882
+ const { cancelTask } = require('./task-manager');
1883
+ cancelTask(node.taskId);
1884
+ reaped += 1;
1885
+ } catch (err) {
1886
+ console.warn(`[pipeline] reap orphan: cancelTask(${node.taskId}) threw:`, err);
1887
+ }
1888
+ }
1889
+ }
1890
+ }
1891
+ }
1892
+ if (reaped > 0) {
1893
+ console.log(`[pipeline] reaped ${reaped} orphaned task(s) left running from a previous boot`);
1894
+ }
1895
+ } catch (err) {
1896
+ console.warn('[pipeline] reapOrphanedPipelineTasks failed:', err);
1897
+ }
1898
+ }
1899
+
1900
+ // One-shot orphan reap on boot, then standard recovery loop.
1901
+ setTimeout(() => {
1902
+ reapOrphanedPipelineTasks();
1903
+ recoverStuckPipelines();
1904
+ }, 5000);
1905
+
1806
1906
  // Run recovery every 30 seconds
1807
1907
  setInterval(recoverStuckPipelines, 30_000);
1808
- // Also run once on load
1809
- setTimeout(recoverStuckPipelines, 5000);
1810
1908
 
1811
1909
  /**
1812
1910
  * Retry a single failed node. Cascades-reset to any downstream nodes that
@@ -1828,8 +1926,23 @@ export async function retryNode(pipelineId: string, nodeId: string): Promise<{ o
1828
1926
 
1829
1927
  const nodeState = pipeline.nodes[nodeId];
1830
1928
  if (!nodeState) return { ok: false, error: `node '${nodeId}' is not in this pipeline` };
1831
- if (nodeState.status !== 'failed') {
1832
- return { ok: false, error: `node is in status '${nodeState.status}' only failed nodes can be retried` };
1929
+ // Allow retry on 'failed' OR 'running'. Running covers the
1930
+ // forge-restart-orphaned case: the node thinks it's executing but
1931
+ // the underlying task is dead. Anything else (pending/done/skipped)
1932
+ // is a misclick.
1933
+ if (nodeState.status !== 'failed' && nodeState.status !== 'running') {
1934
+ return { ok: false, error: `node is in status '${nodeState.status}' — only failed or running nodes can be retried` };
1935
+ }
1936
+
1937
+ // If the node is "running", best-effort terminate the underlying task
1938
+ // so it doesn't keep occupying the project lock / project slot.
1939
+ if (nodeState.status === 'running' && nodeState.taskId) {
1940
+ try {
1941
+ const { cancelTask } = require('./task-manager');
1942
+ cancelTask(nodeState.taskId);
1943
+ } catch (err) {
1944
+ console.warn(`[pipeline] retryNode: cancelTask(${nodeState.taskId}) threw:`, err);
1945
+ }
1833
1946
  }
1834
1947
 
1835
1948
  const workflow = getWorkflow(pipeline.workflowName);
@@ -2088,6 +2201,15 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
2088
2201
  conversationId: '',
2089
2202
  });
2090
2203
  pipelineTaskIds.add(task.id);
2204
+
2205
+ // Skills from the pipeline (forwarded from a job's `skills`) become
2206
+ // an --append-system-prompt on the task's claude invocation. We
2207
+ // attach the override BEFORE the task runner picks the task up so
2208
+ // the first attempt sees it.
2209
+ const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
2210
+ if (skillsAppend) {
2211
+ taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
2212
+ }
2091
2213
  // Pipeline tasks use the same model selection as normal tasks:
2092
2214
  // agent model > global taskModel. No separate pipelineModel override.
2093
2215
 
@@ -14,7 +14,7 @@ import { join, dirname } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import YAML from 'yaml';
17
- import type { PluginDefinition, InstalledPlugin, PluginSource, Connector } from './types';
17
+ import type { PluginDefinition, InstalledPlugin, PluginSource } from './types';
18
18
  import { encryptSecret, decryptSecret, isEncrypted } from '../crypto';
19
19
 
20
20
  const _filename = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
@@ -33,62 +33,29 @@ function loadPluginYaml(filePath: string): PluginDefinition | null {
33
33
  const raw = readFileSync(filePath, 'utf-8');
34
34
  const def = YAML.parse(raw) as PluginDefinition;
35
35
  if (!def.id || !def.name) return null;
36
- // Connector plugins don't need `actions` (tools execute in the extension).
37
- // Everything else must declare at least one action.
38
- const isConnector = def.category === 'connector';
39
- if (!isConnector && !def.actions) return null;
40
- if (isConnector && !def.tools && !def.connectors?.length) return null;
41
- // Defaults
36
+ if (!def.actions) return null;
42
37
  if (!def.config) def.config = {};
43
38
  if (!def.params) def.params = {};
44
- if (!def.actions) def.actions = {};
45
39
  if (!def.icon) def.icon = '🔌';
46
40
  if (!def.version) def.version = '0.0.1';
47
- if (!def.category) def.category = isConnectorByShape(def) ? 'connector' : 'pipeline-node';
48
- if (!def.mode) def.mode = def.category === 'connector' ? 'browser-side' : 'server-side';
41
+ if (!def.category) def.category = 'pipeline-node';
42
+ if (!def.mode) def.mode = 'server-side';
49
43
  return def;
50
44
  } catch {
51
45
  return null;
52
46
  }
53
47
  }
54
48
 
55
- /** Heuristic: shape-detect a connector even if `category` is omitted. */
56
- function isConnectorByShape(def: PluginDefinition): boolean {
57
- return Boolean(def.tools || def.connectors?.length);
58
- }
59
-
60
- /**
61
- * Normalize a plugin to its connector list.
62
- * - 1:1 shape (top-level `tools`) → synthesizes a single Connector with id === plugin id.
63
- * - 1:N shape (`connectors[]`) → returned as-is.
64
- * - Non-connector plugins → returns [].
65
- */
66
- export function getConnectorsForPlugin(def: PluginDefinition): Connector[] {
67
- if (def.connectors?.length) return def.connectors;
68
- if (def.tools) {
69
- return [{
70
- id: def.id,
71
- host_permissions: def.host_permissions,
72
- tools: def.tools,
73
- settings: def.settings,
74
- host_match: def.host_match,
75
- login_redirect: def.login_redirect,
76
- runner: def.runner,
77
- }];
78
- }
79
- return [];
80
- }
81
-
82
49
  // ─── Config Storage ──────────────────────────────────────
83
50
 
84
51
  /**
85
- * Return field names declared as `type: secret` (or legacy `password`)
86
- * for the given plugin definition's top-level settings. Used to decide
87
- * which config fields are encrypted on disk.
52
+ * Return field names declared as `type: secret` on the plugin's
53
+ * top-level `config` schema. Used to decide which fields are
54
+ * encrypted on disk.
88
55
  */
89
56
  export function getSecretFieldNames(def: PluginDefinition | null): string[] {
90
- if (!def?.settings) return [];
91
- return Object.entries(def.settings)
57
+ if (!def?.config) return [];
58
+ return Object.entries(def.config)
92
59
  .filter(([, schema]) => {
93
60
  const t = String((schema as any)?.type || '');
94
61
  return t === 'secret' || t === 'password';
@@ -9,18 +9,12 @@
9
9
  /** Plugin action execution type */
10
10
  export type PluginActionType = 'http' | 'poll' | 'shell' | 'script';
11
11
 
12
- /** What kind of plugin this is — drives marketplace filtering + extension surfacing */
13
- export type PluginCategory = 'connector' | 'pipeline-node' | 'mcp-server' | 'tool' | 'other';
12
+ /** What kind of plugin this is — drives marketplace filtering. */
13
+ export type PluginCategory = 'pipeline-node' | 'mcp-server' | 'tool' | 'other';
14
14
 
15
15
  /** Where a plugin's tools execute. Default is server-side (Forge node process). */
16
16
  export type PluginMode = 'server-side' | 'browser-side' | 'hybrid';
17
17
 
18
- /**
19
- * Which execution context the Forge browser extension uses to run a
20
- * connector's scripts. See PluginDefinition.runner for the full rationale.
21
- */
22
- export type PluginRunner = 'main' | 'isolated';
23
-
24
18
  /** Schema field definition for config/params */
25
19
  export interface PluginFieldSchema {
26
20
  type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
@@ -59,106 +53,6 @@ export interface PluginAction {
59
53
  output?: Record<string, string>; // { fieldName: "$.json.path" or "$body" or "$stdout" }
60
54
  }
61
55
 
62
- /**
63
- * Where the extension's generic runner should land the active tab before
64
- * executing a tool's script.
65
- */
66
- export interface ConnectorPage {
67
- /** Template URL; supports {base_url}, {settings.*}, {args.*}. */
68
- url: string;
69
- /**
70
- * Substring; if the current tab URL already contains it, skip navigation.
71
- * Templated values are expanded the same way as `url` (settings server-side,
72
- * args at runtime). Omit to always navigate.
73
- */
74
- on_target?: string;
75
- }
76
-
77
- /**
78
- * A tool a browser-side connector exposes to the extension's LLM.
79
- *
80
- * The script body runs in the user's tab via chrome.scripting.executeScript
81
- * (page context, has document/fetch/etc, no chrome.* APIs). The extension is
82
- * a generic runner — it doesn't know about Mantis or GitLab specifically.
83
- * Adding a new connector means dropping a manifest with `page` + `script`
84
- * per tool; no extension rebuild required.
85
- *
86
- * See docs/Connector-DeclarativeExtract-Spec.md.
87
- */
88
- export interface ConnectorTool {
89
- description: string;
90
- parameters?: Record<string, PluginFieldSchema>;
91
- /** Requires user confirmation before invoking (e.g. add_comment, close_issue). */
92
- destructive?: boolean;
93
- /** Free-form description of the return shape — surfaced to the LLM. */
94
- returns?: string;
95
- /** Where to navigate before running the script. */
96
- page?: ConnectorPage;
97
- /**
98
- * JavaScript function body. Receives `args` (the LLM's parsed parameters).
99
- * Returns a JSON-serializable value. Runs in the user's tab — must be
100
- * self-contained (no closures over Forge/extension code).
101
- */
102
- script?: string;
103
-
104
- // ─── Phase 3: multi-protocol tools (server-side execution) ──
105
- /**
106
- * Where the tool runs. Default 'browser' (extension runner via bridge).
107
- * 'browser' — page DOM script via chrome.scripting (legacy default)
108
- * 'http' — Forge issues an HTTP request server-side
109
- * 'shell' — Forge spawns a process (arg array, no shell:true)
110
- */
111
- protocol?: 'browser' | 'http' | 'shell';
112
- /** http protocol: request shape. Templates {base_url}, {settings.*}, {args.*}. */
113
- request?: HttpRequestSpec;
114
- /** shell protocol: command + args (each templated independently — no shell injection). */
115
- command?: string[];
116
- /** shell protocol: working directory (templated). */
117
- cwd?: string;
118
- /** shell protocol: extra env vars (values templated). */
119
- env?: Record<string, string>;
120
- /** shell/http: timeout in milliseconds. Default 30000, max 300000. */
121
- timeout_ms?: number;
122
- }
123
-
124
- export interface HttpRequestSpec {
125
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
126
- /** Full URL. Templated. */
127
- url: string;
128
- /** Header values are templated. */
129
- headers?: Record<string, string>;
130
- /** Query params. Values templated. Appended after any existing ? on url. */
131
- query?: Record<string, string>;
132
- /** Body. If string: sent as-is (after templating). If object: JSON.stringify'd; values templated where strings. */
133
- body?: string | Record<string, unknown>;
134
- }
135
-
136
- /**
137
- * One connector inside a plugin. Most plugins are 1:1 with a single connector
138
- * declared via top-level `tools`/`host_permissions`/`settings` fields on
139
- * PluginDefinition. Use `connectors[]` only when one auth covers multiple
140
- * sibling adapters (Atlassian, Google Workspace, M365).
141
- */
142
- export interface Connector {
143
- id: string;
144
- host_permissions?: string[];
145
- tools: Record<string, ConnectorTool>;
146
- /** Per-user settings (host URL, default project, etc.) — rendered as a form by the extension. */
147
- settings?: Record<string, PluginFieldSchema>;
148
- /**
149
- * Chrome match pattern. Runner uses this to find/open authenticated tabs
150
- * for this connector. Supports {base_url}, {settings.*}.
151
- */
152
- host_match?: string;
153
- /**
154
- * Substring. If the tab URL contains this after a navigation, treat as
155
- * "user not logged in" and abort with loginRequired: true.
156
- */
157
- login_redirect?: string;
158
- /** See PluginDefinition.runner. */
159
- runner?: PluginRunner;
160
- }
161
-
162
56
  /** Plugin definition (loaded from plugin.yaml) */
163
57
  export interface PluginDefinition {
164
58
  id: string;
@@ -168,21 +62,12 @@ export interface PluginDefinition {
168
62
  author?: string;
169
63
  description?: string;
170
64
 
171
- /** Classifies the plugin for marketplace + extension filtering. */
65
+ /** Classifies the plugin for marketplace filtering. */
172
66
  category?: PluginCategory;
173
67
 
174
- /** Where this plugin's tools execute. Connector plugins are usually `browser-side`. */
68
+ /** Where this plugin's tools execute. */
175
69
  mode?: PluginMode;
176
70
 
177
- /**
178
- * Which extension execution context runs this connector's scripts.
179
- * Default `main` for back-compat (Mantis, GitLab — permissive-CSP sites).
180
- * Use `isolated` for sites with strict CSP that blocks `unsafe-eval`
181
- * (Teams, github.com, banks). Per-plugin, not per-tool — a host has
182
- * one CSP profile.
183
- */
184
- runner?: PluginRunner;
185
-
186
71
  /** Global config — set once when installing the plugin */
187
72
  config: Record<string, PluginFieldSchema>;
188
73
 
@@ -194,16 +79,6 @@ export interface PluginDefinition {
194
79
 
195
80
  /** Default action to run if none specified */
196
81
  defaultAction?: string;
197
-
198
- // ─── Connector fields (category === 'connector') ─────────────────────────
199
- /** 1:1 sugar — when present, treated as a single connector with id === plugin id. */
200
- host_permissions?: string[];
201
- tools?: Record<string, ConnectorTool>;
202
- settings?: Record<string, PluginFieldSchema>;
203
- host_match?: string;
204
- login_redirect?: string;
205
- /** 1:N escape hatch — only for same-vendor suites with shared auth. */
206
- connectors?: Connector[];
207
82
  }
208
83
 
209
84
  /** Installed plugin instance (definition + user config values) */
package/lib/settings.ts CHANGED
@@ -65,6 +65,12 @@ export interface Settings {
65
65
  manageClaudeConfig: boolean;
66
66
  notificationRetentionDays: number;
67
67
  skillsRepoUrl: string;
68
+ /**
69
+ * Remote registry for connector manifests. Points to the raw-content
70
+ * URL of a forge-connectors-style repo. Setting empty disables sync
71
+ * (offline only). Default: `aiwatching/forge-connectors`.
72
+ */
73
+ connectorsRepoUrl: string;
68
74
  displayName: string;
69
75
  displayEmail: string;
70
76
  favoriteProjects: string[];
@@ -119,6 +125,7 @@ const defaults: Settings = {
119
125
  manageClaudeConfig: true,
120
126
  notificationRetentionDays: 30,
121
127
  skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
128
+ connectorsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main',
122
129
  displayName: 'Forge',
123
130
  displayEmail: '',
124
131
  favoriteProjects: [],
package/lib/skills.ts CHANGED
@@ -27,6 +27,8 @@ interface SkillItem {
27
27
  installedVersion: string;
28
28
  hasUpdate: boolean;
29
29
  deletedRemotely: boolean;
30
+ /** 'registry' (synced from forge-skills) or 'local' (uploaded). */
31
+ source: 'registry' | 'local';
30
32
  }
31
33
 
32
34
  function db() {
@@ -118,9 +120,13 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
118
120
  tx();
119
121
 
120
122
  // Step 3: Handle items no longer in registry
123
+ // Skip 'local'-source rows entirely — they were uploaded by the
124
+ // user via /api/skills/install-local and have no upstream
125
+ // counterpart, so absence from registry.json is expected.
121
126
  const registryNames = new Set(rawItems.map((s: any) => s.name));
122
- const dbItems = db().prepare('SELECT name, installed_global, installed_projects FROM skills').all() as any[];
127
+ const dbItems = db().prepare('SELECT name, installed_global, installed_projects, source FROM skills').all() as any[];
123
128
  for (const row of dbItems) {
129
+ if (row.source === 'local') continue;
124
130
  if (!registryNames.has(row.name)) {
125
131
  const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
126
132
  if (hasLocal) {
@@ -218,6 +224,7 @@ export function listSkills(): SkillItem[] {
218
224
  installedVersion,
219
225
  hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
220
226
  deletedRemotely: !!r.deleted_remotely,
227
+ source: r.source === 'local' ? 'local' : 'registry',
221
228
  };
222
229
  });
223
230
  }
@@ -299,6 +306,25 @@ export async function installGlobal(name: string): Promise<void> {
299
306
  .run(skill.version || '', name);
300
307
  }
301
308
 
309
+ /**
310
+ * Idempotent: if the skill is already installed for projectPath (or
311
+ * globally) and the on-disk version matches the registry, do nothing.
312
+ * Otherwise pull a fresh copy into the project. Used by job dispatch
313
+ * to guarantee that any skill named in `job.skills` is reachable to
314
+ * the spawned task — no friction "install this first" prompt.
315
+ */
316
+ export async function ensureInstalledInProject(name: string, projectPath: string): Promise<{ installed: boolean; reason: string }> {
317
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
318
+ if (!skill) return { installed: false, reason: 'skill not found in registry' };
319
+ const existing = (() => { try { return JSON.parse(skill.installed_projects || '[]') as string[]; } catch { return [] as string[]; } })();
320
+ const upToDate = (skill.installed_version || '') === (skill.version || '');
321
+ if (existing.includes(projectPath) && upToDate) {
322
+ return { installed: true, reason: 'already installed at version ' + skill.version };
323
+ }
324
+ await installProject(name, projectPath);
325
+ return { installed: true, reason: existing.includes(projectPath) ? 'updated to ' + skill.version : 'installed' };
326
+ }
327
+
302
328
  export async function installProject(name: string, projectPath: string): Promise<void> {
303
329
  const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
304
330
  if (!skill) throw new Error(`Skill "${name}" not found`);
@@ -44,6 +44,14 @@ function db() {
44
44
  // Per-task model overrides (used by pipeline to set pipelineModel)
45
45
  export const taskModelOverrides = new Map<string, string>();
46
46
 
47
+ /**
48
+ * Per-task append-system-prompt overrides. Used by Forge job/pipeline
49
+ * to point the agent at specific skills for this run. Keyed by task
50
+ * id; lives in-process (re-applied via the pipeline state on restart
51
+ * since we re-create the task there).
52
+ */
53
+ export const taskAppendSystemPromptOverrides = new Map<string, string>();
54
+
47
55
  // ─── CRUD ────────────────────────────────────────────────────
48
56
 
49
57
  export function createTask(opts: {
@@ -351,6 +359,50 @@ async function processNextTask() {
351
359
  }
352
360
  }
353
361
 
362
+ /**
363
+ * Surface installed-connector PATs as env vars to shell tasks. Lets
364
+ * pipeline shell steps (push-and-mr, gh-issue-fix, ...) use the same
365
+ * credential the user typed into Settings → Connectors instead of
366
+ * relying on `glab auth login` / `gh auth login` having been run
367
+ * separately (and not revoked).
368
+ *
369
+ * Only well-known connectors map to env vars. Token never leaves the
370
+ * server — the bash child inherits the env directly.
371
+ */
372
+ function connectorEnv(): Record<string, string> {
373
+ try {
374
+ const { getInstalledConnector } = require('./connectors/registry');
375
+ const out: Record<string, string> = {};
376
+ const gitlab = getInstalledConnector('gitlab');
377
+ if (gitlab?.enabled) {
378
+ const tok = typeof gitlab.config?.token === 'string' ? gitlab.config.token.trim() : '';
379
+ if (tok) {
380
+ // glab CLI honours GITLAB_TOKEN; HTTP libs honour CI_JOB_TOKEN-style names too,
381
+ // but GITLAB_TOKEN covers glab + most curl-based scripts in our pipelines.
382
+ out.GITLAB_TOKEN = tok;
383
+ if (typeof gitlab.config?.base_url === 'string' && gitlab.config.base_url) {
384
+ // glab uses GITLAB_URI for the API host on self-hosted instances.
385
+ try {
386
+ const u = new URL(String(gitlab.config.base_url));
387
+ out.GITLAB_URI = u.origin;
388
+ } catch {}
389
+ }
390
+ }
391
+ }
392
+ const gh = getInstalledConnector('github-api');
393
+ if (gh?.enabled) {
394
+ const tok = typeof gh.config?.token === 'string' ? gh.config.token.trim() : '';
395
+ if (tok) {
396
+ out.GITHUB_TOKEN = tok;
397
+ out.GH_TOKEN = tok; // gh CLI honours either; set both for safety.
398
+ }
399
+ }
400
+ return out;
401
+ } catch {
402
+ return {};
403
+ }
404
+ }
405
+
354
406
  function executeShellTask(task: Task): Promise<void> {
355
407
  return new Promise((resolve) => {
356
408
  updateTaskStatus(task.id, 'running');
@@ -365,7 +417,11 @@ function executeShellTask(task: Task): Promise<void> {
365
417
  const shell = process.env.SHELL && process.env.SHELL.endsWith('bash') ? process.env.SHELL : '/bin/bash';
366
418
  const child = spawn(shell, ['-c', task.prompt], {
367
419
  cwd: task.projectPath,
368
- env: { ...process.env, PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' },
420
+ env: {
421
+ ...process.env,
422
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
423
+ ...connectorEnv(),
424
+ },
369
425
  stdio: ['ignore', 'pipe', 'pipe'],
370
426
  });
371
427
 
@@ -433,9 +489,13 @@ function executeTask(task: Task): Promise<void> {
433
489
  conversationId: task.conversationId || undefined,
434
490
  skipPermissions: true,
435
491
  outputFormat: adapter.config.capabilities?.supportsStreamJson ? 'stream-json' : undefined,
492
+ appendSystemPrompt: taskAppendSystemPromptOverrides.get(task.id),
436
493
  });
437
494
 
438
- const env = { ...process.env, ...(spawnOpts.env || {}) };
495
+ // Surface connector PATs (GITLAB_TOKEN, GITHUB_TOKEN, ) so the
496
+ // child's Bash invocations (claude's Bash tool, gh/glab shells)
497
+ // pick them up automatically. Same logic as executeShellTask.
498
+ const env = { ...process.env, ...connectorEnv(), ...(spawnOpts.env || {}) };
439
499
  delete env.CLAUDECODE;
440
500
 
441
501
  updateTaskStatus(task.id, 'running');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -34,12 +34,14 @@
34
34
  "@anthropic-ai/sdk": "^0.96.0",
35
35
  "@auth/core": "^0.34.3",
36
36
  "@modelcontextprotocol/sdk": "^1.28.0",
37
+ "@types/adm-zip": "^0.5.8",
37
38
  "@xterm/addon-fit": "^0.11.0",
38
39
  "@xterm/addon-search": "^0.16.0",
39
40
  "@xterm/addon-unicode11": "^0.9.0",
40
41
  "@xterm/addon-webgl": "^0.19.0",
41
42
  "@xterm/xterm": "^6.0.0",
42
43
  "@xyflow/react": "^12.10.1",
44
+ "adm-zip": "^0.5.17",
43
45
  "ai": "^6.0.116",
44
46
  "better-sqlite3": "^12.6.2",
45
47
  "esbuild": "^0.27.3",