diff --git a/.surface b/.surface index 184dd58f..cacb7f11 100644 --- a/.surface +++ b/.surface @@ -12802,6 +12802,7 @@ FLAG basecamp setup --account type=string FLAG basecamp setup --agent type=bool FLAG basecamp setup --cache-dir type=string FLAG basecamp setup --count type=bool +FLAG basecamp setup --customize type=bool FLAG basecamp setup --help type=bool FLAG basecamp setup --hints type=bool FLAG basecamp setup --ids-only type=bool @@ -12810,6 +12811,7 @@ FLAG basecamp setup --jq type=string FLAG basecamp setup --json type=bool FLAG basecamp setup --markdown type=bool FLAG basecamp setup --md type=bool +FLAG basecamp setup --minimal type=bool FLAG basecamp setup --no-hints type=bool FLAG basecamp setup --no-stats type=bool FLAG basecamp setup --profile type=string diff --git a/README.md b/README.md index 59c8b4d2..29cb2643 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install On Windows 11 with Smart App Control, see [Troubleshooting](#windows-smart-app-control-and-smartscreen) if the install is blocked. -That's it. You now have full access to Basecamp from your terminal. +On an interactive terminal, the installer opens Basecamp setup: approve OAuth in your browser and the CLI uses the account granted by OAuth, otherwise preserves an existing account or selects the first available. It saves that account globally, clears the global project default, and connects every detected coding agent. Directory-specific and environment project settings continue to apply. Use `basecamp setup --customize` to choose those settings instead.
Other installation methods @@ -69,6 +69,11 @@ nix profile install github:basecamp/basecamp-cli go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest ``` +**mise:** +```bash +mise use --global github:basecamp/basecamp-cli@latest +``` + **GitHub Release:** download from [Releases](https://github.com/basecamp/basecamp-cli/releases).
@@ -83,7 +88,7 @@ What happens depends on how the CLI was installed: - **Installer script / tarball** (a binary under your home directory, e.g. `~/bin` or `~/.local/bin`): upgrades in place. The CLI downloads the release for your platform, verifies its Sigstore signature (the keyless `checksums.txt.bundle` published by the release pipeline, identity-pinned to the release workflow and tag) and SHA-256 checksum, swaps the executable transactionally, and confirms the installed binary reports the new version. On failure the previous binary is restored; in the worst case — restoration itself fails mid-swap — the error names the preserved backup file next to the binary so you can put it back by hand. - **Homebrew / Scoop**: delegates to `brew upgrade --cask` / `scoop update`, then verifies the manager-installed binary actually reports the new version. -- **System packages** (apt/dnf/apk, AUR, Nix) and **`go install` builds**: never touched. `basecamp upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. `go install`; otherwise which package manager to use). +- **System packages** (apt/dnf/apk, AUR, Nix), **mise**, and **`go install` builds**: never touched. `basecamp upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. mise or `go install`; otherwise which package manager to use). `basecamp upgrade` exits 0 only when there is no update, or the update was applied *and confirmed*. Every other outcome is a structured failure (`"ok": false` in JSON) with one of these codes: @@ -96,6 +101,20 @@ What happens depends on how the CLI was installed: The install scripts verify release signatures when `cosign` is available: cosign v3 verifies the published bundle format as-is, v2.6+ is driven with `--new-bundle-format=true`, and older versions skip signature verification with a warning (SHA-256 checksums are always verified). +## First-time setup + +The first interactive `basecamp` run applies the recommended setup automatically after browser approval: + +- Account granted by OAuth, otherwise the existing configured account or first available account, saved globally +- No global default project; directory-specific and environment project settings continue to apply +- Every detected Claude Code or Codex integration + +Run the same setup directly with `basecamp setup`. To choose the account, default project, config scope, and agent integrations, run: + +```bash +basecamp setup --customize +``` + ## Usage ```bash diff --git a/e2e/installer.bats b/e2e/installer.bats index 8584dfa3..71042eee 100644 --- a/e2e/installer.bats +++ b/e2e/installer.bats @@ -8,7 +8,7 @@ setup() { # The installer contract keys off these; a leaked value would skew results. - unset BASECAMP_SKIP_SETUP BASECAMP_SETUP_AGENT + unset BASECAMP_SKIP_SETUP BASECAMP_NONINTERACTIVE BASECAMP_SETUP_AGENT INSTALL_SH="${BATS_TEST_DIRNAME}/../scripts/install.sh" INSTALL_PS1="${BATS_TEST_DIRNAME}/../scripts/install.ps1" @@ -95,6 +95,68 @@ run_post_install_setup() { [[ "$output" != *"BASH_SOURCE[0]: unbound variable"* ]] } +@test "install.sh gives piped first-time setup the controlling terminal" { + run grep -F 'run_first_time_setup "$BIN_DIR/$binary_name" /dev/null' "$INSTALL_SH" + [[ "$status" -eq 0 ]] +} + +@test "install.sh matches the CLI non-interactive truthy values" { + run bash -c " + set -euo pipefail + source '$INSTALL_SH' + for value in 1 true TRUE True; do + env_value_is_true \"\$value\" || exit 1 + done + for value in 0 false FALSE yes ''; do + if env_value_is_true \"\$value\"; then exit 1; fi + done + " + [[ "$status" -eq 0 ]] +} + +@test "install.sh rejects a present but unusable controlling terminal" { + [[ "$(uname -s)" == "Linux" ]] || skip "setsid reproduction requires Linux" + command -v setsid >/dev/null 2>&1 || skip "setsid is required" + command -v script >/dev/null 2>&1 || skip "script is required" + + cat > "$STUB_DIR/tty-probe" < "$STUB_DIR/setup-fails" <<'EOF' +#!/usr/bin/env bash +exit 23 +EOF + chmod +x "$STUB_DIR/setup-fails" + + run bash -c " + set -euo pipefail + source '$INSTALL_SH' + run_first_time_setup '$STUB_DIR/setup-fails' + echo install-survived + " + [[ "$status" -eq 0 ]] + [[ "$output" == *"First-time setup did not finish"* ]] + [[ "$output" == *"basecamp setup"* ]] + [[ "$output" == *"install-survived"* ]] +} + @test "new binary: post_install_setup dispatches to 'setup agents', never 'setup claude'" { run_post_install_setup [[ "$status" -eq 0 ]] @@ -218,6 +280,114 @@ run_post_install_setup() { [[ "$output" == *"nk=1 skill install"* ]] } +@test "install.ps1 honors non-interactive values and tolerates setup failure" { + if ! command -v pwsh >/dev/null 2>&1; then + if [[ -n "${CI:-}" ]]; then + echo "pwsh is required in CI for install.ps1 setup coverage" >&2 + return 1 + fi + skip "pwsh not installed" + fi + + cat > "$STUB_DIR/ps-setup-fails" <<'EOF' +#!/usr/bin/env bash +exit 23 +EOF + chmod +x "$STUB_DIR/ps-setup-fails" + + cat > "$STUB_DIR/first-time-driver.ps1" <<'EOF' +$ErrorActionPreference = 'Stop' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" } +foreach ($name in @('Test-TruthyEnvironmentValue', 'Invoke-FirstTimeSetup')) { + $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name }, $true) + if (-not $fn) { throw "$name not found in install.ps1" } + . ([scriptblock]::Create($fn.Extent.Text)) +} +function Warn([string]$Message) { $script:WarningMessage = $Message } +foreach ($value in @('1', 'true', 'TRUE', 'True')) { + if (-not (Test-TruthyEnvironmentValue $value)) { throw "truthy value rejected: $value" } +} +foreach ($value in @('0', 'false', 'yes', '')) { + if (Test-TruthyEnvironmentValue $value) { throw "falsey value accepted: $value" } +} +Invoke-FirstTimeSetup $env:PS_SETUP_STUB +"WARN:$script:WarningMessage" +'install-survived' +EOF + + run bash -c " + set -euo pipefail + export INSTALL_PS1_PATH='$INSTALL_PS1' PS_SETUP_STUB='$STUB_DIR/ps-setup-fails' + pwsh -NoProfile -File '$STUB_DIR/first-time-driver.ps1' + " + [[ "$status" -eq 0 ]] + [[ "$output" == *"WARN:First-time setup did not finish"* ]] + [[ "$output" == *"install-survived"* ]] + grep -qF 'Test-TruthyEnvironmentValue $env:BASECAMP_NONINTERACTIVE' "$INSTALL_PS1" +} + +@test "install.ps1 first-time setup preserves terminal streams and visible output" { + [[ "$(uname -s)" == "Linux" ]] || skip "PTY stream reproduction requires Linux" + command -v pwsh >/dev/null 2>&1 || skip "pwsh not installed" + command -v script >/dev/null 2>&1 || skip "script is required" + + PS_TTY_LOG="$STUB_DIR/ps-setup-tty.log" + cat > "$STUB_DIR/ps-setup-probe" <<'EOF' +#!/usr/bin/env bash +stdin=redirected +stdout=redirected +stderr=redirected +[[ -t 0 ]] && stdin=tty +[[ -t 1 ]] && stdout=tty +[[ -t 2 ]] && stderr=tty +printf 'stdin=%s stdout=%s stderr=%s\n' "$stdin" "$stdout" "$stderr" > "$PS_TTY_LOG" +echo SETUP_OUTPUT_VISIBLE +EOF + chmod +x "$STUB_DIR/ps-setup-probe" + + cat > "$STUB_DIR/ps-first-time-tty-driver.ps1" <<'EOF' +$ErrorActionPreference = 'Stop' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" } +foreach ($name in @('Warn', 'Invoke-FirstTimeSetup')) { + $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name }, $true) + if (-not $fn) { throw "$name not found in install.ps1" } + . ([scriptblock]::Create($fn.Extent.Text)) +} +Invoke-FirstTimeSetup $env:PS_SETUP_STUB +EOF + + run script -qec "INSTALL_PS1_PATH='$INSTALL_PS1' PS_SETUP_STUB='$STUB_DIR/ps-setup-probe' PS_TTY_LOG='$PS_TTY_LOG' pwsh -NoProfile -File '$STUB_DIR/ps-first-time-tty-driver.ps1'" /dev/null + [[ "$status" -eq 0 ]] + [[ "$output" == *"SETUP_OUTPUT_VISIBLE"* ]] + [[ "$(cat "$PS_TTY_LOG")" == "stdin=tty stdout=tty stderr=tty" ]] + run grep -F '[void](Invoke-FirstTimeSetup' "$INSTALL_PS1" + [[ "$status" -ne 0 ]] +} + +@test "install.ps1 treats redirected stderr as non-interactive" { + [[ "$(uname -s)" == "Linux" ]] || skip "PTY stream reproduction requires Linux" + command -v pwsh >/dev/null 2>&1 || skip "pwsh not installed" + command -v script >/dev/null 2>&1 || skip "script is required" + + cat > "$STUB_DIR/ps-interactive-driver.ps1" <<'EOF' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" } +$fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Test-InteractiveSession' }, $true) +if (-not $fn) { throw 'Test-InteractiveSession not found in install.ps1' } +. ([scriptblock]::Create($fn.Extent.Text)) +"INTERACTIVE:$(Test-InteractiveSession)" +EOF + + run script -qec "INSTALL_PS1_PATH='$INSTALL_PS1' pwsh -NoProfile -File '$STUB_DIR/ps-interactive-driver.ps1' 2>'$STUB_DIR/ps-stderr.log'" /dev/null + [[ "$status" -eq 0 ]] + [[ "$output" == *"INTERACTIVE:False"* ]] +} + # The Windows canary can never prove the ps1 belt — Credential Manager works # headlessly with or without it — so pin the behavior here. The function under # test is extracted from install.ps1's AST and evaluated alone: Main never diff --git a/e2e/setup.bats b/e2e/setup.bats index 7e7aba7b..c455b13b 100644 --- a/e2e/setup.bats +++ b/e2e/setup.bats @@ -1,10 +1,10 @@ #!/usr/bin/env bats -# setup.bats - `basecamp setup` refuses to prompt when nothing can answer it. +# setup.bats - `basecamp setup` refuses when first-time setup cannot run safely. # -# The wizard is prompts end to end, and huh runs them as a bubbletea program. -# Redirecting stdin does not make that program fail: bubbletea sees a -# non-terminal stdin and opens /dev/tty instead, so the prompt waits on the real -# terminal — `basecamp setup --json < /dev/null` hung forever. +# Recommended setup opens browser OAuth, while `--customize` also uses huh +# prompts. Redirecting stdin does not make a bubbletea prompt fail: it can open +# /dev/tty instead and wait on the real terminal. The setup gate keeps both +# modes out of contexts that cannot complete them. # # Every case runs under a timeout, and the timeout is the assertion: exit 124 is # the bug reproducing. A unit test with a fake reader cannot catch this, because @@ -68,6 +68,42 @@ assert_refused() { assert_refused } +@test "setup --customize refuses under --json with redirected stdin" { + create_credentials + create_global_config '{"account_id": 99999}' + + run_guarded "basecamp setup --customize --json < /dev/null" + assert_refused +} + +@test "setup --minimal refuses under --json with redirected stdin" { + create_credentials + create_global_config '{"account_id": 99999}' + + run_guarded "basecamp setup --minimal --json < /dev/null" + assert_refused +} + +@test "setup --project gives non-interactive config guidance" { + create_credentials + create_global_config '{"account_id": 99999}' + + run_guarded "basecamp setup --project 123 --json < /dev/null" + assert_not_timed_out + assert_failure + assert_json_value '.code' 'usage' + assert_json_value '.hint | contains("basecamp config set project_id ")' 'true' + assert_json_value '.hint | contains("--customize")' 'false' +} + +@test "setup rejects an unknown subcommand before onboarding" { + run_guarded "basecamp setup codxe --json < /dev/null" + assert_not_timed_out + assert_failure + assert_json_value '.error | contains("unknown command")' 'true' + assert_json_value '.error | contains("interactive terminal")' 'false' +} + @test "setup without --json and stdin closed refuses instead of hanging" { create_credentials create_global_config '{"account_id": 99999}' diff --git a/install.md b/install.md index b77d28eb..6ab458d6 100644 --- a/install.md +++ b/install.md @@ -2,7 +2,7 @@ Install the Basecamp CLI and connect it to your AI agent. Execute all steps autonomously. -The piped installer (Step 1) already installs the baseline agent skill and, on a best-effort basis, attempts to connect a single detected coding agent. The optional "Connect Your Agent" section below is for forcing a specific agent, handling multiple detected agents, or verifying the connection. +On an interactive terminal, the installer runs the recommended setup after download: browser authentication, the OAuth-bound account (otherwise the existing configured account or first available account) saved globally, the global project default cleared, and every detected coding agent connected. Directory-specific and environment project settings continue to apply. Use `basecamp setup --customize` when you want to choose those settings. Without a usable controlling terminal, the installer installs the baseline agent skill and, on a best-effort basis, attempts to connect a single detected coding agent. **DONE WHEN:** `basecamp --version && basecamp auth status` both succeed. @@ -30,7 +30,7 @@ curl -fsSL https://basecamp.com/install-cli | bash irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex ``` -> **Note:** The install scripts auto-detect non-interactive environments (CI, piped input, coding agents) and skip the interactive setup wizard. In that case they still run `basecamp setup agents`, which installs the baseline agent skill and **attempts to connect** a single detected coding agent (best effort). If several agents are detected, or none is, only the baseline skill is installed and the per-agent commands are surfaced. Explicitly skipping the wizard with `BASECAMP_SKIP_SETUP=1` still runs `setup agents`. +> **Note:** The install scripts run `basecamp setup` whenever they can attach it to a usable interactive terminal, including the standard `curl | bash` command. When no usable controlling terminal is available, output is redirected, or `BASECAMP_NONINTERACTIVE=1`/`true` is set, they skip authentication and run `basecamp setup agents`. That command installs the baseline agent skill and **attempts to connect** a single detected coding agent (best effort). If several agents are detected, or none is, only the baseline skill is installed and the per-agent commands are surfaced. Explicitly skipping first-time setup with `BASECAMP_SKIP_SETUP=1` still runs `setup agents`. If optional first-time setup is cancelled or does not finish, the installed CLI remains ready and the installer prints the command to resume setup. > > Choose which agent to connect with `BASECAMP_SETUP_AGENT` (`claude`, `codex`, `all`, or `none`). Set it for the interpreter, not the fetch: > - Bash: `curl -fsSL https://basecamp.com/install-cli | BASECAMP_SETUP_AGENT=codex bash` @@ -77,7 +77,12 @@ nix profile install github:basecamp/basecamp-cli go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest ``` -### Option G: GitHub Release +### Option G: mise +```bash +mise use --global github:basecamp/basecamp-cli@latest +``` + +### Option H: GitHub Release Download the archive for your platform from [Releases](https://github.com/basecamp/basecamp-cli/releases), extract, and move `basecamp` to a directory on your PATH. **Verify:** @@ -96,17 +101,19 @@ export PATH="$HOME/go/bin:$PATH" ### Upgrading later -Run `basecamp upgrade`. Installer-script installs upgrade in place (Sigstore-verified download, transactional executable swap, post-upgrade version confirmation). Homebrew and Scoop installs delegate to their package manager, then verify the installed binary reports the new version. System packages (apt/dnf/apk, AUR, Nix) and `go install` builds are never touched — the command exits nonzero with upgrade guidance for that install method instead. Treat any nonzero exit as **not upgraded** and follow the hint in the error (`upgrade_required`, `upgrade_incomplete`, `upgrade_unverified`, or `upgrade_failed`). +Run `basecamp upgrade`. Installer-script installs upgrade in place (Sigstore-verified download, transactional executable swap, post-upgrade version confirmation). Homebrew and Scoop installs delegate to their package manager, then verify the installed binary reports the new version. System packages (apt/dnf/apk, AUR, Nix), mise, and `go install` builds are never touched — the command exits nonzero with upgrade guidance for that install method instead. Treat any nonzero exit as **not upgraded** and follow the hint in the error (`upgrade_required`, `upgrade_incomplete`, `upgrade_unverified`, or `upgrade_failed`). --- ## Step 2: Authenticate +Interactive installs authenticate during Step 1. If setup was skipped, did not finish, or the installer had no usable terminal, run: + ```bash basecamp auth login ``` -Opens browser for OAuth. Grant access when prompted. +This opens browser OAuth. Grant access when prompted. **Verify:** ```bash @@ -124,7 +131,7 @@ EXECUTE NOW: Start with Step 1. Mark TODO items complete as you go. Stop when `b **Do not execute this section unless explicitly requested.** The core installation is complete when DONE WHEN passes. -The piped installer (Step 1) already installs the baseline skill and attempts to connect a single detected agent. Run the commands here to force a specific agent, connect a second one, or when several agents were detected and the installer connected none. Both agent plugins require the `basecamp` CLI installed above — the plugin invokes it for every Basecamp operation. +Interactive setup in Step 1 connects every detected agent. Without a controlling terminal, the installer still installs the baseline skill and attempts to connect one detected agent. Run the commands here to force a specific agent, repair a connection, or connect agents that were not available during installation. Both agent plugins require the `basecamp` CLI installed above — the plugin invokes it for every Basecamp operation. ### Claude Code diff --git a/internal/appctx/context.go b/internal/appctx/context.go index 856eb550..29e569d0 100644 --- a/internal/appctx/context.go +++ b/internal/appctx/context.go @@ -36,6 +36,10 @@ type App struct { Names *names.Resolver Output *output.Writer + // SuppressPostRunNotices keeps maintenance notices out of a command's + // intentional final output. + SuppressPostRunNotices bool + // Observability Collector *observability.SessionCollector Hooks *observability.CLIHooks diff --git a/internal/cli/root.go b/internal/cli/root.go index 3ab3b7ef..fb95735c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -204,7 +204,7 @@ func NewRootCmd() *cobra.Command { app.Close() } if commands.RefreshSkillsIfVersionChanged() { - if app == nil || !app.IsMachineOutput() { + if postRunNoticesEnabled(app) { fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", version.Version) // One-time hint: if plugin/CLI version mismatch after upgrade, nudge the user @@ -218,7 +218,7 @@ func NewRootCmd() *cobra.Command { // Skip after upgrade (just acted on it) and doctor (has its own version check). if updateCheck != nil && cmd.Name() != "upgrade" && cmd.Name() != "doctor" { if notice := updateCheck.Notice(); notice != "" { - if app != nil && app.IsInteractive() && !app.IsMachineOutput() { + if app != nil && app.IsInteractive() && postRunNoticesEnabled(app) { fmt.Fprintln(os.Stderr, notice) } } @@ -285,6 +285,12 @@ func NewRootCmd() *cobra.Command { return cmd } +// postRunNoticesEnabled reports whether maintenance notices belong after the +// command's intentional output. +func postRunNoticesEnabled(app *appctx.App) bool { + return app == nil || (!app.IsMachineOutput() && !app.SuppressPostRunNotices) +} + // Execute runs the root command. func Execute() { cmd := NewRootCmd() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 81887b21..a0d34379 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -18,6 +18,20 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) +func TestPostRunNoticesEnabled(t *testing.T) { + assert.True(t, postRunNoticesEnabled(nil)) + + app := &appctx.App{} + assert.True(t, postRunNoticesEnabled(app)) + + app.SuppressPostRunNotices = true + assert.False(t, postRunNoticesEnabled(app)) + + app.SuppressPostRunNotices = false + app.Flags.JSON = true + assert.False(t, postRunNoticesEnabled(app)) +} + // TestBadLLMEndpointDoesNotBlockUnrelatedCommands is a regression test for // the startup-validation lockout: llm_endpoint is consumed only by the // dev-gated TUI's summarize path (which fail-closes via diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 56474201..849d4830 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -125,7 +125,7 @@ func CommandCategories() []CommandCategory { {Name: "logout", Category: "auth", Description: "Remove stored credentials"}, {Name: "config", Category: "auth", Description: "Manage configuration", Actions: []string{"show", "init", "set", "unset", "project", "trust", "untrust"}}, {Name: "me", Category: "auth", Description: "Show current user profile"}, - {Name: "setup", Category: "auth", Description: "Interactive first-time setup"}, + {Name: "setup", Category: "auth", Description: "First-time setup with recommended defaults"}, {Name: "quick-start", Category: "auth", Description: "Show getting started guide"}, {Name: "doctor", Category: "auth", Description: "Check CLI health and diagnose issues"}, {Name: "upgrade", Category: "auth", Description: "Upgrade to the latest version"}, diff --git a/internal/commands/quickstart.go b/internal/commands/quickstart.go index bd08f5ad..e076de1d 100644 --- a/internal/commands/quickstart.go +++ b/internal/commands/quickstart.go @@ -54,13 +54,14 @@ func NewQuickStartCmd() *cobra.Command { // RunQuickStartDefault is called when basecamp is run with no args. // If this is a first run (unauthenticated, interactive TTY, no BASECAMP_TOKEN), -// it runs the setup wizard. Non-interactive invocations (piped, non-TTY, or -// machine-output modes — whether flag-driven or config-driven) preserve the -// quick-start JSON envelope. Interactive TTY shows help. +// it runs setup with the recommended defaults. Non-interactive invocations +// (piped, non-TTY, or machine-output modes — whether flag-driven or +// config-driven) preserve the quick-start JSON envelope. Interactive TTY shows +// help. func RunQuickStartDefault(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) if app != nil && isFirstRun(app) { - return runWizard(cmd, app) + return runFastSetup(cmd, app, false) } if app != nil && (!app.IsInteractive() || app.IsMachineOutput()) { return runQuickStart(cmd, args) diff --git a/internal/commands/upgrade_selfupdate.go b/internal/commands/upgrade_selfupdate.go index 5ff99ade..1c1a056d 100644 --- a/internal/commands/upgrade_selfupdate.go +++ b/internal/commands/upgrade_selfupdate.go @@ -122,18 +122,24 @@ func resolveSelfUpdateTarget() (string, error) { // non-empty reason (and a reason-specific hint) when the target must not be // self-updated; empty reason means eligible. func selfUpdateIneligibility(target string) (reason, hint string) { + // Managed installs get method-specific guidance before the generic root and + // home-directory checks. Every branch still refuses self-mutation. + normalizedTarget := filepath.ToSlash(target) + if strings.HasPrefix(normalizedTarget, "/nix/store/") { + return "nix_store", + "This binary lives in the Nix store. Upgrade it the Nix way, e.g.: nix profile upgrade basecamp-cli (or update your flake pin)" + } + + if strings.Contains(normalizedTarget, "/installs/github-basecamp-basecamp-cli/") { + return "mise_install", + "This binary is managed by mise. Upgrade it with: mise use --global github:basecamp/basecamp-cli@latest" + } + if runtime.GOOS != "windows" && euidResolver() == 0 { return "running_as_root", "Re-run basecamp upgrade as the user who installed the CLI, or upgrade via your system package manager" } - // Nix gets its own message before the generic home test: /nix/store paths - // are immutable by design, and "outside your home" would mislead. - if strings.HasPrefix(filepath.ToSlash(target), "/nix/store/") { - return "nix_store", - "This binary lives in the Nix store. Upgrade it the Nix way, e.g.: nix profile upgrade basecamp-cli (or update your flake pin)" - } - home, err := homeDirResolver() if err != nil || home == "" || !pathWithin(home, target) { return "system_path", diff --git a/internal/commands/upgrade_selfupdate_test.go b/internal/commands/upgrade_selfupdate_test.go index b5d655af..3a8f2a7d 100644 --- a/internal/commands/upgrade_selfupdate_test.go +++ b/internal/commands/upgrade_selfupdate_test.go @@ -499,6 +499,15 @@ func TestSelfUpdateIneligibleNixStore(t *testing.T) { assert.Contains(t, hint, "Nix") } +func TestSelfUpdateIneligibleMiseInstall(t *testing.T) { + for _, euid := range []int{1000, 0} { + stubEuid(t, euid) + reason, hint := selfUpdateIneligibility("/home/user/.local/share/mise/installs/github-basecamp-basecamp-cli/0.9.1/basecamp") + assert.Equal(t, "mise_install", reason) + assert.Contains(t, hint, "mise use --global github:basecamp/basecamp-cli@latest") + } +} + func TestSelfUpdateIneligibleSiblingPrefixHomeEscape(t *testing.T) { stubEuid(t, 1000) base := t.TempDir() diff --git a/internal/commands/wizard.go b/internal/commands/wizard.go index 5cc611b0..64c86116 100644 --- a/internal/commands/wizard.go +++ b/internal/commands/wizard.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "charm.land/lipgloss/v2" @@ -15,11 +16,11 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/auth" + "github.com/basecamp/basecamp-cli/internal/config" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/stdinarg" "github.com/basecamp/basecamp-cli/internal/tui" "github.com/basecamp/basecamp-cli/internal/tui/resolve" - "github.com/basecamp/basecamp-cli/internal/version" ) // WizardResult holds the outcome of the first-run wizard, for showSuccess to @@ -27,25 +28,38 @@ import ( // structured envelope it once emitted is unreachable and nothing serializes // this. type WizardResult struct { - Status string // "complete" or "incomplete" - AccountID string - AccountName string - ProjectID string - ProjectName string - ConfigScope string // "global", "local", or "" if skipped + Status string // "complete" or "incomplete" + AuthenticatedAs string + AccountID string + AccountName string + ProjectID string + ProjectName string + ConfigScope string // "global", "local", or "" if skipped } -// NewSetupCmd creates the setup command (explicit wizard invocation). +// NewSetupCmd creates the setup command. func NewSetupCmd() *cobra.Command { + var customize bool + var minimal bool + cmd := &cobra.Command{ Use: "setup", - Short: "Interactive first-time setup", - Long: "Walk through authentication, account selection, project configuration, and coding agent integration.", + Short: "First-time setup with recommended defaults", + Args: cobra.NoArgs, + Long: "Authenticate with Basecamp, use the OAuth-bound or existing configured account, save it globally without a global project default, " + + "and connect detected coding agents. With neither account available, setup selects the first authorized account. " + + "Use --account to choose an account compatible with the login, --customize to choose each setting, or --minimal for a concise completion message.", RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) - return runWizard(cmd, app) + if customize { + return runWizard(cmd, app) + } + return runFastSetup(cmd, app, minimal) }, } + cmd.Flags().BoolVar(&customize, "customize", false, "Choose the account, default project, config scope, and coding agent setup") + cmd.Flags().BoolVar(&minimal, "minimal", false, "Show a concise completion message without starter commands") + cmd.MarkFlagsMutuallyExclusive("customize", "minimal") for _, sub := range newSetupAgentCmds() { cmd.AddCommand(sub) } @@ -53,7 +67,82 @@ func NewSetupCmd() *cobra.Command { return cmd } -// runWizard runs the interactive first-run setup wizard. +// runFastSetup authenticates and applies the recommended first-run defaults. +func runFastSetup(cmd *cobra.Command, app *appctx.App, minimal bool) error { + if app == nil { + return fmt.Errorf("app not initialized") + } + app.SuppressPostRunNotices = true + if app.Flags.JQFilter != "" { + return output.ErrJQNotSupported("the setup command") + } + if !setupCanRun(app) { + return output.ErrUsageHint("basecamp setup needs an interactive terminal", wizardEscapeHint()) + } + if app.Flags.Project != "" { + return output.ErrUsageHint("choosing a default project uses customized setup", "Run: basecamp setup --customize --project "+app.Flags.Project) + } + + styles := tui.NewStylesWithTheme(tui.ResolveTheme(tui.DetectDark())) + waitAnim := showWelcome(cmd.OutOrStdout(), styles) + waitAnim() + + authenticatedAs, err := wizardAuth(cmd, app, styles, false) + if err != nil { + return err + } + + accountID, accountName, err := automaticAccount(cmd, app) + if err != nil { + return err + } + if err := persistRecommendedDefaults(app, accountID); err != nil { + return err + } + + showFastAuthenticated(cmd.OutOrStdout(), styles, authenticatedAs, accountID, accountName) + + var agentOutcome agentSetupOutcome + err = tui.RunWithSpinner(cmd.OutOrStdout(), styles.Theme(), "Setting up AI coding agents...", func() error { + var setupErr error + agentOutcome, setupErr = automaticAgents(cmd, styles) + return setupErr + }) + if err != nil { + return err + } + showFastAgentStatus(cmd.OutOrStdout(), styles, agentOutcome) + + var omarchyOutcome omarchyPluginOutcome + if detectOmarchy() { + _ = tui.RunWithSpinner(cmd.OutOrStdout(), styles.Theme(), "Setting up Basecamp for Omarchy...", func() error { + omarchyOutcome = setupOmarchyPlugin(cmd.Context()) + return nil + }) + } + + if err := resolve.PersistValue("onboarded", "true", "global"); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to persist onboarding flag: %v\n", err) + } + + showFastCompletion(cmd.OutOrStdout(), styles, agentOutcome, omarchyOutcome, minimal) + return nil +} + +// persistRecommendedDefaults saves account-wide global defaults together. +// Directory and environment project settings remain persisted at their own +// higher-precedence sources and apply to later commands in those contexts. +func persistRecommendedDefaults(app *appctx.App, accountID string) error { + if err := resolve.PersistValues(map[string]string{"account_id": accountID}, []string{"project_id"}, "global"); err != nil { + return fmt.Errorf("saving the recommended defaults: %w", err) + } + app.Config.AccountID = accountID + app.Config.ProjectID = "" + delete(app.Config.Sources, "project_id") + return nil +} + +// runWizard runs the customizable first-run setup wizard. // It walks the user through authentication, account selection, and project selection. func runWizard(cmd *cobra.Command, app *appctx.App) error { if app == nil { @@ -72,7 +161,7 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { // The gate belongs to this RunE alone: `setup claude`, `setup codex` and // `setup agents` are the supported non-interactive paths and must keep // working, which a persistent hook here would have broken. - if !wizardCanRun(app) { + if !setupCanRun(app) { return output.ErrUsageHint("basecamp setup needs an interactive terminal", wizardEscapeHint()) } @@ -83,12 +172,13 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { waitAnim() // Step 2: Auth - if err := wizardAuth(cmd, app, styles); err != nil { + authenticatedAs, err := wizardAuth(cmd, app, styles, true) + if err != nil { return err } // Step 3: Account selection - result := WizardResult{Status: "complete"} + result := WizardResult{Status: "complete", AuthenticatedAs: authenticatedAs} accountID, err := wizardAccount(cmd, app, styles) if err != nil { return err @@ -126,6 +216,17 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { } result.Status = statusFromOutcome(agentOutcome) + omarchyOutcome := setupOmarchyPlugin(cmd.Context()) + if omarchyOutcome.Detected { + fmt.Fprintln(w, styles.Heading.Render(" Omarchy Integration")) + fmt.Fprintln(w) + showOmarchyPluginStatus(w, styles, omarchyOutcome) + fmt.Fprintln(w) + if omarchyOutcome.failed() { + result.Status = "incomplete" + } + } + // Persist onboarded flag (always global so it applies everywhere) if err := resolve.PersistValue("onboarded", "true", "global"); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to persist onboarding flag: %v\n", err) @@ -137,35 +238,57 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { // structured-envelope branch that used to sit here was reachable only under // machine output, which the gate now refuses; it and its two helpers went // with it rather than being kept alive for nobody. - showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Checks, agentOutcome.Issues, agentOutcome.Skipped) + showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Checks, agentOutcome.Issues, agentOutcome.Skipped, omarchyOutcome) return nil } -// wizardEscapeHint names the non-interactive paths that cover what the wizard -// would have prompted for, rather than restating that a terminal is missing. -// Modeled on stdinEscapeHint: point at the real alternatives. +// wizardEscapeHint names the non-interactive paths that cover setup work, +// rather than restating that a terminal is missing. Modeled on stdinEscapeHint: +// point at the real alternatives. func wizardEscapeHint() string { return "Agent setup runs without a terminal: basecamp setup agents (or basecamp setup claude / basecamp setup codex). " + - "Set the defaults the wizard would ask for with basecamp config set account_id (or basecamp accounts use ) and basecamp config set project_id . " + + "Set defaults directly with basecamp config set account_id (or basecamp accounts use ) and basecamp config set project_id . " + "Check authentication with basecamp auth status." } -// showWelcome displays the welcome screen with animated logo. -// Returns a wait function that must be called before interactive prompts. +// showWelcome displays the welcome screen with the current snowglobe mark. // All output goes to w so the command fully honors its output writer. func showWelcome(w io.Writer, styles *tui.Styles) func() { - aw, waitAnim := tui.AnimateWordmarkAsync(w, styles.Theme()) - fmt.Fprintln(aw) - fmt.Fprintln(aw, styles.Title.Render("Welcome to Basecamp")) - fmt.Fprintln(aw) - fmt.Fprintln(aw, styles.Body.Render(fmt.Sprintf("The command-line interface for Basecamp (v%s).", version.Version))) - fmt.Fprintln(aw, styles.Body.Render("Let's get you set up. This will only take a moment.")) - fmt.Fprintln(aw) - return waitAnim -} - -// wizardAuth handles the authentication flow. -func wizardAuth(cmd *cobra.Command, app *appctx.App, styles *tui.Styles) error { + fmt.Fprintln(w) + fmt.Fprintln(w, tui.RenderSnowglobe(styles.Theme())) + fmt.Fprintln(w) + fmt.Fprintln(w, styles.Title.Render("Basecamp at your command (line).")) + fmt.Fprintln(w, styles.Body.Render("Let's get you set up. It’ll only take a moment.")) + fmt.Fprintln(w) + return func() {} +} + +func showAuthenticationStart(w io.Writer, styles *tui.Styles, stepByStep bool) string { + if !stepByStep { + return "" + } + + fmt.Fprintln(w, styles.Heading.Render(" Step 1: Authentication")) + fmt.Fprintln(w) + return " " +} + +func authenticationLogger(w io.Writer, prefix string) func(string) { + launchpadOpeningShown := false + return func(message string) { + if strings.HasPrefix(message, "Authenticating via launchpad (") { + message = "Opening browser for Basecamp login..." + launchpadOpeningShown = true + } else if launchpadOpeningShown && strings.TrimSpace(message) == "Opening browser for authentication..." { + return + } + fmt.Fprintln(w, prefix+message) + } +} + +// wizardAuth handles authentication. showResult enables the step-by-step +// presentation and renders the authenticated identity immediately. +func wizardAuth(cmd *cobra.Command, app *appctx.App, styles *tui.Styles, showResult bool) (string, error) { w := cmd.OutOrStdout() if app.Auth.IsAuthenticated() { @@ -178,35 +301,31 @@ func wizardAuth(cmd *cobra.Command, app *appctx.App, styles *tui.Styles) error { FilterProduct: "bc3", }) } - if epErr == nil && err == nil { - name := fmt.Sprintf("%s %s", info.Identity.FirstName, info.Identity.LastName) - fmt.Fprintln(w, styles.Success.Render(fmt.Sprintf(" Logged in as %s (%s)", name, info.Identity.EmailAddress))) - if len(info.Accounts) > 0 { - var lines string - for _, acct := range info.Accounts { - lines += fmt.Sprintf(" • %s\n", acct.Name) - } - fmt.Fprint(w, styles.Muted.Render(lines)) + if showResult { + if epErr == nil && err == nil { + name := strings.TrimSpace(fmt.Sprintf("%s %s", info.Identity.FirstName, info.Identity.LastName)) + fmt.Fprintln(w, styles.Success.Render(fmt.Sprintf(" Logged in as %s (%s)", name, info.Identity.EmailAddress))) + } else { + fmt.Fprintln(w, styles.Success.Render(" Already authenticated.")) } - } else { - fmt.Fprintln(w, styles.Success.Render(" Already authenticated.")) + fmt.Fprintln(w) } - fmt.Fprintln(w) - return nil + if epErr == nil && err == nil { + return identityLabel(info.Identity.FirstName, info.Identity.LastName, info.Identity.EmailAddress), nil + } + return app.Auth.GetUserEmail(), nil } - fmt.Fprintln(w, styles.Heading.Render(" Step 1: Authentication")) - fmt.Fprintln(w) - fmt.Fprintln(w, styles.Muted.Render(" Opening browser for Basecamp login...")) - fmt.Fprintln(w) - + loggerPrefix := showAuthenticationStart(w, styles, showResult) result, err := app.Auth.Login(cmd.Context(), auth.LoginOptions{ - Logger: func(msg string) { fmt.Fprintln(w, " "+msg) }, + Logger: authenticationLogger(w, loggerPrefix), }) if err != nil { - return fmt.Errorf("authentication failed: %w", err) + return "", fmt.Errorf("authentication failed: %w", err) } + authenticatedAs := app.Auth.GetUserEmail() + // Try to fetch user profile for a friendly greeting resp, profileErr := app.SDK.Get(cmd.Context(), "/my/profile.json") if profileErr == nil { @@ -217,18 +336,168 @@ func wizardAuth(cmd *cobra.Command, app *appctx.App, styles *tui.Styles) error { } if err := resp.UnmarshalData(&profile); err == nil { _ = app.Auth.SetUserIdentity(fmt.Sprintf("%d", profile.ID), profile.Email) - fmt.Fprintln(w, styles.Success.Render(fmt.Sprintf(" Logged in as %s.", profile.Name))) + authenticatedAs = strings.TrimSpace(profile.Name) + if authenticatedAs == "" { + authenticatedAs = profile.Email + } + if showResult { + fmt.Fprintln(w, styles.Success.Render(fmt.Sprintf(" Logged in as %s.", profile.Name))) + } } - } else { + } else if showResult { fmt.Fprintln(w, styles.Success.Render(" Authentication successful.")) } - if result.Scope != "" { + if showResult && result.Scope != "" { fmt.Fprintln(w, styles.Muted.Render(fmt.Sprintf(" Access: %s", result.Scope))) } - fmt.Fprintln(w) + if showResult { + fmt.Fprintln(w) + } - return nil + return authenticatedAs, nil +} + +// identityLabel returns the most useful concise identity available. +func identityLabel(firstName, lastName, email string) string { + name := strings.TrimSpace(strings.TrimSpace(firstName) + " " + strings.TrimSpace(lastName)) + if name != "" { + return name + } + return strings.TrimSpace(email) +} + +// automaticAccount selects an explicit account, the OAuth-bound account, an +// existing configured account, or the first authorized account in that order. +func automaticAccount(cmd *cobra.Command, app *appctx.App) (string, string, error) { + explicitAccountID := app.Flags.Account + boundAccountID := app.Auth.AccountID() + configuredAccountID := app.Config.AccountID + configuredSource := app.Config.Sources["account_id"] + + if explicitAccountID == "" && boundAccountID != "" && configuredAccountID != "" && + configuredAccountOverridesGlobal(configuredSource) && !accountIDsEqual(boundAccountID, configuredAccountID) { + return "", "", configuredAccountMismatchError(configuredSource, configuredAccountID, boundAccountID) + } + + var accounts []basecamp.AuthorizedAccount + if boundAccountID == "" { + var err error + accounts, err = app.Resolve().ListAccounts(cmd.Context()) + if err != nil { + return "", "", err + } + } + + accountID, accountName, err := chooseAutomaticAccount(explicitAccountID, boundAccountID, configuredAccountID, accounts) + if err != nil { + return "", "", err + } + if accountName == "" { + accountName = fetchAccountName(cmd, app, accountID) + } + + app.Config.AccountID = accountID + if err := app.RequireAccount(); err != nil { + return "", "", err + } + app.Names.SetAccountID(accountID) + return accountID, accountName, nil +} + +// chooseAutomaticAccount preserves explicit intent while honoring the account +// boundary carried by OAuth credentials. Unbound credentials validate explicit +// and configured candidates against the authorization service before setup +// saves them. +func chooseAutomaticAccount(explicitAccountID, boundAccountID, configuredAccountID string, accounts []basecamp.AuthorizedAccount) (string, string, error) { + if explicitAccountID != "" { + explicitAccountID, err := canonicalAutomaticAccountID(explicitAccountID) + if err != nil { + return "", "", err + } + if boundAccountID != "" { + boundAccountID, err = canonicalAutomaticAccountID(boundAccountID) + if err != nil { + return "", "", err + } + if explicitAccountID != boundAccountID { + return "", "", output.ErrUsageHint( + fmt.Sprintf("account %s does not match the OAuth-bound account %s", explicitAccountID, boundAccountID), + "Use --account "+boundAccountID+" or authenticate for the requested account.", + ) + } + return boundAccountID, "", nil + } + return authorizedAutomaticAccount(explicitAccountID, accounts) + } + if boundAccountID != "" { + boundAccountID, err := canonicalAutomaticAccountID(boundAccountID) + if err != nil { + return "", "", err + } + return boundAccountID, "", nil + } + if configuredAccountID != "" { + configuredAccountID, err := canonicalAutomaticAccountID(configuredAccountID) + if err != nil { + return "", "", err + } + return authorizedAutomaticAccount(configuredAccountID, accounts) + } + if len(accounts) == 0 { + return "", "", output.ErrNotFound("account", "any") + } + return fmt.Sprintf("%d", accounts[0].ID), accounts[0].Name, nil +} + +func authorizedAutomaticAccount(accountID string, accounts []basecamp.AuthorizedAccount) (string, string, error) { + for _, account := range accounts { + if fmt.Sprintf("%d", account.ID) == accountID { + return accountID, account.Name, nil + } + } + return "", "", output.ErrNotFound("account", accountID) +} + +func canonicalAutomaticAccountID(accountID string) (string, error) { + for _, char := range accountID { + if char < '0' || char > '9' { + return "", output.ErrUsage(fmt.Sprintf("Invalid account ID %q: must contain only digits", accountID)) + } + } + value, err := strconv.ParseInt(accountID, 10, 64) + if accountID == "" || err != nil { + return "", output.ErrUsage(fmt.Sprintf("Invalid account ID %q", accountID)) + } + return strconv.FormatInt(value, 10), nil +} + +func accountIDsEqual(first, second string) bool { + first, firstErr := canonicalAutomaticAccountID(first) + second, secondErr := canonicalAutomaticAccountID(second) + return firstErr == nil && secondErr == nil && first == second +} + +func configuredAccountOverridesGlobal(source string) bool { + switch config.Source(source) { + case config.SourceFlag, config.SourceEnv, config.SourceLocal, config.SourceRepo: + return true + case config.SourceDefault, config.SourceSystem, config.SourceGlobal, config.SourcePrompt: + return false + default: + return source == "profile" + } +} + +func configuredAccountMismatchError(source, configuredAccountID, boundAccountID string) error { + hint := fmt.Sprintf("Set or remove account_id in the %s configuration before running setup again.", source) + if source == string(config.SourceEnv) { + hint = "Unset BASECAMP_ACCOUNT_ID or set it to " + boundAccountID + " before running setup again." + } + return output.ErrUsageHint( + fmt.Sprintf("the %s account %s does not match the OAuth-bound account %s", source, configuredAccountID, boundAccountID), + hint, + ) } // wizardAccount resolves the account using the existing interactive picker. @@ -356,14 +625,107 @@ func successHeadline(status string, issueCount int) string { return fmt.Sprintf("Setup finished — %d steps need attention", issueCount) } -// showSuccess displays the completion summary with example commands. checks is -// the agent-health snapshot rendered as the checklist; issues holds every -// unresolved problem — snapshot failures plus standalone setup failures such as -// the baseline skill install or surviving stale entries — and drives the -// headline and remediation. When the user skipped agent setup the checks are -// reported as skipped rather than as failures, so the summary never shows red -// checks under a "complete" headline. -func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks []agentCheck, issues []agentIssue, skipped bool) { +// showFastAuthenticated displays the authenticated identity and selected account. +func showFastAuthenticated(w io.Writer, styles *tui.Styles, authenticatedAs, accountID, accountName string) { + authLabel := "Authenticated" + if authenticatedAs != "" { + authLabel += " as " + authenticatedAs + } + fmt.Fprintln(w, styles.RenderStatus(true, authLabel)) + accountLabel := accountName + if accountLabel == "" { + accountLabel = accountID + } + if accountLabel != "" { + fmt.Fprintln(w, styles.RenderStatus(true, "Using account "+accountLabel)) + } +} + +// showFastAgentStatus displays the durable result that replaces the coding-agent spinner. +func showFastAgentStatus(w io.Writer, styles *tui.Styles, agents agentSetupOutcome) { + switch { + case len(agents.Issues) > 0: + fmt.Fprintln(w, styles.RenderStatus(false, "AI coding agents need attention — run: basecamp doctor")) + case agents.Detected == 0: + fmt.Fprintln(w, styles.Muted.Render(" AI coding agents: none detected")) + default: + fmt.Fprintln(w, styles.RenderStatus(true, "AI coding agents set up")) + } +} + +// showFastCompletion displays the combined integration result and setup next steps. +func showFastCompletion(w io.Writer, styles *tui.Styles, agents agentSetupOutcome, omarchy omarchyPluginOutcome, minimal bool) { + if omarchy.Detected { + showOmarchyPluginStatus(w, styles, omarchy) + } + fmt.Fprintln(w) + + if minimal { + title := "SETUP COMPLETE" + if len(agents.Issues) > 0 || omarchy.failed() { + title = "SETUP NEEDS ATTENTION" + } + fmt.Fprintln(w, fastSetupTitleStyle(styles).Render(title)) + fmt.Fprintln(w) + return + } + showFastSetupExamples(w, styles) +} + +func showOmarchyPluginStatus(w io.Writer, styles *tui.Styles, outcome omarchyPluginOutcome) { + switch outcome.Status { + case "installed": + fmt.Fprintln(w, styles.RenderStatus(true, "Basecamp plugin installed for Omarchy")) + case "ready": + fmt.Fprintln(w, styles.RenderStatus(true, "Basecamp plugin ready for Omarchy")) + case "failed": + message := "Basecamp plugin setup needs attention" + if outcome.Manual != "" { + message += " — run: " + outcome.Manual + } + fmt.Fprintln(w, styles.RenderStatus(false, message)) + } +} + +func fastSetupTitleStyle(styles *tui.Styles) lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(styles.Theme().Primary) +} + +// showFastSetupExamples prints account-wide commands after recommended setup +// clears the active and global default project. +func showFastSetupExamples(w io.Writer, styles *tui.Styles) { + titleStyle := fastSetupTitleStyle(styles) + descStyle := lipgloss.NewStyle().Italic(true) + examples := []struct{ cmd, desc string }{ + {"basecamp projects list", "List your projects"}, + {"basecamp assignments", "View your assignments"}, + {"basecamp timeline", "See recent activity"}, + {`basecamp search "quarterly planning"`, "Search across Basecamp"}, + } + + fmt.Fprintln(w, titleStyle.Render("Try it out!")) + fmt.Fprintln(w) + + width := 0 + for _, example := range examples { + width = max(width, len(example.cmd)) + } + for _, example := range examples { + fmt.Fprintf(w, "%s%s %s\n", + example.cmd, + strings.Repeat(" ", width-len(example.cmd)), + descStyle.Render(example.desc), + ) + } + fmt.Fprintln(w) +} + +// showSuccess displays the customizable setup summary with example commands. +// checks is the agent-health snapshot rendered as the checklist; issues holds +// every unresolved problem and drives the headline and remediation. When the +// user skipped agent setup the checks are reported as skipped rather than as +// failures. +func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks []agentCheck, issues []agentIssue, skipped bool, omarchy omarchyPluginOutcome) { divider := styles.Muted.Render("─────────────────────────────────") headlineStyle := styles.Success @@ -371,8 +733,13 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks [] headlineStyle = styles.Warning } + issueCount := len(issues) + if omarchy.failed() { + issueCount++ + } + fmt.Fprintln(w, divider) - fmt.Fprintln(w, headlineStyle.Render(" "+successHeadline(result.Status, len(issues)))) + fmt.Fprintln(w, headlineStyle.Render(" "+successHeadline(result.Status, issueCount))) fmt.Fprintln(w, divider) fmt.Fprintln(w) @@ -398,12 +765,15 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks [] fmt.Fprintln(w, styles.RenderStatus(check.Status == "pass", check.Name)) } } + if omarchy.Detected { + showOmarchyPluginStatus(w, styles, omarchy) + } fmt.Fprintln(w) // Remediation for anything that did not complete. Each issue carries its own - // check's hint, so guidance stays specific to the failing agent instead of - // hardcoding one agent's commands. - if len(issues) > 0 { + // check's hint, so guidance stays agent-specific; Omarchy carries the exact + // plugin command that completes its setup. + if len(issues) > 0 || omarchy.failed() { fmt.Fprintln(w, styles.Body.Render(" Some steps need attention:")) for _, issue := range issues { // Check names usually already carry the agent (e.g. "Claude Code @@ -419,7 +789,16 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks [] } fmt.Fprintln(w, styles.Warning.Render(line)) } - fmt.Fprintln(w, styles.Muted.Render(" Then verify with: basecamp doctor")) + if omarchy.failed() { + line := " Omarchy — " + omarchy.Detail + if omarchy.Manual != "" { + line += ": " + omarchy.Manual + } + fmt.Fprintln(w, styles.Warning.Render(line)) + } + if len(issues) > 0 { + fmt.Fprintln(w, styles.Muted.Render(" Then verify coding agents with: basecamp doctor")) + } fmt.Fprintln(w) } @@ -478,31 +857,24 @@ func fetchProjectName(cmd *cobra.Command, app *appctx.App, projectID string) str return project.Name } -// wizardCanRun reports whether the interactive wizard can actually be shown. +// setupCanRun reports whether human first-time setup can run safely. // -// The wizard is prompts end to end, and redirecting stdin does not skip one — -// bubbletea falls back to /dev/tty and waits on the real terminal (see -// tui.ErrNotInteractive). Asking a caller who requested machine output to answer -// a question is the same mistake with a terminal attached. Three checks, because -// no one of them sees everything: IsInteractive covers non-terminal -// stdin/stdout, the machine-output flags and the BASECAMP_NONINTERACTIVE escape -// hatch; IsMachineOutput adds the config-driven json/quiet formats it does not -// look at (standing down when an explicit --styled/--md overrides them, since -// ApplyFlags renders those human); InteractivePrompt adds stderr, which is -// where huh actually draws. +// Recommended setup opens browser OAuth, while customized setup also uses huh +// prompts that draw to stderr. The combined checks keep setup in human-output +// terminal contexts: IsInteractive covers stdin/stdout, flags, and +// BASECAMP_NONINTERACTIVE; IsMachineOutput adds config-driven json/quiet formats +// while honoring explicit --styled/--md overrides; InteractivePrompt adds stderr. // -// Two callers, deliberately different responses. `basecamp setup` was asked for -// by name, so it refuses out loud. Bare `basecamp` never asked for a wizard at -// all, so isFirstRun simply declines to start one and the caller falls through -// to help or the quick-start envelope — answering a question the user did not -// ask with an error about a command they did not type. -func wizardCanRun(app *appctx.App) bool { +// Two callers deliberately respond differently. Explicit `basecamp setup` +// refuses out loud. Bare `basecamp` quietly declines first-time setup and falls +// through to help or the quick-start envelope. +func setupCanRun(app *appctx.App) bool { return app.IsInteractive() && !app.IsMachineOutput() && stdinarg.InteractivePrompt() } // isFirstRun returns true if this appears to be a first-time run. // Checks: onboarded flag, stored credentials, BASECAMP_TOKEN env, and whether -// the wizard could be shown at all. +// first-time setup can run safely. func isFirstRun(app *appctx.App) bool { if app.Config.Onboarded != nil && *app.Config.Onboarded { return false @@ -513,5 +885,5 @@ func isFirstRun(app *appctx.App) bool { if os.Getenv("BASECAMP_TOKEN") != "" { return false } - return wizardCanRun(app) + return setupCanRun(app) } diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index d581ddd4..fb5c3fb1 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -59,9 +59,10 @@ type agentIssue struct { // authoritative for the wizard's completion status; Skipped is metadata only (a // deliberate skip records no issues, so it stays "complete"). type agentSetupOutcome struct { - Skipped bool - Checks []agentCheck - Issues []agentIssue + Skipped bool + Detected int + Checks []agentCheck + Issues []agentIssue } // snapshotAgentChecks captures every check across the given agents in one pass. @@ -249,8 +250,32 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { return nil } +// automaticAgents sets up every detected coding agent without asking questions. +func automaticAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, error) { + agents := harness.DetectedAgents() + if len(agents) == 0 { + return agentSetupOutcome{}, nil + } + + preChecks := snapshotAgentChecks(agents) + if detectedAgentsReady(preChecks) { + return agentSetupOutcome{Detected: len(agents), Checks: preChecks}, nil + } + + return installDetectedAgentsQuietly(cmd, styles, agents) +} + +// installDetectedAgentsQuietly suppresses setup chatter while preserving the +// post-install health result for the final summary. +func installDetectedAgentsQuietly(cmd *cobra.Command, styles *tui.Styles, agents []harness.AgentInfo) (agentSetupOutcome, error) { + quietCmd := &cobra.Command{} + quietCmd.SetContext(cmd.Context()) + quietCmd.SetOut(io.Discard) + quietCmd.SetErr(io.Discard) + return installDetectedAgents(quietCmd, styles, agents) +} + // wizardAgents offers to set up detected coding agents. -// Replaces the old wizardClaude() — works for any registered agent. func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, error) { agents := harness.DetectedAgents() if len(agents) == 0 { @@ -258,40 +283,31 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er } w := cmd.OutOrStdout() - - // One pre-setup snapshot drives both the all-good gate below and the checklist - // rendered in the summary for the paths that do not run setup. preChecks := snapshotAgentChecks(agents) - - // Check if all detected agents are already fully set up - // (agent checks pass AND baseline skill is installed) - allGood := baselineSkillInstalled() && len(harness.StalePluginKeys()) == 0 && len(issuesFromChecks(preChecks)) == 0 - if allGood { - for _, a := range agents { - fmt.Fprintln(w, styles.RenderStatus(true, a.Name+" plugin installed")) + if detectedAgentsReady(preChecks) { + for _, agent := range agents { + fmt.Fprintln(w, styles.RenderStatus(true, agent.Name+" plugin installed")) } fmt.Fprintln(w) - return agentSetupOutcome{Checks: preChecks}, nil + return agentSetupOutcome{Detected: len(agents), Checks: preChecks}, nil } fmt.Fprintln(w, styles.Heading.Render(" Step 5: Coding Agent Setup")) fmt.Fprintln(w) - // Show detected agents var names []string - for _, a := range agents { - names = append(names, a.Name) + for _, agent := range agents { + names = append(names, agent.Name) } fmt.Fprintln(w, styles.Body.Render(fmt.Sprintf(" Detected: %s", joinNames(names)))) fmt.Fprintln(w) - // Build numbered list of what will happen fmt.Fprintln(w, styles.Body.Render(" This will:")) step := 1 fmt.Fprintln(w, styles.Muted.Render(fmt.Sprintf(" %d. Install Basecamp agent skill to ~/.agents/skills/basecamp/", step))) step++ - for _, a := range agents { - handler, ok := agentSetupHandlers[a.ID] + for _, agent := range agents { + handler, ok := agentSetupHandlers[agent.ID] if !ok { continue } @@ -309,34 +325,39 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er if confirmErr != nil || !install { fmt.Fprintln(w) fmt.Fprintln(w, styles.Muted.Render(" You can set up agents later:")) - for _, a := range agents { - if _, ok := agentSetupHandlers[a.ID]; ok { - fmt.Fprintln(w, styles.Bold.Render(fmt.Sprintf(" basecamp setup %s", a.ID))) + for _, agent := range agents { + if _, ok := agentSetupHandlers[agent.ID]; ok { + fmt.Fprintln(w, styles.Bold.Render(fmt.Sprintf(" basecamp setup %s", agent.ID))) } } fmt.Fprintln(w) - // Skipped carries the current snapshot for the checklist but records no - // issues, so a deliberate skip stays "complete". - return agentSetupOutcome{Skipped: true, Checks: preChecks}, nil + return agentSetupOutcome{Skipped: true, Detected: len(agents), Checks: preChecks}, nil } fmt.Fprintln(w) + return installDetectedAgents(cmd, styles, agents) +} +// detectedAgentsReady reports whether every detected integration and the shared +// skill are ready to use. +func detectedAgentsReady(checks []agentCheck) bool { + return baselineSkillInstalled() && len(harness.StalePluginKeys()) == 0 && len(issuesFromChecks(checks)) == 0 +} + +// installDetectedAgents installs the shared skill and every supplied agent integration. +func installDetectedAgents(cmd *cobra.Command, styles *tui.Styles, agents []harness.AgentInfo) (agentSetupOutcome, error) { + w := cmd.OutOrStdout() var issues []agentIssue - // Install baseline skill (always, for any agent) if _, err := installSkillFiles(); err != nil { fmt.Fprintln(w, styles.Warning.Render(fmt.Sprintf(" Skill install failed: %s", err))) - // Not "basecamp setup": this runs inside it, so that advice is circular. - // `setup agents` retries exactly the step that failed, and needs no terminal. issues = append(issues, agentIssue{Check: "Agent skill", Hint: "Run: basecamp setup agents"}) } else { fmt.Fprintln(w, styles.RenderStatus(true, "Agent skill installed")) } - // Run each detected agent's handler - for _, a := range agents { - handler, ok := agentSetupHandlers[a.ID] + for _, agent := range agents { + handler, ok := agentSetupHandlers[agent.ID] if !ok { continue } @@ -345,21 +366,12 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er } } - // Re-snapshot the agents after setup ran so failed installs (e.g. a plugin - // that could not be cloned) surface as issues rather than a silent "complete". - // The same snapshot renders the summary checklist, so status and checklist - // can never disagree. postChecks := snapshotAgentChecks(agents) issues = append(issues, issuesFromChecks(postChecks)...) - - // Post-condition: the all-good gate also requires stale-plugin cleanup, but a - // current-plugin health check can pass while stale entries survive (removal - // failed). Re-check so leftover stale keys mark the run incomplete instead of - // reporting "complete". issues = append(issues, claudeStaleIssues()...) fmt.Fprintln(w) - return agentSetupOutcome{Checks: postChecks, Issues: issues}, nil + return agentSetupOutcome{Detected: len(agents), Checks: postChecks, Issues: issues}, nil } // claudeStaleIssues reports leftover stale plugin entries as an issue so the diff --git a/internal/commands/wizard_omarchy.go b/internal/commands/wizard_omarchy.go new file mode 100644 index 00000000..676e4322 --- /dev/null +++ b/internal/commands/wizard_omarchy.go @@ -0,0 +1,115 @@ +package commands + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + omarchyBasecampPluginID = "37signals.basecamp" + omarchyBasecampPluginSource = "https://github.com/basecamp/omarchy-basecamp-plugin.git" + omarchySetupTimeout = time.Minute +) + +type omarchyPluginOutcome struct { + Detected bool + Status string + Detail string + Manual string +} + +func (o omarchyPluginOutcome) failed() bool { + return o.Status == "failed" +} + +func detectOmarchy() bool { + if os.Getenv("OMARCHY_PATH") != "" { + return true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return false + } + info, err := os.Stat(filepath.Join(home, ".local", "state", "omarchy")) + return err == nil && info.IsDir() +} + +func setupOmarchyPlugin(ctx context.Context) omarchyPluginOutcome { + if !detectOmarchy() { + return omarchyPluginOutcome{} + } + return ensureOmarchyPlugin(ctx, runOmarchySetupCommand) +} + +type omarchySetupRunner func(context.Context, ...string) (string, error) + +func ensureOmarchyPlugin(ctx context.Context, run omarchySetupRunner) omarchyPluginOutcome { + outcome := omarchyPluginOutcome{Detected: true} + ctx, cancel := context.WithTimeout(ctx, omarchySetupTimeout) + defer cancel() + + output, err := run(ctx, "plugin", "list", "--json") + if err != nil { + outcome.Status = "failed" + outcome.Detail = "could not inspect installed plugins" + outcome.Manual = "omarchy plugin list --json" + return outcome + } + + var plugins []struct { + ID string `json:"id"` + } + trimmed := strings.TrimSpace(output) + if !strings.HasPrefix(trimmed, "[") || json.Unmarshal([]byte(trimmed), &plugins) != nil { + outcome.Status = "failed" + outcome.Detail = "could not read the installed plugin list" + outcome.Manual = "omarchy plugin list --json" + return outcome + } + + installed := false + for _, plugin := range plugins { + if plugin.ID == omarchyBasecampPluginID { + installed = true + break + } + } + + if installed { + if _, err := run(ctx, "plugin", "update", omarchyBasecampPluginID, "--yes"); err != nil { + outcome.Status = "failed" + outcome.Detail = "could not update the Basecamp plugin" + outcome.Manual = "omarchy plugin update " + omarchyBasecampPluginID + return outcome + } + outcome.Status = "ready" + return outcome + } + + if _, err := run(ctx, "plugin", "add", omarchyBasecampPluginSource, "--enable", "--yes"); err != nil { + outcome.Status = "failed" + outcome.Detail = "could not install the Basecamp plugin" + outcome.Manual = "omarchy plugin add " + omarchyBasecampPluginSource + " --enable" + return outcome + } + outcome.Status = "installed" + return outcome +} + +func runOmarchySetupCommand(ctx context.Context, args ...string) (string, error) { + path, err := exec.LookPath("omarchy") + if err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, path, args...) //nolint:gosec // executable is resolved from the fixed omarchy command + output, err := cmd.CombinedOutput() + if ctxErr := ctx.Err(); ctxErr != nil { + return string(output), ctxErr + } + return string(output), err +} diff --git a/internal/commands/wizard_omarchy_test.go b/internal/commands/wizard_omarchy_test.go new file mode 100644 index 00000000..de98f377 --- /dev/null +++ b/internal/commands/wizard_omarchy_test.go @@ -0,0 +1,96 @@ +package commands + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectOmarchy(t *testing.T) { + t.Run("OMARCHY_PATH", func(t *testing.T) { + t.Setenv("OMARCHY_PATH", t.TempDir()) + assert.True(t, detectOmarchy()) + }) + + t.Run("state directory", func(t *testing.T) { + t.Setenv("OMARCHY_PATH", "") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".local", "state", "omarchy"), 0o755)) + assert.True(t, detectOmarchy()) + }) + + t.Run("not detected", func(t *testing.T) { + t.Setenv("OMARCHY_PATH", "") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + assert.False(t, detectOmarchy()) + }) +} + +func TestEnsureOmarchyPluginInstallsMissingPlugin(t *testing.T) { + var calls []string + run := func(_ context.Context, args ...string) (string, error) { + calls = append(calls, strings.Join(args, " ")) + if len(calls) == 1 { + return `[{"id":"37signals.hey","enabled":true}]`, nil + } + return "installed", nil + } + + outcome := ensureOmarchyPlugin(context.Background(), run) + assert.Equal(t, "installed", outcome.Status) + assert.Equal(t, []string{ + "plugin list --json", + "plugin add " + omarchyBasecampPluginSource + " --enable --yes", + }, calls) +} + +func TestEnsureOmarchyPluginRefreshesInstalledPlugin(t *testing.T) { + var calls []string + run := func(_ context.Context, args ...string) (string, error) { + calls = append(calls, strings.Join(args, " ")) + if len(calls) == 1 { + return `[{"id":"37signals.basecamp","enabled":false}]`, nil + } + return "updated", nil + } + + outcome := ensureOmarchyPlugin(context.Background(), run) + assert.Equal(t, "ready", outcome.Status) + assert.Equal(t, []string{ + "plugin list --json", + "plugin update " + omarchyBasecampPluginID + " --yes", + }, calls) +} + +func TestEnsureOmarchyPluginReportsRemediation(t *testing.T) { + t.Run("unexpected list", func(t *testing.T) { + outcome := ensureOmarchyPlugin(context.Background(), func(_ context.Context, _ ...string) (string, error) { + return `{"plugins":[]}`, nil + }) + assert.True(t, outcome.failed()) + assert.Equal(t, "omarchy plugin list --json", outcome.Manual) + }) + + t.Run("update failure", func(t *testing.T) { + calls := 0 + outcome := ensureOmarchyPlugin(context.Background(), func(_ context.Context, _ ...string) (string, error) { + calls++ + if calls == 1 { + return `[{"id":"37signals.basecamp","enabled":true}]`, nil + } + return "failed", errors.New("exit status 1") + }) + assert.True(t, outcome.failed()) + assert.Equal(t, "omarchy plugin update "+omarchyBasecampPluginID, outcome.Manual) + }) +} diff --git a/internal/commands/wizard_test.go b/internal/commands/wizard_test.go index 986456e4..7b5cecdd 100644 --- a/internal/commands/wizard_test.go +++ b/internal/commands/wizard_test.go @@ -4,12 +4,15 @@ import ( "bytes" "context" "encoding/json" + "fmt" "os" "path/filepath" "runtime" + "strings" "testing" "time" + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -112,7 +115,7 @@ func TestShowSuccessIncomplete(t *testing.T) { issues := issuesFromChecks(checks) var buf bytes.Buffer - showSuccess(&buf, styles, WizardResult{Status: "incomplete", AccountID: "123"}, checks, issues, false) + showSuccess(&buf, styles, WizardResult{Status: "incomplete", AccountID: "123"}, checks, issues, false, omarchyPluginOutcome{}) out := buf.String() assert.NotContains(t, out, "Setup complete!") @@ -121,6 +124,23 @@ func TestShowSuccessIncomplete(t *testing.T) { assert.Contains(t, out, "basecamp doctor") } +func TestShowSuccessOmarchyFailure(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.NoColorTheme()) + var buf bytes.Buffer + showSuccess(&buf, styles, WizardResult{Status: "incomplete", AccountID: "123"}, nil, nil, false, omarchyPluginOutcome{ + Detected: true, + Status: "failed", + Detail: "could not update the Basecamp plugin", + Manual: "omarchy plugin update 37signals.basecamp", + }) + + out := buf.String() + assert.Contains(t, out, "1 step needs attention") + assert.Contains(t, out, "Basecamp plugin setup needs attention") + assert.Contains(t, out, "omarchy plugin update 37signals.basecamp") + assert.NotContains(t, out, "basecamp doctor") +} + // TestClaudeStaleIssues verifies surviving stale plugin entries become an issue // (so status goes incomplete) and a clean home yields none. func TestClaudeStaleIssues(t *testing.T) { @@ -151,7 +171,7 @@ func TestShowSuccessSkipped(t *testing.T) { checks := snapshotAgentChecks(fakeAgents()) var buf bytes.Buffer - showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil, true) + showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil, true, omarchyPluginOutcome{}) out := buf.String() assert.Contains(t, out, "Setup complete!") @@ -167,7 +187,7 @@ func TestShowSuccessComplete(t *testing.T) { checks := []agentCheck{{Agent: "Claude Code", Name: "Claude Code Plugin", Status: "pass"}} var buf bytes.Buffer - showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil, false) + showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil, false, omarchyPluginOutcome{}) out := buf.String() assert.Contains(t, out, "Setup complete!") @@ -189,6 +209,381 @@ func TestNewSetupCmd(t *testing.T) { cmd := NewSetupCmd() assert.Equal(t, "setup", cmd.Use) assert.Contains(t, cmd.Short, "setup") + + customize := cmd.Flags().Lookup("customize") + require.NotNil(t, customize) + assert.Equal(t, "false", customize.DefValue) + + minimal := cmd.Flags().Lookup("minimal") + require.NotNil(t, minimal) + assert.Equal(t, "false", minimal.DefValue) +} + +func TestSetupRejectsUnknownSubcommandBeforeRunning(t *testing.T) { + app, _ := setupQuickstartTestApp(t, "", "") + var stdout, stderr bytes.Buffer + + cmd := NewSetupCmd() + cmd.SetArgs([]string{"codxe"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown command "codxe"`) + assert.False(t, app.SuppressPostRunNotices) + assert.NotContains(t, stdout.String(), "Basecamp at your command") +} + +func TestFastSetupNonInteractiveProjectUsesConfigHint(t *testing.T) { + app, _ := setupQuickstartTestApp(t, "", "") + app.Flags.Project = "123" + cmd := &cobra.Command{} + cmd.SetContext(appctx.WithApp(context.Background(), app)) + + err := runFastSetup(cmd, app, false) + require.Error(t, err) + assert.True(t, app.SuppressPostRunNotices) + assert.Equal(t, output.CodeUsage, output.AsError(err).Code) + assert.Contains(t, output.AsError(err).Hint, "basecamp config set project_id ") + assert.NotContains(t, output.AsError(err).Hint, "--customize") +} + +func TestChooseAutomaticAccount(t *testing.T) { + accounts := []basecamp.AuthorizedAccount{ + {ID: 123, Name: "First Account"}, + {ID: 456, Name: "Second Account"}, + } + + id, name, err := chooseAutomaticAccount("", "", "", accounts) + require.NoError(t, err) + assert.Equal(t, "123", id) + assert.Equal(t, "First Account", name) + + id, name, err = chooseAutomaticAccount("", "", "0456", accounts) + require.NoError(t, err) + assert.Equal(t, "456", id) + assert.Equal(t, "Second Account", name) + + id, name, err = chooseAutomaticAccount("", "789", "456", nil) + require.NoError(t, err) + assert.Equal(t, "789", id) + assert.Empty(t, name) + + id, name, err = chooseAutomaticAccount("0789", "789", "456", nil) + require.NoError(t, err) + assert.Equal(t, "789", id) + assert.Empty(t, name) + + id, name, err = chooseAutomaticAccount("0123", "", "456", accounts) + require.NoError(t, err) + assert.Equal(t, "123", id) + assert.Equal(t, "First Account", name) + + _, _, err = chooseAutomaticAccount("999", "", "456", accounts) + require.Error(t, err) + assert.Equal(t, output.CodeNotFound, output.AsError(err).Code) + + _, _, err = chooseAutomaticAccount("999", "789", "456", nil) + require.Error(t, err) + assert.Equal(t, output.CodeUsage, output.AsError(err).Code) + assert.Contains(t, output.AsError(err).Hint, "--account 789") + + _, _, err = chooseAutomaticAccount("abc", "", "", accounts) + require.Error(t, err) + assert.Equal(t, output.CodeUsage, output.AsError(err).Code) + + _, _, err = chooseAutomaticAccount("", "", "999", accounts) + require.Error(t, err) + assert.Equal(t, output.CodeNotFound, output.AsError(err).Code) + + _, _, err = chooseAutomaticAccount("", "", "", nil) + require.Error(t, err) + assert.Equal(t, output.CodeNotFound, output.AsError(err).Code) +} + +func TestConfiguredAccountConflictWithOAuthBinding(t *testing.T) { + for _, source := range []string{"local", "repo", "env", "flag", "profile"} { + t.Run(source, func(t *testing.T) { + assert.True(t, configuredAccountOverridesGlobal(source)) + err := configuredAccountMismatchError(source, "456", "123") + require.Error(t, err) + assert.Equal(t, output.CodeUsage, output.AsError(err).Code) + assert.Contains(t, err.Error(), "456") + assert.Contains(t, err.Error(), "123") + }) + } + + for _, source := range []string{"", "default", "system", "global", "prompt"} { + t.Run("safe-"+source, func(t *testing.T) { + assert.False(t, configuredAccountOverridesGlobal(source)) + }) + } + assert.True(t, accountIDsEqual("00123", "123")) + assert.False(t, accountIDsEqual("456", "123")) +} + +func TestPersistRecommendedDefaultsClearsOnlyTheGlobalProject(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + globalPath := filepath.Join(configHome, "basecamp", "config.json") + require.NoError(t, os.MkdirAll(filepath.Dir(globalPath), 0o700)) + require.NoError(t, os.WriteFile(globalPath, []byte(`{"account_id":"111","project_id":"222","hints":true}`), 0o600)) + + workingDir := t.TempDir() + previousDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(workingDir)) + t.Cleanup(func() { require.NoError(t, os.Chdir(previousDir)) }) + localPath := filepath.Join(workingDir, ".basecamp", "config.json") + require.NoError(t, os.MkdirAll(filepath.Dir(localPath), 0o700)) + require.NoError(t, os.WriteFile(localPath, []byte(`{"project_id":"333"}`), 0o600)) + + app, _ := setupQuickstartTestApp(t, "111", "333") + app.Config.Sources = map[string]string{"account_id": "global", "project_id": "local"} + require.NoError(t, persistRecommendedDefaults(app, "456")) + + var global map[string]any + data, err := os.ReadFile(globalPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &global)) + assert.Equal(t, "456", global["account_id"]) + assert.Equal(t, true, global["hints"]) + assert.NotContains(t, global, "project_id") + + localData, err := os.ReadFile(localPath) + require.NoError(t, err) + assert.JSONEq(t, `{"project_id":"333"}`, string(localData)) + assert.Equal(t, "456", app.Config.AccountID) + assert.Empty(t, app.Config.ProjectID) + assert.NotContains(t, app.Config.Sources, "project_id") +} + +func TestShowWelcome(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + var buf bytes.Buffer + wait := showWelcome(&buf, styles) + wait() + + out := buf.String() + assert.Contains(t, out, "Basecamp at your command (line).") + assert.Equal(t, 1, strings.Count(out, "Basecamp"), "the logo should not repeat the product name") + assert.NotContains(t, out, "Welcome to Basecamp") + assert.Contains(t, out, "Let's get you set up. It’ll only take a moment.") + assert.NotContains(t, out, "command-line interface for Basecamp") +} + +func TestShowFastAuthenticationStart(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.NoColorTheme()) + var buf bytes.Buffer + prefix := showAuthenticationStart(&buf, styles, false) + log := authenticationLogger(&buf, prefix) + log("Authenticating via launchpad (https://launchpad.37signals.com/authorization/new)") + log("\nOpening browser for authentication...") + + assert.Empty(t, prefix) + assert.Equal(t, "Opening browser for Basecamp login...\n", buf.String()) + assert.NotContains(t, buf.String(), "Step 1") + assert.NotContains(t, buf.String(), "launchpad") + assert.NotContains(t, buf.String(), "Opening browser for authentication") + + var deviceFlow bytes.Buffer + deviceLog := authenticationLogger(&deviceFlow, "") + deviceLog("Authenticating via https://3.basecampapi.com (device flow)") + deviceLog("\nOpening browser for authentication...") + assert.Contains(t, deviceFlow.String(), "Authenticating via https://3.basecampapi.com (device flow)") + assert.Contains(t, deviceFlow.String(), "Opening browser for authentication") +} + +func TestShowFastSuccess(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + result := WizardResult{AuthenticatedAs: "Jane Smith"} + + var buf bytes.Buffer + showFastAuthenticated(&buf, styles, result.AuthenticatedAs, "123", "Acme") + showFastAgentStatus(&buf, styles, agentSetupOutcome{Detected: 2}) + showFastCompletion(&buf, styles, agentSetupOutcome{}, omarchyPluginOutcome{}, false) + out := buf.String() + + assert.NotContains(t, out, "Setup complete!") + assert.Contains(t, out, "Authenticated as Jane Smith") + assert.Contains(t, out, "Using account Acme") + assert.Contains(t, out, "AI coding agents set up") + assert.NotContains(t, out, "Account: Acme") + assert.NotContains(t, out, "basecamp setup --customize") + assert.NotContains(t, out, "Try these commands") + for _, want := range []string{ + "Try it out!", + "basecamp projects list", + "basecamp assignments", + "basecamp timeline", + `basecamp search "quarterly planning"`, + } { + assert.Contains(t, out, want) + } +} + +func TestShowFastSuccessMinimal(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.DefaultTheme(false)) + var buf bytes.Buffer + showFastAuthenticated(&buf, styles, "Jane Smith", "123", "Acme") + showFastAgentStatus(&buf, styles, agentSetupOutcome{Detected: 2}) + showFastCompletion(&buf, styles, agentSetupOutcome{}, omarchyPluginOutcome{}, true) + + out := buf.String() + assert.Contains(t, out, "Authenticated as Jane Smith") + assert.Contains(t, out, "Using account Acme") + assert.Contains(t, out, "AI coding agents set up") + assert.Contains(t, out, fastSetupTitleStyle(styles).Render("SETUP COMPLETE")) + assert.NotContains(t, out, "Try it out!") + assert.NotContains(t, out, "basecamp projects list") + assert.True(t, strings.HasSuffix(out, "\n\n"), "completion message should have a blank line below it") +} + +func TestShowFastCompletionReportsIntegrationOutcomes(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.NoColorTheme()) + + var installed bytes.Buffer + showFastCompletion(&installed, styles, agentSetupOutcome{}, omarchyPluginOutcome{ + Detected: true, + Status: "installed", + }, false) + assert.Contains(t, installed.String(), "Basecamp plugin installed for Omarchy") + + var ready bytes.Buffer + showFastCompletion(&ready, styles, agentSetupOutcome{}, omarchyPluginOutcome{ + Detected: true, + Status: "ready", + }, false) + assert.Contains(t, ready.String(), "Basecamp plugin ready for Omarchy") + assert.NotContains(t, ready.String(), "updated") + + var omarchyFailed bytes.Buffer + showFastCompletion(&omarchyFailed, styles, agentSetupOutcome{}, omarchyPluginOutcome{ + Detected: true, + Status: "failed", + Manual: "omarchy plugin update 37signals.basecamp", + }, true) + assert.Contains(t, omarchyFailed.String(), "Basecamp plugin setup needs attention") + assert.Contains(t, omarchyFailed.String(), "omarchy plugin update 37signals.basecamp") + assert.Contains(t, omarchyFailed.String(), "SETUP NEEDS ATTENTION") + assert.NotContains(t, omarchyFailed.String(), "SETUP COMPLETE") + + var agentsFailed bytes.Buffer + showFastCompletion(&agentsFailed, styles, agentSetupOutcome{ + Issues: []agentIssue{{Check: "Claude Code Plugin"}}, + }, omarchyPluginOutcome{}, true) + assert.Contains(t, agentsFailed.String(), "SETUP NEEDS ATTENTION") + assert.NotContains(t, agentsFailed.String(), "SETUP COMPLETE") +} + +func TestShowFastSetupExamplesUseTerminalColor(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + var buf bytes.Buffer + showFastSetupExamples(&buf, styles) + + lines := strings.Split(buf.String(), "\n") + for _, command := range []string{ + "basecamp projects list", + "basecamp assignments", + "basecamp timeline", + `basecamp search "quarterly planning"`, + } { + var found bool + for _, line := range lines { + if strings.HasPrefix(line, command) { + found = true + break + } + } + assert.True(t, found, "command should start at column zero in the terminal's default color: %s", command) + } +} + +func TestShowFastSuccessWithoutAgents(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + var buf bytes.Buffer + showFastAgentStatus(&buf, styles, agentSetupOutcome{}) + + assert.Contains(t, buf.String(), "AI coding agents: none detected") +} + +func TestShowFastSuccessHidesAgentDetails(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + var buf bytes.Buffer + showFastAgentStatus(&buf, styles, agentSetupOutcome{ + Detected: 1, + Issues: []agentIssue{{ + Check: "Claude Code Plugin", + Hint: "Plugin version mismatch", + }}, + }) + + out := buf.String() + assert.Contains(t, out, "AI coding agents need attention — run: basecamp doctor") + assert.NotContains(t, out, "Claude Code Plugin") + assert.NotContains(t, out, "version mismatch") +} + +func TestInstallDetectedAgentsRunsEveryHandler(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + originalHandlers := agentSetupHandlers + var ran []string + agentSetupHandlers = map[string]agentSetupHandler{ + "alpha": {Run: func(*cobra.Command, *tui.Styles) error { + ran = append(ran, "alpha") + return nil + }}, + "beta": {Run: func(*cobra.Command, *tui.Styles) error { + ran = append(ran, "beta") + return nil + }}, + } + t.Cleanup(func() { agentSetupHandlers = originalHandlers }) + + agents := []harness.AgentInfo{ + {ID: "alpha", Name: "Alpha", Checks: func() []*harness.StatusCheck { return nil }}, + {ID: "beta", Name: "Beta", Checks: func() []*harness.StatusCheck { return nil }}, + } + cmd := &cobra.Command{} + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(context.Background()) + + outcome, err := installDetectedAgents(cmd, tui.NewStylesWithTheme(tui.ResolveTheme(false)), agents) + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "beta"}, ran) + assert.Equal(t, 2, outcome.Detected) +} + +func TestInstallDetectedAgentsQuietlySuppressesChatter(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + originalHandlers := agentSetupHandlers + agentSetupHandlers = map[string]agentSetupHandler{ + "alpha": {Run: func(cmd *cobra.Command, _ *tui.Styles) error { + fmt.Fprintln(cmd.OutOrStdout(), "plugin mismatch") + fmt.Fprintln(cmd.ErrOrStderr(), "agent skill updated") + return nil + }}, + } + t.Cleanup(func() { agentSetupHandlers = originalHandlers }) + + agents := []harness.AgentInfo{ + {ID: "alpha", Name: "Alpha", Checks: func() []*harness.StatusCheck { return nil }}, + } + var stdout, stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(context.Background()) + + _, err := installDetectedAgentsQuietly(cmd, tui.NewStylesWithTheme(tui.ResolveTheme(false)), agents) + require.NoError(t, err) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) } // TestNewSetupCmdHasClaudeSubcommand verifies setup has the claude subcommand. @@ -675,8 +1070,8 @@ func nonInteractiveStdin(t *testing.T, kind string) { } // runSetupWithin executes the setup command and fails if it has not returned -// within the timeout. The timeout is the real assertion: an ungated wizard -// reaches a huh prompt, which blocks on /dev/tty rather than failing. +// within the timeout. The timeout guards against setup reaching a browser or +// prompt in a context that cannot complete it. func runSetupWithin(t *testing.T, cmd *cobra.Command, timeout time.Duration) error { t.Helper() @@ -733,8 +1128,8 @@ func TestSetupRefusesNonInteractiveStdio(t *testing.T) { } // TestSetupRefusesUnderNonInteractiveEnv verifies BASECAMP_NONINTERACTIVE is -// honored even where stdio would pass: the wizard is prompts end to end, so -// the env var that means "never prompt me" has to reach it. +// honored even where stdio would pass. The env var that disables human setup +// applies to both recommended and customized setup. func TestSetupRefusesUnderNonInteractiveEnv(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("BASECAMP_NONINTERACTIVE", "1") @@ -753,12 +1148,11 @@ func TestSetupRefusesUnderNonInteractiveEnv(t *testing.T) { } // TestSetupRefusesMachineOutputOnATerminal covers the other half of the gate. -// Terminal stdio is not enough: a caller that asked for machine output has -// declared it is not there to answer questions, and the wizard is nothing but -// questions. Config-driven json/quiet counts too — app.IsInteractive() does not -// look at it, which is why the gate also asks IsMachineOutput(). An explicit -// --styled/--md overrides a configured machine format there, so that pairing -// prompts like any human invocation. +// Terminal stdio is not enough: a caller that requested machine output is not +// running human first-time setup. Config-driven json/quiet counts too — +// app.IsInteractive() does not look at it, which is why the gate also asks +// IsMachineOutput(). An explicit --styled/--md override restores human output, +// so that pairing can run setup like any human invocation. func TestSetupRefusesMachineOutputOnATerminal(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("no /dev/ptmx on Windows") @@ -832,14 +1226,11 @@ func TestSetupSubcommandsSurviveTheGate(t *testing.T) { } // TestBareBasecampNeverReportsASetupError covers the difference between asking -// for the wizard and being handed one. `basecamp setup` refuses out loud when it -// cannot prompt — the user named that command. Bare `basecamp` never asked for a -// wizard, so an error about `basecamp setup` is an answer to a question nobody -// posed, and it replaces output the user was entitled to. +// for setup and receiving first-run behavior implicitly. Explicit setup refuses +// when it cannot run safely. Bare `basecamp` falls through to its normal output. // -// Both rows reach runWizard through RunQuickStartDefault's first-run path and -// would have hit the gate, because isFirstRun's old check saw only stdin and -// stdout: +// Both rows reach RunQuickStartDefault's first-run decision and would have hit +// the setup gate when isFirstRun only checked stdin and stdout: // // - stderr redirected: stdin/stdout are terminals, so first-run fires, but // huh draws to stderr and could not have shown anything. @@ -873,7 +1264,7 @@ func TestBareBasecampNeverReportsASetupError(t *testing.T) { tc.apply(t, app) // Without this the test proves nothing: isFirstRun bails on - // IsInteractive before it ever reaches the wizard, and every + // IsInteractive before it ever reaches setup, and every // assertion below passes for the wrong reason. An earlier version // of this test did exactly that — it set stdout and stderr but left // stdin on go test's /dev/null, so it passed against the bug. diff --git a/internal/tui/resolve/persist.go b/internal/tui/resolve/persist.go index 6cfc10c3..7343972e 100644 --- a/internal/tui/resolve/persist.go +++ b/internal/tui/resolve/persist.go @@ -50,57 +50,67 @@ func PromptAndPersist(opt PersistOption) (bool, error) { // PersistValue saves a config value to the specified scope. func PersistValue(key, value, scope string) error { - var configPath string + return PersistValues(map[string]string{key: value}, nil, scope) +} - switch scope { - case "global": - configPath = filepath.Join(config.GlobalConfigDir(), "config.json") - case "local": - configPath = filepath.Join(".basecamp", "config.json") - default: - return fmt.Errorf("invalid scope: %s (must be 'local' or 'global')", scope) +// PersistValues saves and removes config values in one atomic update. +func PersistValues(values map[string]string, remove []string, scope string) error { + configPath, err := persistencePath(scope) + if err != nil { + return err } - // Ensure directory exists configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - // Load existing config or create new configData := make(map[string]any) if data, err := os.ReadFile(configPath); err == nil { //nolint:gosec // G304: Path is from trusted config location _ = json.Unmarshal(data, &configData) // Ignore error - start fresh if invalid } - // Set the value (use native JSON types for boolean keys) - switch key { - case "onboarded", "hints", "stats", "cache_enabled": - switch strings.ToLower(strings.TrimSpace(value)) { - case "true", "1": - configData[key] = true - case "false", "0": - configData[key] = false - default: - configData[key] = value - } - default: - configData[key] = value + for key, value := range values { + configData[key] = persistenceValue(key, value) + } + for _, key := range remove { + delete(configData, key) } - // Write back (atomic: temp + rename) data, err := json.MarshalIndent(configData, "", " ") if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } - if err := atomicWriteFile(configPath, append(data, '\n')); err != nil { return fmt.Errorf("failed to write config: %w", err) } - return nil } +func persistencePath(scope string) (string, error) { + switch scope { + case "global": + return filepath.Join(config.GlobalConfigDir(), "config.json"), nil + case "local": + return filepath.Join(".basecamp", "config.json"), nil + default: + return "", fmt.Errorf("invalid scope: %s (must be 'local' or 'global')", scope) + } +} + +func persistenceValue(key, value string) any { + switch key { + case "onboarded", "hints", "stats", "cache_enabled": + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1": + return true + case "false", "0": + return false + } + } + return value +} + // atomicWriteFile writes data to a file atomically using temp+rename. func atomicWriteFile(path string, data []byte) error { dir := filepath.Dir(path) diff --git a/internal/tui/resolve/persist_test.go b/internal/tui/resolve/persist_test.go index 44a4dcc1..2a055b20 100644 --- a/internal/tui/resolve/persist_test.go +++ b/internal/tui/resolve/persist_test.go @@ -81,6 +81,26 @@ func TestPersistValueStringKeys(t *testing.T) { assert.Equal(t, "12345", val, "string keys should remain strings") } +func TestPersistValuesSetsAndRemovesInOneUpdate(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + configPath := filepath.Join(tmpDir, "basecamp", "config.json") + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + require.NoError(t, os.WriteFile(configPath, []byte(`{"account_id":"111","project_id":"222","hints":true}`), 0o600)) + + err := PersistValues(map[string]string{"account_id": "333"}, []string{"project_id"}, "global") + require.NoError(t, err) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + assert.Equal(t, "333", raw["account_id"]) + assert.Equal(t, true, raw["hints"]) + assert.NotContains(t, raw, "project_id") +} + func TestPersistValueBooleanWhitespaceTolerance(t *testing.T) { tmpDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmpDir) diff --git a/internal/tui/snowglobe.go b/internal/tui/snowglobe.go new file mode 100644 index 00000000..11ef19d6 --- /dev/null +++ b/internal/tui/snowglobe.go @@ -0,0 +1,131 @@ +package tui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +const ( + snowglobeWidth = 32 + snowglobeHeight = 28 +) + +// snowglobeRaster is a terminal-sized color map derived from the current +// Basecamp snowglobe mark. Spaces are transparent; digits and A index the +// glass-blue and mountain-green palette below. +const snowglobeRaster = ` 666666 + 6666666666 + 65556666666566 + 6355556666666556 + 533355566666666556 + 52333555556666666556 + 5222335555555556556556 + 22222335555555787555556 + 312222333355558989755555 + 512111233333337A7999755535 + 21112422333324A98999975555 + 31114777322324AA789999A73535 + 21147778821149A878999AAA6335 + 311147777A8449A9789999AAA93335 + 2114777779AAAAA778999AAAAA7223 + 2117777778AAAA87889999AAAAA523 + 11477777888AA878889999AAAAA723 +21177777778888788889999AAAAAA525 +21477777788888888888999AAAAAA625 +11477777788888888888999AAAAAA925 +11777777788888888888999AAAAAA925 +214777778888888888888999AAAAA625 + 11478777888888888888999AAAA625 + 11478888888888888899AAAA7423 + 211477999AAAAAAAAAAA974225 + 221124477789997776432235 + 33221112333332223355 + 333333333555` + +var snowglobePalette = map[byte]color.Color{ + '1': lipgloss.Color("#e6f0fd"), + '2': lipgloss.Color("#d5e2fd"), + '3': lipgloss.Color("#c9dcfb"), + '4': lipgloss.Color("#b3d9de"), + '5': lipgloss.Color("#b6cef8"), + '6': lipgloss.Color("#9bbff1"), + '7': lipgloss.Color("#5ac26b"), + '8': lipgloss.Color("#43b360"), + '9': lipgloss.Color("#3aa562"), + 'A': lipgloss.Color("#19964e"), +} + +// RenderSnowglobe renders the current Basecamp snowglobe mark as static +// terminal art. Colored terminals use half-block pixels for the blue glass and +// green mountain; NO_COLOR uses a two-tone text rendering. +func RenderSnowglobe(theme Theme) string { + lines := strings.Split(snowglobeRaster, "\n") + if _, noColor := theme.Primary.(lipgloss.NoColor); noColor { + return renderSnowglobeNoColor(lines) + } + return renderSnowglobeColor(lines) +} + +func renderSnowglobeColor(lines []string) string { + var b strings.Builder + for y := 0; y < snowglobeHeight; y += 2 { + if y > 0 { + b.WriteByte('\n') + } + for x := 0; x < snowglobeWidth; x++ { + top := snowglobePixel(lines, x, y) + bottom := snowglobePixel(lines, x, y+1) + topColor, topSet := snowglobePalette[top] + bottomColor, bottomSet := snowglobePalette[bottom] + + switch { + case !topSet && !bottomSet: + b.WriteByte(' ') + case topSet && !bottomSet: + b.WriteString(lipgloss.NewStyle().Foreground(topColor).Render("▀")) + case !topSet && bottomSet: + b.WriteString(lipgloss.NewStyle().Foreground(bottomColor).Render("▄")) + case topColor == bottomColor: + b.WriteString(lipgloss.NewStyle().Foreground(topColor).Render("█")) + default: + b.WriteString(lipgloss.NewStyle().Foreground(topColor).Background(bottomColor).Render("▀")) + } + } + } + return b.String() +} + +func renderSnowglobeNoColor(lines []string) string { + var b strings.Builder + for y := 0; y < snowglobeHeight; y += 2 { + if y > 0 { + b.WriteByte('\n') + } + for x := 0; x < snowglobeWidth; x++ { + top := snowglobePixel(lines, x, y) + bottom := snowglobePixel(lines, x, y+1) + switch { + case snowglobeGreen(top) || snowglobeGreen(bottom): + b.WriteRune('▓') + case top != ' ' || bottom != ' ': + b.WriteRune('░') + default: + b.WriteByte(' ') + } + } + } + return b.String() +} + +func snowglobePixel(lines []string, x, y int) byte { + if y < 0 || y >= len(lines) || x < 0 || x >= len(lines[y]) { + return ' ' + } + return lines[y][x] +} + +func snowglobeGreen(pixel byte) bool { + return pixel >= '7' && pixel <= '9' || pixel == 'A' +} diff --git a/internal/tui/snowglobe_test.go b/internal/tui/snowglobe_test.go new file mode 100644 index 00000000..d533a836 --- /dev/null +++ b/internal/tui/snowglobe_test.go @@ -0,0 +1,33 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnowglobeRasterDimensions(t *testing.T) { + lines := strings.Split(snowglobeRaster, "\n") + require.Len(t, lines, snowglobeHeight) + for _, line := range lines { + assert.LessOrEqual(t, len(line), snowglobeWidth) + } +} + +func TestRenderSnowglobeNoColor(t *testing.T) { + rendered := RenderSnowglobe(NoColorTheme()) + assert.NotContains(t, rendered, "\x1b[") + assert.NotContains(t, rendered, "Basecamp") + assert.Contains(t, rendered, "░") + assert.Contains(t, rendered, "▓") + assert.Len(t, strings.Split(rendered, "\n"), snowglobeHeight/2) +} + +func TestRenderSnowglobeWithColor(t *testing.T) { + rendered := RenderSnowglobe(DefaultTheme(true)) + assert.Contains(t, rendered, "\x1b[") + assert.Contains(t, rendered, "▀") + assert.NotContains(t, rendered, "Basecamp") +} diff --git a/internal/tui/spinner.go b/internal/tui/spinner.go new file mode 100644 index 00000000..8c928421 --- /dev/null +++ b/internal/tui/spinner.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + "io" + "time" + + "charm.land/lipgloss/v2" +) + +const ( + spinnerDelay = 150 * time.Millisecond + spinnerInterval = 80 * time.Millisecond +) + +var spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// RunWithSpinner displays message while task runs on a terminal, clears the +// transient line when task finishes, and returns the task error. Fast tasks and +// non-terminal writers complete without transient output. +func RunWithSpinner(w io.Writer, theme Theme, message string, task func() error) error { + return runWithSpinner(w, theme, message, task, isWriterTTY(w), spinnerDelay, spinnerInterval) +} + +func runWithSpinner(w io.Writer, theme Theme, message string, task func() error, terminal bool, delayDuration, interval time.Duration) error { + if !terminal { + return task() + } + + done := make(chan error, 1) + go func() { + done <- task() + }() + + delay := time.NewTimer(delayDuration) + defer delay.Stop() + select { + case err := <-done: + return err + case <-delay.C: + } + + spinnerStyle := lipgloss.NewStyle().Foreground(theme.Primary) + messageStyle := lipgloss.NewStyle().Foreground(theme.Muted) + ticker := time.NewTicker(interval) + defer ticker.Stop() + defer fmt.Fprint(w, "\r\033[2K") + + frame := 0 + for { + fmt.Fprintf(w, "\r%s %s", spinnerStyle.Render(spinnerFrames[frame]), messageStyle.Render(message)) + select { + case err := <-done: + return err + case <-ticker.C: + frame = (frame + 1) % len(spinnerFrames) + } + } +} diff --git a/internal/tui/spinner_test.go b/internal/tui/spinner_test.go new file mode 100644 index 00000000..02e19c11 --- /dev/null +++ b/internal/tui/spinner_test.go @@ -0,0 +1,62 @@ +package tui + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRunWithSpinnerNonTTY(t *testing.T) { + var buf bytes.Buffer + called := false + err := RunWithSpinner(&buf, DefaultTheme(true), "Working...", func() error { + called = true + return nil + }) + + assert.NoError(t, err) + assert.True(t, called) + assert.Empty(t, buf.String()) +} + +func TestRunWithSpinnerReturnsTaskError(t *testing.T) { + var buf bytes.Buffer + want := errors.New("task failed") + err := RunWithSpinner(&buf, DefaultTheme(true), "Working...", func() error { + return want + }) + + assert.ErrorIs(t, err, want) + assert.Empty(t, buf.String()) +} + +func TestRunWithSpinnerTerminalPathClearsAfterSuccess(t *testing.T) { + var buf bytes.Buffer + err := runWithSpinner(&buf, NoColorTheme(), "Working...", func() error { + time.Sleep(20 * time.Millisecond) + return nil + }, true, time.Millisecond, time.Millisecond) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Working...") + assert.True(t, strings.HasSuffix(buf.String(), "\r\033[2K")) + assert.NotContains(t, buf.String(), "\033[?25l") +} + +func TestRunWithSpinnerTerminalPathClearsAfterError(t *testing.T) { + var buf bytes.Buffer + want := errors.New("task failed") + err := runWithSpinner(&buf, NoColorTheme(), "Working...", func() error { + time.Sleep(20 * time.Millisecond) + return want + }, true, time.Millisecond, time.Millisecond) + + assert.ErrorIs(t, err, want) + assert.Contains(t, buf.String(), "Working...") + assert.True(t, strings.HasSuffix(buf.String(), "\r\033[2K")) + assert.NotContains(t, buf.String(), "\033[?25l") +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5d9c2ef3..2bec8692 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -9,8 +9,10 @@ try { # Environment options: # BASECAMP_VERSION Specific version to install (default: latest) # BASECAMP_BIN_DIR Where to install the binary -# BASECAMP_SKIP_SETUP Set to 1 to skip the interactive wizard (still runs +# BASECAMP_SKIP_SETUP Set to 1 to skip first-time setup (still runs # `basecamp setup agents`) +# BASECAMP_NONINTERACTIVE +# Set to 1 or true to use non-interactive setup # BASECAMP_SETUP_AGENT Which coding agent(s) `setup agents` connects: # claude | codex | all | none. Unset = auto-detect. # Piped install sets it for the interpreter, not the fetch: @@ -28,6 +30,14 @@ function Info([string]$Message) { Write-Host " + $Message" -ForegroundColor Green } +function Warn([string]$Message) { + Write-Warning $Message +} + +function Test-TruthyEnvironmentValue([string]$Value) { + return $Value -match '^(?i:1|true)$' +} + function Fail([string]$Message) { throw $Message } @@ -287,12 +297,29 @@ function Test-InteractiveSession { } try { - return -not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected + return -not [Console]::IsInputRedirected -and + -not [Console]::IsOutputRedirected -and + -not [Console]::IsErrorRedirected } catch { return $false } } +# Invoke-FirstTimeSetup runs optional onboarding without changing the successful +# installation result. The native process inherits the console streams so setup +# can render and pass its terminal-safety gate. +function Invoke-FirstTimeSetup([string]$Binary) { + try { + & $Binary setup + if ($LASTEXITCODE -eq 0) { + return + } + } catch { + # The retry guidance below covers process-launch and command failures alike. + } + Warn 'First-time setup did not finish. Run it again with: basecamp setup' +} + # Invoke-PostInstallSetup installs the baseline skill and connects coding agents # without prompting, honoring BASECAMP_SETUP_AGENT (claude|codex|all|none; # unset = auto-detect). It is strictly best-effort: agent setup must never fail @@ -416,22 +443,22 @@ function Main { Write-Host '' if ($SkipSetup -eq '1') { - Step 'Skipping setup wizard (BASECAMP_SKIP_SETUP=1)' + Step 'Skipping first-time setup (BASECAMP_SKIP_SETUP=1)' # Still install the baseline skill and connect coding agents (best-effort). Invoke-PostInstallSetup $installedBinary Write-Host '' Write-Host ' Next steps:' Write-Host ' basecamp auth login Authenticate with Basecamp' - Write-Host ' basecamp setup Run interactive setup wizard' - Write-Host '' - } elseif ($isInteractive) { - & $installedBinary setup - Write-Host '' - Write-Host ' Next steps:' - Write-Host ' basecamp auth login Authenticate with Basecamp' + Write-Host ' basecamp setup Run first-time setup' Write-Host '' + } elseif ($isInteractive -and -not (Test-TruthyEnvironmentValue $env:BASECAMP_NONINTERACTIVE)) { + Invoke-FirstTimeSetup $installedBinary } else { - Info 'Skipping interactive setup because PowerShell is running non-interactively.' + if (Test-TruthyEnvironmentValue $env:BASECAMP_NONINTERACTIVE) { + Info 'Skipping first-time setup because BASECAMP_NONINTERACTIVE is enabled.' + } else { + Info 'Skipping first-time setup because PowerShell is running non-interactively.' + } # Install the baseline skill and connect coding agents (best-effort). Invoke-PostInstallSetup $installedBinary Write-Host '' diff --git a/scripts/install.sh b/scripts/install.sh index 729e0ece..3334ce22 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,7 +9,11 @@ # (default: ~/bin if on PATH, else ~/.local/bin if on PATH; # otherwise ~/bin on Windows, ~/.local/bin elsewhere) # BASECAMP_VERSION Specific version to install (default: latest) -# BASECAMP_SKIP_SETUP Set to 1 to skip the interactive setup wizard after install +# BASECAMP_SKIP_SETUP Set to 1 to skip first-time setup after install +# (still runs `basecamp setup agents` to install the skill +# and connect coding agents) +# BASECAMP_NONINTERACTIVE +# Set to 1 or true to use non-interactive setup # (still runs `basecamp setup agents` to install the skill # and connect coding agents) # BASECAMP_SETUP_AGENT @@ -44,8 +48,29 @@ fi info() { echo " $(green "✓") $1"; } step() { echo " $(bold "→") $1"; } +warn() { echo " $(bold "!") $1" >&2; } error() { echo " $(red "✗ ERROR:") $1" >&2; exit 1; } +env_value_is_true() { + case "$1" in + 1|[Tt][Rr][Uu][Ee]) return 0 ;; + *) return 1 ;; + esac +} + +can_run_first_time_setup() { + ! env_value_is_true "${BASECAMP_NONINTERACTIVE:-}" && + [[ -t 1 ]] && [[ -t 2 ]] && { : /dev/null +} + +run_first_time_setup() { + local binary="$1" + if "$binary" setup; then + return 0 + fi + warn "First-time setup did not finish. Run it again with: basecamp setup" +} + find_sha256_cmd() { if command -v sha256sum &>/dev/null; then echo "sha256sum" @@ -513,28 +538,33 @@ main() { echo "" - # Run interactive setup wizard only when stdin is a TTY and not explicitly skipped. - # Non-interactive environments (CI, piped input, coding agents like Claude Code - # or Codex) get the baseline skill installed, a best-effort agent connection via - # `setup agents`, and next-step instructions instead — the wizard requires - # interactive prompts that don't work without a terminal. + # Run first-time setup when a controlling terminal can handle OAuth approval. + # Non-interactive environments (CI, redirected output, coding agents like Claude + # Code or Codex) get the baseline skill installed, a best-effort agent connection + # via `setup agents`, and next-step instructions instead. if [[ "${BASECAMP_SKIP_SETUP:-}" == "1" ]]; then - step "Skipping setup wizard (BASECAMP_SKIP_SETUP=1)" + step "Skipping first-time setup (BASECAMP_SKIP_SETUP=1)" post_install_setup "$binary_name" echo "" echo " Next steps:" echo " $(bold "basecamp auth login") Authenticate with Basecamp" - echo " $(bold "basecamp setup") Run interactive setup wizard" + echo " $(bold "basecamp setup") Run first-time setup" echo "" - elif [[ -t 0 ]] && [[ -t 1 ]]; then - "$BIN_DIR/$binary_name" setup + elif can_run_first_time_setup; then + # The canonical `curl ... | bash` install owns stdin while Bash reads the + # script. Give setup the controlling terminal so OAuth can complete. + run_first_time_setup "$BIN_DIR/$binary_name"