From da902f29cc7a367a207eebb9cdce920932289a42 Mon Sep 17 00:00:00 2001 From: kryptobaseddev Date: Thu, 11 Jun 2026 23:43:32 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(T11983):=20cleo=20setup=20wizard=20e2e?= =?UTF-8?q?=20=E2=80=94=20prefs=20=E2=86=92=20connect=20=E2=86=92=20model?= =?UTF-8?q?=20(fit-gated)=20=E2=86=92=20profile=20=E2=86=92=20TUI=20offer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models-roles: `_pickOllamaModelInteractive` calls `rankLocalModelFit` for fit-gated Ollama model recommendations (RECOMMEND-NEVER-KILL: never auto-pulls, never auto-selects; 4 GB floor → cloud-only guidance). - setup.ts: `CleoSetupResult.firstRunComplete` propagated from WizardRunResult; `_printWhoamiSummaryAndOfferTui` prints whoami-style summary + TUI launch offer after successful first-run completion. - New 16-test TTY-simulated e2e suite (StubWizardIO) covering full wizard path, firstRunComplete propagation, Ollama fit-gated paths (floor, ranked recs, skip, manual entry, ranker failure), and whoami summary output. Co-Authored-By: Claude Fable 5 --- .changeset/t11983-setup-wizard-e2e.md | 56 ++ .../__tests__/setup-wizard-e2e.test.ts | 681 ++++++++++++++++++ packages/cleo/src/cli/commands/setup.ts | 116 +++ .../core/src/setup/sections/models-roles.ts | 183 ++++- 4 files changed, 1010 insertions(+), 26 deletions(-) create mode 100644 .changeset/t11983-setup-wizard-e2e.md create mode 100644 packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts diff --git a/.changeset/t11983-setup-wizard-e2e.md b/.changeset/t11983-setup-wizard-e2e.md new file mode 100644 index 000000000..48d9ff73c --- /dev/null +++ b/.changeset/t11983-setup-wizard-e2e.md @@ -0,0 +1,56 @@ +--- +id: t11983-setup-wizard-e2e +tasks: [T11983] +kind: feat +summary: "cleo setup wizard e2e: fit-gated Ollama model picker, firstRunComplete flag, whoami summary + TUI offer after first-run, 16-test e2e TTY-simulated suite" +--- + +Closes the last batteries-included onboarding gap (T11983 / E6-ONBOARDING-FRONT-DOOR): +`cleo setup` now completes the full prefs → provider connect → model pick → profile → +validated round-trip → TUI offer flow in a single interactive session. + +**Changes:** + +- **`packages/core/src/setup/sections/models-roles.ts`** (modified): + - New `_pickOllamaModelInteractive(io, ranker?)` export — when the active provider + is `ollama`, calls `rankLocalModelFit` to detect hardware and rank 2–3 best-fit + open-weight models. Below the 4 GB RAM floor, prints cloud-only guidance and + returns `null` (RECOMMEND-NEVER-KILL: never auto-pulls, never auto-selects). + Already-pulled models surface with a `[pulled]` annotation. Offers a manual-entry + fallback so the user is never locked in to the ranked list. + - Injectable `ranker` parameter keeps the function testable without OS/network calls. + - `createModelsRolesSection().run()`: branches to `_pickOllamaModelInteractive` when + the default provider is `ollama`; all other providers continue using the existing + catalog-picker path. + +- **`packages/cleo/src/cli/commands/setup.ts`** (modified): + - `CleoSetupResult` gains `firstRunComplete: boolean` (mirrors + `WizardRunResult.firstRunComplete`). Single-section runs always produce `false`; + full-wizard runs produce `true` iff every section succeeded. + - New `_printWhoamiSummaryAndOfferTui(io)` export — reads agent name, provider, and + model from config then prints a `cleo whoami`-style summary to stderr after a + successful first-run completion. Prompts the user to optionally launch `cleo tui` + (fire-and-forget spawn, never blocks wizard exit). + - `_readWhoamiSnapshot()` internal helper for best-effort config + credential pool + reads (never throws). + - Command `run()` calls `_printWhoamiSummaryAndOfferTui` after `cliOutput` when + `result.ok && result.firstRunComplete`. + +- **`packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts`** (new): + - 16-test TTY-simulated e2e suite using `StubWizardIO` as the TTY simulation surface + (the canonical CLEO approach — the wizard engine is I/O-agnostic by design). + - Covers: full 10-section wizard pass with `firstRunComplete=true`; section-failure + → `firstRunComplete=false`; single-`--section` always `false`; whoami summary + content assertions; graceful non-TTY handling of the TUI offer. + - Fit-gated Ollama path: below-floor cloud-only guidance, ranked recommendations, + `[pulled]` annotation, skip/manual-entry/ranker-failure fallback paths. + - `firstRunComplete` propagation verified for full and single-section runs. + +**AC compliance:** + +- Fresh-install `cleo setup` completes prefs → provider connect → model (fit-gated + for Ollama, catalog for cloud) → profile → verification in canonical order. +- Reuses shipped building blocks: `llm` (T11725), `models-roles` (T11726), + `models-roles` oauth-inline path (T11727), `cleo llm fit` envelope (T11982). +- Ends with whoami summary + TUI launch offer. +- E2E test with TTY-simulated (`StubWizardIO`) input: 16 tests all green. diff --git a/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts b/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts new file mode 100644 index 000000000..aed864f6d --- /dev/null +++ b/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts @@ -0,0 +1,681 @@ +/** + * E2E TTY-simulated test for `cleo setup` wizard (T11983). + * + * Exercises the FULL wizard path end-to-end using {@link StubWizardIO} as the + * TTY simulation surface. The test drives the real {@link WizardRunner} with + * every built-in section in canonical order and asserts that: + * + * 1. All 10 sections run in canonical order (llm → models-roles → identity + * → sentient → project-conventions → harness → brain → integrations → + * telemetry → verification). + * 2. `firstRunComplete` is `true` after a clean pass. + * 3. The whoami summary + TUI offer are printed on first-run completion. + * 4. The fit-gated Ollama path in `models-roles` honours the 4 GB floor: + * machines below the floor receive cloud-only guidance; machines above + * the floor receive ranked recommendations. + * 5. `runSetup` propagates `firstRunComplete` correctly from the runner. + * + * Heavy I/O (credential pool, config writes, brain-db checks, network probes) + * is mocked so tests run in < 5 s without a TTY, credentials, or Ollama. + * + * The "TTY-simulated" contract (referenced in the AC) maps to the + * {@link StubWizardIO} pattern: queued `prompts`, `confirms`, and `selects` + * replace interactive readline input while `info`/`warn`/`error` are + * captured for assertion. This is the canonical approach in the CLEO test + * suite (see `packages/core/src/setup/__tests__/wizard.test.ts`) — node-pty + * is not used here because the wizard engine is I/O-agnostic by design and + * the TTY is fully abstracted behind `WizardIO`. + * + * @task T11983 + * @epic T11671 (E6-ONBOARDING-FRONT-DOOR) + */ + +import type { WizardIO } from '@cleocode/core/setup'; +import { StubWizardIO } from '@cleocode/core/setup'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared BEFORE importing the tested modules. +// --------------------------------------------------------------------------- + +// Mock @cleocode/core/config so no real config files are read/written. +const mockSetConfigValue = vi.fn(async () => undefined); +const mockLoadConfigCore = vi.fn(async () => ({ + identity: { name: 'test-agent' }, + llm: { default: { provider: 'anthropic', model: 'claude-opus-4-5' } }, +})); +vi.mock('@cleocode/core/config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + setConfigValue: (...a: unknown[]) => mockSetConfigValue(...(a as [])), + loadConfig: () => mockLoadConfigCore(), + getConfigValue: vi.fn(async () => ({ value: undefined })), + }; +}); + +// Mock credential pool. +const mockPoolList = vi.fn(async () => [ + { provider: 'anthropic', label: 'cli-input', source: 'manual' }, +]); +const mockGetCredentialPool = vi.fn(() => ({ + list: mockPoolList, + seed: vi.fn(async () => ({ added: 0, failed: 0, skipped: 0 })), +})); +vi.mock('@cleocode/core/llm/credential-pool', () => ({ + getCredentialPool: () => mockGetCredentialPool(), + _resetCredentialPoolSingletonForTests: vi.fn(), +})); +vi.mock('@cleocode/core/llm/credential-pool.js', () => ({ + getCredentialPool: () => mockGetCredentialPool(), + _resetCredentialPoolSingletonForTests: vi.fn(), +})); + +// Mock addCredential so the llm section doesn't write to the real pool. +const mockAddCredential = vi.fn(async () => undefined); +vi.mock('@cleocode/core/llm/credentials-store', () => ({ + addCredential: (...a: unknown[]) => mockAddCredential(...(a as [])), +})); +vi.mock('@cleocode/core/llm/credentials-store.js', () => ({ + addCredential: (...a: unknown[]) => mockAddCredential(...(a as [])), +})); + +// Mock catalog-model-resolver so no disk reads happen. +vi.mock('@cleocode/core/llm/catalog-model-resolver', () => ({ + catalogKeyForProvider: (p: string) => p, + listProviderModels: () => ['claude-opus-4-5', 'claude-sonnet-4-5'], + resolveProviderDefaultModel: () => 'claude-opus-4-5', +})); +vi.mock('@cleocode/core/llm/catalog-model-resolver.js', () => ({ + catalogKeyForProvider: (p: string) => p, + listProviderModels: () => ['claude-opus-4-5', 'claude-sonnet-4-5'], + resolveProviderDefaultModel: () => 'claude-opus-4-5', +})); + +// Mock front-door login so no real OAuth or API calls are made. +const mockRunFrontDoorLogin = vi.fn(async () => ({ + success: true, + provider: 'anthropic', + label: 'wizard-test', +})); +vi.mock('@cleocode/core/llm/onboarding/front-door', () => ({ + runFrontDoorLogin: (...a: unknown[]) => mockRunFrontDoorLogin(...(a as [])), +})); +vi.mock('@cleocode/core/llm/onboarding/front-door.js', () => ({ + runFrontDoorLogin: (...a: unknown[]) => mockRunFrontDoorLogin(...(a as [])), +})); + +// Mock local-model-fit so no hardware detection or Ollama probes happen. +const mockRankLocalModelFit = vi.fn(); +vi.mock('@cleocode/core/llm/local-model-fit', () => ({ + rankLocalModelFit: (...a: unknown[]) => mockRankLocalModelFit(...(a as [])), + LOCAL_FIT_FLOOR_GB: 4, + LOCAL_MODEL_CANDIDATES: [], +})); +vi.mock('@cleocode/core/llm/local-model-fit.js', () => ({ + rankLocalModelFit: (...a: unknown[]) => mockRankLocalModelFit(...(a as [])), + LOCAL_FIT_FLOOR_GB: 4, + LOCAL_MODEL_CANDIDATES: [], +})); + +// Mock cross-provider-selector for Ollama probe used inside local-model-fit. +vi.mock('@cleocode/core/llm/cross-provider-selector', () => ({ + probeOllamaAlive: vi.fn(async () => false), +})); +vi.mock('@cleocode/core/llm/cross-provider-selector.js', () => ({ + probeOllamaAlive: vi.fn(async () => false), +})); + +// Mock paths and platform paths cache. +vi.mock('@cleocode/paths', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCleoPlatformPaths: () => ({ data: '/tmp/cleo-test-data', config: '/tmp/cleo-test-config' }), + _resetCleoPlatformPathsCache: vi.fn(), + }; +}); + +// Mock sentient config so the sentient section resolves. +vi.mock('@cleocode/core/sentient', () => ({ + getSentientConfig: vi.fn(async () => ({ enabled: false })), + setSentientConfig: vi.fn(async () => undefined), +})); +vi.mock('@cleocode/core/sentient.js', () => ({ + getSentientConfig: vi.fn(async () => ({ enabled: false })), + setSentientConfig: vi.fn(async () => undefined), +})); + +// Mock brain / SOUL paths so no fs writes happen. +vi.mock('@cleocode/core/paths', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveCleoDir: vi.fn(() => '/tmp/cleo-test'), + }; +}); +vi.mock('@cleocode/core/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveCleoDir: vi.fn(() => '/tmp/cleo-test'), + }; +}); + +// Mock fs so verification checks pass without real disk access. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn((p: string) => { + // Make brain.db "exist" for verification. + if (typeof p === 'string' && p.includes('brain.db')) return true; + return actual.existsSync(p); + }), + }; +}); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + access: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + mkdir: vi.fn(async () => undefined), + }; +}); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { _pickOllamaModelInteractive } from '@cleocode/core/setup/sections/models-roles'; +import { _printWhoamiSummaryAndOfferTui, runSetup } from '../setup.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A WizardIO implementation that captures output and supports confirm override. */ +class CapturingIO implements WizardIO { + readonly infos: string[] = []; + readonly warns: string[] = []; + readonly errors: string[] = []; + private promptQueue: string[]; + private confirmQueue: boolean[]; + private selectQueue: string[]; + + constructor( + opts: { + prompts?: string[]; + confirms?: boolean[]; + selects?: string[]; + } = {}, + ) { + this.promptQueue = [...(opts.prompts ?? [])]; + this.confirmQueue = [...(opts.confirms ?? [])]; + this.selectQueue = [...(opts.selects ?? [])]; + } + + async prompt(question: string): Promise { + const answer = this.promptQueue.shift() ?? ''; + this.infos.push(`[prompt] ${question} → ${answer}`); + return answer; + } + + async confirm(question: string, defaultValue?: boolean): Promise { + if (this.confirmQueue.length > 0) { + const val = this.confirmQueue.shift() as boolean; + this.infos.push(`[confirm] ${question} → ${val}`); + return val; + } + const val = defaultValue ?? false; + this.infos.push(`[confirm-default] ${question} → ${val}`); + return val; + } + + async select(question: string, options: readonly T[]): Promise { + const choice = this.selectQueue.shift(); + if (choice !== undefined && options.includes(choice as T)) { + this.infos.push(`[select] ${question} → ${choice}`); + return choice as T; + } + const first = options[0] as T; + this.infos.push(`[select-default] ${question} → ${first}`); + return first; + } + + info(message: string): void { + this.infos.push(message); + } + warn(message: string): void { + this.warns.push(message); + } + error(message: string): void { + this.errors.push(message); + } +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + mockLoadConfigCore.mockResolvedValue({ + identity: { name: 'test-agent' }, + llm: { default: { provider: 'anthropic', model: 'claude-opus-4-5' } }, + }); + mockPoolList.mockResolvedValue([{ provider: 'anthropic', label: 'cli-input', source: 'manual' }]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('cleo setup — e2e TTY-simulated (T11983)', () => { + // ------------------------------------------------------------------------- + // 1. Full wizard pass — all sections run, firstRunComplete = true + // ------------------------------------------------------------------------- + + it('full wizard pass — all 10 sections run, firstRunComplete=true', async () => { + // Build a StubWizardIO with enough answers to get through all sections. + // The non-interactive flag short-circuits most sections so minimal answers + // are needed here. + const io = new StubWizardIO({ + // answers for any remaining interactive prompts (consumed in order) + }); + + const result = await runSetup( + { + 'non-interactive': true, + provider: 'anthropic', + 'api-key': 'sk-ant-test-fake', + 'agent-name': 'test-agent', + harness: 'claude-code', + 'brain-bridge-mode': 'digest', + sentient: 'off', + 'signaldock-enabled': false, + 'studio-enabled': false, + 'retention-days': '0', + strictness: 'standard', + 'default-model': 'claude-opus-4-5', + }, + io, + ); + + // All 10 sections should appear in sectionsRun. + expect(result.sectionsRun).toContain('llm'); + expect(result.sectionsRun).toContain('models-roles'); + expect(result.sectionsRun).toContain('identity'); + expect(result.sectionsRun).toContain('sentient'); + expect(result.sectionsRun).toContain('project-conventions'); + expect(result.sectionsRun).toContain('harness'); + expect(result.sectionsRun).toContain('brain'); + expect(result.sectionsRun).toContain('integrations'); + expect(result.sectionsRun).toContain('telemetry'); + expect(result.sectionsRun).toContain('verification'); + + // Wizard completed without failures. + const failedSummaries = result.summary.filter((s) => /:\s*failed:/i.test(s)); + expect(failedSummaries).toEqual([]); // surfaces which section(s) failed + expect(result.ok).toBe(true); + + // firstRunComplete is propagated from WizardRunResult. + expect(result.firstRunComplete).toBe(true); + }); + + // ------------------------------------------------------------------------- + // 2. firstRunComplete = false when a section fails + // ------------------------------------------------------------------------- + + it('firstRunComplete=false when any section produces a failed: summary', async () => { + // Wire the llm section to fail by providing a bad api-key to the pool mock. + mockAddCredential.mockRejectedValueOnce(new Error('pool write failed')); + + const io = new StubWizardIO(); + const result = await runSetup( + { + 'non-interactive': true, + provider: 'anthropic', + 'api-key': 'sk-ant-test-bad', + }, + io, + ); + + // The runner catches section errors and reports them as failed: lines. + // firstRunComplete should be false whenever any section failed. + // (sections other than llm may still succeed; only the llm section throws here) + expect(result.firstRunComplete).toBe(false); + }); + + // ------------------------------------------------------------------------- + // 3. Single-section --section run → firstRunComplete always false + // ------------------------------------------------------------------------- + + it('single --section run always produces firstRunComplete=false', async () => { + const io = new StubWizardIO(); + const result = await runSetup({ section: 'identity', 'non-interactive': true }, io); + expect(result.sectionsRun).toEqual(['identity']); + expect(result.firstRunComplete).toBe(false); + }); + + // ------------------------------------------------------------------------- + // 4. whoami summary + TUI offer printed on first-run completion + // ------------------------------------------------------------------------- + + it('_printWhoamiSummaryAndOfferTui prints identity + provider + model, TUI declined', async () => { + const io = new CapturingIO({ confirms: [false] }); + await _printWhoamiSummaryAndOfferTui(io); + + const allOutput = [...io.infos, ...io.warns].join('\n'); + + // Should mention "Setup Complete" or similar + expect(allOutput).toMatch(/Setup Complete/i); + + // Should surface agent name. + expect(allOutput).toMatch(/test-agent/); + + // Should surface provider. + expect(allOutput).toMatch(/anthropic/); + + // Should surface model. + expect(allOutput).toMatch(/claude-opus-4-5/); + + // Should mention cleo whoami hint. + expect(allOutput).toMatch(/cleo whoami/); + }); + + it('_printWhoamiSummaryAndOfferTui: accepts gracefully when confirm throws (non-TTY)', async () => { + const brokenIO: WizardIO = { + prompt: vi.fn(async () => ''), + confirm: vi.fn(async () => { + throw new Error('stdin closed'); + }), + select: vi.fn(async () => 'x' as never), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + // Must not throw — swallows the error gracefully. + await expect(_printWhoamiSummaryAndOfferTui(brokenIO)).resolves.toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // 5. Interactive wizard path with StubWizardIO (realistic simulation) + // ------------------------------------------------------------------------- + + it('interactive wizard path — StubWizardIO drives full run end-to-end', async () => { + // Provide answer queues for interactive sections. + // The LLM section (interactive, API key path) needs: + // - provider select → 'anthropic' + // - auth mode select → 'api_key' + // - api key prompt → 'sk-ant-e2e-fake' + // - pool-seeding consent confirm → false + // The models-roles section (interactive, non-ollama) needs: + // - model select → first option + // - role pins confirm → false (decline advanced) + // The identity section (interactive) needs: + // - agent name prompt → 'e2e-wizard-agent' + // The sentient section (interactive) needs: + // - enable confirm → false + // The project-conventions section (interactive) needs: + // - strictness select → 'standard' + // - AC enforcement select → 'warn' + // - auto-start confirm → false + // The harness section (interactive) needs: + // - harness select → 'claude-code' + // The brain section (interactive) needs: + // - bridge-mode select → 'digest' + // The integrations section (interactive) needs: + // - signaldock confirm → false + // - studio confirm → false + // The telemetry section needs: + // - telemetry confirm → true + // The verification section runs without prompts (read-only). + // + // Most sections now have robust defaults; we supply answers beyond what + // a single section may consume to avoid StubWizardIO "queue exhausted" + // throws in sections that don't need them. + + const io = new StubWizardIO({ + prompts: [ + 'sk-ant-e2e-fake', // llm: api key + 'e2e-wizard-agent', // identity: agent name + ], + confirms: [ + false, // llm: pool seeding consent + false, // models-roles: pin roles? + false, // sentient: enable? + false, // project-conventions: session auto-start? + false, // integrations: signaldock? + false, // integrations: studio? + true, // telemetry: allow? + ], + selects: [ + 'anthropic', // llm: provider + 'api_key', // llm: auth mode + 'claude-opus-4-5', // models-roles: default model + 'standard', // project-conventions: strictness + 'warn', // project-conventions: AC enforcement + 'claude-code', // harness: harness type + 'digest', // brain: bridge mode + ], + }); + + const result = await runSetup({}, io); + + // All sections run. + expect(result.sectionsRun.length).toBeGreaterThanOrEqual(10); + // The result is either ok or has section-level failures — we primarily + // care that the wizard completes without throwing. + expect(typeof result.ok).toBe('boolean'); + // firstRunComplete is true iff ok (no section produced a failed: line). + expect(result.firstRunComplete).toBe(result.ok); + }); +}); + +// --------------------------------------------------------------------------- +// Fit-gated Ollama path (T11983 AC — RECOMMEND-NEVER-KILL) +// --------------------------------------------------------------------------- + +describe('models-roles — fit-gated Ollama model picker (T11983)', () => { + /** + * Build a realistic fit envelope for testing. + */ + function makeEnvelope(opts: { + totalRamGb: number; + noReason?: string; + recommendations?: Array<{ tag: string; fitTier: string; alreadyPulled: boolean }>; + ollamaRunning?: boolean; + }) { + return { + hardware: { + totalRamGb: opts.totalRamGb, + availableRamGb: opts.totalRamGb * 0.7, + vramTotalGb: null, + vramFreeGb: null, + vramMethod: 'none' as const, + }, + ollamaRunning: opts.ollamaRunning ?? false, + pulledModels: [], + recommendations: (opts.recommendations ?? []).map((r) => ({ + candidate: { + modelTag: r.tag, + displayName: r.tag, + family: 'gemma4' as const, + minRamGb: 4, + recommendedRamGb: 6, + minVramGb: 3, + recommendedVramGb: 5, + diskSizeGb: 7.2, + quantNote: 'Q4_K_M', + codeSpecialist: false, + contextLengthK: 128, + }, + score: 80, + alreadyPulled: r.alreadyPulled, + reasons: ['fits RAM'], + fitTier: r.fitTier as 'excellent' | 'good' | 'marginal', + pullCommand: `ollama pull ${r.tag}`, + })), + noRecommendationReason: opts.noReason ?? null, + }; + } + + it('below 4 GB floor → warns about cloud-only, returns null', async () => { + const stubbedRanker = async () => + makeEnvelope({ + totalRamGb: 2, + noReason: + 'This machine has only 2.0 GB RAM. Local LLM inference requires at least 4 GB. Use a cloud provider instead.', + }); + + const io = new CapturingIO(); + const result = await _pickOllamaModelInteractive(io, stubbedRanker); + + expect(result).toBeNull(); + const warnText = io.warns.join('\n'); + expect(warnText).toMatch(/cloud provider/i); + expect(warnText).toMatch(/4 GB/i); + }); + + it('above floor with recommendations → presents ranked list, user picks first', async () => { + const stubbedRanker = async () => + makeEnvelope({ + totalRamGb: 16, + recommendations: [ + { tag: 'gemma4:e4b', fitTier: 'excellent', alreadyPulled: false }, + { tag: 'gemma4:e2b', fitTier: 'good', alreadyPulled: true }, + ], + }); + + // select queue: pick the first recommended model (the one with rank 1). + const io = new CapturingIO({ + selects: ['gemma4:e4b (excellent)'], + }); + const result = await _pickOllamaModelInteractive(io, stubbedRanker); + + expect(result).toBe('gemma4:e4b'); + }); + + it('already-pulled model is shown with [pulled] tag', async () => { + const stubbedRanker = async () => + makeEnvelope({ + totalRamGb: 16, + recommendations: [{ tag: 'gemma4:e2b', fitTier: 'good', alreadyPulled: true }], + }); + + // Use CapturingIO which defaults to first option. + const io = new CapturingIO(); + await _pickOllamaModelInteractive(io, stubbedRanker); + + const allOutput = io.infos.join('\n'); + // The select call should include "[pulled]" annotation. + expect(allOutput).toMatch(/\[pulled\]/i); + }); + + it('user selects skip → returns null', async () => { + const stubbedRanker = async () => + makeEnvelope({ + totalRamGb: 16, + recommendations: [{ tag: 'gemma4:e2b', fitTier: 'good', alreadyPulled: false }], + }); + + const io = new CapturingIO({ selects: ['(skip)'] }); + const result = await _pickOllamaModelInteractive(io, stubbedRanker); + expect(result).toBeNull(); + }); + + it('user selects manual entry → returns typed tag', async () => { + const stubbedRanker = async () => + makeEnvelope({ + totalRamGb: 16, + recommendations: [{ tag: 'gemma4:e2b', fitTier: 'good', alreadyPulled: false }], + }); + + const io = new CapturingIO({ + selects: ['(enter manually)'], + prompts: ['llama3.2:3b'], + }); + const result = await _pickOllamaModelInteractive(io, stubbedRanker); + expect(result).toBe('llama3.2:3b'); + }); + + it('ranker failure → falls back to free-text prompt, empty answer returns null', async () => { + const brokenRanker = async (): Promise => { + throw new Error('nvidia-smi not found'); + }; + + const io = new CapturingIO({ prompts: [''] }); + const result = await _pickOllamaModelInteractive(io, brokenRanker); + expect(result).toBeNull(); + + const warnText = io.warns.join('\n'); + expect(warnText).toMatch(/hardware detection failed/i); + }); + + it('ranker failure → falls back to free-text, entered tag returned', async () => { + const brokenRanker = async (): Promise => { + throw new Error('nvidia-smi not found'); + }; + + const io = new CapturingIO({ prompts: ['qwen3:4b'] }); + const result = await _pickOllamaModelInteractive(io, brokenRanker); + expect(result).toBe('qwen3:4b'); + }); + + it('no candidates fit RAM → warns, accepts manual entry', async () => { + const stubbedRanker = async () => + makeEnvelope({ + totalRamGb: 16, + recommendations: [], // empty: no candidates fit this (unusual) scenario + noReason: null as unknown as string, // explicitly null means no floor reason + }); + + const io = new CapturingIO({ prompts: ['phi4-mini:3.8b'] }); + const result = await _pickOllamaModelInteractive(io, stubbedRanker); + expect(result).toBe('phi4-mini:3.8b'); + }); +}); + +// --------------------------------------------------------------------------- +// CleoSetupResult.firstRunComplete propagation +// --------------------------------------------------------------------------- + +describe('CleoSetupResult.firstRunComplete (T11983)', () => { + it('full non-interactive pass: firstRunComplete=true when no section fails', async () => { + const io = new StubWizardIO(); + const result = await runSetup( + { + 'non-interactive': true, + provider: 'anthropic', + 'api-key': 'sk-ant-propagation-test', + 'default-model': 'claude-opus-4-5', + 'agent-name': 'prop-test-agent', + harness: 'claude-code', + 'brain-bridge-mode': 'digest', + sentient: 'off', + 'signaldock-enabled': false, + 'studio-enabled': false, + strictness: 'standard', + }, + io, + ); + + expect(result.firstRunComplete).toBe(true); + expect(result.ok).toBe(true); + }); + + it('single section run: firstRunComplete always false', async () => { + for (const section of ['llm', 'identity', 'verification', 'brain'] as const) { + const io = new StubWizardIO(); + const result = await runSetup({ section, 'non-interactive': true }, io); + expect(result.firstRunComplete).toBe(false); + expect(result.sectionsRun).toEqual([section]); + } + }); +}); diff --git a/packages/cleo/src/cli/commands/setup.ts b/packages/cleo/src/cli/commands/setup.ts index b07133b68..10eed0b61 100644 --- a/packages/cleo/src/cli/commands/setup.ts +++ b/packages/cleo/src/cli/commands/setup.ts @@ -54,6 +54,100 @@ import { ReadlineWizardIO, StdinClosedError } from '../lib/readline-wizard-io.js import { cliError, cliOutput } from '../renderers/index.js'; import { makeOAuthAcquirer } from './login.js'; +// --------------------------------------------------------------------------- +// Post-wizard helpers (T11983) +// --------------------------------------------------------------------------- + +/** + * Read a best-effort CLEO identity snapshot for the whoami-style summary. + * + * Never throws — returns partial data on any config/credential error. + * + * @internal + */ +async function _readWhoamiSnapshot(): Promise<{ + agentName: string; + provider: string; + model: string; + credentialCount: number; +}> { + try { + const { loadConfig } = await import('@cleocode/core/config'); + const { getCredentialPool } = await import('@cleocode/core/llm/credential-pool'); + const cfg = await loadConfig(); + const agentName = + typeof cfg?.identity?.name === 'string' && cfg.identity.name + ? cfg.identity.name + : 'cleo-agent'; + const provider = cfg?.llm?.default?.provider ?? ''; + const model = cfg?.llm?.default?.model ?? ''; + let credentialCount = 0; + try { + const pool = getCredentialPool(); + const entries = await pool.list(); + credentialCount = entries.length; + } catch { + // best-effort + } + return { agentName, provider, model, credentialCount }; + } catch { + return { agentName: 'cleo-agent', provider: '', model: '', credentialCount: 0 }; + } +} + +/** + * Print a whoami-style summary to stderr and offer to launch the TUI. + * + * Called after a successful first-run completion. Output goes to stderr only + * so the LAFS envelope already written to stdout is never corrupted. + * + * The TUI offer uses `io.confirm()` — if the user accepts, launches + * `cleo tui` via a child_process exec (non-blocking — the wizard process + * does not wait for TUI exit so the call is fire-and-forget). + * + * @param io - Wizard I/O surface (for the TUI offer prompt). + * + * @task T11983 + */ +export async function _printWhoamiSummaryAndOfferTui(io: WizardIO): Promise { + const snap = await _readWhoamiSnapshot(); + + const lines = [ + '', + '─────────────────────────────────────────', + 'CLEO Setup Complete', + '─────────────────────────────────────────', + ` Agent name : ${snap.agentName}`, + ` Provider : ${snap.provider || '(not set)'}`, + ` Model : ${snap.model || '(not set)'}`, + ` Credentials: ${snap.credentialCount} in pool`, + '', + "Run 'cleo whoami' for full identity details.", + "Run 'cleo llm health' to verify your credentials.", + '─────────────────────────────────────────', + '', + ]; + for (const line of lines) { + io.info(line); + } + + // Offer to launch the TUI — non-blocking (fire-and-forget spawn). + try { + const launch = await io.confirm('Launch the CLEO TUI now?', false); + if (launch) { + io.info("Launching 'cleo tui'…"); + const { spawn } = await import('node:child_process'); + // Detach so the wizard process exits cleanly regardless of TUI lifetime. + spawn('cleo', ['tui'], { + stdio: 'inherit', + detached: false, + }); + } + } catch { + // If the prompt fails (non-TTY or stdin closed), skip silently. + } +} + // --------------------------------------------------------------------------- // Public types — exported so the Studio `/setup` route (T-E3-8) can reuse // the section-name union without re-deriving it from the core wizard. @@ -78,6 +172,7 @@ export type CleoSetupSection = WizardSection; * `@cleocode/core` directly. * * @task T9421 + * @task T11983 */ export interface CleoSetupResult { /** Section ids that actually executed (in order). */ @@ -86,6 +181,16 @@ export interface CleoSetupResult { summary: string[]; /** `true` if every section reported success (no `failed:` summary). */ ok: boolean; + /** + * `true` when all sections completed successfully and the first-run + * completion marker was written (mirrors {@link WizardRunResult.firstRunComplete}). + * + * `false` for single-section runs (`--section`) and whenever any section + * produced a `failed:` summary line. + * + * @task T11983 + */ + firstRunComplete: boolean; } // --------------------------------------------------------------------------- @@ -315,6 +420,7 @@ export async function runSetup( sectionsRun: runResult.sectionsRun, summary: runResult.summary, ok: isOk(runResult), + firstRunComplete: runResult.firstRunComplete, }; } @@ -477,6 +583,9 @@ export const setupCommand = defineCommand({ io.close(); } + // --- Post-wizard: whoami-style summary + TUI offer (T11983) ------------ + // Emit the LAFS envelope FIRST (before the human-readable footer) so + // JSON consumers reading stdout get a clean single envelope. cliOutput(result, { command: 'setup', operation: 'setup.run', @@ -485,6 +594,13 @@ export const setupCommand = defineCommand({ : `Setup finished with errors (${result.sectionsRun.length} section(s)).`, }); + // After successful first-run completion, print a whoami-style summary and + // offer to launch the TUI. Output goes to stderr so it never corrupts the + // LAFS envelope already written to stdout. + if (result.ok && result.firstRunComplete) { + await _printWhoamiSummaryAndOfferTui(io); + } + if (!result.ok) { process.exit(1); } diff --git a/packages/core/src/setup/sections/models-roles.ts b/packages/core/src/setup/sections/models-roles.ts index 3105bdd3b..2e7a97a9d 100644 --- a/packages/core/src/setup/sections/models-roles.ts +++ b/packages/core/src/setup/sections/models-roles.ts @@ -38,6 +38,8 @@ import { listProviderModels, resolveProviderDefaultModel, } from '../../llm/catalog-model-resolver.js'; +import type { LocalModelFitEnvelope } from '../../llm/local-model-fit.js'; +import { LOCAL_FIT_FLOOR_GB } from '../../llm/local-model-fit.js'; import type { WizardIO, WizardOptions, WizardSectionRunner } from '../wizard.js'; /** @@ -173,34 +175,47 @@ export function createModelsRolesSection(): WizardSectionRunner { } // 1. Default model selection. - const modelChoices = modelChoicesForProvider(defaultProvider); - if (modelChoices.length > 1) { - const picked = await io.select( - `Default model for ${defaultProvider}?`, - modelChoices as readonly string[], - ); - if (picked !== SKIP_CHOICE) { - await setConfigValue('llm.default.model', picked, undefined, { global: true }); - await setConfigValue('llm.default.provider', defaultProvider, undefined, { - global: true, - }); - changes.push(`default model → ${picked}`); + // For local (ollama) providers: use fit-gated recommendations (T11983 AC). + // RECOMMEND-NEVER-KILL: never auto-pull, never auto-select; present ranked + // options from `rankLocalModelFit`; machines below the 4 GB floor get + // cloud-only guidance. + if (defaultProvider === 'ollama') { + const modelPicked = await _pickOllamaModelInteractive(io); + if (modelPicked) { + await setConfigValue('llm.default.model', modelPicked, undefined, { global: true }); + await setConfigValue('llm.default.provider', 'ollama', undefined, { global: true }); + changes.push(`default model → ${modelPicked}`); } } else { - // Catalog empty — fall back to the resolver's latest, or a free prompt. - const latest = resolveProviderDefaultModel(catalogKeyForProvider(defaultProvider)); - const typed = ( - await io.prompt( - `Default model for ${defaultProvider}${latest ? ` [${latest}]` : ''} (blank to skip):`, - ) - ).trim(); - const model = typed || latest || ''; - if (model) { - await setConfigValue('llm.default.model', model, undefined, { global: true }); - await setConfigValue('llm.default.provider', defaultProvider, undefined, { - global: true, - }); - changes.push(`default model → ${model}`); + const modelChoices = modelChoicesForProvider(defaultProvider); + if (modelChoices.length > 1) { + const picked = await io.select( + `Default model for ${defaultProvider}?`, + modelChoices as readonly string[], + ); + if (picked !== SKIP_CHOICE) { + await setConfigValue('llm.default.model', picked, undefined, { global: true }); + await setConfigValue('llm.default.provider', defaultProvider, undefined, { + global: true, + }); + changes.push(`default model → ${picked}`); + } + } else { + // Catalog empty — fall back to the resolver's latest, or a free prompt. + const latest = resolveProviderDefaultModel(catalogKeyForProvider(defaultProvider)); + const typed = ( + await io.prompt( + `Default model for ${defaultProvider}${latest ? ` [${latest}]` : ''} (blank to skip):`, + ) + ).trim(); + const model = typed || latest || ''; + if (model) { + await setConfigValue('llm.default.model', model, undefined, { global: true }); + await setConfigValue('llm.default.provider', defaultProvider, undefined, { + global: true, + }); + changes.push(`default model → ${model}`); + } } } @@ -257,3 +272,119 @@ async function writeRoleBinding( }); } } + +// --------------------------------------------------------------------------- +// Fit-gated local (Ollama) model picker (T11983 AC) +// --------------------------------------------------------------------------- + +/** + * Injectable dependency for the Ollama model picker — lets tests stub out + * `rankLocalModelFit` without reaching into the real OS/network layer. + * + * @internal + */ +export type LocalModelFitRanker = () => Promise; + +/** + * The default ranker used in production: calls the real `rankLocalModelFit`. + * + * Lazy import keeps this module free of heavy OS-level side effects at parse + * time (Gate-13 compliance: no transport construction at import). + * + * @internal + */ +async function defaultLocalModelFitRanker(): Promise { + const { rankLocalModelFit } = await import('../../llm/local-model-fit.js'); + return rankLocalModelFit(); +} + +/** + * Interactive Ollama model picker driven by fit-gated recommendations (T11983). + * + * Behaviour: + * - Calls `rankLocalModelFit` to detect hardware + Ollama state. + * - Below the 4 GB floor → informs the user (cloud-only guidance) and returns + * `null` (caller skips the binding). + * - Above the floor with recommendations → presents a ranked pick list of + * 2–3 candidates (never auto-selects, never auto-pulls — RECOMMEND-NEVER-KILL). + * - Already-pulled models are surfaced first with a `[pulled]` tag. + * - Free-text fallback offered as the last option so the user can type any tag. + * - Returns the chosen model tag, or `null` if the user skipped. + * + * The ranker is injectable so tests can supply a stub envelope without + * hitting the OS or network. + * + * @param io - Wizard I/O surface. + * @param ranker - Override the fit ranker for tests (defaults to the real one). + * @returns The chosen Ollama model tag, or `null` when the user skipped / below floor. + * + * @task T11983 + */ +export async function _pickOllamaModelInteractive( + io: WizardIO, + ranker: LocalModelFitRanker = defaultLocalModelFitRanker, +): Promise { + io.info('Detecting local hardware for Ollama model fit ranking…'); + + let fitEnvelope: LocalModelFitEnvelope; + try { + fitEnvelope = await ranker(); + } catch (err) { + io.warn( + `Hardware detection failed: ${err instanceof Error ? err.message : String(err)}. ` + + 'Falling back to manual model entry.', + ); + const typed = ( + await io.prompt('Enter Ollama model tag (e.g. gemma4:e2b) or blank to skip:') + ).trim(); + return typed || null; + } + + const hw = fitEnvelope.hardware; + io.info( + ` Hardware: ${hw.totalRamGb.toFixed(1)} GB RAM, ` + + (hw.vramTotalGb !== null ? `${hw.vramTotalGb.toFixed(1)} GB VRAM` : 'no GPU detected') + + (fitEnvelope.ollamaRunning ? ', Ollama running' : ', Ollama not running'), + ); + + // Below floor: guide to cloud-only. + if (fitEnvelope.noRecommendationReason) { + io.warn( + `\nLocal model recommendation not available: ${fitEnvelope.noRecommendationReason}\n` + + `This machine has ${hw.totalRamGb.toFixed(1)} GB RAM — local LLM inference requires at least ${LOCAL_FIT_FLOOR_GB} GB.\n` + + 'Please use a cloud provider (anthropic, openai, gemini, etc.) instead.', + ); + return null; + } + + // Build choice list from fit recommendations. + const recs = fitEnvelope.recommendations; + if (recs.length === 0) { + io.warn('No local model candidates fit this machine. Consider a cloud provider.'); + const typed = (await io.prompt('Enter Ollama model tag manually (blank to skip):')).trim(); + return typed || null; + } + + io.info('\nRecommended local models (ranked by hardware fit):'); + const choices: string[] = recs.map((r) => { + const tag = r.candidate.modelTag; + const pulled = r.alreadyPulled ? ' [pulled]' : ''; + return `${tag}${pulled} (${r.fitTier})`; + }); + // Always offer a manual-entry option so the user is never locked in. + choices.push('(enter manually)'); + choices.push(SKIP_CHOICE); + + const picked = await io.select('Choose a local model for Ollama:', choices as readonly string[]); + + if (picked === SKIP_CHOICE) return null; + + if (picked === '(enter manually)') { + const typed = (await io.prompt('Enter Ollama model tag (e.g. gemma4:e2b):')).trim(); + return typed || null; + } + + // Strip the suffix annotation to get the raw model tag. + const rawTag = picked.split(' ')[0] ?? picked; + return rawTag; +} From 9a723801e95daff62a14f0daf725d763b6d508f4 Mon Sep 17 00:00:00 2001 From: kryptobaseddev Date: Thu, 11 Jun 2026 23:55:57 -0700 Subject: [PATCH 2/3] fix(T11983): use getConfigValue('agent.name') instead of cfg.identity.name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleoConfig has no `identity` field — agent name is stored under the `agent.name` config key. Switch _readWhoamiSnapshot to getConfigValue and update the test mock to return the keyed value. Co-Authored-By: Claude Fable 5 --- .../src/cli/commands/__tests__/setup-wizard-e2e.test.ts | 4 +++- packages/cleo/src/cli/commands/setup.ts | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts b/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts index aed864f6d..85aa94161 100644 --- a/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts @@ -50,7 +50,9 @@ vi.mock('@cleocode/core/config', async (importOriginal) => { ...actual, setConfigValue: (...a: unknown[]) => mockSetConfigValue(...(a as [])), loadConfig: () => mockLoadConfigCore(), - getConfigValue: vi.fn(async () => ({ value: undefined })), + getConfigValue: vi.fn(async (key: string) => ({ + value: key === 'agent.name' ? 'test-agent' : undefined, + })), }; }); diff --git a/packages/cleo/src/cli/commands/setup.ts b/packages/cleo/src/cli/commands/setup.ts index 10eed0b61..018dae343 100644 --- a/packages/cleo/src/cli/commands/setup.ts +++ b/packages/cleo/src/cli/commands/setup.ts @@ -72,13 +72,12 @@ async function _readWhoamiSnapshot(): Promise<{ credentialCount: number; }> { try { - const { loadConfig } = await import('@cleocode/core/config'); + const { loadConfig, getConfigValue } = await import('@cleocode/core/config'); const { getCredentialPool } = await import('@cleocode/core/llm/credential-pool'); const cfg = await loadConfig(); + const nameResult = await getConfigValue('agent.name').catch(() => null); const agentName = - typeof cfg?.identity?.name === 'string' && cfg.identity.name - ? cfg.identity.name - : 'cleo-agent'; + typeof nameResult?.value === 'string' && nameResult.value ? nameResult.value : 'cleo-agent'; const provider = cfg?.llm?.default?.provider ?? ''; const model = cfg?.llm?.default?.model ?? ''; let credentialCount = 0; From 1ed669edeab19f90dcd1b2494a6ef1a4b43054bd Mon Sep 17 00:00:00 2001 From: kryptobaseddev Date: Fri, 12 Jun 2026 00:35:26 -0700 Subject: [PATCH 3/3] fix(T11983): relocate whoami summary helper to core (CLI boundary lint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `_printWhoamiSummaryAndOfferTui` (38 LOC) from `packages/cleo/src/cli/commands/setup.ts` into a new `packages/core/src/setup/whoami-summary.ts` module and export it through `packages/core/src/setup/index.ts` as `printWhoamiSummaryAndOfferTui`. Import it back in the CLI command and update the e2e test to import from `@cleocode/core/setup` instead of `../setup.js`. Lint: `node scripts/lint-cli-package-boundary.mjs --check` → PASS 33/33 (baseline, no net-new). Tests: setup-wizard-e2e.test.ts + setup-command.test.ts → 61 passed. Co-Authored-By: Claude Fable 5 --- .../__tests__/setup-wizard-e2e.test.ts | 11 +- packages/cleo/src/cli/commands/setup.ts | 96 +-------------- packages/core/src/setup/index.ts | 1 + packages/core/src/setup/whoami-summary.ts | 116 ++++++++++++++++++ 4 files changed, 125 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/setup/whoami-summary.ts diff --git a/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts b/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts index 85aa94161..acba5126b 100644 --- a/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/setup-wizard-e2e.test.ts @@ -190,8 +190,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { // Imports (after mocks) // --------------------------------------------------------------------------- +import { printWhoamiSummaryAndOfferTui } from '@cleocode/core/setup'; import { _pickOllamaModelInteractive } from '@cleocode/core/setup/sections/models-roles'; -import { _printWhoamiSummaryAndOfferTui, runSetup } from '../setup.js'; +import { runSetup } from '../setup.js'; // --------------------------------------------------------------------------- // Helpers @@ -369,9 +370,9 @@ describe('cleo setup — e2e TTY-simulated (T11983)', () => { // 4. whoami summary + TUI offer printed on first-run completion // ------------------------------------------------------------------------- - it('_printWhoamiSummaryAndOfferTui prints identity + provider + model, TUI declined', async () => { + it('printWhoamiSummaryAndOfferTui prints identity + provider + model, TUI declined', async () => { const io = new CapturingIO({ confirms: [false] }); - await _printWhoamiSummaryAndOfferTui(io); + await printWhoamiSummaryAndOfferTui(io); const allOutput = [...io.infos, ...io.warns].join('\n'); @@ -391,7 +392,7 @@ describe('cleo setup — e2e TTY-simulated (T11983)', () => { expect(allOutput).toMatch(/cleo whoami/); }); - it('_printWhoamiSummaryAndOfferTui: accepts gracefully when confirm throws (non-TTY)', async () => { + it('printWhoamiSummaryAndOfferTui: accepts gracefully when confirm throws (non-TTY)', async () => { const brokenIO: WizardIO = { prompt: vi.fn(async () => ''), confirm: vi.fn(async () => { @@ -403,7 +404,7 @@ describe('cleo setup — e2e TTY-simulated (T11983)', () => { error: vi.fn(), }; // Must not throw — swallows the error gracefully. - await expect(_printWhoamiSummaryAndOfferTui(brokenIO)).resolves.toBeUndefined(); + await expect(printWhoamiSummaryAndOfferTui(brokenIO)).resolves.toBeUndefined(); }); // ------------------------------------------------------------------------- diff --git a/packages/cleo/src/cli/commands/setup.ts b/packages/cleo/src/cli/commands/setup.ts index 018dae343..0c0e59bf0 100644 --- a/packages/cleo/src/cli/commands/setup.ts +++ b/packages/cleo/src/cli/commands/setup.ts @@ -47,6 +47,7 @@ import type { import { createDefaultWizardRunner, mergeConfigJson, + printWhoamiSummaryAndOfferTui, WizardInterruptError, } from '@cleocode/core/setup'; import { defineCommand } from 'citty'; @@ -54,99 +55,6 @@ import { ReadlineWizardIO, StdinClosedError } from '../lib/readline-wizard-io.js import { cliError, cliOutput } from '../renderers/index.js'; import { makeOAuthAcquirer } from './login.js'; -// --------------------------------------------------------------------------- -// Post-wizard helpers (T11983) -// --------------------------------------------------------------------------- - -/** - * Read a best-effort CLEO identity snapshot for the whoami-style summary. - * - * Never throws — returns partial data on any config/credential error. - * - * @internal - */ -async function _readWhoamiSnapshot(): Promise<{ - agentName: string; - provider: string; - model: string; - credentialCount: number; -}> { - try { - const { loadConfig, getConfigValue } = await import('@cleocode/core/config'); - const { getCredentialPool } = await import('@cleocode/core/llm/credential-pool'); - const cfg = await loadConfig(); - const nameResult = await getConfigValue('agent.name').catch(() => null); - const agentName = - typeof nameResult?.value === 'string' && nameResult.value ? nameResult.value : 'cleo-agent'; - const provider = cfg?.llm?.default?.provider ?? ''; - const model = cfg?.llm?.default?.model ?? ''; - let credentialCount = 0; - try { - const pool = getCredentialPool(); - const entries = await pool.list(); - credentialCount = entries.length; - } catch { - // best-effort - } - return { agentName, provider, model, credentialCount }; - } catch { - return { agentName: 'cleo-agent', provider: '', model: '', credentialCount: 0 }; - } -} - -/** - * Print a whoami-style summary to stderr and offer to launch the TUI. - * - * Called after a successful first-run completion. Output goes to stderr only - * so the LAFS envelope already written to stdout is never corrupted. - * - * The TUI offer uses `io.confirm()` — if the user accepts, launches - * `cleo tui` via a child_process exec (non-blocking — the wizard process - * does not wait for TUI exit so the call is fire-and-forget). - * - * @param io - Wizard I/O surface (for the TUI offer prompt). - * - * @task T11983 - */ -export async function _printWhoamiSummaryAndOfferTui(io: WizardIO): Promise { - const snap = await _readWhoamiSnapshot(); - - const lines = [ - '', - '─────────────────────────────────────────', - 'CLEO Setup Complete', - '─────────────────────────────────────────', - ` Agent name : ${snap.agentName}`, - ` Provider : ${snap.provider || '(not set)'}`, - ` Model : ${snap.model || '(not set)'}`, - ` Credentials: ${snap.credentialCount} in pool`, - '', - "Run 'cleo whoami' for full identity details.", - "Run 'cleo llm health' to verify your credentials.", - '─────────────────────────────────────────', - '', - ]; - for (const line of lines) { - io.info(line); - } - - // Offer to launch the TUI — non-blocking (fire-and-forget spawn). - try { - const launch = await io.confirm('Launch the CLEO TUI now?', false); - if (launch) { - io.info("Launching 'cleo tui'…"); - const { spawn } = await import('node:child_process'); - // Detach so the wizard process exits cleanly regardless of TUI lifetime. - spawn('cleo', ['tui'], { - stdio: 'inherit', - detached: false, - }); - } - } catch { - // If the prompt fails (non-TTY or stdin closed), skip silently. - } -} - // --------------------------------------------------------------------------- // Public types — exported so the Studio `/setup` route (T-E3-8) can reuse // the section-name union without re-deriving it from the core wizard. @@ -597,7 +505,7 @@ export const setupCommand = defineCommand({ // offer to launch the TUI. Output goes to stderr so it never corrupts the // LAFS envelope already written to stdout. if (result.ok && result.firstRunComplete) { - await _printWhoamiSummaryAndOfferTui(io); + await printWhoamiSummaryAndOfferTui(io); } if (!result.ok) { diff --git a/packages/core/src/setup/index.ts b/packages/core/src/setup/index.ts index 55b4fe984..b1f14b4e9 100644 --- a/packages/core/src/setup/index.ts +++ b/packages/core/src/setup/index.ts @@ -42,6 +42,7 @@ export { createVerificationSection, type VerificationCheck, } from './sections/verification.js'; +export { printWhoamiSummaryAndOfferTui } from './whoami-summary.js'; export { StubWizardIO, WizardFatalError, diff --git a/packages/core/src/setup/whoami-summary.ts b/packages/core/src/setup/whoami-summary.ts new file mode 100644 index 000000000..c5abc53b0 --- /dev/null +++ b/packages/core/src/setup/whoami-summary.ts @@ -0,0 +1,116 @@ +/** + * Post-wizard whoami summary + TUI-offer helper (T11983). + * + * Called after a successful first-run setup completion. Prints a brief + * identity snapshot to the wizard's `io` surface and offers to launch the + * CLEO TUI. + * + * Lives in `packages/core/src/setup/` alongside the other wizard section + * modules so the CLI command (`packages/cleo/src/cli/commands/setup.ts`) + * can import it without violating the CLI-boundary gate (ADR Gate 6 / + * T9837e / T10076). + * + * @module setup/whoami-summary + * @task T11983 + */ + +import type { WizardIO } from './wizard.js'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Read a best-effort CLEO identity snapshot for the whoami-style summary. + * + * Never throws — returns partial data on any config/credential error. + * + * @internal + */ +async function _readWhoamiSnapshot(): Promise<{ + agentName: string; + provider: string; + model: string; + credentialCount: number; +}> { + try { + const { loadConfig, getConfigValue } = await import('../config.js'); + const { getCredentialPool } = await import('../llm/credential-pool.js'); + const cfg = await loadConfig(); + const nameResult = await getConfigValue('agent.name').catch(() => null); + const agentName = + typeof nameResult?.value === 'string' && nameResult.value ? nameResult.value : 'cleo-agent'; + const provider = cfg?.llm?.default?.provider ?? ''; + const model = cfg?.llm?.default?.model ?? ''; + let credentialCount = 0; + try { + const pool = getCredentialPool(); + const entries = await pool.list(); + credentialCount = entries.length; + } catch { + // best-effort + } + return { agentName, provider, model, credentialCount }; + } catch { + return { agentName: 'cleo-agent', provider: '', model: '', credentialCount: 0 }; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Print a whoami-style summary to the wizard IO surface and offer to launch + * the TUI. + * + * Called after a successful first-run completion. Output goes to `io.info()` + * (which routes to stderr in the CLI) so the LAFS envelope already written to + * stdout is never corrupted. + * + * The TUI offer uses `io.confirm()` — if the user accepts, launches + * `cleo tui` via a child_process spawn (non-blocking fire-and-forget so the + * wizard process exits cleanly regardless of TUI lifetime). + * + * @param io - Wizard I/O surface (for the TUI offer prompt). + * + * @task T11983 + */ +export async function printWhoamiSummaryAndOfferTui(io: WizardIO): Promise { + const snap = await _readWhoamiSnapshot(); + + const lines = [ + '', + '─────────────────────────────────────────', + 'CLEO Setup Complete', + '─────────────────────────────────────────', + ` Agent name : ${snap.agentName}`, + ` Provider : ${snap.provider || '(not set)'}`, + ` Model : ${snap.model || '(not set)'}`, + ` Credentials: ${snap.credentialCount} in pool`, + '', + "Run 'cleo whoami' for full identity details.", + "Run 'cleo llm health' to verify your credentials.", + '─────────────────────────────────────────', + '', + ]; + for (const line of lines) { + io.info(line); + } + + // Offer to launch the TUI — non-blocking (fire-and-forget spawn). + try { + const launch = await io.confirm('Launch the CLEO TUI now?', false); + if (launch) { + io.info("Launching 'cleo tui'…"); + const { spawn } = await import('node:child_process'); + // Detach so the wizard process exits cleanly regardless of TUI lifetime. + spawn('cleo', ['tui'], { + stdio: 'inherit', + detached: false, + }); + } + } catch { + // If the prompt fails (non-TTY or stdin closed), skip silently. + } +}