@aion0/forge 0.10.40 → 0.10.42
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/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +4 -7
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +920 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/app/chat/page.tsx +8 -5
- package/bin/forge-server.mjs +98 -1
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/llm/anthropic.ts +6 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- 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'>('
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
{
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
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}` : (
|
|
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
|
-
|
|
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}" && ${
|
|
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} (${
|
|
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
|
-
|
|
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}" &&
|
|
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
|
-
|
|
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
|
|
6
|
-
# ./install.sh local
|
|
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}" &&
|
|
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}" &&
|
|
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
|
-
|
|
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:
|
|
118
|
+
path: resolved,
|
|
105
119
|
enabled: true,
|
|
106
120
|
type: 'claude-code',
|
|
107
121
|
capabilities: CAPABILITIES,
|
package/lib/agents/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/auth/login-status.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -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:
|
|
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
|
'',
|