@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
@@ -55,6 +55,10 @@ interface OnboardingState {
55
55
  default_enabled: boolean;
56
56
  has_prompts: boolean;
57
57
  already_installed: boolean;
58
+ /** Template-baked field values for this connector (excluding ${...}
59
+ * prompts, which appear in section 3). Secrets are masked to
60
+ * '••••••••'. Instances arrays are flattened to '<name>.<field>'. */
61
+ defaults?: Array<{ field: string; value: string; is_secret: boolean }>;
58
62
  }>;
59
63
  template_prompts: Record<string, PromptDef>;
60
64
  /** Per ${key} prompt: true = at least one target field already has a real value;
@@ -62,7 +66,51 @@ interface OnboardingState {
62
66
  prompt_values_set: Record<string, boolean>;
63
67
  /** Per ${key}: which connector.field references it (for UI hint). */
64
68
  prompt_targets: Record<string, Array<{ connector: string; field: string }>>;
69
+ /** Pre-baked CLI agents the template will install (from `_agents`). */
70
+ template_agents_preview?: Array<{ id: string; tool?: string; has_secret: boolean }>;
71
+ /** Pre-baked API profiles the template will install (from `_apiProfiles`). */
72
+ template_api_profiles_preview?: Array<{ id: string; provider?: string; model?: string; has_secret: boolean }>;
73
+ /** Keys the template auto-derives from other prompts (e.g. user_email_local). */
74
+ template_derive_keys?: string[];
75
+ /** Lowercased enterprise name when the template is enterprise-sourced. */
76
+ template_enterprise_name?: string;
77
+ /** Wizard layout knobs declared by template._wizard — controls how much of
78
+ * the wizard's collapsing/hiding logic kicks in. Enterprise templates with
79
+ * nearly-everything baked set minimal:true so the user only sees Identity
80
+ * + required prompts + preview. */
81
+ template_wizard?: {
82
+ minimal: boolean;
83
+ hide_connectors: boolean;
84
+ hide_pipelines: boolean;
85
+ required_only: boolean;
86
+ };
87
+ /** When the template ships a `_temperAdmin: {url, token}` block (or the user
88
+ * set temperAdminToken manually), Forge will auto-provision a Temper memory
89
+ * account on Apply. `provisioned` reflects whether settings.temperKey is
90
+ * already populated (= no re-provision will happen). */
91
+ memory_auto_provision?: {
92
+ url: string;
93
+ provisioned: boolean;
94
+ };
65
95
  suggested_pipelines: string[];
96
+ /** E2: list of source ids the wizard can switch between. Powers the
97
+ * tenant selector at the top. `public` always present; user override
98
+ * + each enterprise tenant appear when configured. */
99
+ available_sources?: Array<{ id: string; display_name: string; has_template: boolean }>;
100
+ /** Source id that actually fed the rendered template — null when none
101
+ * cached (bundled fallback in use). */
102
+ resolved_source?: string | null;
103
+ /** The source_id the GET request asked for (null when none requested). */
104
+ requested_source_id?: string | null;
105
+ /** E3: dept picker — list of departments within the currently-scoped
106
+ * source. Empty = single-template source, no picker shown. */
107
+ available_departments?: Array<{ id: string; display_name: string }>;
108
+ /** Dept id actually rendered (defaults to first dept when none requested). */
109
+ resolved_dept?: string | null;
110
+ requested_dept_id?: string | null;
111
+ /** Template's self-declared `_department_name`. Surfaced for the
112
+ * breadcrumb so the user sees what {dept.name} resolves to. */
113
+ template_department_name?: string;
66
114
  }
67
115
 
68
116
  interface ApplyResultPhase {
@@ -111,6 +159,13 @@ export function OnboardingBanner({ onOpen }: { onOpen: () => void }) {
111
159
 
112
160
  const inputCls = 'w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]';
113
161
 
162
+ function buildOnboardingUrl(sourceId: string | null, deptId: string | null): string {
163
+ const q = new URLSearchParams();
164
+ if (sourceId) q.set('source_id', sourceId);
165
+ if (deptId) q.set('dept', deptId);
166
+ return q.size ? `/api/onboarding?${q.toString()}` : '/api/onboarding';
167
+ }
168
+
114
169
  const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
115
170
  anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
116
171
  deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
@@ -121,8 +176,19 @@ const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
121
176
  litellm: { baseUrl: 'http://127.0.0.1:4000/v1', model: 'gpt-4o-mini' },
122
177
  };
123
178
 
124
- export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void; onComplete: () => void }) {
179
+ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onClose: () => void; onComplete: () => void; initialSourceId?: string | null }) {
125
180
  const [state, setState] = useState<OnboardingState | null>(null);
181
+ // E2: which source's template is currently scoped to. null = use the
182
+ // default priority chain (server's resolveTemplate decides). Changing
183
+ // this triggers a refetch + re-render of the entire wizard so prompts
184
+ // and connector lists swap to the chosen tenant's template.
185
+ // E4: when EnterpriseBadge hands off after a fresh key add, the
186
+ // source id arrives via prop so the wizard renders scoped from the
187
+ // first mount.
188
+ const [currentSource, setCurrentSource] = useState<string | null>(initialSourceId ?? null);
189
+ // E3: which dept within `currentSource`. null = use the source's first
190
+ // dept. Changing this re-fetches the wizard state.
191
+ const [currentDept, setCurrentDept] = useState<string | null>(null);
126
192
 
127
193
  // Section 1: Identity
128
194
  const [displayName, setDisplayName] = useState('');
@@ -192,7 +258,15 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
192
258
  // backend monitor probes — so users see what's actually live before
193
259
  // we dismiss the wizard.
194
260
  type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
195
- const [phase, setPhase] = useState<'form' | 'checks'>('form');
261
+ // 'splash' is the cold-boot choose-tenant gate: shown when there's no
262
+ // enterprise source yet so the user picks between adding an enterprise
263
+ // key or continuing with the public template. Skipped entirely when
264
+ // the wizard launches scoped to an existing source (e.g. handoff from
265
+ // EnterpriseBadge add) or when enterprise is already configured.
266
+ const [phase, setPhase] = useState<'splash' | 'form' | 'checks'>('form');
267
+ const [splashAddKey, setSplashAddKey] = useState('');
268
+ const [splashBusy, setSplashBusy] = useState(false);
269
+ const [splashError, setSplashError] = useState('');
196
270
  const [checks, setChecks] = useState<Record<string, CheckState>>({
197
271
  glabCli: { status: 'pending' },
198
272
  loginStatus: { status: 'pending' },
@@ -293,7 +367,8 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
293
367
  return;
294
368
  }
295
369
  // Pull fresh onboarding state (which re-computes suggested_pipelines).
296
- const s = await fetch('/api/onboarding').then(x => x.json()) as OnboardingState;
370
+ const url = buildOnboardingUrl(currentSource, currentDept);
371
+ const s = await fetch(url).then(x => x.json()) as OnboardingState;
297
372
  setState(s);
298
373
  // Auto-select fortinet-* (existing user selections preserved).
299
374
  setSelectedPipelines(prev => {
@@ -312,13 +387,27 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
312
387
  // so the main state arrives instantly, CLI list fills in shortly
313
388
  // after. Avoids blocking the wizard mount on `which claude` etc.
314
389
  useEffect(() => {
390
+ // E2/E3: re-runs whenever `currentSource` or `currentDept` changes,
391
+ // so switching either dropdown pulls a fresh template + prompts.
392
+ // detect-cli is scope-agnostic, only re-fetch on mount.
393
+ const url = buildOnboardingUrl(currentSource, currentDept);
315
394
  Promise.all([
316
- fetch('/api/onboarding').then(r => r.json()),
395
+ fetch(url).then(r => r.json()),
317
396
  fetch('/api/onboarding/detect-cli').then(r => r.json()).catch(() => ({ detected: [] })),
318
397
  ]).then(([s, d]: [OnboardingState, { detected: typeof s.detected_cli }]) => {
319
398
  // Merge probe results into state
320
399
  s.detected_cli = d.detected || [];
321
400
  setState(s);
401
+ // Cold-boot gate: no enterprise sources AND no explicit scope from
402
+ // the EnterpriseBadge handoff → show the choose-tenant splash.
403
+ // Once user picks (add enterprise OR continue with public), we
404
+ // flip to 'form' and the load effect re-runs with the choice
405
+ // baked in.
406
+ const hasEnterpriseSource = Array.isArray(s.available_sources)
407
+ && s.available_sources.some(src => src.id !== 'public' && src.id !== 'user' && src.has_template);
408
+ if (!hasEnterpriseSource && !currentSource && !initialSourceId) {
409
+ setPhase('splash');
410
+ }
322
411
  setDisplayName(s.current.displayName);
323
412
  setDisplayEmail(s.current.displayEmail);
324
413
  setSelectedPipelines(new Set(s.suggested_pipelines));
@@ -377,7 +466,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
377
466
  current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
378
467
  detected_cli: [], template_connectors: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
379
468
  }));
380
- }, []);
469
+ }, [currentSource, currentDept]);
381
470
 
382
471
  // Quick provider preset switch
383
472
  function setProviderPreset(key: keyof typeof PROVIDER_DEFAULTS) {
@@ -388,6 +477,36 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
388
477
  setApiProvider(key === 'anthropic' ? 'anthropic' : 'openai-compatible');
389
478
  }
390
479
 
480
+ async function splashAddEnterprise() {
481
+ const key = splashAddKey.trim();
482
+ if (!key || splashBusy) return;
483
+ setSplashBusy(true); setSplashError('');
484
+ try {
485
+ const r = await fetch('/api/enterprise-keys', {
486
+ method: 'POST',
487
+ headers: { 'Content-Type': 'application/json' },
488
+ body: JSON.stringify({ key }),
489
+ });
490
+ const data = await r.json();
491
+ if (!data.ok) { setSplashError(data.error || 'failed to add key'); return; }
492
+ // Hand off to the scoped wizard. Setting currentSource fires the
493
+ // load effect which re-fetches the rendered template, prompts,
494
+ // dept list etc. for the new tenant. `phase` flips to 'form' so
495
+ // the splash is gone on next render.
496
+ if (data.source_id) setCurrentSource(data.source_id);
497
+ setPhase('form');
498
+ } catch (e) {
499
+ setSplashError(e instanceof Error ? e.message : String(e));
500
+ } finally {
501
+ setSplashBusy(false);
502
+ }
503
+ }
504
+
505
+ function splashUsePublic() {
506
+ setCurrentSource(null); // priority chain → public falls through
507
+ setPhase('form');
508
+ }
509
+
391
510
  async function apply() {
392
511
  if (applying) return;
393
512
  setApplying(true); setResult(null);
@@ -407,6 +526,11 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
407
526
  selectedConnectors: Array.from(selectedConnectors),
408
527
  pipelines: Array.from(selectedPipelines),
409
528
  projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
529
+ // E2/E3: apply against the same template GET rendered. Use
530
+ // state.resolved_* as fallback so an untouched dropdown still
531
+ // sends what was rendered (server's default lookup matches).
532
+ ...(currentSource || state?.resolved_source ? { sourceId: currentSource || state?.resolved_source } : {}),
533
+ ...(currentDept || state?.resolved_dept ? { deptId: currentDept || state?.resolved_dept } : {}),
410
534
  };
411
535
  // Send apiProfile if a key was typed OR if we're re-confirming an
412
536
  // existing profile (apiKeyExisting=true and user changed any field).
@@ -477,6 +601,62 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
477
601
  if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose();
478
602
  };
479
603
 
604
+ if (phase === 'splash') {
605
+ return (
606
+ <DrawerShell onClose={onClose}>
607
+ <div className="overflow-y-auto flex-1 p-5 space-y-4">
608
+ <h2 className="text-sm font-medium text-[var(--text-primary)]">Welcome to Forge</h2>
609
+ <p className="text-[11px] text-[var(--text-secondary)] leading-snug">
610
+ Start with an enterprise template (Fortinet, etc. — pre-baked
611
+ tokens, agents, and pipelines for your org), or continue with
612
+ the public template (you wire each connector by hand).
613
+ </p>
614
+
615
+ <div className="border border-[var(--accent)]/30 bg-[var(--accent)]/5 rounded p-3 space-y-2">
616
+ <div className="text-[11px] font-medium text-[var(--text-primary)]">🔒 Add enterprise key</div>
617
+ <div className="text-[10px] text-[var(--text-secondary)] leading-snug">
618
+ Paste a key like <code>fortinet:&lt;PAT&gt;</code> or the long form
619
+ <code> &lt;PAT&gt;@github.com/org/repo</code>. After validation the
620
+ wizard reloads scoped to that tenant.
621
+ </div>
622
+ <input
623
+ type="password"
624
+ value={splashAddKey}
625
+ onChange={(e) => setSplashAddKey(e.target.value)}
626
+ placeholder="enterprise key"
627
+ autoFocus
628
+ className={inputCls}
629
+ onKeyDown={(e) => { if (e.key === 'Enter') splashAddEnterprise(); }}
630
+ />
631
+ {splashError && (
632
+ <div className="text-[10px] text-red-400">{splashError}</div>
633
+ )}
634
+ <button
635
+ onClick={splashAddEnterprise}
636
+ disabled={splashBusy || !splashAddKey.trim()}
637
+ className="text-[11px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
638
+ >
639
+ {splashBusy ? 'Adding…' : 'Add + continue →'}
640
+ </button>
641
+ </div>
642
+
643
+ <div className="text-center text-[10px] text-[var(--text-secondary)]">or</div>
644
+
645
+ <button
646
+ onClick={splashUsePublic}
647
+ className="w-full text-[11px] px-3 py-2 rounded border border-[var(--border)] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
648
+ >
649
+ Continue with public template →
650
+ </button>
651
+
652
+ <p className="text-[9px] text-[var(--text-secondary)] italic">
653
+ You can always add an enterprise key later from the badge in the top-left.
654
+ </p>
655
+ </div>
656
+ </DrawerShell>
657
+ );
658
+ }
659
+
480
660
  if (phase === 'checks') {
481
661
  return (
482
662
  <DrawerShell onClose={onClose}>
@@ -580,6 +760,12 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
580
760
  );
581
761
  }
582
762
 
763
+ // Section numbers are computed render-time so they stay sequential even
764
+ // when the template auto-installs API profile / agents (which hides sections
765
+ // 2 and 3) and minimal-mode pipelines stay visible.
766
+ let step = 0;
767
+ const stepN = () => ++step;
768
+
583
769
  return (
584
770
  <DrawerShell onClose={guardedClose}>
585
771
  <div className="overflow-y-auto flex-1 p-4 space-y-5">
@@ -587,19 +773,117 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
587
773
  <p className="text-[11px] text-[var(--text-secondary)] -mt-3">
588
774
  Fill in once. Re-runnable from Settings → "Re-run Onboarding".
589
775
  </p>
776
+ {/* E2: tenant selector — only renders when there's more than one
777
+ source to pick from (typical user has 1 enterprise + public).
778
+ `resolved_source` is highlighted with ✓; sources that haven't
779
+ cached a template yet show greyed out with "(not synced)". */}
780
+ {((state.available_sources && state.available_sources.filter(s => s.has_template).length > 1)
781
+ || (state.available_departments && state.available_departments.length > 1)) && (
782
+ <div className="flex items-center gap-2 text-[11px] -mt-2 flex-wrap">
783
+ <span className="text-[var(--text-secondary)]">Wizard for:</span>
784
+ {state.available_sources && state.available_sources.filter(s => s.has_template).length > 1 && (
785
+ <select
786
+ value={currentSource ?? (state.resolved_source ?? '')}
787
+ onChange={(e) => {
788
+ // Source switch resets the dept choice — the new tenant's
789
+ // depts are a different namespace, so let the server pick
790
+ // the default again rather than mis-mapping the slug.
791
+ setCurrentSource(e.target.value || null);
792
+ setCurrentDept(null);
793
+ }}
794
+ className="text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
795
+ >
796
+ {state.available_sources.map(s => (
797
+ <option key={s.id} value={s.id} disabled={!s.has_template}>
798
+ {s.display_name}{!s.has_template ? ' (not synced)' : ''}{s.id === state.resolved_source ? ' ✓' : ''}
799
+ </option>
800
+ ))}
801
+ </select>
802
+ )}
803
+ {state.available_departments && state.available_departments.length > 1 && (
804
+ <>
805
+ <span className="text-[var(--text-secondary)]">·</span>
806
+ <select
807
+ value={currentDept ?? (state.resolved_dept ?? '')}
808
+ onChange={(e) => setCurrentDept(e.target.value || null)}
809
+ className="text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
810
+ >
811
+ {state.available_departments.map(d => (
812
+ <option key={d.id} value={d.id}>
813
+ {d.display_name}{d.id === state.resolved_dept ? ' ✓' : ''}
814
+ </option>
815
+ ))}
816
+ </select>
817
+ </>
818
+ )}
819
+ {state.template_department_name && (
820
+ <span className="text-[10px] text-[var(--text-secondary)] italic">
821
+ {`{dept.name} = "${state.template_department_name}"`}
822
+ </span>
823
+ )}
824
+ </div>
825
+ )}
590
826
 
591
- {/* ── 1. Identity ──────────────────────────────────────── */}
592
- <Section title="1. Identity" hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}.">
827
+ {/* ── Identity ─────────────────────────────────────────── */}
828
+ <Section title={`${stepN()}. Identity`} hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}. Forge derives things like jenkins username from the email's local-part.">
593
829
  <Field label="Display name">
594
830
  <input className={inputCls} value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Zhen Liu" />
595
831
  </Field>
596
832
  <Field label="Email">
597
833
  <input className={inputCls} value={displayEmail} onChange={e => setDisplayEmail(e.target.value)} placeholder="zliu@fortinet.com" />
598
834
  </Field>
835
+ {state.template_derive_keys?.length ? (
836
+ <p className="text-[9px] text-[var(--text-secondary)] italic">
837
+ Auto-derived from your email: {state.template_derive_keys.join(', ')}.
838
+ </p>
839
+ ) : null}
599
840
  </Section>
600
841
 
601
- {/* ── 2. API Profile ───────────────────────────────────── */}
602
- <Section title="2. Chat API key" hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest.">
842
+ {/* ── Template pre-baked items ─────────────────────────── */}
843
+ {(state.template_agents_preview?.length || state.template_api_profiles_preview?.length) && (
844
+ <Section
845
+ title={`${stepN()}. ${state.template_enterprise_name
846
+ ? `🔒 ${state.template_enterprise_name} — auto-installed`
847
+ : '🔒 Template — auto-installed'}`}
848
+ hint="The enterprise template pre-bakes these on your behalf. Tokens are encrypted in git and decrypted locally. You can edit / disable any of them in Settings after onboarding."
849
+ >
850
+ {state.template_agents_preview?.length ? (
851
+ <div>
852
+ <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1">CLI agents</div>
853
+ <ul className="space-y-0.5">
854
+ {state.template_agents_preview.map((a) => (
855
+ <li key={a.id} className="text-[11px] font-mono flex items-center gap-1.5">
856
+ <span className="text-emerald-500">✓</span>
857
+ <span className="text-[var(--text-primary)]">{a.id}</span>
858
+ {a.tool && <span className="text-[9px] text-[var(--text-secondary)]">{a.tool}</span>}
859
+ {a.has_secret && <span className="text-[9px] text-amber-500">🔐 secret</span>}
860
+ </li>
861
+ ))}
862
+ </ul>
863
+ </div>
864
+ ) : null}
865
+ {state.template_api_profiles_preview?.length ? (
866
+ <div className="mt-2">
867
+ <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1">Chat profiles</div>
868
+ <ul className="space-y-0.5">
869
+ {state.template_api_profiles_preview.map((p) => (
870
+ <li key={p.id} className="text-[11px] font-mono flex items-center gap-1.5">
871
+ <span className="text-emerald-500">✓</span>
872
+ <span className="text-[var(--text-primary)]">{p.id}</span>
873
+ {p.provider && <span className="text-[9px] text-[var(--text-secondary)]">{p.provider}</span>}
874
+ {p.model && <span className="text-[9px] text-[var(--text-secondary)]">{p.model}</span>}
875
+ {p.has_secret && <span className="text-[9px] text-amber-500">🔐 secret</span>}
876
+ </li>
877
+ ))}
878
+ </ul>
879
+ </div>
880
+ ) : null}
881
+ </Section>
882
+ )}
883
+
884
+ {/* ── 2. API Profile (hidden when template provides one) ─ */}
885
+ {!state.template_api_profiles_preview?.length && (
886
+ <Section title={`${stepN()}. Chat API key`} hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest.">
603
887
  <div className="flex gap-1 mb-1 flex-wrap">
604
888
  {(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
605
889
  <button
@@ -631,9 +915,11 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
631
915
  Use this profile as the default chat agent
632
916
  </label>
633
917
  </Section>
918
+ )}
634
919
 
635
- {/* ── 2.5. CLI Agent ──────────────────────────────────── */}
636
- <Section title="3. CLI Agent" hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below.">
920
+ {/* ── 2.5. CLI Agent (hidden when template provides _agents) ─ */}
921
+ {!state.template_agents_preview?.length && (
922
+ <Section title={`${stepN()}. CLI Agent`} hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below.">
637
923
  {state.detected_cli.length === 0 && state.current.agents.length === 0 && (
638
924
  <p className="text-[10px] text-amber-500">
639
925
  No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
@@ -682,20 +968,73 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
682
968
  </label>
683
969
  )}
684
970
  </Section>
971
+ )}
972
+
973
+ {/* ── 3. Connectors ──────────────────────────────────────
974
+ In minimal mode we drop the bulk selector AND filter the connector
975
+ list to ones that still need user input — anything fully baked or
976
+ already-set silently rides along on the pre-selected set. */}
977
+ {(() => {
978
+ const w = state.template_wizard;
979
+ const minimal = !!w?.minimal;
980
+ const hideConnectors = !!w?.hide_connectors;
981
+ const requiredOnly = !!w?.required_only;
982
+ const allConnectors = state.template_connectors || [];
983
+ const promptsByKey = state.template_prompts || {};
984
+ const valuesSet = state.prompt_values_set || {};
985
+
986
+ // Which prompts to show for a given connector under the current
987
+ // visibility rules. In `requiredOnly` mode: drop non-required ones,
988
+ // but ALWAYS keep required prompts visible (set or not) so the user
989
+ // can re-enter a password / rotate a token without leaving minimal
990
+ // mode. The "● currently set" badge plus "leave blank to keep"
991
+ // placeholder already communicates that they don't have to touch it.
992
+ const visiblePromptsFor = (connectorId: string): string[] => {
993
+ const keys = (promptGroups.find(([c]) => c === connectorId)?.[1]) || [];
994
+ if (!requiredOnly) return keys;
995
+ return keys.filter(k => promptsByKey[k]?.required);
996
+ };
685
997
 
686
- {/* ── 3. Connectors ────────────────────────────────────── */}
687
- <Section title="3. Connector tokens" hint="Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once.">
688
- {(state.template_connectors || []).length === 0 && (
998
+ // Show every connector that has SOMETHING to display — either
999
+ // user-visible prompts or template-baked defaults. Even in
1000
+ // minimal mode we no longer hide connectors with no prompts:
1001
+ // the user explicitly wants visibility into what the template
1002
+ // installs (e.g. mantis base_url, pmdb base_url).
1003
+ const visibleConnectors = hideConnectors
1004
+ ? []
1005
+ : (minimal
1006
+ ? allConnectors.filter(({ id, has_prompts, defaults }) =>
1007
+ (has_prompts && visiblePromptsFor(id).length > 0)
1008
+ || (Array.isArray(defaults) && defaults.length > 0))
1009
+ : allConnectors);
1010
+ return (
1011
+ <Section
1012
+ title={`${stepN()}. Required tokens`}
1013
+ hint={minimal
1014
+ ? "Only fields you must fill in are shown. Everything else (defaults, presets) is auto-installed."
1015
+ : "Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once."}
1016
+ >
1017
+ {allConnectors.length === 0 && (
689
1018
  <p className="text-[10px] text-amber-500">
690
1019
  No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
691
1020
  </p>
692
1021
  )}
693
- {/* Bulk select/deselect */}
694
- {(state.template_connectors || []).length > 0 && (
1022
+ {hideConnectors && allConnectors.length > 0 && visibleConnectors.length === 0 && (
1023
+ <p className="text-[10px] text-[var(--text-secondary)]">
1024
+ ✓ All connectors are pre-configured by your template. Nothing to fill in here.
1025
+ </p>
1026
+ )}
1027
+ {!hideConnectors && minimal && allConnectors.length > 0 && visibleConnectors.length === 0 && (
1028
+ <p className="text-[10px] text-[var(--text-secondary)]">
1029
+ ✓ All required tokens already set. You can re-enter values to overwrite them.
1030
+ </p>
1031
+ )}
1032
+ {/* Bulk select/deselect — hidden in minimal mode (all pre-selected) */}
1033
+ {!minimal && allConnectors.length > 0 && (
695
1034
  <div className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
696
1035
  <button
697
1036
  type="button"
698
- onClick={() => setSelectedConnectors(new Set((state.template_connectors || []).map(c => c.id)))}
1037
+ onClick={() => setSelectedConnectors(new Set(allConnectors.map(c => c.id)))}
699
1038
  className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
700
1039
  >Select all</button>
701
1040
  <button
@@ -703,12 +1042,12 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
703
1042
  onClick={() => setSelectedConnectors(new Set())}
704
1043
  className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
705
1044
  >Deselect all</button>
706
- <span className="ml-auto">{selectedConnectors.size}/{(state.template_connectors || []).length} selected</span>
1045
+ <span className="ml-auto">{selectedConnectors.size}/{allConnectors.length} selected</span>
707
1046
  </div>
708
1047
  )}
709
- {(state.template_connectors || []).map(({ id: connector, has_prompts, already_installed }) => {
1048
+ {visibleConnectors.map(({ id: connector, has_prompts, already_installed, defaults }) => {
710
1049
  const checked = selectedConnectors.has(connector);
711
- const promptKeys = (promptGroups.find(([c]) => c === connector)?.[1]) || [];
1050
+ const promptKeys = visiblePromptsFor(connector);
712
1051
  const toggle = () => {
713
1052
  setSelectedConnectors(prev => {
714
1053
  const next = new Set(prev);
@@ -759,13 +1098,41 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
759
1098
  </div>
760
1099
  );
761
1100
  })}
1101
+ {/* Template-baked defaults — what lands on Apply for the
1102
+ fields the user doesn't fill in. Distinct from the
1103
+ italic gray hint text by being monospace + chip-style.
1104
+ Auto-collapsed; click to expand. */}
1105
+ {checked && defaults && defaults.length > 0 && (
1106
+ <details className="ml-5">
1107
+ <summary className="text-[9px] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]">
1108
+ Template defaults ({defaults.length})
1109
+ </summary>
1110
+ <div className="mt-1 space-y-0.5">
1111
+ {defaults.map(d => (
1112
+ <div key={d.field} className="flex items-baseline gap-1.5 text-[10px] font-mono leading-relaxed">
1113
+ <span className="text-[var(--text-secondary)] shrink-0">{d.field}</span>
1114
+ <span className="text-[var(--text-secondary)]">=</span>
1115
+ <span className={
1116
+ d.is_secret
1117
+ ? 'px-1 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 break-all'
1118
+ : 'px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border)] break-all'
1119
+ }>
1120
+ {d.value}
1121
+ </span>
1122
+ </div>
1123
+ ))}
1124
+ </div>
1125
+ </details>
1126
+ )}
762
1127
  </div>
763
1128
  );
764
1129
  })}
765
1130
  </Section>
1131
+ );
1132
+ })()}
766
1133
 
767
- {/* ── 4. Pipelines ─────────────────────────────────────── */}
768
- <Section title="4. Pipelines" hint="Installs from the workflow marketplace. fortinet-* default-selected.">
1134
+ {/* ── Pipelines ───────────────────────────────────────── */}
1135
+ <Section title={`${stepN()}. Pipelines`} hint="Installs from the workflow marketplace. fortinet-* default-selected.">
769
1136
  <div className="flex items-center gap-2 mb-1">
770
1137
  <button
771
1138
  type="button"
@@ -798,12 +1165,16 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
798
1165
  ))}
799
1166
  </Section>
800
1167
 
801
- {/* ── 5. Projects ──────────────────────────────────────── */}
802
- <Section title="5. Project roots (optional)" hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line).">
803
- {state.current.projectRoots.length > 0 && (
1168
+ {/* ── Projects ────────────────────────────────────────── */}
1169
+ <Section title={`${stepN()}. Project roots (optional)`} hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line).">
1170
+ {state.current.projectRoots.length > 0 ? (
804
1171
  <p className="text-[10px] text-[var(--text-secondary)]">
805
1172
  Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
806
1173
  </p>
1174
+ ) : (
1175
+ <p className="text-[10px] text-amber-400/90">
1176
+ No project roots yet. Pipelines first try to auto-clone the GitLab connector's <span className="font-mono">default_project_path</span>; if that's not set or clone fails, they fall back to <span className="font-mono">&lt;dataDir&gt;/scratch</span> so they still run (worktrees land inside scratch).
1177
+ </p>
807
1178
  )}
808
1179
  <div className="flex items-center gap-2">
809
1180
  <button
@@ -868,12 +1239,28 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
868
1239
  )}
869
1240
  </Section>
870
1241
 
871
- {/* ── 6. Memory (info only) ────────────────────────────── */}
872
- <Section title="6. Memory">
873
- <p className="text-[10px] text-[var(--text-secondary)]">
874
- Defaults to local SQLite — no setup needed. Want team-shared memory?
875
- Configure Temper later in Settings Memory.
876
- </p>
1242
+ {/* ── Memory (info only) ──────────────────────────────── */}
1243
+ <Section title={`${stepN()}. Memory`}>
1244
+ {state.memory_auto_provision ? (
1245
+ <div className="space-y-1">
1246
+ <div className="text-[11px] px-2 py-1.5 rounded border border-emerald-500/40 bg-emerald-500/10 text-emerald-300">
1247
+ <strong>Auto:</strong>{' '}
1248
+ {state.memory_auto_provision.provisioned
1249
+ ? <>Temper account already provisioned — chat memory backed by <span className="font-mono">{state.memory_auto_provision.url}</span>.</>
1250
+ : <>On <strong>Apply</strong>, Forge will provision a Temper account at <span className="font-mono">{state.memory_auto_provision.url}</span> using your identity above and wire chat memory to it.</>
1251
+ }
1252
+ </div>
1253
+ <p className="text-[10px] text-[var(--text-secondary)]">
1254
+ Prefer local-only memory? Switch to <strong>Local</strong> in Settings → Memory after onboarding —
1255
+ Forge falls back to a local SQLite store and your Temper credentials remain inert.
1256
+ </p>
1257
+ </div>
1258
+ ) : (
1259
+ <p className="text-[10px] text-[var(--text-secondary)]">
1260
+ Defaults to local SQLite — no setup needed. Want team-shared memory?
1261
+ Configure Temper later in Settings → Memory.
1262
+ </p>
1263
+ )}
877
1264
  </Section>
878
1265
  </div>
879
1266