From ba6aede5f57c24e607a5b41f8d4a7e5c2e740602 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 13 Sep 2026 11:07:53 -0600 Subject: [PATCH 1/6] onboarding improvements, helmfile adjustments --- bindings/GitOnboardingState.ts | 3 + bindings/GitProviderOnboardingResponse.ts | 4 + bindings/HostedCollectionSelection.ts | 3 + bindings/LaunchConfig.ts | 4 + bindings/LaunchConfiguration.ts | 2 +- bindings/LaunchConfigurationPatch.ts | 2 +- bindings/SetGitSessionEnvRequest.ts | 3 + bindings/SetGitSessionEnvResponse.ts | 3 + bindings/SetupCollectionResponse.ts | 3 + bindings/SetupExecutionTarget.ts | 3 + bindings/SetupInitializeRequest.ts | 7 + bindings/SetupInitializeResponse.ts | 3 + bindings/SetupStatusResponse.ts | 3 + bindings/SetupStep.ts | 6 + bindings/SetupStepResponse.ts | 4 + bindings/ValidateGitTokenRequest.ts | 3 + bindings/ValidateGitTokenResponse.ts | 3 + bindings/WriteGitConfigRequest.ts | 3 + bindings/WriteGitConfigResponse.ts | 3 + config/default.toml | 6 +- docs/_config.yml | 5 +- docs/assets/css/main.css | 2 +- docs/assets/css/tokens.css | 25 +- docs/configuration/index.md | 1 + docs/delegators/index.md | 8 +- docs/getting-started/platforms/kubernetes.md | 6 +- docs/schemas/config.json | 28 +- docs/schemas/config.md | 5 +- docs/schemas/metadata.md | 4 +- docs/schemas/openapi.json | 275 +++++++-- docs/schemas/state.md | 37 ++ docs/startup/index.md | 209 ++++--- shared/types.ts | 40 +- src/agents/delegator_resolution.rs | 21 +- src/agents/launcher/cmux_session.rs | 6 +- src/agents/launcher/llm_command.rs | 19 +- src/agents/launcher/mod.rs | 8 +- src/agents/launcher/options.rs | 17 + src/agents/launcher/step_command.rs | 29 +- src/agents/launcher/tmux_session.rs | 6 +- src/agents/launcher/zellij_session.rs | 6 +- src/app/git_onboarding.rs | 314 ++-------- src/app/kanban_onboarding.rs | 12 + src/app/keyboard.rs | 230 +++++--- src/app/mod.rs | 36 +- src/app/tickets.rs | 174 ++---- src/auth/scope.rs | 10 + src/config.rs | 81 ++- src/config/sessions.rs | 14 +- src/config/targets.rs | 86 ++- src/docs_gen/startup.rs | 16 +- src/integrations/catalog.rs | 70 +++ src/lib.rs | 7 +- src/llm/mod.rs | 27 + src/main.rs | 3 + src/rest/dto/configuration.rs | 3 + src/rest/dto/git_onboarding.rs | 77 +++ src/rest/dto/mod.rs | 4 + src/rest/dto/setup.rs | 87 +++ src/rest/mod.rs | 14 + src/rest/openapi.rs | 44 +- src/rest/routes/collections.rs | 29 +- src/rest/routes/configuration.rs | 6 + src/rest/routes/git_onboarding.rs | 234 ++++++++ src/rest/routes/kanban_onboarding.rs | 4 +- src/rest/routes/mod.rs | 2 + src/rest/routes/setup.rs | 418 +++++++++++++ src/schemas/issuetype_schema.json | 10 +- src/services/git_onboarding.rs | 252 ++++++++ src/services/kanban_onboarding.rs | 6 +- src/services/mod.rs | 1 + src/setup.rs | 216 ++++++- src/startup/mod.rs | 250 +------- src/startup/steps.rs | 427 ++++++++++++++ src/ui/setup/mod.rs | 406 +++++++++++-- src/ui/setup/steps/git.rs | 135 +++++ src/ui/setup/steps/kanban.rs | 185 +----- src/ui/setup/steps/mod.rs | 6 + src/ui/setup/steps/model_server.rs | 150 +++++ src/ui/setup/steps/target.rs | 149 +++++ src/ui/setup/tests.rs | 459 ++++++++++++++- src/ui/setup/types.rs | 52 +- src/ui/status_panel.rs | 3 +- ...26-09-12-getting-started-parity-phase-a.md | 549 ++++++++++++++++++ .../2026-09-13-web-setup-wizard-design.md | 319 ++++++++++ tests/setup_parity.rs | 192 ++++++ ui/src/WorkspaceGate.tsx | 23 + ui/src/api-client.ts | 144 +++++ ui/src/main.tsx | 7 +- ui/src/routes/LoginPage.tsx | 6 +- ui/src/routes/SetupPage.tsx | 2 +- .../onboarding/OnboardingPage.module.css | 36 ++ ui/src/routes/onboarding/OnboardingPage.tsx | 121 ++++ ui/src/routes/onboarding/steps.tsx | 181 ++++++ ui/src/routes/onboarding/types.ts | 34 ++ 95 files changed, 5838 insertions(+), 1313 deletions(-) create mode 100644 bindings/GitOnboardingState.ts create mode 100644 bindings/GitProviderOnboardingResponse.ts create mode 100644 bindings/HostedCollectionSelection.ts create mode 100644 bindings/SetGitSessionEnvRequest.ts create mode 100644 bindings/SetGitSessionEnvResponse.ts create mode 100644 bindings/SetupCollectionResponse.ts create mode 100644 bindings/SetupExecutionTarget.ts create mode 100644 bindings/SetupInitializeRequest.ts create mode 100644 bindings/SetupInitializeResponse.ts create mode 100644 bindings/SetupStatusResponse.ts create mode 100644 bindings/SetupStep.ts create mode 100644 bindings/SetupStepResponse.ts create mode 100644 bindings/ValidateGitTokenRequest.ts create mode 100644 bindings/ValidateGitTokenResponse.ts create mode 100644 bindings/WriteGitConfigRequest.ts create mode 100644 bindings/WriteGitConfigResponse.ts create mode 100644 src/rest/dto/git_onboarding.rs create mode 100644 src/rest/dto/setup.rs create mode 100644 src/rest/routes/git_onboarding.rs create mode 100644 src/rest/routes/setup.rs create mode 100644 src/services/git_onboarding.rs create mode 100644 src/startup/steps.rs create mode 100644 src/ui/setup/steps/git.rs create mode 100644 src/ui/setup/steps/model_server.rs create mode 100644 src/ui/setup/steps/target.rs create mode 100644 superpowers/plans/2026-09-12-getting-started-parity-phase-a.md create mode 100644 superpowers/specs/2026-09-13-web-setup-wizard-design.md create mode 100644 tests/setup_parity.rs create mode 100644 ui/src/WorkspaceGate.tsx create mode 100644 ui/src/routes/onboarding/OnboardingPage.module.css create mode 100644 ui/src/routes/onboarding/OnboardingPage.tsx create mode 100644 ui/src/routes/onboarding/steps.tsx create mode 100644 ui/src/routes/onboarding/types.ts diff --git a/bindings/GitOnboardingState.ts b/bindings/GitOnboardingState.ts new file mode 100644 index 00000000..0c221428 --- /dev/null +++ b/bindings/GitOnboardingState.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GitOnboardingState = "cli-missing" | "token-required" | "authenticated"; diff --git a/bindings/GitProviderOnboardingResponse.ts b/bindings/GitProviderOnboardingResponse.ts new file mode 100644 index 00000000..064914cf --- /dev/null +++ b/bindings/GitProviderOnboardingResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GitOnboardingState } from "./GitOnboardingState"; + +export type GitProviderOnboardingResponse = { slug: string, label: string, docs_url: string, configured: boolean, command: string, token_env: string, state: GitOnboardingState, action_url: string, username?: string | null, }; diff --git a/bindings/HostedCollectionSelection.ts b/bindings/HostedCollectionSelection.ts new file mode 100644 index 00000000..06a863b8 --- /dev/null +++ b/bindings/HostedCollectionSelection.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HostedCollectionSelection = { id: string, checksum: string, }; diff --git a/bindings/LaunchConfig.ts b/bindings/LaunchConfig.ts index db3a67c2..cc2e565b 100644 --- a/bindings/LaunchConfig.ts +++ b/bindings/LaunchConfig.ts @@ -3,6 +3,10 @@ import type { DockerConfig } from "./DockerConfig"; import type { YoloConfig } from "./YoloConfig"; export type LaunchConfig = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, +/** + * Default named execution target. Per-launch and per-delegator choices take precedence. + */ +target: string | null, /** * Docker execution configuration */ diff --git a/bindings/LaunchConfiguration.ts b/bindings/LaunchConfiguration.ts index 61119331..59d68fae 100644 --- a/bindings/LaunchConfiguration.ts +++ b/bindings/LaunchConfiguration.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SessionWrapper } from "./SessionWrapper"; -export type LaunchConfiguration = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, docker_enabled: boolean, docker_image: string, yolo_enabled: boolean, session_wrapper: SessionWrapper, }; +export type LaunchConfiguration = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, target: string | null, docker_enabled: boolean, docker_image: string, yolo_enabled: boolean, session_wrapper: SessionWrapper, }; diff --git a/bindings/LaunchConfigurationPatch.ts b/bindings/LaunchConfigurationPatch.ts index a5beaf91..693efd05 100644 --- a/bindings/LaunchConfigurationPatch.ts +++ b/bindings/LaunchConfigurationPatch.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SessionWrapper } from "./SessionWrapper"; -export type LaunchConfigurationPatch = { confirm_autonomous: boolean | null, confirm_paired: boolean | null, launch_delay_ms: bigint | null, docker_enabled: boolean | null, docker_image: string | null, yolo_enabled: boolean | null, session_wrapper: SessionWrapper | null, }; +export type LaunchConfigurationPatch = { confirm_autonomous: boolean | null, confirm_paired: boolean | null, launch_delay_ms: bigint | null, target: string | null, docker_enabled: boolean | null, docker_image: string | null, yolo_enabled: boolean | null, session_wrapper: SessionWrapper | null, }; diff --git a/bindings/SetGitSessionEnvRequest.ts b/bindings/SetGitSessionEnvRequest.ts new file mode 100644 index 00000000..47c578fd --- /dev/null +++ b/bindings/SetGitSessionEnvRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetGitSessionEnvRequest = { provider: string, token: string, }; diff --git a/bindings/SetGitSessionEnvResponse.ts b/bindings/SetGitSessionEnvResponse.ts new file mode 100644 index 00000000..f15ff7c6 --- /dev/null +++ b/bindings/SetGitSessionEnvResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetGitSessionEnvResponse = { shell_export_block: string, }; diff --git a/bindings/SetupCollectionResponse.ts b/bindings/SetupCollectionResponse.ts new file mode 100644 index 00000000..f489151a --- /dev/null +++ b/bindings/SetupCollectionResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupCollectionResponse = { id: string, name: string, description: string, types: Array, default_selected: Array, origin: string, note?: string | null, checksum: string, }; diff --git a/bindings/SetupExecutionTarget.ts b/bindings/SetupExecutionTarget.ts new file mode 100644 index 00000000..24178d67 --- /dev/null +++ b/bindings/SetupExecutionTarget.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupExecutionTarget = { "kind": "local" } | { "kind": "coder", name: string, template: string, }; diff --git a/bindings/SetupInitializeRequest.ts b/bindings/SetupInitializeRequest.ts new file mode 100644 index 00000000..9d2fe364 --- /dev/null +++ b/bindings/SetupInitializeRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CollectionPreset } from "./CollectionPreset"; +import type { HostedCollectionSelection } from "./HostedCollectionSelection"; +import type { SessionWrapperType } from "./SessionWrapperType"; +import type { SetupExecutionTarget } from "./SetupExecutionTarget"; + +export type SetupInitializeRequest = { preset: CollectionPreset, task_fields: Array, wrapper: SessionWrapperType, execution_target: SetupExecutionTarget, use_worktrees: boolean, acceptance_criteria: string, model_servers: Array, hosted_collections: Array, }; diff --git a/bindings/SetupInitializeResponse.ts b/bindings/SetupInitializeResponse.ts new file mode 100644 index 00000000..83ee3933 --- /dev/null +++ b/bindings/SetupInitializeResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupInitializeResponse = { initialized: boolean, config_path: string, tickets_path: string, files_created: Array, files_skipped: Array, projects: Array, }; diff --git a/bindings/SetupStatusResponse.ts b/bindings/SetupStatusResponse.ts new file mode 100644 index 00000000..266c6a24 --- /dev/null +++ b/bindings/SetupStatusResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupStatusResponse = { initialized: boolean, admin_configured: boolean, config_path: string, tickets_path: string, projects_by_tool: { [key in string]: Array }, default_acceptance_criteria: string, }; diff --git a/bindings/SetupStep.ts b/bindings/SetupStep.ts new file mode 100644 index 00000000..e25b413b --- /dev/null +++ b/bindings/SetupStep.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A step in the setup wizard. + */ +export type SetupStep = "welcome" | "kanban-info" | "model-server" | "git-provider" | "collection-source" | "hosted-collections" | "task-field-config" | "session-wrapper-choice" | "execution-target" | "worktree-preference" | "admin-password" | "tmux-onboarding" | "vscode-setup" | "cmux-setup" | "zellij-setup" | "acceptance-criteria" | "startup-tickets" | "confirm"; diff --git a/bindings/SetupStepResponse.ts b/bindings/SetupStepResponse.ts new file mode 100644 index 00000000..2d4fb63b --- /dev/null +++ b/bindings/SetupStepResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SetupStep } from "./SetupStep"; + +export type SetupStepResponse = { slug: SetupStep, name: string, description: string, help_text: string, order: number, }; diff --git a/bindings/ValidateGitTokenRequest.ts b/bindings/ValidateGitTokenRequest.ts new file mode 100644 index 00000000..cee87cab --- /dev/null +++ b/bindings/ValidateGitTokenRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ValidateGitTokenRequest = { provider: string, token: string, }; diff --git a/bindings/ValidateGitTokenResponse.ts b/bindings/ValidateGitTokenResponse.ts new file mode 100644 index 00000000..80070803 --- /dev/null +++ b/bindings/ValidateGitTokenResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ValidateGitTokenResponse = { valid: boolean, username?: string | null, error?: string | null, }; diff --git a/bindings/WriteGitConfigRequest.ts b/bindings/WriteGitConfigRequest.ts new file mode 100644 index 00000000..88b79a54 --- /dev/null +++ b/bindings/WriteGitConfigRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WriteGitConfigRequest = { provider: string, token_env: string, }; diff --git a/bindings/WriteGitConfigResponse.ts b/bindings/WriteGitConfigResponse.ts new file mode 100644 index 00000000..8002eea0 --- /dev/null +++ b/bindings/WriteGitConfigResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WriteGitConfigResponse = { provider: string, token_env: string, shell_export_block: string, username?: string | null, }; diff --git a/config/default.toml b/config/default.toml index 22ba39e7..c24bf532 100644 --- a/config/default.toml +++ b/config/default.toml @@ -89,6 +89,9 @@ confirm_paired = true # Delay between launching multiple agents (milliseconds) launch_delay_ms = 2000 +# Default execution target. Named targets are declared with [[targets]]. +# target = "local" + # Session wrapper configuration # Controls how operator creates and manages terminal sessions for agents [sessions] @@ -140,8 +143,7 @@ connect_timeout_ms = 5000 # Agent delegator configurations # Delegators are named {tool, model, model_server} triples for autonomous -# ticket launching. `model_server` is optional — omit it to use the llm_tool's -# implicit vendor default (claude → anthropic-api, codex → openai-api, etc.). +# ticket launching. `model_server` is optional. omit it to use the llm_tool's implicit vendor default (claude → anthropic-api, codex → openai-api, etc.). # [[delegators]] # name = "claude-opus-auto" # llm_tool = "claude" diff --git a/docs/_config.yml b/docs/_config.yml index 00a1107a..cd539185 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -5,9 +5,8 @@ url: "https://operator.untra.io" baseurl: "" # Social/SEO -# 1200x630 social card (raster PNG — SVG is not rendered by social unfurlers). -# Also set in `defaults` below because jekyll-seo-tag emits og:image from -# page.image, not site.image. +# 1200x630 social card (raster PNG / SVG is not rendered by social unfurlers). +# Also set in `defaults` below because jekyll-seo-tag emits og:image from page.image, not site.image. image: /assets/img/operator_og.png twitter: card: summary_large_image diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 1af23e51..f83beac7 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -302,7 +302,7 @@ summary.nav-item-row::-webkit-details-marker { color: inherit; } -/* Workflows nav button — the collection catalog. +/* Workflows nav button . The collection catalog. * A shade apart from both the sage (--color-green-l1) used by every ordinary * nav link and the salmon Downloads button below it, so the two read as a pair * of distinct calls to action rather than one repeated. */ diff --git a/docs/assets/css/tokens.css b/docs/assets/css/tokens.css index c0f6c69f..05e3a228 100644 --- a/docs/assets/css/tokens.css +++ b/docs/assets/css/tokens.css @@ -1,32 +1,29 @@ -/* Operator! brand tokens — SINGLE SOURCE OF TRUTH for web surfaces. +/* Operator! brand tokens : SINGLE SOURCE OF TRUTH for web surfaces. * * Consumed by both the docs site (docs/assets/css/main.css) and the embedded - * React SPA (ui/src/index.css). Do not re-declare these brand vars in either - * consumer — change them here and both surfaces follow. + * React SPA (ui/src/index.css). Do not re-declare these brand vars in either consumer. * * The TUI (src/ui/*.rs, ANSI) and the VS Code webview (defers to the editor - * theme) deliberately do NOT consume this file — see operator/CLAUDE.md - * "Design & UI Consistency" and docs/design-system/. + * theme) deliberately do NOT consume this file : see operator/CLAUDE.md * * Palette: warm terracotta + cornflower + cream, over a green scale - * (sage -> teal -> deep pine -> midnight). Light default, dark via - * [data-theme="dark"]. + * (sage -> teal -> deep pine -> midnight). Light default, dark via [data-theme="dark"]. */ :root { /* Brand palette */ - --color-salmon: #e05d44; /* Terracotta — primary brand */ - --color-cornflower: #6688aa; /* Muted blue — secondary text / separators */ + --color-salmon: #e05d44; /* Terracotta : primary brand */ + --color-cornflower: #6688aa; /* Muted blue : secondary text / separators */ --color-cream: #f2eac9; /* Warm accent / highlights */ --color-coral: #e05d44; /* Link / accent (alias of salmon in light) */ --color-bg: #faf8f5; /* Page background */ --color-white: #ffffff; /* Green scale */ - --color-green-l1: #66aa99; /* Sage — nav buttons */ - --color-green-l2: #448880; /* Teal — hover / success */ - --color-green-l3: #115566; /* Deep pine — selected / primary text */ - --color-green-l4: #082226; /* Midnight — darkest */ + --color-green-l1: #66aa99; /* Sage : nav buttons */ + --color-green-l2: #448880; /* Teal : hover / success */ + --color-green-l3: #115566; /* Deep pine : selected / primary text */ + --color-green-l4: #082226; /* Midnight : darkest */ --color-teal: #115566; /* Body text (== green-l3) */ /* Shared layout */ @@ -41,7 +38,7 @@ --color-bg: #0d1011; --color-white: #1a1d1e; - /* Green scale — adjusted for dark mode */ + /* Green scale : adjusted for dark mode */ --color-green-l1: #3d8a7a; --color-green-l2: #5aa896; --color-green-l3: #88ccbb; diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 3831c541..fa1a5e57 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -91,6 +91,7 @@ Agent launch behavior and confirmations | `confirm_autonomous` * | `boolean` | true | | | `confirm_paired` * | `boolean` | true | | | `launch_delay_ms` * | `integer` | 2000 | | +| `target` | `string` \| `null` | - | Default named execution target. Per-launch and per-delegator choices take precedence. | | `docker` | → `DockerConfig` | - | Docker execution configuration | | `yolo` | → `YoloConfig` | - | YOLO (auto-accept) mode configuration | diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 2f388d55..3ec83a2a 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -105,6 +105,9 @@ llm_tool = "claude" model = "opus" [delegators.launch_config] target = "cloud" + +[launch] +target = "cloud" # default when a launch/delegator does not override it ``` **Resolution precedence** (first match wins): @@ -115,10 +118,11 @@ target = "cloud" 2. `host` name (deprecated) - the `[[hosts]]` entry of that name 3. `docker = true` (deprecated) - the synthesized docker target 4. `docker = false` - local -5. `launch.docker.enabled = true` - the synthesized docker target. +5. `launch.target` - the named global default target. +6. `launch.docker.enabled = true` - the synthesized docker target. **Behavior change:** this was previously only a TUI dialog gate; it is now a real fallback, so REST/CLI/auto launches with it set run in docker. -6. otherwise - local +7. otherwise - local Legacy inputs are synthesized rather than special-cased: `[launch.docker]` becomes a target named `docker`, and every `[[hosts]]` entry becomes an ssh diff --git a/docs/getting-started/platforms/kubernetes.md b/docs/getting-started/platforms/kubernetes.md index f5341ef9..1376ed4c 100644 --- a/docs/getting-started/platforms/kubernetes.md +++ b/docs/getting-started/platforms/kubernetes.md @@ -222,7 +222,11 @@ Coder's own ingress NetworkPolicy has to admit Operator's namespace too. kubectl -n operator exec operator-0 -- curl -sSf https://coder.example.com/api/v2/buildinfo ``` -**3. Nothing else.** The image already ships `openssh-client`, and Operator downloads the `coder` CLI from the deployment on first use, caching it on the persistent volume at `.tickets/operator/bin/coder`. No custom image, no initContainer, and no relaxing of `readOnlyRootFilesystem` - the cache and the SSH fragments both live under `/op`. +**3. Configure the target in Operator.** On a new installation, finish the getting-started wizard in the web UI, choose **Coder** as the execution target, and enter the child-workspace template. The wizard writes the project configuration to `/op/.tickets/operator/config.toml`; the chart does not own or project an Operator configuration file. + +The image already ships `openssh-client`, and Operator downloads the `coder` CLI from the deployment on first use, caching it on the persistent volume at `.tickets/operator/bin/coder`. + +No custom image, initContainer, ConfigMap, or relaxing of `readOnlyRootFilesystem` is required - the configuration, cache, and SSH fragments all live under `/op`. ## Security context diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 3cd9e402..94228608 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -528,6 +528,14 @@ "format": "uint64", "minimum": 0 }, + "target": { + "description": "Default named execution target. Per-launch and per-delegator choices\ntake precedence.", + "type": [ + "string", + "null" + ], + "default": null + }, "docker": { "description": "Docker execution configuration", "$ref": "#/$defs/DockerConfig", @@ -1118,7 +1126,7 @@ "default": true }, "host": { - "description": "Address the REST API binds to. Defaults to `127.0.0.1` (local only) so\nthe server — which reports the project directory name — is not reachable\nfrom other hosts. Set to `0.0.0.0` to expose it on all interfaces.", + "description": "Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces.", "type": "string", "default": "127.0.0.1" }, @@ -1139,7 +1147,7 @@ "default": [] }, "public_url": { - "description": "Externally reachable base URL (e.g. `https://operator.example.com`).\n\nOAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header,\nwhich a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy.", + "description": "Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host.", "type": [ "string", "null" @@ -1384,7 +1392,7 @@ "default": {} }, "github": { - "description": "GitHub Projects v2 instances keyed by owner login (user or org)\n\nNOTE: This is the *kanban* GitHub integration (Projects v2), distinct\nfrom `GitHubConfig` which is the *git provider* used for PRs and\nbranches. The two use different env vars and different scopes — see\n`docs/getting-started/kanban/github.md` for the full disambiguation.", + "description": "GitHub Projects v2 instances keyed by owner login (user or org)\n\nNOTE: This is the *kanban* GitHub integration (Projects v2), distinct\nfrom `GitHubConfig` which is the *git provider* used for PRs and\nbranches. The two use different env vars and different scopes - see\n`docs/getting-started/kanban/github.md` for the full disambiguation.", "type": "object", "additionalProperties": { "$ref": "#/$defs/GithubProjectsConfig" @@ -1524,7 +1532,7 @@ } }, "GithubProjectsConfig": { - "description": "GitHub Projects v2 (kanban) provider configuration\n\nThe owner login (user or org) is specified as the `HashMap` key in\n`KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node\nIDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly\nby every GitHub Projects v2 mutation without needing a lookup.\n\n**Distinct from `GitHubConfig`** (the git provider used for PR/branch\noperations). They live in different parts of the config tree, use\ndifferent env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and\nrequire different OAuth scopes (`project` vs `repo`). See\n`docs/getting-started/kanban/github.md` for the full rationale.", + "description": "GitHub Projects v2 (kanban) provider configuration\n\nThe owner login (user or org) is specified as the `HashMap` key in\n`KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node\nIDs (e.g., `PVT_kwDOABcdefg`) - opaque, stable identifiers used directly\nby every GitHub Projects v2 mutation without needing a lookup.\n\n**Distinct from `GitHubConfig`** (the git provider used for PR/branch\noperations). They live in different parts of the config tree, use\ndifferent env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and\nrequire different OAuth scopes (`project` vs `repo`). See\n`docs/getting-started/kanban/github.md` for the full rationale.", "type": "object", "properties": { "enabled": { @@ -1533,7 +1541,7 @@ "default": false }, "api_key_env": { - "description": "Environment variable name containing the GitHub token (default:\n`OPERATOR_GITHUB_TOKEN`). The token must have `project` (or\n`read:project`) scope, NOT just `repo` — see the disambiguation\nguide in the kanban github docs.", + "description": "Environment variable name containing the GitHub token (default:\n`OPERATOR_GITHUB_TOKEN`). The token must have `project` (or\n`read:project`) scope, NOT just `repo` - see the disambiguation\nguide in the kanban github docs.", "type": "string", "default": "OPERATOR_GITHUB_TOKEN" }, @@ -1548,7 +1556,7 @@ } }, "OpenspecConfig": { - "description": "`OpenSpec` provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials — the provider reads local markdown under `root_path`.", + "description": "`OpenSpec` provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials - the provider reads local markdown under `root_path`.", "type": "object", "properties": { "enabled": { @@ -1660,7 +1668,7 @@ "default": null }, "remote_agent": { - "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally — resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and — for `platform == \"agnt\"` — surfaced in the\n`--format agnt` workflow export as a native AGNT `agnt-agent` node, whose\n`agentId` is this reference's `id` (AGNT identifies agents by UUID, so the\n`id` must be the agent's UUID, not its display name). `None` = ordinary,\nlocally launchable delegator.", + "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally - resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and - for `platform == \"agnt\"` - surfaced in the\n`--format agnt` workflow export as a native AGNT `agnt-agent` node, whose\n`agentId` is this reference's `id` (AGNT identifies agents by UUID, so the\n`id` must be the agent's UUID, not its display name). `None` = ordinary,\nlocally launchable delegator.", "anyOf": [ { "$ref": "#/$defs/RemoteAgentRef" @@ -1846,7 +1854,7 @@ } }, "RemoteAgentRef": { - "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) — deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", + "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) - deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", "type": "object", "properties": { "platform": { @@ -2029,11 +2037,11 @@ ] }, "CoderConfig": { - "description": "Coder workspace target: lifecycle + alias provisioning around the shared\nSSH remote-launch path. There is no `enabled` field — presence in\n`[[targets]]` is the enablement.", + "description": "Coder workspace target: lifecycle + alias provisioning around the shared\nSSH remote-launch path. There is no `enabled` field - presence in\n`[[targets]]` is the enablement.", "type": "object", "properties": { "template": { - "description": "Coder template child workspaces are created from (an allowlist —\nnever per-ticket input)", + "description": "Coder template child workspaces are created from (an allowlist -\nnever per-ticket input)", "type": "string" }, "url_env": { diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 32d94bd7..81043f55 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -145,6 +145,7 @@ Webhook notification configuration. | `confirm_autonomous` | `boolean` | Yes | | | `confirm_paired` | `boolean` | Yes | | | `launch_delay_ms` | `integer` | Yes | | +| `target` | `string` \| `null` | No | Default named execution target. Per-launch and per-delegator choices take precedence. | | `docker` | → `DockerConfig` | No | Docker execution configuration | | `yolo` | → `YoloConfig` | No | YOLO (auto-accept) mode configuration | @@ -363,10 +364,10 @@ REST API server configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether the REST API is enabled | -| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server - which reports the project directory name - is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | +| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | | `port` | `integer` | No | Port for the REST API server | | `cors_origins` | `array` | No | CORS allowed origins. Empty means **same-origin only** | -| `public_url` | `string` \| `null` | No | Externally reachable base URL (e.g. `https://operator.example.com`). OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. | +| `public_url` | `string` \| `null` | No | Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. | ### GitConfig diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index a303680a..0f4e3579 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -25,7 +25,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc | --- | --- | --- | --- | | `id` | `string` | Yes | Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. Key grammar: uppercase start, then uppercase letters, digits, or underscores (hyphen is reserved as the key/number separator). | | `status` | `string` | Yes | Operator workflow status | -| `collection` | `string` | No | Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets - resolution falls back to the active collection, then a deterministic search. | +| `collection` | `string` | No | Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search. | | `step` | `string` | No | Current workflow step name (e.g., plan, build, code, test, deploy) | | `priority` | `string` | No | Ticket priority level | | `project` | `string` | No | Target project name (subdirectory in projects root) | @@ -54,7 +54,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc ### collection -- **Description**: Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets - resolution falls back to the active collection, then a deterministic search. +- **Description**: Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search. - **Type**: `string` - **Pattern**: `^[a-z0-9_]{3,64}$` - **Examples**: `dev_kanban`, `ralph_loop`, `custom` diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index cea164bf..e8f07490 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -181,7 +181,7 @@ "Agents" ], "summary": "Focus the terminal session of a running agent in its session wrapper.", - "description": "The web UI's launch panel calls this for **cmux** launches: cmux exposes no\nbrowser URL scheme, so the operator control plane (which runs inside cmux)\nshells out to `cmux focus-workspace` for the agent's saved workspace ref to\nbring its pane to the foreground. Other wrappers are unsupported here — VS\nCode focuses through its extension's URI handler, and tmux/zellij are\ndisplay-only in the UI, so it never calls this for them.", + "description": "The web UI's launch panel calls this for **cmux** launches: cmux exposes no\nbrowser URL scheme, so the operator control plane (which runs inside cmux)\nshells out to `cmux focus-workspace` for the agent's saved workspace ref to\nbring its pane to the foreground. Other wrappers are unsupported here - VS\nCode focuses through its extension's URI handler, and tmux/zellij are\ndisplay-only in the UI, so it never calls this for them.", "operationId": "agents_focus_session", "parameters": [ { @@ -623,6 +623,38 @@ "security": [] } }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Return recovery guidance without confirming whether the username exists.", + "operationId": "auth_forgot_password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recovery guidance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordResponse" + } + } + } + } + }, + "security": [] + } + }, "/api/v1/auth/keys": { "get": { "tags": [ @@ -817,7 +849,7 @@ } }, "401": { - "description": "Bad password", + "description": "Bad username or password", "headers": { "WWW-Authenticate": { "schema": { @@ -911,6 +943,74 @@ "x-operator-scope": "write" } }, + "/api/v1/auth/reset-password": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Change the password using the current username and password.", + "operationId": "auth_reset_password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordResponse" + } + } + } + }, + "401": { + "description": "Invalid current credentials", + "headers": { + "WWW-Authenticate": { + "schema": { + "type": "string" + }, + "description": "Authentication challenge naming the accepted schemes." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Too many attempts", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds the client must wait before retrying." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] + } + }, "/api/v1/auth/session": { "get": { "tags": [ @@ -2385,7 +2485,7 @@ "Issue Types" ], "summary": "Get an issue type's Operator workflow document", - "description": "Returns the issue type verbatim, in the same shape as the `.json` files\nin a hosted collection bundle (`/schemas/issuetype.json`). This is the\n*native* Operator workflow — the ordered step graph every export format is\nderived from — so the web UI and the docs site render identical graphs from\nidentical bytes. Prefer [`get_one`] for display metadata; use this when you\nneed the full step structure including step types, reject edges, and\nper-type fan-out configuration.", + "description": "Returns the issue type verbatim, in the same shape as the `.json` files\nin a hosted collection bundle (`/schemas/issuetype.json`). This is the\n*native* Operator workflow - the ordered step graph every export format is\nderived from - so the web UI and the docs site render identical graphs from\nidentical bytes. Prefer [`get_one`] for display metadata; use this when you\nneed the full step structure including step types, reject edges, and\nper-type fan-out configuration.", "operationId": "issuetypes_get_document", "parameters": [ { @@ -2746,7 +2846,7 @@ "Kanban" ], "summary": "PUT /`api/v1/kanban/config`", - "description": "Write or upsert a kanban provider+project section into `config.toml`.\nDoes NOT receive the actual secret — only the env var name (`api_key_env`).", + "description": "Write or upsert a kanban provider+project section into `config.toml`.\nDoes NOT receive the actual secret - only the env var name (`api_key_env`).", "operationId": "kanban_write_config", "parameters": [ { @@ -3492,7 +3592,7 @@ "tags": [ "MCP" ], - "summary": "Message endpoint — receives JSON-RPC requests and sends responses via SSE", + "summary": "Message endpoint - receives JSON-RPC requests and sends responses via SSE", "operationId": "mcp_message", "parameters": [ { @@ -3566,7 +3666,7 @@ "tags": [ "MCP" ], - "summary": "SSE endpoint — opens an event stream and sends the message endpoint URL", + "summary": "SSE endpoint - opens an event stream and sends the message endpoint URL", "description": "The client connects here first, receives the message endpoint URL,\nthen sends JSON-RPC requests to that endpoint.", "operationId": "mcp_sse", "responses": { @@ -3744,7 +3844,7 @@ "ModelServers" ], "summary": "List the models a *provider kind* offers, via a live probe.", - "description": "Resolves to the declared instance of that kind (if the user has one) else a\ntransient instance built from the kind's probe defaults — so the Model\nProviders catalog can show connection state + live models for every supported\nprovider without first declaring one. `reachable` doubles as \"connected\".", + "description": "Resolves to the declared instance of that kind (if the user has one) else a\ntransient instance built from the kind's probe defaults - so the Model\nProviders catalog can show connection state + live models for every supported\nprovider without first declaring one. `reachable` doubles as \"connected\".", "operationId": "model_servers_kind_models", "parameters": [ { @@ -4018,7 +4118,7 @@ "ModelServers" ], "summary": "List the models a server offers, via a live probe of its inference endpoint.", - "description": "The probe doubles as a reachability check — `reachable: false` with an `error`\nwhen the endpoint is unreachable or rejects the request.", + "description": "The probe doubles as a reachability check - `reachable: false` with an `error`\nwhen the endpoint is unreachable or rejects the request.", "operationId": "model_servers_models", "parameters": [ { @@ -5059,7 +5159,7 @@ "Workflow" ], "summary": "List the workflow export formats operator can emit.", - "description": "Returns each [`WorkflowFormat`] with its label, file extension, support\nstatus, and docs link — derived from `WorkflowFormat::ALL` joined to the\n`Workflows` catalog vertical. Lets UIs render a format picker for the\n`format` query param accepted by export/preview.", + "description": "Returns each [`WorkflowFormat`] with its label, file extension, support\nstatus, and docs link - derived from `WorkflowFormat::ALL` joined to the\n`Workflows` catalog vertical. Lets UIs render a format picker for the\n`format` query param accepted by export/preview.", "operationId": "workflow_formats", "responses": { "200": { @@ -5099,7 +5199,7 @@ "Health" ], "summary": "Liveness probe", - "description": "Answers only \"is the process serving HTTP\". It deliberately does not touch\nthe database: a liveness failure restarts the pod, and restarting will not\nfix a corrupt database — it would just crash-loop.", + "description": "Answers only \"is the process serving HTTP\". It deliberately does not touch\nthe database: a liveness failure restarts the pod, and restarting will not\nfix a corrupt database - it would just crash-loop.", "operationId": "livez", "responses": { "200": { @@ -5463,7 +5563,7 @@ }, { "$ref": "#/components/schemas/RemoteAgentRef", - "description": "Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...).\n`None` = a locally launchable agent, not bound to a remote platform." + "description": "Declarative reference to a remote, named agent. `None` = a locally launchable agent, not bound to a remote target." } ] }, @@ -5479,7 +5579,7 @@ "string", "null" ], - "description": "System prompt. Operator has no first-class system prompt, so this is\npreserved opaquely across import (see [`Delegator::unmapped_core`])." + "description": "System prompt. This is preserved opaquely across import (see [`Delegator::unmapped_core`])." }, "tools": { "type": "array", @@ -5489,10 +5589,10 @@ "description": "Tool names. Preserved opaquely across import." }, "x_agnt": { - "description": "AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`,\n`creditLimit`, ...). Operator never interprets this — pure pass-through." + "description": "AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, `creditLimit`, ...)." }, "x_openai": { - "description": "OpenAI-owned extension fields, opaque (`instructions`, `tools`,\n`tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a\nsecond platform — never interpreted. This field is the whole per-tool cost\nof adding `OpenAI`: a passthrough bag, no mapping logic." + "description": "OpenAI-owned extension fields, opaque (`instructions`, `tools`, `tool_resources`, `metadata`, thread refs, ...)." }, "x_operator": { "oneOf": [ @@ -5501,7 +5601,7 @@ }, { "$ref": "#/components/schemas/XOperator", - "description": "Operator-owned extension fields (typed). `None` when the agent carries no\nOperator-specific configuration." + "description": "Operator-owned extension fields (typed). `None` when the agent carries no Operator-specific configuration." } ] } @@ -5717,12 +5817,17 @@ "type": "object", "description": "Result of a successful bootstrap.", "required": [ - "state" + "state", + "username" ], "properties": { "state": { "$ref": "#/components/schemas/BootstrapState", - "description": "The state after submission — `Complete` on success." + "description": "The state after submission - `Complete` on success." + }, + "username": { + "type": "string", + "description": "The account name created by bootstrap." } } }, @@ -5862,7 +5967,7 @@ "expires_in_days": { "type": "integer", "format": "int64", - "description": "Days until the key expires. Expiry is mandatory — there is no\nnon-expiring key.", + "description": "Days until the key expires. Expiry is mandatory - there is no\nnon-expiring key.", "maximum": 365, "minimum": 1 }, @@ -5885,7 +5990,7 @@ }, "CreateAccessKeyResponse": { "type": "object", - "description": "A newly created access key. **The secret appears here and nowhere else,\never** — only its hash is stored, so it cannot be shown again.", + "description": "A newly created access key. **The secret appears here and nowhere else,\never** - only its hash is stored, so it cannot be shown again.", "required": [ "key", "secret" @@ -6403,7 +6508,7 @@ }, "subject": { "type": "string", - "description": "Account name — always `admin`, the single human account." + "description": "Account name - always `admin`, the single human account." } } }, @@ -7054,6 +7159,32 @@ } } }, + "ForgotPasswordRequest": { + "type": "object", + "description": "Request recovery instructions without revealing whether an account exists.", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "maxLength": 128, + "minLength": 1 + } + } + }, + "ForgotPasswordResponse": { + "type": "object", + "description": "Generic recovery guidance for a self-hosted Operator deployment.", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, "GitConfigEntry": { "type": "object", "required": [ @@ -7165,7 +7296,7 @@ "properties": { "node_id": { "type": "string", - "description": "`GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key" + "description": "`GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) - used as the project key" }, "number": { "type": "integer", @@ -7188,7 +7319,7 @@ }, "GithubSessionEnv": { "type": "object", - "description": "GitHub Projects session env body — includes the actual secret to set in env.", + "description": "GitHub Projects session env body - includes the actual secret to set in env.", "required": [ "token", "api_key_env" @@ -7421,7 +7552,7 @@ }, "JiraCredentials": { "type": "object", - "description": "Ephemeral Jira credentials supplied by a client during onboarding.\n\nThese are never persisted to disk by the onboarding endpoints that take\nthis struct — the actual secret stays in the env var named in\n`api_key_env` once set via `/api/v1/kanban/session-env`.", + "description": "Ephemeral Jira credentials supplied by a client during onboarding.\n\nThese are never persisted to disk by the onboarding endpoints that take\nthis struct - the actual secret stays in the env var named in\n`api_key_env` once set via `/api/v1/kanban/session-env`.", "required": [ "domain", "email", @@ -7446,7 +7577,7 @@ }, "JiraSessionEnv": { "type": "object", - "description": "Jira session env body — includes the actual secret to set in env.", + "description": "Jira session env body - includes the actual secret to set in env.", "required": [ "domain", "email", @@ -7815,6 +7946,12 @@ "session_wrapper": { "$ref": "#/components/schemas/SessionWrapper" }, + "target": { + "type": [ + "string", + "null" + ] + }, "yolo_enabled": { "type": "boolean" } @@ -7872,6 +8009,13 @@ ], "default": null }, + "target": { + "type": [ + "string", + "null" + ], + "default": null + }, "yolo_enabled": { "type": [ "boolean", @@ -7898,21 +8042,21 @@ "string", "null" ], - "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator" + "description": "Model to use (e.g., \"sonnet\", \"opus\") - legacy fallback when no delegator" }, "model_server": { "type": [ "string", "null" ], - "description": "Ad-hoc model server to target (e.g. \"ollama-local\") — legacy fallback when\nno delegator. Injects the server's base URL / API key env at spawn." + "description": "Ad-hoc model server to target (e.g. \"ollama-local\") - legacy fallback when\nno delegator. Injects the server's base URL / API key env at spawn." }, "provider": { "type": [ "string", "null" ], - "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator" + "description": "LLM provider to use (e.g., \"claude\") - legacy fallback when no delegator" }, "resume_session_id": { "type": [ @@ -8045,7 +8189,7 @@ }, "LinearSessionEnv": { "type": "object", - "description": "Linear session env body — includes the actual secret to set in env.", + "description": "Linear session env body - includes the actual secret to set in env.", "required": [ "api_key", "api_key_env" @@ -8178,7 +8322,7 @@ }, "ListKanbanStatusesRequest": { "type": "object", - "description": "Request to list workflow statuses/columns for a specific project using\nephemeral creds (onboarding wizard — before any config is persisted).", + "description": "Request to list workflow statuses/columns for a specific project using\nephemeral creds (onboarding wizard - before any config is persisted).", "required": [ "provider", "project_key" @@ -8274,6 +8418,7 @@ "type": "object", "description": "Password login, exchanged for an opaque server-side session cookie.", "required": [ + "username", "password" ], "properties": { @@ -8284,12 +8429,18 @@ "writeOnly": true, "maxLength": 1024, "minLength": 12 + }, + "username": { + "type": "string", + "description": "The account name. Required even while Operator supports one human account.", + "maxLength": 128, + "minLength": 1 } } }, "LoginResponse": { "type": "object", - "description": "Successful login. The session itself rides in a `Set-Cookie` header, not in\nthis body — a body-borne session identifier would be readable by script.", + "description": "Successful login. The session itself rides in a `Set-Cookie` header, not in\nthis body - a body-borne session identifier would be readable by script.", "required": [ "scopes", "expires_at", @@ -8631,7 +8782,7 @@ }, "OAuthErrorResponse": { "type": "object", - "description": "Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can\ninterpret it — notably `authorization_pending` and `slow_down`, which a\ndevice-flow client polls against.", + "description": "Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can\ninterpret it - notably `authorization_pending` and `slow_down`, which a\ndevice-flow client polls against.", "required": [ "error" ], @@ -8658,7 +8809,7 @@ }, "OpenspecSourceDto": { "type": "object", - "description": "`OpenSpec` source location supplied during onboarding. Not a credential —\n`OpenSpec` reads local markdown; there is no secret to validate or store.", + "description": "`OpenSpec` source location supplied during onboarding. Not a credential -\n`OpenSpec` reads local markdown; there is no secret to validate or store.", "required": [ "root_path" ], @@ -9109,7 +9260,7 @@ }, "RemoteAgentRef": { "type": "object", - "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) — deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", + "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) - deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", "required": [ "platform", "id" @@ -9125,6 +9276,48 @@ } } }, + "ResetPasswordRequest": { + "type": "object", + "description": "Change the account password after proving knowledge of the current one.", + "required": [ + "username", + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string", + "format": "password", + "writeOnly": true, + "maxLength": 1024, + "minLength": 12 + }, + "new_password": { + "type": "string", + "format": "password", + "writeOnly": true, + "maxLength": 1024, + "minLength": 12 + }, + "username": { + "type": "string", + "maxLength": 128, + "minLength": 1 + } + } + }, + "ResetPasswordResponse": { + "type": "object", + "description": "Result of changing the account password.", + "required": [ + "changed" + ], + "properties": { + "changed": { + "type": "boolean" + } + } + }, "ReviewResponse": { "type": "object", "description": "Response for agent review operations (approve/reject)", @@ -9321,7 +9514,7 @@ }, "SessionSummary": { "type": "object", - "description": "An active or expired browser session. Carries no session identifier — the\ncookie value is never readable back out, only the session's `id` for\nrevocation.", + "description": "An active or expired browser session. Carries no session identifier - the\ncookie value is never readable back out, only the session's `id` for\nrevocation.", "required": [ "id", "created_at", @@ -9437,7 +9630,7 @@ }, "SetKanbanSessionEnvResponse": { "type": "object", - "description": "Response from setting session env vars.\n\n`shell_export_block` uses `` placeholders, NOT the actual\nsecret — it is meant for the user to copy into their shell profile.", + "description": "Response from setting session env vars.\n\n`shell_export_block` uses `` placeholders, NOT the actual\nsecret - it is meant for the user to copy into their shell profile.", "required": [ "env_vars_set", "shell_export_block" @@ -10446,7 +10639,7 @@ }, "ValidateKanbanCredentialsResponse": { "type": "object", - "description": "Response from validating kanban credentials.\n\n`valid: false` is returned for auth failures — never a 4xx/5xx HTTP\nstatus — so clients can display `error` inline without exception handling.", + "description": "Response from validating kanban credentials.\n\n`valid: false` is returned for auth failures - never a 4xx/5xx HTTP\nstatus - so clients can display `error` inline without exception handling.", "required": [ "valid" ], @@ -10530,7 +10723,7 @@ }, "WorkflowFormatDto": { "type": "object", - "description": "One workflow export format operator can emit, for `GET /api/v1/workflow-formats`.\n\nA projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry —\nthe single source of truth for the format's [`SupportStatus`] and docs. Lets\nthe UIs render a format picker without hardcoding the list.", + "description": "One workflow export format operator can emit, for `GET /api/v1/workflow-formats`.\n\nA projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry -\nthe single source of truth for the format's [`SupportStatus`] and docs. Lets\nthe UIs render a format picker without hardcoding the list.", "required": [ "slug", "label", @@ -10555,7 +10748,7 @@ }, "slug": { "type": "string", - "description": "Stable slug (e.g. \"claude\", \"agnt\") — the value the `format` query param takes." + "description": "Stable slug (e.g. \"claude\", \"agnt\") - the value the `format` query param takes." }, "status": { "$ref": "#/components/schemas/SupportStatus", @@ -10640,7 +10833,7 @@ "properties": { "api_key_env": { "type": "string", - "description": "Env var name where the project-scoped token is set\n(default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN`\n— see Token Disambiguation in the kanban github docs." + "description": "Env var name where the project-scoped token is set\n(default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN`\n- see Token Disambiguation in the kanban github docs." }, "owner": { "type": "string", @@ -10708,7 +10901,7 @@ }, "WriteKanbanConfigRequest": { "type": "object", - "description": "Request to write or upsert a kanban config section.\n\nThis endpoint does NOT take the secret — only the env var NAME\n(`api_key_env`). The secret is set via `/api/v1/kanban/session-env`.", + "description": "Request to write or upsert a kanban config section.\n\nThis endpoint does NOT take the secret - only the env var NAME\n(`api_key_env`). The secret is set via `/api/v1/kanban/session-env`.", "required": [ "provider" ], @@ -10838,7 +11031,7 @@ }, "XOperator": { "type": "object", - "description": "The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator\ncarries that have no shared-core equivalent. AGNT ignores this bag; Operator\nround-trips it losslessly.", + "description": "The Operator-namespaced half of an [`AgentProfile`] - the fields a Delegator\ncarries that have no shared-core equivalent.", "properties": { "display_name": { "type": [ diff --git a/docs/schemas/state.md b/docs/schemas/state.md index 9bcb2390..3215eebd 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -38,6 +38,7 @@ This file tracks the current state of agents, completed tickets, and system stat | Property | Type | Required | Description | | --- | --- | --- | --- | +| `git_context` | object | No | Non-secret Git configuration captured at launch. | | `id` | `string` | Yes | | | `ticket_id` | `string` | Yes | | | `ticket_type` | `string` | Yes | | @@ -71,6 +72,42 @@ This file tracks the current state of agents, completed tickets, and system stat | `step_launch_context` | object | No | Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. | | `target_name` | `string` \| `null` | No | Name of the resolved execution target this agent launched on | +### GitExecutionConfig + +Git settings owned by a named delegator. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `identity` | object | No | | +| `credentials` | object | No | | +| `settings` | `array` | No | | + +### GitIdentityConfig + +Commit identity template for delegated work. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | | +| `email` | `string` | Yes | | + +### GitCredentialConfig + +Supplied HTTPS credential, bound to a repository; contains no secret value. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `repository_url` | `string` | Yes | | +| `username` | `string` | Yes | | +| `token_env` | `string` | Yes | | + +### GitConfigEntry + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `key` | `string` | Yes | | +| `value` | `string` | Yes | | + ### StepLaunchContext Launch context fixed at launch time, persisted with the agent record, and diff --git a/docs/startup/index.md b/docs/startup/index.md index 170855aa..6e27bb3b 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -3,7 +3,7 @@ title: "Setup Wizard" layout: doc --- - + When Operator starts and no `.tickets/` directory exists, the setup wizard guides you through first-time initialization. This reference documents each step of the wizard. @@ -13,21 +13,23 @@ When Operator starts and no `.tickets/` directory exists, the setup wizard guide | Step | Name | Description | | --- | --- | --- | | 1 | Welcome | Splash screen showing detected LLM tools and discovered projects | -| 2 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | -| 3 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | -| 4 | Web UI Password | Optionally set the admin password for the web dashboard | -| 5 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | -| 6 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | -| 7 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | -| 8 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | -| 9 | Kanban Info | Kanban integration overview and provider credential detection | -| 10 | Kanban Provider Setup | Per-provider credential validation and project selection | -| 11 | Collection Source | Choose which issue type collection to use | -| 12 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | -| 13 | Task Field Config | Configure optional fields for TASK issue type | -| 14 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | -| 15 | Startup Tickets | Optionally create tickets to bootstrap your projects | -| 16 | Confirm | Review settings and confirm initialization | +| 2 | Kanban Info | Connect a kanban provider, or skip and connect one later | +| 3 | Model Server | Declare which model providers this workspace uses | +| 4 | Git Provider | Connect a git provider so agents can branch, push and open PRs | +| 5 | Collection Source | Choose which issue type collection to use | +| 6 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | +| 7 | Task Field Config | Configure optional fields for TASK issue type | +| 8 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | +| 9 | Execution Target | Choose whether agents run locally or in Coder workspaces | +| 10 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | +| 11 | Web UI Password | Optionally set the admin password for the web dashboard | +| 12 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | +| 13 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | +| 14 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | +| 15 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | +| 16 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | +| 17 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 18 | Confirm | Review settings and confirm initialization | ## Step Details @@ -45,7 +47,89 @@ This gives you an overview of your development environment before proceeding. **Navigation**: Enter to continue, Esc to cancel -### 2. Session Wrapper Choice +### 2. Kanban Info + +*Connect a kanban provider, or skip and connect one later* + +Operator can sync with external kanban providers to pull in issues as tickets. +Supported providers: Jira, Linear, GitHub Projects. + +Credentials already exported (e.g. OPERATOR_JIRA_API_KEY) are listed as detected providers. + +**Connect a kanban provider** opens the same onboarding dialog the dashboard uses: pick a provider, enter its credentials, validate them against the live API, and choose a project. The provider section is written to config.toml and the token is exported into this session, with a shell snippet to make it permanent. + +**Skip for now** moves on; press `K` from the dashboard at any time. + +**Navigation**: ↑/↓ to select, Enter to confirm, Esc to go back + +### 3. Model Server + +*Declare which model providers this workspace uses* + +Model providers are where inference happens - distinct from the agent CLI that calls them. + +Each provider is probed live: a row reads `N models` when Operator can reach it, `key missing` when its API key env var is unset, or `unreachable` with the reason. + +Space declares a provider, writing a `[[model_servers]]` entry. Operator stores only the *name* of the environment variable holding the key, never the key itself - export it in your shell to make it permanent. + +Providers needing a custom base URL (OpenAI-compatible, LM Studio) are listed but not selectable here; add them to config.toml directly. + +This step is optional - Operator ships working defaults for the first-party vendors. + +**Navigation**: ↑/↓ or j/k to navigate, Space to declare, Enter to continue, Esc to go back + +### 4. Git Provider + +*Connect a git provider so agents can branch, push and open PRs* + +Operator branches per ticket and opens pull requests on your behalf, which needs a provider and a token. + +Each row reports what was found: the provider CLI (`gh`, `glab`, `tea`) not installed, an existing CLI login Operator can adopt with no typing, or a prompt for a personal access token. + +Only the *name* of the environment variable holding the token is written to config.toml. The token itself is exported into this session, and the step prints the shell line to make that permanent - without it the token is gone when Operator exits. + +This step is optional; Operator works against a local repository with no provider connected. + +**Navigation**: ↑/↓ or j/k to navigate, Enter to connect, Esc to go back + +### 5. Collection Source + +*Choose which issue type collection to use* + +Select a preset collection of issue types: +- **Simple**: Just TASK - minimal setup for general work +- **Dev Kanban**: 3 types (TASK, FEAT, FIX) for development workflows +- **DevOps Kanban**: 5 types (TASK, SPIKE, INV, FEAT, FIX) for full DevOps +- **Custom Selection**: Choose individual issue types + +**Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back + +### 6. Hosted Collections + +*Browse and select hosted collections (only shown if Browse chosen)* + +Pick one or more curated collections published at operator.untra.io. + +The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. + +Selections are additive - choose as many as apply. + +**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back + +### 7. Task Field Config + +*Configure optional fields for TASK issue type* + +TASK is the foundational issue type. Configure which optional fields to include: +- **priority**: Priority level (P0-critical to P3-low) +- **points**: Story points estimate +- **user_story**: User story or background context + +These choices propagate to other issue types. The 'summary' field is always required, and 'id' is auto-generated. + +**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back + +### 8. Session Wrapper Choice *Select which session wrapper to use for launching coding agents* @@ -59,7 +143,19 @@ Your choice determines which setup steps follow. **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 3. Worktree Preference +### 9. Execution Target + +*Choose whether agents run locally or in Coder workspaces* + +Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH. + +Coder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment. + +Coder targets disable git worktrees and relay injection, and cannot be combined with Zellij. + +**Navigation**: ↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back + +### 10. Worktree Preference *Choose whether to use git worktrees for ticket isolation* @@ -71,7 +167,7 @@ Worktrees allow multiple agents to work on different tickets simultaneously with **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 4. Web UI Password +### 11. Web UI Password *Optionally set the admin password for the web dashboard* @@ -85,7 +181,7 @@ The password must be at least 12 characters. This step is hidden whe **Navigation**: Tab to switch fields, Enter to continue (blank to skip), Esc to go back -### 5. Tmux Onboarding +### 12. Tmux Onboarding *Help and documentation about tmux session management (shown if tmux selected)* @@ -99,7 +195,7 @@ Operator session names start with 'op-' for easy identification. **Navigation**: Enter to continue, Esc to go back -### 6. VS Code Setup +### 13. VS Code Setup *VS Code extension setup and verification (shown if VS Code selected)* @@ -110,7 +206,7 @@ Install the extension from the VS Code marketplace if prompted. **Navigation**: Enter to continue, Esc to go back -### 7. Cmux Setup +### 14. Cmux Setup *cmux session wrapper setup (shown if cmux selected)* @@ -120,7 +216,7 @@ This step verifies the cmux app's CLI binary exists at the configured binary_pat **Navigation**: Enter to continue, Esc to go back -### 8. Zellij Setup +### 15. Zellij Setup *Zellij session wrapper setup (shown if Zellij selected)* @@ -130,68 +226,7 @@ This step verifies Zellij is installed and configures the layout Operator will u **Navigation**: Enter to continue, Esc to go back -### 9. Kanban Info - -*Kanban integration overview and provider credential detection* - -Operator can sync with external kanban providers to pull in issues as tickets. -Supported providers: Jira, Linear, GitHub Projects. - -Credentials are read from environment variables (e.g. OPERATOR_JIRA_API_KEY). This step shows which providers were detected and validates connectivity. - -**Navigation**: Enter to continue, Esc to go back - -### 10. Kanban Provider Setup - -*Per-provider credential validation and project selection* - -For each detected provider, Operator: -1. Validates your API credentials against the provider -2. Fetches your workspace and user information -3. Discovers available projects for you to select - -Only projects you select will be synced to your ticket queue. You can skip this step to configure kanban providers later. - -**Navigation**: ↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back - -### 11. Collection Source - -*Choose which issue type collection to use* - -Select a preset collection of issue types: -- **Simple**: Just TASK - minimal setup for general work -- **Dev Kanban**: 3 types (TASK, FEAT, FIX) for development workflows -- **DevOps Kanban**: 5 types (TASK, SPIKE, INV, FEAT, FIX) for full DevOps -- **Custom Selection**: Choose individual issue types - -**Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back - -### 12. Hosted Collections - -*Browse and select hosted collections (only shown if Browse chosen)* - -Pick one or more curated collections published at operator.untra.io. - -The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. - -Selections are additive - choose as many as apply. - -**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back - -### 13. Task Field Config - -*Configure optional fields for TASK issue type* - -TASK is the foundational issue type. Configure which optional fields to include: -- **priority**: Priority level (P0-critical to P3-low) -- **points**: Story points estimate -- **user_story**: User story or background context - -These choices propagate to other issue types. The 'summary' field is always required, and 'id' is auto-generated. - -**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back - -### 14. Acceptance Criteria +### 16. Acceptance Criteria *Review and configure acceptance criteria for ticket completion* @@ -202,7 +237,7 @@ The default criteria cover formatting, tests, and lint checks. You can customize **Navigation**: Enter to continue, Esc to go back -### 15. Startup Tickets +### 17. Startup Tickets *Optionally create tickets to bootstrap your projects* @@ -215,7 +250,7 @@ These tickets are optional and help automate common setup tasks. **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 16. Confirm +### 18. Confirm *Review settings and confirm initialization* diff --git a/shared/types.ts b/shared/types.ts index aa1ef078..66a0f6d2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -366,6 +366,11 @@ export type UiConfig = { refresh_rate_ms: bigint, completed_history_hours: bigin export type PanelNamesConfig = { status: string, queue: string, in_progress: string, completed: string, }; export type LaunchConfig = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, +/** + * Default named execution target. Per-launch and per-delegator choices + * take precedence. + */ +target: string | null, /** * Docker execution configuration */ @@ -415,9 +420,7 @@ export type RestApiConfig = { */ enabled: boolean, /** - * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so - * the server — which reports the project directory name — is not reachable - * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. */ host: string, /** @@ -429,10 +432,7 @@ port: number, */ cors_origins: Array, /** - * Externally reachable base URL (e.g. `https://operator.example.com`). - * - * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, - * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + * Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. */ public_url: string | null, }; @@ -603,9 +603,9 @@ model_server: string | null, * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). * * Export-only: Operator has no runtime client for those platforms, so a - * delegator carrying this CANNOT be launched locally — resolution errors out + * delegator carrying this CANNOT be launched locally - resolution errors out * (see `delegator_resolution`). It is stored, listed, serialized into an - * `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + * `AgentProfile`, and - for `platform == "agnt"` - surfaced in the * `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose * `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the * `id` must be the agent's UUID, not its display name). `None` = ordinary, @@ -697,8 +697,7 @@ provider: string, */ model: string, /** - * System prompt. Operator has no first-class system prompt, so this is - * preserved opaquely across import (see [`Delegator::unmapped_core`]). + * System prompt. This is preserved opaquely across import (see [`Delegator::unmapped_core`]). */ system_prompt?: string | null, /** @@ -714,25 +713,19 @@ mcp_servers: Array, */ tools: Array, /** - * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). - * `None` = a locally launchable agent, not bound to a remote platform. + * Declarative reference to a remote, named agent. `None` = a locally launchable agent, not bound to a remote target. */ remote_agent?: RemoteAgentRef | null, /** - * Operator-owned extension fields (typed). `None` when the agent carries no - * Operator-specific configuration. + * Operator-owned extension fields (typed). `None` when the agent carries no Operator-specific configuration. */ x_operator?: XOperator | null, /** - * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, - * `creditLimit`, ...). Operator never interprets this — pure pass-through. + * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, `creditLimit`, ...). */ x_agnt?: JsonValue | null, /** - * OpenAI-owned extension fields, opaque (`instructions`, `tools`, - * `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a - * second platform — never interpreted. This field is the whole per-tool cost - * of adding `OpenAI`: a passthrough bag, no mapping logic. + * OpenAI-owned extension fields, opaque (`instructions`, `tools`, `tool_resources`, `metadata`, thread refs, ...). */ x_openai?: JsonValue | null, }; @@ -1190,7 +1183,7 @@ contents: string, }; export type WorkflowFormatDto = { /** - * Stable slug (e.g. "claude", "agnt") — the value the `format` query param takes. + * Stable slug (e.g. "claude", "agnt") - the value the `format` query param takes. */ slug: string, /** @@ -1709,7 +1702,7 @@ export type VsCodeLaunchOptions = { */ delegator: string | null, /** - * Model to use (sonnet, opus, haiku) — fallback when no delegator + * Model to use (sonnet, opus, haiku) - fallback when no delegator */ model: VsCodeModelOption, /** @@ -1758,4 +1751,3 @@ worktreePath?: string, * Git branch name */ branch?: string, }; - diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index fdc3040c..2efc5dfa 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -134,8 +134,9 @@ fn adhoc_model_server_env( /// 2. `host` name set (deprecated) → ssh target of that name /// 3. `docker: Some(true)` (deprecated) → synthesized docker target /// 4. `docker: Some(false)` → local -/// 5. `launch.docker.enabled` → synthesized docker target -/// 6. → local +/// 5. `launch.target` → named global default +/// 6. `launch.docker.enabled` → synthesized docker target +/// 7. → local /// /// Deprecated combinations resolve deterministically (`target` wins over /// `host`/`docker`; `host` wins over `docker: true`) with one deprecation @@ -181,6 +182,9 @@ pub fn resolve_target( None => {} } } + if let Some(name) = &config.launch.target { + return resolve_named_target(config, name); + } if config.launch.docker.enabled { return Ok(TargetDef::docker(config.launch.docker.clone())); } @@ -926,7 +930,16 @@ mod tests { } #[test] - fn test_target_row5_enabled_true_now_targets_docker_for_auto_launches() { + fn test_target_row5_global_target_beats_docker_fallback() { + let mut config = Config::default(); + config.launch.target = Some("local".to_string()); + config.launch.docker.enabled = true; + let target = resolve_target(None, &config).unwrap(); + assert_eq!(target.kind, TargetKind::Local); + } + + #[test] + fn test_target_row6_enabled_true_now_targets_docker_for_auto_launches() { // BEHAVIOR CHANGE (approved): launch.docker.enabled was previously only // a TUI dialog gate; it is now a real resolution fallback, so REST/CLI/ // auto launches with enabled = true run in docker. @@ -942,7 +955,7 @@ mod tests { } #[test] - fn test_target_row6_default_is_local() { + fn test_target_row7_default_is_local() { let config = Config::default(); assert_eq!( resolve_target(None, &config).unwrap().kind, diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index dcef1e92..b06ef984 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -201,7 +201,7 @@ pub fn launch_in_cmux_with_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } @@ -377,7 +377,9 @@ pub fn launch_in_cmux_with_relaunch_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index ba83f64e..eb2346c0 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -178,8 +178,15 @@ fn wrap_for_target_impl( /// Whether Operator itself is running inside a container (docker/podman). fn is_containerized() -> bool { - std::path::Path::new("/.dockerenv").exists() - || std::path::Path::new("/run/.containerenv").exists() + container_signals( + std::path::Path::new("/.dockerenv").exists(), + std::path::Path::new("/run/.containerenv").exists(), + std::env::var_os("KUBERNETES_SERVICE_HOST").is_some(), + ) +} + +fn container_signals(docker: bool, podman: bool, kubernetes: bool) -> bool { + docker || podman || kubernetes } /// Build a docker command that wraps the LLM command. @@ -1151,6 +1158,14 @@ mod tests { ); } + #[test] + fn test_container_signals_include_kubernetes() { + assert!(container_signals(true, false, false)); + assert!(container_signals(false, true, false)); + assert!(container_signals(false, false, true)); + assert!(!container_signals(false, false, false)); + } + // ======================================== // get_default_model() tests // ======================================== diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index e355552e..c67c8ce2 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -951,7 +951,7 @@ impl Launcher { model, yolo: options.yolo_mode, session_id: session_id.map(str::to_string), - opr8r: step_command::resolve_opr8r_invocation(options.is_docker()), + opr8r: step_command::resolve_opr8r_invocation(options.resolves_on_target_path()), operator_relay: options.operator_relay, extra_flags: options.extra_flags.clone(), } @@ -1131,7 +1131,7 @@ impl Launcher { // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); if step_command::chain_step(&self.config, &ticket, &step_name) { llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); @@ -1440,7 +1440,9 @@ impl Launcher { // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); if step_command::chain_step(&self.config, &ticket, &step_name) { llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index b19b677c..f09483c1 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -101,6 +101,12 @@ impl LaunchOptions { matches!(self.target.kind, TargetKind::Docker(_)) } + /// Whether the launched command executes outside this machine and must + /// resolve executables from the target's PATH. + pub fn resolves_on_target_path(&self) -> bool { + !matches!(self.target.kind, TargetKind::Local) + } + /// The remote host this launch runs on, if any: the provisioned coder /// workspace when set, else an ssh target's declared host. pub fn remote_host(&self) -> Option { @@ -208,6 +214,17 @@ mod tests { } } + #[test] + fn test_resolves_on_target_path_for_execution_targets() { + assert!(!options_with(TargetKind::Local, false).resolves_on_target_path()); + assert!( + options_with(TargetKind::Docker(DockerConfig::default()), false) + .resolves_on_target_path() + ); + assert!(options_with(TargetKind::Coder(coder_config()), false).resolves_on_target_path()); + assert!(options_with(TargetKind::Ssh(ssh_target()), false).resolves_on_target_path()); + } + #[test] fn test_launch_mode_roundtrip() { let kinds = [ diff --git a/src/agents/launcher/step_command.rs b/src/agents/launcher/step_command.rs index 4a7ac78c..95c0cb30 100644 --- a/src/agents/launcher/step_command.rs +++ b/src/agents/launcher/step_command.rs @@ -176,10 +176,10 @@ pub fn chain_step(config: &Config, ticket: &Ticket, step_name: &str) -> bool { /// Resolve the opr8r invocation for the launch environment. /// -/// Inside a container the image ships `opr8r` on PATH; locally the binary -/// sits alongside `operator` (or on PATH as a fallback). -pub fn resolve_opr8r_invocation(containerized: bool) -> String { - if containerized { +/// Commands that execute on another target resolve `opr8r` from that target's +/// PATH; local commands prefer the binary alongside `operator`. +pub fn resolve_opr8r_invocation(on_target_path: bool) -> String { + if on_target_path { return "opr8r".to_string(); } locate_opr8r_binary().map_or_else(|| "opr8r".to_string(), |p| p.display().to_string()) @@ -517,10 +517,29 @@ mod tests { } #[test] - fn test_resolve_opr8r_invocation_containerized_uses_path_name() { + fn test_resolve_opr8r_invocation_on_target_uses_path_name() { assert_eq!(resolve_opr8r_invocation(true), "opr8r"); } + #[test] + fn test_resolve_opr8r_invocation_coder_uses_path_name() { + let options = crate::agents::launcher::LaunchOptions { + target: crate::config::TargetDef { + name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), + display_name: None, + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: "operator-agent".to_string(), + ..Default::default() + }), + }, + ..Default::default() + }; + assert_eq!( + resolve_opr8r_invocation(options.resolves_on_target_path()), + "opr8r" + ); + } + #[test] fn test_step_launch_context_roundtrip() { let ctx = make_ctx(); diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index 06abeb74..c64d7580 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -208,7 +208,7 @@ pub fn launch_in_tmux_with_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } @@ -474,7 +474,9 @@ pub fn launch_in_tmux_with_relaunch_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 6669d458..5d5df9be 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -136,7 +136,7 @@ pub fn launch_in_zellij_with_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } @@ -316,7 +316,9 @@ pub fn launch_in_zellij_with_relaunch_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } diff --git a/src/app/git_onboarding.rs b/src/app/git_onboarding.rs index 90bbe5d4..23915312 100644 --- a/src/app/git_onboarding.rs +++ b/src/app/git_onboarding.rs @@ -1,277 +1,47 @@ -//! Git provider onboarding logic. -//! -//! Detects CLI tools, grabs tokens, validates credentials, and resolves -//! the appropriate onboarding step for a given provider. - -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result}; - -use crate::api::cli_detection::onboarding_spec_for_slug; -use crate::config::{Config, GitProviderConfig}; - -/// The resolved onboarding step for a provider. -#[derive(Debug)] -pub enum OnboardingStep { - /// CLI not installed - open install page. - InstallCli { - install_url: String, - provider_display: String, - }, - /// CLI installed but no token - show PAT dialog. - CollectToken { - pat_url: String, - provider: String, - provider_display: String, - placeholder: String, - }, - /// CLI installed and authenticated - token ready to use. - AutoConfigured { - username: String, - token: String, - provider: String, - provider_display: String, - }, -} - -/// Check if a CLI tool is available on PATH (synchronous). -fn is_cli_installed(command: &str) -> bool { - Command::new(command) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Try to grab an auth token from a CLI tool (synchronous). -fn grab_cli_token(command: &str, args: &[&str]) -> Option { - let output = Command::new(command) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .ok()?; - - if output.status.success() { - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if token.is_empty() { - None - } else { - Some(token) - } - } else { - None - } -} - -/// Validate a GitHub personal access token and return the username. -pub fn validate_github_token(token: &str) -> Result { - let client = reqwest::blocking::Client::new(); - let resp = client - .get("https://api.github.com/user") - .header("Authorization", format!("Bearer {token}")) - .header("User-Agent", "operator") - .send() - .context("Failed to reach GitHub API")?; - - if !resp.status().is_success() { - anyhow::bail!("GitHub token validation failed (HTTP {})", resp.status()); - } - - let body: serde_json::Value = resp.json().context("Failed to parse GitHub response")?; - body["login"] - .as_str() - .map(std::string::ToString::to_string) - .context("GitHub response missing 'login' field") -} - -/// Validate a GitLab personal access token and return the username. -pub fn validate_gitlab_token(token: &str) -> Result { - let client = reqwest::blocking::Client::new(); - let resp = client - .get("https://gitlab.com/api/v4/user") - .header("Private-Token", token) - .header("User-Agent", "operator") - .send() - .context("Failed to reach GitLab API")?; - - if !resp.status().is_success() { - anyhow::bail!("GitLab token validation failed (HTTP {})", resp.status()); - } - - let body: serde_json::Value = resp.json().context("Failed to parse GitLab response")?; - body["username"] - .as_str() - .map(std::string::ToString::to_string) - .context("GitLab response missing 'username' field") -} - -/// Resolve the onboarding step for a provider. -/// -/// Checks CLI installation → CLI authentication → returns the appropriate step. -pub fn resolve_onboarding(provider: &str) -> Option { - let meta = onboarding_spec_for_slug(provider)?; - - if !is_cli_installed(meta.command) { - return Some(OnboardingStep::InstallCli { - install_url: meta.install_url.to_string(), - provider_display: meta.display_name.to_string(), - }); - } - - if let Some(token) = (!meta.auth_args.is_empty()) - .then(|| grab_cli_token(meta.command, meta.auth_args)) - .flatten() - { - // Validate the token - let username = match provider { - "github" => validate_github_token(&token), - "gitlab" => validate_gitlab_token(&token), - _ => return None, - }; - - if let Ok(username) = username { - return Some(OnboardingStep::AutoConfigured { +//! TUI orchestration for shared git provider onboarding. + +pub(crate) use crate::services::git_onboarding::{ + apply_git_provider, complete_git_onboarding, resolve_onboarding_with_config, + shell_export_block, token_env_for, validate_token_with_config, OnboardingStep, +}; + +impl crate::app::App { + pub(super) fn connect_git_provider_from_setup(&mut self, slug: &str) { + let step = resolve_onboarding_with_config(&self.config, slug); + let status = match step { + Some(OnboardingStep::InstallCli { + install_url, + provider_display, + }) => { + let _ = crate::app::status_actions::open_in_browser(&install_url); + format!("install the {provider_display} CLI, then retry") + } + Some(OnboardingStep::CollectToken { + pat_url, + provider, + provider_display, + placeholder, + }) => { + let _ = crate::app::status_actions::open_in_browser(&pat_url); + self.git_token_dialog + .show(&provider, &provider_display, &pat_url, &placeholder); + return; + } + Some(OnboardingStep::AutoConfigured { username, token, - provider: provider.to_string(), - provider_display: meta.display_name.to_string(), - }); - } - // CLI token is stale/invalid, fall through to manual entry - } - - Some(OnboardingStep::CollectToken { - pat_url: meta.pat_url.to_string(), - provider: provider.to_string(), - provider_display: meta.display_name.to_string(), - placeholder: meta.placeholder.to_string(), - }) -} - -/// Complete git onboarding by writing provider config and setting the env var. -pub fn complete_git_onboarding(config: &mut Config, provider: &str, token: &str) -> Result<()> { - match provider { - "github" => { - config.git.provider = Some(GitProviderConfig::GitHub); - config.git.github.enabled = true; - config.save()?; - std::env::set_var(&config.git.github.token_env, token); - } - "gitlab" => { - config.git.provider = Some(GitProviderConfig::GitLab); - config.git.gitlab.enabled = true; - config.save()?; - std::env::set_var(&config.git.gitlab.token_env, token); - } - "gitea" => { - config.git.provider = Some(GitProviderConfig::Gitea); - config.git.gitea.enabled = true; - config.save()?; - std::env::set_var(&config.git.gitea.token_env, token); - } - _ => anyhow::bail!("Unsupported provider: {provider}"), - } - Ok(()) -} - -/// Validate a token for the given provider, returning the username on success. -pub fn validate_token(provider: &str, token: &str) -> Result { - match provider { - "github" => validate_github_token(token), - "gitlab" => validate_gitlab_token(token), - _ => anyhow::bail!("Unsupported provider: {provider}"), - } -} - -pub fn resolve_onboarding_with_config(config: &Config, provider: &str) -> Option { - let mut step = resolve_onboarding(provider)?; - if provider == "gitea" { - let base = - crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com") - .ok()?; - if let OnboardingStep::CollectToken { pat_url, .. } = &mut step { - *pat_url = base.join("user/settings/applications").ok()?.to_string(); + provider, + .. + }) => match apply_git_provider(&mut self.config, &provider, &token) { + Ok(()) => format!("connected as {username}"), + Err(error) => format!("failed: {error}"), + }, + None => "unsupported provider".to_string(), + }; + let export_hint = token_env_for(&self.config, slug).map(|env| shell_export_block(&env)); + if let Some(setup) = self.setup_screen.as_mut() { + setup.set_git_provider_status(slug, status); + setup.git_export_hint = export_hint; } } - Some(step) -} - -pub fn validate_token_with_config(config: &Config, provider: &str, token: &str) -> Result { - if provider != "gitea" { - return validate_token(provider, token); - } - let base = crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com")?; - let git = crate::config::GitExecutionConfig { - credentials: Some(crate::config::GitCredentialConfig { - repository_url: base.join("operator/authentication")?.to_string(), - username: "operator".into(), - token_env: config.git.gitea.token_env.clone(), - }), - ..Default::default() - }; - let runtime = crate::git::runtime::GitRuntime::create_with_token(&git, Some(token))?; - let output = Command::new(crate::api::cli_detection::binary_for( - crate::types::pr::GitProvider::Gitea, - )) - .args(["api", "--login", "operator", "user"]) - .env("XDG_CONFIG_HOME", &runtime.path) - .output() - .context("Gitea requires tea with the api command")?; - anyhow::ensure!(output.status.success(), "Gitea token validation failed"); - let body: serde_json::Value = serde_json::from_slice(&output.stdout)?; - body["login"] - .as_str() - .map(str::to_owned) - .context("Gitea response missing login") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_meta_for_github() { - let meta = onboarding_spec_for_slug("github").unwrap(); - assert_eq!(meta.command, "gh"); - assert_eq!(meta.display_name, "GitHub"); - assert_eq!( - meta.pat_url, - "https://github.com/settings/personal-access-tokens/new" - ); - } - - #[test] - fn test_meta_for_gitlab() { - let meta = onboarding_spec_for_slug("gitlab").unwrap(); - assert_eq!(meta.command, "glab"); - assert_eq!(meta.display_name, "GitLab"); - assert_eq!( - meta.pat_url, - "https://gitlab.com/-/user_settings/personal_access_tokens" - ); - } - - #[test] - fn test_meta_for_unknown_returns_none() { - assert!(onboarding_spec_for_slug("bitbucket").is_none()); - assert!(onboarding_spec_for_slug("").is_none()); - } - - #[test] - fn test_is_cli_installed_nonexistent() { - assert!(!is_cli_installed("nonexistent-cli-tool-xyz-12345")); - } - - #[test] - fn test_grab_cli_token_nonexistent() { - assert!(grab_cli_token("nonexistent-cli-tool-xyz-12345", &["auth", "token"]).is_none()); - } - - #[test] - fn test_resolve_onboarding_unknown_provider() { - assert!(resolve_onboarding("bitbucket").is_none()); - } } diff --git a/src/app/kanban_onboarding.rs b/src/app/kanban_onboarding.rs index de978e77..61193a24 100644 --- a/src/app/kanban_onboarding.rs +++ b/src/app/kanban_onboarding.rs @@ -37,6 +37,16 @@ pub(crate) struct LinearCredsInflight { impl App { /// Show the kanban onboarding dialog (entry point from the kanban view). + /// `write_config` persists straight to disk (re-reading it first, so + /// concurrent writers are not clobbered). Pick the result back up, or the + /// next `self.config.save()` would write the section away again. + fn reload_config_after_kanban_write(&mut self) { + match crate::config::Config::load(None) { + Ok(fresh) => self.config = fresh, + Err(e) => tracing::warn!(error = %e, "Failed to reload config after kanban write"), + } + } + pub(super) fn show_kanban_onboarding_dialog(&mut self) { self.kanban_onboarding_dialog.show(); self.kanban_onboarding_creds = KanbanOnboardingCreds::default(); @@ -260,6 +270,7 @@ impl App { }; kanban_onboarding::write_config(write_req, None) .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; + self.reload_config_after_kanban_write(); // Set session env let env_req = SetKanbanSessionEnvRequest { @@ -310,6 +321,7 @@ impl App { }; kanban_onboarding::write_config(write_req, None) .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; + self.reload_config_after_kanban_write(); let env_req = SetKanbanSessionEnvRequest { provider: KanbanProviderKind::Linear, diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index c156c872..de8d2292 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -17,6 +17,118 @@ impl App { let code = key.code; let mods = key.modifiers; + // Modal credential dialogs outrank the setup screen: both can be opened from inside the wizard, + // whose bindings would otherwise eat their text input (`c` quits, `i` initializes). + if self.git_token_dialog.visible { + match code { + KeyCode::Esc => { + self.git_token_dialog.hide(); + } + KeyCode::Enter => { + let token = self.git_token_dialog.token().to_string(); + if token.is_empty() { + self.git_token_dialog.set_error("Token cannot be empty"); + } else { + let provider = self.git_token_dialog.provider.clone(); + let provider_display = self.git_token_dialog.provider_display.clone(); + match git_onboarding::validate_token_with_config( + &self.config, + &provider, + &token, + ) { + Ok(username) => { + // The wizard persists once at Confirm; saving + // here would strand a partial config on cancel. + let in_setup = self.setup_screen.is_some(); + let applied = if in_setup { + git_onboarding::apply_git_provider( + &mut self.config, + &provider, + &token, + ) + } else { + git_onboarding::complete_git_onboarding( + &mut self.config, + &provider, + &token, + ) + }; + match applied { + Ok(()) => { + self.git_token_dialog.hide(); + if let Some(setup) = self.setup_screen.as_mut() { + setup.set_git_provider_status( + &provider, + format!("connected as {username}"), + ); + } else { + self.dashboard.update_config(&self.config); + self.refresh_data()?; + self.dashboard.set_status(&format!( + "{provider_display} connected as {username}" + )); + } + } + Err(e) => { + self.git_token_dialog + .set_error(&format!("Failed to save config: {e}")); + } + } + } + Err(e) => { + self.git_token_dialog + .set_error(&format!("Token validation failed: {e}")); + } + } + } + } + KeyCode::Char(c) => { + self.git_token_dialog.handle_char(c); + } + KeyCode::Backspace => { + self.git_token_dialog.handle_backspace(); + } + KeyCode::Delete => { + self.git_token_dialog.handle_delete(); + } + KeyCode::Left => { + self.git_token_dialog.cursor_left(); + } + KeyCode::Right => { + self.git_token_dialog.cursor_right(); + } + KeyCode::Home => { + self.git_token_dialog.cursor_home(); + } + KeyCode::End => { + self.git_token_dialog.cursor_end(); + } + _ => {} + } + return Ok(()); + } + + // Sync confirm dialog handling + if self.sync_confirm_dialog.visible { + if let Some(result) = self.sync_confirm_dialog.handle_key(code) { + match result { + SyncConfirmResult::Confirmed => { + self.run_kanban_sync_all().await?; + } + SyncConfirmResult::Cancelled => { + // Already hidden by handle_key + } + } + } + return Ok(()); + } + + if self.kanban_onboarding_dialog.visible { + let action = self.kanban_onboarding_dialog.handle_key(code); + self.handle_kanban_onboarding_action(action).await?; + return Ok(()); + } + // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { // The password step needs raw characters, and the wizard bindings @@ -39,6 +151,16 @@ impl App { setup.handle_password_key(code); return Ok(()); } + if setup.step == crate::ui::setup::SetupStep::ExecutionTarget + && setup.execution_target_state.selected() == Some(1) + && matches!( + code, + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete + ) + { + setup.handle_execution_target_key(code); + return Ok(()); + } match code { KeyCode::Char('i' | 'I') if setup.confirm_selected => { @@ -72,6 +194,21 @@ impl App { let timeout = templates.collections_fetch_timeout_secs; setup.load_hosted_collections(url.as_deref(), timeout).await; } + if matches!(setup.step, crate::ui::setup::SetupStep::ModelServer) + && !setup.model_servers_probed + { + setup.probe_model_servers(&self.config).await; + } + // Drain both requests before touching `self`, so + // the borrow on `setup_screen` has ended. + let open_kanban = setup.take_kanban_dialog_request(); + let git_slug = setup.take_git_connect_request(); + if open_kanban { + self.show_kanban_onboarding_dialog(); + } + if let Some(slug) = git_slug { + self.connect_git_provider_from_setup(&slug); + } } } } @@ -323,99 +460,6 @@ impl App { return Ok(()); } - // Git token dialog handling - if self.git_token_dialog.visible { - match code { - KeyCode::Esc => { - self.git_token_dialog.hide(); - } - KeyCode::Enter => { - let token = self.git_token_dialog.token().to_string(); - if token.is_empty() { - self.git_token_dialog.set_error("Token cannot be empty"); - } else { - let provider = self.git_token_dialog.provider.clone(); - let provider_display = self.git_token_dialog.provider_display.clone(); - match git_onboarding::validate_token_with_config( - &self.config, - &provider, - &token, - ) { - Ok(username) => { - match git_onboarding::complete_git_onboarding( - &mut self.config, - &provider, - &token, - ) { - Ok(()) => { - self.git_token_dialog.hide(); - self.dashboard.update_config(&self.config); - self.refresh_data()?; - self.dashboard.set_status(&format!( - "{provider_display} connected as {username}" - )); - } - Err(e) => { - self.git_token_dialog - .set_error(&format!("Failed to save config: {e}")); - } - } - } - Err(e) => { - self.git_token_dialog - .set_error(&format!("Token validation failed: {e}")); - } - } - } - } - KeyCode::Char(c) => { - self.git_token_dialog.handle_char(c); - } - KeyCode::Backspace => { - self.git_token_dialog.handle_backspace(); - } - KeyCode::Delete => { - self.git_token_dialog.handle_delete(); - } - KeyCode::Left => { - self.git_token_dialog.cursor_left(); - } - KeyCode::Right => { - self.git_token_dialog.cursor_right(); - } - KeyCode::Home => { - self.git_token_dialog.cursor_home(); - } - KeyCode::End => { - self.git_token_dialog.cursor_end(); - } - _ => {} - } - return Ok(()); - } - - // Sync confirm dialog handling - if self.sync_confirm_dialog.visible { - if let Some(result) = self.sync_confirm_dialog.handle_key(code) { - match result { - SyncConfirmResult::Confirmed => { - self.run_kanban_sync_all().await?; - } - SyncConfirmResult::Cancelled => { - // Already hidden by handle_key - } - } - } - return Ok(()); - } - - // Kanban onboarding dialog handling - if self.kanban_onboarding_dialog.visible { - let action = self.kanban_onboarding_dialog.handle_key(code); - self.handle_kanban_onboarding_action(action).await?; - return Ok(()); - } - // Normal mode match code { KeyCode::Char('q') => { diff --git a/src/app/mod.rs b/src/app/mod.rs index be2caa1f..2ba759f7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -116,45 +116,13 @@ pub struct App { impl App { pub async fn new(mut config: Config, start_web: bool, open_ui: bool) -> Result { - // Refresh LLM tool detection every startup so config edits and - // runtime-loaded tool JSONs take effect (cached tools skip re-probe) - let refreshed = crate::llm::refresh_tool_detection(&config.llm_tools); - let detection_changed = - serde_json::to_value(&refreshed).ok() != serde_json::to_value(&config.llm_tools).ok(); - config.llm_tools = refreshed; - - // Log detected tools - for tool in &config.llm_tools.detected { - tracing::info!( - tool = %tool.name, - version = %tool.version, - path = %tool.path, - "LLM tool detected" - ); - } - - // Log available providers - for provider in &config.llm_tools.providers { - tracing::debug!( - tool = %provider.tool, - model = %provider.model, - "LLM provider available" - ); - } - - // Save only when detection results changed, to avoid rewriting - // config.toml on every boot - if detection_changed { - if let Err(e) = config.save() { - tracing::warn!("Failed to save LLM detection results: {}", e); - } - } + crate::llm::refresh_config_detection(&mut config); let dashboard = Dashboard::new(&config); // Check if tickets directory exists let tickets_path = config.tickets_path(); - let needs_setup = !tickets_path.join("queue").exists(); + let needs_setup = !crate::startup::workspace_initialized(&config); // For setup screen, we discover projects dynamically // After setup, we use the projects list from config diff --git a/src/app/tickets.rs b/src/app/tickets.rs index ae21ae0e..8ed13242 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -1,13 +1,9 @@ use anyhow::Result; -use std::fs; -use crate::agents::{generate_status_script, generate_tmux_conf}; use crate::agents::{AgentTicketCreator, AssessTicketCreator}; use crate::auth::store::AuthStore; use crate::queue::TicketCreator; -use crate::setup::filter_schema_fields; use crate::state::State; -use crate::templates::TemplateType; use crate::ui::create_dialog::CreateDialogResult; use crate::ui::projects_dialog::{ProjectAction, ProjectsDialogResult}; use crate::ui::with_suspended_tui; @@ -15,108 +11,43 @@ use crate::ui::with_suspended_tui; use super::{App, AppTerminal}; impl App { - /// Initialize the tickets directory with default templates and save config - pub(super) fn initialize_tickets(&mut self) -> Result<()> { - let tickets_path = self.config.tickets_path(); - - // Create directories - fs::create_dir_all(tickets_path.join("queue"))?; - fs::create_dir_all(tickets_path.join("in-progress"))?; - fs::create_dir_all(tickets_path.join("completed"))?; - fs::create_dir_all(tickets_path.join("templates"))?; - fs::create_dir_all(tickets_path.join("operator"))?; - - // Get selected issuetype collection and configured fields from setup screen - let (selected_preset, selected_collection, task_fields) = self - .setup_screen - .as_ref() - .map(|s| (s.preset(), s.collection(), s.configured_task_fields())) - .unwrap_or_else(|| { - ( - crate::config::CollectionPreset::Simple, - vec!["TASK".to_string()], - vec!["priority".to_string(), "context".to_string()], - ) - }); - - // Update config with selected preset and collection - self.config.templates.preset = selected_preset; - if selected_preset == crate::config::CollectionPreset::Custom { - self.config.templates.collection = selected_collection.clone(); - } else { - self.config.templates.collection.clear(); - } - - // Write template files (only for selected types) - for template_type in TemplateType::all() { - let type_str = template_type.as_str(); - if !selected_collection.contains(&type_str.to_string()) { - continue; - } + /// Build setup options from the wizard's collected choices. + fn setup_options(&self) -> crate::setup::SetupOptions { + let Some(screen) = self.setup_screen.as_ref() else { + return crate::setup::SetupOptions::default(); + }; - let filename = match template_type { - TemplateType::Feature => "feature.md", - TemplateType::Fix => "fix.md", - TemplateType::Task => "task.md", - TemplateType::Spike => "spike.md", - TemplateType::Investigation => "investigation.md", - TemplateType::Assess => "assess.md", - TemplateType::Sync => "sync.md", - TemplateType::Init => "init.md", - }; - let filepath = tickets_path.join("templates").join(filename); - fs::write(&filepath, template_type.template_content())?; - - // Also write the JSON schema (with field filtering applied) - let schema_filename = match template_type { - TemplateType::Feature => "feature.json", - TemplateType::Fix => "fix.json", - TemplateType::Task => "task.json", - TemplateType::Spike => "spike.json", - TemplateType::Investigation => "investigation.json", - TemplateType::Assess => "assess.json", - TemplateType::Sync => "sync.json", - TemplateType::Init => "init.json", - }; - let schema_filepath = tickets_path.join("templates").join(schema_filename); - let filtered_schema = filter_schema_fields(template_type.schema(), &task_fields)?; - fs::write(&schema_filepath, filtered_schema)?; - } + let hosted: Vec = screen + .selected_hosted_collections() + .into_iter() + .map(|r| (r.manifest.clone(), r.files.clone(), r.icon_svg.clone())) + .collect(); + let active_collection = match hosted.as_slice() { + [single] => Some(single.0.id.clone()), + _ => None, + }; - // If the user picked hosted collections, scaffold each into its own - // collection-scoped directory (manifest + verified issuetype files). The - // loader discovers every templates//collection.json; when exactly one - // was chosen it also becomes the active collection. - let hosted: Vec<_> = self - .setup_screen - .as_ref() - .map(|s| { - s.selected_hosted_collections() - .into_iter() - .map(|r| (r.manifest.clone(), r.files.clone(), r.icon_svg.clone())) - .collect() - }) - .unwrap_or_default(); - for (manifest, files, icon_svg) in &hosted { - crate::startup::templates::write_fetched_collection( - &tickets_path.join("templates"), - manifest, - files, - icon_svg.as_deref(), - )?; + crate::setup::SetupOptions { + preset: screen.preset(), + task_fields: screen.configured_task_fields(), + use_worktrees: screen.use_worktrees, + wrapper: Some(screen.selected_wrapper), + acceptance_criteria: Some(screen.acceptance_criteria_text.clone()), + custom_collection: screen.collection(), + active_collection, + hosted_collections: hosted, + model_servers: screen.declared_model_servers(), + execution_target: Some(screen.selected_execution_target()), + ..Default::default() } - if let [single] = hosted.as_slice() { - self.config.templates.active_collection = Some(single.0.id.clone()); - } - - // Generate tmux configuration files - self.generate_tmux_config()?; + } - // Discover projects (one-time scan during setup) - // Use full discovery to get git info for filtering - let discovered_full = self.config.discover_projects_full(); - let discovered_projects: Vec = - discovered_full.iter().map(|p| p.name.clone()).collect(); + /// Initialize the tickets directory with default templates and save config + pub(super) fn initialize_tickets(&mut self) -> Result<()> { + let options = self.setup_options(); + let result = crate::setup::initialize_workspace(&mut self.config, &options)?; + let discovered_full = result.discovered; + let discovered_projects = self.config.projects.clone(); // Create the admin account before the config is written. if let Some(password) = self @@ -128,13 +59,11 @@ impl App { persist_admin_password(&store, Some(password))?; } - // Update config with discovered projects and save - self.config.projects = discovered_projects.clone(); self.config.save()?; // Reload the issue type registry so the chosen collection is active // without requiring a restart (mirrors App::new's load path). - let mut registry = crate::startup::templates::load_registry(&tickets_path); + let mut registry = crate::startup::templates::load_registry(&self.config.tickets_path()); if let Some(ref active) = self.config.templates.active_collection { if let Err(e) = registry.activate_collection(active) { tracing::warn!("Failed to activate collection '{}': {}", active, e); @@ -220,41 +149,6 @@ impl App { Ok(()) } - /// Generate custom tmux config and status script - pub(super) fn generate_tmux_config(&mut self) -> Result<()> { - let state_path = self.config.state_path(); - let tmux_conf_path = self.config.tmux_config_path(); - let status_script_path = self.config.tmux_status_script_path(); - - // Generate tmux.conf - let tmux_conf_content = generate_tmux_conf(&status_script_path, &state_path); - fs::write(&tmux_conf_path, tmux_conf_content)?; - - // Generate status script - let status_script_content = generate_status_script(); - fs::write(&status_script_path, status_script_content)?; - - // Make status script executable - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&status_script_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&status_script_path, perms)?; - } - - // Mark config as generated - self.config.tmux.config_generated = true; - - tracing::info!( - tmux_conf = %tmux_conf_path.display(), - status_script = %status_script_path.display(), - "Generated tmux configuration files" - ); - - Ok(()) - } - /// Create a new ticket from the dialog result pub(super) fn create_ticket( &mut self, diff --git a/src/auth/scope.rs b/src/auth/scope.rs index 0ff76578..be7d4340 100644 --- a/src/auth/scope.rs +++ b/src/auth/scope.rs @@ -99,6 +99,11 @@ pub static ROUTE_RULES: &[RouteRule] = &[ read("GET", "/api/v1/status"), read("GET", "/api/v1/sections"), read("GET", "/api/v1/integrations"), + // --- Setup -------------------------------------------------------------- + read("GET", "/api/v1/setup/status"), + read("GET", "/api/v1/setup/steps"), + read("GET", "/api/v1/setup/collections"), + admin("POST", "/api/v1/setup/initialize"), // --- Issue types -------------------------------------------------------- read("GET", "/api/v1/issuetypes"), write("POST", "/api/v1/issuetypes"), @@ -160,6 +165,11 @@ pub static ROUTE_RULES: &[RouteRule] = &[ // Writing provider config and setting process env are administration. admin("PUT", "/api/v1/kanban/config"), admin("POST", "/api/v1/kanban/session-env"), + // --- Git onboarding ----------------------------------------------------- + read("GET", "/api/v1/git/providers"), + execute("POST", "/api/v1/git/validate"), + admin("PUT", "/api/v1/git/config"), + admin("POST", "/api/v1/git/session-env"), // --- Skills / LLM tools ------------------------------------------------- read("GET", "/api/v1/skills"), read("GET", "/api/v1/llm-tools"), diff --git a/src/config.rs b/src/config.rs index 908f96e7..3a157da5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -213,6 +213,9 @@ pub struct LaunchConfig { pub confirm_autonomous: bool, pub confirm_paired: bool, pub launch_delay_ms: u64, + /// Default named execution target. Per-launch and per-delegator choices take precedence. + #[serde(default)] + pub target: Option, /// Docker execution configuration #[serde(default)] pub docker: DockerConfig, @@ -456,7 +459,19 @@ fn default_acp_max_sessions() -> usize { } /// Predefined issue type collections -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + TS, + utoipa::ToSchema, +)] #[ts(export)] #[serde(rename_all = "snake_case")] pub enum CollectionPreset { @@ -683,11 +698,17 @@ fn env_source() -> config::Environment { } impl Config { - /// Path to the operator config file within .tickets/ + /// Bootstrap-only config location, relative to cwd. `load()` needs a path + /// before a `Config` exists; everything else uses `operator_config_path_for`. pub fn operator_config_path() -> PathBuf { PathBuf::from(".tickets/operator/config.toml") } + /// Where this config persists: derived from `paths.state`, not the cwd. + pub fn operator_config_path_for(&self) -> PathBuf { + self.state_path().join("config.toml") + } + pub fn load(config_path: Option<&str>) -> Result { // Start with embedded defaults so operator works without config files let defaults = Config::default(); @@ -761,10 +782,11 @@ impl Config { Ok(cfg) } - /// Save config to .tickets/operator/config.toml + /// Save config to `paths.state`/config.toml. pub fn save(&self) -> Result<()> { + validate_targets(self)?; crate::git::identity::validate_config(self)?; - let config_path = Self::operator_config_path(); + let config_path = self.operator_config_path_for(); // Ensure parent directory exists if let Some(parent) = config_path.parent() { @@ -775,9 +797,7 @@ impl Config { let toml_str = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?; - // Write to a sibling temp file and rename over the target. A plain - // write truncates first, so a crash or a full disk mid-write leaves a - // half-written config.toml that will not parse. prevents startup failure later + // Write to a sibling temp file and rename over the target. prevents startup failure later let temp_path = config_path.with_extension(format!("toml.tmp.{}", uuid::Uuid::new_v4())); std::fs::write(&temp_path, toml_str).context("Failed to write config file")?; if let Err(e) = std::fs::rename(&temp_path, &config_path) { @@ -867,15 +887,6 @@ impl Config { pub fn discover_projects(&self) -> Vec { crate::projects::discover_projects(&self.projects_path()) } - - /// Discover projects with full git and LLM tool information - /// - /// Returns projects found by scanning for .git directories and LLM marker files. - /// Each project includes git repo info (remote URL, default branch, GitHub info) - /// and a list of available LLM tools. - pub fn discover_projects_full(&self) -> Vec { - crate::projects::discover_projects_with_git(&self.projects_path()) - } } impl Default for Config { @@ -920,6 +931,7 @@ impl Default for Config { confirm_autonomous: true, confirm_paired: true, launch_delay_ms: 2000, + target: None, docker: DockerConfig::default(), yolo: YoloConfig::default(), }, @@ -1018,6 +1030,43 @@ mod tests { let cfg = config_from_env(&[("OPERATOR_REST_API__HOST", "0.0.0.0")]); assert_eq!(cfg.rest_api.port, default_rest_port()); } + + // --- Save destination --- + + #[test] + fn test_operator_config_path_for_derives_from_state_path() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.paths.state = dir.path().to_string_lossy().to_string(); + + assert_eq!( + cfg.operator_config_path_for(), + dir.path().join("config.toml") + ); + } + + #[test] + fn test_operator_config_path_for_matches_legacy_path_on_defaults() { + let cfg = Config::default(); + let cwd = std::env::current_dir().unwrap(); + + assert_eq!( + cfg.operator_config_path_for(), + cwd.join(Config::operator_config_path()) + ); + } + + #[test] + fn test_save_writes_under_paths_state_not_cwd() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.paths.state = dir.path().join("state").to_string_lossy().to_string(); + + cfg.save().unwrap(); + + assert!(dir.path().join("state/config.toml").exists()); + assert!(!dir.path().join(".tickets/operator/config.toml").exists()); + } } #[cfg(test)] diff --git a/src/config/sessions.rs b/src/config/sessions.rs index 7cb73906..1e456e52 100644 --- a/src/config/sessions.rs +++ b/src/config/sessions.rs @@ -3,7 +3,19 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// Session wrapper type for terminal session management -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + JsonSchema, + TS, + utoipa::ToSchema, +)] #[serde(rename_all = "lowercase")] #[ts(export)] pub enum SessionWrapperType { diff --git a/src/config/targets.rs b/src/config/targets.rs index 721113d7..377a524a 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -18,6 +18,11 @@ use super::DockerConfig; pub const TARGET_LOCAL: &str = "local"; /// Reserved target name synthesized from `[launch.docker]`. pub const TARGET_DOCKER: &str = "docker"; +pub const DEFAULT_CODER_TARGET_NAME: &str = "coder-agents"; +pub const DEFAULT_CODER_URL_ENV: &str = "CODER_URL"; +pub const DEFAULT_CODER_TOKEN_ENV: &str = "CODER_SESSION_TOKEN"; +const DEFAULT_CODER_NAME_PREFIX: &str = "op"; +const DEFAULT_CODER_CREATE_TIMEOUT_SECS: u64 = 300; /// A named execution target agents can be launched on. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] @@ -154,20 +159,36 @@ pub struct CoderConfig { pub parameters: std::collections::HashMap, } +impl Default for CoderConfig { + fn default() -> Self { + Self { + template: String::new(), + url_env: default_coder_url_env(), + token_env: default_coder_token_env(), + name_prefix: default_coder_name_prefix(), + workdir: None, + stop_on_complete: true, + create_timeout_secs: default_coder_create_timeout_secs(), + callback_url: None, + parameters: std::collections::HashMap::new(), + } + } +} + fn default_coder_url_env() -> String { - "CODER_URL".to_string() + DEFAULT_CODER_URL_ENV.to_string() } fn default_coder_token_env() -> String { - "CODER_SESSION_TOKEN".to_string() + DEFAULT_CODER_TOKEN_ENV.to_string() } fn default_coder_name_prefix() -> String { - "op".to_string() + DEFAULT_CODER_NAME_PREFIX.to_string() } fn default_coder_create_timeout_secs() -> u64 { - 300 + DEFAULT_CODER_CREATE_TIMEOUT_SECS } fn default_true() -> bool { @@ -204,6 +225,27 @@ pub fn validate_targets(config: &super::Config) -> anyhow::Result<()> { ); } } + if let TargetKind::Coder(coder) = &target.kind { + if coder.template.trim().is_empty() { + anyhow::bail!("Coder target '{}' requires a template", target.name); + } + if coder.url_env.trim().is_empty() || coder.token_env.trim().is_empty() { + anyhow::bail!( + "Coder target '{}' requires url_env and token_env names", + target.name + ); + } + } + } + + if let Some(name) = &config.launch.target { + if !known_target_name(config, name) { + anyhow::bail!( + "launch.target references unknown target '{}' (known: {})", + name, + known_target_names(config).join(", ") + ); + } } for delegator in &config.delegators { @@ -443,6 +485,17 @@ create_timeout_secs = 600 } } + fn coder_def(template: &str) -> TargetDef { + TargetDef { + name: "coder-agents".to_string(), + display_name: None, + kind: TargetKind::Coder(CoderConfig { + template: template.to_string(), + ..Default::default() + }), + } + } + #[test] fn test_validate_targets_duplicate_names_error() { let config = config_with_targets(vec![ssh_def("a"), ssh_def("a")]); @@ -473,6 +526,22 @@ create_timeout_secs = 600 assert!(err.contains("collides"), "{err}"); } + #[test] + fn test_validate_targets_rejects_incomplete_coder_target() { + let config = config_with_targets(vec![coder_def(" ")]); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("requires a template"), "{err}"); + + let mut target = coder_def("operator-agent"); + let TargetKind::Coder(coder) = &mut target.kind else { + unreachable!(); + }; + coder.token_env.clear(); + let config = config_with_targets(vec![target]); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("requires url_env and token_env"), "{err}"); + } + #[test] fn test_validate_targets_unknown_delegator_reference_error() { let mut config = config_with_targets(vec![]); @@ -501,6 +570,15 @@ create_timeout_secs = 600 ); } + #[test] + fn test_validate_targets_unknown_global_reference_error() { + let mut config = config_with_targets(vec![]); + config.launch.target = Some("nope".to_string()); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("launch.target"), "{err}"); + assert!(err.contains("unknown target 'nope'"), "{err}"); + } + #[test] fn test_validate_targets_builtin_and_host_references_ok() { let mut config = config_with_targets(vec![ssh_def("gpu-vm")]); diff --git a/src/docs_gen/startup.rs b/src/docs_gen/startup.rs index 04524689..f729f32e 100644 --- a/src/docs_gen/startup.rs +++ b/src/docs_gen/startup.rs @@ -2,7 +2,7 @@ use super::markdown::{heading, table}; use super::{format_header, DocGenerator}; -use crate::startup::SETUP_STEPS; +use crate::startup::steps::setup_steps; use anyhow::Result; /// Generates setup wizard documentation from the startup step registry @@ -14,7 +14,7 @@ impl DocGenerator for StartupDocGenerator { } fn source(&self) -> &'static str { - "src/startup/mod.rs" + "src/startup/steps.rs" } fn output_path(&self) -> &'static str { @@ -37,7 +37,7 @@ impl DocGenerator for StartupDocGenerator { output.push_str(&heading(2, "Step Details")); output.push('\n'); - for (i, step) in SETUP_STEPS.iter().enumerate() { + for (i, step) in setup_steps().iter().enumerate() { output.push_str(&heading(3, &format!("{}. {}", i + 1, step.name))); output.push_str(&format!("*{}*\n\n", step.description)); output.push_str(step.help_text); @@ -68,7 +68,7 @@ impl DocGenerator for StartupDocGenerator { impl StartupDocGenerator { fn generate_overview_table(&self) -> String { let headers = &["Step", "Name", "Description"]; - let rows: Vec> = SETUP_STEPS + let rows: Vec> = setup_steps() .iter() .enumerate() .map(|(i, step)| { @@ -95,7 +95,7 @@ mod tests { // Should have the auto-generated header assert!(result.contains("AUTO-GENERATED FROM")); - assert!(result.contains("startup/mod.rs")); + assert!(result.contains("startup/steps.rs")); // Should have the main heading assert!(result.contains("title: \"Setup Wizard\"")); @@ -117,7 +117,7 @@ mod tests { let result = generator.generate().unwrap(); // Check that all step names appear in the documentation - for step in SETUP_STEPS { + for step in setup_steps() { assert!( result.contains(step.name), "Step '{}' should be documented", @@ -134,7 +134,7 @@ mod tests { // Count table rows (lines starting with |) let row_count = overview.lines().filter(|l| l.starts_with('|')).count(); // Should have header + separator + all steps - assert_eq!(row_count, SETUP_STEPS.len() + 2); + assert_eq!(row_count, setup_steps().len() + 2); } #[test] @@ -143,7 +143,7 @@ mod tests { let result = generator.generate().unwrap(); // Check that numbered headings exist - for i in 1..=SETUP_STEPS.len() { + for i in 1..=setup_steps().len() { assert!( result.contains(&format!("### {i}.")), "Step {i} should be numbered" diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index a8c5efc2..e28ac7c6 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -432,6 +432,21 @@ pub fn all_integrations() -> Vec { } /// Find the catalog entry for a `(vertical, slug)` pair, if present. +/// Entries a first-run wizard may offer for this vertical. +/// +/// `Alpha`+ and documented: onboarding links out to each provider's page, and +/// `Proto` entries are by definition not advertised. This is what keeps the TUI +/// wizard, the web wizard and the docs offering the same providers - promoting +/// one is a status bump here, not an edit in each surface. +pub fn onboardable(vertical: Vertical) -> Vec { + all_integrations() + .into_iter() + .filter(|e| { + e.vertical == vertical && e.status >= SupportStatus::Alpha && e.docs_path.is_some() + }) + .collect() +} + pub fn entry_for(vertical: Vertical, slug: &str) -> Option { all_integrations() .into_iter() @@ -530,4 +545,59 @@ mod tests { assert_eq!(jira.status, SupportStatus::Beta); assert!(entry_for(Vertical::Kanban, "nope").is_none()); } + + // --- Onboarding surface --- + + fn slugs(vertical: Vertical) -> Vec<&'static str> { + onboardable(vertical).iter().map(|e| e.slug).collect() + } + + #[test] + fn test_onboardable_git_is_the_alpha_or_better_set() { + assert_eq!(slugs(Vertical::Git), vec!["github", "gitlab", "gitea"]); + } + + #[test] + fn test_onboardable_model_excludes_proto_entries() { + let model = slugs(Vertical::Model); + assert!(model.contains(&"anthropic-api")); + assert!(model.contains(&"ollama")); + assert!(!model.contains(&"openai-compat"), "openai-compat is Proto"); + assert!(!model.contains(&"lmstudio"), "lmstudio is Proto"); + } + + /// Onboarding links out to each provider's page, so an entry without docs + /// must never reach a wizard. + #[test] + fn test_onboardable_entries_are_all_documented() { + for vertical in Vertical::ALL { + for entry in onboardable(vertical) { + assert!( + entry.docs_path.is_some(), + "{}/{} is offered for onboarding but has no docs page", + vertical.slug(), + entry.slug + ); + } + } + } + + #[test] + fn test_onboardable_never_includes_proto() { + for vertical in Vertical::ALL { + for entry in onboardable(vertical) { + assert!(entry.status >= SupportStatus::Alpha, "{}", entry.slug); + } + } + } + + #[test] + fn test_onboardable_preserves_catalog_order() { + let all: Vec<&str> = all_integrations() + .iter() + .filter(|e| e.vertical == Vertical::Model && e.status >= SupportStatus::Alpha) + .map(|e| e.slug) + .collect(); + assert_eq!(slugs(Vertical::Model), all); + } } diff --git a/src/lib.rs b/src/lib.rs index 5cdbd8b6..18618063 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,8 @@ pub mod editors; pub mod git; pub mod queue; pub mod rest; +pub mod setup; +pub mod startup; pub mod state; pub mod types; @@ -24,9 +26,8 @@ mod llm; mod notifications; mod permissions; mod pr_config; -mod projects; -mod services; -mod startup; +pub mod projects; +pub mod services; mod steps; #[allow(dead_code)] pub mod taxonomy; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index fa11691a..ce36ea6c 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -17,3 +17,30 @@ pub use detection::verify_tool_health; #[allow(unused_imports)] // Used by main.rs binary pub use detection::{detect_all_tools, refresh_tool_detection}; pub use skill_deployer::deploy_skills; + +/// Refresh cached tool detection and persist only when the detected state changed. +#[allow(dead_code)] // Shared by the binary's TUI and API entry points. +pub fn refresh_config_detection(config: &mut crate::config::Config) -> bool { + let refreshed = refresh_tool_detection(&config.llm_tools); + let changed = + serde_json::to_value(&refreshed).ok() != serde_json::to_value(&config.llm_tools).ok(); + config.llm_tools = refreshed; + + for tool in &config.llm_tools.detected { + tracing::info!( + tool = %tool.name, + version = %tool.version, + path = %tool.path, + "LLM tool detected" + ); + } + for provider in &config.llm_tools.providers { + tracing::debug!(tool = %provider.tool, model = %provider.model, "LLM provider available"); + } + if changed { + if let Err(error) = config.save() { + tracing::warn!(%error, "Failed to save LLM detection results"); + } + } + changed +} diff --git a/src/main.rs b/src/main.rs index 1b3258a7..a103dde1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1070,6 +1070,8 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R } async fn cmd_api(config: &Config, port: Option, open: bool) -> Result<()> { + let mut config = config.clone(); + crate::llm::refresh_config_detection(&mut config); let port = port.unwrap_or(config.rest_api.port); println!("Starting REST API server..."); @@ -1219,6 +1221,7 @@ fn cmd_setup( println!(); let result = initialize_workspace(&mut config, &options)?; + config.save()?; // Report results if !result.directories_created.is_empty() { diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index e445c453..4bd588b4 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -507,6 +507,7 @@ pub struct LaunchConfiguration { pub confirm_autonomous: bool, pub confirm_paired: bool, pub launch_delay_ms: u64, + pub target: Option, pub docker_enabled: bool, pub docker_image: String, pub yolo_enabled: bool, @@ -616,6 +617,7 @@ pub struct LaunchConfigurationPatch { pub confirm_autonomous: Option, pub confirm_paired: Option, pub launch_delay_ms: Option, + pub target: Option, pub docker_enabled: Option, pub docker_image: Option, pub yolo_enabled: Option, @@ -627,6 +629,7 @@ impl LaunchConfigurationPatch { self.confirm_autonomous.is_none() && self.confirm_paired.is_none() && self.launch_delay_ms.is_none() + && self.target.is_none() && self.docker_enabled.is_none() && self.docker_image.is_none() && self.yolo_enabled.is_none() diff --git a/src/rest/dto/git_onboarding.rs b/src/rest/dto/git_onboarding.rs new file mode 100644 index 00000000..9ac375de --- /dev/null +++ b/src/rest/dto/git_onboarding.rs @@ -0,0 +1,77 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum GitOnboardingState { + CliMissing, + TokenRequired, + Authenticated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GitProviderOnboardingResponse { + pub slug: String, + pub label: String, + pub docs_url: String, + pub configured: bool, + pub command: String, + pub token_env: String, + pub state: GitOnboardingState, + pub action_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateGitTokenRequest { + pub provider: String, + #[schema(write_only, format = Password)] + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateGitTokenResponse { + pub valid: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteGitConfigRequest { + pub provider: String, + pub token_env: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteGitConfigResponse { + pub provider: String, + pub token_env: String, + pub shell_export_block: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetGitSessionEnvRequest { + pub provider: String, + #[schema(write_only, format = Password)] + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetGitSessionEnvResponse { + pub shell_export_block: String, +} diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index ed1119ba..a60436f1 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -10,20 +10,24 @@ pub mod agents; pub mod auth; pub mod configuration; +pub mod git_onboarding; pub mod integrations; pub mod issue_types; pub mod kanban; pub mod sections; +pub mod setup; pub mod tickets; pub mod workflow; pub use agents::*; pub use auth::*; pub use configuration::*; +pub use git_onboarding::*; pub use integrations::*; pub use issue_types::*; pub use kanban::*; pub use sections::*; +pub use setup::*; pub use tickets::*; pub use workflow::*; diff --git a/src/rest/dto/setup.rs b/src/rest/dto/setup.rs new file mode 100644 index 00000000..dfef9aba --- /dev/null +++ b/src/rest/dto/setup.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::config::{CollectionPreset, SessionWrapperType}; +use crate::startup::steps::SetupStep; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupStatusResponse { + pub initialized: bool, + pub admin_configured: bool, + pub config_path: String, + pub tickets_path: String, + pub projects_by_tool: HashMap>, + pub default_acceptance_criteria: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupStepResponse { + pub slug: SetupStep, + pub name: String, + pub description: String, + pub help_text: String, + pub order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupCollectionResponse { + pub id: String, + pub name: String, + pub description: String, + pub types: Vec, + pub default_selected: Vec, + pub origin: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + pub checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct HostedCollectionSelection { + pub id: String, + pub checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "lowercase")] +#[ts(export)] +pub enum SetupExecutionTarget { + Local, + Coder { name: String, template: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupInitializeRequest { + pub preset: CollectionPreset, + #[serde(default)] + pub task_fields: Vec, + pub wrapper: SessionWrapperType, + pub execution_target: SetupExecutionTarget, + #[serde(default)] + pub use_worktrees: bool, + pub acceptance_criteria: String, + #[serde(default)] + pub model_servers: Vec, + #[serde(default)] + pub hosted_collections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupInitializeResponse { + pub initialized: bool, + pub config_path: String, + pub tickets_path: String, + pub files_created: Vec, + pub files_skipped: Vec, + pub projects: Vec, +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index dd9ede1b..2acffa70 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -94,6 +94,18 @@ fn auth_router() -> OpenApiRouter { .routes(routes!(routes::auth::revoke_access_key)) } +fn first_run_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(routes::setup::status)) + .routes(routes!(routes::setup::steps)) + .routes(routes!(routes::setup::collections)) + .routes(routes!(routes::setup::initialize)) + .routes(routes!(routes::git_onboarding::providers)) + .routes(routes!(routes::git_onboarding::validate)) + .routes(routes!(routes::git_onboarding::write_config)) + .routes(routes!(routes::git_onboarding::set_session_env)) +} + /// Build the documented API surface as a `utoipa_axum::OpenApiRouter`. /// /// Every always-on route is mounted here via `routes!`, so mounting a route @@ -115,6 +127,8 @@ fn documented_router() -> OpenApiRouter { .routes(routes!(routes::sections::list)) // Vertical integration catalog + support status .routes(routes!(routes::integrations::catalog)) + // First-run setup + .merge(first_run_router()) // Issue type endpoints .routes(routes!( routes::issuetypes::list, diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 862a3b2d..cb54b63c 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -29,9 +29,10 @@ use crate::rest::dto::{ DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, DeviceApprovalRequest, DeviceApprovalResponse, DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceSummary, ExternalIssueTypeSummary, FieldResponse, ForgotPasswordRequest, ForgotPasswordResponse, - HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, - KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, - KanbanTicketCard, LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, + GitOnboardingState, GitProviderOnboardingResponse, HealthResponse, HostedCollectionSelection, + IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, + KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, + LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, ListKanbanStatusesRequest, ListKanbanStatusesResponse, LoginRequest, LoginResponse, LogoutResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, NextStepInfo, @@ -39,13 +40,16 @@ use crate::rest::dto::{ QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ResetPasswordRequest, ResetPasswordResponse, ReviewResponse, RevokeAccessKeyResponse, Scope, SectionDto, SectionRowDto, SessionListResponse, SessionSummary, SetDefaultLlmRequest, - SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, - StatusResponse, StepCompleteRequest, StepCompleteResponse, StepResponse, - SyncKanbanIssueTypesResponse, TicketDetailResponse, TokenRequest, TokenResponse, + SetGitSessionEnvRequest, SetGitSessionEnvResponse, SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, SetupCollectionResponse, SetupExecutionTarget, + SetupInitializeRequest, SetupInitializeResponse, SetupStatusResponse, SetupStepResponse, + SkillEntry, SkillsResponse, StatusResponse, StepCompleteRequest, StepCompleteResponse, + StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, TokenRequest, TokenResponse, UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, - UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, - ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, - WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, + UpdateTicketStatusResponse, ValidateGitTokenRequest, ValidateGitTokenResponse, + ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WorkflowExportResponse, + WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, WriteGitConfigRequest, + WriteGitConfigResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; // AgentProfile interchange types live in `crate::config`, not `rest::dto`. use crate::config::{AgentProfile, DelegatorLaunchConfig, RemoteAgentRef, XOperator}; @@ -205,6 +209,26 @@ use crate::rest::error::ErrorResponse; AccessKeySummary, AccessKeyListResponse, RevokeAccessKeyResponse, + // Setup + SetupStatusResponse, + SetupStepResponse, + SetupCollectionResponse, + HostedCollectionSelection, + SetupExecutionTarget, + SetupInitializeRequest, + SetupInitializeResponse, + crate::startup::steps::SetupStep, + crate::config::CollectionPreset, + crate::config::SessionWrapperType, + // Git onboarding + GitOnboardingState, + GitProviderOnboardingResponse, + ValidateGitTokenRequest, + ValidateGitTokenResponse, + WriteGitConfigRequest, + WriteGitConfigResponse, + SetGitSessionEnvRequest, + SetGitSessionEnvResponse, ) ), modifiers(&SecurityAddon), @@ -231,6 +255,8 @@ use crate::rest::error::ErrorResponse; (name = "Configuration", description = "Operator configuration read/write"), (name = "Kanban", description = "Kanban provider issue types and onboarding"), (name = "Auth", description = "Bootstrap, sessions, OAuth device flow, and access keys"), + (name = "Setup", description = "First-run workspace initialization"), + (name = "Git", description = "Git provider onboarding"), ) )] pub struct ApiDoc; diff --git a/src/rest/routes/collections.rs b/src/rest/routes/collections.rs index 2c38b374..77248022 100644 --- a/src/rest/routes/collections.rs +++ b/src/rest/routes/collections.rs @@ -103,9 +103,20 @@ pub async fn activate( State(state): State, Path(name): Path, ) -> Result, ApiError> { - let mut registry = state.registry.write().await; + if state.registry.read().await.get_collection(&name).is_none() { + return Err(ApiError::NotFound(format!("Collection '{name}' not found"))); + } + state + .mutate_config({ + let name = name.clone(); + move |config| { + config.templates.active_collection = Some(name); + Ok(()) + } + }) + .await?; - // Activate the collection + let mut registry = state.registry.write().await; registry .activate_collection(&name) .map_err(|e| ApiError::NotFound(format!("Failed to activate collection: {e}")))?; @@ -124,10 +135,12 @@ mod tests { use crate::config::Config; fn make_state() -> ApiState { - let config = Config::default(); - // Use a unique temp directory for each test to avoid state pollution + let mut config = Config::default(); let temp_dir = tempfile::TempDir::new().unwrap(); - ApiState::new(config, temp_dir.keep()) + let root = temp_dir.keep(); + config.paths.tickets = root.join("tickets").display().to_string(); + config.paths.state = root.join("state").display().to_string(); + ApiState::new(config, root.join("tickets")) } #[tokio::test] @@ -183,5 +196,11 @@ mod tests { // Verify it's now active let registry = state.registry.read().await; assert_eq!(registry.active_collection_name(), "simple"); + assert_eq!( + state.config().templates.active_collection.as_deref(), + Some("simple") + ); + let saved = std::fs::read_to_string(state.config().operator_config_path_for()).unwrap(); + assert!(saved.contains("active_collection = \"simple\"")); } } diff --git a/src/rest/routes/configuration.rs b/src/rest/routes/configuration.rs index 7aa0eb59..429a609c 100644 --- a/src/rest/routes/configuration.rs +++ b/src/rest/routes/configuration.rs @@ -44,6 +44,7 @@ fn response(config: &Config) -> ConfigurationResponse { confirm_autonomous: config.launch.confirm_autonomous, confirm_paired: config.launch.confirm_paired, launch_delay_ms: config.launch.launch_delay_ms, + target: config.launch.target.clone(), docker_enabled: config.launch.docker.enabled, docker_image: config.launch.docker.image.clone(), yolo_enabled: config.launch.yolo.enabled, @@ -134,6 +135,9 @@ fn apply_patch(config: &mut Config, patch: UpdateConfigurationRequest) { if let Some(value) = launch.launch_delay_ms { config.launch.launch_delay_ms = value; } + if let Some(value) = launch.target { + config.launch.target = Some(value); + } if let Some(value) = launch.docker_enabled { config.launch.docker.enabled = value; } @@ -191,6 +195,8 @@ pub async fn patch_config( let updated = state .mutate_config(move |config| { apply_patch(config, patch); + crate::config::validate_targets(config) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; Ok(response(config)) }) .await?; diff --git a/src/rest/routes/git_onboarding.rs b/src/rest/routes/git_onboarding.rs new file mode 100644 index 00000000..76c65fa4 --- /dev/null +++ b/src/rest/routes/git_onboarding.rs @@ -0,0 +1,234 @@ +use axum::{extract::State, Json}; + +use crate::integrations::catalog::{onboardable, Vertical}; +use crate::rest::dto::{ + GitOnboardingState, GitProviderOnboardingResponse, SetGitSessionEnvRequest, + SetGitSessionEnvResponse, ValidateGitTokenRequest, ValidateGitTokenResponse, + WriteGitConfigRequest, WriteGitConfigResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; +use crate::services::git_onboarding::{ + self, configure_git_provider, resolve_onboarding_with_config, OnboardingStep, +}; + +fn provider_is_offered(provider: &str) -> bool { + onboardable(Vertical::Git) + .iter() + .any(|entry| entry.slug == provider) +} + +fn validate_env_name(name: &str) -> Result<(), ApiError> { + let mut chars = name.chars(); + let valid_first = chars + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()); + if !valid_first || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + return Err(ApiError::ValidationError(format!( + "'{name}' is not a valid environment variable name" + ))); + } + Ok(()) +} + +#[utoipa::path( + get, + path = "/api/v1/git/providers", + tag = "Git", + operation_id = "git_providers", + responses((status = 200, body = [GitProviderOnboardingResponse])) +)] +pub async fn providers( + State(state): State, +) -> Result>, ApiError> { + let config = state.config(); + tokio::task::spawn_blocking(move || { + onboardable(Vertical::Git) + .into_iter() + .filter_map(|entry| { + let step = resolve_onboarding_with_config(&config, entry.slug)?; + let token_env = git_onboarding::token_env_for(&config, entry.slug)?; + let command = crate::api::cli_detection::onboarding_spec_for_slug(entry.slug)? + .command + .to_string(); + let configured = git_onboarding::provider_is_configured(&config, entry.slug); + let (state, action_url, username) = match step { + OnboardingStep::InstallCli { install_url, .. } => { + (GitOnboardingState::CliMissing, install_url, None) + } + OnboardingStep::CollectToken { pat_url, .. } => { + (GitOnboardingState::TokenRequired, pat_url, None) + } + OnboardingStep::AutoConfigured { username, .. } => ( + GitOnboardingState::Authenticated, + entry.docs_url().unwrap_or_default(), + Some(username), + ), + }; + Some(GitProviderOnboardingResponse { + slug: entry.slug.to_string(), + label: entry.label.to_string(), + docs_url: entry.docs_url().unwrap_or_default(), + configured, + command, + token_env, + state, + action_url, + username, + }) + }) + .collect() + }) + .await + .map(Json) + .map_err(|error| ApiError::InternalError(format!("Git detection task failed: {error}"))) +} + +#[utoipa::path( + post, + path = "/api/v1/git/validate", + tag = "Git", + operation_id = "git_validate", + request_body = ValidateGitTokenRequest, + responses((status = 200, body = ValidateGitTokenResponse)) +)] +pub async fn validate( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + if !provider_is_offered(&request.provider) { + return Err(ApiError::ValidationError(format!( + "Git provider '{}' is not available for onboarding", + request.provider + ))); + } + let config = state.config(); + let provider = request.provider; + let token = request.token; + let result = tokio::task::spawn_blocking(move || { + git_onboarding::validate_token_with_config(&config, &provider, &token) + }) + .await + .map_err(|error| ApiError::InternalError(format!("Git validation task failed: {error}")))?; + Ok(Json(match result { + Ok(username) => ValidateGitTokenResponse { + valid: true, + username: Some(username), + error: None, + }, + Err(error) => ValidateGitTokenResponse { + valid: false, + username: None, + error: Some(error.to_string()), + }, + })) +} + +#[utoipa::path( + put, + path = "/api/v1/git/config", + tag = "Git", + operation_id = "git_write_config", + request_body = WriteGitConfigRequest, + responses((status = 200, body = WriteGitConfigResponse)) +)] +pub async fn write_config( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + if !provider_is_offered(&request.provider) { + return Err(ApiError::ValidationError(format!( + "Git provider '{}' is not available for onboarding", + request.provider + ))); + } + validate_env_name(&request.token_env)?; + + let detection_config = state.config(); + let detection_provider = request.provider.clone(); + let adopted = tokio::task::spawn_blocking(move || { + match resolve_onboarding_with_config(&detection_config, &detection_provider) { + Some(OnboardingStep::AutoConfigured { + username, token, .. + }) => Some((username, token)), + _ => None, + } + }) + .await + .map_err(|error| ApiError::InternalError(format!("Git detection task failed: {error}")))?; + + if let Some((_, token)) = &adopted { + std::env::set_var(&request.token_env, token); + } + let provider = request.provider; + let token_env = request.token_env; + let username = adopted.map(|(username, _)| username); + let response = state + .mutate_config(move |config| { + configure_git_provider(config, &provider, &token_env) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + Ok(WriteGitConfigResponse { + provider, + shell_export_block: git_onboarding::shell_export_block(&token_env), + token_env, + username, + }) + }) + .await?; + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/api/v1/git/session-env", + tag = "Git", + operation_id = "git_set_session_env", + request_body = SetGitSessionEnvRequest, + responses((status = 200, body = SetGitSessionEnvResponse)) +)] +pub async fn set_session_env( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + if !provider_is_offered(&request.provider) { + return Err(ApiError::ValidationError(format!( + "Git provider '{}' is not available for onboarding", + request.provider + ))); + } + let token_env = git_onboarding::token_env_for(&state.config(), &request.provider) + .ok_or_else(|| ApiError::ValidationError("Unsupported git provider".to_string()))?; + validate_env_name(&token_env)?; + tokio::task::spawn_blocking({ + let token_env = token_env.clone(); + move || std::env::set_var(token_env, request.token) + }) + .await + .map_err(|error| ApiError::InternalError(format!("Git environment task failed: {error}")))?; + Ok(Json(SetGitSessionEnvResponse { + shell_export_block: git_onboarding::shell_export_block(&token_env), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_validate_env_name_rejects_shell_syntax() { + assert!(validate_env_name("TOKEN;echo").is_err()); + assert!(validate_env_name("9TOKEN").is_err()); + assert!(validate_env_name("TOKEN_NAME").is_ok()); + } + + #[test] + fn test_git_provider_set_matches_catalog() { + let offered: HashSet<_> = onboardable(Vertical::Git) + .into_iter() + .map(|entry| entry.slug) + .collect(); + assert!(offered.iter().all(|slug| provider_is_offered(slug))); + } +} diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index d37fcb20..b4f786f0 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -107,9 +107,7 @@ pub async fn write_config( .mutate_config(move |config| { let section_header = kanban_onboarding::apply_config_request(config, req)?; Ok(WriteKanbanConfigResponse { - written_path: crate::config::Config::operator_config_path() - .display() - .to_string(), + written_path: config.operator_config_path_for().display().to_string(), section_header, }) }) diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 6507ecd0..6603bc3b 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod collections; pub mod configuration; pub mod delegators; +pub mod git_onboarding; pub mod health; pub mod integrations; pub mod issuetypes; @@ -17,6 +18,7 @@ pub mod probes; pub mod projects; pub mod queue; pub mod sections; +pub mod setup; pub mod skills; pub mod steps; pub mod tickets; diff --git a/src/rest/routes/setup.rs b/src/rest/routes/setup.rs new file mode 100644 index 00000000..2555cf23 --- /dev/null +++ b/src/rest/routes/setup.rs @@ -0,0 +1,418 @@ +use std::collections::{HashMap, HashSet}; + +use axum::{extract::State, Json}; + +use crate::api::providers::model_server::ModelServerKind; +use crate::collections::fetch::{CollectionOrigin, ResolvedCollection}; +use crate::config::{CollectionPreset, ModelServer}; +use crate::integrations::catalog::{onboardable, Vertical}; +use crate::rest::dto::{ + SetupCollectionResponse, SetupExecutionTarget, SetupInitializeRequest, SetupInitializeResponse, + SetupStatusResponse, SetupStepResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; +use crate::setup::{initialize_workspace, FetchedCollection, SetupOptions, COMMON_OPTIONAL_FIELDS}; +use crate::startup::steps::SetupStep; + +async fn resolve_collections(config: &crate::config::Config) -> Vec { + let manifest = config + .templates + .collections_fetch_enabled + .then_some(config.templates.collections_manifest_url.as_deref()) + .flatten(); + crate::collections::fetch::resolve_for_setup( + manifest, + config.templates.collections_fetch_timeout_secs, + ) + .await +} + +#[utoipa::path( + get, + path = "/api/v1/setup/status", + tag = "Setup", + operation_id = "setup_status", + responses((status = 200, body = SetupStatusResponse)) +)] +pub async fn status(State(state): State) -> Result, ApiError> { + let config = state.config(); + let projects_path = config.projects_path(); + let store = state.auth.store.clone(); + let (projects_by_tool, admin_configured) = tokio::task::spawn_blocking(move || { + let projects = crate::projects::discover_projects_by_tool(&projects_path); + let configured = store + .bootstrap_state() + .is_ok_and(|status| status != crate::rest::dto::BootstrapState::Uninitialized); + (projects, configured) + }) + .await + .map_err(|error| ApiError::InternalError(format!("Setup status task failed: {error}")))?; + + Ok(Json(SetupStatusResponse { + initialized: crate::startup::workspace_initialized(&config), + admin_configured, + config_path: config.operator_config_path_for().display().to_string(), + tickets_path: config.tickets_path().display().to_string(), + projects_by_tool, + default_acceptance_criteria: include_str!("../../templates/ACCEPTANCE_CRITERIA.md") + .to_string(), + })) +} + +#[utoipa::path( + get, + path = "/api/v1/setup/steps", + tag = "Setup", + operation_id = "setup_steps", + responses((status = 200, body = [SetupStepResponse])) +)] +pub async fn steps() -> Json> { + Json( + SetupStep::ALL + .into_iter() + .enumerate() + .map(|(order, slug)| { + let info = slug.info(); + SetupStepResponse { + slug, + name: info.name.to_string(), + description: info.description.to_string(), + help_text: info.help_text.to_string(), + order, + } + }) + .collect(), + ) +} + +#[utoipa::path( + get, + path = "/api/v1/setup/collections", + tag = "Setup", + operation_id = "setup_collections", + responses((status = 200, body = [SetupCollectionResponse])) +)] +pub async fn collections(State(state): State) -> Json> { + let resolved = resolve_collections(&state.config()).await; + Json( + resolved + .into_iter() + .map(|collection| { + let types = collection.manifest.type_keys(); + let origin = match collection.origin { + CollectionOrigin::Hosted => "hosted", + CollectionOrigin::Embedded => "embedded", + }; + SetupCollectionResponse { + id: collection.manifest.id, + name: collection.manifest.name, + description: collection.manifest.description, + types, + default_selected: collection.manifest.default_selected, + origin: origin.to_string(), + note: collection.note, + checksum: collection.manifest.checksum.unwrap_or_default(), + } + }) + .collect(), + ) +} + +fn validate_task_fields(fields: &[String]) -> Result, ApiError> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for field in fields { + if !COMMON_OPTIONAL_FIELDS.contains(&field.as_str()) { + return Err(ApiError::ValidationError(format!( + "Unknown TASK field '{field}'" + ))); + } + if seen.insert(field.clone()) { + out.push(field.clone()); + } + } + Ok(out) +} + +fn selected_models(slugs: &[String]) -> Result, ApiError> { + let offered: HashSet<&str> = onboardable(Vertical::Model) + .into_iter() + .map(|entry| entry.slug) + .collect(); + let mut seen = HashSet::new(); + let mut servers = Vec::new(); + for slug in slugs { + if !offered.contains(slug.as_str()) || !seen.insert(slug.clone()) { + if seen.contains(slug) { + continue; + } + return Err(ApiError::ValidationError(format!( + "Model provider '{slug}' is not available for onboarding" + ))); + } + let kind = ModelServerKind::from_slug(slug) + .ok_or_else(|| ApiError::ValidationError(format!("Unknown model provider '{slug}'")))?; + if !kind.connectable_from_defaults() { + return Err(ApiError::ValidationError(format!( + "Model provider '{slug}' requires a custom base URL" + ))); + } + servers.push(ModelServer { + name: slug.clone(), + kind: slug.clone(), + base_url: kind.default_base_url().map(str::to_string), + api_key_env: kind.default_api_key_env().map(str::to_string), + extra_env: HashMap::new(), + display_name: Some(kind.display_name().to_string()), + }); + } + Ok(servers) +} + +fn selected_collections( + request: &SetupInitializeRequest, + resolved: Vec, +) -> Result<(Vec, Vec, Option), ApiError> { + if request.preset != CollectionPreset::Custom { + if !request.hosted_collections.is_empty() { + return Err(ApiError::ValidationError( + "Hosted collections require the custom preset".to_string(), + )); + } + return Ok((Vec::new(), Vec::new(), None)); + } + if request.hosted_collections.is_empty() { + return Err(ApiError::ValidationError( + "The custom preset requires at least one hosted collection".to_string(), + )); + } + + let mut fetched = Vec::new(); + let mut issue_types = Vec::new(); + for selected in &request.hosted_collections { + let collection = resolved + .iter() + .find(|candidate| candidate.manifest.id == selected.id) + .ok_or_else(|| { + ApiError::Conflict(format!( + "Collection '{}' is no longer available; reload the collection list", + selected.id + )) + })?; + if collection.manifest.checksum.as_deref() != Some(selected.checksum.as_str()) { + return Err(ApiError::Conflict(format!( + "Collection '{}' changed; reload the collection list", + selected.id + ))); + } + let keys = if collection.manifest.default_selected.is_empty() { + collection.manifest.type_keys() + } else { + collection.manifest.default_selected.clone() + }; + for key in keys { + if !issue_types.contains(&key) { + issue_types.push(key); + } + } + fetched.push(( + collection.manifest.clone(), + collection.files.clone(), + collection.icon_svg.clone(), + )); + } + let active = (fetched.len() == 1).then(|| fetched[0].0.id.clone()); + Ok((fetched, issue_types, active)) +} + +fn selected_execution_target( + target: SetupExecutionTarget, + wrapper: crate::config::SessionWrapperType, +) -> Result { + match target { + SetupExecutionTarget::Local => Ok(crate::config::TargetDef::local()), + SetupExecutionTarget::Coder { name, template } => { + let name = name.trim(); + let template = template.trim(); + if wrapper == crate::config::SessionWrapperType::Zellij { + return Err(ApiError::ValidationError( + "Coder targets cannot use the Zellij session wrapper".to_string(), + )); + } + if name.is_empty() + || matches!( + name, + crate::config::TARGET_LOCAL | crate::config::TARGET_DOCKER + ) + { + return Err(ApiError::ValidationError( + "Coder target name is empty or reserved".to_string(), + )); + } + if template.is_empty() { + return Err(ApiError::ValidationError( + "Coder template is required".to_string(), + )); + } + Ok(crate::config::TargetDef { + name: name.to_string(), + display_name: Some("Coder".to_string()), + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: template.to_string(), + ..Default::default() + }), + }) + } + } +} + +#[utoipa::path( + post, + path = "/api/v1/setup/initialize", + tag = "Setup", + operation_id = "setup_initialize", + request_body = SetupInitializeRequest, + responses( + (status = 200, body = SetupInitializeResponse), + (status = 409, description = "Workspace already initialized") + ) +)] +pub async fn initialize( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let task_fields = validate_task_fields(&request.task_fields)?; + let model_servers = selected_models(&request.model_servers)?; + let resolved = if request.preset == CollectionPreset::Custom { + resolve_collections(&state.config()).await + } else { + Vec::new() + }; + let (hosted_collections, custom_collection, active_collection) = + selected_collections(&request, resolved)?; + let execution_target = selected_execution_target(request.execution_target, request.wrapper)?; + let options = SetupOptions { + preset: request.preset, + task_fields, + use_worktrees: request.use_worktrees, + wrapper: Some(request.wrapper), + execution_target: Some(execution_target), + acceptance_criteria: Some(request.acceptance_criteria), + custom_collection, + active_collection, + hosted_collections, + model_servers, + ..Default::default() + }; + let tickets_path = state.config().tickets_path(); + let result = state + .mutate_config(move |config| { + if crate::startup::workspace_initialized(config) { + return Err(ApiError::Conflict( + "Workspace is already initialized".to_string(), + )); + } + initialize_workspace(config, &options).map_err(ApiError::from) + }) + .await?; + + let registry = crate::startup::templates::load_registry(&tickets_path); + *state.registry.write().await = registry; + + Ok(Json(SetupInitializeResponse { + initialized: true, + config_path: result.config_path.display().to_string(), + tickets_path: tickets_path.display().to_string(), + files_created: result + .files_created + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + files_skipped: result + .files_skipped + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + projects: result + .discovered + .into_iter() + .map(|project| project.name) + .collect(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn initialize_request() -> SetupInitializeRequest { + SetupInitializeRequest { + preset: CollectionPreset::Simple, + task_fields: vec!["priority".to_string()], + wrapper: crate::config::SessionWrapperType::Tmux, + execution_target: SetupExecutionTarget::Local, + use_worktrees: true, + acceptance_criteria: "Ship only when verified.".to_string(), + model_servers: Vec::new(), + hosted_collections: Vec::new(), + } + } + + #[test] + fn test_validate_task_fields_rejects_unknown_field() { + let error = validate_task_fields(&["mystery".to_string()]).unwrap_err(); + assert!(matches!(error, ApiError::ValidationError(_))); + } + + #[test] + fn test_setup_steps_match_catalog_order() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let response = runtime.block_on(steps()); + assert_eq!(response.0.len(), SetupStep::ALL.len()); + for (order, step) in response.0.iter().enumerate() { + assert_eq!(step.slug, SetupStep::ALL[order]); + assert_eq!(step.order, order); + } + } + + #[test] + fn test_coder_target_rejects_zellij() { + let result = selected_execution_target( + SetupExecutionTarget::Coder { + name: "coder-agents".to_string(), + template: "operator".to_string(), + }, + crate::config::SessionWrapperType::Zellij, + ); + assert!(matches!(result, Err(ApiError::ValidationError(_)))); + } + + #[tokio::test] + async fn test_initialize_persists_and_rejects_reinitialization() { + let temp = tempfile::TempDir::new().unwrap(); + let mut config = crate::config::Config::default(); + config.paths.tickets = temp.path().join("tickets").display().to_string(); + config.paths.state = temp.path().join("state").display().to_string(); + config.paths.projects = temp.path().join("projects").display().to_string(); + let state = ApiState::new(config, temp.path().join("tickets")); + + let response = initialize(State(state.clone()), Json(initialize_request())) + .await + .unwrap(); + assert!(response.initialized); + assert!(state.config().operator_config_path_for().is_file()); + assert!(crate::startup::workspace_initialized(&state.config())); + assert_eq!( + state.config().sessions.wrapper, + crate::config::SessionWrapperType::Tmux + ); + assert_eq!( + state.config().launch.target.as_deref(), + Some(crate::config::TARGET_LOCAL) + ); + + let second = initialize(State(state), Json(initialize_request())).await; + assert!(matches!(second, Err(ApiError::Conflict(_)))); + } +} diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index dade2ac3..f30062a5 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1312,7 +1312,7 @@ } }, "prompt_variations": { - "description": "Prompt variations (M) — Handlebars templates, minimum 2", + "description": "Prompt variations (M) - Handlebars templates, minimum 2", "type": "array", "items": { "type": "string" @@ -1353,7 +1353,7 @@ ] }, "PipelineConfig": { - "description": "Configuration for pipeline steps: iterate a list of items through ordered\nstages with no barrier (each item flows through all stages independently).\n\nThe step graph stays linear — a pipeline step still has exactly one\n`next_step`. The fan-out (N items x M stages) lives entirely inside this one\nstep; iteration is an intra-step concern, never a step-to-step edge.", + "description": "Configuration for pipeline steps: iterate a list of items through ordered\nstages with no barrier (each item flows through all stages independently).\n\nThe step graph stays linear - a pipeline step still has exactly one\n`next_step`. The fan-out (N items x M stages) lives entirely inside this one\nstep; iteration is an intra-step concern, never a step-to-step edge.", "type": "object", "properties": { "item_source": { @@ -1390,7 +1390,7 @@ ] }, { - "description": "An array produced by a prior step. Emits that step's result identifier\n(`r_`) — a runtime value, so the graph width is symbolic.", + "description": "An array produced by a prior step. Emits that step's result identifier\n(`r_`) - a runtime value, so the graph width is symbolic.", "type": "object", "properties": { "step": { @@ -1447,7 +1447,7 @@ ] }, { - "description": "A ticket field value split into a list. Resolution is deferred — there\nis no list `FieldType` and ticket field values are not captured at\nexport time yet — so this currently emits a symbolic placeholder.", + "description": "A ticket field value split into a list. Resolution is deferred - there\nis no list `FieldType` and ticket field values are not captured at\nexport time yet - so this currently emits a symbolic placeholder.", "type": "object", "properties": { "name": { @@ -1467,7 +1467,7 @@ ] }, "PipelineStage": { - "description": "A single stage in a pipeline — deliberately flat (not a recursive\n`StepSchema`): \"prompt + optional agent/model/schema\" only. It has no\n`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the\nstep-graph linearity question.", + "description": "A single stage in a pipeline - deliberately flat (not a recursive\n`StepSchema`): \"prompt + optional agent/model/schema\" only. It has no\n`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the\nstep-graph linearity question.", "type": "object", "properties": { "prompt": { diff --git a/src/services/git_onboarding.rs b/src/services/git_onboarding.rs new file mode 100644 index 00000000..357cd6d6 --- /dev/null +++ b/src/services/git_onboarding.rs @@ -0,0 +1,252 @@ +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; + +use crate::api::cli_detection::onboarding_spec_for_slug; +use crate::config::{Config, GitProviderConfig}; + +#[derive(Debug)] +pub enum OnboardingStep { + InstallCli { + install_url: String, + provider_display: String, + }, + CollectToken { + pat_url: String, + provider: String, + provider_display: String, + placeholder: String, + }, + AutoConfigured { + username: String, + token: String, + provider: String, + provider_display: String, + }, +} + +fn is_cli_installed(command: &str) -> bool { + Command::new(command) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn grab_cli_token(command: &str, args: &[&str]) -> Option { + let output = Command::new(command) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|token| !token.is_empty()) +} + +pub fn validate_github_token(token: &str) -> Result { + let response = reqwest::blocking::Client::new() + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {token}")) + .header("User-Agent", "operator") + .send() + .context("Failed to reach GitHub API")?; + anyhow::ensure!( + response.status().is_success(), + "GitHub token validation failed (HTTP {})", + response.status() + ); + let body: serde_json::Value = response.json().context("Failed to parse GitHub response")?; + body["login"] + .as_str() + .map(str::to_owned) + .context("GitHub response missing 'login' field") +} + +pub fn validate_gitlab_token(token: &str) -> Result { + let response = reqwest::blocking::Client::new() + .get("https://gitlab.com/api/v4/user") + .header("Private-Token", token) + .header("User-Agent", "operator") + .send() + .context("Failed to reach GitLab API")?; + anyhow::ensure!( + response.status().is_success(), + "GitLab token validation failed (HTTP {})", + response.status() + ); + let body: serde_json::Value = response.json().context("Failed to parse GitLab response")?; + body["username"] + .as_str() + .map(str::to_owned) + .context("GitLab response missing 'username' field") +} + +pub fn resolve_onboarding(provider: &str) -> Option { + let meta = onboarding_spec_for_slug(provider)?; + if !is_cli_installed(meta.command) { + return Some(OnboardingStep::InstallCli { + install_url: meta.install_url.to_string(), + provider_display: meta.display_name.to_string(), + }); + } + if let Some(token) = (!meta.auth_args.is_empty()) + .then(|| grab_cli_token(meta.command, meta.auth_args)) + .flatten() + { + let username = match provider { + "github" => validate_github_token(&token), + "gitlab" => validate_gitlab_token(&token), + _ => return None, + }; + if let Ok(username) = username { + return Some(OnboardingStep::AutoConfigured { + username, + token, + provider: provider.to_string(), + provider_display: meta.display_name.to_string(), + }); + } + } + Some(OnboardingStep::CollectToken { + pat_url: meta.pat_url.to_string(), + provider: provider.to_string(), + provider_display: meta.display_name.to_string(), + placeholder: meta.placeholder.to_string(), + }) +} + +pub fn configure_git_provider(config: &mut Config, provider: &str, token_env: &str) -> Result<()> { + match provider { + "github" => { + config.git.provider = Some(GitProviderConfig::GitHub); + config.git.github.enabled = true; + config.git.github.token_env = token_env.to_string(); + } + "gitlab" => { + config.git.provider = Some(GitProviderConfig::GitLab); + config.git.gitlab.enabled = true; + config.git.gitlab.token_env = token_env.to_string(); + } + "gitea" => { + config.git.provider = Some(GitProviderConfig::Gitea); + config.git.gitea.enabled = true; + config.git.gitea.token_env = token_env.to_string(); + } + _ => anyhow::bail!("Unsupported provider: {provider}"), + } + Ok(()) +} + +pub fn apply_git_provider(config: &mut Config, provider: &str, token: &str) -> Result<()> { + let token_env = token_env_for(config, provider) + .ok_or_else(|| anyhow::anyhow!("Unsupported provider: {provider}"))?; + configure_git_provider(config, provider, &token_env)?; + std::env::set_var(token_env, token); + Ok(()) +} + +pub fn complete_git_onboarding(config: &mut Config, provider: &str, token: &str) -> Result<()> { + apply_git_provider(config, provider, token)?; + config.save() +} + +pub fn token_env_for(config: &Config, provider: &str) -> Option { + match provider { + "github" => Some(config.git.github.token_env.clone()), + "gitlab" => Some(config.git.gitlab.token_env.clone()), + "gitea" => Some(config.git.gitea.token_env.clone()), + _ => None, + } +} + +pub fn shell_export_block(token_env: &str) -> String { + format!("export {token_env}=") +} + +pub fn validate_token(provider: &str, token: &str) -> Result { + match provider { + "github" => validate_github_token(token), + "gitlab" => validate_gitlab_token(token), + _ => anyhow::bail!("Unsupported provider: {provider}"), + } +} + +pub fn resolve_onboarding_with_config(config: &Config, provider: &str) -> Option { + let mut step = resolve_onboarding(provider)?; + if provider == "gitea" { + let base = + crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com") + .ok()?; + if let OnboardingStep::CollectToken { pat_url, .. } = &mut step { + *pat_url = base.join("user/settings/applications").ok()?.to_string(); + } + } + Some(step) +} + +pub fn validate_token_with_config(config: &Config, provider: &str, token: &str) -> Result { + if provider != "gitea" { + return validate_token(provider, token); + } + let base = crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com")?; + let git = crate::config::GitExecutionConfig { + credentials: Some(crate::config::GitCredentialConfig { + repository_url: base.join("operator/authentication")?.to_string(), + username: "operator".into(), + token_env: config.git.gitea.token_env.clone(), + }), + ..Default::default() + }; + let runtime = crate::git::runtime::GitRuntime::create_with_token(&git, Some(token))?; + let output = Command::new(crate::api::cli_detection::binary_for( + crate::types::pr::GitProvider::Gitea, + )) + .args(["api", "--login", "operator", "user"]) + .env("XDG_CONFIG_HOME", &runtime.path) + .output() + .context("Gitea requires tea with the api command")?; + anyhow::ensure!(output.status.success(), "Gitea token validation failed"); + let body: serde_json::Value = serde_json::from_slice(&output.stdout)?; + body["login"] + .as_str() + .map(str::to_owned) + .context("Gitea response missing login") +} + +pub fn provider_is_configured(config: &Config, provider: &str) -> bool { + match provider { + "github" => config.git.provider == Some(GitProviderConfig::GitHub), + "gitlab" => config.git.provider == Some(GitProviderConfig::GitLab), + "gitea" => config.git.provider == Some(GitProviderConfig::Gitea), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_onboarding_spec_for_supported_providers() { + for provider in ["github", "gitlab", "gitea"] { + assert!(onboarding_spec_for_slug(provider).is_some()); + } + assert!(onboarding_spec_for_slug("bitbucket").is_none()); + } + + #[test] + fn test_cli_helpers_reject_missing_binary() { + assert!(!is_cli_installed("nonexistent-cli-tool-xyz-12345")); + assert!(grab_cli_token("nonexistent-cli-tool-xyz-12345", &["auth", "token"]).is_none()); + } + + #[test] + fn test_resolve_onboarding_rejects_unknown_provider() { + assert!(resolve_onboarding("bitbucket").is_none()); + } +} diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index 1f2e8d79..95678b97 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -317,8 +317,8 @@ pub async fn list_statuses( /// Write or upsert a kanban config section to `config.toml`. /// -/// `config_override_path` is optional - when `None`, falls back to -/// `Config::operator_config_path()` (which is what production uses). +/// `config_override_path` is optional - when `None`, the config's own +/// `operator_config_path_for()` is used (which is what production uses). /// When `Some`, the config is loaded from and saved to that path instead /// (used by unit tests). #[allow(dead_code)] @@ -344,7 +344,7 @@ pub fn write_config( config .save() .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - Config::operator_config_path().display().to_string() + config.operator_config_path_for().display().to_string() }; info!(section = %section_header, "Wrote kanban config section"); diff --git a/src/services/mod.rs b/src/services/mod.rs index baa8b1f2..dcd9f1f1 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -5,6 +5,7 @@ #![allow(unused_imports)] // Re-exports for future integration +pub mod git_onboarding; pub mod kanban_issuetype_service; pub mod kanban_onboarding; pub mod kanban_sync; diff --git a/src/setup.rs b/src/setup.rs index b31a1430..3bdd3ba4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::PathBuf; use crate::agents::{generate_status_script, generate_tmux_conf}; -use crate::config::{CollectionPreset, Config}; +use crate::config::{CollectionPreset, Config, SessionWrapperType}; use crate::templates::TemplateType; /// Common optional fields that can be configured for TASK and propagated to other types @@ -32,8 +32,29 @@ pub struct SetupOptions { pub llm_tool: Option, /// Whether to use git worktrees for per-ticket isolation (default: false) pub use_worktrees: bool, + /// Session wrapper chosen during setup; `None` leaves the configured value + pub wrapper: Option, + /// Acceptance criteria body; `None` writes the shipped template + pub acceptance_criteria: Option, + /// Issue types for a `Custom` preset (e.g. a merged hosted selection) + pub custom_collection: Vec, + /// Collection to activate after scaffolding + pub active_collection: Option, + /// Hosted collections to scaffold: (manifest, files, `icon_svg`) + pub hosted_collections: Vec, + /// Model providers declared during setup + pub model_servers: Vec, + /// Default execution target selected during setup. + pub execution_target: Option, } +/// A hosted collection resolved for scaffolding. +pub type FetchedCollection = ( + crate::collections::manifest::CollectionManifest, + Vec<(String, String, Option)>, + Option, +); + /// Result of setup operation #[derive(Debug)] pub struct SetupResult { @@ -41,6 +62,8 @@ pub struct SetupResult { pub files_created: Vec, pub files_skipped: Vec, pub config_path: PathBuf, + /// Projects discovered during initialization, with git info + pub discovered: Vec, } /// Parse collection preset from string @@ -62,7 +85,8 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu directories_created: Vec::new(), files_created: Vec::new(), files_skipped: Vec::new(), - config_path: tickets_path.join("operator").join("config.toml"), + config_path: config.operator_config_path_for(), + discovered: Vec::new(), }; // Create directories @@ -85,7 +109,11 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu // Get effective issue types from preset let issue_types = if options.preset == CollectionPreset::Custom { - config.templates.collection.clone() + if options.custom_collection.is_empty() { + config.templates.collection.clone() + } else { + options.custom_collection.clone() + } } else { options.preset.issue_types() }; @@ -118,7 +146,10 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu let operator_templates = tickets_path.join("operator").join("templates"); write_file_if_allowed( &operator_templates.join("ACCEPTANCE_CRITERIA.md"), - include_str!("templates/ACCEPTANCE_CRITERIA.md"), + options + .acceptance_criteria + .as_deref() + .unwrap_or(include_str!("templates/ACCEPTANCE_CRITERIA.md")), options.force, &mut result, )?; @@ -135,26 +166,53 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu &mut result, )?; - // Update config with preset + for (manifest, files, icon_svg) in &options.hosted_collections { + crate::startup::templates::write_fetched_collection( + &tickets_path.join("templates"), + manifest, + files, + icon_svg.as_deref(), + )?; + } + config.templates.preset = options.preset; if options.preset == CollectionPreset::Custom { - // Keep existing collection - } else { config.templates.collection = issue_types; + } else { + config.templates.collection.clear(); + } + if let Some(active) = &options.active_collection { + config.templates.active_collection = Some(active.clone()); } - // Configure git worktree preference config.git.use_worktrees = options.use_worktrees; - // Generate tmux config - generate_tmux_config(config)?; + if let Some(wrapper) = options.wrapper { + config.sessions.wrapper = wrapper; + } + if let Some(tool) = &options.llm_tool { + config.llm_tools.default_tool = Some(tool.clone()); + } + for server in &options.model_servers { + if !config.model_servers.iter().any(|s| s.name == server.name) { + config.model_servers.push(server.clone()); + } + } + if let Some(target) = &options.execution_target { + config.launch.target = Some(target.name.clone()); + if !matches!(target.kind, crate::config::TargetKind::Local) { + if let Some(existing) = config.targets.iter_mut().find(|t| t.name == target.name) { + existing.clone_from(target); + } else { + config.targets.push(target.clone()); + } + } + } - // Discover projects (git repos and/or LLM marker files) - let discovered = crate::projects::discover_projects_with_git(&config.projects_path()); - config.projects = discovered.iter().map(|p| p.name.clone()).collect(); + generate_tmux_config(config)?; - // Save config (must be after directories are created) - config.save()?; + result.discovered = crate::projects::discover_projects_with_git(&config.projects_path()); + config.projects = result.discovered.iter().map(|p| p.name.clone()).collect(); Ok(result) } @@ -501,4 +559,132 @@ mod tests { // No projects should be discovered assert!(config.projects.is_empty()); } + + // --- Wizard choices that were previously collected and dropped --- + + fn workspace_config(temp_dir: &TempDir) -> Config { + let tickets_path = temp_dir.path().join(".tickets"); + let mut config = Config::default(); + config.paths.tickets = tickets_path.to_string_lossy().to_string(); + config.paths.state = tickets_path.join("operator").to_string_lossy().to_string(); + config.paths.projects = temp_dir.path().to_string_lossy().to_string(); + config + } + + #[test] + fn test_initialize_workspace_persists_session_wrapper() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + wrapper: Some(SessionWrapperType::Zellij), + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + assert_eq!(config.sessions.wrapper, SessionWrapperType::Zellij); + } + + #[test] + fn test_initialize_workspace_leaves_wrapper_untouched_when_unset() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + config.sessions.wrapper = SessionWrapperType::Cmux; + + initialize_workspace(&mut config, &SetupOptions::default()).unwrap(); + + assert_eq!(config.sessions.wrapper, SessionWrapperType::Cmux); + } + + #[test] + fn test_initialize_workspace_persists_use_worktrees() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + use_worktrees: true, + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + assert!(config.git.use_worktrees); + } + + #[test] + fn test_initialize_workspace_writes_custom_acceptance_criteria() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + acceptance_criteria: Some("- ships on a tuesday".to_string()), + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + let written = fs::read_to_string( + temp_dir + .path() + .join(".tickets/operator/templates/ACCEPTANCE_CRITERIA.md"), + ) + .unwrap(); + assert_eq!(written, "- ships on a tuesday"); + } + + #[test] + fn test_initialize_workspace_applies_llm_tool() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + llm_tool: Some("claude".to_string()), + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + assert_eq!(config.llm_tools.default_tool.as_deref(), Some("claude")); + } + + #[test] + fn test_initialize_workspace_upserts_default_coder_target() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + let target = crate::config::TargetDef { + name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), + display_name: Some("Coder".to_string()), + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: "operator-agent".to_string(), + ..Default::default() + }), + }; + + for _ in 0..2 { + initialize_workspace( + &mut config, + &SetupOptions { + execution_target: Some(target.clone()), + ..Default::default() + }, + ) + .unwrap(); + } + + assert_eq!( + config.launch.target.as_deref(), + Some(crate::config::DEFAULT_CODER_TARGET_NAME) + ); + assert_eq!(config.targets, [target]); + } + + #[test] + fn test_initialize_workspace_does_not_save() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + initialize_workspace(&mut config, &SetupOptions::default()).unwrap(); + + assert!( + !config.operator_config_path_for().exists(), + "initialize_workspace must leave persistence to the caller" + ); + } } diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 68dde61a..b07874e4 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -10,9 +10,9 @@ //! ## Usage //! //! ```rust,ignore -//! use crate::startup::{SETUP_STEPS, SetupStepInfo}; +//! use crate::startup::setup_steps; //! -//! for step in SETUP_STEPS { +//! for step in setup_steps() { //! println!("{}: {}", step.name, step.description); //! } //! @@ -21,246 +21,14 @@ //! init_default_templates(&templates_path)?; //! ``` +pub mod steps; pub mod templates; -/// Information about a setup wizard step for documentation purposes. -#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs -#[derive(Debug, Clone)] -pub struct SetupStepInfo { - /// Display name of the step (e.g., "Welcome") - pub name: &'static str, - /// Brief description of what happens in this step - pub description: &'static str, - /// Detailed help text explaining the step - pub help_text: &'static str, - /// Navigation instructions (keys to use) - pub navigation: &'static str, -} - -/// All setup wizard steps in order. +/// Whether a workspace has been initialized at `config`'s tickets path. /// -/// These steps correspond to the `SetupStep` enum in `src/ui/setup/types.rs`. -/// Steps follow a progressive disclosure model: config/welcome first, then -/// connections (session wrapper + git), then kanban providers, then issue type -/// selection, and finally confirmation. -/// -/// When adding new steps to the setup wizard, add corresponding entries here. -#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs -pub static SETUP_STEPS: &[SetupStepInfo] = &[ - // ── Tier 0: Config / Welcome ───────────────────────────────────────────── - SetupStepInfo { - name: "Welcome", - description: "Splash screen showing detected LLM tools and discovered projects", - help_text: "The welcome screen displays:\n\ - - Detected LLM tools (Claude, Gemini, Codex, etc.) with version and model count\n\ - - Discovered projects organized by which LLM tool marker files they contain\n\ - - The path where the tickets directory will be created\n\n\ - This gives you an overview of your development environment before proceeding.", - navigation: "Enter to continue, Esc to cancel", - }, - // ── Tier 1: Connections (session wrapper + git) ────────────────────────── - SetupStepInfo { - name: "Session Wrapper Choice", - description: "Select which session wrapper to use for launching coding agents", - help_text: "Choose how Operator will manage coding agent sessions:\n\ - - **tmux**: Terminal multiplexer, recommended for most setups\n\ - - **VS Code**: Launch agents as VS Code tasks (requires extension)\n\ - - **cmux**: Native macOS terminal for AI agents, organized into windows and workspaces\n\ - - **Zellij**: Modern terminal workspace with built-in layouts\n\n\ - Your choice determines which setup steps follow.", - navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", - }, - SetupStepInfo { - name: "Worktree Preference", - description: "Choose whether to use git worktrees for ticket isolation", - help_text: "Configure how Operator manages git branches per ticket:\n\ - - **In-place branches**: Each agent works in the main checkout, switching branches\n\ - - **Git worktrees**: Each ticket gets its own worktree directory for full isolation\n\n\ - Worktrees allow multiple agents to work on different tickets simultaneously \ - without branch conflicts.", - navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", - }, - SetupStepInfo { - name: "Web UI Password", - description: "Optionally set the admin password for the web dashboard", - help_text: "Operator has a single human account, `admin`.\n\n\ - This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists.\n\n\ - Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page.\n\n\ - The password must be at least 12 characters. This step is hidden when an admin account already exists.", - navigation: "Tab to switch fields, Enter to continue (blank to skip), Esc to go back", - }, - SetupStepInfo { - name: "Tmux Onboarding", - description: - "Help and documentation about tmux session management (shown if tmux selected)", - help_text: "Operator launches Coding agents in tmux sessions. Essential commands:\n\ - - **Detach from session**: Ctrl+a (quick, no prefix needed!)\n\ - - **Fallback detach**: Ctrl+b then d\n\ - - **List sessions**: `tmux ls`\n\ - - **Attach to session**: `tmux attach -t `\n\n\ - Operator session names start with 'op-' for easy identification.", - navigation: "Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "VS Code Setup", - description: "VS Code extension setup and verification (shown if VS Code selected)", - help_text: "Operator integrates with the VS Code extension to launch agents as tasks.\n\ - This step verifies the extension is installed and the webhook server is reachable.\n\n\ - Install the extension from the VS Code marketplace if prompted.", - navigation: "Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Cmux Setup", - description: "cmux session wrapper setup (shown if cmux selected)", - help_text: "cmux is a native macOS terminal that organizes AI agent sessions into \ - windows and workspaces.\n\n\ - This step verifies the cmux app's CLI binary exists at the configured \ - binary_path (by default inside /Applications/cmux.app) and meets the \ - minimum supported version.", - navigation: "Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Zellij Setup", - description: "Zellij session wrapper setup (shown if Zellij selected)", - help_text: - "Zellij is a modern terminal workspace with built-in layouts and multiplexing.\n\n\ - This step verifies Zellij is installed and configures the layout Operator will use \ - when launching agents.", - navigation: "Enter to continue, Esc to go back", - }, - // ── Tier 2: Kanban providers ───────────────────────────────────────────── - SetupStepInfo { - name: "Kanban Info", - description: "Kanban integration overview and provider credential detection", - help_text: - "Operator can sync with external kanban providers to pull in issues as tickets.\n\ - Supported providers: Jira, Linear, GitHub Projects.\n\n\ - Credentials are read from environment variables (e.g. OPERATOR_JIRA_API_KEY). \ - This step shows which providers were detected and validates connectivity.", - navigation: "Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Kanban Provider Setup", - description: "Per-provider credential validation and project selection", - help_text: "For each detected provider, Operator:\n\ - 1. Validates your API credentials against the provider\n\ - 2. Fetches your workspace and user information\n\ - 3. Discovers available projects for you to select\n\n\ - Only projects you select will be synced to your ticket queue. \ - You can skip this step to configure kanban providers later.", - navigation: - "↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back", - }, - // ── Tier 3: Issue types (configured after kanban providers are connected) ─ - SetupStepInfo { - name: "Collection Source", - description: "Choose which issue type collection to use", - help_text: "Select a preset collection of issue types:\n\ - - **Simple**: Just TASK - minimal setup for general work\n\ - - **Dev Kanban**: 3 types (TASK, FEAT, FIX) for development workflows\n\ - - **DevOps Kanban**: 5 types (TASK, SPIKE, INV, FEAT, FIX) for full DevOps\n\ - - **Custom Selection**: Choose individual issue types", - navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", - }, - SetupStepInfo { - name: "Hosted Collections", - description: "Browse and select hosted collections (only shown if Browse chosen)", - help_text: "Pick one or more curated collections published at operator.untra.io.\n\n\ - The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps.\n\n\ - Selections are additive - choose as many as apply.", - navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Task Field Config", - description: "Configure optional fields for TASK issue type", - help_text: - "TASK is the foundational issue type. Configure which optional fields to include:\n\ - - **priority**: Priority level (P0-critical to P3-low)\n\ - - **points**: Story points estimate\n\ - - **user_story**: User story or background context\n\n\ - These choices propagate to other issue types. The 'summary' field is always required, \ - and 'id' is auto-generated.", - navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", - }, - // ── Tier 4: Finalize ───────────────────────────────────────────────────── - SetupStepInfo { - name: "Acceptance Criteria", - description: "Review and configure acceptance criteria for ticket completion", - help_text: "Define what 'done' means for tickets in this workspace.\n\ - Acceptance criteria are checked by agents before marking a ticket complete.\n\n\ - The default criteria cover formatting, tests, and lint checks. \ - You can customize them for your team's standards.", - navigation: "Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Startup Tickets", - description: "Optionally create tickets to bootstrap your projects", - help_text: "Create startup tickets to help initialize your projects:\n\ - - **ASSESS tickets**: Scan projects for catalog-info.yaml, create if missing\n\ - - **AGENT_SETUP tickets**: Configure Claude agents for each project\n\ - - **PROJECT_INIT tickets**: Run both ASSESS and AGENT_SETUP for each project\n\n\ - These tickets are optional and help automate common setup tasks.", - navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Confirm", - description: "Review settings and confirm initialization", - help_text: "Review your configuration before initialization:\n\ - - Path where `.tickets/` will be created\n\ - - Selected issue types and preset name\n\ - - Directories that will be created: queue/, in-progress/, completed/, templates/\n\n\ - Choose Initialize to create the ticket queue, or Cancel to exit without changes.", - navigation: "Tab or Space to toggle selection, Enter to confirm, Esc to go back", - }, -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_setup_steps_not_empty() { - assert!(!SETUP_STEPS.is_empty()); - } - - #[test] - fn test_setup_steps_have_required_fields() { - for step in SETUP_STEPS { - assert!(!step.name.is_empty(), "Step name should not be empty"); - assert!( - !step.description.is_empty(), - "Step description should not be empty" - ); - assert!( - !step.help_text.is_empty(), - "Step help_text should not be empty" - ); - assert!( - !step.navigation.is_empty(), - "Step navigation should not be empty" - ); - } - } - - #[test] - fn test_setup_steps_count_matches_enum() { - // 16 steps: Welcome, SessionWrapperChoice, WorktreePreference, - // AdminPassword, TmuxOnboarding, VSCodeSetup, CmuxSetup, ZellijSetup, - // KanbanInfo, KanbanProviderSetup, CollectionSource, HostedCollectionFetch, TaskFieldConfig, - // AcceptanceCriteria, StartupTickets, Confirm - assert_eq!(SETUP_STEPS.len(), 16); - } - - #[test] - fn test_step_names_are_unique() { - let names: Vec<&str> = SETUP_STEPS.iter().map(|s| s.name).collect(); - let mut unique_names = names.clone(); - unique_names.sort_unstable(); - unique_names.dedup(); - assert_eq!( - names.len(), - unique_names.len(), - "Step names should be unique" - ); - } +/// One predicate for every surface: the TUI decides whether to show the setup +/// wizard from it, and it keeps "is this set up?" from drifting between them. +#[allow(dead_code)] // Used via binary today; the REST setup surface will share it +pub fn workspace_initialized(config: &crate::config::Config) -> bool { + config.tickets_path().join("queue").exists() } diff --git a/src/startup/steps.rs b/src/startup/steps.rs new file mode 100644 index 00000000..6b9c9183 --- /dev/null +++ b/src/startup/steps.rs @@ -0,0 +1,427 @@ +//! The setup wizard's step catalog: identity, order and copy. +//! +//! This is the single source of truth for the wizard. The ratatui renderer +//! matches on [`SetupStep`], `docs_gen` renders [`setup_steps`] into +//! `docs/startup/index.md`, and `bindings/SetupStep.ts` is generated from the +//! same enum. Adding a variant is a compile error until [`SetupStep::info`] +//! and [`SetupStep::slug`] account for it, and until [`SetupStep::ALL`] is +//! resized to place it in the walk order. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +/// Documentation copy for one wizard step. +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +#[derive(Debug, Clone)] +pub struct SetupStepInfo { + /// Display name of the step (e.g., "Welcome") + pub name: &'static str, + /// Brief description of what happens in this step + pub description: &'static str, + /// Detailed help text explaining the step + pub help_text: &'static str, + /// Navigation instructions (keys to use) + pub navigation: &'static str, +} + +/// A step in the setup wizard. +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, ToSchema, TS, +)] +#[ts(export)] +pub enum SetupStep { + /// Splash screen with discovered projects and detected LLM tools + #[serde(rename = "welcome")] + Welcome, + /// Kanban integration overview and provider credential detection + #[serde(rename = "kanban-info")] + KanbanInfo, + /// Declare which model providers this workspace uses + #[serde(rename = "model-server")] + ModelServer, + /// Connect a git provider so agents can branch, push and open PRs + #[serde(rename = "git-provider")] + GitProvider, + /// Choose which issue type collection to use + #[serde(rename = "collection-source")] + CollectionSource, + /// Browse and multi-select hosted collections + #[serde(rename = "hosted-collections")] + HostedCollectionFetch, + /// Configure optional TASK fields + #[serde(rename = "task-field-config")] + TaskFieldConfig, + /// Select the session wrapper agents launch into + #[serde(rename = "session-wrapper-choice")] + SessionWrapperChoice, + /// Choose where agent commands execute + #[serde(rename = "execution-target")] + ExecutionTarget, + /// Choose in-place branches or per-ticket worktrees + #[serde(rename = "worktree-preference")] + WorktreePreference, + /// Optional admin password for the web dashboard + #[serde(rename = "admin-password")] + AdminPassword, + /// tmux help, shown only when tmux is selected + #[serde(rename = "tmux-onboarding")] + TmuxOnboarding, + /// VS Code extension setup, shown only when VS Code is selected + #[serde(rename = "vscode-setup")] + VSCodeSetup, + /// cmux setup, shown only when cmux is selected + #[serde(rename = "cmux-setup")] + CmuxSetup, + /// Zellij setup, shown only when Zellij is selected + #[serde(rename = "zellij-setup")] + ZellijSetup, + /// Review the acceptance criteria template + #[serde(rename = "acceptance-criteria")] + AcceptanceCriteria, + /// Optionally create bootstrap tickets + #[serde(rename = "startup-tickets")] + StartupTickets, + /// Review and confirm initialization + #[serde(rename = "confirm")] + Confirm, +} + +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +impl SetupStep { + /// Every step, in the order the wizard walks them. Conditional steps + /// (the per-wrapper ones) appear here even though a given run skips most. + pub const ALL: [SetupStep; 18] = [ + SetupStep::Welcome, + SetupStep::KanbanInfo, + SetupStep::ModelServer, + SetupStep::GitProvider, + SetupStep::CollectionSource, + SetupStep::HostedCollectionFetch, + SetupStep::TaskFieldConfig, + SetupStep::SessionWrapperChoice, + SetupStep::ExecutionTarget, + SetupStep::WorktreePreference, + SetupStep::AdminPassword, + SetupStep::TmuxOnboarding, + SetupStep::VSCodeSetup, + SetupStep::CmuxSetup, + SetupStep::ZellijSetup, + SetupStep::AcceptanceCriteria, + SetupStep::StartupTickets, + SetupStep::Confirm, + ]; + + /// Stable identifier used by docs URLs and the web renderer. + pub fn slug(self) -> &'static str { + match self { + SetupStep::Welcome => "welcome", + SetupStep::KanbanInfo => "kanban-info", + SetupStep::ModelServer => "model-server", + SetupStep::GitProvider => "git-provider", + SetupStep::CollectionSource => "collection-source", + SetupStep::HostedCollectionFetch => "hosted-collections", + SetupStep::TaskFieldConfig => "task-field-config", + SetupStep::SessionWrapperChoice => "session-wrapper-choice", + SetupStep::ExecutionTarget => "execution-target", + SetupStep::WorktreePreference => "worktree-preference", + SetupStep::AdminPassword => "admin-password", + SetupStep::TmuxOnboarding => "tmux-onboarding", + SetupStep::VSCodeSetup => "vscode-setup", + SetupStep::CmuxSetup => "cmux-setup", + SetupStep::ZellijSetup => "zellij-setup", + SetupStep::AcceptanceCriteria => "acceptance-criteria", + SetupStep::StartupTickets => "startup-tickets", + SetupStep::Confirm => "confirm", + } + } + + /// Documentation copy for this step. + pub fn info(self) -> SetupStepInfo { + match self { + SetupStep::Welcome => SetupStepInfo { + name: "Welcome", + description: "Splash screen showing detected LLM tools and discovered projects", + help_text: "The welcome screen displays:\n\ + - Detected LLM tools (Claude, Gemini, Codex, etc.) with version and model count\n\ + - Discovered projects organized by which LLM tool marker files they contain\n\ + - The path where the tickets directory will be created\n\n\ + This gives you an overview of your development environment before proceeding.", + navigation: "Enter to continue, Esc to cancel", + }, + SetupStep::KanbanInfo => SetupStepInfo { + name: "Kanban Info", + description: "Connect a kanban provider, or skip and connect one later", + help_text: + "Operator can sync with external kanban providers to pull in issues as tickets.\n\ + Supported providers: Jira, Linear, GitHub Projects.\n\n\ + Credentials already exported (e.g. OPERATOR_JIRA_API_KEY) are listed as \ + detected providers.\n\n\ + **Connect a kanban provider** opens the same onboarding dialog the dashboard \ + uses: pick a provider, enter its credentials, validate them against the live \ + API, and choose a project. The provider section is written to config.toml and \ + the token is exported into this session, with a shell snippet to make it \ + permanent.\n\n\ + **Skip for now** moves on; press `K` from the dashboard at any time.", + navigation: "↑/↓ to select, Enter to confirm, Esc to go back", + }, + SetupStep::ModelServer => SetupStepInfo { + name: "Model Server", + description: "Declare which model providers this workspace uses", + help_text: + "Model providers are where inference happens - distinct from the agent CLI \ + that calls them.\n\n\ + Each provider is probed live: a row reads `N models` when Operator can reach \ + it, `key missing` when its API key env var is unset, or `unreachable` with \ + the reason.\n\n\ + Space declares a provider, writing a `[[model_servers]]` entry. Operator \ + stores only the *name* of the environment variable holding the key, never \ + the key itself - export it in your shell to make it permanent.\n\n\ + Providers needing a custom base URL (OpenAI-compatible, LM Studio) are \ + listed but not selectable here; add them to config.toml directly.\n\n\ + This step is optional - Operator ships working defaults for the first-party \ + vendors.", + navigation: + "↑/↓ or j/k to navigate, Space to declare, Enter to continue, Esc to go back", + }, + SetupStep::GitProvider => SetupStepInfo { + name: "Git Provider", + description: "Connect a git provider so agents can branch, push and open PRs", + help_text: + "Operator branches per ticket and opens pull requests on your behalf, which \ + needs a provider and a token.\n\n\ + Each row reports what was found: the provider CLI (`gh`, `glab`, `tea`) not \ + installed, an existing CLI login Operator can adopt with no typing, or a \ + prompt for a personal access token.\n\n\ + Only the *name* of the environment variable holding the token is written to \ + config.toml. The token itself is exported into this session, and the step \ + prints the shell line to make that permanent - without it the token is gone \ + when Operator exits.\n\n\ + This step is optional; Operator works against a local repository with no \ + provider connected.", + navigation: + "↑/↓ or j/k to navigate, Enter to connect, Esc to go back", + }, + SetupStep::CollectionSource => SetupStepInfo { + name: "Collection Source", + description: "Choose which issue type collection to use", + help_text: "Select a preset collection of issue types:\n\ + - **Simple**: Just TASK - minimal setup for general work\n\ + - **Dev Kanban**: 3 types (TASK, FEAT, FIX) for development workflows\n\ + - **DevOps Kanban**: 5 types (TASK, SPIKE, INV, FEAT, FIX) for full DevOps\n\ + - **Custom Selection**: Choose individual issue types", + navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", + }, + SetupStep::HostedCollectionFetch => SetupStepInfo { + name: "Hosted Collections", + description: "Browse and select hosted collections (only shown if Browse chosen)", + help_text: "Pick one or more curated collections published at operator.untra.io.\n\n\ + The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps.\n\n\ + Selections are additive - choose as many as apply.", + navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", + }, + SetupStep::TaskFieldConfig => SetupStepInfo { + name: "Task Field Config", + description: "Configure optional fields for TASK issue type", + help_text: + "TASK is the foundational issue type. Configure which optional fields to include:\n\ + - **priority**: Priority level (P0-critical to P3-low)\n\ + - **points**: Story points estimate\n\ + - **user_story**: User story or background context\n\n\ + These choices propagate to other issue types. The 'summary' field is always required, \ + and 'id' is auto-generated.", + navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", + }, + SetupStep::SessionWrapperChoice => SetupStepInfo { + name: "Session Wrapper Choice", + description: "Select which session wrapper to use for launching coding agents", + help_text: "Choose how Operator will manage coding agent sessions:\n\ + - **tmux**: Terminal multiplexer, recommended for most setups\n\ + - **VS Code**: Launch agents as VS Code tasks (requires extension)\n\ + - **cmux**: Native macOS terminal for AI agents, organized into windows and workspaces\n\ + - **Zellij**: Modern terminal workspace with built-in layouts\n\n\ + Your choice determines which setup steps follow.", + navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", + }, + SetupStep::ExecutionTarget => SetupStepInfo { + name: "Execution Target", + description: "Choose whether agents run locally or in Coder workspaces", + help_text: "Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH.\n\nCoder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment.\n\nCoder targets disable git worktrees and relay injection, and cannot be combined with Zellij.", + navigation: "↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back", + }, + SetupStep::WorktreePreference => SetupStepInfo { + name: "Worktree Preference", + description: "Choose whether to use git worktrees for ticket isolation", + help_text: "Configure how Operator manages git branches per ticket:\n\ + - **In-place branches**: Each agent works in the main checkout, switching branches\n\ + - **Git worktrees**: Each ticket gets its own worktree directory for full isolation\n\n\ + Worktrees allow multiple agents to work on different tickets simultaneously \ + without branch conflicts.", + navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", + }, + SetupStep::AdminPassword => SetupStepInfo { + name: "Web UI Password", + description: "Optionally set the admin password for the web dashboard", + help_text: "Operator has a single human account, `admin`.\n\n\ + This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists.\n\n\ + Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page.\n\n\ + The password must be at least 12 characters. This step is hidden when an admin account already exists.", + navigation: "Tab to switch fields, Enter to continue (blank to skip), Esc to go back", + }, + SetupStep::TmuxOnboarding => SetupStepInfo { + name: "Tmux Onboarding", + description: + "Help and documentation about tmux session management (shown if tmux selected)", + help_text: "Operator launches Coding agents in tmux sessions. Essential commands:\n\ + - **Detach from session**: Ctrl+a (quick, no prefix needed!)\n\ + - **Fallback detach**: Ctrl+b then d\n\ + - **List sessions**: `tmux ls`\n\ + - **Attach to session**: `tmux attach -t `\n\n\ + Operator session names start with 'op-' for easy identification.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStep::VSCodeSetup => SetupStepInfo { + name: "VS Code Setup", + description: "VS Code extension setup and verification (shown if VS Code selected)", + help_text: "Operator integrates with the VS Code extension to launch agents as tasks.\n\ + This step verifies the extension is installed and the webhook server is reachable.\n\n\ + Install the extension from the VS Code marketplace if prompted.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStep::CmuxSetup => SetupStepInfo { + name: "Cmux Setup", + description: "cmux session wrapper setup (shown if cmux selected)", + help_text: "cmux is a native macOS terminal that organizes AI agent sessions into \ + windows and workspaces.\n\n\ + This step verifies the cmux app's CLI binary exists at the configured \ + binary_path (by default inside /Applications/cmux.app) and meets the \ + minimum supported version.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStep::ZellijSetup => SetupStepInfo { + name: "Zellij Setup", + description: "Zellij session wrapper setup (shown if Zellij selected)", + help_text: + "Zellij is a modern terminal workspace with built-in layouts and multiplexing.\n\n\ + This step verifies Zellij is installed and configures the layout Operator will use \ + when launching agents.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStep::AcceptanceCriteria => SetupStepInfo { + name: "Acceptance Criteria", + description: "Review and configure acceptance criteria for ticket completion", + help_text: "Define what 'done' means for tickets in this workspace.\n\ + Acceptance criteria are checked by agents before marking a ticket complete.\n\n\ + The default criteria cover formatting, tests, and lint checks. \ + You can customize them for your team's standards.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStep::StartupTickets => SetupStepInfo { + name: "Startup Tickets", + description: "Optionally create tickets to bootstrap your projects", + help_text: "Create startup tickets to help initialize your projects:\n\ + - **ASSESS tickets**: Scan projects for catalog-info.yaml, create if missing\n\ + - **AGENT_SETUP tickets**: Configure Claude agents for each project\n\ + - **PROJECT_INIT tickets**: Run both ASSESS and AGENT_SETUP for each project\n\n\ + These tickets are optional and help automate common setup tasks.", + navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", + }, + SetupStep::Confirm => SetupStepInfo { + name: "Confirm", + description: "Review settings and confirm initialization", + help_text: "Review your configuration before initialization:\n\ + - Path where `.tickets/` will be created\n\ + - Selected issue types and preset name\n\ + - Directories that will be created: queue/, in-progress/, completed/, templates/\n\n\ + Choose Initialize to create the ticket queue, or Cancel to exit without changes.", + navigation: "Tab or Space to toggle selection, Enter to confirm, Esc to go back", + }, + } + } +} + +/// The full catalog in wizard order, for documentation generation. +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +pub fn setup_steps() -> Vec { + SetupStep::ALL.iter().map(|s| s.info()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_every_step_info_field_is_non_empty() { + for step in SetupStep::ALL { + let info = step.info(); + assert!(!info.name.is_empty(), "{step:?}: empty name"); + assert!(!info.description.is_empty(), "{step:?}: empty description"); + assert!(!info.help_text.is_empty(), "{step:?}: empty help_text"); + assert!(!info.navigation.is_empty(), "{step:?}: empty navigation"); + } + } + + #[test] + fn test_all_contains_no_duplicates() { + let unique: HashSet<_> = SetupStep::ALL.iter().collect(); + assert_eq!(unique.len(), SetupStep::ALL.len()); + } + + #[test] + fn test_slugs_are_unique() { + let unique: HashSet<_> = SetupStep::ALL.iter().map(|s| s.slug()).collect(); + assert_eq!(unique.len(), SetupStep::ALL.len()); + } + + #[test] + fn test_step_names_are_unique() { + let unique: HashSet<_> = SetupStep::ALL.iter().map(|s| s.info().name).collect(); + assert_eq!(unique.len(), SetupStep::ALL.len()); + } + + /// Slugs key docs URLs and the web renderer's component map, so a rename is + /// a breaking change and must be deliberate. + #[test] + fn test_slugs_match_frozen_snapshot() { + let slugs: Vec<&str> = SetupStep::ALL.iter().map(|s| s.slug()).collect(); + assert_eq!( + slugs, + vec![ + "welcome", + "kanban-info", + "model-server", + "git-provider", + "collection-source", + "hosted-collections", + "task-field-config", + "session-wrapper-choice", + "execution-target", + "worktree-preference", + "admin-password", + "tmux-onboarding", + "vscode-setup", + "cmux-setup", + "zellij-setup", + "acceptance-criteria", + "startup-tickets", + "confirm", + ] + ); + } + + #[test] + fn test_serde_representation_matches_slug() { + for step in SetupStep::ALL { + let json = serde_json::to_string(&step).unwrap(); + assert_eq!(json, format!("\"{}\"", step.slug())); + } + } + + #[test] + fn test_setup_steps_covers_every_variant() { + assert_eq!(setup_steps().len(), SetupStep::ALL.len()); + } +} diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index 6bb6b7a5..7e87e118 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use crate::agents::{SystemTmuxClient, TmuxClient, TmuxError}; +use crate::api::providers::model_server::ModelServerKind; use crate::config::{CollectionPreset, SessionWrapperType}; +use crate::integrations::catalog::{onboardable, CatalogEntry, Vertical}; use crate::ui::masked_input::MaskedInput; use ratatui::{widgets::ListState, Frame}; @@ -15,6 +17,10 @@ pub use types::*; #[cfg(test)] mod tests; +pub(crate) const LOCAL_TARGET_OPTION_INDEX: usize = 0; +pub(crate) const CODER_TARGET_OPTION_INDEX: usize = 1; +const EXECUTION_TARGET_OPTION_COUNT: usize = 2; + /// Setup screen shown when .tickets/ directory doesn't exist pub struct SetupScreen { /// Whether the screen is visible @@ -67,23 +73,41 @@ pub struct SetupScreen { /// Detected kanban providers from environment variables pub detected_kanban_providers: Vec, /// Indices of providers with valid credentials - pub valid_kanban_providers: Vec, - /// Projects fetched from current provider being configured - pub kanban_projects: - super::paginated_list::PaginatedList, - /// Issue types for the currently selected project - pub kanban_issue_types: Vec, - /// Member count for the currently selected project - pub kanban_member_count: usize, + /// Highlighted row on the kanban info step (0 = connect, 1 = skip) + pub(crate) kanban_choice_state: ListState, + /// Set when the user chose "connect"; the key handler opens the dialog + pub(crate) kanban_dialog_requested: bool, + // ─── Model Server State ───────────────────────────────────────────────── + /// Cursor over `model_providers()` + pub(crate) model_server_state: ListState, + /// Live probe result per kind slug, filled on entering the step + pub(crate) model_server_probes: std::collections::HashMap, + /// Kind slugs the user declared, written as `[[model_servers]]` entries + pub model_servers_declared: Vec, + /// Whether the probe pass has run + pub model_servers_probed: bool, + // ─── Git Provider State ───────────────────────────────────────────────── + /// Cursor over `git_providers()` + pub(crate) git_provider_state: ListState, + /// Slug the user asked to connect; the key handler resolves onboarding + pub(crate) git_connect_requested: Option, + /// Per-slug outcome line rendered next to the provider + pub(crate) git_provider_status: std::collections::HashMap, + /// Shell line that persists the connected provider's token + pub(crate) git_export_hint: Option, /// Whether kanban detection/testing has run pub kanban_detection_complete: bool, - /// Whether the user chose to skip kanban setup - pub kanban_skipped: bool, // ─── Session Wrapper Setup State ──────────────────────────────────────────── /// Selected session wrapper type pub selected_wrapper: SessionWrapperType, /// List state for wrapper selection pub(crate) wrapper_state: ListState, + // ─── Execution Target State ───────────────────────────────────────────── + pub(crate) execution_target_state: ListState, + pub(crate) coder_target_name: String, + pub(crate) coder_template: String, + pub(crate) coder_field: CoderSetupField, + pub(crate) execution_target_error: Option, /// Tmux availability status (checked during `TmuxOnboarding` step) pub tmux_status: TmuxDetectionStatus, /// VS Code extension status (checked during `VSCodeSetup` step) @@ -130,6 +154,9 @@ impl SetupScreen { let mut worktree_state = ListState::default(); worktree_state.select(Some(0)); + let mut execution_target_state = ListState::default(); + execution_target_state.select(Some(LOCAL_TARGET_OPTION_INDEX)); + let mut hosted_state = ListState::default(); hosted_state.select(Some(0)); @@ -162,15 +189,37 @@ impl SetupScreen { .to_string(), // Kanban setup state detected_kanban_providers: Vec::new(), - valid_kanban_providers: Vec::new(), - kanban_projects: super::paginated_list::PaginatedList::new(8), - kanban_issue_types: Vec::new(), - kanban_member_count: 0, + kanban_choice_state: { + let mut st = ListState::default(); + st.select(Some(0)); + st + }, + kanban_dialog_requested: false, + model_server_state: { + let mut st = ListState::default(); + st.select(Some(0)); + st + }, + model_server_probes: std::collections::HashMap::new(), + model_servers_declared: Vec::new(), + model_servers_probed: false, + git_provider_state: { + let mut st = ListState::default(); + st.select(Some(0)); + st + }, + git_connect_requested: None, + git_provider_status: std::collections::HashMap::new(), + git_export_hint: None, kanban_detection_complete: false, - kanban_skipped: false, // Session wrapper state selected_wrapper: SessionWrapperType::Tmux, wrapper_state, + execution_target_state, + coder_target_name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), + coder_template: String::new(), + coder_field: CoderSetupField::TargetName, + execution_target_error: None, tmux_status: TmuxDetectionStatus::NotChecked, vscode_status: VSCodeDetectionStatus::NotChecked, // Git worktree state @@ -311,6 +360,7 @@ impl SetupScreen { /// Toggle selection (Space key) pub fn toggle_selection(&mut self) { match self.step { + SetupStep::ModelServer => self.toggle_model_server(), SetupStep::HostedCollectionFetch => { // Toggle the highlighted collection in the multi-select picker. if let Some(r) = self.highlighted_hosted() { @@ -344,6 +394,9 @@ impl SetupScreen { } } } + SetupStep::ExecutionTarget => { + self.coder_field = self.coder_field.toggled(); + } SetupStep::WorktreePreference => { // Select the currently highlighted worktree option if let Some(i) = self.worktree_state.selected() { @@ -375,6 +428,29 @@ impl SetupScreen { /// Move to next item in list pub fn select_next(&mut self) { match self.step { + SetupStep::KanbanInfo => { + let i = self + .kanban_choice_state + .selected() + .map_or(0, |i| (i + 1) % 2); + self.kanban_choice_state.select(Some(i)); + } + SetupStep::ModelServer => { + let len = Self::model_providers().len(); + let i = self + .model_server_state + .selected() + .map_or(0, |i| (i + 1) % len); + self.model_server_state.select(Some(i)); + } + SetupStep::GitProvider => { + let len = Self::git_providers().len() + 1; + let i = self + .git_provider_state + .selected() + .map_or(0, |i| (i + 1) % len); + self.git_provider_state.select(Some(i)); + } SetupStep::CollectionSource => { let len = self.source_options.len(); if len > 0 { @@ -399,6 +475,16 @@ impl SetupScreen { let i = self.wrapper_state.selected().map_or(0, |i| (i + 1) % len); self.wrapper_state.select(Some(i)); } + SetupStep::ExecutionTarget => { + let i = self + .execution_target_state + .selected() + .map_or(LOCAL_TARGET_OPTION_INDEX, |i| { + (i + 1) % EXECUTION_TARGET_OPTION_COUNT + }); + self.execution_target_state.select(Some(i)); + self.execution_target_error = None; + } SetupStep::WorktreePreference => { let len = WorktreeOption::all().len(); let i = self.worktree_state.selected().map_or(0, |i| (i + 1) % len); @@ -416,6 +502,35 @@ impl SetupScreen { /// Move to previous item in list pub fn select_prev(&mut self) { match self.step { + SetupStep::KanbanInfo => { + let i = self + .kanban_choice_state + .selected() + .map_or(0, |i| (i + 1) % 2); + self.kanban_choice_state.select(Some(i)); + } + SetupStep::ModelServer => { + let len = Self::model_providers().len(); + let i = self.model_server_state.selected().map_or(0, |i| { + if i == 0 { + len - 1 + } else { + i - 1 + } + }); + self.model_server_state.select(Some(i)); + } + SetupStep::GitProvider => { + let len = Self::git_providers().len() + 1; + let i = self.git_provider_state.selected().map_or(0, |i| { + if i == 0 { + len - 1 + } else { + i - 1 + } + }); + self.git_provider_state.select(Some(i)); + } SetupStep::CollectionSource => { let len = self.source_options.len(); if len > 0 { @@ -458,6 +573,16 @@ impl SetupScreen { .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 }); self.wrapper_state.select(Some(i)); } + SetupStep::ExecutionTarget => { + let i = self + .execution_target_state + .selected() + .map_or(CODER_TARGET_OPTION_INDEX, |i| { + usize::from(i == LOCAL_TARGET_OPTION_INDEX) + }); + self.execution_target_state.select(Some(i)); + self.execution_target_error = None; + } SetupStep::WorktreePreference => { let len = WorktreeOption::all().len(); let i = @@ -547,6 +672,160 @@ impl SetupScreen { self.password_error = None; } + pub fn handle_execution_target_key(&mut self, code: ratatui::crossterm::event::KeyCode) { + use ratatui::crossterm::event::KeyCode; + + if self.execution_target_state.selected() != Some(CODER_TARGET_OPTION_INDEX) { + return; + } + let value = match self.coder_field { + CoderSetupField::TargetName => &mut self.coder_target_name, + CoderSetupField::Template => &mut self.coder_template, + }; + match code { + KeyCode::Char(c) => value.push(c), + KeyCode::Backspace | KeyCode::Delete => { + value.pop(); + } + _ => return, + } + self.execution_target_error = None; + } + + pub fn selected_execution_target(&self) -> crate::config::TargetDef { + if self.execution_target_state.selected() != Some(CODER_TARGET_OPTION_INDEX) { + return crate::config::TargetDef::local(); + } + crate::config::TargetDef { + name: self.coder_target_name.trim().to_string(), + display_name: Some("Coder".to_string()), + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: self.coder_template.trim().to_string(), + ..Default::default() + }), + } + } + + fn coder_target_selected(&self) -> bool { + self.execution_target_state.selected() == Some(CODER_TARGET_OPTION_INDEX) + } + + /// Declare or undeclare the highlighted provider. Kinds without a default + /// base URL need one supplied by hand, so they are not selectable here. + fn toggle_model_server(&mut self) { + let Some(kind) = self + .model_server_state + .selected() + .and_then(|i| Self::model_providers().get(i).map(|(_, k)| *k)) + else { + return; + }; + if !kind.connectable_from_defaults() { + return; + } + let slug = kind.slug().to_string(); + if let Some(pos) = self.model_servers_declared.iter().position(|s| s == &slug) { + self.model_servers_declared.remove(pos); + } else { + self.model_servers_declared.push(slug); + } + } + + /// Probe every connectable kind against its defaults, mirroring what the + /// web UI's provider cards show. + pub async fn probe_model_servers(&mut self, config: &crate::config::Config) { + if self.model_servers_probed { + return; + } + self.model_servers_probed = true; + let policy = crate::auth::egress::EgressPolicy::from_config(config); + for (_, kind) in Self::model_providers() { + if !kind.connectable_from_defaults() { + continue; + } + let server = crate::config::ModelServer { + name: kind.slug().to_string(), + kind: kind.slug().to_string(), + base_url: kind.default_base_url().map(str::to_string), + api_key_env: kind.default_api_key_env().map(str::to_string), + extra_env: std::collections::HashMap::new(), + display_name: None, + }; + let outcome = crate::api::providers::model_server::probe_models(&server, &policy).await; + let status = if outcome.reachable { + format!("{} models", outcome.models.len()) + } else if kind + .default_api_key_env() + .is_some_and(|e| std::env::var(e).is_err()) + { + "key missing".to_string() + } else { + outcome + .error + .unwrap_or_else(|| "unreachable".to_string()) + .chars() + .take(40) + .collect() + }; + self.model_server_probes + .insert(kind.slug().to_string(), status); + } + } + + /// The declared providers, as `[[model_servers]]` entries. + pub fn declared_model_servers(&self) -> Vec { + self.model_servers_declared + .iter() + .filter_map(|slug| ModelServerKind::from_slug(slug)) + .map(|kind| crate::config::ModelServer { + name: kind.slug().to_string(), + kind: kind.slug().to_string(), + base_url: kind.default_base_url().map(str::to_string), + api_key_env: kind.default_api_key_env().map(str::to_string), + extra_env: std::collections::HashMap::new(), + display_name: Some(kind.display_name().to_string()), + }) + .collect() + } + + /// Git providers the wizard offers, from the integration catalog. + pub(crate) fn git_providers() -> Vec { + onboardable(Vertical::Git) + } + + /// Model providers the wizard offers, from the integration catalog. Each + /// resolves to a `ModelServerKind` (guaranteed by `tests/vertical_parity.rs`). + pub(crate) fn model_providers() -> Vec<(CatalogEntry, ModelServerKind)> { + onboardable(Vertical::Model) + .into_iter() + .filter_map(|e| ModelServerKind::from_slug(e.slug).map(|k| (e, k))) + .collect() + } + + /// The highlighted provider slug, or `None` for the trailing "skip" row. + pub(crate) fn selected_git_provider(&self) -> Option { + let providers = Self::git_providers(); + self.git_provider_state + .selected() + .and_then(|i| providers.get(i)) + .map(|e| e.slug.to_string()) + } + + /// Consume a pending request to connect a git provider. + pub fn take_git_connect_request(&mut self) -> Option { + self.git_connect_requested.take() + } + + /// Record the outcome of a connect attempt for display. + pub fn set_git_provider_status(&mut self, slug: &str, status: String) { + self.git_provider_status.insert(slug.to_string(), status); + } + + /// Consume a pending request to open the kanban onboarding dialog. + pub fn take_kanban_dialog_request(&mut self) -> bool { + std::mem::take(&mut self.kanban_dialog_requested) + } + pub fn confirm(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => { @@ -562,23 +841,24 @@ impl SetupScreen { SetupResult::Continue } SetupStep::KanbanInfo => { - // Configure valid providers, otherwise proceed to the collection step. - if self.valid_kanban_providers.is_empty() || self.kanban_skipped { - self.enter_collection_source(); + // Row 0 hands off to the shared onboarding dialog, which + // collects credentials and writes the provider section. + if self.kanban_choice_state.selected() == Some(0) { + self.kanban_dialog_requested = true; } else { - self.step = SetupStep::KanbanProviderSetup { provider_index: 0 }; + self.step = SetupStep::ModelServer; } SetupResult::Continue } - SetupStep::KanbanProviderSetup { provider_index } => { - // Move to the next provider or on to the collection step. - let next_index = provider_index + 1; - if next_index < self.valid_kanban_providers.len() { - self.step = SetupStep::KanbanProviderSetup { - provider_index: next_index, - }; - } else { - self.enter_collection_source(); + SetupStep::ModelServer => { + self.step = SetupStep::GitProvider; + SetupResult::Continue + } + SetupStep::GitProvider => { + // Row 0..n connect; the last row moves on without a provider. + match self.selected_git_provider() { + Some(slug) => self.git_connect_requested = Some(slug), + None => self.enter_collection_source(), } SetupResult::Continue } @@ -643,7 +923,38 @@ impl SetupScreen { self.selected_wrapper = options[i].to_wrapper_type(); } } - // Navigate to worktree preference step + self.step = SetupStep::ExecutionTarget; + SetupResult::Continue + } + SetupStep::ExecutionTarget => { + if self.coder_target_selected() { + if self.selected_wrapper == SessionWrapperType::Zellij { + self.execution_target_error = + Some("Coder targets cannot use the Zellij session wrapper".to_string()); + return SetupResult::Continue; + } + if self.coder_target_name.trim().is_empty() { + self.execution_target_error = + Some("Coder target name is required".to_string()); + return SetupResult::Continue; + } + if matches!( + self.coder_target_name.trim(), + crate::config::TARGET_LOCAL | crate::config::TARGET_DOCKER + ) { + self.execution_target_error = Some(format!( + "'{}' is reserved for a built-in target", + self.coder_target_name.trim() + )); + return SetupResult::Continue; + } + if self.coder_template.trim().is_empty() { + self.execution_target_error = + Some("Coder template is required".to_string()); + return SetupResult::Continue; + } + self.use_worktrees = false; + } self.step = SetupStep::WorktreePreference; SetupResult::Continue } @@ -652,7 +963,8 @@ impl SetupScreen { if let Some(i) = self.worktree_state.selected() { let options = WorktreeOption::all(); if i < options.len() { - self.use_worktrees = options[i].to_use_worktrees(); + self.use_worktrees = + !self.coder_target_selected() && options[i].to_use_worktrees(); } } // The wrapper fan-out now lives on the AdminPassword arm, so @@ -724,26 +1036,16 @@ impl SetupScreen { self.step = SetupStep::Welcome; SetupResult::Continue } - SetupStep::KanbanProviderSetup { provider_index } => { - if provider_index > 0 { - self.step = SetupStep::KanbanProviderSetup { - provider_index: provider_index - 1, - }; - } else { - self.step = SetupStep::KanbanInfo; - } + SetupStep::ModelServer => { + self.step = SetupStep::KanbanInfo; + SetupResult::Continue + } + SetupStep::GitProvider => { + self.step = SetupStep::ModelServer; SetupResult::Continue } SetupStep::CollectionSource => { - // Return to the kanban step that preceded the collection step. - if !self.valid_kanban_providers.is_empty() && !self.kanban_skipped { - let last_index = self.valid_kanban_providers.len() - 1; - self.step = SetupStep::KanbanProviderSetup { - provider_index: last_index, - }; - } else { - self.step = SetupStep::KanbanInfo; - } + self.step = SetupStep::GitProvider; SetupResult::Continue } SetupStep::HostedCollectionFetch => { @@ -764,6 +1066,10 @@ impl SetupScreen { SetupResult::Continue } SetupStep::WorktreePreference => { + self.step = SetupStep::ExecutionTarget; + SetupResult::Continue + } + SetupStep::ExecutionTarget => { self.step = SetupStep::SessionWrapperChoice; SetupResult::Continue } @@ -836,15 +1142,15 @@ impl SetupScreen { SetupStep::HostedCollectionFetch => self.render_hosted_collection_step(frame), SetupStep::TaskFieldConfig => self.render_task_field_config_step(frame), SetupStep::SessionWrapperChoice => self.render_session_wrapper_choice_step(frame), + SetupStep::ExecutionTarget => self.render_execution_target_step(frame), SetupStep::WorktreePreference => self.render_worktree_preference_step(frame), SetupStep::TmuxOnboarding => self.render_tmux_onboarding_step(frame), SetupStep::VSCodeSetup => self.render_vscode_setup_step(frame), SetupStep::CmuxSetup => self.render_cmux_setup_step(frame), SetupStep::ZellijSetup => self.render_zellij_setup_step(frame), SetupStep::KanbanInfo => self.render_kanban_info_step(frame), - SetupStep::KanbanProviderSetup { provider_index } => { - self.render_kanban_provider_setup_step(frame, provider_index); - } + SetupStep::ModelServer => self.render_model_server_step(frame), + SetupStep::GitProvider => self.render_git_provider_step(frame), SetupStep::AdminPassword => self.render_admin_password_step(frame), SetupStep::AcceptanceCriteria => self.render_acceptance_criteria_step(frame), SetupStep::StartupTickets => self.render_startup_tickets_step(frame), diff --git a/src/ui/setup/steps/git.rs b/src/ui/setup/steps/git.rs new file mode 100644 index 00000000..de27fbd1 --- /dev/null +++ b/src/ui/setup/steps/git.rs @@ -0,0 +1,135 @@ +//! Git provider step: connect a provider so agents can branch, push and PR. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::api::cli_detection::onboarding_spec_for_slug; +use crate::integrations::catalog::CatalogEntry; +use crate::ui::dialogs::centered_rect; + +use super::super::SetupScreen; + +impl SetupScreen { + pub(crate) fn render_git_provider_step(&mut self, frame: &mut Frame) { + let area = centered_rect(70, 80, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Git Provider ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Title + Constraint::Length(3), // Description + Constraint::Min(6), // Provider rows + Constraint::Length(3), // Token note + Constraint::Length(2), // Footer + ]) + .split(inner); + + let title = Paragraph::new(Line::from(vec![Span::styled( + "Branches, pushes and pull requests", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )])); + frame.render_widget(title, chunks[0]); + + let description = Paragraph::new(vec![ + Line::from("Operator adopts an existing CLI login where it finds one,"), + Line::from("and otherwise asks for a personal access token."), + ]) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(description, chunks[1]); + + let providers = SetupScreen::git_providers(); + let selected = self.git_provider_state.selected().unwrap_or(0); + let mut rows: Vec = providers + .iter() + .enumerate() + .map(|(i, entry)| self.git_provider_row(entry, i == selected)) + .collect(); + rows.push(Line::from("")); + rows.push(row_line( + "Continue without a git provider", + selected == providers.len(), + None, + )); + frame.render_widget(Paragraph::new(rows), chunks[2]); + + let note = match &self.git_export_hint { + Some(export) => Paragraph::new(vec![ + Line::from("Token exported for this session only. To keep it:"), + Line::from(Span::styled( + export.clone(), + Style::default().fg(Color::Cyan), + )), + ]), + None => Paragraph::new(vec![ + Line::from("config.toml records only the env var name holding the token."), + Line::from("Export it in your shell to keep it past this session."), + ]) + .style(Style::default().fg(Color::DarkGray)), + }; + frame.render_widget(note, chunks[3]); + + let footer = Line::from(vec![ + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Connect "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Back"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[4], + ); + } + + fn git_provider_row(&self, entry: &CatalogEntry, highlighted: bool) -> Line<'static> { + let status = self + .git_provider_status + .get(entry.slug) + .cloned() + .unwrap_or_else(|| match onboarding_spec_for_slug(entry.slug) { + Some(spec) => format!("via {}, or a token", spec.command), + None => "personal access token".to_string(), + }); + row_line(entry.label, highlighted, Some(status)) + } +} + +fn row_line(label: &str, highlighted: bool, status: Option) -> Line<'static> { + let (marker, color) = if highlighted { + ("> ", Color::Cyan) + } else { + (" ", Color::Gray) + }; + let mut spans = vec![ + Span::raw(" "), + Span::styled(marker, Style::default().fg(color)), + Span::styled(label.to_string(), Style::default().fg(color)), + ]; + if let Some(status) = status { + let status_color = if status.starts_with("connected") { + Color::Green + } else { + Color::DarkGray + }; + spans.push(Span::raw(" ")); + spans.push(Span::styled(status, Style::default().fg(status_color))); + } + Line::from(spans) +} diff --git a/src/ui/setup/steps/kanban.rs b/src/ui/setup/steps/kanban.rs index c68eddac..0ba6206e 100644 --- a/src/ui/setup/steps/kanban.rs +++ b/src/ui/setup/steps/kanban.rs @@ -35,7 +35,9 @@ impl SetupScreen { Constraint::Length(6), // Supported providers list (4 providers) Constraint::Length(1), // Spacer Constraint::Length(2), // Detected header - Constraint::Min(6), // Detected providers list + Constraint::Min(4), // Detected providers list + Constraint::Length(1), // Spacer + Constraint::Length(2), // Action rows Constraint::Length(2), // Footer/help ]) .split(inner); @@ -126,8 +128,7 @@ impl SetupScreen { Style::default().fg(Color::DarkGray), )])); } else { - for (i, provider) in self.detected_kanban_providers.iter().enumerate() { - let is_valid = self.valid_kanban_providers.contains(&i); + for provider in &self.detected_kanban_providers { let (icon, icon_color) = match &provider.status { ProviderStatus::Untested => ("?", Color::Yellow), ProviderStatus::Testing => ("~", Color::Yellow), @@ -155,14 +156,7 @@ impl SetupScreen { Span::raw(" ["), Span::styled(icon, Style::default().fg(icon_color)), Span::raw("] "), - Span::styled( - provider_name, - Style::default().fg(if is_valid { - Color::White - } else { - Color::DarkGray - }), - ), + Span::styled(provider_name, Style::default().fg(Color::White)), Span::raw(" - "), Span::styled(&provider.domain, Style::default().fg(Color::Cyan)), Span::raw(" ("), @@ -174,153 +168,38 @@ impl SetupScreen { let detected_list = Paragraph::new(detected_lines); frame.render_widget(detected_list, chunks[7]); - // Footer - let footer = if self.valid_kanban_providers.is_empty() { - Line::from(vec![ - Span::styled("[Enter]", Style::default().fg(Color::Yellow)), - Span::raw(" Continue "), - Span::styled("[Esc]", Style::default().fg(Color::Yellow)), - Span::raw(" Back"), - ]) - } else { - Line::from(vec![ - Span::styled("[Enter]", Style::default().fg(Color::Yellow)), - Span::raw(" Configure providers "), - Span::styled("[S]", Style::default().fg(Color::Yellow)), - Span::raw(" Skip "), - Span::styled("[Esc]", Style::default().fg(Color::Yellow)), - Span::raw(" Back"), - ]) - }; - let footer_para = Paragraph::new(footer).alignment(Alignment::Center); - frame.render_widget(footer_para, chunks[8]); - } - - pub(crate) fn render_kanban_provider_setup_step( - &mut self, - frame: &mut Frame, - provider_index: usize, - ) { - let area = centered_rect(70, 80, frame.area()); - frame.render_widget(Clear, area); - - // Get the provider being configured - let provider_idx = self - .valid_kanban_providers - .get(provider_index) - .copied() - .unwrap_or(0); - let provider = self.detected_kanban_providers.get(provider_idx); - - let title = if let Some(p) = provider { - let provider_name = match p.provider_type { - KanbanProviderType::Jira => "Jira", - KanbanProviderType::Linear => "Linear", - KanbanProviderType::Github => "GitHub", - KanbanProviderType::Openspec => "OpenSpec", - }; - format!(" Setup: {} - {} ", provider_name, p.domain) - } else { - " Kanban Provider Setup ".to_string() - }; - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(2), // Instructions - Constraint::Length(1), // Spacer - Constraint::Min(10), // Project list - Constraint::Length(1), // Spacer - Constraint::Length(3), // Preview info - Constraint::Length(2), // Footer - ]) - .split(inner); - - // Instructions - let instructions = - Paragraph::new("Select a project to sync:").style(Style::default().fg(Color::Gray)); - frame.render_widget(instructions, chunks[0]); - - // Project list - if self.kanban_projects.is_empty() { - let loading = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![Span::styled( - "Loading projects...", - Style::default().fg(Color::Yellow), - )]), - Line::from(""), - Line::from(vec![Span::styled( - "(Projects will be fetched when you enter this step)", - Style::default().fg(Color::DarkGray), - )]), - ]) - .alignment(Alignment::Center); - frame.render_widget(loading, chunks[2]); - } else { - crate::ui::paginated_list::render_paginated_list( - frame, - chunks[2], - &mut self.kanban_projects, - "Projects", - |project, _selected| { - ratatui::widgets::ListItem::new(Line::from(vec![ - Span::styled( - format!("{:8}", project.key), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - "), - Span::styled(project.name.clone(), Style::default().fg(Color::White)), - ])) - }, - ); - } - - // Preview info - let preview = if self.kanban_issue_types.is_empty() { - Line::from(vec![Span::styled( - "Select a project to see details", - Style::default().fg(Color::DarkGray), - )]) - } else { - Line::from(vec![ - Span::styled("Issue Types: ", Style::default().fg(Color::Yellow)), - Span::styled( - self.kanban_issue_types.join(", "), - Style::default().fg(Color::White), - ), - Span::raw(" | "), - Span::styled("Members: ", Style::default().fg(Color::Yellow)), - Span::styled( - self.kanban_member_count.to_string(), - Style::default().fg(Color::White), - ), - ]) - }; - let preview_para = Paragraph::new(preview); - frame.render_widget(preview_para, chunks[4]); + // Actions. Connecting hands off to the shared onboarding dialog, so + // credentials are collected the same way here and from the dashboard. + let selected = self.kanban_choice_state.selected().unwrap_or(0); + let action_rows = Paragraph::new(vec![ + choice_line("Connect a kanban provider", selected == 0), + choice_line("Skip for now", selected == 1), + ]); + frame.render_widget(action_rows, chunks[9]); - // Footer let footer = Line::from(vec![ - Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), Span::raw(" Select "), - Span::styled("[n/p]", Style::default().fg(Color::Yellow)), - Span::raw(" Page "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Confirm "), Span::styled("[Esc]", Style::default().fg(Color::Yellow)), - Span::raw(" Skip provider"), + Span::raw(" Back"), ]); let footer_para = Paragraph::new(footer).alignment(Alignment::Center); - frame.render_widget(footer_para, chunks[5]); + frame.render_widget(footer_para, chunks[10]); } } + +/// A selectable action row on the kanban info step. +fn choice_line(label: &str, selected: bool) -> Line<'_> { + let (marker, color) = if selected { + ("> ", Color::Cyan) + } else { + (" ", Color::Gray) + }; + Line::from(vec![ + Span::raw(" "), + Span::styled(marker, Style::default().fg(color)), + Span::styled(label.to_string(), Style::default().fg(color)), + ]) +} diff --git a/src/ui/setup/steps/mod.rs b/src/ui/setup/steps/mod.rs index c5207255..125b7e2e 100644 --- a/src/ui/setup/steps/mod.rs +++ b/src/ui/setup/steps/mod.rs @@ -4,9 +4,12 @@ mod acceptance; mod admin_password; mod collection; mod confirm; +mod git; mod hosted; mod kanban; +mod model_server; mod startup; +mod target; mod task_fields; mod welcome; mod wrapper; @@ -14,9 +17,12 @@ mod wrapper; pub use acceptance::*; pub use collection::*; pub use confirm::*; +pub use git::*; pub use hosted::*; pub use kanban::*; +pub use model_server::*; pub use startup::*; +pub use target::*; pub use task_fields::*; pub use welcome::*; pub use wrapper::*; diff --git a/src/ui/setup/steps/model_server.rs b/src/ui/setup/steps/model_server.rs new file mode 100644 index 00000000..74573e18 --- /dev/null +++ b/src/ui/setup/steps/model_server.rs @@ -0,0 +1,150 @@ +//! Model server step: declare which providers this workspace uses. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::api::providers::model_server::ModelServerKind; +use crate::integrations::catalog::CatalogEntry; +use crate::ui::dialogs::centered_rect; + +use super::super::SetupScreen; + +impl SetupScreen { + pub(crate) fn render_model_server_step(&mut self, frame: &mut Frame) { + let area = centered_rect(70, 80, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Model Providers ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Title + Constraint::Length(3), // Description + Constraint::Min(10), // Provider rows + Constraint::Length(2), // Key hint + Constraint::Length(2), // Footer + ]) + .split(inner); + + let title = Paragraph::new(Line::from(vec![Span::styled( + "Where inference happens", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )])); + frame.render_widget(title, chunks[0]); + + let description = Paragraph::new(vec![ + Line::from("Declaring a provider records it in config.toml. Operator stores"), + Line::from("only the name of the env var holding its key, never the key."), + ]) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(description, chunks[1]); + + let selected = self.model_server_state.selected().unwrap_or(0); + let mut rows = Vec::new(); + let mut last_class = None; + let providers = SetupScreen::model_providers(); + for (i, (entry, kind)) in providers.iter().enumerate() { + let class = kind.provider_class(); + if last_class != Some(class) { + rows.push(Line::from(vec![Span::styled( + format!(" {}", class.display_name()), + Style::default().fg(Color::Yellow), + )])); + last_class = Some(class); + } + rows.push(self.provider_row(entry, *kind, i == selected)); + } + frame.render_widget(Paragraph::new(rows), chunks[2]); + + let hint = match self + .model_server_state + .selected() + .and_then(|i| providers.get(i).map(|(_, k)| k)) + { + Some(kind) if !kind.connectable_from_defaults() => { + Paragraph::new(Line::from(vec![Span::styled( + "Needs a base URL - add a [[model_servers]] entry to config.toml.", + Style::default().fg(Color::DarkGray), + )])) + } + Some(kind) => match kind.default_api_key_env() { + Some(env) => Paragraph::new(Line::from(vec![ + Span::raw("Key env var: "), + Span::styled(env, Style::default().fg(Color::Cyan)), + ])), + None => Paragraph::new(Line::from(vec![Span::styled( + "No API key required.", + Style::default().fg(Color::DarkGray), + )])), + }, + None => Paragraph::new(""), + }; + frame.render_widget(hint, chunks[3]); + + let footer = Line::from(vec![ + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate "), + Span::styled("[Space]", Style::default().fg(Color::Yellow)), + Span::raw(" Declare "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Continue "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Back"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[4], + ); + } + + fn provider_row( + &self, + entry: &CatalogEntry, + kind: ModelServerKind, + highlighted: bool, + ) -> Line<'static> { + let slug = kind.slug(); + let declared = self.model_servers_declared.iter().any(|s| s == slug); + let marker = if highlighted { "> " } else { " " }; + let checkbox = if declared { "[x]" } else { "[ ]" }; + let name_color = if highlighted { + Color::Cyan + } else { + Color::White + }; + + let (status, status_color) = if kind.connectable_from_defaults() { + match self.model_server_probes.get(slug) { + Some(s) if s.ends_with("models") => (s.clone(), Color::Green), + Some(s) => (s.clone(), Color::DarkGray), + None => ("checking...".to_string(), Color::DarkGray), + } + } else { + ("needs base URL".to_string(), Color::DarkGray) + }; + + Line::from(vec![ + Span::raw(" "), + Span::styled(marker, Style::default().fg(name_color)), + Span::styled(checkbox, Style::default().fg(name_color)), + Span::raw(" "), + Span::styled(entry.label, Style::default().fg(name_color)), + Span::raw(" "), + Span::styled(status, Style::default().fg(status_color)), + ]) + } +} diff --git a/src/ui/setup/steps/target.rs b/src/ui/setup/steps/target.rs new file mode 100644 index 00000000..958a68e7 --- /dev/null +++ b/src/ui/setup/steps/target.rs @@ -0,0 +1,149 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::config::{DEFAULT_CODER_TOKEN_ENV, DEFAULT_CODER_URL_ENV}; +use crate::ui::dialogs::centered_rect; +use crate::ui::setup::{ + CoderSetupField, SetupScreen, CODER_TARGET_OPTION_INDEX, LOCAL_TARGET_OPTION_INDEX, +}; + +impl SetupScreen { + pub(crate) fn render_execution_target_step(&self, frame: &mut Frame) { + let area = centered_rect(72, 76, frame.area()); + frame.render_widget(Clear, area); + let block = Block::default() + .title(" Execution Target ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Length(4), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(2), + Constraint::Length(2), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new("Where agent commands run").style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + chunks[0], + ); + + let selected = self + .execution_target_state + .selected() + .unwrap_or(LOCAL_TARGET_OPTION_INDEX); + frame.render_widget( + Paragraph::new(vec![ + target_line( + "Local", + selected == LOCAL_TARGET_OPTION_INDEX, + "run beside Operator", + ), + target_line( + "Coder", + selected == CODER_TARGET_OPTION_INDEX, + "one workspace per ticket over SSH", + ), + ]), + chunks[1], + ); + + if selected == CODER_TARGET_OPTION_INDEX { + render_field( + frame, + chunks[2], + "Target name", + &self.coder_target_name, + self.coder_field == CoderSetupField::TargetName, + ); + render_field( + frame, + chunks[3], + "Coder template", + &self.coder_template, + self.coder_field == CoderSetupField::Template, + ); + let credentials = format!( + "{DEFAULT_CODER_URL_ENV}: {} {DEFAULT_CODER_TOKEN_ENV}: {}", + env_status(DEFAULT_CODER_URL_ENV), + env_status(DEFAULT_CODER_TOKEN_ENV) + ); + frame.render_widget( + Paragraph::new(credentials).style(Style::default().fg(Color::DarkGray)), + chunks[4], + ); + } + + if let Some(error) = &self.execution_target_error { + frame.render_widget( + Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), + chunks[5], + ); + } + + frame.render_widget( + Paragraph::new("↑/↓ Select Tab Switch field Enter Continue Esc Back") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)), + chunks[6], + ); + } +} + +fn target_line(name: &str, selected: bool, description: &str) -> Line<'static> { + let marker = if selected { "(o)" } else { "( )" }; + let color = if selected { Color::Cyan } else { Color::Gray }; + Line::from(vec![ + Span::styled(format!("{marker} {name}"), Style::default().fg(color)), + Span::raw(format!(" {description}")), + ]) +} + +fn render_field( + frame: &mut Frame, + area: ratatui::layout::Rect, + label: &str, + value: &str, + focused: bool, +) { + let border = if focused { + Color::Cyan + } else { + Color::DarkGray + }; + frame.render_widget( + Paragraph::new(value.to_string()).block( + Block::default() + .title(format!(" {label} ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(border)), + ), + area, + ); +} + +fn env_status(name: &str) -> &'static str { + if std::env::var_os(name).is_some() { + "set" + } else { + "missing" + } +} diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index d05aa981..3b59b325 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -2,6 +2,7 @@ use super::types::*; use super::SetupScreen; +use crate::api::providers::model_server::ModelServerKind; use crate::config::SessionWrapperType; use std::collections::HashMap; @@ -180,9 +181,9 @@ fn test_setup_navigation_to_worktree_preference() { screen.selected_wrapper = SessionWrapperType::Tmux; screen.wrapper_state.select(Some(0)); // Select tmux - // SessionWrapperChoice -> WorktreePreference + // SessionWrapperChoice -> ExecutionTarget screen.confirm(); - assert_eq!(screen.step, SetupStep::WorktreePreference); + assert_eq!(screen.step, SetupStep::ExecutionTarget); } #[test] @@ -218,7 +219,55 @@ fn test_setup_worktree_preference_go_back() { screen.step = SetupStep::WorktreePreference; screen.go_back(); - assert_eq!(screen.step, SetupStep::SessionWrapperChoice); + assert_eq!(screen.step, SetupStep::ExecutionTarget); +} + +#[test] +fn test_execution_target_local_is_the_default() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionTarget; + + assert_eq!( + screen.selected_execution_target().kind, + crate::config::TargetKind::Local + ); + screen.confirm(); + assert_eq!(screen.step, SetupStep::WorktreePreference); +} + +#[test] +fn test_execution_target_coder_requires_template() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionTarget; + screen.execution_target_state.select(Some(1)); + screen.coder_template.clear(); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::ExecutionTarget); + assert!(screen + .execution_target_error + .as_deref() + .unwrap_or_default() + .contains("template")); +} + +#[test] +fn test_execution_target_coder_builds_target_and_disables_worktrees() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionTarget; + screen.execution_target_state.select(Some(1)); + screen.coder_template = "operator-agent".to_string(); + screen.use_worktrees = true; + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::WorktreePreference); + assert!(!screen.use_worktrees); + assert!(matches!( + screen.selected_execution_target().kind, + crate::config::TargetKind::Coder(_) + )); } #[test] @@ -301,11 +350,15 @@ fn test_welcome_advances_to_kanban_info() { } #[test] -fn test_kanban_info_no_providers_advances_to_collection_source() { +fn test_kanban_skip_advances_to_curated_collection_source() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); screen.step = SetupStep::KanbanInfo; - // No valid providers -> straight to the collection source step. - screen.confirm(); + screen.select_next(); // "Skip for now" + + screen.confirm(); // -> ModelServer + screen.confirm(); // -> GitProvider + skip_git_provider(&mut screen); + assert_eq!(screen.step, SetupStep::CollectionSource); // Curated options only (no per-provider import options). assert_eq!(screen.source_options, CollectionSourceOption::curated()); @@ -662,3 +715,397 @@ fn test_wizard_command_characters_are_typable_in_a_password() { assert_eq!(screen.password.value(), "ick j"); assert_eq!(screen.step, SetupStep::AdminPassword, "still on the step"); } + +// ─── Kanban step (defect A: the step used to be unreachable dead code) ────── + +/// Move the git step's cursor to its trailing "continue without" row and +/// confirm, so a walk does not stall on a live connect attempt. +fn skip_git_provider(screen: &mut SetupScreen) { + for _ in 0..SetupScreen::git_providers().len() { + screen.select_next(); + } + screen.confirm(); +} + +fn at_kanban_info() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::KanbanInfo; + screen +} + +#[test] +fn test_kanban_info_follows_welcome() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::Welcome; + + screen.confirm(); + assert_eq!(screen.step, SetupStep::KanbanInfo); +} + +#[test] +fn test_kanban_info_defaults_to_the_connect_row() { + let screen = at_kanban_info(); + assert_eq!(screen.kanban_choice_state.selected(), Some(0)); +} + +#[test] +fn test_kanban_connect_requests_the_dialog_without_advancing() { + let mut screen = at_kanban_info(); + + screen.confirm(); + + assert_eq!( + screen.step, + SetupStep::KanbanInfo, + "the wizard waits on the dialog rather than moving on" + ); + assert!(screen.take_kanban_dialog_request()); + assert!( + !screen.take_kanban_dialog_request(), + "the request is consumed once, so the dialog opens once" + ); +} + +#[test] +fn test_kanban_skip_advances_without_requesting_the_dialog() { + let mut screen = at_kanban_info(); + screen.select_next(); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::ModelServer); + assert!(!screen.take_kanban_dialog_request()); +} + +#[test] +fn test_kanban_choice_selection_wraps() { + let mut screen = at_kanban_info(); + + screen.select_next(); + assert_eq!(screen.kanban_choice_state.selected(), Some(1)); + screen.select_next(); + assert_eq!(screen.kanban_choice_state.selected(), Some(0)); + screen.select_prev(); + assert_eq!(screen.kanban_choice_state.selected(), Some(1)); +} + +#[test] +fn test_collection_source_goes_back_to_git_provider() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::CollectionSource; + + screen.go_back(); + assert_eq!(screen.step, SetupStep::GitProvider); +} + +/// Defect A regression: `KanbanProviderSetup` sat in the step enum, rendered a +/// screen and was never reachable, because the collection it keyed off was +/// never populated. Walking every wrapper branch proves each catalogued step +/// is actually selected by the state machine. +#[test] +fn test_wizard_walk_visits_every_catalog_step() { + // Steps only reachable from a branch the walk below does not take. + let conditional = [ + SetupStep::HostedCollectionFetch, // needs the hosted picker chosen + ]; + + let mut visited = std::collections::HashSet::new(); + for wrapper in [ + SessionWrapperType::Tmux, + SessionWrapperType::Vscode, + SessionWrapperType::Cmux, + SessionWrapperType::Zellij, + ] { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.tmux_status = TmuxDetectionStatus::Available { + version: "3.4".to_string(), + }; + + visited.insert(screen.step); + for _ in 0..SetupStep::ALL.len() * 2 { + if screen.step == SetupStep::Confirm { + break; + } + // Skip past the kanban hand-off; the dialog is driven by the app. + if screen.step == SetupStep::KanbanInfo { + screen.select_next(); + } + // Connecting shells out to provider CLIs; take the skip row. + if screen.step == SetupStep::GitProvider { + for _ in 0..SetupScreen::git_providers().len() { + screen.select_next(); + } + } + // `confirm` commits the highlighted wrapper, so steer the list. + if screen.step == SetupStep::SessionWrapperChoice { + let i = SessionWrapperOption::all() + .iter() + .position(|o| o.to_wrapper_type() == wrapper) + .expect("every wrapper is offered"); + screen.wrapper_state.select(Some(i)); + } + screen.confirm(); + screen.take_kanban_dialog_request(); + visited.insert(screen.step); + } + assert_eq!( + screen.step, + SetupStep::Confirm, + "{wrapper:?} branch never reached Confirm" + ); + } + + for step in SetupStep::ALL { + if conditional.contains(&step) { + continue; + } + assert!( + visited.contains(&step), + "{step:?} is in the catalog but no wizard path reaches it" + ); + } +} + +// ─── Model server step (D1a) ─────────────────────────────────────────────── + +fn at_model_server() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ModelServer; + screen +} + +fn select_kind(screen: &mut SetupScreen, kind: ModelServerKind) { + let i = SetupScreen::model_providers() + .iter() + .position(|(_, k)| *k == kind) + .expect("kind is offered by the wizard"); + screen.model_server_state.select(Some(i)); +} + +#[test] +fn test_model_server_step_follows_kanban_skip() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::KanbanInfo; + screen.select_next(); // "Skip for now" + + screen.confirm(); + assert_eq!(screen.step, SetupStep::ModelServer); +} + +#[test] +fn test_model_server_advances_to_git_provider() { + let mut screen = at_model_server(); + + screen.confirm(); + assert_eq!(screen.step, SetupStep::GitProvider); +} + +#[test] +fn test_model_server_go_back_returns_to_kanban_info() { + let mut screen = at_model_server(); + + screen.go_back(); + assert_eq!(screen.step, SetupStep::KanbanInfo); +} + +#[test] +fn test_model_server_declares_nothing_by_default() { + let screen = at_model_server(); + assert!(screen.declared_model_servers().is_empty()); +} + +#[test] +fn test_model_server_toggle_declares_the_highlighted_kind() { + let mut screen = at_model_server(); + select_kind(&mut screen, ModelServerKind::Ollama); + + screen.toggle_selection(); + + let declared = screen.declared_model_servers(); + assert_eq!(declared.len(), 1); + assert_eq!(declared[0].kind, ModelServerKind::Ollama.slug()); + assert_eq!( + declared[0].base_url.as_deref(), + ModelServerKind::Ollama.default_base_url() + ); +} + +#[test] +fn test_model_server_toggle_is_reversible() { + let mut screen = at_model_server(); + select_kind(&mut screen, ModelServerKind::Ollama); + + screen.toggle_selection(); + screen.toggle_selection(); + + assert!(screen.declared_model_servers().is_empty()); +} + +/// The key is referenced by env-var name; the secret never reaches config. +#[test] +fn test_declared_server_records_the_key_env_name_not_a_secret() { + let mut screen = at_model_server(); + select_kind(&mut screen, ModelServerKind::AnthropicApi); + + screen.toggle_selection(); + + let declared = screen.declared_model_servers(); + assert_eq!( + declared[0].api_key_env.as_deref(), + ModelServerKind::AnthropicApi.default_api_key_env() + ); +} + +/// Declaring writes a `[[model_servers]]` entry from kind defaults, so every +/// offered provider must actually have a default base URL to write. +#[test] +fn test_every_offered_model_provider_is_connectable_from_defaults() { + for (entry, kind) in SetupScreen::model_providers() { + assert!( + kind.connectable_from_defaults(), + "{} is offered but has no default base URL to declare", + entry.slug + ); + } +} + +#[test] +fn test_model_server_navigation_wraps_over_every_offered_provider() { + let mut screen = at_model_server(); + let len = SetupScreen::model_providers().len(); + assert_eq!(screen.model_server_state.selected(), Some(0)); + + for _ in 0..len { + screen.select_next(); + } + assert_eq!(screen.model_server_state.selected(), Some(0)); + + screen.select_prev(); + assert_eq!(screen.model_server_state.selected(), Some(len - 1)); +} + +// ─── Git provider step (D1b) ─────────────────────────────────────────────── + +fn at_git_provider() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::GitProvider; + screen +} + +#[test] +fn test_git_provider_step_follows_model_server() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ModelServer; + + screen.confirm(); + assert_eq!(screen.step, SetupStep::GitProvider); +} + +#[test] +fn test_git_provider_go_back_returns_to_model_server() { + let mut screen = at_git_provider(); + + screen.go_back(); + assert_eq!(screen.step, SetupStep::ModelServer); +} + +/// The offered set comes from the integration catalog, so promoting a provider +/// into onboarding is a `SupportStatus` bump rather than an edit here. +#[test] +fn test_git_provider_offers_the_catalog_onboardable_set() { + let slugs: Vec<&str> = SetupScreen::git_providers() + .iter() + .map(|e| e.slug) + .collect(); + assert_eq!(slugs, vec!["github", "gitlab", "gitea"]); +} + +/// Proto entries are unadvertised and undocumented, so onboarding must not +/// surface them - in either provider vertical. +#[test] +fn test_wizard_offers_no_proto_providers() { + for slug in ["bitbucket", "azure", "forgejo"] { + assert!( + !SetupScreen::git_providers().iter().any(|e| e.slug == slug), + "{slug} is Proto and must not be offered" + ); + } + for slug in ["openai-compat", "lmstudio"] { + assert!( + !SetupScreen::model_providers() + .iter() + .any(|(e, _)| e.slug == slug), + "{slug} is Proto and must not be offered" + ); + } +} + +/// Every offered provider links out to its docs page from the wizard copy. +#[test] +fn test_offered_providers_are_documented() { + for entry in SetupScreen::git_providers() { + assert!(entry.docs_path.is_some(), "{}", entry.slug); + } + for (entry, _) in SetupScreen::model_providers() { + assert!(entry.docs_path.is_some(), "{}", entry.slug); + } +} + +#[test] +fn test_git_provider_enter_requests_the_highlighted_provider() { + let mut screen = at_git_provider(); + + screen.confirm(); + + assert_eq!( + screen.step, + SetupStep::GitProvider, + "the wizard waits on the connect attempt rather than moving on" + ); + assert_eq!(screen.take_git_connect_request().as_deref(), Some("github")); + assert!( + screen.take_git_connect_request().is_none(), + "the request is consumed once" + ); +} + +#[test] +fn test_git_provider_last_row_continues_without_a_provider() { + let mut screen = at_git_provider(); + for _ in 0..SetupScreen::git_providers().len() { + screen.select_next(); + } + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::CollectionSource); + assert!(screen.take_git_connect_request().is_none()); +} + +#[test] +fn test_git_provider_navigation_includes_the_skip_row() { + let mut screen = at_git_provider(); + let rows = SetupScreen::git_providers().len() + 1; + + for _ in 0..rows { + screen.select_next(); + } + assert_eq!(screen.git_provider_state.selected(), Some(0)); + + screen.select_prev(); + assert_eq!(screen.git_provider_state.selected(), Some(rows - 1)); + assert!(screen.selected_git_provider().is_none()); +} + +#[test] +fn test_git_provider_status_is_recorded_per_provider() { + let mut screen = at_git_provider(); + + screen.set_git_provider_status("gitlab", "connected as octocat".to_string()); + + assert_eq!( + screen.git_provider_status.get("gitlab").map(String::as_str), + Some("connected as octocat") + ); + assert!(!screen.git_provider_status.contains_key("github")); +} diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index b43cc1bd..8230c30e 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -156,6 +156,21 @@ pub enum SetupResult { Initialize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoderSetupField { + TargetName, + Template, +} + +impl CoderSetupField { + pub fn toggled(self) -> Self { + match self { + Self::TargetName => Self::Template, + Self::Template => Self::TargetName, + } + } +} + /// Startup ticket options for project initialization #[derive(Debug, Clone)] pub struct StartupTicketOption { @@ -324,42 +339,7 @@ impl WorktreeOption { } } -/// Steps in the setup process -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SetupStep { - /// Welcome splash screen with discovered projects - Welcome, - /// Select template collection source - CollectionSource, - /// Browse and multi-select hosted collections (fetched from the manifest URL) - HostedCollectionFetch, - /// Configure TASK optional fields - TaskFieldConfig, - /// Select session wrapper (tmux or vscode) - SessionWrapperChoice, - /// Git worktree preference (use worktrees vs in-place branches) - WorktreePreference, - /// Optional admin password for the web dashboard. Skipped entirely when an admin account already exists. - AdminPassword, - /// Tmux onboarding/help (only shown if tmux selected) - TmuxOnboarding, - /// VS Code extension setup (only shown if vscode selected) - VSCodeSetup, - /// cmux setup (only shown if cmux selected) - CmuxSetup, - /// Zellij setup (only shown if zellij selected) - ZellijSetup, - /// Kanban integration info and provider detection - KanbanInfo, - /// Per-provider setup with project selection (index into `valid_providers`) - KanbanProviderSetup { provider_index: usize }, - /// Review and configure acceptance criteria - AcceptanceCriteria, - /// Optional startup tickets creation - StartupTickets, - /// Confirm initialization - Confirm, -} +pub use crate::startup::steps::SetupStep; /// Which of the two password fields has focus. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 940514f2..1f62ead8 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -668,7 +668,8 @@ impl StatusSnapshot { let working_dir = std::env::current_dir() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); - let config_path = Config::operator_config_path() + let config_path = config + .operator_config_path_for() .to_string_lossy() .into_owned(); let tickets_dir = config.paths.tickets.clone(); diff --git a/superpowers/plans/2026-09-12-getting-started-parity-phase-a.md b/superpowers/plans/2026-09-12-getting-started-parity-phase-a.md new file mode 100644 index 00000000..f340f116 --- /dev/null +++ b/superpowers/plans/2026-09-12-getting-started-parity-phase-a.md @@ -0,0 +1,549 @@ +# Getting-Started Parity — Phase A: audit + TUI alignment + +## Context + +Operator can be driven from two surfaces — the ratatui TUI (`operator`) and the +web SPA (`operator api`) — and both are supposed to land a new user at the same +place: a working `config.toml` with kanban, model and git providers connected. + +`docs/getting-started/index.md` is a five-line stub naming an order +(agent → kanban → git) that **neither surface implements**. The audit found the +mismatch is not editorial: the TUI wizard has an unreachable step, silently +discards three of the user's choices, and has two divergent workspace writers; +the web UI has no guided flow at all and locks its own configuration pages +behind a prerequisite only the TUI can satisfy. + +**Neither surface currently satisfies "by the end of this flow you have a usable +config.toml", and the web flow is not independently completable.** + +Sequencing is the user's: **audit and report first, then align UI and TUI, then +document as it exists.** + +### Scope of this plan + +The full arc runs ten stages. **This plan authorizes stages 0–5 only** — fix the +defects and land both new TUI steps (decision D1). The web wizard (D2), the REST +surface it needs, re-runnability and the documentation rewrite are deferred to a +follow-up plan, to be written once the TUI half is real and has been used. + +Docs are deliberately *not* in this phase: the user's rule is "document as it +exists", and after stage 5 only half the alignment exists. Writing the +getting-started flows now would document a state that stage 8 changes. + +Decisions already made by the user, carried forward: + +- **D1 (this plan)** — add a Model Server step and a Git Provider step to the TUI wizard. +- **D2 (deferred)** — a web wizard mirroring the TUI, writing the same `config.toml`. +- **D3 (deferred)** — either flow, run alone, ends with a usable `config.toml`. +- **D4 (deferred)** — docs last; per-surface flows link out to the existing + kanban/model/git pages rather than re-explaining them inline. + +> One open recommendation, easy to reverse: stage 2 promotes the step catalog to a +> single Rust source of truth. The user did not rule on this; it is on the critical +> path to D1 regardless, and the rationale is in Phase 2 below. + +> Per `CLAUDE.md`, once approved this plan should also be committed to +> `superpowers/plans/`. + +--- + +## Phase 1 — Audit report (complete) + +### Blocking defects + +| | Defect | Evidence | +|---|---|---| +| **A** | Kanban provider step is unreachable dead code | `valid_kanban_providers` (`src/ui/setup/mod.rs:70`) initialized empty at `:165`, **never pushed to anywhere**; `confirm()` at `:566` always takes the `is_empty()` branch | +| **B** | Three screens collect choices that are never persisted | `src/app/tickets.rs` has **zero** matches for `wrapper`/`worktree`/`acceptance`; `sessions.wrapper` and `git.use_worktrees` keep their defaults regardless of what the user picked | +| **C** | Two divergent workspace writers | `App::initialize_tickets` (`src/app/tickets.rs:19-207`) vs `setup::initialize_workspace` (`src/setup.rs:56-150`) — different dirs, different files, different config keys | +| **D** | Wizard can't be re-run | `setup_screen = Some(..)` only in `App::new`; `operator setup --interactive` is a stub (`src/main.rs:1146-1151`); trigger is the *queue dir*, not config (`src/app/mod.rs:157`) | +| **E** | Generated wizard doc is already wrong | `SETUP_STEPS` (`src/startup/mod.rs`) lists the pre-reorder order and mentions a git step that never existed; only guard is `assert_eq!(len(), 16)` at `:244` — it passed while the data was wrong | +| **F** | Web UI locks its own pages behind a TUI-only prerequisite | `refresh_tool_detection` runs only at `src/app/mod.rs:121`; `operator api` never detects ⇒ `llm` Yellow ⇒ **Model Providers + Delegators disabled in the sidebar** on real working routes | +| **G** | Git unreachable from the browser | Zero `/api/v1/git*` routes; `ConfigureGitProvider` has no `web_url()`, so `#/git` rows render as inert text | +| **H** | Kanban plumbing exists but has no SPA client | `PUT /api/v1/kanban/config` + validate/projects/statuses/session-env all persist correctly; only `vscode-extension/` calls them | + +Defects **F, G, H** are web-side and are addressed in the deferred phase. + +### Two further defects found during design (both verified) + +- **I** — `SetupOptions::kanban_provider` and `::llm_tool` (`src/setup.rs:30,32`) are + validated and printed by `cmd_setup` but **never read** by `initialize_workspace`. + `operator setup --kanban-provider jira --llm-tool claude` is a no-op. +- **J** — `Config::operator_config_path()` (`src/config.rs:691`) is a hardcoded + **relative** `.tickets/operator/config.toml`, and `Config::save()` uses it. The + tempdir tests at `src/setup.rs:351-499` therefore write into the **repo root** — + confirmed present on disk (2884 bytes, gitignored), which is why it went + unnoticed. This blocks writing parallel-safe tests for any new init path. + +### Corrections to assumptions + +- **Forgejo is not covered by git onboarding.** `onboarding_spec_for_slug` + (`src/api/cli_detection.rs:138`) returns `Some` only when `pat_url` is non-empty; + verified only `github`, `gitlab`, `gitea` have one. +- **Auth needs no work.** Browser session principals carry `Scope::ALL` + (`src/auth/store.rs:462`). + +### Lesser findings (for the deferred phase) + +- `POST /api/v1/collections/{name}/activate` is in-memory only — lost on restart. +- `PATCH /api/v1/configuration`, `PUT /api/v1/llm-tools/default` and the + model-server `PUT`/`DELETE` persist but no SPA page calls them. +- `#/settings/security` and `#/status` have no nav entry. +- Wizard footers advertise unbound `[S]`/`[R]`/`[T]`/`[n/p]`; `i` and `c` are live + on every screen (force-initialize / quit). +- Secrets are consistently stored **by reference** (env var *name*) across kanban, + git and model servers. Correct — preserve it. It is also what users trip on, + because a token exported after the process started is invisible to it. + +--- + +## Phase 2 — TUI alignment (stages 0–5) + +### Stage 0 — Defect J: make the config path derive from state + +Add `Config::operator_config_path_for(&self) -> PathBuf` deriving from +`paths.state`; keep the associated `operator_config_path()` for `Config::load()`'s +bootstrap (it must resolve a path before a `Config` exists). Point `Config::save()` +(`src/config.rs:769`) at the `&self` variant. + +For a normally-invoked process (cwd == workspace root) the resolved path is +identical, so behaviour is unchanged in practice — but it is a real semantic change +affecting every `config.save()` call site, so it gets **its own commit and its own +test**, ahead of everything else. It also stops `cargo test` scribbling in the repo +root, which is what makes stages 1–5 testable. + +### Stage 1 — Defects C, B, I: one workspace writer + +`initialize_workspace` is already the better implementation: it honours `force` via +`write_file_if_allowed`, writes `operator/templates/` plus the three interpolation +docs, and persists `git.use_worktrees`. `App::initialize_tickets` does none of that +and unconditionally `fs::write`s. + +Extend `SetupOptions` with the dropped choices, and make it **not save** so the +caller owns persistence: + +```rust +pub struct SetupOptions { + // existing: preset, force, task_fields, working_dir, + // kanban_provider, llm_tool, use_worktrees + pub wrapper: Option, + pub acceptance_criteria: Option, + pub custom_collection: Vec, + pub active_collection: Option, + pub hosted_collections: Vec, +} + +/// Creates directories, writes templates, mutates `config`. Does NOT persist — +/// the caller owns the save. +pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Result; +``` + +Dropping the internal save is what later lets a REST handler wrap the whole thing in +`ApiState::mutate_config` (which saves atomically **and** `replace_config`s, so a +running server isn't left on a stale snapshot — `src/rest/state.rs:156-167`). Doing +it now means the deferred phase needs no second refactor here. + +`App::initialize_tickets` shrinks to: build `SetupOptions` from `SetupScreen` → call +`initialize_workspace` → TUI-only tail (admin account, `generate_tmux_config`, +registry reload, startup tickets) → one `config.save()`. `cmd_setup` adds its own +`config.save()?`. + +**B and I fall out of this**: once `SetupOptions` carries them, `sessions.wrapper`, +`git.use_worktrees`, the acceptance text, `kanban_provider` and `llm_tool` all land. + +### Stage 2 — Defect E: promote the step catalog + +Defect E's guard already existed and still passed while the data was wrong — the +count never changed, only the order did. Sharing the **step catalog** (identity, +order, copy) replaces that with a compile-time guard. This is not the renderer +abstraction the user rejected; renderers stay hand-written. + +1. New `src/startup/steps.rs`; move `SetupStep` there from + `src/ui/setup/types.rs:329`. **Mandatory, not stylistic**: `src/rest` compiles in + the lib and must not reference bin-only `src/ui`, so the deferred REST surface + cannot see the enum where it lives today. Doing the move now avoids reopening + these files later. +2. Drop the `KanbanProviderSetup { provider_index }` payload — move the index onto + `SetupScreen` as a plain cursor field like every other step's. A data-free enum + makes `const ALL` and the derives trivial. +3. Replace the parallel `SETUP_STEPS` static with + `impl SetupStep { fn info(&self) -> SetupStepInfo }` — one exhaustive `match`, so + a new variant is a **compile error** until copy exists. `SETUP_STEPS` survives as + `ORDER.iter().map(SetupStep::info)`, leaving `src/docs_gen/startup.rs:39` untouched. +4. Add `ALL`, `ORDER`, `slug()`. One test: `ORDER` is a permutation of `ALL`. +5. `#[derive(TS, Serialize, Deserialize, ToSchema)] #[ts(export)]` → + `bindings/SetupStep.ts` via `make bindings`. Unused by the SPA until the deferred + phase, but generated from the start so the binding never has to be backfilled. + +**Defect E closes here**: regenerating `docs/startup/index.md` emits the real order. + +### Stage 3 — Defect A: bring the kanban step to life + +Deleting `KanbanProviderSetup` would leave the TUI unable to configure kanban while +a future web wizard trivially can — *creating* the asymmetry this work exists to +remove. So wire it up instead: + +- Populate `valid_kanban_providers` in the `Welcome` arm from + `p.has_required_env_vars()` — the same predicate `CollectionSourceOption::with_providers` + already uses (`src/ui/setup/types.rs:79`). +- Make the step persist via + `services::kanban_onboarding::{apply_config_request, set_session_env}` — **the same + two functions `PUT /api/v1/kanban/config` and `POST /api/v1/kanban/session-env` + call** (`src/rest/routes/kanban_onboarding.rs:104,136`). No new provider logic; + both surfaces converge on one service. +- Bind the advertised `[S]` skip key, or remove it from the footer. + +Also extract `startup::workspace_initialized(&config)` from `src/app/mod.rs:157` as +the single "is this set up?" predicate — cheap now, and the deferred phase depends +on both surfaces agreeing on it. + +~80 lines. + +### Target step order + +``` +Welcome → KanbanInfo → KanbanProviderSetup → ModelServer → GitProvider + → CollectionSource (→ HostedCollectionFetch) → TaskFieldConfig + → SessionWrapperChoice → WorktreePreference → AdminPassword + → {Tmux|VSCode|Cmux|Zellij} → AcceptanceCriteria → StartupTickets → Confirm +``` + +Kanban, model servers and git are the three external-service connections and are +siblings in the sidebar prereq chain, so they group consecutively. Kanban must stay +before `CollectionSource` (that step offers "import from a configured provider" — +`src/ui/setup/mod.rs:553`). The session wrapper is a local concern and reads better +next to its wrapper-specific follow-up. Changing this later is one edit to `ORDER`. + +### Stage 4 — TUI Model Server step (D1a) + +New `src/ui/setup/steps/model_server.rs`. Lists `ModelServerKind::ALL` grouped by +`provider_class()`, with a live status column from +`probe_models(&server, &EgressPolicy::from_config(&config))` against a transient +server built from kind defaults — byte-for-byte what +`routes::model_servers::kind_models` does (`src/rest/routes/model_servers.rs:353-390`). + +`probe_models` is async and `confirm()` is sync, so follow the existing precedent: +`src/app/keyboard.rs:57-72` awaits `load_hosted_collections` right after `confirm()` +returns `Continue` for the hosted step. Add `SetupScreen::probe_model_servers` and an +identical guard clause. + +Three sub-states, all preserving secret-by-reference: + +- env var present → Enter appends a `ModelServer` to `config.model_servers`; +- absent → an editable env-var-**name** field prefilled with `default_api_key_env()`, + plus a copy-paste shell export block (precedent: + `services::kanban_onboarding::build_shell_export_block_*`), and optionally a masked + paste that only `set_var`s for the running process; +- `openai-compat` / `lmstudio` (`connectable_from_defaults() == false`) require a + `base_url` before the row can be selected. + +No secret reaches disk. Fully skippable. + +### Stage 5 — TUI Git Provider step (D1b) — completes D1 + +New `src/ui/setup/steps/git.rs`, covering **github, gitlab, gitea** out of the box. +State per row comes from `git_onboarding::resolve_onboarding_with_config` +(`src/app/git_onboarding.rs:196`), reused verbatim: `InstallCli` / `AutoConfigured` +(Enter connects with zero typing) / `CollectToken` (opens the PAT page, shows the +existing `GitTokenDialog`). + +**Forgejo needs one extra line** — a `pat_url` on its `CliSpec` +(`src/api/cli_detection.rs:95-105`) plus `"forgejo"` arms in +`complete_git_onboarding` and `validate_token_with_config`. Forgejo speaks Gitea's +API and `tea` drives both, so the gitea path works verbatim with a different base +URL. Recommend including it (~15 lines); it closes the "config structs but no TUI +row" gap. Bitbucket and Azure DevOps have no PAT flow — leave them out. + +Two refactors this step requires: + +1. **`complete_git_onboarding` saves mid-flow** (`git_onboarding.rs:159`). Inside the + wizard that writes `config.toml` *before* Confirm, so a later Cancel leaves a + partial config. Split into `apply_git_provider` (mutate + `set_var`, no save) and + keep `complete_git_onboarding` as `apply + save` for the existing dashboard + callers (`src/app/status_actions.rs:166`, `src/app/keyboard.rs:333`). Same + one-writer discipline as stage 1. +2. **Key routing — verified hazard.** `src/app/keyboard.rs:21` has an unconditional + early `return Ok(())` for the setup screen at `:102`, and the git-token dialog + branch sits at `:326` — **below it**. The dialog would never receive keys while + the wizard is open, and typing a PAT would quit the app on the first `c`. Hoist + the dialog branch above the setup branch, or add a delegation guard at the top of + it. Precedent: the `AdminPassword` text-routing guard at `:26-39` exists for + exactly this reason. + +Add `git_onboarding::shell_export_block(provider, token_env)` and render it — kanban +has this block, git does not, and without it the token dies with the process. Make +`token_env` editable for users on `GH_TOKEN` or a per-host var. + +> `validate_*_token` uses `reqwest::blocking` inside an async fn. Fine in a TUI (it +> briefly stalls the event loop) — **but not fine in an axum handler**, which is a +> constraint the deferred REST work must respect via `spawn_blocking`. + +--- + +## Staging + +`make check` must pass at every boundary. All five stages are independently shippable. + +| # | Stage | Depends on | Regenerate | +|---|---|---|---| +| 0 | Defect J — `operator_config_path_for(&self)` | — | — | +| 1 | One writer — C + B + I | 0 | `docs/configuration/index.md` if doc-comments change | +| 2 | Step catalog — E | — | `make bindings`; `cargo run -- docs` → **defect E closed** | +| 3 | Kanban step alive — A; `workspace_initialized` | 2 | — | +| 4 | TUI Model Server step (D1a) | 2 | `cargo run -- docs` | +| 5 | TUI Git Provider step (D1b) | 2 | `cargo run -- docs`; watch `tests/vertical_parity.rs` for Forgejo | + +Critical path: 0 → 1 → 2 → 5. Stages 3 and 4 are off it and can land in parallel. + +--- + +## Testing + +Per repo TDD convention — write the failing test first. + +**In-file `#[cfg(test)] mod tests`:** + +- `src/config.rs` — `test_save_writes_under_paths_state_not_cwd` (stage 0). +- `src/setup.rs` (tempdir tests at `:351-499` are the model) — wrapper/worktree + persistence (B), acceptance criteria, kanban/llm options (I), + `test_initialize_workspace_does_not_save` (the stage-1 contract change), + `test_initialize_workspace_is_idempotent_without_force`. **Only safe after stage 0.** +- `src/startup/steps.rs` — replaces the hardcoded `len() == 16`: + `test_order_is_a_permutation_of_all`, `test_every_step_info_field_is_non_empty`, + `test_slugs_are_unique`, `test_slugs_match_frozen_snapshot` (slugs will key docs + URLs and, later, the SPA component map). +- `src/ui/setup/tests.rs` (664 lines; + `test_admin_password_step_follows_worktree_preference:465` is the model) — step-order + tests for the two new steps, `test_model_server_selection_appends_config_entry`, + `test_model_server_non_connectable_kind_requires_base_url`, + `test_git_selection_sets_provider_without_saving`, and the **defect A regression + test**: `test_wizard_walk_visits_every_catalog_step`, driving `confirm()` from + Welcome to Confirm across all four wrapper branches and asserting the union equals + `SetupStep::ALL` minus a documented allowlist. This is the test that would have + caught A on day one. +- `src/app/git_onboarding.rs` — Forgejo arm, `apply_git_provider` does not save. +- `src/app/keyboard.rs` — a dialog-over-wizard routing test, so the PAT field can + never again be eaten by the wizard's `c`/`i` bindings. + +**`tests/`:** + +- New `tests/setup_parity.rs`, using the `include_str!` source-scanning pattern from + `tests/surface_parity.rs`: every variant has non-empty info and appears in `ORDER`; + `bindings/SetupStep.ts` union equals the Rust slugs; **`docs/startup/index.md` + lists headings in `ORDER`** — the direct defect-E regression test. +- `tests/vertical_parity.rs` is an existing *constraint*, not new work: if the git + step advertises Forgejo, check its `SupportStatus` first — `Alpha`+ entries owe a + resolving docs page and a README badge. + +## End-to-end verification + +1. `make check` at every stage boundary. +2. **Clean room**: `rm -rf /tmp/opr-tui && mkdir -p /tmp/opr-tui && cd /tmp/opr-tui && operator`. + Walk the wizard end to end, choosing a non-default on every screen. +3. Assert on `/tmp/opr-tui/.tickets/operator/config.toml` that `sessions.wrapper`, + `git.use_worktrees`, `git.provider`, `git.

.enabled`, `[kanban.*]` and + `[[model_servers]]` all reflect what was chosen — the direct B/A/D1 check. +4. Confirm no config.toml appeared in the repo root after `cargo test` (defect J). +5. Re-run the wizard over the existing workspace (delete `.tickets/queue` for now; + proper re-entry is deferred) and confirm templates are **not** clobbered. +6. `cargo run -- docs && make bindings`, then `git diff --exit-code` to prove + generated artifacts are committed fresh — and eyeball that + `docs/startup/index.md` now lists the real order. + +--- + +## Deferred to the follow-up plan (stages 6–10) + +Recorded so the design is not lost. Do not build these now. + +- **6 — PREREQUISITE FOUND DURING STAGE 1.** `src/setup.rs` is **bin-only** + (`mod setup;` in `src/main.rs:39`; it is absent from `src/lib.rs`). `src/rest/` + compiles in the lib, so `POST /setup/initialize` **cannot** call + `setup::initialize_workspace` where it lives today. Stage 6c must first move + `src/setup.rs` into the lib (`pub mod setup;`), which also pulls in its deps — + `projects` and `startup` are already lib-private modules, so they only need + visibility widening, not relocation. Same class of constraint as the + `SetupStep` move in stage 2. Budget this before estimating 6c. +- **6 — Server enablers.** `startup::refresh_and_persist_detection` called from + `cmd_api` (fixes defect F: `llm` goes Green for web users, unlocking Model + Providers + Delegators); persist `collections/{name}/activate` through + `mutate_config`; `GET /setup/{status,steps}` + `POST /setup/initialize`; + `/api/v1/git/*` mirroring the kanban quartet (fixes defect G) — **every shelling + or `reqwest::blocking` call wrapped in `spawn_blocking`**; `ROUTE_RULES` entries + (`tests/route_scope_parity.rs` fails closed on any route missing from the table). +- **7 — `ui/src/api-client.ts`** kanban/git/setup methods; port from + `vscode-extension/src/kanban-onboarding.ts`, the working reference (fixes defect H). +- **8 — Web wizard (D2/D3).** `/#/onboarding` mounted as a **sibling of `setup`, not + inside `Layout`** (`ui/src/main.tsx:35-36`) — `Layout.tsx:22` disables nav items + whose `section.met` is false, so a wizard inside it would be surrounded by the + gating it exists to resolve. Component map `satisfies Record` so + `tsc` errors on a missing step. Plain React + CSS modules — + `tests/ui_packaging.rs` enforces a nine-entry dep allowlist and bans CSS-in-JS. + Render Startup Tickets read-only in v1 rather than shipping half-working ticket + creation and calling it parity. +- **9 — Re-runnability (defect D).** `SetupScreen::from_config` prefilling from live + config (`WorktreeOption::from_use_worktrees` already exists marked + `#[allow(dead_code)] // Useful for future config-to-UI state conversion`, + `src/ui/setup/types.rs:316` — this is that future); a TUI key; a real + `operator setup --interactive`; a web re-run entry. Re-run must be additive and + idempotent: `force = false`, kanban upserts, model servers PUT-or-POST (`POST` + 409s on duplicates, `src/rest/routes/model_servers.rs:143`), startup tickets + default to none. +- **10 — Docs (D4).** `docs/getting-started/index.md` stays slim: an **"expected + setup"** section naming the connections a working install needs, linking out to + `/getting-started/{kanban,model-servers,git,sessions}/` with **no inline provider + instructions**, then a chooser into two new **level-2** pages (one per surface), + both linking to `/startup/` rather than restating generated steps. + Constraints: nav entry in `docs/_data/navigation.yml` with a byte-matching title; + front matter exactly `title`/`description`/`layout: doc`; body starts at `##`; + absolute trailing-slash links; no callout/tab includes exist. + Verify with `cargo test --test docs_structure`. + +## Open items to confirm during implementation + +1. Forgejo's current `SupportStatus` in `src/integrations/catalog.rs` — determines + whether stage 5 also owes a docs page and README badge to satisfy + `tests/vertical_parity.rs`. +2. Stage 0's blast radius across all `config.save()` call sites. The resolved path + should be identical for normally-invoked processes, but this deserves its own + commit and review rather than being bundled. +3. Whether dropping the `KanbanProviderSetup` payload (stage 2) disturbs any + `go_back` logic in `src/ui/setup/mod.rs:720-825` beyond the mechanical change. + +--- + +## Implementation log (Phase A) + +Deviations from the plan as written, with reasons. + +- **Stage 3 reshaped.** The planned in-wizard per-provider step was gated on + `has_required_env_vars()`, i.e. it renders nothing unless the user has already + exported an API key — useless for the first-run audience it exists for, which + is very likely why `valid_kanban_providers` was never populated. On the user's + call the step now **delegates to the existing `K` onboarding dialog** (cold + onboarding: provider → credentials → live validation → project → config + + shell export). `SetupStep::KanbanProviderSetup` was removed from the catalog + (15 steps now), and `KanbanInfo` gained a connect/skip choice. +- **Keyboard routing fixed early.** The modal git-token and kanban dialogs were + below the setup screen's unconditional `return Ok(())`, so they could never + receive keys over the wizard. Hoisted above it in stage 3 rather than stage 5, + since stage 3 needed it first; stage 5 now inherits the fix. +- **Latent clobber fixed.** `services::kanban_onboarding::write_config` persists + straight to disk but `self.config` was never refreshed, so any later + `config.save()` wrote the kanban section away again. Pre-existing on the + dashboard path; certain once the wizard delegates (Confirm always saves). + `reload_config_after_kanban_write` now picks the result back up. +- **`SetupStep::ORDER` dropped.** `ALL` is declared in wizard order, so a + separate `ORDER` would have been the same array and its permutation test + vacuous. One ordered const instead. +- **Dead code removed along the way:** `App::generate_tmux_config`, + `Config::discover_projects_full`, `SetupScreen::{kanban_projects, + kanban_issue_types, kanban_member_count, kanban_skipped, + valid_kanban_providers}`, and `render_kanban_provider_setup_step`. +- **Defect J had a wider blast radius than expected** — three call sites + *reported* or *opened* the config path (`status_panel.rs`, + `services/kanban_onboarding.rs`, `rest/routes/kanban_onboarding.rs`) and would + have named a file `save()` no longer writes. All repointed. + +### Stages 4-5 + +- **Forgejo excluded, narrower than the plan recommended.** The plan proposed + adding a `pat_url` to bring Forgejo into the git step. Checking + `src/integrations/catalog.rs` settled the open item: Forgejo is **Proto**, + undocumented, with no `docs_path` - as are Bitbucket and Azure DevOps. Putting + a Proto integration in the primary onboarding flow would promote it ahead of + its support status and owe it docs under `tests/vertical_parity.rs`. The step + offers `GIT_PROVIDER_SLUGS = ["github", "gitlab", "gitea"]`, which is exactly + the Alpha-or-better set and exactly the set with a PAT flow. +- **Non-connectable model kinds are shown but not declarable.** Rather than an + inline `base_url` text field, `openai-compat` and `lmstudio` render with a + "needs base URL" hint and refuse toggling - the same restriction the web UI + applies (no Connect button), which keeps the two surfaces honest for D3. +- **The git token dialog defers its save inside the wizard.** `apply_git_provider` + when `setup_screen.is_some()`, `complete_git_onboarding` otherwise, so Confirm + stays the single persistence point and cancelling strands nothing. +- **Test churn is inherent to inserting steps.** Each new step invalidated the + ordering assertions of the step before it; those were updated, not deleted. + The wizard-walk test takes each new step's skip row so it does not shell out + to provider CLIs. + +### Verification + +`make check` passes (fmt + clippy `--locked --all-targets --all-features -D +warnings` + full test suite). Both parity tests were confirmed non-vacuous by +deliberately breaking them and watching them fail. + +One unrelated flake observed: `auth::schema::tests::test_concurrent_migration_of_one_database_is_safe` +failed once under full-suite load and passed 3/3 in isolation and on every +re-run. It is an 8-thread SQLite contention test in its own tempdir, touching +nothing in this change. + +### Pre-existing generated-artifact drift (not from this work) + +`cargo run -- docs` + `make bindings` do **not** reproduce the committed +artifacts on this branch, in both directions: + +- `docs/schemas/{config,metadata,state}.md` and `docs/schemas/openapi.json` + regenerate with em-dashes where the committed copies have hyphens; +- `src/schemas/issuetype_schema.json` regenerates with hyphens where the + committed copy has em-dashes. + +None of these files' sources were touched here. This matters for the deferred +stage 10: a docs task that runs the generators will surface this drift, and any +CI check doing `cargo run -- docs && git diff --exit-code` is not currently +clean. Worth a separate cleanup commit. + +### Catalog-derived provider lists (added after stage 5, before stage 8) + +The stage 4/5 steps keyed off per-vertical enums, and the git step's list was a +hand-written `GIT_PROVIDER_SLUGS` const - the catalog was consulted to *decide* +it, then the answer was hardcoded. Same drift class as defect E. + +Both steps now derive from `integrations::catalog::onboardable(vertical)` +(`Alpha`+ and documented). `GIT_PROVIDER_SLUGS` is deleted; rows, labels and +docs links come from `CatalogEntry`, and the slug→CLI mapping comes from +`cli_detection::onboarding_spec_for_slug`, which already owned it. + +Why the catalog rather than the enums: `integrations` is `pub mod` in `lib.rs`, +unlike `startup` and `setup`, so the stage-8 web wizard can read the same list +over the existing `/api/v1/integrations` without a module move. One list, three +surfaces; promoting a provider into onboarding is a `SupportStatus` bump. + +Consequences: +- Model Server drops from 7 rows to 5 (`openai-compat`, `lmstudio` are Proto). +- `toggle_model_server`'s `connectable_from_defaults()` guard is now unreachable + via the offered list. Kept (still correct if a future Alpha provider ships + without a default base URL), but the test that exercised it was replaced with + one asserting the real invariant rather than left to pass vacuously. +- Two hand-rules had coincidentally agreed: git used support status, model used + base-URL availability. Same answer today, divergent the moment a Beta provider + ships without a default base URL. + +Three guards, the third verified by reintroducing a hardcoded `"github"`: +`onboardable` never yields Proto/undocumented; the step files contain no +provider slug literals; `setup/mod.rs` must reference `onboardable(Vertical::*)`. + +### Open: pre-existing auth test flake (NOT from this work) + +`auth::schema::tests::test_concurrent_migration_of_one_database_is_safe` fails +~8% of the time **in isolation** (measured 2/25), with +`enabling WAL: database is locked`. It intermittently fails `make check`. + +`src/auth/schema.rs` is not in this change set. A hypothesis that +`apply_pragmas` sets `busy_timeout` *after* the `journal_mode = WAL` switch that +needs it was tested and **disproven** - reordering measured 2/25 before and +2/25 after, so the change was reverted. There is currently no working +explanation; SQLite's busy handler appears not to engage for this particular +conflict, but that was not established. Needs its own investigation. + +Treat a red `make check` on this branch as "check which test failed" until this +is resolved. + +### Final verification (stages 0-5 + catalog rewiring) + +lib 2206 passed / 0 failed; bin 2806 passed / 0 failed; 29 integration targets +all ok; `cargo fmt --check` clean; `clippy --locked --all-targets +--all-features -D warnings` clean. + +Not done: nobody has walked the two new screens in a terminal. They are covered +by unit tests and the reachability walk, which is not the same thing. diff --git a/superpowers/specs/2026-09-13-web-setup-wizard-design.md b/superpowers/specs/2026-09-13-web-setup-wizard-design.md new file mode 100644 index 00000000..d611e893 --- /dev/null +++ b/superpowers/specs/2026-09-13-web-setup-wizard-design.md @@ -0,0 +1,319 @@ +# Web Setup Wizard — design spec (D2 / D3) + +**Status:** ready to plan. Phase A (TUI alignment) is landed and verified. +**Audience:** the agent that will plan and implement this. Read this whole file +before writing a plan; it encodes decisions already made and traps already hit. + +**Related:** `superpowers/plans/2026-09-12-getting-started-parity-phase-a.md` — +the completed phase, including its audit and implementation log. This spec +supersedes that plan's "Deferred (stages 6–10)" section, which was written +before Phase A landed and is stale in two places called out below. + +--- + +## 1. Goal + +- **D2** — a multi-step first-run wizard in the web SPA that mirrors the TUI + wizard and writes the same `config.toml`. +- **D3** — **either** flow, run alone, ends with a `config.toml` the other + surface can use. This is the acceptance bar, not a nice-to-have. + +Out of scope here: documentation (D4). Docs come after this lands, per the +user's sequencing — "document as it exists". Do not write getting-started docs +as part of this work. + +## 2. Why this exists + +An audit found the two surfaces were not merely inconsistent: **the web flow +was not independently completable.** Its only guided screen is the admin-password +bootstrap; kanban and git are unreachable from the browser entirely, and the +pages that do work were gated behind a prerequisite only the TUI could satisfy. + +Phase A fixed the TUI half. The wizard now has the three external-service +connections it was missing, all derived from one catalog. This phase brings the +browser to parity. + +## 3. What Phase A already built (do not re-derive) + +### 3.1 The step catalog is the source of truth + +`src/startup/steps.rs` — `SetupStep`, 17 variants, declared **in wizard order**: + +``` +welcome, kanban-info, model-server, git-provider, collection-source, +hosted-collections, task-field-config, session-wrapper-choice, +worktree-preference, admin-password, tmux-onboarding, vscode-setup, +cmux-setup, zellij-setup, acceptance-criteria, startup-tickets, confirm +``` + +- `SetupStep::ALL` is a fixed-size array in walk order. `info()` and `slug()` + are **exhaustive matches**, so adding a variant is a compile error until copy + exists and `ALL` is resized. +- `#[derive(TS)] #[ts(export)]` generates `bindings/SetupStep.ts` (a string + union of the slugs above) via `make bindings`. +- `setup_steps()` feeds `docs/startup/index.md` through `src/docs_gen/startup.rs`. + +The slugs are a **frozen public identifier** — they key docs URLs and will key +your component map. `test_slugs_match_frozen_snapshot` guards renames. + +### 3.2 One workspace writer + +`setup::initialize_workspace(&mut Config, &SetupOptions) -> Result` +creates directories, writes templates, and **mutates config without saving**. +The caller owns persistence. This was done specifically so a REST handler can +wrap it in `ApiState::mutate_config`. `SetupOptions` today: + +```rust +preset, force, task_fields, working_dir, kanban_provider, llm_tool, +use_worktrees, wrapper, acceptance_criteria, custom_collection, +active_collection, hosted_collections, model_servers +``` + +`App::initialize_tickets` builds one of these from the wizard screen and calls +it. Your REST endpoint must do the same, from a DTO. + +### 3.3 Provider lists come from the integration catalog + +`integrations::catalog::onboardable(vertical) -> Vec` returns +`Alpha`+ **and** documented entries. Both TUI provider steps derive rows, +labels and docs links from it. + +| Vertical | Offered today | +|---|---| +| Git | github, gitlab, gitea | +| Model | anthropic-api, openai-api, google-api, ollama, openrouter | + +Proto entries (bitbucket, azure, forgejo, openai-compat, lmstudio) are +deliberately **not** offered — they are unadvertised and have no docs page to +link to. Promoting one into onboarding is a `SupportStatus` bump in +`src/integrations/catalog.rs`, nothing else. + +**Your web wizard must use this same list.** Three guards in +`tests/setup_parity.rs` already enforce it for the TUI; extend them, do not +work around them: + +- `test_wizard_steps_do_not_hardcode_provider_slugs` +- `test_wizard_derives_provider_lists_from_the_catalog` +- `test_binding_union_matches_catalog_slugs` + +### 3.4 Secrets are stored by reference, everywhere + +Kanban, git and model servers all store the **name of an env var**, never the +secret. The secret is `set_var`'d into the running process and the user is shown +a shell-export line to make it permanent. Preserve this. It is also the single +thing users trip over, so the web wizard should surface the export line at least +as prominently as the TUI does. + +## 4. Load-bearing constraints + +Each of these cost real time to discover. Respect them or re-pay that cost. + +### 4.1 `setup` and `startup` are bin-only — this blocks the obvious approach + +``` +src/lib.rs : mod startup; (private) pub mod integrations; +src/main.rs: mod setup; mod startup; mod integrations; +``` + +`src/rest/` compiles **in the lib**, so it cannot see `crate::setup` or +`crate::startup` as they are declared today. A `POST /setup/initialize` that +calls `initialize_workspace` **will not compile** without first moving +`src/setup.rs` into the lib (`pub mod setup;`) and widening `startup`. + +The Phase A plan's deferred section assumed this was free. It is not. Budget it +as the first task of the REST work, and check what else `setup.rs` pulls in +(`projects`, `startup::templates`, `collections::fetch` are all lib-private +today — visibility widening, not relocation). + +`integrations` is already `pub mod`, which is why §3.3 routes cleanly to the +browser with no move at all. + +### 4.2 Mount the wizard OUTSIDE `Layout` + +`ui/src/Layout.tsx:22` disables any nav item whose `section.met` is false. A +wizard rendered inside `Layout` would be surrounded by the very gating it exists +to resolve. `ui/src/main.tsx` already has the precedent and states the reason: + +```tsx +{/* Unauthenticated screens render outside Layout: the shell's own + API calls would 401 for a visitor who cannot yet authenticate. */} + +``` + +Add `onboarding` as a sibling of `setup`, not inside the `}>` block. + +### 4.3 LLM detection runs only in the TUI + +`crate::llm::refresh_tool_detection` is called exactly once, at +`src/app/mod.rs:121`. `operator api` never detects tools, so `llm_tools.detected` +stays empty, the `llm` section stays Yellow, and **Model Providers + Delegators +are disabled in the web sidebar** — on real, working routes. + +Fix this by hoisting detection into a shared entry point called from `cmd_api` +as well. It is a **user-visible fix worth shipping on its own**, independent of +the wizard, and the wizard's Welcome step needs it to show anything useful. + +Cost is bounded: `refresh_cached_tool` reuses cached path/version, and the +config write is gated on `detection_changed`. + +### 4.4 Blocking calls in async handlers + +`git_onboarding::resolve_onboarding*` shells out via `std::process::Command`, +and `validate_*_token` uses `reqwest::blocking`. Both are fine in the TUI (they +briefly stall the event loop). In an axum handler they block a runtime worker, +and `reqwest::blocking` can panic when constructed inside a tokio context. + +**Wrap every such call in `tokio::task::spawn_blocking`.** This is the largest +correctness risk in the REST work. Prefer wrapping over writing async twins, so +one implementation stays shared with the TUI. + +### 4.5 The SPA dependency allowlist + +`tests/ui_packaging.rs` enforces a **7-entry** allowlist for `ui/package.json` +(react, react-dom, react-router-dom, three @dnd-kit packages, @vscode/codicons) +and bans CSS-in-JS. Build the wizard with plain React and CSS modules, matching +`SetupPage.tsx` / `AuthPage.module.css`. Reaching for a form or wizard library +fails the build. + +### 4.6 Config is written relative to `paths.state` + +`Config::save()` writes `config.operator_config_path_for()` = `paths.state` + +`config.toml` (default `.tickets/operator/config.toml`, resolved against the +process cwd). `operator api` started from a different directory silently uses a +different config. Mention this in whatever the wizard shows as its destination +path — `GET /setup/status` should return the resolved absolute path. + +### 4.7 `ApiState::mutate_config` is the only REST write path + +```rust +pub async fn mutate_config(&self, mutate: impl FnOnce(&mut Config) -> Result) + -> Result +``` + +It serialises on a lock, saves atomically, **and** `replace_config`s so later +handlers do not see a stale snapshot. Never call `config.save()` directly from a +handler. + +### 4.8 Route scopes fail closed + +`tests/route_scope_parity.rs` walks the generated OpenAPI spec and fails on any +route missing from `ROUTE_RULES` in `src/auth/scope.rs` (`read` / `execute` / +`admin` helpers). Add an entry per new route or the test fails — which is the +desired behaviour, since an unknown route denies. + +**Auth needs no other work.** Browser session principals carry `Scope::ALL` +(`src/auth/store.rs:462`), so a post-bootstrap admin satisfies every rule the +wizard touches. + +## 5. What already exists vs. what is new + +### Already built — zero or near-zero Rust work + +| Concern | Endpoints | Note | +|---|---|---| +| Admin bootstrap | `GET`/`POST /api/v1/auth/bootstrap`, `POST /auth/login` | already drives `SetupPage.tsx` | +| **Kanban** | `POST /kanban/validate`, `/kanban/projects`, `/kanban/statuses`, `PUT /kanban/config`, `POST /kanban/session-env` | **fully built and proven — persists correctly.** Only `vscode-extension/src/kanban-onboarding.ts` calls it; `ui/src/api-client.ts` has no methods. Port that client. | +| Model servers | `GET /model-servers/kinds`, `GET /kinds/{slug}/models` (live probe), `POST /model-servers` | `ModelProvidersPage.tsx` already drives connect; extract its card grid | +| Provider catalog | `GET /api/v1/integrations` | serves §3.3 to the browser with no new route | +| Collections | `GET /collections`, `POST /collections/{name}/activate` | **activate is in-memory only** — see below | + +The kanban quartet being done is the single biggest head start here. Read +`vscode-extension/src/kanban-onboarding.ts` as the working reference. + +### New work + +1. **Move `setup.rs` into the lib** (§4.1). Prerequisite for everything else. +2. **Detection in `cmd_api`** (§4.3). Independently shippable. +3. **Persist collection activation** — `src/rest/routes/collections.rs:102` + mutates the in-memory registry only, so the wizard's collection choice + evaporates on restart. A direct D3 violation. Wrap in `mutate_config`. +4. **Git REST surface** — nothing exists; zero `/api/v1/git*` routes. Mirror + the kanban quartet exactly, including its convention that `validate` returns + `{valid, error}` rather than a 4xx on a bad token: + + ``` + GET /api/v1/git/providers read -> catalog entries + CLI/auth state + POST /api/v1/git/validate execute -> {valid, username?, error?} + PUT /api/v1/git/config admin -> {provider, token_env} NO secret + POST /api/v1/git/session-env admin -> {provider, token} -> {shell_export_block} + ``` + +5. **Setup surface**: + + ``` + GET /api/v1/setup/status read -> {initialized, admin_configured, config_path, tickets_path} + GET /api/v1/setup/steps read -> the catalog: slug, name, description, help_text, order + POST /api/v1/setup/initialize admin -> SetupOptions DTO, wrapped in mutate_config + ``` + + `initialized` **must** use the existing shared predicate + `startup::workspace_initialized(&config)` — already extracted in Phase A and + already used by `App::new`. If the two surfaces compute "is this set up?" + differently, D3 is unenforceable. + +6. **`ui/src/api-client.ts`** — kanban, git, setup, integrations methods. +7. **The wizard itself** — `ui/src/routes/onboarding/`, one component per slug, + map declared `satisfies Record` so `tsc` errors + when a Rust variant lands without a web component. That is the TypeScript-side + compile-time guard, replacing a drift test. + +## 6. Deliberate scope cuts + +- **Startup Tickets renders read-only in v1** ("you can create these later from + the dashboard"), and the parity test allowlists it explicitly. Shipping + half-working ticket creation and calling it parity is worse than an honest gap. +- **Per-wrapper steps** (tmux/vscode/cmux/zellij onboarding) are terminal-centric + help screens. Decide deliberately whether the browser shows them, shows one + combined "session target" screen, or skips them — and record the decision in + the parity test's allowlist either way. + +## 7. Acceptance — how to prove D3 + +The bar is not "tests pass". It is two clean rooms producing interchangeable +config. + +1. `rm -rf /tmp/opr-tui && mkdir -p /tmp/opr-tui && cd /tmp/opr-tui && operator` + — walk the wizard, choosing a non-default on every screen. +2. `rm -rf /tmp/opr-web && mkdir -p /tmp/opr-web && cd /tmp/opr-web && operator api --open` + — bootstrap at `/#/setup`, complete `/#/onboarding`, choosing the same options. +3. **Diff the two `config.toml` files.** Differences must be explainable by the + choices made, not by which surface wrote them. This is the D3 check. +4. Start `operator` in `/tmp/opr-web`: the TUI must read the web-produced config + without re-running setup. Then the reverse. +5. In the web run, confirm the sidebar has **no disabled entries** afterwards + (the §4.3 check). +6. `make check`, then `cargo run -- docs && make bindings && git diff --exit-code`. + +## 8. Known-bad state you will inherit + +Both pre-date this work. Neither is yours to fix, but both will confuse you. + +- **`make check` is not reliably green.** + `auth::schema::tests::test_concurrent_migration_of_one_database_is_safe` + fails ~8% of the time **in isolation** (measured 2/25) with + `enabling WAL: database is locked`. A hypothesis that `apply_pragmas` sets + `busy_timeout` after the `journal_mode=WAL` switch that needs it was **tested + and disproven** (2/25 before, 2/25 after) and reverted. There is no working + explanation yet. Treat a red `make check` as "check which test failed". +- **Generated artifacts do not round-trip.** `cargo run -- docs` regenerates + `docs/schemas/{config,metadata,state}.md` and `docs/schemas/openapi.json` with + em-dashes where the committed copies have hyphens, while + `src/schemas/issuetype_schema.json` regenerates with hyphens where the + committed copy has em-dashes. None of those sources were touched in Phase A. + Step 6 of §7 will surface this. It wants its own cleanup commit. + +## 9. Working agreements + +From `CLAUDE.md` and established over Phase A: + +- **TDD.** Write the failing test first; confirm it fails for the right reason. +- **Verify guards are not vacuous.** Every parity test added in Phase A was + confirmed by deliberately breaking it and watching it fail. Do the same. +- **`make check` before declaring done** — and read its *actual* exit code. A + grep over its output that matches nothing is not evidence of success; that + mistake hid a real failure during Phase A. +- **Do not commit.** The user handles all git operations manually. +- **Minimal comments**, terse and one line where they earn their place. +- **Ask rather than assume** on anything that changes the shape of the work. + Phase A's kanban step was redesigned mid-flight because the planned approach + served nobody on a first run; that was worth one question. diff --git a/tests/setup_parity.rs b/tests/setup_parity.rs new file mode 100644 index 00000000..864369a9 --- /dev/null +++ b/tests/setup_parity.rs @@ -0,0 +1,192 @@ +//! Setup wizard parity tests. +//! +//! The wizard's step catalog (`src/startup/steps.rs`) is the single source of +//! truth for the ratatui renderer, the generated TypeScript binding and the +//! hosted docs page. These tests keep the three in step. +//! +//! Uses `include_str!` to scan source files (same pattern as +//! `surface_parity.rs`) because `startup` is a crate-private module and the +//! wizard itself is bin-only, so neither is reachable from an integration test. + +const STEPS_RS: &str = include_str!("../src/startup/steps.rs"); +const BINDING_TS: &str = include_str!("../bindings/SetupStep.ts"); +const STARTUP_DOC: &str = include_str!("../docs/startup/index.md"); +const SETUP_MOD_RS: &str = include_str!("../src/ui/setup/mod.rs"); +const GIT_STEP_RS: &str = include_str!("../src/ui/setup/steps/git.rs"); +const MODEL_STEP_RS: &str = include_str!("../src/ui/setup/steps/model_server.rs"); +const WEB_STEPS_TSX: &str = include_str!("../ui/src/routes/onboarding/steps.tsx"); + +/// Variant names in `SetupStep::ALL`, in declaration order. +fn catalog_order() -> Vec { + let all = STEPS_RS + .split_once("pub const ALL:") + .expect("steps.rs must declare SetupStep::ALL") + .1; + let body = all.split_once('[').unwrap().1.split_once("];").unwrap().0; + body.lines() + .filter_map(|l| l.trim().strip_prefix("SetupStep::")) + .map(|v| v.trim_end_matches(',').to_string()) + .collect() +} + +/// Slugs in the order `slug()` matches them. +fn catalog_slugs() -> Vec { + let arm = STEPS_RS + .split_once("pub fn slug(self)") + .expect("steps.rs must define slug()") + .1; + let body = arm.split_once('{').unwrap().1; + body.lines() + .filter_map(|l| l.trim().split_once("=> \"")) + .map(|(_, rest)| rest.split('"').next().unwrap_or("").to_string()) + .take_while(|s| !s.is_empty()) + .collect() +} + +/// Step display names from the generated docs page headings. +fn doc_step_names() -> Vec { + STARTUP_DOC + .lines() + .filter_map(|l| l.strip_prefix("### ")) + .filter_map(|h| h.split_once(". ")) + .map(|(_, name)| name.trim().to_string()) + .collect() +} + +/// `name:` literals from the `info()` match, in declaration order. +fn catalog_names() -> Vec { + let arm = STEPS_RS + .split_once("pub fn info(self)") + .expect("steps.rs must define info()") + .1; + arm.lines() + .filter_map(|l| l.trim().strip_prefix("name: \"")) + .map(|rest| rest.split('"').next().unwrap_or("").to_string()) + .collect() +} + +#[test] +fn test_catalog_is_non_empty() { + assert!( + !catalog_order().is_empty(), + "failed to parse SetupStep::ALL" + ); + assert_eq!(catalog_order().len(), catalog_slugs().len()); + assert_eq!(catalog_order().len(), catalog_names().len()); +} + +/// Defect E regression: the generated docs page drifted out of the wizard's +/// real order and nothing caught it, because the only guard was a step count. +#[test] +fn test_docs_page_lists_steps_in_catalog_order() { + assert_eq!( + doc_step_names(), + catalog_names(), + "docs/startup/index.md is stale - run `cargo run -- docs --only startup`" + ); +} + +#[test] +fn test_binding_union_matches_catalog_slugs() { + let union = BINDING_TS + .split_once("export type SetupStep =") + .expect("binding must declare SetupStep") + .1; + let members: Vec = union + .split('|') + .map(|m| m.trim().trim_end_matches(';').trim_matches('"').to_string()) + .collect(); + assert_eq!( + members, + catalog_slugs(), + "bindings/SetupStep.ts is stale - run `make bindings`" + ); +} + +/// Every variant must be reachable from the wizard state machine; a step the +/// renderer never selects is dead code (defect A's failure mode). +#[test] +fn test_every_catalog_step_is_referenced_by_the_wizard() { + for variant in catalog_order() { + assert!( + SETUP_MOD_RS.contains(&format!("SetupStep::{variant}")), + "SetupStep::{variant} is never referenced in src/ui/setup/mod.rs" + ); + } +} + +/// Provider slugs the wizard must never hardcode: the offered set comes from +/// `integrations::catalog::onboardable`, so promoting a provider into +/// onboarding is a `SupportStatus` bump rather than an edit in each surface. +/// The same list will back the web wizard over REST. +#[test] +fn test_wizard_steps_do_not_hardcode_provider_slugs() { + const SLUGS: &[&str] = &[ + "github", + "gitlab", + "gitea", + "bitbucket", + "forgejo", + "anthropic-api", + "openai-api", + "google-api", + "ollama", + "openrouter", + "openai-compat", + "lmstudio", + ]; + for (name, source) in [ + ("steps/git.rs", GIT_STEP_RS), + ("steps/model_server.rs", MODEL_STEP_RS), + ] { + for slug in SLUGS { + assert!( + !source.contains(&format!("\"{slug}\"")), + "{name} hardcodes the provider slug {slug:?}; derive the list from \ + integrations::catalog::onboardable instead" + ); + } + } +} + +/// The wizard must key its provider rows off the catalog, not a per-vertical +/// enum - the enums include Proto entries that onboarding does not advertise. +#[test] +fn test_wizard_derives_provider_lists_from_the_catalog() { + assert!( + SETUP_MOD_RS.contains("onboardable(Vertical::Git)"), + "git rows must come from the catalog" + ); + assert!( + SETUP_MOD_RS.contains("onboardable(Vertical::Model)"), + "model rows must come from the catalog" + ); +} + +#[test] +fn test_web_wizard_has_an_exhaustive_component_map() { + assert!( + WEB_STEPS_TSX.contains("satisfies Record"), + "the web wizard must fail TypeScript compilation when the Rust step union grows" + ); + for slug in catalog_slugs() { + assert!( + WEB_STEPS_TSX.contains(&format!("{slug}:")) + || WEB_STEPS_TSX.contains(&format!("'{slug}':")), + "web component map is missing {slug:?}" + ); + } +} + +#[test] +fn test_web_wizard_derives_provider_lists_from_rest_catalogs() { + assert!(WEB_STEPS_TSX.contains("entry.vertical === 'model'")); + assert!(WEB_STEPS_TSX.contains("api.gitProviders()")); + assert!(WEB_STEPS_TSX.contains("api.kanbanProviders()")); +} + +#[test] +fn test_web_parity_scope_cuts_are_explicit() { + assert!(WEB_STEPS_TSX.contains("Ticket creation is read-only")); + assert!(WEB_STEPS_TSX.contains("wrapperSteps.has(step)")); +} diff --git a/ui/src/WorkspaceGate.tsx b/ui/src/WorkspaceGate.tsx new file mode 100644 index 00000000..037b5c14 --- /dev/null +++ b/ui/src/WorkspaceGate.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { OperatorApi } from './api-client'; +import { useHost } from './host'; + +export function WorkspaceGate() { + const host = useHost(); + const [initialized, setInitialized] = useState(null); + + useEffect(() => { + let active = true; + new OperatorApi(host) + .setupStatus() + .then((status) => active && setInitialized(status.initialized)) + .catch(() => active && setInitialized(null)); + return () => { active = false; }; + }, [host]); + + if (initialized === null) { + return null; + } + return initialized ? : ; +} diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index aa7ce099..c46355be 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -33,6 +33,30 @@ import type { CreateModelServerRequest } from '@operator/bindings/CreateModelSer import type { DelegatorsResponse } from '@operator/bindings/DelegatorsResponse'; import type { DelegatorResponse } from '@operator/bindings/DelegatorResponse'; import type { CreateDelegatorRequest } from '@operator/bindings/CreateDelegatorRequest'; +import type { IntegrationCatalogEntryDto } from '@operator/bindings/IntegrationCatalogEntryDto'; +import type { SetupStatusResponse } from '@operator/bindings/SetupStatusResponse'; +import type { SetupStepResponse } from '@operator/bindings/SetupStepResponse'; +import type { SetupCollectionResponse } from '@operator/bindings/SetupCollectionResponse'; +import type { SetupInitializeRequest } from '@operator/bindings/SetupInitializeRequest'; +import type { SetupInitializeResponse } from '@operator/bindings/SetupInitializeResponse'; +import type { GitProviderOnboardingResponse } from '@operator/bindings/GitProviderOnboardingResponse'; +import type { ValidateGitTokenRequest } from '@operator/bindings/ValidateGitTokenRequest'; +import type { ValidateGitTokenResponse } from '@operator/bindings/ValidateGitTokenResponse'; +import type { WriteGitConfigRequest } from '@operator/bindings/WriteGitConfigRequest'; +import type { WriteGitConfigResponse } from '@operator/bindings/WriteGitConfigResponse'; +import type { SetGitSessionEnvRequest } from '@operator/bindings/SetGitSessionEnvRequest'; +import type { SetGitSessionEnvResponse } from '@operator/bindings/SetGitSessionEnvResponse'; +import type { KanbanProviderCatalogEntry } from '@operator/bindings/KanbanProviderCatalogEntry'; +import type { ValidateKanbanCredentialsRequest } from '@operator/bindings/ValidateKanbanCredentialsRequest'; +import type { ValidateKanbanCredentialsResponse } from '@operator/bindings/ValidateKanbanCredentialsResponse'; +import type { ListKanbanProjectsRequest } from '@operator/bindings/ListKanbanProjectsRequest'; +import type { ListKanbanProjectsResponse } from '@operator/bindings/ListKanbanProjectsResponse'; +import type { ListKanbanStatusesRequest } from '@operator/bindings/ListKanbanStatusesRequest'; +import type { ListKanbanStatusesResponse } from '@operator/bindings/ListKanbanStatusesResponse'; +import type { WriteKanbanConfigRequest } from '@operator/bindings/WriteKanbanConfigRequest'; +import type { WriteKanbanConfigResponse } from '@operator/bindings/WriteKanbanConfigResponse'; +import type { SetKanbanSessionEnvRequest } from '@operator/bindings/SetKanbanSessionEnvRequest'; +import type { SetKanbanSessionEnvResponse } from '@operator/bindings/SetKanbanSessionEnvResponse'; import type { AccessKeyListResponse } from '@operator/bindings/AccessKeyListResponse'; import type { BootstrapStatusResponse } from '@operator/bindings/BootstrapStatusResponse'; @@ -94,6 +118,22 @@ export type { DelegatorsResponse, DelegatorResponse, CreateDelegatorRequest, + IntegrationCatalogEntryDto, + SetupStatusResponse, + SetupStepResponse, + SetupCollectionResponse, + SetupInitializeRequest, + SetupInitializeResponse, + GitProviderOnboardingResponse, + ValidateGitTokenResponse, + WriteGitConfigResponse, + SetGitSessionEnvResponse, + KanbanProviderCatalogEntry, + ValidateKanbanCredentialsResponse, + ListKanbanProjectsResponse, + ListKanbanStatusesResponse, + WriteKanbanConfigResponse, + SetKanbanSessionEnvResponse, }; export class ApiError extends Error { @@ -317,6 +357,110 @@ export class OperatorApi { return request(this.base, '/api/v1/sections'); } + integrations(): Promise { + return request(this.base, '/api/v1/integrations'); + } + + // --- First-run setup --- + + setupStatus(): Promise { + return request(this.base, '/api/v1/setup/status'); + } + + setupSteps(): Promise { + return request(this.base, '/api/v1/setup/steps'); + } + + setupCollections(): Promise { + return request(this.base, '/api/v1/setup/collections'); + } + + initializeSetup(body: SetupInitializeRequest): Promise { + return request(this.base, '/api/v1/setup/initialize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + // --- Git onboarding --- + + gitProviders(): Promise { + return request(this.base, '/api/v1/git/providers'); + } + + validateGitToken(body: ValidateGitTokenRequest): Promise { + return request(this.base, '/api/v1/git/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + writeGitConfig(body: WriteGitConfigRequest): Promise { + return request(this.base, '/api/v1/git/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + setGitSessionEnv(body: SetGitSessionEnvRequest): Promise { + return request(this.base, '/api/v1/git/session-env', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + // --- Kanban onboarding --- + + kanbanProviders(): Promise { + return request(this.base, '/api/v1/kanban/providers'); + } + + validateKanbanCredentials( + body: ValidateKanbanCredentialsRequest, + ): Promise { + return request(this.base, '/api/v1/kanban/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + listKanbanProjects(body: ListKanbanProjectsRequest): Promise { + return request(this.base, '/api/v1/kanban/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + listKanbanStatuses(body: ListKanbanStatusesRequest): Promise { + return request(this.base, '/api/v1/kanban/statuses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + writeKanbanConfig(body: WriteKanbanConfigRequest): Promise { + return request(this.base, '/api/v1/kanban/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + setKanbanSessionEnv(body: SetKanbanSessionEnvRequest): Promise { + return request(this.base, '/api/v1/kanban/session-env', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + // --- Queue --- queueStatus(): Promise { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 7c8adf9b..794a8bad 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -19,6 +19,8 @@ import { ResetPasswordPage } from './routes/ResetPasswordPage'; import { SetupPage } from './routes/SetupPage'; import { DevicePage } from './routes/DevicePage'; import { SecurityPage } from './routes/SecurityPage'; +import { OnboardingPage } from './routes/onboarding/OnboardingPage'; +import { WorkspaceGate } from './WorkspaceGate'; const host = createBrowserHost(); @@ -33,7 +35,9 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> - }> + } /> + }> + }> } /> } /> } /> @@ -50,6 +54,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + diff --git a/ui/src/routes/LoginPage.tsx b/ui/src/routes/LoginPage.tsx index 81df5328..1991332e 100644 --- a/ui/src/routes/LoginPage.tsx +++ b/ui/src/routes/LoginPage.tsx @@ -36,8 +36,10 @@ export function LoginPage() { setError(null); setBusy(true); try { - await new OperatorApi(host).login(username, password); - void navigate('/', { replace: true }); + const api = new OperatorApi(host); + await api.login(username, password); + const setup = await api.setupStatus(); + void navigate(setup.initialized ? '/' : '/onboarding', { replace: true }); } catch (e) { // 429 carries a wait, not a wrong password; saying "incorrect" would // send the operator hunting for a password problem they do not have. diff --git a/ui/src/routes/SetupPage.tsx b/ui/src/routes/SetupPage.tsx index 959e56c5..cf7895f5 100644 --- a/ui/src/routes/SetupPage.tsx +++ b/ui/src/routes/SetupPage.tsx @@ -53,7 +53,7 @@ export function SetupPage() { }); // Bootstrap creates the account but does not sign you in. await api.login(result.username, password); - void navigate('/', { replace: true }); + void navigate('/onboarding', { replace: true }); } catch (e) { if (e instanceof ApiError && e.status === 409) { setError('This server already has an admin account. Sign in instead.'); diff --git a/ui/src/routes/onboarding/OnboardingPage.module.css b/ui/src/routes/onboarding/OnboardingPage.module.css new file mode 100644 index 00000000..6e72c66f --- /dev/null +++ b/ui/src/routes/onboarding/OnboardingPage.module.css @@ -0,0 +1,36 @@ +.page { min-height: 100vh; display: grid; grid-template-columns: 17rem minmax(0, 1fr); background: var(--color-bg); color: var(--text); } +.sidebar { padding: 2rem 1.25rem; border-right: 1px solid var(--border); background: var(--surface-alt); overflow: auto; } +.brand { color: var(--accent); font-size: 1.35rem; font-weight: 700; margin: 0 0 1.5rem .5rem; } +.sidebar ol { list-style: none; margin: 0; padding: 0; } +.sidebar li { display: flex; gap: .7rem; align-items: center; padding: .45rem .55rem; color: var(--text-muted); font-size: .85rem; } +.sidebar li span { width: 1.45rem; height: 1.45rem; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; font-size: .7rem; } +.sidebar .active { color: var(--text); font-weight: 600; } +.sidebar .active span { border-color: var(--accent); color: var(--accent); } +.sidebar .complete span { background: var(--accent); color: var(--color-bg); border-color: var(--accent); } +.content { min-width: 0; display: grid; grid-template-rows: auto 1fr auto; } +.content > header, .body, .content > footer { width: min(54rem, calc(100% - 4rem)); margin-inline: auto; } +.content > header { padding-top: 3rem; border-bottom: 1px solid var(--border); } +.content > header > p:first-child { text-transform: uppercase; letter-spacing: .08em; color: var(--accent); font-size: .75rem; } +.content h1 { margin: .35rem 0; } +.body { padding-block: 2rem; } +.intro h2 { margin-top: 0; } +.intro dl { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1rem; } +.intro dd { margin: 0; overflow-wrap: anywhere; } +.choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .75rem; margin-block: 1.25rem; } +.choice { display: flex; flex-direction: column; align-items: flex-start; gap: .35rem; padding: 1rem; text-align: left; color: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: .4rem; cursor: pointer; } +.choice:hover, .choice.selected { border-color: var(--accent); } +.choice.selected { box-shadow: inset 3px 0 var(--accent); } +.choice span, .choice small { color: var(--text-muted); } +.form { display: grid; gap: .9rem; margin-top: 1rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: .4rem; } +.form label, .mapping label { display: grid; gap: .35rem; } +.form input, .form select, .mapping select, .editor { box-sizing: border-box; width: 100%; padding: .65rem; color: inherit; background: var(--color-bg); border: 1px solid var(--border); border-radius: .25rem; } +.form button, .content footer button, .export button { padding: .65rem 1rem; color: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: .25rem; cursor: pointer; } +.mapping { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } +.editor { min-height: 24rem; resize: vertical; font-family: var(--font-mono); } +.export { margin-top: 1rem; padding: 1rem; border: 1px solid var(--accent); border-radius: .4rem; } +.export pre { overflow: auto; white-space: pre-wrap; } +.content > footer { display: flex; justify-content: space-between; padding-block: 1.25rem 2rem; border-top: 1px solid var(--border); } +.content footer .primary { color: var(--color-bg); background: var(--accent); border-color: var(--accent); } +.error { width: min(54rem, calc(100% - 4rem)); margin: 1rem auto 0; padding: .75rem 1rem; color: var(--danger); border: 1px solid var(--danger); border-radius: .35rem; } +.loading { min-height: 100vh; display: grid; place-items: center; background: var(--color-bg); color: var(--text); } +@media (max-width: 760px) { .page { grid-template-columns: 1fr; } .sidebar { display: none; } .content > header, .body, .content > footer, .error { width: calc(100% - 2rem); } .mapping { grid-template-columns: 1fr; } } diff --git a/ui/src/routes/onboarding/OnboardingPage.tsx b/ui/src/routes/onboarding/OnboardingPage.tsx new file mode 100644 index 00000000..3bc952a9 --- /dev/null +++ b/ui/src/routes/onboarding/OnboardingPage.tsx @@ -0,0 +1,121 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { SetupStep } from '@operator/bindings/SetupStep'; +import { ApiError, OperatorApi } from '../../api-client'; +import { useHost } from '../../host'; +import { STEP_COMPONENTS, visibleSteps } from './steps'; +import type { WizardDraft } from './types'; +import styles from './OnboardingPage.module.css'; + +export function OnboardingPage() { + const host = useHost(); + const navigate = useNavigate(); + const [api] = useState(() => new OperatorApi(host)); + const [status, setStatus] = useState> | null>(null); + const [steps, setSteps] = useState>>([]); + const [integrations, setIntegrations] = useState>>([]); + const [collections, setCollections] = useState>>([]); + const [draft, setDraft] = useState({ + preset: 'devops_kanban', + taskFields: ['priority', 'points', 'user_story'], + wrapper: 'tmux', + executionTarget: { kind: 'local' }, + useWorktrees: false, + acceptanceCriteria: '', + modelServers: [], + hostedCollectionIds: [], + }); + const [currentSlug, setCurrentSlug] = useState('welcome'); + const [exports, setExports] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let active = true; + Promise.all([api.refreshCsrf(), api.setupStatus(), api.setupSteps(), api.integrations(), api.setupCollections()]) + .then(([, nextStatus, nextSteps, nextIntegrations, nextCollections]) => { + if (!active) { return undefined; } + if (nextStatus.initialized) { void navigate('/', { replace: true }); return undefined; } + setStatus(nextStatus); + setSteps(nextSteps); + setIntegrations(nextIntegrations); + setCollections(nextCollections); + setDraft({ + preset: 'devops_kanban', + taskFields: ['priority', 'points', 'user_story'], + wrapper: 'tmux', + executionTarget: { kind: 'local' }, + useWorktrees: false, + acceptanceCriteria: nextStatus.default_acceptance_criteria, + modelServers: [], + hostedCollectionIds: [], + }); + return undefined; + }) + .catch((cause: unknown) => active && setError(cause instanceof Error ? cause.message : 'Could not load setup')); + return () => { active = false; }; + }, [api, navigate]); + + const walk = useMemo(() => visibleSteps(steps.map((step) => step.slug), draft), [draft, steps]); + const currentIndex = Math.max(0, walk.indexOf(currentSlug)); + const current = steps.find((step) => step.slug === walk[currentIndex]); + const Step = current ? STEP_COMPONENTS[current.slug] : null; + + function addExport(value: string) { + setExports((currentExports) => currentExports.includes(value) ? currentExports : [...currentExports, value]); + } + + function next() { + if (!current) { return; } + if (current.slug === 'hosted-collections' && draft.hostedCollectionIds.length === 0) { + setError('Select at least one collection.'); + return; + } + if (current.slug === 'execution-target' && draft.executionTarget.kind === 'coder' && (!draft.executionTarget.name.trim() || !draft.executionTarget.template.trim())) { + setError('Coder target name and template are required.'); + return; + } + setError(null); + setCurrentSlug(walk[Math.min(currentIndex + 1, walk.length - 1)]); + } + + async function initialize() { + setBusy(true); + setError(null); + try { + await api.initializeSetup({ + preset: draft.preset, + task_fields: draft.taskFields, + wrapper: draft.wrapper, + execution_target: draft.executionTarget, + use_worktrees: draft.executionTarget.kind === 'coder' ? false : draft.useWorktrees, + acceptance_criteria: draft.acceptanceCriteria, + model_servers: draft.modelServers, + hosted_collections: draft.hostedCollectionIds.map((id) => { + const collection = collections.find((item) => item.id === id); + if (!collection) { throw new Error(`Collection ${id} is no longer available`); } + return { id, checksum: collection.checksum }; + }), + }); + void navigate('/', { replace: true }); + } catch (cause) { + setError(cause instanceof ApiError ? cause.message : cause instanceof Error ? cause.message : 'Initialization failed'); + } finally { + setBusy(false); + } + } + + if (!status || !current || !Step) { + return

{error ?? 'Loading workspace setup…'}
; + } + + return
+ +
+

Step {currentIndex + 1} of {walk.length}

{current.name}

{current.description}

+ {error &&
{error}
} +
+
{currentIndex === walk.length - 1 ? : }
+
+
; +} diff --git a/ui/src/routes/onboarding/steps.tsx b/ui/src/routes/onboarding/steps.tsx new file mode 100644 index 00000000..1ffe023b --- /dev/null +++ b/ui/src/routes/onboarding/steps.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { KanbanProviderKind } from '@operator/bindings/KanbanProviderKind'; +import type { SetupStep } from '@operator/bindings/SetupStep'; +import type { StepComponent, StepProps } from './types'; +import styles from './OnboardingPage.module.css'; + +const TASK_FIELDS = ['priority', 'points', 'user_story'] as const; +const WRAPPERS = ['tmux', 'vscode', 'cmux', 'zellij'] as const; +const KANBAN_KINDS = ['jira', 'linear', 'github', 'openspec'] as const; + +function Intro({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Choice({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) { + return ; +} + +function ExportBlock({ value }: { value: string }) { + return
Make this permanent in your shell profile
{value}
; +} + +const Welcome: StepComponent = ({ status }) =>

Welcome to Operator

We’ll configure this workspace for both the terminal and browser.

Configuration
{status.config_path}
Tickets
{status.tickets_path}
{Object.entries(status.projects_by_tool).map(([tool, projects]) =>

{tool}: {projects.join(', ') || 'none'}

)}
; + +function KanbanInfo({ api, addExport }: StepProps) { + const [providers, setProviders] = useState>>([]); + const [provider, setProvider] = useState(''); + const [domain, setDomain] = useState(''); + const [email, setEmail] = useState(''); + const [token, setToken] = useState(''); + const [rootPath, setRootPath] = useState(''); + const [instance, setInstance] = useState('default'); + const [projects, setProjects] = useState<{ id: string; key: string; name: string }[]>([]); + const [projectKey, setProjectKey] = useState(''); + const [statuses, setStatuses] = useState([]); + const [mapping, setMapping] = useState({ todo: '', doing: '', done: '' }); + const [syncUserId, setSyncUserId] = useState(''); + const [workspaceKey, setWorkspaceKey] = useState(''); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { void api.kanbanProviders().then(setProviders).catch((error: Error) => setMessage(error.message)); }, [api]); + + const credentials = () => ({ + provider: provider as KanbanProviderKind, + jira: provider === 'jira' ? { domain, email, api_token: token } : null, + linear: provider === 'linear' ? { api_key: token } : null, + github: provider === 'github' ? { token } : null, + openspec: provider === 'openspec' ? { root_path: rootPath } : null, + }); + + async function connect() { + if (!provider) { return; } + setBusy(true); + setMessage(null); + try { + const validation = await api.validateKanbanCredentials(credentials()); + if (!validation.valid) { throw new Error(validation.error ?? 'Credentials were rejected'); } + setSyncUserId(validation.jira?.account_id ?? validation.linear?.user_id ?? validation.github?.user_id ?? ''); + setWorkspaceKey(validation.github?.user_login ?? ''); + if (provider === 'openspec') { + await api.writeKanbanConfig({ provider, openspec: { instance, root_path: rootPath, project: null }, jira: null, linear: null, github: null }); + setMessage('OpenSpec connected.'); + return; + } + const listed = await api.listKanbanProjects(credentials()); + setProjects(listed.projects); + setMessage('Credentials validated. Choose a project.'); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Connection failed'); } + finally { setBusy(false); } + } + + async function chooseProject(value: string) { + setProjectKey(value); + if (!provider || !value) { return; } + try { + const result = await api.listKanbanStatuses({ ...credentials(), project_key: value }); + setStatuses(result.statuses); + setMapping({ todo: result.statuses[0] ?? '', doing: result.statuses[1] ?? '', done: result.statuses.at(-1) ?? '' }); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Could not list statuses'); } + } + + async function save() { + if (!provider || !projectKey) { return; } + setBusy(true); + try { + const env = provider === 'jira' ? 'OPERATOR_JIRA_API_KEY' : provider === 'linear' ? 'OPERATOR_LINEAR_API_KEY' : 'OPERATOR_GITHUB_TOKEN'; + const status_mapping = mapping; + if (provider === 'jira') { + const envResult = await api.setKanbanSessionEnv({ provider, jira: { domain, email, api_token: token, api_key_env: env }, linear: null, github: null }); + await api.writeKanbanConfig({ provider, jira: { domain, email, api_key_env: env, project_key: projectKey, sync_user_id: syncUserId, status_mapping }, linear: null, github: null, openspec: null }); + addExport(envResult.shell_export_block); + } else if (provider === 'linear') { + const envResult = await api.setKanbanSessionEnv({ provider, linear: { api_key: token, api_key_env: env }, jira: null, github: null }); + const selected = projects.find((item) => item.key === projectKey); + await api.writeKanbanConfig({ provider, linear: { workspace_key: selected?.id ?? projectKey, api_key_env: env, project_key: projectKey, sync_user_id: syncUserId, status_mapping }, jira: null, github: null, openspec: null }); + addExport(envResult.shell_export_block); + } else if (provider === 'github') { + const envResult = await api.setKanbanSessionEnv({ provider, github: { token, api_key_env: env }, jira: null, linear: null }); + const selected = projects.find((item) => item.key === projectKey); + const owner = selected?.name.split('/#', 1)[0] ?? workspaceKey; + await api.writeKanbanConfig({ provider, github: { owner, api_key_env: env, project_key: selected?.id ?? projectKey, sync_user_id: syncUserId, status_mapping }, jira: null, linear: null, openspec: null }); + addExport(envResult.shell_export_block); + } + setToken(''); + setMessage('Kanban provider connected.'); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Could not save provider'); } + finally { setBusy(false); } + } + + return

Kanban

Connect a board now, or continue and connect one later.

{providers.map((item) => setProvider(KANBAN_KINDS.find((kind) => kind === item.slug) ?? '')}>{item.display_name}{item.description})}
{provider &&
{provider === 'jira' && <>}{provider === 'openspec' ? <> : }{projects.length > 0 && <>{statuses.length > 0 &&
{(['todo', 'doing', 'done'] as const).map((state) => )}
}}{message &&

{message}

}
}
; +} + +function ModelServer({ api, integrations, draft, setDraft }: StepProps) { + const entries = useMemo(() => integrations.filter((entry) => entry.vertical === 'model'), [integrations]); + const [kinds, setKinds] = useState>>([]); + const [probes, setProbes] = useState>({}); + useEffect(() => { let active = true; void api.listProviderKinds().then((result) => { if (active) { setKinds(result); } return undefined; }); for (const entry of entries) { void api.providerModels(entry.slug).then((result) => { if (active) { setProbes((current) => ({ ...current, [entry.slug]: result.reachable ? `${result.models.length} models` : result.error ?? 'unreachable' })); } return undefined; }).catch(() => { if (active) { setProbes((current) => ({ ...current, [entry.slug]: 'unreachable' })); } }); } return () => { active = false; }; }, [api, entries]); + const keyExports = draft.modelServers.flatMap((slug) => { const env = kinds.find((kind) => kind.slug === slug)?.default_api_key_env; return env ? [`export ${env}=""`] : []; }); + return

Model providers

Select the providers this workspace uses. Operator stores environment-variable names, never API keys.

{entries.map((entry) => setDraft((current) => ({ ...current, modelServers: current.modelServers.includes(entry.slug) ? current.modelServers.filter((slug) => slug !== entry.slug) : [...current.modelServers, entry.slug] }))}>{entry.label}{probes[entry.slug] ?? 'checking…'})}
{keyExports.length > 0 && }
; +} + +function GitProvider({ api, addExport }: StepProps) { + const [providers, setProviders] = useState>>([]); + const [selected, setSelected] = useState(''); + const [token, setToken] = useState(''); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + useEffect(() => { void api.gitProviders().then(setProviders).catch((error: Error) => setMessage(error.message)); }, [api]); + const provider = providers.find((item) => item.slug === selected); + async function save() { if (!provider) { return; } setBusy(true); setMessage(null); try { if (provider.state !== 'authenticated') { const validation = await api.validateGitToken({ provider: provider.slug, token }); if (!validation.valid) { throw new Error(validation.error ?? 'Token was rejected'); } } const config = await api.writeGitConfig({ provider: provider.slug, token_env: provider.token_env }); if (token) { const env = await api.setGitSessionEnv({ provider: provider.slug, token }); addExport(env.shell_export_block); } else { addExport(config.shell_export_block); } setToken(''); setMessage(`Connected ${provider.label}${config.username ? ` as ${config.username}` : ''}.`); } catch (error) { setMessage(error instanceof Error ? error.message : 'Could not connect provider'); } finally { setBusy(false); } } + return

Git provider

Choose a catalog provider. Existing CLI authentication is adopted when available.

{providers.map((item) => setSelected(item.slug)}>{item.label}{item.state === 'authenticated' ? `authenticated${item.username ? ` as ${item.username}` : ''}` : item.command})}
{provider &&
{provider.state !== 'authenticated' && }Provider setup{message &&

{message}

}
}
; +} + +const CollectionSource: StepComponent = ({ draft, setDraft }) =>

Issue type collection

{([['simple', 'Simple'], ['dev_kanban', 'Development'], ['devops_kanban', 'DevOps'], ['custom', 'Hosted collections']] as const).map(([value, label]) => setDraft((current) => ({ ...current, preset: value }))}>{label})}
; + +const HostedCollections: StepComponent = ({ collections, draft, setDraft }) =>

Hosted collections

Select one or more. The checksum locks initialization to the version you reviewed.

{collections.map((item) => setDraft((current) => ({ ...current, hostedCollectionIds: current.hostedCollectionIds.includes(item.id) ? current.hostedCollectionIds.filter((id) => id !== item.id) : [...current.hostedCollectionIds, item.id] }))}>{item.name}{item.description}{item.types.join(', ')})}
; + +const TaskFieldConfig: StepComponent = ({ draft, setDraft }) =>

Optional task fields

{TASK_FIELDS.map((field) => setDraft((current) => ({ ...current, taskFields: current.taskFields.includes(field) ? current.taskFields.filter((item) => item !== field) : [...current.taskFields, field] }))}>{field.replace('_', ' ')})}
; + +const SessionWrapperChoice: StepComponent = ({ draft, setDraft }) =>

Session wrapper

{WRAPPERS.map((wrapper) => setDraft((current) => ({ ...current, wrapper, executionTarget: wrapper === 'zellij' && current.executionTarget.kind === 'coder' ? { kind: 'local' } : current.executionTarget }))}>{wrapper})}
; + +const ExecutionTarget: StepComponent = ({ draft, setDraft }) =>

Execution target

setDraft((current) => ({ ...current, executionTarget: { kind: 'local' } }))}>LocalRun beside Operator setDraft((current) => ({ ...current, useWorktrees: false, executionTarget: { kind: 'coder', name: 'coder-agents', template: '' } }))}>CoderOne workspace per ticket over SSH
{draft.executionTarget.kind === 'coder' &&

Set CODER_URL and CODER_SESSION_TOKEN in the server environment.

}
; + +const WorktreePreference: StepComponent = ({ draft, setDraft }) =>

Git worktrees

Coder targets always isolate work remotely, so local worktrees are disabled for them.

setDraft((current) => ({ ...current, useWorktrees: false }))}>In-place branches setDraft((current) => ({ ...current, useWorktrees: true }))}>Per-ticket worktrees
; + +const AdminPassword: StepComponent = () =>

Admin account

Your browser session is authenticated. The password was configured before this workspace wizard opened.

; +const TmuxOnboarding: StepComponent = () =>

tmux

Operator will launch each agent in its own tmux session. Install tmux and keep it available on PATH.

; +const VSCodeSetup: StepComponent = () =>

VS Code

Install the Operator extension to launch and follow agent terminals from VS Code.

; +const CmuxSetup: StepComponent = () =>

cmux

Run Operator inside cmux so launched workspaces can be focused from the dashboard.

; +const ZellijSetup: StepComponent = () =>

Zellij

Operator will create a Zellij session for each agent. Coder targets are not compatible with this wrapper.

; +const AcceptanceCriteria: StepComponent = ({ draft, setDraft }) =>

Acceptance criteria