@aion0/forge 0.10.86 → 0.10.88

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 CHANGED
@@ -1,11 +1,8 @@
1
- # Forge v0.10.86
1
+ # Forge v0.10.88
2
2
 
3
- Released: 2026-06-16
3
+ Released: 2026-06-17
4
4
 
5
- ## Changes since v0.10.85
5
+ ## Changes since v0.10.87
6
6
 
7
- ### Other
8
- - fix(schedules): pause/resume + extractor openai support
9
7
 
10
-
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.85...v0.10.86
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.87...v0.10.88
@@ -219,6 +219,8 @@ async function apply(template: any, values: Record<string, string>): Promise<{
219
219
  fields_preserved: Array<{ connector: string; field: string; reason: string }>;
220
220
  fields_left_empty: Array<{ connector: string; field: string }>;
221
221
  agents_applied?: string[];
222
+ skills_installed?: string[];
223
+ skills_failed?: Array<{ name: string; error: string }>;
222
224
  }> {
223
225
  // Auto-inject user identity from settings so connectors (e.g. tp.username)
224
226
  // can use {user_name} / {user_email} without prompting the user again.
@@ -359,6 +361,30 @@ async function apply(template: any, values: Record<string, string>): Promise<{
359
361
  }
360
362
  }
361
363
 
364
+ // ─── _skills block — auto-install marketplace skills to global ─────
365
+ //
366
+ // Template declares `"_skills": ["prelude", ...]`. On apply we sync the
367
+ // marketplace registry once, then installGlobal each — so e.g. the fortinet
368
+ // template pulls prelude into ~/.claude/skills/ at setup time. installGlobal
369
+ // always resolves the registry's latest version, so "0.2.0 and later" tracks
370
+ // automatically. Failures are collected, never abort the rest of the apply.
371
+ const skillsInstalled: string[] = [];
372
+ const skillsFailed: Array<{ name: string; error: string }> = [];
373
+ const skillsList = Array.isArray((template as any)._skills) ? (template as any)._skills : [];
374
+ if (skillsList.length > 0) {
375
+ const { syncSkills, installGlobal } = await import('@/lib/skills');
376
+ // syncSkills populates the local skills DB that installGlobal reads from.
377
+ // If it fails (offline), installGlobal will likely fail too (it downloads
378
+ // files from GitHub) — that surfaces per-skill in skills_failed, never aborts.
379
+ try { await syncSkills(); } catch { /* non-fatal — per-skill install still attempted */ }
380
+ for (const raw of skillsList) {
381
+ const name = typeof raw === 'string' ? raw.trim() : '';
382
+ if (!name) continue;
383
+ try { await installGlobal(name); skillsInstalled.push(name); }
384
+ catch (e: any) { skillsFailed.push({ name, error: e?.message || String(e) }); }
385
+ }
386
+ }
387
+
362
388
  return {
363
389
  applied,
364
390
  installed_from_registry: installedFromRegistry,
@@ -367,6 +393,8 @@ async function apply(template: any, values: Record<string, string>): Promise<{
367
393
  fields_preserved: preserved,
368
394
  fields_left_empty: leftEmpty,
369
395
  agents_applied: agentsApplied,
396
+ skills_installed: skillsInstalled,
397
+ skills_failed: skillsFailed,
370
398
  };
371
399
  }
372
400
 
@@ -28,6 +28,17 @@ import { homedir } from 'node:os';
28
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
29
  const ROOT = join(__dirname, '..');
30
30
 
31
+ // Pre-install EnvHttpProxyAgent before any dynamic import('undici') can
32
+ // clobber Node's NODE_USE_ENV_PROXY agent (see lib/proxy-setup.ts).
33
+ // No-op off-corp (no proxy env), so non-docker deployments are unchanged.
34
+ if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
35
+ process.env.https_proxy || process.env.http_proxy) {
36
+ try {
37
+ const { setGlobalDispatcher, EnvHttpProxyAgent } = await import('undici');
38
+ setGlobalDispatcher(new EnvHttpProxyAgent());
39
+ } catch { /* undici unavailable — leave Node's default dispatcher */ }
40
+ }
41
+
31
42
  /** Build Next.js — install devDependencies first if missing */
32
43
  function buildNext() {
33
44
  // Check if devDependencies are installed (e.g. @tailwindcss/postcss)
@@ -26,6 +26,8 @@ interface Skill {
26
26
  installedProjects: string[];
27
27
  deletedRemotely: boolean;
28
28
  source?: 'registry' | 'local';
29
+ sourceId?: string;
30
+ isEnterprise?: boolean;
29
31
  }
30
32
 
31
33
  interface ProjectInfo {
@@ -584,6 +586,15 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
584
586
  >
585
587
  <div className="flex items-center gap-2">
586
588
  <span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{skill.displayName}</span>
589
+ {/* Source badge: enterprise skills (synced from an enterprise
590
+ repo, winning over a same-name public skill) get a 🔒 chip
591
+ with the tenant id; public skills stay unmarked. */}
592
+ {skill.isEnterprise && (
593
+ <span
594
+ className="text-[8px] px-1 rounded font-medium bg-amber-500/20 text-amber-500 shrink-0"
595
+ title={`Enterprise source: ${skill.sourceId?.replace(/^enterprise-/, '') || ''}`}
596
+ >🔒 {skill.sourceId?.replace(/^enterprise-/, '') || 'enterprise'}</span>
597
+ )}
587
598
  <span className="text-[8px] text-[var(--text-secondary)] font-mono shrink-0">v{skill.version}</span>
588
599
  {skill.rating > 0 && (
589
600
  <span className="text-[8px] text-[var(--yellow)] shrink-0" title={`Rating: ${skill.rating}/5`}>
@@ -2,6 +2,8 @@
2
2
  * Next.js instrumentation — runs once when the server starts.
3
3
  * Loads .env.local and prints login password.
4
4
  */
5
+ import './lib/proxy-setup'; // MUST be first — see proxy-setup.ts header.
6
+
5
7
  export async function register() {
6
8
  // Only run on server, not Edge
7
9
  if (process.env.NEXT_RUNTIME === 'nodejs') {
@@ -39,6 +39,7 @@
39
39
  * Usage: npx tsx lib/browser-bridge-standalone.ts [--forge-port=8403]
40
40
  */
41
41
 
42
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
42
43
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
43
44
  import { WebSocket, WebSocketServer } from 'ws';
44
45
  import { randomUUID, createHash } from 'node:crypto';
@@ -241,8 +241,14 @@ async function exchangeBearerToken(
241
241
  const exchangeFetchInit = { method: auth.exchange_method || 'POST', headers, body };
242
242
  let res: Response;
243
243
  if (verifyTls === false) {
244
- const { fetch: undiciFetch, Agent } = await import('undici');
245
- const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
244
+ // Per-call dispatcher bypasses the global proxy agent — route via
245
+ // ProxyAgent when a proxy is set (corp), else plain Agent (direct).
246
+ const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
247
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
248
+ process.env.HTTP_PROXY || process.env.http_proxy;
249
+ const dispatcher = proxyUrl
250
+ ? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
251
+ : new Agent({ connect: { rejectUnauthorized: false } });
246
252
  res = await undiciFetch(exchangeUrl, { ...exchangeFetchInit, dispatcher }) as unknown as Response;
247
253
  } else {
248
254
  res = await fetch(exchangeUrl, exchangeFetchInit);
@@ -488,8 +494,14 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
488
494
  // fetch are version-matched; passing an external undici Agent
489
495
  // into Node's bundled global fetch fails with UND_ERR_INVALID_ARG
490
496
  // because Node 22 ships an older undici than the installed one.
491
- const { fetch: undiciFetch, Agent } = await import('undici');
492
- const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
497
+ // Per-call dispatcher bypasses the global proxy agent — route via
498
+ // ProxyAgent when a proxy is set (corp), else plain Agent (direct).
499
+ const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
500
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
501
+ process.env.HTTP_PROXY || process.env.http_proxy;
502
+ const dispatcher = proxyUrl
503
+ ? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
504
+ : new Agent({ connect: { rejectUnauthorized: false } });
493
505
  res = await undiciFetch(url, { method, headers, body, signal: controller.signal, dispatcher }) as unknown as Response;
494
506
  } else {
495
507
  res = await fetch(url, { method, headers, body, signal: controller.signal });
@@ -30,6 +30,7 @@
30
30
  * keep their existing WS path; HTTP-only surfaces use SSE.
31
31
  */
32
32
 
33
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
33
34
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
34
35
  import {
35
36
  createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
@@ -134,8 +134,14 @@ async function runHttpProbe(
134
134
  // NAC, ESXi …) need undici with rejectUnauthorized:false, same as http.ts.
135
135
  const fetchInit = { method, headers, body, signal: ctrl.signal };
136
136
  if (def.http?.verify_tls === false) {
137
- const { fetch: undiciFetch, Agent } = await import('undici');
138
- const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
137
+ // A per-call dispatcher bypasses the global proxy agent, so when a proxy
138
+ // is set (corp) route through ProxyAgent; else plain Agent (direct).
139
+ const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
140
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
141
+ process.env.HTTP_PROXY || process.env.http_proxy;
142
+ const dispatcher = proxyUrl
143
+ ? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
144
+ : new Agent({ connect: { rejectUnauthorized: false } });
139
145
  res = await undiciFetch(url, { ...fetchInit, dispatcher } as any) as unknown as Response;
140
146
  } else {
141
147
  res = await fetch(url, fetchInit);
@@ -76,6 +76,19 @@ The WebSocket dropped (system suspend, network blip). Forge auto-reconnects afte
76
76
  gh auth login
77
77
  ```
78
78
 
79
+ ### Connector Test fails with `ENETUNREACH` behind a corp proxy
80
+ Symptom: a brand-new deployment behind a corporate proxy/ZTNA, configure a
81
+ connector (e.g. GitLab) → **Test** → `request failed: fetch failed: connect
82
+ ENETUNREACH <backend-ip>:443`.
83
+
84
+ Forge honours `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` automatically — every
85
+ entry point pre-installs an `EnvHttpProxyAgent` before any code makes a request,
86
+ so a proxy set in the environment is used for all connector calls (including
87
+ `verify_tls: false` self-signed appliances like FortiNAC). Just make sure the
88
+ proxy env vars are exported for the Forge process (in Docker, via the
89
+ container's environment). Off-corp deployments with no proxy env are unaffected
90
+ — requests go direct as before.
91
+
79
92
  ### Skills not syncing
80
93
  Click "Sync" in Skills tab. Check `skillsRepoUrl` in Settings points to valid registry.
81
94
 
@@ -18,6 +18,8 @@
18
18
  * next sub-task still runs in the same cycle.
19
19
  */
20
20
 
21
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
22
+
21
23
  const MEMORY_TICK_SEC = 120;
22
24
 
23
25
  export interface MemorySubTask {
package/lib/pipeline.ts CHANGED
@@ -10,7 +10,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSy
10
10
  import { execSync } from 'node:child_process';
11
11
  import { join } from 'node:path';
12
12
  import YAML from 'yaml';
13
- import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
13
+ import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, taskSkillsOverrides, cancelTask } from './task-manager';
14
14
  import { getProjectInfo, resolveOrCloneProject, resolveProjectStrict, getProjectWorktreeRoot, ensureScratchProject } from './projects';
15
15
  import { loadSettings } from './settings';
16
16
  import { getAgent, listAgents } from './agents';
@@ -182,6 +182,13 @@ export interface Workflow {
182
182
  * Default: false (preserves the scratch fallback for connector-only / chat
183
183
  * pipelines that don't touch a repo). */
184
184
  requires_real_project?: boolean;
185
+ /** Skills auto-attached to every task this workflow spawns, declared in the
186
+ * workflow YAML. Merged (deduped) with any skills the dispatching
187
+ * Job/Schedule/chat passes at runtime — so a pipeline can carry a default
188
+ * skill (e.g. prelude) regardless of how it's triggered, including manual
189
+ * Fire which passes no opts.skills. Composed into each task's
190
+ * --append-system-prompt via renderSkillsAppendPrompt + auto-installed. */
191
+ skills?: string[];
185
192
  /** Default execution backend for every node's task. 'tmux' runs interactive
186
193
  * claude in a per-node tmux session (subscription billing); 'headless' /
187
194
  * omitted uses default `claude -p`. Per-node `backend:` overrides this. */
@@ -535,6 +542,9 @@ export function parseWorkflow(raw: string): Workflow {
535
542
  // would newly enable the preflight on existing fortinet pipelines, an
536
543
  // unrelated behavior change that belongs in its own commit.
537
544
  requires_real_project: parsed.requires_real_project === true,
545
+ skills: Array.isArray(parsed.skills)
546
+ ? parsed.skills.filter((s: any) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
547
+ : undefined,
538
548
  };
539
549
  }
540
550
 
@@ -1093,7 +1103,10 @@ export function startPipeline(
1093
1103
  nodes,
1094
1104
  nodeOrder,
1095
1105
  createdAt: new Date().toISOString(),
1096
- skills: opts.skills && opts.skills.length ? [...opts.skills] : undefined,
1106
+ skills: (() => {
1107
+ const merged = [...(workflow.skills || []), ...(opts.skills || [])];
1108
+ return merged.length ? [...new Set(merged)] : undefined;
1109
+ })(),
1097
1110
  // Runtime override (e.g. chat "use tmux") wins over the workflow's declared default.
1098
1111
  backend: opts.backend ?? workflow.backend,
1099
1112
  forEach: forEachState,
@@ -1274,7 +1287,10 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
1274
1287
  pipelineTaskIds.add(task.id);
1275
1288
  {
1276
1289
  const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
1277
- if (skillsAppend) taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
1290
+ if (skillsAppend) {
1291
+ taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
1292
+ taskSkillsOverrides.set(task.id, pipeline.skills!);
1293
+ }
1278
1294
  }
1279
1295
 
1280
1296
  // Add pending message
@@ -2157,6 +2173,7 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
2157
2173
  const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
2158
2174
  if (skillsAppend) {
2159
2175
  taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
2176
+ taskSkillsOverrides.set(task.id, pipeline.skills!);
2160
2177
  }
2161
2178
  // Pipeline tasks use the same model selection as normal tasks
2162
2179
  // (per-task override > agent scene model > agent default).
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pre-install an EnvHttpProxyAgent on undici's global dispatcher BEFORE
3
+ * anything dynamic-imports the npm undici (8.x).
4
+ *
5
+ * Why: Node honours NODE_USE_ENV_PROXY by putting EnvHttpProxyAgent on
6
+ * undici v1's dispatcher symbol. The npm undici uses v2; on load its
7
+ * `if (getGlobalDispatcher() === undefined) setGlobalDispatcher(new Agent())`
8
+ * reads v2 (undefined) and writes a plain Agent into BOTH v1 and v2,
9
+ * silently clobbering Node's proxy agent — after which every fetch ignores
10
+ * HTTPS_PROXY and direct-connects (ENETUNREACH behind corp ZTNA).
11
+ * Setting v2 here makes that check non-undefined, so npm undici skips it.
12
+ *
13
+ * MUST be the FIRST import in every entry point. Off-corp (no proxy env)
14
+ * this is a no-op, so non-docker / standalone deployments are unchanged.
15
+ */
16
+ import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici';
17
+
18
+ if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
19
+ process.env.https_proxy || process.env.http_proxy) {
20
+ setGlobalDispatcher(new EnvHttpProxyAgent());
21
+ }
package/lib/skills.ts CHANGED
@@ -9,6 +9,7 @@ import { getDb } from '../src/core/db/database';
9
9
  import { getDbPath } from '../src/config';
10
10
  import { loadSettings } from './settings';
11
11
  import { getClaudeDir } from './dirs';
12
+ import { listSources, fetchSourceFile, type SourceMeta } from './connectors/sync';
12
13
 
13
14
  type ItemType = 'skill' | 'command';
14
15
 
@@ -30,6 +31,11 @@ interface SkillItem {
30
31
  deletedRemotely: boolean;
31
32
  /** 'registry' (synced from forge-skills) or 'local' (uploaded). */
32
33
  source: 'registry' | 'local';
34
+ /** Which marketplace source the registry row came from: 'public' or an
35
+ * 'enterprise-<tenant>' id. Lets the UI show a per-skill source badge. */
36
+ sourceId: string;
37
+ /** True when sourceId is an enterprise source (for the 🔒 badge). */
38
+ isEnterprise: boolean;
33
39
  }
34
40
 
35
41
  function db() {
@@ -49,6 +55,40 @@ function getRepoInfo(): { owner: string; repo: string; branch: string } {
49
55
  return { owner: 'aiwatching', repo: 'forge-skills', branch: 'main' };
50
56
  }
51
57
 
58
+ // ─── Multi-source (#354) ──────────────────────────────────────
59
+ //
60
+ // Skills sync from the same source set as connectors: enterprise sources
61
+ // (by priority) layered OVER the public marketplace. We reuse the connector
62
+ // module's SourceMeta + fetchSourceFile (it already handles raw vs github_api
63
+ // + private-repo PAT auth), but the PUBLIC source must point at forge-skills,
64
+ // not forge-connectors — so we take the enterprise entries from listSources()
65
+ // and swap in the skills public base.
66
+
67
+ /** Ordered skill sources, highest priority first, public last. */
68
+ function listSkillSources(): SourceMeta[] {
69
+ const enterprise = listSources().filter((s) => s.is_enterprise);
70
+ const publicSrc: SourceMeta = {
71
+ id: 'public',
72
+ display_name: 'Public',
73
+ is_enterprise: false,
74
+ priority: enterprise.length,
75
+ fetch_mode: 'raw',
76
+ base_url: getBaseUrl(),
77
+ };
78
+ return [...enterprise, publicSrc];
79
+ }
80
+
81
+ function sourceById(id: string): SourceMeta | undefined {
82
+ return listSkillSources().find((s) => s.id === id);
83
+ }
84
+
85
+ /** "owner/repo" for a source — for the source_repo DB column. */
86
+ function sourceRepoOf(s: SourceMeta): string {
87
+ if (s.is_enterprise) return (s.repo_url || '').replace(/^github\.com\//, '');
88
+ const r = getRepoInfo();
89
+ return `${r.owner}/${r.repo}`;
90
+ }
91
+
52
92
  function compareVersions(a: string, b: string): number {
53
93
  const pa = (a || '0.0.0').split('.').map(Number);
54
94
  const pb = (b || '0.0.0').split('.').map(Number);
@@ -64,85 +104,112 @@ function compareVersions(a: string, b: string): number {
64
104
  /** Max info.json enrichments per sync (incremental) */
65
105
  const ENRICH_BATCH_SIZE = 10;
66
106
 
107
+ function parseRegistryItems(data: any): any[] {
108
+ if (data && data.version === 2) {
109
+ return [
110
+ ...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
111
+ ...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
112
+ ];
113
+ }
114
+ return (data?.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' }));
115
+ }
116
+
67
117
  export async function syncSkills(): Promise<{ synced: number; enriched: number; total?: number; remaining?: number; error?: string }> {
68
118
  console.log('[skills] Syncing from registry...');
69
119
  const baseUrl = getBaseUrl();
120
+ const cacheBust = `_t=${Date.now()}`;
70
121
 
71
122
  try {
72
- // Step 1: Fetch registry.json (always fresh)
73
- const controller = new AbortController();
74
- const timeout = setTimeout(() => controller.abort(), 10000);
75
- const cacheBust = `_t=${Date.now()}`;
76
- const res = await fetch(`${baseUrl}/registry.json?${cacheBust}`, {
77
- signal: controller.signal,
78
- headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
79
- });
80
- clearTimeout(timeout);
81
-
82
- if (!res.ok) return { synced: 0, enriched: 0, error: `Registry fetch failed: ${res.status}` };
123
+ // Step 1+2: fetch + upsert from EVERY source. Process lowest-priority
124
+ // first (public), then enterprise — so an enterprise skill of the same
125
+ // name overwrites the public one on INSERT OR REPLACE (enterprise wins,
126
+ // matching connector/pipeline precedence). Each row records which source
127
+ // it came from so installGlobal knows where (and with what auth) to pull.
128
+ const ordered = [...listSkillSources()].reverse(); // public → … → highest enterprise
83
129
 
84
- const data = await res.json();
85
-
86
- // Parse registry items (v1 + v2 support)
87
- let rawItems: any[] = [];
88
- if (data.version === 2) {
89
- rawItems = [
90
- ...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
91
- ...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
92
- ];
93
- } else {
94
- rawItems = (data.skills || []).map((s: any) => ({ ...s, type: s.type || 'command' }));
95
- }
96
-
97
- // Step 2: Upsert all items from registry.json directly (fast, no extra fetch)
98
130
  const upsertStmt = db().prepare(`
99
- INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive, synced_at,
131
+ INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive,
132
+ source_id, source_repo, source_branch, synced_at,
100
133
  installed_global, installed_projects, installed_version)
101
134
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
135
+ ?, ?, ?,
102
136
  COALESCE((SELECT synced_at FROM skills WHERE name = ?), datetime('now')),
103
137
  COALESCE((SELECT installed_global FROM skills WHERE name = ?), 0),
104
138
  COALESCE((SELECT installed_projects FROM skills WHERE name = ?), '[]'),
105
139
  COALESCE((SELECT installed_version FROM skills WHERE name = ?), ''))
106
140
  `);
107
141
 
108
- const tx = db().transaction(() => {
109
- for (const s of rawItems) {
110
- upsertStmt.run(
111
- s.name || '', s.type || 'skill',
112
- s.display_name || '', s.description || '',
113
- (s.author?.name || s.author || '').toString(), s.version || '',
114
- JSON.stringify(s.tags || []),
115
- s.score ?? 0, s.rating ?? 0, s.source?.url || s.source_url || '',
116
- '', // archive
117
- s.name || '', s.name || '', s.name || '', s.name || ''
118
- );
142
+ const registryNames = new Set<string>();
143
+ const sourceErrors: string[] = [];
144
+ let syncedTotal = 0;
145
+ let anyOk = false;
146
+
147
+ for (const source of ordered) {
148
+ let items: any[];
149
+ try {
150
+ // fetchSourceFile busts cache internally (raw appends ?_t, github_api
151
+ // appends &_t) pass the bare path so we don't double the query string.
152
+ const text = await fetchSourceFile(source, 'registry.json');
153
+ items = parseRegistryItems(JSON.parse(text));
154
+ } catch (e) {
155
+ // One source down (offline / PAT issue) must not wipe the others.
156
+ sourceErrors.push(`${source.id}: ${e instanceof Error ? e.message : String(e)}`);
157
+ continue;
119
158
  }
120
- });
121
- tx();
122
-
123
- // Step 3: Handle items no longer in registry
124
- // Skip 'local'-source rows entirely — they were uploaded by the
125
- // user via /api/skills/install-local and have no upstream
126
- // counterpart, so absence from registry.json is expected.
127
- const registryNames = new Set(rawItems.map((s: any) => s.name));
128
- const dbItems = db().prepare('SELECT name, installed_global, installed_projects, source FROM skills').all() as any[];
129
- for (const row of dbItems) {
130
- if (row.source === 'local') continue;
131
- if (!registryNames.has(row.name)) {
132
- const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
133
- if (hasLocal) {
134
- db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
135
- } else {
136
- db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
159
+ anyOk = true;
160
+ const repo = sourceRepoOf(source);
161
+ const branch = source.is_enterprise ? 'main' : getRepoInfo().branch;
162
+ const tx = db().transaction(() => {
163
+ for (const s of items) {
164
+ if (!s.name) continue;
165
+ upsertStmt.run(
166
+ s.name, s.type || 'skill',
167
+ s.display_name || '', s.description || '',
168
+ (s.author?.name || s.author || '').toString(), s.version || '',
169
+ JSON.stringify(s.tags || []),
170
+ s.score ?? 0, s.rating ?? 0, s.source?.url || s.source_url || '',
171
+ '', // archive
172
+ source.id, repo, branch,
173
+ s.name, s.name, s.name, s.name
174
+ );
175
+ registryNames.add(s.name);
176
+ }
177
+ });
178
+ tx();
179
+ syncedTotal += items.length;
180
+ }
181
+
182
+ if (!anyOk) {
183
+ // Every source failed (typically offline) — leave the DB untouched.
184
+ return { synced: 0, enriched: 0, error: sourceErrors.join('; ') || 'all sources unreachable' };
185
+ }
186
+
187
+ // Step 3: prune items no longer in ANY registry — but ONLY when every
188
+ // source synced cleanly. If an enterprise source was transiently down,
189
+ // skipping this avoids wrongly deleting its (still-valid) skills.
190
+ // 'local'-source rows are never pruned (user uploads, no upstream).
191
+ if (sourceErrors.length === 0) {
192
+ const dbItems = db().prepare('SELECT name, installed_global, installed_projects, source FROM skills').all() as any[];
193
+ for (const row of dbItems) {
194
+ if (row.source === 'local') continue;
195
+ if (!registryNames.has(row.name)) {
196
+ const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
197
+ if (hasLocal) {
198
+ db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
199
+ } else {
200
+ db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
201
+ }
137
202
  }
138
203
  }
139
204
  }
140
205
 
141
- // Step 4: Incremental enrichment fetch info.json for oldest-synced items
142
- // Pick items whose synced_at is oldest (or version changed since last enrich)
206
+ // Step 4: Incremental enrichment from info.json PUBLIC source only.
207
+ // Enterprise registry.json entries are authored in full (version, tags,
208
+ // description), so they need no enrichment; and their info.json lives in a
209
+ // private repo that the plain fetch() below can't read anyway.
143
210
  const staleItems = db().prepare(`
144
211
  SELECT name, type, version FROM skills
145
- WHERE deleted_remotely = 0
212
+ WHERE deleted_remotely = 0 AND source_id = 'public'
146
213
  ORDER BY synced_at ASC
147
214
  LIMIT ?
148
215
  `).all(ENRICH_BATCH_SIZE) as any[];
@@ -190,8 +257,16 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
190
257
 
191
258
  const totalCount = (db().prepare('SELECT count(*) as c FROM skills WHERE deleted_remotely = 0').get() as any).c;
192
259
  const remaining = totalCount - ENRICH_BATCH_SIZE; // approximate items not yet enriched this round
193
- console.log(`[skills] Synced ${rawItems.length} items, enriched ${enriched}/${staleItems.length} from info.json`);
194
- return { synced: rawItems.length, enriched, total: totalCount, remaining: Math.max(0, remaining) };
260
+ console.log(`[skills] Synced ${syncedTotal} items across ${ordered.length} source(s), enriched ${enriched}/${staleItems.length} from info.json`);
261
+ return {
262
+ synced: syncedTotal,
263
+ enriched,
264
+ total: totalCount,
265
+ remaining: Math.max(0, remaining),
266
+ // Surface a partial-failure note (e.g. one enterprise source down) while
267
+ // still reporting success for the sources that did sync.
268
+ error: sourceErrors.length ? `partial: ${sourceErrors.join('; ')}` : undefined,
269
+ };
195
270
  } catch (e) {
196
271
  const msg = e instanceof Error ? e.message : String(e);
197
272
  // Corp networks routinely can't reach GitHub raw — this is expected
@@ -226,6 +301,8 @@ export function listSkills(): SkillItem[] {
226
301
  hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
227
302
  deletedRemotely: !!r.deleted_remotely,
228
303
  source: r.source === 'local' ? 'local' : 'registry',
304
+ sourceId: r.source_id || 'public',
305
+ isEnterprise: typeof r.source_id === 'string' && r.source_id.startsWith('enterprise-'),
229
306
  };
230
307
  });
231
308
  }
@@ -269,6 +346,59 @@ async function downloadFile(url: string): Promise<string> {
269
346
  return res.text();
270
347
  }
271
348
 
349
+ /** Recursively list a directory in a private enterprise repo via the GitHub
350
+ * contents API (PAT-authenticated). Returns repo-relative file paths. */
351
+ async function listEnterpriseDir(source: SourceMeta, dirPath: string): Promise<string[]> {
352
+ const repo = (source.repo_url || '').replace(/^github\.com\//, '');
353
+ const out: string[] = [];
354
+ async function recurse(path: string): Promise<void> {
355
+ const url = `https://api.github.com/repos/${repo}/contents/${path}?ref=main`;
356
+ const res = await fetch(url, {
357
+ headers: {
358
+ Authorization: `Bearer ${source.github_pat}`,
359
+ Accept: 'application/vnd.github.v3+json',
360
+ 'User-Agent': 'forge-skills-sync/1.0',
361
+ },
362
+ });
363
+ if (!res.ok) return;
364
+ const items = await res.json();
365
+ if (!Array.isArray(items)) return;
366
+ for (const it of items) {
367
+ if (it.type === 'file') out.push(it.path);
368
+ else if (it.type === 'dir') await recurse(it.path);
369
+ }
370
+ }
371
+ await recurse(dirPath);
372
+ return out;
373
+ }
374
+
375
+ /** Source-aware fetch of all files for a skill/command. Public skills use the
376
+ * unauthenticated GitHub contents API; enterprise skills list + download via
377
+ * the recorded source's PAT (private repos). Returns local-relative paths. */
378
+ async function fetchSkillFiles(name: string, type: ItemType, sourceId: string): Promise<{ path: string; content: string }[]> {
379
+ const source = sourceById(sourceId);
380
+ // Public (or a source no longer configured) → legacy unauthenticated path.
381
+ if (!source || !source.is_enterprise) {
382
+ const files = await listRepoFiles(name, type);
383
+ const out: { path: string; content: string }[] = [];
384
+ for (const f of files) out.push({ path: f.path, content: await downloadFile(f.download_url) });
385
+ return out;
386
+ }
387
+ // Enterprise → private repo: list with PAT, fetch each via fetchSourceFile.
388
+ const dirs = type === 'skill' ? ['skills', 'commands'] : ['commands', 'skills'];
389
+ for (const dir of dirs) {
390
+ const repoPaths = await listEnterpriseDir(source, `${dir}/${name}`);
391
+ if (repoPaths.length === 0) continue;
392
+ const stripRe = new RegExp(`^${dir}/${name}/`);
393
+ const out: { path: string; content: string }[] = [];
394
+ for (const p of repoPaths) {
395
+ out.push({ path: p.replace(stripRe, ''), content: await fetchSourceFile(source, p) });
396
+ }
397
+ return out;
398
+ }
399
+ return [];
400
+ }
401
+
272
402
  // ─── Install ─────────────────────────────────────────────────
273
403
 
274
404
  function getClaudeHome(): string {
@@ -290,15 +420,14 @@ export async function installGlobal(name: string): Promise<void> {
290
420
  const subdir = type === 'skill' ? 'skills' : 'commands';
291
421
  const targetDir = join(claudeHome, subdir, name);
292
422
 
293
- const files = await listRepoFiles(name, type);
423
+ const files = await fetchSkillFiles(name, type, skill.source_id || 'public');
294
424
  if (files.length === 0) throw new Error(`No files found for ${name}`);
295
425
 
296
426
  mkdirSync(targetDir, { recursive: true });
297
427
  for (const f of files) {
298
- const content = await downloadFile(f.download_url);
299
428
  const targetPath = join(targetDir, f.path);
300
429
  mkdirSync(dirname(targetPath), { recursive: true });
301
- writeFileSync(targetPath, content);
430
+ writeFileSync(targetPath, f.content);
302
431
  }
303
432
 
304
433
  // Update installed state
@@ -333,15 +462,14 @@ export async function installProject(name: string, projectPath: string): Promise
333
462
  const subdir = type === 'skill' ? 'skills' : 'commands';
334
463
  const targetDir = join(projectPath, '.claude', subdir, name);
335
464
 
336
- const files = await listRepoFiles(name, type);
465
+ const files = await fetchSkillFiles(name, type, skill.source_id || 'public');
337
466
  if (files.length === 0) throw new Error(`No files found for ${name}`);
338
467
 
339
468
  mkdirSync(targetDir, { recursive: true });
340
469
  for (const f of files) {
341
- const content = await downloadFile(f.download_url);
342
470
  const targetPath = join(targetDir, f.path);
343
471
  mkdirSync(dirname(targetPath), { recursive: true });
344
- writeFileSync(targetPath, content);
472
+ writeFileSync(targetPath, f.content);
345
473
  }
346
474
 
347
475
  // Update installed state
@@ -69,6 +69,19 @@ export const taskModelOverrides = new Map<string, string>();
69
69
  */
70
70
  export const taskAppendSystemPromptOverrides = new Map<string, string>();
71
71
 
72
+ /**
73
+ * Skill names attached to a task (parallel to the append-system-prompt
74
+ * override above). Purely for observability — surfaced in the task's
75
+ * init log so a pipeline run shows which skills it loaded.
76
+ */
77
+ export const taskSkillsOverrides = new Map<string, string[]>();
78
+
79
+ /** ` | Skills: a, b` for the init log, or '' when none attached. */
80
+ function skillsLogSuffix(taskId: string): string {
81
+ const s = taskSkillsOverrides.get(taskId);
82
+ return s && s.length ? ` | Skills: ${s.join(', ')}` : '';
83
+ }
84
+
72
85
  // ─── CRUD ────────────────────────────────────────────────────
73
86
 
74
87
  export function createTask(opts: {
@@ -538,7 +551,7 @@ function executeTmuxBackendTask(task: Task): Promise<void> {
538
551
 
539
552
  updateTaskStatus(task.id, 'running');
540
553
  db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
541
- appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux`, timestamp: new Date().toISOString() });
554
+ appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux${skillsLogSuffix(task.id)}`, timestamp: new Date().toISOString() });
542
555
 
543
556
  return executeTmuxTask(task, {
544
557
  appendLog: (entry) => appendLog(task.id, entry),
@@ -670,7 +683,7 @@ function executeTask(task: Task): Promise<void> {
670
683
  console.log(`[task] ${task.projectName} [${agentName}${supportsModel && model ? '/' + model : ''}]: "${task.prompt.slice(0, 60)}..."`);
671
684
 
672
685
  // Log agent info as first entry
673
- appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}`, timestamp: new Date().toISOString() });
686
+ appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}${skillsLogSuffix(task.id)}`, timestamp: new Date().toISOString() });
674
687
 
675
688
  const needsTTY = adapter.config.capabilities?.requiresTTY;
676
689
  let child: any;
@@ -4,6 +4,7 @@
4
4
  * Runs as a single process — no duplication from Next.js workers.
5
5
  */
6
6
 
7
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
7
8
  import { loadSettings } from './settings';
8
9
 
9
10
  const settings = loadSettings();
@@ -24,6 +24,7 @@
24
24
  * Usage: npx tsx lib/terminal-standalone.ts
25
25
  */
26
26
 
27
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
27
28
  import { WebSocketServer, WebSocket } from 'ws';
28
29
  import * as pty from 'node-pty';
29
30
  import { execSync } from 'node:child_process';
@@ -12,6 +12,7 @@
12
12
  * FORGE_DATA_DIR — data directory
13
13
  */
14
14
 
15
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
15
16
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
16
17
  import { readdirSync, statSync } from 'node:fs';
17
18
  import { join, resolve } from 'node:path';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.86",
3
+ "version": "0.10.88",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -246,6 +246,13 @@ function initSchema(db: Database.Database) {
246
246
  // Local skills are kept across syncs (they're not in the remote registry,
247
247
  // so the deleted_remotely housekeeping would otherwise wipe them).
248
248
  migrate("ALTER TABLE skills ADD COLUMN source TEXT NOT NULL DEFAULT 'registry'");
249
+ // Multi-source skill sync (#354): which marketplace source a registry skill
250
+ // came from, so installGlobal knows where (and with what auth) to fetch its
251
+ // files. 'public' = the public forge-skills repo; otherwise an enterprise
252
+ // tenant_id. source_repo is "owner/repo", source_branch the ref.
253
+ migrate("ALTER TABLE skills ADD COLUMN source_id TEXT NOT NULL DEFAULT 'public'");
254
+ migrate("ALTER TABLE skills ADD COLUMN source_repo TEXT NOT NULL DEFAULT ''");
255
+ migrate("ALTER TABLE skills ADD COLUMN source_branch TEXT NOT NULL DEFAULT 'main'");
249
256
  migrate('ALTER TABLE project_pipelines ADD COLUMN last_run_at TEXT');
250
257
  migrate('ALTER TABLE pipeline_runs ADD COLUMN dedup_key TEXT');
251
258
  migrate("ALTER TABLE tasks ADD COLUMN agent TEXT DEFAULT 'claude'");