Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,7 @@ CMD basecamp search
CMD basecamp search metadata
CMD basecamp search types
CMD basecamp setup
CMD basecamp setup agents
CMD basecamp setup claude
CMD basecamp setup codex
CMD basecamp show
Expand Down Expand Up @@ -11428,6 +11429,27 @@ FLAG basecamp setup --stats type=bool
FLAG basecamp setup --styled type=bool
FLAG basecamp setup --todolist type=string
FLAG basecamp setup --verbose type=count
FLAG basecamp setup agents --account type=string
FLAG basecamp setup agents --agent type=bool
FLAG basecamp setup agents --cache-dir type=string
FLAG basecamp setup agents --count type=bool
FLAG basecamp setup agents --help type=bool
FLAG basecamp setup agents --hints type=bool
FLAG basecamp setup agents --ids-only type=bool
FLAG basecamp setup agents --in type=string
FLAG basecamp setup agents --jq type=string
FLAG basecamp setup agents --json type=bool
FLAG basecamp setup agents --markdown type=bool
FLAG basecamp setup agents --md type=bool
FLAG basecamp setup agents --no-hints type=bool
FLAG basecamp setup agents --no-stats type=bool
FLAG basecamp setup agents --profile type=string
FLAG basecamp setup agents --project type=string
FLAG basecamp setup agents --quiet type=bool
FLAG basecamp setup agents --stats type=bool
FLAG basecamp setup agents --styled type=bool
FLAG basecamp setup agents --todolist type=string
FLAG basecamp setup agents --verbose type=count
FLAG basecamp setup claude --account type=string
FLAG basecamp setup claude --agent type=bool
FLAG basecamp setup claude --cache-dir type=string
Expand Down Expand Up @@ -16470,6 +16492,7 @@ SUB basecamp search
SUB basecamp search metadata
SUB basecamp search types
SUB basecamp setup
SUB basecamp setup agents
SUB basecamp setup claude
SUB basecamp setup codex
SUB basecamp show
Expand Down
151 changes: 151 additions & 0 deletions e2e/installer.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env bats
# installer.bats - Tests for the install scripts' post-install agent setup.
#
# #528: the non-TTY and skip branches must run `setup agents` (baseline skill +
# best-effort agent connection), never the hardcoded `setup claude`. Old release
# binaries (which lack `setup agents`) must fall back without reintroducing the
# Claude-first bug.

setup() {
# The installer contract keys off these; a leaked value would skew results.
unset BASECAMP_SKIP_SETUP BASECAMP_SETUP_AGENT

INSTALL_SH="${BATS_TEST_DIRNAME}/../scripts/install.sh"
INSTALL_PS1="${BATS_TEST_DIRNAME}/../scripts/install.ps1"

STUB_DIR="$(mktemp -d)"
LOG="$STUB_DIR/calls.log"
write_stub new # default: a binary that supports `setup agents`
}

teardown() {
[[ -n "${STUB_DIR:-}" ]] && rm -rf "$STUB_DIR"
}

# write_stub emits a `basecamp` stub that logs its argv. mode=new advertises the
# `setup agents` subcommand in `setup --help`; mode=old omits it and fails an
# actual `setup agents` invocation, mimicking a pre-v0.7.3 release binary.
write_stub() {
local mode="$1"
{
echo '#!/usr/bin/env bash'
echo "echo \"\$@\" >> \"$LOG\""
echo 'if [[ "$1 $2" == "setup --help" ]]; then'
echo ' echo " claude Install the Basecamp plugin for Claude Code"'
if [[ "$mode" == "new" ]]; then
echo ' echo " agents Install the Basecamp skill and connect detected coding agents"'
fi
echo ' exit 0'
echo 'fi'
if [[ "$mode" == "old" ]]; then
# A pre-`setup agents` binary advertises only `setup claude`. Reject every
# OTHER `setup <sub>`: the real old parent would swallow it as a stray arg
# and launch the interactive wizard — the exact bug the installer must avoid.
echo 'if [[ "$1" == "setup" && "$2" != "claude" && "$2" != "--help" ]]; then echo "unknown command \"$2\"" >&2; exit 1; fi'
fi
echo 'exit 0'
} > "$STUB_DIR/basecamp"
chmod +x "$STUB_DIR/basecamp"
}

run_post_install_setup() {
run bash -c "
set -euo pipefail
${1:-}
source '$INSTALL_SH'
BIN_DIR='$STUB_DIR'
post_install_setup basecamp
cat '$LOG'
"
}

# The if-form guard must let sourcing define functions without running main.
@test "install.sh can be sourced without running the installer" {
run bash -c "set -euo pipefail; source '$INSTALL_SH'; echo sourced-ok"
[[ "$status" -eq 0 ]]
[[ "$output" == *"sourced-ok"* ]]
[[ "$output" != *"Basecamp CLI"* ]] # banner would print if main ran
}

@test "new binary: post_install_setup dispatches to 'setup agents', never 'setup claude'" {
run_post_install_setup
[[ "$status" -eq 0 ]]
[[ "$output" == *"setup agents"* ]]
[[ "$output" != *"setup claude"* ]]
}

@test "new binary: BASECAMP_SKIP_SETUP path still runs 'setup agents'" {
run_post_install_setup "export BASECAMP_SKIP_SETUP=1"
[[ "$status" -eq 0 ]]
[[ "$output" == *"setup agents"* ]]
[[ "$output" != *"setup claude"* ]]
}

# Cross-version regression: an old release binary (no `setup agents`) must NOT
# silently fall back to Claude when the selector is unset — it installs the
# shared skill only.
@test "old binary + unset selector falls back to 'skill install', never 'setup claude'" {
write_stub old
run_post_install_setup
[[ "$status" -eq 0 ]]
[[ "$output" == *"skill install"* ]]
[[ "$output" != *"setup claude"* ]]
[[ "$output" != *"setup agents"$'\n'* ]] # the unknown command is never left as the outcome
}

@test "old binary + BASECAMP_SETUP_AGENT=claude connects claude explicitly" {
write_stub old
run_post_install_setup "export BASECAMP_SETUP_AGENT=claude"
[[ "$status" -eq 0 ]]
[[ "$output" == *"setup claude"* ]]
[[ "$output" != *"skill install"* ]]
}

# Explicit `all` intent must dispatch every per-agent setup the old binary
# supports (here the stub advertises only `claude`), never collapse to skill-only.
@test "old binary + BASECAMP_SETUP_AGENT=all runs the supported per-agent setups" {
write_stub old
run_post_install_setup "export BASECAMP_SETUP_AGENT=all"
[[ "$status" -eq 0 ]]
[[ "$output" == *"setup claude"* ]]
[[ "$output" != *"setup codex"* ]] # codex unadvertised → never invoked
}

# Explicit `codex` on an old binary that lacks `setup codex` must NOT run the
# unknown subcommand (which would launch the interactive wizard) — it degrades
# to the shared skill.
@test "old binary + BASECAMP_SETUP_AGENT=codex degrades to 'skill install', never 'setup codex'" {
write_stub old
run_post_install_setup "export BASECAMP_SETUP_AGENT=codex"
[[ "$status" -eq 0 ]]
[[ "$output" == *"skill install"* ]]
[[ "$output" != *"setup codex"* ]]
}

@test "install.sh has no residual 'setup claude' dispatch" {
# `setup claude` may appear only inside the explicit-selector fallback case.
run grep -n 'setup claude' "$INSTALL_SH"
[[ "$status" -ne 0 ]] # no literal `setup claude` string in the script
}

@test "install.sh skip and non-tty branches both dispatch via post_install_setup" {
run grep -c 'post_install_setup "\$binary_name"' "$INSTALL_SH"
[[ "$status" -eq 0 ]]
[[ "$output" -ge 2 ]]
}

@test "install.ps1 routes both branches through the guarded best-effort helper" {
run grep -c 'Invoke-PostInstallSetup \$installedBinary' "$INSTALL_PS1"
[[ "$status" -eq 0 ]]
[[ "$output" -eq 2 ]]
}

@test "install.ps1 helper is guarded and cross-version aware" {
grep -q 'function Invoke-PostInstallSetup' "$INSTALL_PS1"
grep -q 'setup agents' "$INSTALL_PS1"
grep -q 'skill install' "$INSTALL_PS1"
grep -q 'catch {' "$INSTALL_PS1"
# Explicit claude|codex selectors must be capability-checked before dispatch,
# so an old binary never gets an unadvertised subcommand as a stray arg.
grep -qF 'match "(?m)^\s+$selector\s"' "$INSTALL_PS1"
}
4 changes: 4 additions & 0 deletions e2e/smoke/smoke_lifecycle.bats
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ load smoke_helper
mark_out_of_scope "Modifies Claude Code config"
}

@test "setup agents is out of scope" {
mark_out_of_scope "Modifies coding-agent config"
}

@test "quick-start is out of scope" {
mark_out_of_scope "Interactive onboarding wizard"
}
Expand Down
12 changes: 9 additions & 3 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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.

**DONE WHEN:** `basecamp --version && basecamp auth status` both succeed.

---
Expand All @@ -10,7 +12,7 @@ Install the Basecamp CLI and connect it to your AI agent. Execute all steps auto

- [ ] Install Basecamp CLI
- [ ] Authenticate with Basecamp
- [ ] Connect agent (optional)
- [ ] Connect agent (optional — the piped installer already attempts one detected agent)

---

Expand All @@ -28,7 +30,11 @@ 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. You can also explicitly skip it with `BASECAMP_SKIP_SETUP=1`.
> **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`.
>
> 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`
> - PowerShell: `$env:BASECAMP_SETUP_AGENT='codex'; irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex`
>
> **Windows note:** if `curl` fails with a `schannel` / `CRYPT_E_NO_REVOCATION_CHECK` TLS error, prefer the PowerShell installer, Scoop, or Git Bash's `/usr/bin/curl` instead of the system `curl.exe`.

Expand Down Expand Up @@ -112,7 +118,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.

Both agent plugins require the `basecamp` CLI installed above — the plugin invokes it for every Basecamp operation.
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.

### Claude Code

Expand Down
29 changes: 25 additions & 4 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -332,18 +333,38 @@ func buildLogoutCmd(use string) *cobra.Command {
}

// printAgentNudge prints a hint about coding agent setup after login.
//
// Detection proves presence, not intent: with a single detected-unhealthy agent
// it points at that agent; with several, it never guesses — it prints every
// `basecamp setup <id>` choice so the user picks.
func printAgentNudge(w io.Writer, r *output.Renderer) {
type nudgeAgent struct{ id, name string }
var unhealthy []nudgeAgent
for _, agent := range harness.DetectedAgents() {
if agent.Checks == nil {
continue
}
for _, c := range agent.Checks() {
if c.Status != "pass" {
fmt.Fprintln(w)
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf(" %s detected. Connect it to Basecamp:", agent.Name)))
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf(" basecamp setup %s", agent.ID)))
return // one nudge is enough
unhealthy = append(unhealthy, nudgeAgent{id: agent.ID, name: agent.Name})
break
}
}
}
sort.Slice(unhealthy, func(i, j int) bool { return unhealthy[i].id < unhealthy[j].id })

switch len(unhealthy) {
case 0:
return
case 1:
fmt.Fprintln(w)
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf(" %s detected. Connect it to Basecamp:", unhealthy[0].name)))
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf(" basecamp setup %s", unhealthy[0].id)))
default:
fmt.Fprintln(w)
fmt.Fprintln(w, r.Muted.Render(" Multiple coding agents detected. Choose one:"))
for _, a := range unhealthy {
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf(" basecamp setup %s", a.id)))
}
}
}
63 changes: 63 additions & 0 deletions internal/commands/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package commands

import (
"bytes"
"io"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/output"
)

// nudgeOutput runs printAgentNudge against a non-styled renderer and returns
// the plain text written for the login hint.
func nudgeOutput(t *testing.T) string {
t.Helper()
buf := &bytes.Buffer{}
printAgentNudge(buf, output.NewRenderer(io.Discard, false))
return buf.String()
}

// TestPrintAgentNudgeNoneDetected: no detected agent → no nudge.
func TestPrintAgentNudgeNoneDetected(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", filepath.Join(home, "empty-bin"))

assert.Empty(t, nudgeOutput(t))
}

// TestPrintAgentNudgeSingle: one detected-unhealthy agent → its `setup <id>`.
func TestPrintAgentNudgeSingle(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", filepath.Join(home, "empty-bin"))
require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o755))

out := nudgeOutput(t)

assert.Contains(t, out, "Codex detected")
assert.Contains(t, out, "basecamp setup codex")
assert.NotContains(t, out, "basecamp setup claude")
}

// TestPrintAgentNudgeMultiple: ≥2 detected-unhealthy → every choice printed
// directly (never Claude-first, never routed to `setup agents`).
func TestPrintAgentNudgeMultiple(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", filepath.Join(home, "empty-bin"))
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o755))

out := nudgeOutput(t)

assert.Contains(t, out, "Multiple coding agents detected")
assert.Contains(t, out, "basecamp setup claude")
assert.Contains(t, out, "basecamp setup codex")
assert.NotContains(t, out, "basecamp setup agents")
}
Loading
Loading