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.
This commit is contained in:
Mohamed Boudra
2026-08-21 23:11:55 +02:00
committed by GitHub
parent 956f6ea7ad
commit 8f0c35e924
13 changed files with 1027 additions and 73 deletions
@@ -120,6 +120,52 @@ describe("Hub HTTP client", () => {
assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); 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 () => { it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
const requests: Array<{ url: string | undefined; body: string }> = []; const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer( const origin = await startServer(
@@ -91,6 +91,56 @@ describe("Hub commands", () => {
assert.equal(result.data.origin, "https://hub.paseo.sh"); 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 () => { it("connect exchanges authority once and gives only the enrollment token to the daemon", async () => {
const credentials = new MemoryCredentials(); const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" }); credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" });
@@ -484,6 +534,10 @@ class FakeDaemon implements HubDaemonClient {
return { status: hubStatus("connected", this.origin) }; return { status: hubStatus("connected", this.origin) };
} }
async getProvidersSnapshot() {
return { entries: [] };
}
async disconnectHub(force: boolean) { async disconnectHub(force: boolean) {
this.disconnects += 1; this.disconnects += 1;
this.disconnectForces.push(force); this.disconnectForces.push(force);
@@ -1,4 +1,5 @@
import { connectToDaemon } from "../../utils/client.js"; import { connectToDaemon } from "../../utils/client.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
export interface HubStatus { export interface HubStatus {
state: string; state: string;
@@ -9,10 +10,17 @@ export interface HubStatus {
lastError: string | null; lastError: string | null;
} }
export interface HubProvidersSnapshotOptions {
cwd?: string;
}
export interface HubDaemonClient { export interface HubDaemonClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>; connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
getHubStatus(): Promise<{ status: HubStatus }>; getHubStatus(): Promise<{ status: HubStatus }>;
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>; disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
getProvidersSnapshot(
options?: HubProvidersSnapshotOptions,
): Promise<{ entries: ProviderSnapshotEntry[] }>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -12,6 +12,8 @@ import {
type CliAuthorizationPoll, type CliAuthorizationPoll,
type HubInstallResult, type HubInstallResult,
type HubConfigurationResources, type HubConfigurationResources,
setupResourcesSchema,
type HubSetupResources,
type HubProject, type HubProject,
type HubValidationResult, type HubValidationResult,
} from "./internal/contracts.js"; } from "./internal/contracts.js";
@@ -22,6 +24,7 @@ export type {
CliAuthorizationPoll, CliAuthorizationPoll,
HubInstallResult, HubInstallResult,
HubConfigurationResources, HubConfigurationResources,
HubSetupResources,
HubProject, HubProject,
HubValidationResult, HubValidationResult,
} from "./internal/contracts.js"; } from "./internal/contracts.js";
@@ -95,6 +98,18 @@ export class HubHttpClient {
}); });
} }
listSetupResources(origin: string, apiKey: string): Promise<HubSetupResources> {
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<HubInstallResult> { installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return requestHub({ return requestHub({
origin: input.origin, origin: input.origin,
@@ -54,6 +54,25 @@ export const configurationResourcesSchema = z
}) })
.strict(); .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 export const installResponseSchema = z
.object({ .object({
projectSlug: z.string().min(1), projectSlug: z.string().min(1),
@@ -75,5 +94,6 @@ export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>; export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>; export type HubProject = z.infer<typeof projectSchema>;
export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>; export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
export type HubSetupResources = z.infer<typeof setupResourcesSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>; export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>; export type HubValidationResult = z.infer<typeof validationResponseSchema>;
+3 -1
View File
@@ -18,7 +18,7 @@ import { addHubProjectsCommand } from "./projects.js";
import { processHubReporter, type HubReporter } from "./reporter.js"; import { processHubReporter, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js"; import { hubStatusResult } from "./status-output.js";
import { addHubResolutionHelp } from "./help.js"; import { addHubResolutionHelp } from "./help.js";
import { addHubInitCommand } from "./init.js"; import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
interface HubCommandEnvironment { interface HubCommandEnvironment {
env: Readonly<Record<string, string | undefined>>; env: Readonly<Record<string, string | undefined>>;
@@ -56,6 +56,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
credentials: environment.credentials, credentials: environment.credentials,
flow: environment.login, flow: environment.login,
reporter: environment.reporter, reporter: environment.reporter,
isInteractive: environment.isInteractive,
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
}); });
addHubInitCommand(hub, environment); addHubInitCommand(hub, environment);
addHubConnectCommand(hub, { addHubConnectCommand(hub, {
@@ -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<boolean> {
this.confirmations.push(message);
return this.confirmAnswers.shift() ?? false;
}
async select(options: {
message: string;
options?: { label: string; hint?: string }[];
}): Promise<string> {
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<string> {
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<string | undefined> = [];
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<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-flow-"));
directories.push(directory);
return directory;
}
@@ -7,6 +7,7 @@ import { discoverHubBundle } from "./deploy-bundle.js";
import { runHubDeploy } from "./deploy.js"; import { runHubDeploy } from "./deploy.js";
import { import {
createHubInitScaffold, createHubInitScaffold,
hubLoginResumeCommand,
planHubInitOpening, planHubInitOpening,
resolveHubInitConnection, resolveHubInitConnection,
resolveHubInitProjects, resolveHubInitProjects,
@@ -26,6 +27,12 @@ afterEach(async () => {
}); });
describe("Hub init planning", () => { 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", () => { it("includes login only when there is no active login", () => {
expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({
replaceExisting: false, replaceExisting: false,
@@ -86,10 +93,25 @@ describe("Hub init planning", () => {
}); });
describe("Hub init scaffold", () => { 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([ it.each([
["github", { repo: "getpaseo/paseo", user: "boudra" }], ["github", { repo: "getpaseo/paseo", user: "boudra" }],
["slack", { workspace: "paseo", user: "boudra" }], ["slack", { workspace: "T123456", user: "U123456" }],
["discord", { guild: "paseo", user: "boudra" }], ["discord", { guild: "123456789", user: "987654321" }],
] satisfies readonly [HubInitProvider, Record<string, string>][])( ] satisfies readonly [HubInitProvider, Record<string, string>][])(
"creates a closed %s trigger that the deploy discovery path accepts", "creates a closed %s trigger that the deploy discovery path accepts",
async (provider, providerFilters) => { async (provider, providerFilters) => {
@@ -97,6 +119,11 @@ describe("Hub init scaffold", () => {
const scaffold = createHubInitScaffold({ const scaffold = createHubInitScaffold({
cwd, cwd,
daemonSlug: "build-studio", daemonSlug: "build-studio",
agent: {
provider: "codex",
model: "gpt-5",
mode: "full-access",
},
provider, provider,
providerFilters, providerFilters,
}); });
@@ -114,7 +141,12 @@ describe("Hub init scaffold", () => {
filters: { from_users: string[]; channels?: string[] }; filters: { from_users: string[]; channels?: string[] };
}; };
expect(parsed.filters.from_users).toEqual([providerFilters.user]); 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(parsed.filters.channels).toBeUndefined();
expect(YAML.parse(scaffold.hub)).toMatchObject({
agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } },
});
let validatedPaths: readonly string[] = []; let validatedPaths: readonly string[] = [];
const result = await runHubDeploy( const result = await runHubDeploy(
+11 -4
View File
@@ -3,6 +3,7 @@ import YAML from "yaml";
import type { HubDeployBundle } from "./deploy-bundle.js"; import type { HubDeployBundle } from "./deploy-bundle.js";
import type { HubProject } from "./hub-client/index.js"; import type { HubProject } from "./hub-client/index.js";
import type { HubStatus } from "./daemon-client.js"; import type { HubStatus } from "./daemon-client.js";
import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js";
export type HubInitProvider = "github" | "slack" | "discord"; export type HubInitProvider = "github" | "slack" | "discord";
@@ -25,6 +26,7 @@ export interface HubInitOpeningPlan {
export interface HubInitScaffoldInput { export interface HubInitScaffoldInput {
cwd: string; cwd: string;
daemonSlug: string; daemonSlug: string;
agent: HubStarterAgentRuntime;
provider: HubInitProvider; provider: HubInitProvider;
providerFilters: Readonly<Record<string, string>>; providerFilters: Readonly<Record<string, string>>;
} }
@@ -36,6 +38,10 @@ export interface HubInitScaffold {
testAction: string; 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: { export function planHubInitOpening(input: {
loggedIn: boolean; loggedIn: boolean;
paseoDirectoryExists: boolean; paseoDirectoryExists: boolean;
@@ -97,9 +103,10 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff
}, },
}, },
agents: { agents: {
codex: { starter: {
provider: "codex", provider: input.agent.provider,
mode: "full-access", model: input.agent.model,
...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }),
}, },
}, },
}, },
@@ -180,7 +187,7 @@ function workflow(input: {
environment: input.environment, environment: input.environment,
max_runtime: "90m", max_runtime: "90m",
idle_timeout: "10m", idle_timeout: "10m",
agent: "codex", agent: "starter",
prompt: [ prompt: [
{ {
text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`, text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
+267 -66
View File
@@ -21,28 +21,40 @@ import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection } from "./daemon-client.js"; import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js"; import { withHubDaemon } from "./daemon-client.js";
import { HubCommandError } from "./error.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 { import {
createHubInitBundle, createHubInitBundle,
createHubInitScaffold, createHubInitScaffold,
hubLoginResumeCommand,
planHubInitOpening, planHubInitOpening,
resolveHubInitConnection, resolveHubInitConnection,
resolveHubInitProjects, resolveHubInitProjects,
type HubInitProvider, type HubInitProvider,
} from "./init-plan.js"; } from "./init-plan.js";
import type { CliLoginFlow } from "./login-flow.js"; import type { CliLoginFlow } from "./login-flow.js";
import { runHubLogin } from "./login.js";
import { runHubConnect } from "./connect.js"; import { runHubConnect } from "./connect.js";
import { runHubDeployBundle } from "./deploy.js"; import { runHubDeployBundle } from "./deploy.js";
import { runHubProjects } from "./projects.js"; import { runHubProjects } from "./projects.js";
import type { HubReporter } from "./reporter.js"; import type { HubReporter } from "./reporter.js";
import { normalizeHubOrigin } from "./origin.js"; import { normalizeHubOrigin } from "./origin.js";
import {
availableStarterAgentProviders,
selectedStarterAgentRuntime,
suggestedStarterAgentChoice,
type HubStarterAgentProvider,
type HubStarterAgentRuntime,
} from "./starter-agent-runtime.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000; const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250; const DAEMON_READY_POLL_MS = 250;
interface HubInitEnvironment { export interface HubGuidedSetupEnvironment {
env: Readonly<Record<string, string | undefined>>; env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore; credentials: HubCredentialStore;
hub: HubHttpClient; hub: HubHttpClient;
@@ -50,6 +62,19 @@ interface HubInitEnvironment {
daemon: HubDaemonConnection; daemon: HubDaemonConnection;
reporter: HubReporter; reporter: HubReporter;
cwd(): string; cwd(): string;
isInteractive?(): boolean;
prompts?: {
confirm(message: string, initialValue: boolean): Promise<boolean>;
select(options: Parameters<typeof select<string>>[0]): Promise<string>;
text(options: Parameters<typeof text>[0]): Promise<string>;
message(value: string): void;
};
}
interface HubGuidedSetupState {
origin?: string;
daemonId?: string;
deploy?: boolean;
} }
class HubInitCancelledError extends Error {} class HubInitCancelledError extends Error {}
@@ -61,7 +86,7 @@ function initErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); 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 parent
.command("init") .command("init")
.description("Create and optionally deploy a safe starter Hub bundle") .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<void> { export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> {
requireInteractiveTerminal(); await runHubGuidedSetup(environment);
}
export async function runHubGuidedSetup(
environment: HubGuidedSetupEnvironment,
state: HubGuidedSetupState = {},
): Promise<void> {
requireInteractiveTerminal(environment);
intro("Set up Paseo Hub"); intro("Set up Paseo Hub");
const cwd = environment.cwd(); const cwd = environment.cwd();
@@ -91,28 +123,24 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
}); });
if ( if (
opening.replaceExisting && 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."); throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
} }
const origin = await ensureLogin(activeLogin?.origin, environment); const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment));
const daemonId = await ensureDaemonConnection(origin, environment); const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
const project = await chooseProject(origin, environment); const project = await chooseProject(origin, environment);
const resources = await loadConfigurationResources(origin, environment); const resources = await loadSetupResources(origin, environment);
const daemon = resources.daemons.find(({ id }) => id === daemonId); const daemon = await loadDaemonResource(origin, daemonId, environment);
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.",
);
}
log.success(`Connected as ${daemon.slug}`); log.success(`Connected as ${daemon.slug}`);
const provider = await chooseProvider(); const agent = await chooseStarterAgentRuntime(environment, cwd);
const providerFilters = await collectProviderFilters(provider, resources, cwd); const provider = await chooseProvider(environment);
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
const scaffold = createHubInitScaffold({ const scaffold = createHubInitScaffold({
cwd, cwd,
daemonSlug: daemon.slug, daemonSlug: daemon.slug,
agent,
provider, provider,
providerFilters, providerFilters,
}); });
@@ -132,7 +160,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
await writeScaffold(cwd, scaffold, opening.replaceExisting); await writeScaffold(cwd, scaffold, opening.replaceExisting);
log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); 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) { if (deploy) {
await withSpinner("Deploying bundle", async () => { await withSpinner("Deploying bundle", async () => {
await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, {
@@ -145,7 +173,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
}); });
log.success("Deployed"); log.success("Deployed");
} else { } 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(); const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
@@ -153,11 +181,33 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
outro(deploy ? "Hub is ready" : "Hub bundle is ready"); outro(deploy ? "Hub is ready" : "Hub bundle is ready");
} }
export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
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( async function ensureLogin(
activeOrigin: string | undefined, activeOrigin: string | undefined,
environment: HubInitEnvironment, environment: HubGuidedSetupEnvironment,
): Promise<string> { ): Promise<string> {
const endpoint = await requiredSelect({ const endpoint = await requiredSelect(environment, {
message: "Hub endpoint", message: "Hub endpoint",
initialValue: initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom", activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
@@ -169,7 +219,7 @@ async function ensureLogin(
const origin = const origin =
endpoint === "hosted" endpoint === "hosted"
? DEFAULT_HUB_ORIGIN ? DEFAULT_HUB_ORIGIN
: await requiredText({ : await requiredText(environment, {
message: "Custom Hub URL", message: "Custom Hub URL",
initialValue: initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
@@ -189,23 +239,17 @@ async function ensureLogin(
log.success(`Logged in to ${normalizedOrigin}`); log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin; return normalizedOrigin;
} }
await runHubLogin( environment.reporter.progress(`Logging in to ${normalizedOrigin}`);
normalizedOrigin, const credential = await environment.login.authorize(normalizedOrigin);
{}, environment.credentials.save({ origin: normalizedOrigin, credential });
{
env: environment.env,
credentials: environment.credentials,
flow: environment.login,
reporter: environment.reporter,
},
);
log.success(`Logged in to ${normalizedOrigin}`); log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin; return normalizedOrigin;
} }
async function ensureDaemonConnection( async function ensureDaemonConnection(
origin: string, origin: string,
environment: HubInitEnvironment, environment: HubGuidedSetupEnvironment,
confirmed = false,
): Promise<string> { ): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status), 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}.`, `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."); throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
} }
return connectDaemon(origin, environment);
}
async function connectDaemon(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
await runHubConnect( await runHubConnect(
origin, origin,
{}, {},
@@ -276,7 +330,10 @@ async function waitForDaemonReady(
); );
} }
async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> { async function chooseProject(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubProject> {
const result = await runHubProjects( const result = await runHubProjects(
{ hub: origin }, { hub: origin },
{ {
@@ -295,7 +352,7 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
if (resolution.kind === "selected") { if (resolution.kind === "selected") {
return resolution.project; return resolution.project;
} }
const slug = await requiredSelect({ const slug = await requiredSelect(environment, {
message: "Project", message: "Project",
options: resolution.projects.map((project) => ({ options: resolution.projects.map((project) => ({
value: project.slug, value: project.slug,
@@ -313,8 +370,8 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
return project; return project;
} }
async function chooseProvider(): Promise<HubInitProvider> { async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
return requiredSelect({ return requiredSelect(environment, {
message: "Trigger provider", message: "Trigger provider",
options: [ options: [
{ value: "github", label: "GitHub", hint: "issue or pull request comment" }, { value: "github", label: "GitHub", hint: "issue or pull request comment" },
@@ -324,10 +381,101 @@ async function chooseProvider(): Promise<HubInitProvider> {
}); });
} }
async function chooseStarterAgentRuntime(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<HubStarterAgentRuntime> {
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<HubStarterAgentProvider> {
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<string> {
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<string | undefined> {
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( async function collectProviderFilters(
provider: HubInitProvider, provider: HubInitProvider,
resources: HubConfigurationResources, resources: HubSetupResources,
cwd: string, cwd: string,
environment: HubGuidedSetupEnvironment,
): Promise<Readonly<Record<string, string>>> { ): Promise<Readonly<Record<string, string>>> {
if (provider === "github") { if (provider === "github") {
const [login, originRemote] = await Promise.all([ const [login, originRemote] = await Promise.all([
@@ -349,7 +497,7 @@ async function collectProviderFilters(
); );
} }
return { return {
user: await requiredText({ user: await requiredText(environment, {
message: "Your GitHub username (only this user can trigger the bot)", message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login, initialValue: login,
}), }),
@@ -359,32 +507,34 @@ async function collectProviderFilters(
if (provider === "slack") { if (provider === "slack") {
return { return {
workspace: await chooseConnection( workspace: await chooseConnection(
environment,
"Slack workspace", "Slack workspace",
resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })), resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
), ),
user: await requiredText({ user: await requiredText(environment, {
message: "Your Slack username (only this user can trigger the bot)", message: "Your Slack member ID (only this user can trigger the bot)",
}), }),
}; };
} }
return { return {
guild: await chooseConnection( guild: await chooseConnection(
environment,
"Discord server", "Discord server",
resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })), resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
), ),
user: await requiredText({ user: await requiredText(environment, {
message: "Your Discord username (only this user can trigger the bot)", message: "Your Discord user ID (only this user can trigger the bot)",
}), }),
}; };
} }
async function loadConfigurationResources( async function loadSetupResources(
origin: string, origin: string,
environment: HubInitEnvironment, environment: HubGuidedSetupEnvironment,
): Promise<HubConfigurationResources> { ): Promise<HubSetupResources> {
try { try {
return await withSpinner("Loading Hub connections", () => return await withSpinner("Loading Hub connections", () =>
environment.hub.listConfigurationResources( environment.hub.listSetupResources(
origin, origin,
resolveHubCredential({ resolveHubCredential({
options: { origin }, options: { origin },
@@ -398,16 +548,41 @@ async function loadConfigurationResources(
if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") {
throw new HubCommandError( throw new HubCommandError(
"HUB_UPDATE_REQUIRED", "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; throw error;
} }
} }
async function loadDaemonResource(
origin: string,
daemonId: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubConfigurationResources["daemons"][number]> {
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( async function chooseConnection(
environment: HubGuidedSetupEnvironment,
message: string, message: string,
connections: readonly { slug: string; label: string }[], connections: readonly { id: string; label: string }[],
): Promise<string> { ): Promise<string> {
if (connections.length === 0) { if (connections.length === 0) {
throw new HubCommandError( throw new HubCommandError(
@@ -415,10 +590,10 @@ async function chooseConnection(
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`, `No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
); );
} }
if (connections.length === 1) return connections[0]!.slug; if (connections.length === 1) return connections[0]!.id;
return requiredSelect({ return requiredSelect(environment, {
message, message,
options: connections.map(({ slug, label }) => ({ value: slug, label })), options: connections.map(({ id, label }) => ({ value: id, label })),
}); });
} }
@@ -523,36 +698,62 @@ async function withSpinner<T>(
} }
} }
async function requiredText(options: Parameters<typeof text>[0]): Promise<string> { async function requiredText(
const answer = await text({ environment: HubGuidedSetupEnvironment,
options: Parameters<typeof text>[0],
): Promise<string> {
const request = {
...options, ...options,
validate(value) { validate(value: string | undefined) {
const input = value ?? ""; const input = value ?? "";
const customError = options.validate?.(input); const customError = options.validate?.(input);
if (customError !== undefined) return customError; if (customError !== undefined) return customError;
return input.trim().length === 0 ? "A value is required" : undefined; 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."); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer.trim(); return answer.trim();
} }
async function requiredConfirm(message: string, initialValue: boolean): Promise<boolean> { async function requiredConfirm(
const answer = await confirm({ message, initialValue }); environment: HubGuidedSetupEnvironment,
message: string,
initialValue: boolean,
): Promise<boolean> {
const answer =
environment.prompts === undefined
? await confirm({ message, initialValue })
: await environment.prompts.confirm(message, initialValue);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer; return answer;
} }
async function requiredSelect<T extends string>( async function requiredSelect<T extends string>(
environment: HubGuidedSetupEnvironment,
options: Parameters<typeof select<T>>[0], options: Parameters<typeof select<T>>[0],
): Promise<T> { ): Promise<T> {
const answer = await select<T>(options); const answer =
environment.prompts === undefined
? await select<T>(options)
: ((await environment.prompts.select(options as Parameters<typeof select<string>>[0])) as T);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer; return answer;
} }
function requireInteractiveTerminal(): void { function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) { if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); 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);
}
+9
View File
@@ -25,6 +25,8 @@ interface HubLoginDependencies {
credentials: HubCredentialStore; credentials: HubCredentialStore;
flow: Pick<CliLoginFlow, "authorize">; flow: Pick<CliLoginFlow, "authorize">;
reporter: HubReporter; reporter: HubReporter;
isInteractive?(): boolean;
continueGuidedSetup?(origin: string): Promise<void>;
} }
export async function runHubLogin( export async function runHubLogin(
@@ -40,6 +42,13 @@ export async function runHubLogin(
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`); reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
const credential = await dependencies.flow.authorize(origin); const credential = await dependencies.flow.authorize(origin);
dependencies.credentials.save({ origin, credential }); 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 }; return { type: "single", data: { origin, status: "logged_in" }, schema };
} }
@@ -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();
});
});
@@ -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<T extends { suggested: boolean }>(
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 };
}