@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.
Files changed (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +4 -7
  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 +920 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/app/chat/page.tsx +8 -5
  13. package/bin/forge-server.mjs +98 -1
  14. package/cli/mw.mjs +16 -6
  15. package/cli/mw.ts +19 -6
  16. package/components/ConnectorsPanel.tsx +85 -13
  17. package/components/CraftTerminal.tsx +12 -3
  18. package/components/Dashboard.tsx +55 -17
  19. package/components/DocTerminal.tsx +12 -6
  20. package/components/EnterpriseBadge.tsx +420 -0
  21. package/components/LoginStatusPanel.tsx +15 -1
  22. package/components/OnboardingWizard.tsx +418 -31
  23. package/components/SettingsModal.tsx +382 -63
  24. package/components/SkillsPanel.tsx +116 -91
  25. package/components/WebTerminal.tsx +36 -13
  26. package/dev-test.sh +34 -1
  27. package/install.sh +29 -2
  28. package/lib/agents/claude-adapter.ts +18 -4
  29. package/lib/agents/index.ts +33 -4
  30. package/lib/auth/login-status.ts +14 -0
  31. package/lib/chat/agent-loop.ts +23 -1
  32. package/lib/chat/llm/anthropic.ts +6 -1
  33. package/lib/chat/protocols/http.ts +15 -2
  34. package/lib/chat/tool-dispatcher.ts +163 -1
  35. package/lib/connectors/registry.ts +69 -4
  36. package/lib/connectors/sync.ts +536 -138
  37. package/lib/connectors/test-runner.ts +21 -3
  38. package/lib/connectors/types.ts +36 -4
  39. package/lib/connectors/wizard-template.ts +161 -0
  40. package/lib/dirs.ts +5 -0
  41. package/lib/enterprise-known.ts +34 -0
  42. package/lib/enterprise-secret.ts +87 -0
  43. package/lib/enterprise.ts +208 -0
  44. package/lib/help-docs/00-overview.md +12 -0
  45. package/lib/help-docs/01-settings.md +47 -1
  46. package/lib/help-docs/17-connectors.md +25 -22
  47. package/lib/help-docs/CLAUDE.md +1 -0
  48. package/lib/init.ts +13 -6
  49. package/lib/marketplace-sync.ts +70 -0
  50. package/lib/memory/temper-provision.ts +92 -0
  51. package/lib/pipeline-gc.ts +5 -2
  52. package/lib/pipeline.ts +26 -21
  53. package/lib/plugins/templates.ts +76 -3
  54. package/lib/projects.ts +85 -0
  55. package/lib/settings.ts +10 -0
  56. package/lib/telegram-bot.ts +14 -2
  57. package/lib/workflow-marketplace.ts +174 -108
  58. package/package.json +1 -1
  59. package/{middleware.ts → proxy.ts} +2 -1
  60. package/src/core/db/database.ts +8 -2
  61. package/templates/connector-config-template.json +0 -7
@@ -47,9 +47,19 @@ function SecretChangeDialog({ field, label, isSet, onSave, onClose }: {
47
47
  const [error, setError] = useState('');
48
48
  const [saving, setSaving] = useState(false);
49
49
 
50
+ // First-time admin password bootstrap: when the user is setting the
51
+ // admin password itself AND none exists yet, there's nothing to verify
52
+ // against — hide the "Admin password" input and just collect the new
53
+ // value. Backend mirrors the bypass.
54
+ const isFirstAdminSet = field === 'telegramTunnelPassword' && !isSet;
55
+
50
56
  const canSave = mode === 'clear'
51
57
  ? adminPassword.length > 0
52
- : (adminPassword.length > 0 && newValue.length > 0 && newValue === confirmValue);
58
+ : (
59
+ (isFirstAdminSet || adminPassword.length > 0)
60
+ && newValue.length > 0
61
+ && newValue === confirmValue
62
+ );
53
63
 
54
64
  const handleSave = async () => {
55
65
  if (mode === 'change' && newValue !== confirmValue) {
@@ -95,15 +105,22 @@ function SecretChangeDialog({ field, label, isSet, onSave, onClose }: {
95
105
  )}
96
106
  </div>
97
107
 
98
- <div className="space-y-1">
99
- <label className="text-[10px] text-[var(--text-secondary)]">Admin password (login password)</label>
100
- <SecretInput
101
- value={adminPassword}
102
- onChange={v => { setAdminPassword(v); setError(''); }}
103
- placeholder="Enter login password to verify"
104
- className={inputClass}
105
- />
106
- </div>
108
+ {!isFirstAdminSet && (
109
+ <div className="space-y-1">
110
+ <label className="text-[10px] text-[var(--text-secondary)]">Admin password (login password)</label>
111
+ <SecretInput
112
+ value={adminPassword}
113
+ onChange={v => { setAdminPassword(v); setError(''); }}
114
+ placeholder="Enter login password to verify"
115
+ className={inputClass}
116
+ />
117
+ </div>
118
+ )}
119
+ {isFirstAdminSet && (
120
+ <p className="text-[10px] text-[var(--text-secondary)]">
121
+ No admin password set yet — skipping verification. Pick one below.
122
+ </p>
123
+ )}
107
124
 
108
125
  {mode === 'change' && (
109
126
  <>
@@ -346,6 +363,41 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
346
363
  >
347
364
  <h2 className="text-sm font-bold">Settings</h2>
348
365
 
366
+ {/* Identity — display name, email, company, dept. At the top
367
+ because every other config (connector usernames, Jenkins
368
+ inject_params, tunnel ownership) derives from these via
369
+ {user.*} / {dept.*} tokens. Company + dept are auto-filled
370
+ by the wizard when picking an enterprise template; changing
371
+ dept here triggers a re-apply of that dept's template. */}
372
+ <div className="grid grid-cols-2 gap-2">
373
+ <div className="space-y-1">
374
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Display Name</label>
375
+ <input
376
+ type="text"
377
+ value={(settings as any).displayName || ''}
378
+ onChange={e => setSettings({ ...settings, displayName: e.target.value } as any)}
379
+ placeholder="Forge"
380
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)]"
381
+ />
382
+ </div>
383
+ <div className="space-y-1">
384
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Email</label>
385
+ <input
386
+ type="email"
387
+ value={(settings as any).displayEmail || ''}
388
+ onChange={e => setSettings({ ...settings, displayEmail: e.target.value } as any)}
389
+ placeholder="local@forge"
390
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)]"
391
+ />
392
+ </div>
393
+ </div>
394
+ <ProfileCompanyDeptRow settings={settings} setSettings={setSettings} />
395
+
396
+ {/* Marketplace Providers (enterprise repo keys) — right after
397
+ identity because most users who care about settings at all
398
+ are here to manage / inspect enterprise sources. */}
399
+ <MarketplaceProvidersSection />
400
+
349
401
  {/* Project Roots */}
350
402
  <div className="space-y-2">
351
403
  <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
@@ -803,59 +855,6 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
803
855
 
804
856
  </div>
805
857
 
806
- {/* Display Name */}
807
- <div className="space-y-2">
808
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
809
- Display Name
810
- </label>
811
- <input
812
- type="text"
813
- value={(settings as any).displayName || ''}
814
- onChange={e => setSettings({ ...settings, displayName: e.target.value } as any)}
815
- placeholder="Forge"
816
- className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)]"
817
- />
818
- </div>
819
-
820
- {/* Email */}
821
- <div className="space-y-2">
822
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
823
- Email
824
- </label>
825
- <input
826
- type="email"
827
- value={(settings as any).displayEmail || ''}
828
- onChange={e => setSettings({ ...settings, displayEmail: e.target.value } as any)}
829
- placeholder="local@forge"
830
- className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)]"
831
- />
832
- </div>
833
-
834
- {/* Re-run Onboarding */}
835
- <div className="space-y-2">
836
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
837
- Onboarding
838
- </label>
839
- <button
840
- type="button"
841
- onClick={async () => {
842
- await fetch('/api/onboarding', {
843
- method: 'POST',
844
- headers: { 'Content-Type': 'application/json' },
845
- body: JSON.stringify({ action: 'reset' }),
846
- });
847
- // Reload so the Dashboard banner re-renders.
848
- window.location.reload();
849
- }}
850
- className="text-[11px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
851
- >
852
- ↺ Re-run Onboarding wizard
853
- </button>
854
- <p className="text-[10px] text-[var(--text-secondary)]">
855
- Re-opens the first-run setup banner. Existing values are preserved — the wizard never overwrites non-empty fields.
856
- </p>
857
- </div>
858
-
859
858
  {/* Admin Password */}
860
859
  <div className="space-y-2">
861
860
  <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
@@ -1356,6 +1355,326 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1356
1355
  );
1357
1356
  }
1358
1357
 
1358
+ /**
1359
+ * Company + Dept dropdowns for the Identity block. Pure profile fields
1360
+ * — editing them just updates the label. The wizard does the actual
1361
+ * template apply on first setup; to re-pick a template the user goes
1362
+ * through Re-run wizard.
1363
+ */
1364
+ function ProfileCompanyDeptRow({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1365
+ const [sources, setSources] = useState<EnterpriseSourceView[]>([]);
1366
+ useEffect(() => {
1367
+ fetch('/api/enterprise-keys').then(r => r.json()).then(j => setSources(j.sources || [])).catch(() => {});
1368
+ }, []);
1369
+ const currentCompany: string = settings?.company || '';
1370
+ const sourceForCompany = sources.find(s => s.display_name === currentCompany);
1371
+ return (
1372
+ <div className="space-y-1">
1373
+ <div className="grid grid-cols-2 gap-2">
1374
+ <div className="space-y-1">
1375
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Company</label>
1376
+ <select
1377
+ value={currentCompany}
1378
+ onChange={e => setSettings({ ...settings, company: e.target.value, dept: '' })}
1379
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
1380
+ >
1381
+ <option value="">(none — public)</option>
1382
+ {sources.map(s => (
1383
+ <option key={s.tenant_id} value={s.display_name}>{s.display_name}</option>
1384
+ ))}
1385
+ </select>
1386
+ </div>
1387
+ <div className="space-y-1">
1388
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Department</label>
1389
+ <select
1390
+ value={settings?.dept || ''}
1391
+ disabled={!sourceForCompany?.departments?.length}
1392
+ onChange={e => setSettings({ ...settings, dept: e.target.value })}
1393
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
1394
+ >
1395
+ <option value="">{sourceForCompany?.departments?.length ? '(pick a dept…)' : '(no depts)'}</option>
1396
+ {sourceForCompany?.departments?.map(d => (
1397
+ <option key={d.id} value={d.display_name}>{d.display_name}</option>
1398
+ ))}
1399
+ </select>
1400
+ </div>
1401
+ </div>
1402
+ <p className="text-[10px] text-[var(--text-secondary)]">
1403
+ Auto-filled by the wizard. Editing here just updates the label — to re-apply a different dept's template, use ↺ Re-run wizard below.
1404
+ </p>
1405
+ </div>
1406
+ );
1407
+ }
1408
+
1409
+ interface EnterpriseSourceView {
1410
+ tenant_id: string;
1411
+ display_name: string;
1412
+ repo_url: string;
1413
+ priority: number;
1414
+ pat_preview: string;
1415
+ last_sync?: { ok: boolean; error?: string; fetched_at: string } | null;
1416
+ departments?: Array<{ id: string; display_name: string; has_template?: boolean }>;
1417
+ active_dept?: string | null;
1418
+ }
1419
+
1420
+ /**
1421
+ * Marketplace Providers — manage the enterprise keys that unlock private
1422
+ * connector / workflow registries. See lib/enterprise.ts for the on-disk
1423
+ * format. UI surface: list of configured sources (priority order) + add
1424
+ * field + remove button. Re-order is intentionally not exposed yet —
1425
+ * `remove + add again` is good enough for Phase 1 and avoids the modal
1426
+ * drag-and-drop complexity; revisit if real users want it.
1427
+ */
1428
+ function MarketplaceProvidersSection() {
1429
+ const [sources, setSources] = useState<EnterpriseSourceView[]>([]);
1430
+ const [loading, setLoading] = useState(true);
1431
+ const [adding, setAdding] = useState(false);
1432
+ const [newKey, setNewKey] = useState('');
1433
+ const [error, setError] = useState('');
1434
+ const [status, setStatus] = useState('');
1435
+ // "+ Add another enterprise tenant" is collapsed by default — only one
1436
+ // key is typical, and the input doesn't belong next to the sync /
1437
+ // reinstall actions (those operate on EXISTING sources). Auto-open
1438
+ // when there are zero sources so first-time users still see it.
1439
+ const [showAddTenant, setShowAddTenant] = useState(false);
1440
+
1441
+ const load = useCallback(async () => {
1442
+ setLoading(true);
1443
+ try {
1444
+ const r = await fetch('/api/enterprise-keys');
1445
+ const data = await r.json();
1446
+ setSources(data?.sources || []);
1447
+ } catch (e) {
1448
+ setError(e instanceof Error ? e.message : String(e));
1449
+ } finally {
1450
+ setLoading(false);
1451
+ }
1452
+ }, []);
1453
+
1454
+ useEffect(() => { load(); }, [load]);
1455
+ // Reveal the Add-tenant input automatically when there are no
1456
+ // sources at all — otherwise it stays tucked behind a disclosure.
1457
+ useEffect(() => {
1458
+ if (!loading && sources.length === 0) setShowAddTenant(true);
1459
+ }, [loading, sources.length]);
1460
+
1461
+ const handleAdd = async () => {
1462
+ const key = newKey.trim();
1463
+ if (!key) return;
1464
+ setError('');
1465
+ setStatus('');
1466
+ setAdding(true);
1467
+ try {
1468
+ const r = await fetch('/api/enterprise-keys', {
1469
+ method: 'POST',
1470
+ headers: { 'Content-Type': 'application/json' },
1471
+ body: JSON.stringify({ key }),
1472
+ });
1473
+ const data = await r.json();
1474
+ if (!data.ok) {
1475
+ setError(data.error || 'failed to add key');
1476
+ } else {
1477
+ setNewKey('');
1478
+ setStatus(`✓ Added ${data.tenant_id} — marketplace synced`);
1479
+ await load();
1480
+ }
1481
+ } catch (e) {
1482
+ setError(e instanceof Error ? e.message : String(e));
1483
+ } finally {
1484
+ setAdding(false);
1485
+ }
1486
+ };
1487
+
1488
+ const [resyncing, setResyncing] = useState(false);
1489
+
1490
+ // Comprehensive sync: connectors (registry + manifests + wizard
1491
+ // templates) + workflows (pipelines + recipes) + skills, from every
1492
+ // configured source (public + each enterprise tenant). Same endpoint
1493
+ // as Marketplace top → ↻ Sync all so both views agree.
1494
+ // User: "settings 的 re-sync 是同步所有的数据的".
1495
+ const handleResync = async () => {
1496
+ setError('');
1497
+ setStatus('');
1498
+ setResyncing(true);
1499
+ try {
1500
+ const r = await fetch('/api/marketplace/sync-all', { method: 'POST' });
1501
+ const data = await r.json();
1502
+ if (!data.ok && data.error) { setError(data.error); }
1503
+ else {
1504
+ const parts: string[] = [];
1505
+ if (data.connectors) parts.push(`connectors ${data.connectors.sources_ok ?? 0}/${data.connectors.sources_total ?? 0} sources, ${data.connectors.manifests_refreshed ?? 0} manifests`);
1506
+ if (data.workflows) parts.push(`workflows ${data.workflows.pipelines ?? 0} pipelines + ${data.workflows.recipes ?? 0} recipes`);
1507
+ if (data.skills) parts.push(`skills ${data.skills.synced ?? 0}`);
1508
+ setStatus(`✓ Synced — ${parts.join(' · ')}`);
1509
+ }
1510
+ await load();
1511
+ } catch (e) {
1512
+ setError(e instanceof Error ? e.message : String(e));
1513
+ } finally {
1514
+ setResyncing(false);
1515
+ }
1516
+ };
1517
+
1518
+ const handleRemove = async (tenant_id: string) => {
1519
+ if (!confirm(`Remove enterprise source "${tenant_id}"?\n\nInstalled connectors stay on disk; they just stop receiving updates from this source.`)) return;
1520
+ setError('');
1521
+ setStatus('');
1522
+ try {
1523
+ const r = await fetch(`/api/enterprise-keys?tenant_id=${encodeURIComponent(tenant_id)}`, { method: 'DELETE' });
1524
+ const data = await r.json();
1525
+ if (!data.ok) { setError(data.error || 'remove failed'); return; }
1526
+ setStatus(`✓ Removed ${tenant_id}`);
1527
+ await load();
1528
+ } catch (e) {
1529
+ setError(e instanceof Error ? e.message : String(e));
1530
+ }
1531
+ };
1532
+
1533
+ return (
1534
+ <div className="space-y-2">
1535
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
1536
+ Enterprise
1537
+ </label>
1538
+ <p className="text-[10px] text-[var(--text-secondary)]">
1539
+ Private enterprise registries layered on top of the public marketplace. Higher in this list = higher priority (overrides the public one when the same id exists). Keys are encrypted at rest.
1540
+ </p>
1541
+
1542
+ {/* Configured sources */}
1543
+ <div className="rounded border border-[var(--border)] divide-y divide-[var(--border)] bg-[var(--bg-tertiary)]">
1544
+ {loading ? (
1545
+ <div className="px-3 py-2 text-[11px] text-[var(--text-secondary)]">Loading…</div>
1546
+ ) : sources.length === 0 ? (
1547
+ <div className="px-3 py-2 text-[11px] text-[var(--text-secondary)] italic">
1548
+ No enterprise sources configured. Public marketplace only.
1549
+ </div>
1550
+ ) : (
1551
+ sources.map((s) => {
1552
+ const sync = s.last_sync;
1553
+ const synced = sync?.ok;
1554
+ const failed = sync && !sync.ok;
1555
+ return (
1556
+ <div key={s.tenant_id} className="px-3 py-2 flex flex-col gap-1">
1557
+ <div className="flex items-center gap-2">
1558
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 shrink-0">
1559
+ 🔒 {s.display_name}
1560
+ </span>
1561
+ <div className="flex-1 min-w-0">
1562
+ <div className="text-[11px] font-mono text-[var(--text-primary)] truncate" title={s.repo_url}>
1563
+ {s.repo_url}
1564
+ </div>
1565
+ <div className="text-[9px] text-[var(--text-secondary)] mt-0.5 flex items-center gap-2">
1566
+ <span>priority {s.priority} · PAT {s.pat_preview}</span>
1567
+ {synced && <span className="text-emerald-500">● synced</span>}
1568
+ {failed && <span className="text-red-400">✗ sync failed</span>}
1569
+ {!sync && <span className="italic">(no sync yet)</span>}
1570
+ </div>
1571
+ {/* Dept selection lives in the Identity row above —
1572
+ it's a profile property, not a tenant property. */}
1573
+ </div>
1574
+ <button
1575
+ onClick={() => handleRemove(s.tenant_id)}
1576
+ className="text-[10px] px-2 py-0.5 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10"
1577
+ >
1578
+ Remove
1579
+ </button>
1580
+ </div>
1581
+ {failed && sync?.error && (
1582
+ <div className="text-[10px] text-red-300 border border-red-500/30 bg-red-500/5 rounded px-2 py-1 leading-snug whitespace-pre-wrap break-words">
1583
+ {sync.error}
1584
+ </div>
1585
+ )}
1586
+ </div>
1587
+ );
1588
+ })
1589
+ )}
1590
+ </div>
1591
+
1592
+ <div className="flex gap-2">
1593
+ <button
1594
+ onClick={handleResync}
1595
+ disabled={resyncing}
1596
+ className="text-[11px] px-3 py-1.5 rounded border border-[var(--border)] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:opacity-40"
1597
+ title="Sync EVERYTHING from every source (public + each enterprise tenant): connectors + workflows + skills + wizard templates. Same as Marketplace top → Sync all."
1598
+ >
1599
+ {resyncing ? 'Syncing…' : '↻ Sync all'}
1600
+ </button>
1601
+ <button
1602
+ type="button"
1603
+ onClick={async () => {
1604
+ await fetch('/api/onboarding', {
1605
+ method: 'POST',
1606
+ headers: { 'Content-Type': 'application/json' },
1607
+ body: JSON.stringify({ action: 'reset' }),
1608
+ });
1609
+ window.location.reload();
1610
+ }}
1611
+ className="text-[11px] px-3 py-1.5 rounded border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white"
1612
+ title="Re-run the onboarding wizard to swap tenant / dept or re-apply template defaults."
1613
+ >
1614
+ ↺ Re-run wizard
1615
+ </button>
1616
+ </div>
1617
+
1618
+ {/* Status / error */}
1619
+ {error && (
1620
+ <div className="text-[10px] text-red-400 border border-red-500/30 bg-red-500/5 rounded px-2 py-1">
1621
+ {error}
1622
+ </div>
1623
+ )}
1624
+ {status && !error && (
1625
+ <div className="text-[10px] text-green-400">{status}</div>
1626
+ )}
1627
+
1628
+ <p className="text-[9px] text-[var(--text-secondary)] italic">
1629
+ Removing a source keeps its installed connectors/workflows on disk — they just stop getting updates from that source.
1630
+ </p>
1631
+
1632
+ {/* Add another tenant — separate concept from sync/reinstall.
1633
+ Collapsed by default since most users only ever have one. */}
1634
+ <div className="border-t border-[var(--border)] pt-2 mt-1">
1635
+ {!showAddTenant ? (
1636
+ <button
1637
+ onClick={() => setShowAddTenant(true)}
1638
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1639
+ >
1640
+ + Add another enterprise tenant
1641
+ </button>
1642
+ ) : (
1643
+ <div className="space-y-1.5">
1644
+ <label className="text-[10px] text-[var(--text-secondary)] block">
1645
+ Add another enterprise tenant (different company / org). Already-configured tenants show above — this is only for layering ANOTHER private registry on top.
1646
+ </label>
1647
+ <div className="flex gap-2">
1648
+ <input
1649
+ type="text"
1650
+ placeholder="acme:github_pat_xxx or github_pat_xxx@github.com/org/forge-enterprise-agent-yourco"
1651
+ value={newKey}
1652
+ onChange={(e) => setNewKey(e.target.value)}
1653
+ onKeyDown={(e) => { if (e.key === 'Enter' && !adding) handleAdd(); }}
1654
+ className="flex-1 text-[11px] font-mono px-2 py-1.5 rounded bg-[var(--bg-primary)] border border-[var(--border)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50"
1655
+ />
1656
+ <button
1657
+ onClick={handleAdd}
1658
+ disabled={adding || !newKey.trim()}
1659
+ className="text-[11px] px-3 py-1.5 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
1660
+ >
1661
+ {adding ? 'Adding…' : 'Add tenant'}
1662
+ </button>
1663
+ <button
1664
+ onClick={() => { setShowAddTenant(false); setNewKey(''); }}
1665
+ className="text-[10px] px-2 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1666
+ >
1667
+ Cancel
1668
+ </button>
1669
+ </div>
1670
+ </div>
1671
+ )}
1672
+ </div>
1673
+
1674
+ </div>
1675
+ );
1676
+ }
1677
+
1359
1678
  function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1360
1679
  const { registry: modelsRegistry } = useModelsRegistry();
1361
1680
  const [agents, setAgents] = useState<AgentEntry[]>([]);