@aion0/forge 0.10.39 → 0.10.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -6
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +189 -30
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. package/templates/connector-config-template.json +0 -7
@@ -142,7 +142,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
142
142
  const [syncing, setSyncing] = useState(false);
143
143
  const [loading, setLoading] = useState(true);
144
144
  const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
145
- const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('pipelines');
145
+ const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('connectors');
146
146
  const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
147
147
  // Rules (CLAUDE.md templates)
148
148
  const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
@@ -246,6 +246,8 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
246
246
  };
247
247
 
248
248
  const [syncProgress, setSyncProgress] = useState('');
249
+ const [syncingGroup, setSyncingGroup] = useState<string>('');
250
+ const [templatesSyncTick, setTemplatesSyncTick] = useState(0);
249
251
  const [uploading, setUploading] = useState(false);
250
252
  const skillUploadRef = useRef<HTMLInputElement | null>(null);
251
253
 
@@ -278,31 +280,24 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
278
280
  } finally { setUploading(false); }
279
281
  }
280
282
 
283
+ // Unified marketplace sync — replaces the old skills-only loop. Covers
284
+ // every kind from every source (public + each enterprise tenant):
285
+ // - connectors (registry + manifests + wizard templates)
286
+ // - workflows (pipelines + recipes)
287
+ // - skills
288
+ // The old per-tab Sync buttons were confusing — users couldn't tell
289
+ // whether they covered enterprise sources or just public. One button,
290
+ // one trip to the server.
281
291
  const sync = async () => {
282
292
  setSyncing(true);
283
293
  setSyncProgress('');
284
294
  try {
285
- let enrichedTotal = 0;
286
- let total = 0;
287
- // Loop: each call enriches a batch of info.json, continue until all done
288
- for (let round = 0; round < 20; round++) { // safety limit
289
- setSyncProgress(total > 0 ? `${Math.min(enrichedTotal, total)}/${total}` : '');
290
- const res = await fetch('/api/skills', {
291
- method: 'POST',
292
- headers: { 'Content-Type': 'application/json' },
293
- body: JSON.stringify({ action: 'sync' }),
294
- });
295
- const data = await res.json();
296
- if (data.error) {
297
- alert(`Sync error: ${data.error}`);
298
- break;
299
- }
300
- total = data.total || 0;
301
- enrichedTotal += data.enriched || 0;
302
- await fetchSkills();
303
- // If remaining is 0 or enriched nothing, we're done
304
- if (!data.remaining || data.enriched === 0) break;
295
+ const res = await fetch('/api/marketplace/sync-all', { method: 'POST' });
296
+ const data = await res.json();
297
+ if (!data.ok && data.error) {
298
+ alert(`Sync failed: ${data.error}`);
305
299
  }
300
+ await fetchSkills();
306
301
  } catch (err: any) {
307
302
  alert(`Sync failed: ${err.message || 'Network error'}`);
308
303
  } finally {
@@ -311,6 +306,37 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
311
306
  }
312
307
  };
313
308
 
309
+ // Per-group sync — hits only the relevant endpoint, faster + clearer
310
+ // than the catch-all sync-all when the user is staring at one category.
311
+ // Each endpoint already walks every enterprise source for that kind.
312
+ const syncGroup = async (group: 'extensions' | 'catalog' | 'templates') => {
313
+ setSyncingGroup(group);
314
+ try {
315
+ const url = group === 'extensions'
316
+ ? '/api/connectors/marketplace'
317
+ : group === 'catalog'
318
+ ? '/api/skills'
319
+ : '/api/workflows/marketplace';
320
+ const res = await fetch(url, {
321
+ method: 'POST',
322
+ headers: { 'Content-Type': 'application/json' },
323
+ body: JSON.stringify({ action: 'sync' }),
324
+ });
325
+ const data = await res.json().catch(() => ({}));
326
+ if (res.ok && data.ok === false && data.error) {
327
+ alert(`Sync failed: ${data.error}`);
328
+ } else if (!res.ok) {
329
+ alert(`Sync failed: HTTP ${res.status}`);
330
+ }
331
+ await fetchSkills();
332
+ if (group === 'templates') setTemplatesSyncTick(t => t + 1);
333
+ } catch (err: any) {
334
+ alert(`Sync failed: ${err.message || 'Network error'}`);
335
+ } finally {
336
+ setSyncingGroup('');
337
+ }
338
+ };
339
+
314
340
  const install = async (name: string, target: string) => {
315
341
  await fetch('/api/skills', {
316
342
  method: 'POST',
@@ -421,55 +447,73 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
421
447
  group label as a placeholder. Picking from any sets the
422
448
  single global typeFilter. */}
423
449
  {(() => {
424
- const groups: Array<{ label: string; opts: Array<{ value: typeof typeFilter; label: string }> }> = [
425
- { label: 'Templates', opts: [
426
- { value: 'pipelines', label: 'Pipelines' },
427
- ]},
428
- { label: 'Extensions', opts: [
429
- { value: 'connectors', label: 'Connectors' },
430
- { value: 'plugins', label: 'Plugins' },
431
- { value: 'crafts', label: 'Crafts' },
432
- ]},
433
- { label: 'Catalog', opts: [
434
- { value: 'all', label: `All (${skills.length})` },
435
- { value: 'skill', label: `Skills (${skillCount})` },
436
- { value: 'command', label: `Commands (${commandCount})` },
437
- { value: 'local', label: `Local (${localCount})` },
438
- { value: 'rules', label: 'Rules' },
439
- ]},
450
+ // Display order: Extensions (Connectors) Catalog (Skills)
451
+ // Templates (Pipelines). Each group is an independent
452
+ // switcher with its OWN sync button — the syncs cover
453
+ // disjoint endpoints so users don't have to wait for the
454
+ // catch-all sync-all to refresh just one category.
455
+ const groups: Array<{
456
+ key: 'extensions' | 'catalog' | 'templates';
457
+ label: string;
458
+ syncTitle: string;
459
+ opts: Array<{ value: typeof typeFilter; label: string }>;
460
+ }> = [
461
+ { key: 'extensions', label: 'Extensions', syncTitle: 'Sync connectors (manifests + registry) from every source',
462
+ opts: [
463
+ { value: 'connectors', label: 'Connectors' },
464
+ { value: 'plugins', label: 'Plugins' },
465
+ { value: 'crafts', label: 'Crafts' },
466
+ ]},
467
+ { key: 'catalog', label: 'Catalog', syncTitle: 'Sync skills & commands from every source',
468
+ opts: [
469
+ { value: 'all', label: `All (${skills.length})` },
470
+ { value: 'skill', label: `Skills (${skillCount})` },
471
+ { value: 'command', label: `Commands (${commandCount})` },
472
+ { value: 'local', label: `Local (${localCount})` },
473
+ { value: 'rules', label: 'Rules' },
474
+ ]},
475
+ { key: 'templates', label: 'Templates', syncTitle: 'Sync pipelines & recipes from every source',
476
+ opts: [
477
+ { value: 'pipelines', label: 'Pipelines' },
478
+ ]},
440
479
  ];
441
480
  return groups.map((g) => {
442
481
  const isActive = g.opts.some((o) => o.value === typeFilter);
443
- if (g.opts.length === 1) {
444
- const only = g.opts[0];
445
- return (
446
- <button
447
- key={g.label}
448
- onClick={() => setTypeFilter(only.value)}
449
- className={`text-[10px] px-2 py-1 rounded border ${
450
- isActive
451
- ? 'border-[var(--accent)] text-[var(--accent)] bg-[var(--accent)]/10'
452
- : 'border-[var(--border)] text-[var(--text-primary)] bg-[var(--bg-tertiary)] hover:border-[var(--text-secondary)]'
453
- }`}
454
- >{only.label}</button>
455
- );
456
- }
482
+ const groupSyncing = syncingGroup === g.key;
457
483
  return (
458
- <select
459
- key={g.label}
460
- value={isActive ? (typeFilter as string) : ''}
461
- onChange={(e) => { if (e.target.value) setTypeFilter(e.target.value as typeof typeFilter); }}
462
- className={`text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border focus:outline-none ${
463
- isActive
464
- ? 'border-[var(--accent)] text-[var(--accent)]'
465
- : 'border-[var(--border)] text-[var(--text-primary)] focus:border-[var(--accent)]'
466
- }`}
467
- >
468
- <option value="" disabled hidden>{g.label}</option>
469
- {g.opts.map((o) => (
470
- <option key={o.value} value={o.value as string}>{o.label}</option>
471
- ))}
472
- </select>
484
+ <div key={g.key} className="flex items-center gap-0.5">
485
+ {g.opts.length === 1 ? (
486
+ <button
487
+ onClick={() => setTypeFilter(g.opts[0].value)}
488
+ className={`text-[10px] px-2 py-1 rounded border ${
489
+ isActive
490
+ ? 'border-[var(--accent)] text-[var(--accent)] bg-[var(--accent)]/10'
491
+ : 'border-[var(--border)] text-[var(--text-primary)] bg-[var(--bg-tertiary)] hover:border-[var(--text-secondary)]'
492
+ }`}
493
+ >{g.opts[0].label}</button>
494
+ ) : (
495
+ <select
496
+ value={isActive ? (typeFilter as string) : ''}
497
+ onChange={(e) => { if (e.target.value) setTypeFilter(e.target.value as typeof typeFilter); }}
498
+ className={`text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border focus:outline-none ${
499
+ isActive
500
+ ? 'border-[var(--accent)] text-[var(--accent)]'
501
+ : 'border-[var(--border)] text-[var(--text-primary)] focus:border-[var(--accent)]'
502
+ }`}
503
+ >
504
+ <option value="" disabled hidden>{g.label}</option>
505
+ {g.opts.map((o) => (
506
+ <option key={o.value} value={o.value as string}>{o.label}</option>
507
+ ))}
508
+ </select>
509
+ )}
510
+ <button
511
+ onClick={() => void syncGroup(g.key)}
512
+ disabled={groupSyncing || syncing}
513
+ title={g.syncTitle}
514
+ className="text-[10px] px-1.5 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--text-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-40"
515
+ >{groupSyncing ? '…' : '↻'}</button>
516
+ </div>
473
517
  );
474
518
  });
475
519
  })()}
@@ -498,9 +542,10 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
498
542
  <button
499
543
  onClick={sync}
500
544
  disabled={syncing}
545
+ title="Sync EVERYTHING from every source (public + each enterprise tenant): connectors + workflows + skills + wizard templates."
501
546
  className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
502
547
  >
503
- {syncing ? `Syncing${syncProgress ? ` ${syncProgress}` : '...'}` : 'Sync'}
548
+ {syncing ? 'Syncing…' : ' Sync all'}
504
549
  </button>
505
550
  </div>
506
551
  {/* Search — hide on rules tab */}
@@ -1114,7 +1159,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
1114
1159
  local copy / instantiate" action. This page exists for
1115
1160
  discovery only. */}
1116
1161
  {(typeFilter === 'recipes' || typeFilter === 'pipelines') && (
1117
- <WorkflowMarketplaceBrowser kind={typeFilter === 'recipes' ? 'recipe' : 'pipeline'} searchQuery={searchQuery} />
1162
+ <WorkflowMarketplaceBrowser kind={typeFilter === 'recipes' ? 'recipe' : 'pipeline'} searchQuery={searchQuery} reloadTick={templatesSyncTick} />
1118
1163
  )}
1119
1164
  </div>
1120
1165
  );
@@ -1139,9 +1184,8 @@ interface MarketRow {
1139
1184
  has_update?: boolean;
1140
1185
  }
1141
1186
 
1142
- function WorkflowMarketplaceBrowser({ kind, searchQuery }: { kind: 'recipe' | 'pipeline'; searchQuery: string }) {
1187
+ function WorkflowMarketplaceBrowser({ kind, searchQuery, reloadTick = 0 }: { kind: 'recipe' | 'pipeline'; searchQuery: string; reloadTick?: number }) {
1143
1188
  const [rows, setRows] = useState<MarketRow[] | null>(null);
1144
- const [busy, setBusy] = useState(false);
1145
1189
  const [err, setErr] = useState<string>('');
1146
1190
 
1147
1191
  const useInLocation = kind === 'recipe' ? 'Jobs tab' : 'Pipelines tab';
@@ -1157,22 +1201,7 @@ function WorkflowMarketplaceBrowser({ kind, searchQuery }: { kind: 'recipe' | 'p
1157
1201
  }
1158
1202
  };
1159
1203
 
1160
- const sync = async () => {
1161
- setBusy(true);
1162
- setErr('');
1163
- try {
1164
- const res = await fetch('/api/workflows/marketplace', {
1165
- method: 'POST', headers: { 'Content-Type': 'application/json' },
1166
- body: JSON.stringify({ action: 'sync' }),
1167
- });
1168
- const data = await res.json();
1169
- if (!data.ok) setErr(data.error || 'sync failed');
1170
- await load();
1171
- } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
1172
- finally { setBusy(false); }
1173
- };
1174
-
1175
- useEffect(() => { void load(); }, [kind]);
1204
+ useEffect(() => { void load(); }, [kind, reloadTick]);
1176
1205
 
1177
1206
  const q = searchQuery.toLowerCase();
1178
1207
  const filtered = (rows || []).filter(r => !q
@@ -1187,12 +1216,8 @@ function WorkflowMarketplaceBrowser({ kind, searchQuery }: { kind: 'recipe' | 'p
1187
1216
  <span className="text-[10px] text-[var(--text-secondary)] flex-1">
1188
1217
  {kind === 'recipe' ? 'Job recipes' : 'Pipeline templates'} from <code className="font-mono">aiwatching/forge-workflow</code>
1189
1218
  {' · '}use them from the {useInLocation}
1219
+ {' · '}sync via the ↻ next to Pipelines above
1190
1220
  </span>
1191
- <button
1192
- onClick={() => void sync()}
1193
- disabled={busy}
1194
- className="text-[9px] px-2 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] disabled:opacity-50"
1195
- >{busy ? 'Syncing…' : 'Sync'}</button>
1196
1221
  </div>
1197
1222
  {err && (
1198
1223
  <div className="px-4 py-2 text-[10px] text-[var(--red)] border-b border-[var(--border)]">{err}</div>
@@ -369,11 +369,17 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
369
369
  const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
370
370
  let mcpFlag = '';
371
371
  try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
372
- // Use the cached default-agent cliCmd. Pre-fetched on mount above;
373
- // if still 'claude' (fetch slow / pending), we use bare claude — old
374
- // behavior, wrong if conda-base shadows the real one, but at least
375
- // doesn't block the command from appearing.
376
- const agentCmd = defaultAgentCmdRef.current;
372
+ // Default-agent cliCmd: prefer the on-mount pre-fetch, but if the
373
+ // race lost (ref still 'claude') await one explicit resolve here
374
+ // bare `claude` on a tmux pane PATH with conda-base or stale
375
+ // homebrew install crashes on resume.
376
+ if (defaultAgentCmdRef.current === 'claude') {
377
+ try {
378
+ const info = await fetch('/api/agents?resolve=').then(r => r.ok ? r.json() : null);
379
+ if (info?.cliCmd) defaultAgentCmdRef.current = info.cliCmd;
380
+ } catch {}
381
+ }
382
+ const agentCmd = `"${defaultAgentCmdRef.current}"`;
377
383
  const cmd = `cd "${projectPath}" && ${agentCmd} --resume ${sessionId}${sf}${mcpFlag}\n`;
378
384
  pendingCommands.set(paneId, cmd);
379
385
  const projectName = projectPath.split('/').pop() || 'Terminal';
@@ -442,18 +448,25 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
442
448
  : '';
443
449
  const envPrefix = envExports ? envExports + ' && ' : '';
444
450
 
445
- // Skip-permissions flag
451
+ // Skip-permissions flag. agent === 'claude' means the agent ID is
452
+ // the base claude — not the resolved path. The check must compare
453
+ // ID, not cliCmd (which is an absolute path).
446
454
  let sf = '';
447
455
  if (skipPermissions) {
448
- sf = agentSkipFlag ? ` ${agentSkipFlag}` : (agentCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
456
+ sf = agentSkipFlag ? ` ${agentSkipFlag}` : (agent === 'claude' ? ' --dangerously-skip-permissions' : '');
449
457
  }
450
458
 
451
- // MCP config for claude-code agents
459
+ // MCP config for claude-code agents — gate on agent ID / derived
460
+ // from claude, not on agentCmd which is an absolute path.
452
461
  let mcpFlag = '';
453
- if (agentCmd === 'claude' && projectPath) {
462
+ const isClaudeCode = agent === 'claude' || agentCmd.endsWith('/claude') || agentCmd.endsWith('\\claude');
463
+ if (isClaudeCode && projectPath) {
454
464
  try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
455
465
  }
456
466
 
467
+ // Quote the resolved path — defensive for paths with spaces.
468
+ const quotedCmd = `"${agentCmd}"`;
469
+
457
470
  let targetTabId: number | null = null;
458
471
 
459
472
  setTabs(prev => {
@@ -465,10 +478,10 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
465
478
  }
466
479
  const tree = makeTerminal(undefined, projectPath);
467
480
  const paneId = firstTerminalId(tree);
468
- pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
481
+ pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${quotedCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
469
482
  const newTab: TabState = {
470
483
  id: nextId++,
471
- label: agent !== 'claude' ? `${projectName} (${agentCmd})` : projectName,
484
+ label: agent !== 'claude' ? `${projectName} (${agent})` : projectName,
472
485
  tree,
473
486
  ratios: {},
474
487
  activeId: paneId,
@@ -1560,12 +1573,22 @@ const MemoTerminalPane = memo(function TerminalPane({
1560
1573
  isNewlyCreated = false;
1561
1574
  const pp = projectPathRef.current;
1562
1575
  import('@/lib/session-utils').then(({ resolveFixedSession, buildResumeFlag, getMcpFlag }) => {
1563
- Promise.all([resolveFixedSession(pp), getMcpFlag(pp)]).then(([fixedId, mcpFlag]) => {
1576
+ // Resolve the agent's absolute claude path — bare
1577
+ // `claude` rides on the tmux pane PATH, which on a
1578
+ // fresh-install machine still points at the user's
1579
+ // stale global @anthropic-ai/claude-code (e.g.
1580
+ // /opt/homebrew/...) and crashes on resume.
1581
+ Promise.all([
1582
+ resolveFixedSession(pp),
1583
+ getMcpFlag(pp),
1584
+ fetch('/api/agents?resolve=').then(r => r.ok ? r.json() : null).catch(() => null),
1585
+ ]).then(([fixedId, mcpFlag, agentInfo]) => {
1564
1586
  const resumeFlag = buildResumeFlag(fixedId, true);
1565
1587
  const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1588
+ const claudeCmd = `"${agentInfo?.cliCmd || 'claude'}"`;
1566
1589
  setTimeout(() => {
1567
1590
  if (!disposed && ws?.readyState === WebSocket.OPEN) {
1568
- ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}${mcpFlag}\n` }));
1591
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && ${claudeCmd}${resumeFlag}${skipFlag}${mcpFlag}\n` }));
1569
1592
  }
1570
1593
  }, 300);
1571
1594
  });
package/dev-test.sh CHANGED
@@ -1,5 +1,38 @@
1
1
  #!/bin/bash
2
2
  # dev-test.sh — Start Forge test instance (port 4000, data ~/.forge-test)
3
+ #
4
+ # Usage:
5
+ # ./dev-test.sh # plain start
6
+ # ./dev-test.sh --enterprise-key=fortinet:github_pat_xxx # + persist enterprise key(s)
7
+ # ./dev-test.sh --enterprise-key=fortinet:p1 --enterprise-key=acme:p2
8
+
9
+ set -e
3
10
 
4
11
  mkdir -p ~/.forge-test
5
- PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test FORGE_EXTERNAL_SERVICES=0 npx next dev --turbopack -p 4000
12
+
13
+ # Persist any --enterprise-key=... args BEFORE next dev starts so the
14
+ # server boots with sources already wired. forge-server.mjs's
15
+ # --add-enterprise-key mode is a one-shot persist + exit.
16
+ ENT_KEYS=()
17
+ for arg in "$@"; do
18
+ case "$arg" in
19
+ --enterprise-key=*) ENT_KEYS+=("$arg") ;;
20
+ esac
21
+ done
22
+
23
+ if [ ${#ENT_KEYS[@]} -gt 0 ]; then
24
+ echo "[dev-test] Registering ${#ENT_KEYS[@]} enterprise key(s) → ~/.forge-test/.enterprise-keys.json"
25
+ node bin/forge-server.mjs --add-enterprise-key "${ENT_KEYS[@]}" --dir ~/.forge-test
26
+ fi
27
+
28
+ # All standalones default to 84xx ports (matching production). dev-test
29
+ # overrides every one so it can coexist with a real Forge on 8403.
30
+ PORT=4000 \
31
+ TERMINAL_PORT=4001 \
32
+ WORKSPACE_PORT=4005 \
33
+ BRIDGE_PORT=4007 \
34
+ CHAT_PORT=4008 \
35
+ MEMORY_PORT=4009 \
36
+ FORGE_DATA_DIR=~/.forge-test \
37
+ FORGE_EXTERNAL_SERVICES=0 \
38
+ npx next dev --turbopack -p 4000
package/install.sh CHANGED
@@ -2,11 +2,28 @@
2
2
  # install.sh — Install Forge globally, ready to run
3
3
  #
4
4
  # Usage:
5
- # ./install.sh # from npm
6
- # ./install.sh local # from local source
5
+ # ./install.sh # from npm
6
+ # ./install.sh local # from local source
7
+ # ./install.sh --enterprise-key=fortinet:github_pat_xxx # + persist one enterprise key
8
+ # ./install.sh --enterprise-key=fortinet:pat --enterprise-key=… # multiple keys
9
+ #
10
+ # Enterprise keys are encrypted with AES-256-GCM and stored in
11
+ # `<dataDir>/.enterprise-keys.json` — see Phase 1 of
12
+ # forge-enterprise-improvements design doc for the marketplace model.
7
13
 
8
14
  set -e
9
15
 
16
+ # Collect enterprise keys + leave non-key args in $@ for downstream parsing.
17
+ ENTERPRISE_KEY_ARGS=()
18
+ NEW_ARGS=()
19
+ for arg in "$@"; do
20
+ case "$arg" in
21
+ --enterprise-key=*) ENTERPRISE_KEY_ARGS+=("$arg") ;;
22
+ *) NEW_ARGS+=("$arg") ;;
23
+ esac
24
+ done
25
+ set -- "${NEW_ARGS[@]:-}"
26
+
10
27
  # tmux is required for browser terminals — warn early if missing
11
28
  if ! command -v tmux >/dev/null 2>&1; then
12
29
  echo "[forge] ⚠️ tmux not found — Forge terminals won't work without it."
@@ -106,6 +123,16 @@ else
106
123
  fi
107
124
 
108
125
  echo ""
126
+
127
+ # Persist any enterprise keys collected from CLI args (encrypted on disk;
128
+ # server doesn't need to be running for this).
129
+ if [ ${#ENTERPRISE_KEY_ARGS[@]} -gt 0 ]; then
130
+ echo "[forge] Registering ${#ENTERPRISE_KEY_ARGS[@]} enterprise key(s)..."
131
+ forge --add-enterprise-key "${ENTERPRISE_KEY_ARGS[@]}" || \
132
+ echo "[forge] ⚠️ Enterprise-key registration failed — you can retry later via Settings → Marketplace Providers."
133
+ echo ""
134
+ fi
135
+
109
136
  echo "[forge] Done."
110
137
  forge --version
111
138
  echo "Run: forge server start"
@@ -83,11 +83,18 @@ export function createClaudeAdapter(config: AgentConfig): AgentAdapter {
83
83
  buildTerminalCommand(opts) {
84
84
  const flag = config.skipPermissionsFlag || '--dangerously-skip-permissions';
85
85
  const skipFlag = opts.skipPermissions && flag ? ` ${flag}` : '';
86
+ // MUST use the agent-configured path, not the literal "claude" on
87
+ // $PATH — Forge ships its own pinned binary at <dataDir>/local-bin/claude,
88
+ // and tmux PATH on a first-install machine often still points at a
89
+ // stale global @anthropic-ai/claude-code (e.g. /opt/homebrew/...).
90
+ // Bare "claude" would silently fall through to that older version
91
+ // and throw on resume. Quote in case the path has spaces.
92
+ const claudeCmd = `"${config.path}"`;
86
93
  if (opts.sessionId) {
87
- return `cd "${opts.projectPath}" && claude --resume ${opts.sessionId}${skipFlag}\n`;
94
+ return `cd "${opts.projectPath}" && ${claudeCmd} --resume ${opts.sessionId}${skipFlag}\n`;
88
95
  }
89
96
  const resumeFlag = opts.resume ? ' -c' : '';
90
- return `cd "${opts.projectPath}" && claude${resumeFlag}${skipFlag}\n`;
97
+ return `cd "${opts.projectPath}" && ${claudeCmd}${resumeFlag}${skipFlag}\n`;
91
98
  },
92
99
  };
93
100
  }
@@ -97,11 +104,18 @@ export function detectClaude(customPath?: string): AgentConfig | null {
97
104
  const paths = customPath ? [customPath] : ['claude'];
98
105
  for (const p of paths) {
99
106
  try {
100
- execSync(`which ${p}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
107
+ // Resolve to absolute path storing the literal `'claude'` is
108
+ // useless: every terminal launch then becomes a bare `claude` on
109
+ // tmux pane PATH, which on first-install machines silently
110
+ // resolves to a stale global @anthropic-ai/claude-code (e.g.
111
+ // /opt/homebrew/lib/node_modules/...) and crashes on resume.
112
+ // If the user passed an absolute path already, keep it as-is.
113
+ const which = execSync(`which ${p}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
114
+ const resolved = (p.startsWith('/') || !which) ? p : which;
101
115
  return {
102
116
  id: 'claude',
103
117
  name: 'Claude Code',
104
- path: p,
118
+ path: resolved,
105
119
  enabled: true,
106
120
  type: 'claude-code',
107
121
  capabilities: CAPABILITIES,
@@ -3,8 +3,26 @@
3
3
  * Agents coexist (not mutually exclusive). Each entry point can select any agent.
4
4
  */
5
5
 
6
- import { execFileSync } from 'node:child_process';
6
+ import { execFileSync, execSync } from 'node:child_process';
7
7
  import { loadSettings } from '../settings';
8
+
9
+ // Cache absolute-path resolution per command — `which <cmd>` is cheap
10
+ // but resolveTerminalLaunch is on the hot path for every chat tool call.
11
+ const _whichCache = new Map<string, string>();
12
+ function resolveAbsoluteBin(cmd: string): string {
13
+ if (!cmd || cmd.startsWith('/')) return cmd;
14
+ const cached = _whichCache.get(cmd);
15
+ if (cached !== undefined) return cached;
16
+ try {
17
+ const out = execSync(`which ${cmd}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
18
+ const resolved = out || cmd;
19
+ _whichCache.set(cmd, resolved);
20
+ return resolved;
21
+ } catch {
22
+ _whichCache.set(cmd, cmd);
23
+ return cmd;
24
+ }
25
+ }
8
26
  import type { AgentAdapter, AgentConfig, AgentId } from './types';
9
27
  import { createClaudeAdapter, detectClaude } from './claude-adapter';
10
28
  import { createGenericAdapter, detectAgent } from './generic-adapter';
@@ -243,8 +261,12 @@ export interface TerminalLaunchInfo {
243
261
  export function resolveTerminalLaunch(agentId?: string, scene: 'terminal' | 'task' | 'telegram' | 'help' | 'mobile' = 'terminal'): TerminalLaunchInfo {
244
262
  const settings = loadSettings();
245
263
  const agentCfg = settings.agents?.[agentId || 'claude'] || {};
246
- // Resolve cliType: own cliType → base agent's cliType → base agent name guessing → agentId name guessing
247
- const baseId = agentCfg.base;
264
+ // Resolve cliType: own cliType → base agent's cliType → base agent name guessing → agentId name guessing.
265
+ // Wizard-generated agents use `tool: claude` instead of `base: claude` —
266
+ // listAgents handles both, but the old resolveTerminalLaunch only looked
267
+ // at `base`, so wizard-installed forti-k2 / forti-coder were missing the
268
+ // inherited path and falling back to bare `claude`.
269
+ const baseId = agentCfg.base || (agentCfg.tool && agentCfg.tool !== agentId ? agentCfg.tool : undefined);
248
270
  const baseCfg = baseId ? (settings.agents?.[baseId] || {}) : {};
249
271
  const cliType = agentCfg.cliType || baseCfg.cliType
250
272
  || (baseId === 'codex' ? 'codex' : baseId === 'aider' ? 'aider' : undefined)
@@ -286,7 +308,14 @@ export function resolveTerminalLaunch(agentId?: string, scene: 'terminal' | 'tas
286
308
  // + forge-server's inherited PATH can pick a different/older install than
287
309
  // the user's interactive shell, and an old claude crashes resuming a
288
310
  // session a newer claude wrote. An absolute path bypasses PATH entirely.
289
- cliCmd: agentCfg.path || cli.cmd,
311
+ // Derived agents (base: 'claude' with no own path e.g. forti-k2) must
312
+ // inherit the base agent's resolved path, otherwise launching them falls
313
+ // back to bare `claude` even though the base IS configured.
314
+ // If the result is still a bare command name (e.g. legacy settings have
315
+ // `path: 'claude'` because old detectClaude stored the input literally),
316
+ // resolve to an absolute path via `which` so tmux pane PATH can't shadow
317
+ // it with a stale global install.
318
+ cliCmd: resolveAbsoluteBin(agentCfg.path || baseCfg.path || cli.cmd),
290
319
  cliType,
291
320
  supportsSession: cli.session,
292
321
  resumeFlag: agentCfg.resumeFlag || cli.resume,
@@ -95,6 +95,20 @@ export function setCachedResult(sourceId: string, r: LoginCheckResult): void {
95
95
  persistCache();
96
96
  }
97
97
 
98
+ /**
99
+ * Drop the cached probe result for a single source. Called by
100
+ * `setConnectorConfig` so the panel auto-re-probes after the user
101
+ * (or a wizard apply) just rewrote creds — otherwise a stale 401
102
+ * from the no-token state lingers until the user clicks Refresh.
103
+ */
104
+ export function invalidateCachedResult(sourceId: string): void {
105
+ const c = loadCache();
106
+ if (sourceId in c) {
107
+ delete c[sourceId];
108
+ persistCache();
109
+ }
110
+ }
111
+
98
112
  // ─── Source enumeration ─────────────────────────────────────────────
99
113
 
100
114
  /**
@@ -334,6 +334,27 @@ function buildConnectorCatalog(openSet: Set<string>): string[] {
334
334
  const sample = sampleNames.join(', ');
335
335
  lines.push(`▸ ${def.id}${status}: ${toolCount} tools (e.g. ${sample}${toolCount > 5 ? ', …' : ''})`);
336
336
  }
337
+ // User defaults — surface so the LLM doesn't ask "which project?" /
338
+ // "which job?" when the wizard already baked one. Looks at both the
339
+ // connector root and the first row of any `instances` array (Jenkins).
340
+ const defaultEntries: string[] = [];
341
+ const seen = new Set<string>();
342
+ const collect = (obj: any) => {
343
+ if (!obj || typeof obj !== 'object') return;
344
+ for (const [k, v] of Object.entries(obj)) {
345
+ if (!k.startsWith('default_')) continue;
346
+ if (typeof v !== 'string' || !v.trim()) continue;
347
+ if (seen.has(k)) continue;
348
+ seen.add(k);
349
+ defaultEntries.push(`${k.slice('default_'.length)}=${v}`);
350
+ }
351
+ };
352
+ collect(inst.config);
353
+ const instances = (inst.config as any)?.instances;
354
+ if (Array.isArray(instances)) for (const row of instances) collect(row);
355
+ if (defaultEntries.length > 0) {
356
+ lines.push(` defaults: ${defaultEntries.join(', ')} — use when the user omits the matching arg`);
357
+ }
337
358
  }
338
359
  return lines;
339
360
  }
@@ -369,13 +390,14 @@ function buildSystemPrompt(
369
390
  ' Don\'t explain how to do something manually before trying the tool. The connector tools run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
370
391
  '- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
371
392
  '- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
372
- '- For trigger_pipeline / dispatch_task: when the user names a "project" (e.g. "FortiNAC"), pass it as input.project verbatim. The names in the "Forge projects" list below ARE the valid values. Call list_forge_context only if you need paths / agents / skills.',
393
+ '- For trigger_pipeline / dispatch_task: `input.project` refers to a FORGE project — names from the "Forge projects" list below (typically just `scratch` unless the user added directories). It is NOT a GitLab / Mantis project name. When in doubt OMIT `input.project` entirely Forge defaults to `scratch` (an internal scratch dir Forge fully manages). Pass repo coordinates via workflow-specific fields like `mr_url` / `project_path` / `bug_id`.',
373
394
  '',
374
395
  'trigger_pipeline specifics — these are easy to get wrong, READ CAREFULLY:',
375
396
  '- FIRST call this session: call trigger_pipeline() with NO arguments. The response lists every workflow + which input fields are required (*) vs have defaults. Field names are EXACT, snake_case (e.g. bug_id), declared by the workflow yaml. They are NOT the same as bash variable names inside pipeline scripts (BUG_ID / BASE / PROJECT_PATH are wrong). DO NOT pass uppercase / made-up names.',
376
397
  '- For optional fields with defaults (mr_body_template / user_prompt / teams_message_template / etc.), OMIT them — let the default apply. NEVER pass empty strings or invented placeholder values.',
377
398
  '- If the response says "Unknown input fields", "Missing required", or "0 iterations" — the pipeline did NOT do what the user asked. Fix the input and retry. Optionally save a pinned memory rule via memory_remember_block({pinned: true, ...}) so the lesson sticks for future sessions.',
378
399
  '- DO NOT trust earlier assistant messages in this conversation that claim a pipeline "already ran" — those may be wrong. If the user re-asks, fire fresh; verify only by re-checking the actual target system (e.g. mantis.get_bug for status).',
400
+ '- AMBIGUOUS IDs (e.g. user says "review mr 14904" or "fix bug 1234567" — just an iid/number, no project / no URL): DO NOT silently fill in a project from the gitlab connector\'s `default_project_path` or guess. An enterprise tenant typically has many repos and the iid is only unique within one repo. Either (a) ask the user "which project? — fortinac/FortiNAC? fortios? something else?", or (b) state the assumption explicitly in your reply ("Assuming fortinac/FortiNAC since that\'s your gitlab default — say otherwise to redirect.") BEFORE calling trigger_pipeline. The wrong choice fires a doomed run that the user then has to cancel + retry.',
379
401
  '',
380
402
  '- Reply without tools ONLY when no system + no time question is involved.',
381
403
  '',