Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary>Other installation methods</summary>
Expand Down Expand Up @@ -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).

</details>
Expand All @@ -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:

Expand All @@ -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
Expand Down
172 changes: 171 additions & 1 deletion e2e/installer.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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/tty' "$INSTALL_SH"
[[ "$status" -eq 0 ]]
run grep -F '[[ -t 1 ]] && [[ -t 2 ]] && { : </dev/tty; } 2>/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" <<EOF
#!/usr/bin/env bash
source '$INSTALL_SH'
if can_run_first_time_setup; then
echo SETUP_CAN_RUN
else
echo SETUP_CANNOT_RUN
fi
EOF
chmod +x "$STUB_DIR/tty-probe"

run script -qec "setsid -f -w '$STUB_DIR/tty-probe'" /dev/null
[[ "$status" -eq 0 ]]
[[ "$output" == *"SETUP_CANNOT_RUN"* ]]
[[ "$output" != *"SETUP_CAN_RUN"* ]]
}

@test "install.sh keeps installation successful when first-time setup fails" {
cat > "$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 ]]
Expand Down Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions e2e/setup.bats
Original file line number Diff line number Diff line change
@@ -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
Comment thread
robzolkos marked this conversation as resolved.
# 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
Expand Down Expand Up @@ -68,6 +68,34 @@ 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 <id>")' 'true'
assert_json_value '.hint | contains("--customize")' 'false'
}

@test "setup without --json and stdin closed refuses instead of hanging" {
create_credentials
create_global_config '{"account_id": 99999}'
Expand Down
19 changes: 13 additions & 6 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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:**
Expand All @@ -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
Expand All @@ -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

Expand Down
Loading
Loading