@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.
- package/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
package/lib/plugins/registry.ts
CHANGED
|
@@ -14,7 +14,8 @@ 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 } from './types';
|
|
17
|
+
import type { PluginDefinition, InstalledPlugin, PluginSource, Connector } from './types';
|
|
18
|
+
import { encryptSecret, decryptSecret, isEncrypted } from '../crypto';
|
|
18
19
|
|
|
19
20
|
const _filename = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
|
|
20
21
|
const _dirname = typeof __dirname !== 'undefined' ? __dirname : dirname(_filename);
|
|
@@ -31,33 +32,153 @@ function loadPluginYaml(filePath: string): PluginDefinition | null {
|
|
|
31
32
|
try {
|
|
32
33
|
const raw = readFileSync(filePath, 'utf-8');
|
|
33
34
|
const def = YAML.parse(raw) as PluginDefinition;
|
|
34
|
-
if (!def.id || !def.name
|
|
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;
|
|
35
41
|
// Defaults
|
|
36
42
|
if (!def.config) def.config = {};
|
|
37
43
|
if (!def.params) def.params = {};
|
|
44
|
+
if (!def.actions) def.actions = {};
|
|
38
45
|
if (!def.icon) def.icon = '🔌';
|
|
39
46
|
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';
|
|
40
49
|
return def;
|
|
41
50
|
} catch {
|
|
42
51
|
return null;
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
|
|
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
|
+
|
|
46
82
|
// ─── Config Storage ──────────────────────────────────────
|
|
47
83
|
|
|
48
|
-
|
|
84
|
+
/**
|
|
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.
|
|
88
|
+
*/
|
|
89
|
+
export function getSecretFieldNames(def: PluginDefinition | null): string[] {
|
|
90
|
+
if (!def?.settings) return [];
|
|
91
|
+
return Object.entries(def.settings)
|
|
92
|
+
.filter(([, schema]) => {
|
|
93
|
+
const t = String((schema as any)?.type || '');
|
|
94
|
+
return t === 'secret' || t === 'password';
|
|
95
|
+
})
|
|
96
|
+
.map(([name]) => name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve a config row's plugin def, accounting for instance `source`. */
|
|
100
|
+
function defForConfigRow(id: string, row: any): PluginDefinition | null {
|
|
101
|
+
const sourceId = (row?.source as string) || id;
|
|
102
|
+
// getPlugin scans YAML files directly — no loadConfigs call, so no
|
|
103
|
+
// cycle even though we're called from loadConfigs.
|
|
104
|
+
try { return getPlugin(sourceId); } catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function loadConfigs(): Record<string, { config: Record<string, any>; installedAt: string; enabled: boolean; source?: string; name?: string }> {
|
|
49
108
|
try {
|
|
50
|
-
if (existsSync(CONFIGS_FILE)) {
|
|
51
|
-
|
|
109
|
+
if (!existsSync(CONFIGS_FILE)) return {};
|
|
110
|
+
const raw = JSON.parse(readFileSync(CONFIGS_FILE, 'utf-8')) as Record<string, any>;
|
|
111
|
+
// Decrypt any encrypted secret fields on read. Plaintext values pass
|
|
112
|
+
// through unchanged so a user-edited file still works (will be
|
|
113
|
+
// encrypted next save by migratePluginSecrets / installPlugin).
|
|
114
|
+
for (const [id, row] of Object.entries(raw)) {
|
|
115
|
+
const def = defForConfigRow(id, row);
|
|
116
|
+
const secrets = getSecretFieldNames(def);
|
|
117
|
+
const cfg = (row as any)?.config;
|
|
118
|
+
if (!cfg || !secrets.length) continue;
|
|
119
|
+
for (const k of secrets) {
|
|
120
|
+
if (typeof cfg[k] === 'string' && isEncrypted(cfg[k])) {
|
|
121
|
+
try { cfg[k] = decryptSecret(cfg[k]); }
|
|
122
|
+
catch (e) { console.warn(`[plugins] decrypt failed for ${id}.${k}:`, (e as Error).message); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
52
125
|
}
|
|
53
|
-
|
|
54
|
-
|
|
126
|
+
return raw;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.warn('[plugins] loadConfigs failed:', (e as Error).message);
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
55
131
|
}
|
|
56
132
|
|
|
57
133
|
function saveConfigs(configs: Record<string, any>): void {
|
|
58
134
|
const dir = dirname(CONFIGS_FILE);
|
|
59
135
|
mkdirSync(dir, { recursive: true });
|
|
60
|
-
|
|
136
|
+
// Encrypt any secret fields before persisting. Operate on a deep-ish
|
|
137
|
+
// copy so the in-memory object the caller still holds remains decrypted.
|
|
138
|
+
const out: Record<string, any> = {};
|
|
139
|
+
for (const [id, row] of Object.entries(configs)) {
|
|
140
|
+
const def = defForConfigRow(id, row);
|
|
141
|
+
const secrets = getSecretFieldNames(def);
|
|
142
|
+
const copy = { ...(row as any), config: { ...((row as any).config || {}) } };
|
|
143
|
+
for (const k of secrets) {
|
|
144
|
+
const v = copy.config[k];
|
|
145
|
+
if (typeof v === 'string' && v && !isEncrypted(v)) {
|
|
146
|
+
try { copy.config[k] = encryptSecret(v); }
|
|
147
|
+
catch (e) { console.warn(`[plugins] encrypt failed for ${id}.${k}:`, (e as Error).message); }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out[id] = copy;
|
|
151
|
+
}
|
|
152
|
+
writeFileSync(CONFIGS_FILE, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One-shot migration: re-write plugin-configs.json so any plaintext
|
|
157
|
+
* secret fields get encrypted in place. Idempotent — calling it
|
|
158
|
+
* repeatedly is cheap once everything is already encrypted.
|
|
159
|
+
*/
|
|
160
|
+
export function migratePluginSecrets(): void {
|
|
161
|
+
if (!existsSync(CONFIGS_FILE)) return;
|
|
162
|
+
let raw: Record<string, any>;
|
|
163
|
+
try { raw = JSON.parse(readFileSync(CONFIGS_FILE, 'utf-8')); }
|
|
164
|
+
catch { return; }
|
|
165
|
+
let dirty = 0;
|
|
166
|
+
for (const [id, row] of Object.entries(raw)) {
|
|
167
|
+
const def = defForConfigRow(id, row);
|
|
168
|
+
const secrets = getSecretFieldNames(def);
|
|
169
|
+
const cfg = row?.config;
|
|
170
|
+
if (!cfg || !secrets.length) continue;
|
|
171
|
+
for (const k of secrets) {
|
|
172
|
+
if (typeof cfg[k] === 'string' && cfg[k] && !isEncrypted(cfg[k])) {
|
|
173
|
+
cfg[k] = encryptSecret(cfg[k]);
|
|
174
|
+
dirty++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (dirty > 0) {
|
|
179
|
+
writeFileSync(CONFIGS_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
|
|
180
|
+
console.log(`[plugins] Migrated ${dirty} plaintext secret(s) to encrypted storage`);
|
|
181
|
+
}
|
|
61
182
|
}
|
|
62
183
|
|
|
63
184
|
// ─── Public API ──────────────────────────────────────────
|
|
@@ -86,6 +207,8 @@ export function listPlugins(): PluginSource[] {
|
|
|
86
207
|
source: 'builtin',
|
|
87
208
|
installed: isInstalledOrHasInstance(def.id),
|
|
88
209
|
configCount: Object.keys(def.config).length,
|
|
210
|
+
category: def.category,
|
|
211
|
+
mode: def.mode,
|
|
89
212
|
});
|
|
90
213
|
}
|
|
91
214
|
}
|
|
@@ -113,6 +236,8 @@ export function listPlugins(): PluginSource[] {
|
|
|
113
236
|
source: 'local',
|
|
114
237
|
installed: isInstalledOrHasInstance(def.id),
|
|
115
238
|
configCount: Object.keys(def.config).length,
|
|
239
|
+
category: def.category,
|
|
240
|
+
mode: def.mode,
|
|
116
241
|
});
|
|
117
242
|
}
|
|
118
243
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template substitution for connector manifest fields.
|
|
3
|
+
*
|
|
4
|
+
* Expands tokens that depend on user settings ({base_url}, {settings.*}) when
|
|
5
|
+
* Forge serves the manifest to the extension. Tokens that depend on the LLM's
|
|
6
|
+
* tool arguments ({args.*}) are left literal — the extension's runner expands
|
|
7
|
+
* those at execution time.
|
|
8
|
+
*
|
|
9
|
+
* See docs/Connector-DeclarativeExtract-Spec.md §4.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type SettingsMap = Record<string, any>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Expand {base_url} and {settings.<key>} tokens in a string. Other tokens
|
|
16
|
+
* (including {args.*}) are passed through unchanged so the extension can
|
|
17
|
+
* finish substitution at run time.
|
|
18
|
+
*/
|
|
19
|
+
export function expandSettingsTokens(template: string, settings: SettingsMap | undefined): string {
|
|
20
|
+
if (!template || !settings) return template;
|
|
21
|
+
return template.replace(/\{([^{}]+)\}/g, (full, raw) => {
|
|
22
|
+
const key = String(raw).trim();
|
|
23
|
+
if (key === 'base_url') {
|
|
24
|
+
const v = settings.base_url;
|
|
25
|
+
return typeof v === 'string' ? stripTrailingSlashes(v) : full;
|
|
26
|
+
}
|
|
27
|
+
if (key.startsWith('settings.')) {
|
|
28
|
+
const path = key.slice('settings.'.length);
|
|
29
|
+
const v = settings[path];
|
|
30
|
+
return typeof v === 'string' ? stripTrailingSlashes(v) : full;
|
|
31
|
+
}
|
|
32
|
+
return full;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripTrailingSlashes(s: string): string {
|
|
37
|
+
return s.replace(/\/+$/, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Expand all tokens — `{base_url}`, `{settings.*}`, `{args.*}` — in one
|
|
42
|
+
* pass. Used by server-side protocols (http, shell) where Forge has both
|
|
43
|
+
* the settings and the LLM's parsed arguments in hand.
|
|
44
|
+
*
|
|
45
|
+
* Unknown tokens are left literal.
|
|
46
|
+
*
|
|
47
|
+
* `{args.*}` supports dotted paths (e.g. `{args.repo.full_name}`).
|
|
48
|
+
* Non-string values are JSON-encoded for object/array, coerced for
|
|
49
|
+
* number/boolean. base_url and settings get trailing slashes stripped;
|
|
50
|
+
* args do not (an args value is whatever the LLM passed).
|
|
51
|
+
*/
|
|
52
|
+
export function expandAllTokens(
|
|
53
|
+
template: string,
|
|
54
|
+
settings: SettingsMap | undefined,
|
|
55
|
+
args: SettingsMap | undefined,
|
|
56
|
+
): string {
|
|
57
|
+
if (!template) return template;
|
|
58
|
+
return template.replace(/\{([^{}]+)\}/g, (full, raw) => {
|
|
59
|
+
const key = String(raw).trim();
|
|
60
|
+
if (key === 'base_url') {
|
|
61
|
+
const v = settings?.base_url;
|
|
62
|
+
return typeof v === 'string' ? stripTrailingSlashes(v) : full;
|
|
63
|
+
}
|
|
64
|
+
if (key.startsWith('settings.')) {
|
|
65
|
+
const v = settings?.[key.slice('settings.'.length)];
|
|
66
|
+
if (v == null) return full;
|
|
67
|
+
return typeof v === 'string' ? v : String(v);
|
|
68
|
+
}
|
|
69
|
+
if (key.startsWith('args.')) {
|
|
70
|
+
const path = key.slice('args.'.length).split('.');
|
|
71
|
+
let v: any = args;
|
|
72
|
+
for (const p of path) {
|
|
73
|
+
if (v == null || typeof v !== 'object') { v = undefined; break; }
|
|
74
|
+
v = v[p];
|
|
75
|
+
}
|
|
76
|
+
if (v == null) return full;
|
|
77
|
+
if (typeof v === 'string') return v;
|
|
78
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
79
|
+
try { return JSON.stringify(v); } catch { return full; }
|
|
80
|
+
}
|
|
81
|
+
return full;
|
|
82
|
+
});
|
|
83
|
+
}
|
package/lib/plugins/types.ts
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
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';
|
|
14
|
+
|
|
15
|
+
/** Where a plugin's tools execute. Default is server-side (Forge node process). */
|
|
16
|
+
export type PluginMode = 'server-side' | 'browser-side' | 'hybrid';
|
|
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
|
+
|
|
12
24
|
/** Schema field definition for config/params */
|
|
13
25
|
export interface PluginFieldSchema {
|
|
14
26
|
type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
|
|
@@ -47,6 +59,106 @@ export interface PluginAction {
|
|
|
47
59
|
output?: Record<string, string>; // { fieldName: "$.json.path" or "$body" or "$stdout" }
|
|
48
60
|
}
|
|
49
61
|
|
|
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
|
+
|
|
50
162
|
/** Plugin definition (loaded from plugin.yaml) */
|
|
51
163
|
export interface PluginDefinition {
|
|
52
164
|
id: string;
|
|
@@ -56,17 +168,42 @@ export interface PluginDefinition {
|
|
|
56
168
|
author?: string;
|
|
57
169
|
description?: string;
|
|
58
170
|
|
|
171
|
+
/** Classifies the plugin for marketplace + extension filtering. */
|
|
172
|
+
category?: PluginCategory;
|
|
173
|
+
|
|
174
|
+
/** Where this plugin's tools execute. Connector plugins are usually `browser-side`. */
|
|
175
|
+
mode?: PluginMode;
|
|
176
|
+
|
|
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
|
+
|
|
59
186
|
/** Global config — set once when installing the plugin */
|
|
60
187
|
config: Record<string, PluginFieldSchema>;
|
|
61
188
|
|
|
62
189
|
/** Per-use params — set each time the plugin is used in a pipeline node */
|
|
63
190
|
params: Record<string, PluginFieldSchema>;
|
|
64
191
|
|
|
65
|
-
/** Named actions this plugin can perform */
|
|
192
|
+
/** Named actions this plugin can perform (server-side execution path) */
|
|
66
193
|
actions: Record<string, PluginAction>;
|
|
67
194
|
|
|
68
195
|
/** Default action to run if none specified */
|
|
69
196
|
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[];
|
|
70
207
|
}
|
|
71
208
|
|
|
72
209
|
/** Installed plugin instance (definition + user config values) */
|
|
@@ -100,4 +237,6 @@ export interface PluginSource {
|
|
|
100
237
|
source: 'builtin' | 'local' | 'registry';
|
|
101
238
|
installed: boolean;
|
|
102
239
|
configCount: number; // number of config fields — 0 means no config needed
|
|
240
|
+
category?: PluginCategory;
|
|
241
|
+
mode?: PluginMode;
|
|
103
242
|
}
|
package/lib/session-watcher.ts
CHANGED
|
@@ -40,7 +40,18 @@ export interface SessionWatcher {
|
|
|
40
40
|
|
|
41
41
|
// ─── Session cache sync ──────────────────────────────────────
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Sync session metadata to the cached_sessions table.
|
|
45
|
+
*
|
|
46
|
+
* @param projectName Optional — limit to one project.
|
|
47
|
+
* @param opts.fast Skip the expensive per-session entry_count update
|
|
48
|
+
* (reads the whole JSONL just to count lines). Used
|
|
49
|
+
* on initial startup sync where we have nothing to
|
|
50
|
+
* diff against yet; the next 30s watcher cycle fills
|
|
51
|
+
* entry_count in. Cuts startup by 3-5s on hosts with
|
|
52
|
+
* many sessions.
|
|
53
|
+
*/
|
|
54
|
+
export function syncSessionsToDb(projectName?: string, opts: { fast?: boolean } = {}) {
|
|
44
55
|
const db = getDb(DB_PATH);
|
|
45
56
|
const projects = projectName
|
|
46
57
|
? [{ name: projectName }]
|
|
@@ -75,13 +86,17 @@ export function syncSessionsToDb(projectName?: string) {
|
|
|
75
86
|
s.gitBranch || null, s.fileSize,
|
|
76
87
|
);
|
|
77
88
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
// Skip entry_count on fast path — it parses the whole JSONL file
|
|
90
|
+
// and is the dominant cost when initial-syncing hundreds of sessions.
|
|
91
|
+
// The 30s watcher cycle will catch up.
|
|
92
|
+
if (!opts.fast) {
|
|
93
|
+
const fp = getSessionFilePath(proj.name, s.sessionId);
|
|
94
|
+
if (fp) {
|
|
95
|
+
try {
|
|
96
|
+
const entries = readSessionEntries(fp);
|
|
97
|
+
updateEntryCount.run(entries.length, proj.name, s.sessionId);
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
totalSynced++;
|
|
@@ -201,8 +216,19 @@ let watcherInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
201
216
|
export function startWatcherLoop() {
|
|
202
217
|
if (watcherInterval) return;
|
|
203
218
|
|
|
204
|
-
//
|
|
205
|
-
|
|
219
|
+
// Defer the initial sync — it was blocking ensureInitialized() by 3-5s
|
|
220
|
+
// on hosts with many sessions (~/.claude/projects/<proj>/*.jsonl per-file
|
|
221
|
+
// parse for entry_count). setImmediate yields the event loop first so
|
|
222
|
+
// the API server starts answering requests; the sync runs in the
|
|
223
|
+
// background. Also pass fast=true to skip the heavy entry_count fill —
|
|
224
|
+
// the 30s watcher cycle picks that up later.
|
|
225
|
+
setImmediate(() => {
|
|
226
|
+
const t0 = Date.now();
|
|
227
|
+
try { syncSessionsToDb(undefined, { fast: true }); }
|
|
228
|
+
catch (e) { console.error('[watcher] Initial sync error:', e); }
|
|
229
|
+
const ms = Date.now() - t0;
|
|
230
|
+
if (ms >= 500) console.log(`[watcher] Initial sync (fast) took ${ms}ms`);
|
|
231
|
+
});
|
|
206
232
|
|
|
207
233
|
// Check every 30 seconds
|
|
208
234
|
watcherInterval = setInterval(runWatcherCheck, 30_000);
|
package/lib/settings.ts
CHANGED
|
@@ -18,18 +18,33 @@ export interface AgentEntry {
|
|
|
18
18
|
base?: string; // base agent ID (e.g., 'claude') — makes this a profile
|
|
19
19
|
// API profile fields
|
|
20
20
|
type?: 'cli' | 'api'; // 'api' = API mode, default = 'cli'
|
|
21
|
-
provider?: string; // API provider (e.g., 'anthropic', 'google')
|
|
21
|
+
provider?: string; // API provider label (e.g., 'anthropic', 'openai', 'litellm', 'grok', 'google')
|
|
22
22
|
model?: string; // model override (for both CLI and API profiles)
|
|
23
23
|
apiKey?: string; // per-profile API key (encrypted)
|
|
24
|
+
/**
|
|
25
|
+
* Base URL override for API profiles. Use this for LiteLLM / Azure /
|
|
26
|
+
* self-hosted proxies. Empty = default for the provider's protocol.
|
|
27
|
+
* For CLI profiles, set base URL via `env` (ANTHROPIC_BASE_URL etc.).
|
|
28
|
+
*/
|
|
29
|
+
baseUrl?: string;
|
|
24
30
|
env?: Record<string, string>; // environment variables injected when spawning CLI
|
|
25
31
|
cliType?: 'claude-code' | 'codex' | 'aider' | 'generic'; // CLI tool type — determines session support, resume flags, etc.
|
|
26
32
|
profile?: string; // linked profile ID — overrides model, env, etc. when launching
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
/**
|
|
36
|
+
* External MCP server configuration — merged into per-project .mcp.json
|
|
37
|
+
* alongside the built-in `forge` entry. Format mirrors the MCP standard.
|
|
38
|
+
*/
|
|
39
|
+
export interface McpServerConfig {
|
|
40
|
+
type: 'stdio' | 'sse' | 'streamable-http';
|
|
41
|
+
// stdio
|
|
42
|
+
command?: string;
|
|
43
|
+
args?: string[];
|
|
44
|
+
env?: Record<string, string>;
|
|
45
|
+
// sse / streamable-http
|
|
46
|
+
url?: string;
|
|
47
|
+
headers?: Record<string, string>;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export interface Settings {
|
|
@@ -56,8 +71,34 @@ export interface Settings {
|
|
|
56
71
|
defaultAgent: string;
|
|
57
72
|
telegramAgent: string;
|
|
58
73
|
docsAgent: string;
|
|
74
|
+
/** Default API agent profile used by Forge's chat backend (extension / web / CLI / Telegram /chat). */
|
|
75
|
+
chatAgent: string;
|
|
76
|
+
/**
|
|
77
|
+
* Temper memory service — long-term memory backend for the chat agent.
|
|
78
|
+
* When url + key are both set, chat fetches pinned blocks / search hits
|
|
79
|
+
* on every turn and exposes memory_* tools to the LLM.
|
|
80
|
+
*/
|
|
81
|
+
temperUrl: string;
|
|
82
|
+
temperKey: string;
|
|
83
|
+
/** Optional namespace override (empty = use the key's default namespace). */
|
|
84
|
+
temperNamespace: string;
|
|
85
|
+
/**
|
|
86
|
+
* Which memory backend the chat agent uses.
|
|
87
|
+
* 'auto' — Temper if URL+key are set, otherwise local SQLite (default).
|
|
88
|
+
* 'local' — always local SQLite, even if Temper credentials are filled.
|
|
89
|
+
* 'temper' — always Temper; if credentials are missing, memory is disabled.
|
|
90
|
+
*/
|
|
91
|
+
memoryBackend: 'auto' | 'local' | 'temper';
|
|
59
92
|
agents: Record<string, AgentEntry>;
|
|
60
|
-
|
|
93
|
+
/** Extra MCP servers merged into each project's .mcp.json (chrome-devtools-mcp etc.) */
|
|
94
|
+
mcpServers: Record<string, McpServerConfig>;
|
|
95
|
+
/**
|
|
96
|
+
* Preferred timezone for rendering timestamps in the UI. Format: IANA
|
|
97
|
+
* (e.g. "America/Los_Angeles", "Asia/Shanghai", "Europe/Berlin").
|
|
98
|
+
* Empty = use the OS / browser default (Intl.DateTimeFormat().resolvedOptions().timeZone).
|
|
99
|
+
* Used by the extension's date formatter for chat / jobs / pipeline run timestamps.
|
|
100
|
+
*/
|
|
101
|
+
timezone: string;
|
|
61
102
|
}
|
|
62
103
|
|
|
63
104
|
const defaults: Settings = {
|
|
@@ -84,39 +125,36 @@ const defaults: Settings = {
|
|
|
84
125
|
defaultAgent: 'claude',
|
|
85
126
|
telegramAgent: '',
|
|
86
127
|
docsAgent: '',
|
|
128
|
+
chatAgent: '',
|
|
129
|
+
temperUrl: '',
|
|
130
|
+
temperKey: '',
|
|
131
|
+
temperNamespace: '',
|
|
132
|
+
memoryBackend: 'auto',
|
|
87
133
|
agents: {},
|
|
88
|
-
|
|
134
|
+
mcpServers: {},
|
|
135
|
+
timezone: '',
|
|
89
136
|
};
|
|
90
137
|
|
|
91
|
-
/** Decrypt nested apiKey fields in
|
|
138
|
+
/** Decrypt nested apiKey fields in agent profiles */
|
|
92
139
|
function decryptNestedSecrets(settings: Settings): void {
|
|
93
|
-
// Decrypt provider apiKeys
|
|
94
|
-
if (settings.providers) {
|
|
95
|
-
for (const p of Object.values(settings.providers)) {
|
|
96
|
-
if (p.apiKey && isEncrypted(p.apiKey)) {
|
|
97
|
-
p.apiKey = decryptSecret(p.apiKey);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Decrypt agent profile apiKeys
|
|
102
140
|
if (settings.agents) {
|
|
103
|
-
for (const a of Object.
|
|
141
|
+
for (const [id, a] of Object.entries(settings.agents)) {
|
|
104
142
|
if (a.apiKey && isEncrypted(a.apiKey)) {
|
|
105
143
|
a.apiKey = decryptSecret(a.apiKey);
|
|
106
144
|
}
|
|
145
|
+
// Defensive: a past bug let the masked placeholder get encrypted
|
|
146
|
+
// and written back. Clear it so callers see "no key" instead of
|
|
147
|
+
// trying to send '••••••••' as a Bearer header.
|
|
148
|
+
if (a.apiKey && /^•+$/.test(a.apiKey)) {
|
|
149
|
+
console.warn(`[settings] agent '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
|
|
150
|
+
a.apiKey = '';
|
|
151
|
+
}
|
|
107
152
|
}
|
|
108
153
|
}
|
|
109
154
|
}
|
|
110
155
|
|
|
111
|
-
/** Encrypt nested apiKey fields in
|
|
156
|
+
/** Encrypt nested apiKey fields in agent profiles */
|
|
112
157
|
function encryptNestedSecrets(settings: Settings): void {
|
|
113
|
-
if (settings.providers) {
|
|
114
|
-
for (const p of Object.values(settings.providers)) {
|
|
115
|
-
if (p.apiKey && !isEncrypted(p.apiKey)) {
|
|
116
|
-
p.apiKey = encryptSecret(p.apiKey);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
158
|
if (settings.agents) {
|
|
121
159
|
for (const a of Object.values(settings.agents)) {
|
|
122
160
|
if (a.apiKey && !isEncrypted(a.apiKey)) {
|
|
@@ -154,13 +192,7 @@ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string,
|
|
|
154
192
|
status[field] = !!settings[field];
|
|
155
193
|
settings[field] = settings[field] ? '••••••••' : '';
|
|
156
194
|
}
|
|
157
|
-
// Mask nested apiKeys
|
|
158
|
-
if (settings.providers) {
|
|
159
|
-
for (const [name, p] of Object.entries(settings.providers)) {
|
|
160
|
-
status[`providers.${name}.apiKey`] = !!p.apiKey;
|
|
161
|
-
p.apiKey = p.apiKey ? '••••••••' : '';
|
|
162
|
-
}
|
|
163
|
-
}
|
|
195
|
+
// Mask nested apiKeys (agent profiles only)
|
|
164
196
|
if (settings.agents) {
|
|
165
197
|
for (const [name, a] of Object.entries(settings.agents)) {
|
|
166
198
|
if (a.apiKey) {
|
package/lib/skills.ts
CHANGED
|
@@ -187,7 +187,9 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
|
|
|
187
187
|
return { synced: rawItems.length, enriched, total: totalCount, remaining: Math.max(0, remaining) };
|
|
188
188
|
} catch (e) {
|
|
189
189
|
const msg = e instanceof Error ? e.message : String(e);
|
|
190
|
-
|
|
190
|
+
// Corp networks routinely can't reach GitHub raw — this is expected
|
|
191
|
+
// and shouldn't look scary in the startup log. Demote to warn.
|
|
192
|
+
console.warn(`[skills] Sync failed (continuing offline):`, msg);
|
|
191
193
|
return { synced: 0, enriched: 0, error: msg };
|
|
192
194
|
}
|
|
193
195
|
}
|