@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.
- package/RELEASE_NOTES.md +6 -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 +4 -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 +66 -6
- package/lib/jobs/store.ts +51 -2
- package/lib/jobs/types.ts +32 -0
- package/lib/pipeline-scheduler.ts +3 -2
- package/lib/pipeline.ts +137 -15
- 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 +4 -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
package/lib/jobs/types.ts
CHANGED
|
@@ -62,6 +62,28 @@ export interface Job {
|
|
|
62
62
|
dispatch_type: DispatchType;
|
|
63
63
|
dispatch_params: DispatchParams;
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Skill names (from forge-skills registry) to make available to the
|
|
67
|
+
* task spawned by this job's dispatch. Forwarded into the task's
|
|
68
|
+
* `--append-system-prompt` so Claude knows which skills to invoke
|
|
69
|
+
* for this run. Missing skills are auto-installed at dispatch time
|
|
70
|
+
* (see lib/jobs/dispatcher.ts).
|
|
71
|
+
*/
|
|
72
|
+
skills: string[];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Trigger model:
|
|
76
|
+
* 'period' — fire every `schedule_interval_minutes`
|
|
77
|
+
* 'once' — fire once at `schedule_at` (ISO), then auto-disable
|
|
78
|
+
* 'cron' — fire on each match of `schedule_cron` (5-field cron, server local TZ)
|
|
79
|
+
* 'manual' — never auto-fire; only the Fire / Force button (POST /api/jobs/[id]/fire) starts a run
|
|
80
|
+
*/
|
|
81
|
+
schedule_kind: 'period' | 'once' | 'cron' | 'manual';
|
|
82
|
+
/** ISO timestamp; only used when schedule_kind === 'once'. */
|
|
83
|
+
schedule_at: string | null;
|
|
84
|
+
/** Cron expression (5 fields); only used when schedule_kind === 'cron'. */
|
|
85
|
+
schedule_cron: string | null;
|
|
86
|
+
|
|
65
87
|
last_run_at: string | null;
|
|
66
88
|
next_run_at: string | null;
|
|
67
89
|
created_at: string;
|
|
@@ -112,6 +134,16 @@ export interface CreateJobInput {
|
|
|
112
134
|
dispatch_type: DispatchType;
|
|
113
135
|
dispatch_params: DispatchParams;
|
|
114
136
|
|
|
137
|
+
/** Skill names to forward into the dispatched task. See Job.skills. */
|
|
138
|
+
skills?: string[];
|
|
139
|
+
|
|
140
|
+
/** Default 'period'. */
|
|
141
|
+
schedule_kind?: 'period' | 'once' | 'cron' | 'manual';
|
|
142
|
+
/** ISO timestamp, required when schedule_kind === 'once'. */
|
|
143
|
+
schedule_at?: string | null;
|
|
144
|
+
/** Cron expression, required when schedule_kind === 'cron'. */
|
|
145
|
+
schedule_cron?: string | null;
|
|
146
|
+
|
|
115
147
|
/** Default true: first tick records existing items as seen without dispatching. */
|
|
116
148
|
mark_existing_as_seen?: boolean;
|
|
117
149
|
}
|
|
@@ -162,14 +162,15 @@ export function resetDedup(projectPath: string, workflowName: string, dedupKey:
|
|
|
162
162
|
|
|
163
163
|
export function triggerPipeline(
|
|
164
164
|
projectPath: string, projectName: string, workflowName: string,
|
|
165
|
-
extraInput?: Record<string, any>, dedupKey?: string
|
|
165
|
+
extraInput?: Record<string, any>, dedupKey?: string,
|
|
166
|
+
opts: { skills?: string[] } = {},
|
|
166
167
|
): { pipelineId: string; runId: string } {
|
|
167
168
|
const input: Record<string, string> = {
|
|
168
169
|
project: projectName,
|
|
169
170
|
...extraInput,
|
|
170
171
|
};
|
|
171
172
|
|
|
172
|
-
const pipeline = startPipeline(workflowName, input);
|
|
173
|
+
const pipeline = startPipeline(workflowName, input, { skills: opts.skills });
|
|
173
174
|
const runId = recordRun(projectPath, workflowName, pipeline.id, dedupKey);
|
|
174
175
|
updateLastRunAt(projectPath, workflowName);
|
|
175
176
|
console.log(`[pipeline-scheduler] Triggered ${workflowName} for ${projectName} (pipeline: ${pipeline.id}${dedupKey ? ', dedup: ' + dedupKey : ''})`);
|
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;
|
|
@@ -560,8 +567,8 @@ nodes:
|
|
|
560
567
|
DESCRIPTION_B64 full bug description (base64; often the most
|
|
561
568
|
important field — repro steps live here)
|
|
562
569
|
ADDITIONAL_INFO_B64 "Additional Information" custom field —
|
|
563
|
-
|
|
564
|
-
|
|
570
|
+
often holds stack traces, env details, log
|
|
571
|
+
snippets. Decode + read.
|
|
565
572
|
NOTES_B64 all comments concatenated, with author + date
|
|
566
573
|
headers ([author @ date]). Use to see what
|
|
567
574
|
QA + reporter already discussed.
|
|
@@ -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
|
|
721
|
-
# token.
|
|
722
|
-
#
|
|
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:
|
|
727
|
-
echo "Fix:
|
|
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
|
-
|
|
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
|
-
|
|
1832
|
-
|
|
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
|
|
package/lib/plugins/registry.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
48
|
-
if (!def.mode) def.mode =
|
|
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`
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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?.
|
|
91
|
-
return Object.entries(def.
|
|
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';
|
package/lib/plugins/types.ts
CHANGED
|
@@ -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
|
|
13
|
-
export type PluginCategory = '
|
|
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
|
|
65
|
+
/** Classifies the plugin for marketplace filtering. */
|
|
172
66
|
category?: PluginCategory;
|
|
173
67
|
|
|
174
|
-
/** Where this plugin's tools execute.
|
|
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`);
|