From 8f0c35e92438a54b59c088d5cca5c4fdf2419797 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 21 Aug 2026 23:11:55 +0200 Subject: [PATCH] feat(hub): guide starter setup (#3651) * feat(hub): guide setup with provider IDs * fix(hub): preserve login setup state * feat(hub): select starter agent runtime Use the connected daemon's provider snapshot so generated Hub bundles only contain complete provider, model, and mode combinations. * fix(hub): stage starter runtime choices Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime. * fix(hub): scope runtime choices to workspace Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy. --- packages/cli/src/commands/hub/client.test.ts | 46 +++ .../cli/src/commands/hub/commands.test.ts | 54 +++ .../cli/src/commands/hub/daemon-client.ts | 8 + .../cli/src/commands/hub/hub-client/index.ts | 15 + .../hub/hub-client/internal/contracts.ts | 20 + packages/cli/src/commands/hub/index.ts | 4 +- .../cli/src/commands/hub/init-flow.test.ts | 364 ++++++++++++++++++ .../cli/src/commands/hub/init-plan.test.ts | 36 +- packages/cli/src/commands/hub/init-plan.ts | 15 +- packages/cli/src/commands/hub/init.ts | 333 ++++++++++++---- packages/cli/src/commands/hub/login.ts | 9 + .../hub/starter-agent-runtime.test.ts | 121 ++++++ .../src/commands/hub/starter-agent-runtime.ts | 75 ++++ 13 files changed, 1027 insertions(+), 73 deletions(-) create mode 100644 packages/cli/src/commands/hub/init-flow.test.ts create mode 100644 packages/cli/src/commands/hub/starter-agent-runtime.test.ts create mode 100644 packages/cli/src/commands/hub/starter-agent-runtime.ts diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index f4948b320..8c777fc1c 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -120,6 +120,52 @@ describe("Hub HTTP client", () => { assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); }); + it("reads strict provider-native setup resources", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body: { + github: [ + { + slug: "getpaseo", + accountLogin: "getpaseo", + accountType: "Organization", + repositories: ["getpaseo/paseo"], + }, + ], + discord: [{ guildId: "guild-123", guildName: "Paseo" }], + slack: [{ teamId: "team-123", teamName: "Paseo" }], + }, + }), + requests, + ); + + const resources = await new HubHttpClient().listSetupResources(origin, "secret"); + + assert.equal(resources.slack[0]?.teamId, "team-123"); + assert.equal(resources.discord[0]?.guildId, "guild-123"); + assert.deepEqual(requests[0], { url: "/api/v1/setup-resources", body: "" }); + }); + + it.each([ + { github: [], discord: [], slack: [{ teamId: "team-123", teamName: "Paseo", slug: "wrong" }] }, + { github: [], discord: [], slack: [{ teamId: 123, teamName: "Paseo" }] }, + ])("rejects malformed or unknown setup resource fields", async (body) => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body, + }), + requests, + ); + + await assert.rejects(new HubHttpClient().listSetupResources(origin, "secret"), { + code: "HUB_INVALID_RESPONSE", + }); + }); + it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index 0857bd197..4aefde042 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -91,6 +91,56 @@ describe("Hub commands", () => { assert.equal(result.data.origin, "https://hub.paseo.sh"); }); + it("interactive login continues through the injected guided setup coordinator without invoking a CLI command", async () => { + const credentials = new MemoryCredentials(); + const events: string[] = []; + + await runHubLogin( + "https://hub.test", + {}, + { + env: {}, + credentials, + flow: { + authorize: async () => { + events.push("login"); + return "paseo_cli_prefix_durable-secret"; + }, + }, + isInteractive: () => true, + continueGuidedSetup: async (origin) => { + events.push(`connect:${origin}`); + events.push("init"); + events.push("deploy"); + }, + reporter: quietReporter, + }, + ); + + assert.deepEqual(events, ["login", "connect:https://hub.test", "init", "deploy"]); + }); + + it("JSON and noninteractive login remain login-only", async () => { + for (const [options, interactive] of [ + [{ json: true }, true], + [{}, false], + ] as const) { + const credentials = new MemoryCredentials(); + let continuationCount = 0; + await runHubLogin(undefined, options, { + env: {}, + credentials, + flow: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + isInteractive: () => interactive, + continueGuidedSetup: async () => { + continuationCount += 1; + }, + reporter: quietReporter, + }); + assert.equal(continuationCount, 0); + } + }); + it("connect exchanges authority once and gives only the enrollment token to the daemon", async () => { const credentials = new MemoryCredentials(); credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" }); @@ -484,6 +534,10 @@ class FakeDaemon implements HubDaemonClient { return { status: hubStatus("connected", this.origin) }; } + async getProvidersSnapshot() { + return { entries: [] }; + } + async disconnectHub(force: boolean) { this.disconnects += 1; this.disconnectForces.push(force); diff --git a/packages/cli/src/commands/hub/daemon-client.ts b/packages/cli/src/commands/hub/daemon-client.ts index 88ced8918..c7eefbd60 100644 --- a/packages/cli/src/commands/hub/daemon-client.ts +++ b/packages/cli/src/commands/hub/daemon-client.ts @@ -1,4 +1,5 @@ import { connectToDaemon } from "../../utils/client.js"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; export interface HubStatus { state: string; @@ -9,10 +10,17 @@ export interface HubStatus { lastError: string | null; } +export interface HubProvidersSnapshotOptions { + cwd?: string; +} + export interface HubDaemonClient { connectHub(url: string, token: string): Promise<{ status: HubStatus }>; getHubStatus(): Promise<{ status: HubStatus }>; disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>; + getProvidersSnapshot( + options?: HubProvidersSnapshotOptions, + ): Promise<{ entries: ProviderSnapshotEntry[] }>; close(): Promise; } diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index 3086d648f..3f87ab3dc 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -12,6 +12,8 @@ import { type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, + setupResourcesSchema, + type HubSetupResources, type HubProject, type HubValidationResult, } from "./internal/contracts.js"; @@ -22,6 +24,7 @@ export type { CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, + HubSetupResources, HubProject, HubValidationResult, } from "./internal/contracts.js"; @@ -95,6 +98,18 @@ export class HubHttpClient { }); } + listSetupResources(origin: string, apiKey: string): Promise { + return requestHub({ + origin, + path: "/api/v1/setup-resources", + method: "GET", + apiKey, + successStatus: 200, + schema: setupResourcesSchema, + failureMessage: "Hub guided setup resource listing failed", + }); + } + installConfiguration(input: HubConfigurationInput): Promise { return requestHub({ origin: input.origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index 82b7e3ca5..2efa77aff 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -54,6 +54,25 @@ export const configurationResourcesSchema = z }) .strict(); +export const setupResourcesSchema = z + .object({ + github: z.array( + z + .object({ + slug: z.string().min(1), + accountLogin: z.string().min(1), + accountType: z.string().min(1), + repositories: z.array(z.string().min(1)), + }) + .strict(), + ), + discord: z.array( + z.object({ guildId: z.string().min(1), guildName: z.string().min(1) }).strict(), + ), + slack: z.array(z.object({ teamId: z.string().min(1), teamName: z.string().min(1) }).strict()), + }) + .strict(); + export const installResponseSchema = z .object({ projectSlug: z.string().min(1), @@ -75,5 +94,6 @@ export type CliAuthorization = z.infer; export type CliAuthorizationPoll = z.infer; export type HubProject = z.infer; export type HubConfigurationResources = z.infer; +export type HubSetupResources = z.infer; export type HubInstallResult = z.infer; export type HubValidationResult = z.infer; diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 3edd48ef3..d4921835d 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -18,7 +18,7 @@ import { addHubProjectsCommand } from "./projects.js"; import { processHubReporter, type HubReporter } from "./reporter.js"; import { hubStatusResult } from "./status-output.js"; import { addHubResolutionHelp } from "./help.js"; -import { addHubInitCommand } from "./init.js"; +import { addHubInitCommand, continueHubGuidedSetup } from "./init.js"; interface HubCommandEnvironment { env: Readonly>; @@ -56,6 +56,8 @@ export function createHubCommand(overrides: Partial = {}) credentials: environment.credentials, flow: environment.login, reporter: environment.reporter, + isInteractive: environment.isInteractive, + continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), }); addHubInitCommand(hub, environment); addHubConnectCommand(hub, { diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts new file mode 100644 index 000000000..4d02f8a49 --- /dev/null +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -0,0 +1,364 @@ +import { strict as assert } from "node:assert"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, it } from "vitest"; +import type { HubCredentialStore, StoredHubCredential } from "./credentials.js"; +import type { HubDaemonClient, HubStatus } from "./daemon-client.js"; +import type { HubHttpClient } from "./hub-client/index.js"; +import { + continueHubGuidedSetup, + runHubGuidedSetup, + type HubGuidedSetupEnvironment, +} from "./init.js"; +import { runHubLogin } from "./login.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("Hub guided setup continuation", () => { + it("uses the login origin and connected daemon to validate, write, and deploy without replaying setup prompts", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + const daemon = new SetupDaemon(); + const prompts = new PromptAnswers( + [true, true], + ["codex", "gpt-5", "full-access", "slack"], + ["U123"], + ); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; + const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls); + + await runHubLogin( + "https://hub.test", + {}, + { + env: {}, + credentials, + flow: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + isInteractive: () => true, + continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), + reporter: { progress() {} }, + }, + ); + + assert.deepEqual(prompts.confirmations, [ + "Connect this daemon to https://hub.test?", + "Initialize and deploy a starter workflow?", + ]); + assert.deepEqual(prompts.selections, [ + "Starter agent provider", + "Starter agent model", + "Starter agent mode", + "Trigger provider", + ]); + assert.deepEqual(prompts.selectionOptions, [ + ["Codex (suggested)"], + ["GPT-5 (suggested)", "GPT-5 mini"], + ["Read only", "Full access (suggested)"], + [ + "GitHub (issue or pull request comment)", + "Slack (channel mention)", + "Discord (channel mention)", + ], + ]); + assert.deepEqual(calls, [ + { operation: "token", origin: "https://hub.test" }, + { operation: "projects", origin: "https://hub.test" }, + { operation: "setup", origin: "https://hub.test" }, + { operation: "resources", origin: "https://hub.test" }, + { + operation: "validate", + origin: "https://hub.test", + files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"], + }, + { + operation: "install", + origin: "https://hub.test", + files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"], + }, + ]); + assert.equal(daemon.connections, 1); + assert.deepEqual(daemon.snapshotCwds, [cwd]); + const hub = await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"); + assert.match(hub, /daemon: macbook/u); + assert.match(hub, /provider: codex\n model: gpt-5\n mode: full-access/u); + }); + + it("prints exact actionable resume commands for login continuation declines", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon(); + const connectDeclined = new PromptAnswers([false], [], []); + + await continueHubGuidedSetup( + "https://hub.test", + setupEnvironment(cwd, credentials, daemon, connectDeclined, []), + ); + assert.deepEqual(connectDeclined.messages, [ + "Skipped daemon connection. Run: paseo hub connect https://hub.test; then paseo hub init", + ]); + + const initDeclined = new PromptAnswers([true, false], [], []); + await continueHubGuidedSetup( + "https://hub.test", + setupEnvironment(cwd, credentials, daemon, initDeclined, []), + ); + assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]); + }); + + it("keeps hub init's existing replacement confirmation", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + const prompts = new PromptAnswers([false], [], []); + + await assert.rejects( + runHubGuidedSetup( + setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []), + ), + /Existing .paseo\/ bundle left unchanged/u, + ); + assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]); + }); + + it("writes the explicitly selected Claude mode when the daemon has no default", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon([ + { + provider: "claude", + status: "ready", + enabled: true, + models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }], + modes: [{ id: "auto", label: "Auto" }], + defaultModeId: null, + }, + ]); + + const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto", "slack"], ["U123"]); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; + + await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }); + assert.deepEqual(prompts.selectionOptions.slice(0, 3), [ + ["claude"], + ["Sonnet (suggested)"], + ["Auto"], + ]); + assert.match( + await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), + /provider: claude\n model: sonnet\n mode: auto/u, + ); + assert.deepEqual( + calls.slice(-2).map(({ operation }) => operation), + ["validate", "install"], + ); + }); +}); + +function setupEnvironment( + cwd: string, + credentials: HubCredentialStore, + daemon: SetupDaemon, + prompts: PromptAnswers, + calls: Array<{ operation: string; origin: string; files?: readonly string[] }>, +): HubGuidedSetupEnvironment { + const hub = { + async issueEnrollmentToken(origin: string) { + calls.push({ operation: "token", origin }); + return "one-time-enrollment-token-with-enough-length"; + }, + async listProjects(origin: string) { + calls.push({ operation: "projects", origin }); + return [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "studio", name: "Studio" }]; + }, + async listSetupResources(origin: string) { + calls.push({ operation: "setup", origin }); + return { + github: [], + discord: [], + slack: [{ teamId: "T123", teamName: "Paseo" }], + }; + }, + async listConfigurationResources(origin: string) { + calls.push({ operation: "resources", origin }); + return { daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], slack: [] }; + }, + async validateConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + calls.push({ + operation: "validate", + origin: input.origin, + files: input.files.map(({ path: filePath }) => filePath), + }); + return { projectSlug: "studio", valid: true }; + }, + async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + calls.push({ + operation: "install", + origin: input.origin, + files: input.files.map(({ path: filePath }) => filePath), + }); + return { + projectSlug: "studio", + version: 1, + versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + active: true, + }; + }, + } as HubHttpClient; + return { + env: {}, + credentials, + hub, + login: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + daemon: { connect: async () => daemon }, + reporter: { progress() {} }, + cwd: () => cwd, + isInteractive: () => true, + prompts, + }; +} + +class PromptAnswers { + readonly confirmations: string[] = []; + readonly selections: string[] = []; + readonly selectionOptions: string[][] = []; + readonly messages: string[] = []; + + constructor( + private readonly confirmAnswers: boolean[], + private readonly selectAnswers: string[], + private readonly textAnswers: string[], + ) {} + + async confirm(message: string): Promise { + this.confirmations.push(message); + return this.confirmAnswers.shift() ?? false; + } + + async select(options: { + message: string; + options?: { label: string; hint?: string }[]; + }): Promise { + this.selections.push(options.message); + this.selectionOptions.push( + options.options?.map( + (option) => `${option.label}${option.hint ? ` (${option.hint})` : ""}`, + ) ?? [], + ); + return this.selectAnswers.shift() ?? ""; + } + + async text(): Promise { + return this.textAnswers.shift() ?? ""; + } + + message(value: string): void { + this.messages.push(value); + } +} + +class MemoryCredentials implements HubCredentialStore { + private record: StoredHubCredential | null = null; + + active(): StoredHubCredential | null { + return this.record; + } + + get(origin: string): StoredHubCredential | null { + return this.record?.origin === origin ? this.record : null; + } + + save(credential: StoredHubCredential): void { + this.record = credential; + } + + logoutActive(): StoredHubCredential | null { + const record = this.record; + this.record = null; + return record; + } +} + +class SetupDaemon implements HubDaemonClient { + connections = 0; + readonly snapshotCwds: Array = []; + private origin: string | null = null; + + constructor( + private readonly providerEntries = [ + { + provider: "codex", + status: "ready" as const, + enabled: true, + label: "Codex", + models: [ + { provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true }, + { provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" }, + ], + modes: [ + { id: "read-only", label: "Read only" }, + { id: "full-access", label: "Full access" }, + ], + defaultModeId: "full-access", + }, + ], + ) {} + + async connectHub(origin: string) { + this.connections += 1; + this.origin = origin; + return { status: status(origin) }; + } + + async getHubStatus() { + return { status: this.origin === null ? disconnectedStatus() : status(this.origin) }; + } + + async getProvidersSnapshot(options?: { cwd?: string }) { + this.snapshotCwds.push(options?.cwd); + return { entries: this.providerEntries }; + } + + async disconnectHub() { + return { status: disconnectedStatus() }; + } + + async close() {} +} + +function status(origin: string): HubStatus { + return { + state: "connected", + daemonId: "daemon-1", + hubOrigin: origin, + scopes: [], + connectedAt: null, + lastError: null, + }; +} + +function disconnectedStatus(): HubStatus { + return { + state: "not_connected", + daemonId: null, + hubOrigin: null, + scopes: [], + connectedAt: null, + lastError: null, + }; +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-flow-")); + directories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts index afc91a690..aa4ce9d24 100644 --- a/packages/cli/src/commands/hub/init-plan.test.ts +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -7,6 +7,7 @@ import { discoverHubBundle } from "./deploy-bundle.js"; import { runHubDeploy } from "./deploy.js"; import { createHubInitScaffold, + hubLoginResumeCommand, planHubInitOpening, resolveHubInitConnection, resolveHubInitProjects, @@ -26,6 +27,12 @@ afterEach(async () => { }); describe("Hub init planning", () => { + it("prints direct resumable commands for declined login continuations", () => { + expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe( + "paseo hub connect https://hub.test", + ); + expect(hubLoginResumeCommand("init", "https://hub.test")).toBe("paseo hub init"); + }); it("includes login only when there is no active login", () => { expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ replaceExisting: false, @@ -86,10 +93,25 @@ describe("Hub init planning", () => { }); describe("Hub init scaffold", () => { + it("omits mode for a provider that explicitly exposes none", () => { + const scaffold = createHubInitScaffold({ + cwd: "/workspace", + daemonSlug: "build-studio", + agent: { provider: "pi", model: "default" }, + provider: "slack", + providerFilters: { workspace: "T123456", user: "U123456" }, + }); + + expect(YAML.parse(scaffold.hub)).toMatchObject({ + agents: { starter: { provider: "pi", model: "default" } }, + }); + expect(YAML.parse(scaffold.hub).agents.starter.mode).toBeUndefined(); + }); + it.each([ ["github", { repo: "getpaseo/paseo", user: "boudra" }], - ["slack", { workspace: "paseo", user: "boudra" }], - ["discord", { guild: "paseo", user: "boudra" }], + ["slack", { workspace: "T123456", user: "U123456" }], + ["discord", { guild: "123456789", user: "987654321" }], ] satisfies readonly [HubInitProvider, Record][])( "creates a closed %s trigger that the deploy discovery path accepts", async (provider, providerFilters) => { @@ -97,6 +119,11 @@ describe("Hub init scaffold", () => { const scaffold = createHubInitScaffold({ cwd, daemonSlug: "build-studio", + agent: { + provider: "codex", + model: "gpt-5", + mode: "full-access", + }, provider, providerFilters, }); @@ -114,7 +141,12 @@ describe("Hub init scaffold", () => { filters: { from_users: string[]; channels?: string[] }; }; expect(parsed.filters.from_users).toEqual([providerFilters.user]); + if (provider === "slack") expect(parsed.filters.workspace).toBe("T123456"); + if (provider === "discord") expect(parsed.filters.guild).toBe("123456789"); expect(parsed.filters.channels).toBeUndefined(); + expect(YAML.parse(scaffold.hub)).toMatchObject({ + agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } }, + }); let validatedPaths: readonly string[] = []; const result = await runHubDeploy( diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts index 88e90de38..e2764ccbf 100644 --- a/packages/cli/src/commands/hub/init-plan.ts +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -3,6 +3,7 @@ import YAML from "yaml"; import type { HubDeployBundle } from "./deploy-bundle.js"; import type { HubProject } from "./hub-client/index.js"; import type { HubStatus } from "./daemon-client.js"; +import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js"; export type HubInitProvider = "github" | "slack" | "discord"; @@ -25,6 +26,7 @@ export interface HubInitOpeningPlan { export interface HubInitScaffoldInput { cwd: string; daemonSlug: string; + agent: HubStarterAgentRuntime; provider: HubInitProvider; providerFilters: Readonly>; } @@ -36,6 +38,10 @@ export interface HubInitScaffold { testAction: string; } +export function hubLoginResumeCommand(step: "connect" | "init", origin: string): string { + return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init"; +} + export function planHubInitOpening(input: { loggedIn: boolean; paseoDirectoryExists: boolean; @@ -97,9 +103,10 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff }, }, agents: { - codex: { - provider: "codex", - mode: "full-access", + starter: { + provider: input.agent.provider, + model: input.agent.model, + ...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }), }, }, }, @@ -180,7 +187,7 @@ function workflow(input: { environment: input.environment, max_runtime: "90m", idle_timeout: "10m", - agent: "codex", + agent: "starter", prompt: [ { text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index c86f79b4e..1900b5af0 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -21,28 +21,40 @@ import type { HubCredentialStore } from "./credentials.js"; import type { HubDaemonConnection } from "./daemon-client.js"; import { withHubDaemon } from "./daemon-client.js"; import { HubCommandError } from "./error.js"; -import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js"; +import type { + HubConfigurationResources, + HubHttpClient, + HubProject, + HubSetupResources, +} from "./hub-client/index.js"; import { createHubInitBundle, createHubInitScaffold, + hubLoginResumeCommand, planHubInitOpening, resolveHubInitConnection, resolveHubInitProjects, type HubInitProvider, } from "./init-plan.js"; import type { CliLoginFlow } from "./login-flow.js"; -import { runHubLogin } from "./login.js"; import { runHubConnect } from "./connect.js"; import { runHubDeployBundle } from "./deploy.js"; import { runHubProjects } from "./projects.js"; import type { HubReporter } from "./reporter.js"; import { normalizeHubOrigin } from "./origin.js"; +import { + availableStarterAgentProviders, + selectedStarterAgentRuntime, + suggestedStarterAgentChoice, + type HubStarterAgentProvider, + type HubStarterAgentRuntime, +} from "./starter-agent-runtime.js"; const execFileAsync = promisify(execFile); const DAEMON_READY_TIMEOUT_MS = 60_000; const DAEMON_READY_POLL_MS = 250; -interface HubInitEnvironment { +export interface HubGuidedSetupEnvironment { env: Readonly>; credentials: HubCredentialStore; hub: HubHttpClient; @@ -50,6 +62,19 @@ interface HubInitEnvironment { daemon: HubDaemonConnection; reporter: HubReporter; cwd(): string; + isInteractive?(): boolean; + prompts?: { + confirm(message: string, initialValue: boolean): Promise; + select(options: Parameters>[0]): Promise; + text(options: Parameters[0]): Promise; + message(value: string): void; + }; +} + +interface HubGuidedSetupState { + origin?: string; + daemonId?: string; + deploy?: boolean; } class HubInitCancelledError extends Error {} @@ -61,7 +86,7 @@ function initErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void { +export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { parent .command("init") .description("Create and optionally deploy a safe starter Hub bundle") @@ -79,8 +104,15 @@ export function addHubInitCommand(parent: Command, environment: HubInitEnvironme }); } -export async function runHubInit(environment: HubInitEnvironment): Promise { - requireInteractiveTerminal(); +export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise { + await runHubGuidedSetup(environment); +} + +export async function runHubGuidedSetup( + environment: HubGuidedSetupEnvironment, + state: HubGuidedSetupState = {}, +): Promise { + requireInteractiveTerminal(environment); intro("Set up Paseo Hub"); const cwd = environment.cwd(); @@ -91,28 +123,24 @@ export async function runHubInit(environment: HubInitEnvironment): Promise }); if ( opening.replaceExisting && - !(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false)) + !(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false)) ) { throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); } - const origin = await ensureLogin(activeLogin?.origin, environment); - const daemonId = await ensureDaemonConnection(origin, environment); + const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment)); + const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment)); const project = await chooseProject(origin, environment); - const resources = await loadConfigurationResources(origin, environment); - const daemon = resources.daemons.find(({ id }) => id === daemonId); - if (daemon === undefined) { - throw new HubCommandError( - "HUB_DAEMON_RESOURCE_MISSING", - "The connected daemon is not available in this Hub organization. Reconnect it and try again.", - ); - } + const resources = await loadSetupResources(origin, environment); + const daemon = await loadDaemonResource(origin, daemonId, environment); log.success(`Connected as ${daemon.slug}`); - const provider = await chooseProvider(); - const providerFilters = await collectProviderFilters(provider, resources, cwd); + const agent = await chooseStarterAgentRuntime(environment, cwd); + const provider = await chooseProvider(environment); + const providerFilters = await collectProviderFilters(provider, resources, cwd, environment); const scaffold = createHubInitScaffold({ cwd, daemonSlug: daemon.slug, + agent, provider, providerFilters, }); @@ -132,7 +160,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise await writeScaffold(cwd, scaffold, opening.replaceExisting); log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); - const deploy = await requiredConfirm("Deploy now?", true); + const deploy = state.deploy ?? (await requiredConfirm(environment, "Deploy now?", true)); if (deploy) { await withSpinner("Deploying bundle", async () => { await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { @@ -145,7 +173,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise }); log.success("Deployed"); } else { - log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); } const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); @@ -153,11 +181,33 @@ export async function runHubInit(environment: HubInitEnvironment): Promise outro(deploy ? "Hub is ready" : "Hub bundle is ready"); } +export async function continueHubGuidedSetup( + origin: string, + environment: HubGuidedSetupEnvironment, +): Promise { + if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) { + reportMessage( + environment, + `Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`, + ); + return; + } + const daemonId = await ensureDaemonConnection(origin, environment, true); + if (!(await requiredConfirm(environment, "Initialize and deploy a starter workflow?", true))) { + reportMessage( + environment, + `Skipped starter workflow. Run: ${hubLoginResumeCommand("init", origin)}`, + ); + return; + } + await runHubGuidedSetup(environment, { origin, daemonId, deploy: true }); +} + async function ensureLogin( activeOrigin: string | undefined, - environment: HubInitEnvironment, + environment: HubGuidedSetupEnvironment, ): Promise { - const endpoint = await requiredSelect({ + const endpoint = await requiredSelect(environment, { message: "Hub endpoint", initialValue: activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom", @@ -169,7 +219,7 @@ async function ensureLogin( const origin = endpoint === "hosted" ? DEFAULT_HUB_ORIGIN - : await requiredText({ + : await requiredText(environment, { message: "Custom Hub URL", initialValue: activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN @@ -189,23 +239,17 @@ async function ensureLogin( log.success(`Logged in to ${normalizedOrigin}`); return normalizedOrigin; } - await runHubLogin( - normalizedOrigin, - {}, - { - env: environment.env, - credentials: environment.credentials, - flow: environment.login, - reporter: environment.reporter, - }, - ); + environment.reporter.progress(`Logging in to ${normalizedOrigin}`); + const credential = await environment.login.authorize(normalizedOrigin); + environment.credentials.save({ origin: normalizedOrigin, credential }); log.success(`Logged in to ${normalizedOrigin}`); return normalizedOrigin; } async function ensureDaemonConnection( origin: string, - environment: HubInitEnvironment, + environment: HubGuidedSetupEnvironment, + confirmed = false, ): Promise { const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => daemon.getHubStatus().then((response) => response.status), @@ -223,9 +267,19 @@ async function ensureDaemonConnection( `This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`, ); } - if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) { + if ( + !confirmed && + !(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true)) + ) { throw new HubInitCancelledError("A connected daemon is required to create the bundle."); } + return connectDaemon(origin, environment); +} + +async function connectDaemon( + origin: string, + environment: HubGuidedSetupEnvironment, +): Promise { await runHubConnect( origin, {}, @@ -276,7 +330,10 @@ async function waitForDaemonReady( ); } -async function chooseProject(origin: string, environment: HubInitEnvironment): Promise { +async function chooseProject( + origin: string, + environment: HubGuidedSetupEnvironment, +): Promise { const result = await runHubProjects( { hub: origin }, { @@ -295,7 +352,7 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P if (resolution.kind === "selected") { return resolution.project; } - const slug = await requiredSelect({ + const slug = await requiredSelect(environment, { message: "Project", options: resolution.projects.map((project) => ({ value: project.slug, @@ -313,8 +370,8 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P return project; } -async function chooseProvider(): Promise { - return requiredSelect({ +async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise { + return requiredSelect(environment, { message: "Trigger provider", options: [ { value: "github", label: "GitHub", hint: "issue or pull request comment" }, @@ -324,10 +381,101 @@ async function chooseProvider(): Promise { }); } +async function chooseStarterAgentRuntime( + environment: HubGuidedSetupEnvironment, + cwd: string, +): Promise { + const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) => + daemon.getProvidersSnapshot({ cwd }), + ); + const providers = availableStarterAgentProviders(snapshot.entries); + if (providers.length === 0) { + throw new HubCommandError( + "HUB_AGENT_RUNTIME_REQUIRED", + "No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.", + ); + } + const provider = await chooseStarterAgentProvider(environment, providers); + const model = await chooseStarterAgentModel(environment, provider); + const mode = await chooseStarterAgentMode(environment, provider); + const runtime = selectedStarterAgentRuntime(provider, model, mode); + if (runtime === undefined) { + throw new HubCommandError( + "HUB_AGENT_RUNTIME_SELECTION_INVALID", + "The selected starter agent runtime is no longer available. Run paseo hub init again.", + ); + } + return runtime; +} + +async function chooseStarterAgentProvider( + environment: HubGuidedSetupEnvironment, + providers: readonly HubStarterAgentProvider[], +): Promise { + const suggested = suggestedStarterAgentChoice(providers); + const selected = await requiredSelect(environment, { + message: "Starter agent provider", + ...(suggested === undefined ? {} : { initialValue: suggested.id }), + options: providers.map((provider) => ({ + value: provider.id, + label: provider.label, + ...(provider.suggested ? { hint: "suggested" } : {}), + })), + }); + const provider = providers.find((candidate) => candidate.id === selected); + if (provider === undefined) throw invalidStarterAgentSelection(); + return provider; +} + +async function chooseStarterAgentModel( + environment: HubGuidedSetupEnvironment, + provider: HubStarterAgentProvider, +): Promise { + const suggested = suggestedStarterAgentChoice(provider.models); + const selected = await requiredSelect(environment, { + message: "Starter agent model", + ...(suggested === undefined ? {} : { initialValue: suggested.id }), + options: provider.models.map((model) => ({ + value: model.id, + label: model.label, + ...(model.suggested ? { hint: "suggested" } : {}), + })), + }); + if (!provider.models.some((model) => model.id === selected)) throw invalidStarterAgentSelection(); + return selected; +} + +async function chooseStarterAgentMode( + environment: HubGuidedSetupEnvironment, + provider: HubStarterAgentProvider, +): Promise { + if (provider.modes.length === 0) return undefined; + const suggested = suggestedStarterAgentChoice(provider.modes); + const selected = await requiredSelect(environment, { + message: "Starter agent mode", + ...(suggested === undefined ? {} : { initialValue: suggested.id }), + options: provider.modes.map((mode) => ({ + value: mode.id, + label: mode.label, + ...(mode.suggested ? { hint: "suggested" } : {}), + })), + }); + if (!provider.modes.some((mode) => mode.id === selected)) throw invalidStarterAgentSelection(); + return selected; +} + +function invalidStarterAgentSelection(): HubCommandError { + return new HubCommandError( + "HUB_AGENT_RUNTIME_SELECTION_INVALID", + "The selected starter agent runtime is no longer available. Run paseo hub init again.", + ); +} + async function collectProviderFilters( provider: HubInitProvider, - resources: HubConfigurationResources, + resources: HubSetupResources, cwd: string, + environment: HubGuidedSetupEnvironment, ): Promise>> { if (provider === "github") { const [login, originRemote] = await Promise.all([ @@ -349,7 +497,7 @@ async function collectProviderFilters( ); } return { - user: await requiredText({ + user: await requiredText(environment, { message: "Your GitHub username (only this user can trigger the bot)", initialValue: login, }), @@ -359,32 +507,34 @@ async function collectProviderFilters( if (provider === "slack") { return { workspace: await chooseConnection( + environment, "Slack workspace", - resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })), + resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })), ), - user: await requiredText({ - message: "Your Slack username (only this user can trigger the bot)", + user: await requiredText(environment, { + message: "Your Slack member ID (only this user can trigger the bot)", }), }; } return { guild: await chooseConnection( + environment, "Discord server", - resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })), + resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })), ), - user: await requiredText({ - message: "Your Discord username (only this user can trigger the bot)", + user: await requiredText(environment, { + message: "Your Discord user ID (only this user can trigger the bot)", }), }; } -async function loadConfigurationResources( +async function loadSetupResources( origin: string, - environment: HubInitEnvironment, -): Promise { + environment: HubGuidedSetupEnvironment, +): Promise { try { return await withSpinner("Loading Hub connections", () => - environment.hub.listConfigurationResources( + environment.hub.listSetupResources( origin, resolveHubCredential({ options: { origin }, @@ -398,16 +548,41 @@ async function loadConfigurationResources( if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { throw new HubCommandError( "HUB_UPDATE_REQUIRED", - "This Hub does not support guided setup. Update the Hub and try again.", + "This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.", ); } throw error; } } +async function loadDaemonResource( + origin: string, + daemonId: string, + environment: HubGuidedSetupEnvironment, +): Promise { + const resources = await environment.hub.listConfigurationResources( + origin, + resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }), + ); + const daemon = resources.daemons.find(({ id }) => id === daemonId); + if (daemon === undefined) { + throw new HubCommandError( + "HUB_DAEMON_RESOURCE_MISSING", + "The connected daemon is not available in this Hub organization. Reconnect it and try again.", + ); + } + return daemon; +} + async function chooseConnection( + environment: HubGuidedSetupEnvironment, message: string, - connections: readonly { slug: string; label: string }[], + connections: readonly { id: string; label: string }[], ): Promise { if (connections.length === 0) { throw new HubCommandError( @@ -415,10 +590,10 @@ async function chooseConnection( `No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`, ); } - if (connections.length === 1) return connections[0]!.slug; - return requiredSelect({ + if (connections.length === 1) return connections[0]!.id; + return requiredSelect(environment, { message, - options: connections.map(({ slug, label }) => ({ value: slug, label })), + options: connections.map(({ id, label }) => ({ value: id, label })), }); } @@ -523,36 +698,62 @@ async function withSpinner( } } -async function requiredText(options: Parameters[0]): Promise { - const answer = await text({ +async function requiredText( + environment: HubGuidedSetupEnvironment, + options: Parameters[0], +): Promise { + const request = { ...options, - validate(value) { + validate(value: string | undefined) { const input = value ?? ""; const customError = options.validate?.(input); if (customError !== undefined) return customError; return input.trim().length === 0 ? "A value is required" : undefined; }, - }); + }; + const answer = + environment.prompts === undefined + ? await text(request) + : await environment.prompts.text(request); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); return answer.trim(); } -async function requiredConfirm(message: string, initialValue: boolean): Promise { - const answer = await confirm({ message, initialValue }); +async function requiredConfirm( + environment: HubGuidedSetupEnvironment, + message: string, + initialValue: boolean, +): Promise { + const answer = + environment.prompts === undefined + ? await confirm({ message, initialValue }) + : await environment.prompts.confirm(message, initialValue); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); return answer; } async function requiredSelect( + environment: HubGuidedSetupEnvironment, options: Parameters>[0], ): Promise { - const answer = await select(options); + const answer = + environment.prompts === undefined + ? await select(options) + : ((await environment.prompts.select(options as Parameters>[0])) as T); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); return answer; } -function requireInteractiveTerminal(): void { - if (!process.stdin.isTTY || !process.stdout.isTTY) { +function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void { + if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) { throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); } } + +function reportMessage(environment: HubGuidedSetupEnvironment, message: string): void { + if (environment.prompts === undefined) { + log.message(message); + return; + } + environment.prompts.message(message); +} diff --git a/packages/cli/src/commands/hub/login.ts b/packages/cli/src/commands/hub/login.ts index d8f227709..f8d20e2c3 100644 --- a/packages/cli/src/commands/hub/login.ts +++ b/packages/cli/src/commands/hub/login.ts @@ -25,6 +25,8 @@ interface HubLoginDependencies { credentials: HubCredentialStore; flow: Pick; reporter: HubReporter; + isInteractive?(): boolean; + continueGuidedSetup?(origin: string): Promise; } export async function runHubLogin( @@ -40,6 +42,13 @@ export async function runHubLogin( reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`); const credential = await dependencies.flow.authorize(origin); dependencies.credentials.save({ origin, credential }); + if ( + !options.json && + dependencies.isInteractive?.() && + dependencies.continueGuidedSetup !== undefined + ) { + await dependencies.continueGuidedSetup(origin); + } return { type: "single", data: { origin, status: "logged_in" }, schema }; } diff --git a/packages/cli/src/commands/hub/starter-agent-runtime.test.ts b/packages/cli/src/commands/hub/starter-agent-runtime.test.ts new file mode 100644 index 000000000..59c0729b4 --- /dev/null +++ b/packages/cli/src/commands/hub/starter-agent-runtime.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + availableStarterAgentProviders, + selectedStarterAgentRuntime, + suggestedStarterAgentChoice, +} from "./starter-agent-runtime.js"; + +describe("starter agent runtime choices", () => { + it.each([ + { + name: "uses a complete daemon default as a staged suggestion", + entries: [ + { + provider: "codex", + status: "ready" as const, + enabled: true, + label: "Codex", + models: [ + { provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true }, + { provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" }, + ], + modes: [ + { id: "read-only", label: "Read only" }, + { id: "full-access", label: "Full access" }, + ], + defaultModeId: "full-access", + }, + ], + expected: [ + { + id: "codex", + label: "Codex", + suggested: true, + models: [ + { id: "gpt-5", label: "GPT-5", suggested: true }, + { id: "gpt-5-mini", label: "GPT-5 mini", suggested: false }, + ], + modes: [ + { id: "read-only", label: "Read only", suggested: false }, + { id: "full-access", label: "Full access", suggested: true }, + ], + }, + ], + }, + { + name: "keeps Claude selectable when it has modes but no default mode", + entries: [ + { + provider: "claude", + status: "ready" as const, + enabled: true, + label: "Claude", + models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }], + modes: [{ id: "auto", label: "Auto" }], + defaultModeId: null, + }, + ], + expected: [ + { + id: "claude", + label: "Claude", + suggested: false, + models: [{ id: "sonnet", label: "Sonnet", suggested: true }], + modes: [{ id: "auto", label: "Auto", suggested: false }], + }, + ], + }, + { + name: "treats an invalid default mode as no suggestion", + entries: [ + { + provider: "claude", + status: "ready" as const, + enabled: true, + models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }], + modes: [{ id: "auto", label: "Auto" }], + defaultModeId: "missing", + }, + ], + expected: [ + { + id: "claude", + label: "claude", + suggested: false, + models: [{ id: "sonnet", label: "Sonnet", suggested: true }], + modes: [{ id: "auto", label: "Auto", suggested: false }], + }, + ], + }, + { + name: "excludes unavailable, disabled, and model-less providers", + entries: [ + { provider: "claude", status: "unavailable" as const, enabled: true }, + { provider: "codex", status: "ready" as const, enabled: false, models: [] }, + { provider: "opencode", status: "ready" as const, enabled: true, models: [] }, + ], + expected: [], + }, + ])("$name", ({ entries, expected }) => { + expect(availableStarterAgentProviders(entries)).toEqual(expected); + }); + + it("creates mode-less definitions for providers that expose no modes", () => { + const [provider] = availableStarterAgentProviders([ + { + provider: "pi", + status: "ready", + enabled: true, + models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }], + modes: [], + }, + ]); + + expect(provider).toBeDefined(); + expect(selectedStarterAgentRuntime(provider!, "default")).toEqual({ + provider: "pi", + model: "default", + }); + expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/hub/starter-agent-runtime.ts b/packages/cli/src/commands/hub/starter-agent-runtime.ts new file mode 100644 index 000000000..66c910fb7 --- /dev/null +++ b/packages/cli/src/commands/hub/starter-agent-runtime.ts @@ -0,0 +1,75 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; + +export interface HubStarterAgentRuntime { + provider: string; + model: string; + mode?: string; +} + +export interface HubStarterAgentProvider { + id: string; + label: string; + models: readonly HubStarterAgentModel[]; + modes: readonly HubStarterAgentMode[]; + suggested: boolean; +} + +export interface HubStarterAgentModel { + id: string; + label: string; + suggested: boolean; +} + +export interface HubStarterAgentMode { + id: string; + label: string; + suggested: boolean; +} + +export function availableStarterAgentProviders( + entries: readonly ProviderSnapshotEntry[], +): HubStarterAgentProvider[] { + return entries.flatMap((entry) => { + if (entry.status !== "ready" || !entry.enabled) return []; + const models = (entry.models ?? []) + .filter((model) => model.isSelectable !== false) + .map((model) => ({ id: model.id, label: model.label, suggested: model.isDefault === true })); + if (models.length === 0) return []; + + const modes = (entry.modes ?? []).map((mode) => ({ + id: mode.id, + label: mode.label, + suggested: mode.id === entry.defaultModeId, + })); + return [ + { + id: entry.provider, + label: entry.label ?? entry.provider, + models, + modes, + suggested: + models.some((model) => model.suggested) && + (modes.length === 0 || modes.some((mode) => mode.suggested)), + }, + ]; + }); +} + +export function suggestedStarterAgentChoice( + choices: readonly T[], +): T | undefined { + return choices.find((choice) => choice.suggested); +} + +export function selectedStarterAgentRuntime( + provider: HubStarterAgentProvider, + modelId: string, + modeId?: string, +): HubStarterAgentRuntime | undefined { + if (!provider.models.some((model) => model.id === modelId)) return undefined; + if (provider.modes.length === 0) { + return modeId === undefined ? { provider: provider.id, model: modelId } : undefined; + } + if (modeId === undefined || !provider.modes.some((mode) => mode.id === modeId)) return undefined; + return { provider: provider.id, model: modelId, mode: modeId }; +}