diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 00000000..5e529908 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "basecamp", + "version": "0.7.2", + "description": "Use Basecamp from Codex to find work, create todos, and keep projects up to date.", + "author": { + "name": "37signals", + "email": "support@37signals.com", + "url": "https://37signals.com" + }, + "homepage": "https://basecamp.com/cli", + "repository": "https://github.com/basecamp/basecamp-cli", + "license": "MIT", + "skills": "./skills/", + "interface": { + "displayName": "Basecamp", + "shortDescription": "Work with Basecamp from Codex.", + "longDescription": "Find assignments, create and update Basecamp work, and diagnose the integration. Requires the Basecamp CLI (basecamp) installed and on your PATH.", + "developerName": "37signals", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://basecamp.com/cli", + "defaultPrompt": [ + "Show my assigned Basecamp work and highlight anything overdue.", + "Create a Basecamp todo from this task and assign it to me.", + "Summarize recent activity in the current Basecamp project." + ], + "composerIcon": "./assets/bc5-snowglobe.png", + "logo": "./assets/bc5-snowglobe.png" + } +} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ac8ccbc7..03065ec3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,7 +6,7 @@ project_name: basecamp before: hooks: - sh -c 'mkdir -p completions && go run ./cmd/basecamp completion bash > completions/basecamp.bash && go run ./cmd/basecamp completion zsh > completions/_basecamp && go run ./cmd/basecamp completion fish > completions/basecamp.fish' - - sh -c '{{ if .Prerelease }}echo "Skipping Claude plugin version stamp for prerelease {{ .Version }}"{{ else }}scripts/stamp-plugin-version.sh {{ .Version }}{{ end }}' + - sh -c '{{ if .Prerelease }}echo "Skipping plugin version stamps for prerelease {{ .Version }}"{{ else }}scripts/stamp-plugin-version.sh {{ .Version }} && scripts/stamp-codex-plugin-version.sh {{ .Version }}{{ end }}' builds: - id: basecamp diff --git a/.surface b/.surface index a42b363f..7f3d7cd5 100644 --- a/.surface +++ b/.surface @@ -845,6 +845,7 @@ CMD basecamp search metadata CMD basecamp search types CMD basecamp setup CMD basecamp setup claude +CMD basecamp setup codex CMD basecamp show CMD basecamp skill CMD basecamp skill install @@ -11394,6 +11395,27 @@ FLAG basecamp setup claude --stats type=bool FLAG basecamp setup claude --styled type=bool FLAG basecamp setup claude --todolist type=string FLAG basecamp setup claude --verbose type=count +FLAG basecamp setup codex --account type=string +FLAG basecamp setup codex --agent type=bool +FLAG basecamp setup codex --cache-dir type=string +FLAG basecamp setup codex --count type=bool +FLAG basecamp setup codex --help type=bool +FLAG basecamp setup codex --hints type=bool +FLAG basecamp setup codex --ids-only type=bool +FLAG basecamp setup codex --in type=string +FLAG basecamp setup codex --jq type=string +FLAG basecamp setup codex --json type=bool +FLAG basecamp setup codex --markdown type=bool +FLAG basecamp setup codex --md type=bool +FLAG basecamp setup codex --no-hints type=bool +FLAG basecamp setup codex --no-stats type=bool +FLAG basecamp setup codex --profile type=string +FLAG basecamp setup codex --project type=string +FLAG basecamp setup codex --quiet type=bool +FLAG basecamp setup codex --stats type=bool +FLAG basecamp setup codex --styled type=bool +FLAG basecamp setup codex --todolist type=string +FLAG basecamp setup codex --verbose type=count FLAG basecamp show --account type=string FLAG basecamp show --agent type=bool FLAG basecamp show --all-comments type=bool @@ -16394,6 +16416,7 @@ SUB basecamp search metadata SUB basecamp search types SUB basecamp setup SUB basecamp setup claude +SUB basecamp setup codex SUB basecamp show SUB basecamp skill SUB basecamp skill install diff --git a/AGENTS.md b/AGENTS.md index ab49f115..ac4765d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,8 @@ basecamp-cli/ │ └── version/ # Version info ├── e2e/ # BATS integration tests ├── skills/ # Agent skills -└── .claude-plugin/ # Claude Code integration +├── .claude-plugin/ # Claude Code integration +└── .codex-plugin/ # Codex plugin manifest ``` ## Basecamp API Reference diff --git a/Makefile b/Makefile index 82bc7419..aa0757cd 100644 --- a/Makefile +++ b/Makefile @@ -401,6 +401,7 @@ check-surface-compat: build .PHONY: check-skill-drift check-skill-drift: @scripts/check-skill-drift.sh + @scripts/check-skill-drift.sh skills/basecamp-doctor/SKILL.md # Verify group commands show help bare (no RunE on parents with subcommands) .PHONY: check-bare-groups diff --git a/README.md b/README.md index 2e5b2a79..dadb00e9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - Works standalone or with any AI agent (Claude, Codex, Copilot, Gemini) - JSON output with breadcrumbs for easy navigation - OAuth authentication with automatic token refresh -- Includes agent skill and Claude plugin +- Includes agent skills plus native Claude Code and Codex plugins ## Quick Start @@ -148,7 +148,21 @@ Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set t `basecamp` works with any AI agent that can run shell commands. -**Claude Code:** `basecamp setup claude` — installs the plugin with skills, hooks, and agent workflow support. +Both plugins require the `basecamp` CLI installed and on your PATH. + +**Claude Code:** `basecamp setup claude` — installs the plugin with skills and agent workflow support. + +**Codex:** `basecamp setup codex` — registers the 37signals marketplace and installs the native plugin with Basecamp skills and diagnostics. Start a new Codex thread afterward to load the skills. + +Manual Codex installation uses the same marketplace: + +```bash +codex plugin marketplace add basecamp/claude-plugins +codex plugin add basecamp@37signals +``` + +To pick up a newer plugin version later, refresh the marketplace with +`codex plugin marketplace upgrade 37signals` (or re-run `basecamp setup codex`). **Other agents:** Point your agent at [`skills/basecamp/SKILL.md`](skills/basecamp/SKILL.md) for Basecamp workflow coverage. @@ -180,6 +194,7 @@ See [install.md](install.md) for step-by-step setup instructions. ```bash basecamp doctor # Check CLI health and diagnose issues basecamp doctor --verbose # Verbose output with details +basecamp doctor --json # Structured checks, including Claude and Codex ``` ## Development diff --git a/RELEASING.md b/RELEASING.md index 098714eb..78f47909 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -23,7 +23,7 @@ make release VERSION=0.2.0 DRY_RUN=1 1. Validates semver format, main branch, clean tree, synced with remote 2. Checks for `replace` directives in go.mod 3. Runs `make release-check` (quality checks, vuln scan, replace-check, race-test, surface compat) -4. Updates stable release metadata (`nix/package.nix` and `.claude-plugin/plugin.json`) for stable versions +4. Updates stable release metadata (`nix/package.nix`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`) for stable versions 5. Creates annotated tag `v$VERSION` and pushes to origin 6. GitHub Actions [release workflow](.github/workflows/release.yml) runs: - Security scan + full test suite + CLI surface compatibility check @@ -58,6 +58,7 @@ manager channels and agent distribution metadata on the latest stable version. | AUR | Updates the `basecamp-cli` package when `AUR_KEY` is configured. | The stable AUR package remains active. | | Nix flake | Updates `nix/package.nix` and verifies the flake. | Nix metadata remains on the latest stable version. | | Claude plugin metadata | Updates `.claude-plugin/plugin.json`. | Plugin metadata remains on the latest stable version. | +| Codex plugin metadata | Updates `.codex-plugin/plugin.json`. | Plugin metadata remains on the latest stable version. | | Skills distribution | Syncs skills to the distribution repo. | Uses the skill embedded in the prerelease binary. | The prerelease binary embeds the matching `skills/basecamp/SKILL.md`. Testers can diff --git a/install.md b/install.md index e6b07448..7f4c2d38 100644 --- a/install.md +++ b/install.md @@ -112,13 +112,38 @@ 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. + ### Claude Code ```bash basecamp setup claude ``` -This registers the marketplace and installs the plugin with skills, hooks, and agent workflow support. +This registers the marketplace and installs the plugin with skills and agent workflow support. + +### Codex + +```bash +basecamp setup codex +``` + +This installs the shared Basecamp skill, registers the 37signals Codex marketplace, and installs the native plugin. Start a new Codex thread after setup to load the skills. + +For a manual install: + +```bash +codex plugin marketplace add basecamp/claude-plugins +codex plugin add basecamp@37signals +``` + +To pick up a newer plugin version later, refresh with `codex plugin marketplace upgrade 37signals` (or re-run `basecamp setup codex`). + +Verify either agent integration with structured diagnostics: + +```bash +basecamp doctor --json +``` ### Other Agents diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index d6db6df0..00fb3b2d 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -206,28 +206,20 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check checks = append(checks, checkSkillVersion()) } for _, agent := range harness.DetectedAgents() { - if agent.Checks != nil { - for _, c := range agent.Checks() { - checks = append(checks, Check{ - Name: c.Name, - Status: c.Status, - Message: c.Message, - Hint: c.Hint, - }) - } + var agentChecks []*harness.StatusCheck + if agent.Diagnostics != nil { + agentChecks = agent.Diagnostics(ctx) + } else if agent.Checks != nil { + agentChecks = agent.Checks() + } + for _, c := range agentChecks { + checks = append(checks, Check{ + Name: c.Name, + Status: c.Status, + Message: c.Message, + Hint: c.Hint, + }) } - } - - // 13. Claude plugin version (doctor-only, not part of generic agent checks - // which gate setup wizard behavior) - if harness.DetectClaude() { - pvc := harness.CheckClaudePluginVersion() - checks = append(checks, Check{ - Name: pvc.Name, - Status: pvc.Status, - Message: pvc.Message, - Hint: pvc.Hint, - }) } return checks @@ -985,6 +977,12 @@ func buildDoctorBreadcrumbs(checks []Check) []output.Breadcrumb { Cmd: "basecamp skill install", Description: "Update installed skill", }) + case "Codex Plugin", "Codex Plugin Version": + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ + Action: "setup_codex", + Cmd: "basecamp setup codex", + Description: "Install or update the Codex plugin", + }) } } diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index e246d007..a37f29ee 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -5,6 +5,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/spf13/cobra" @@ -662,6 +663,51 @@ func TestBuildDoctorBreadcrumbs_SkillVersionWarn(t *testing.T) { assert.Equal(t, "basecamp skill install", breadcrumbs[0].Cmd) } +func TestCheckCodexIntegrationIncludesVersion(t *testing.T) { + logPath := installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + app, _ := setupDoctorTestApp(t, "") + + checks := runDoctorChecks(context.Background(), app, false) + var names []string + for _, check := range checks { + if check.Name == "Codex Plugin" || check.Name == "Codex Plugin Version" { + names = append(names, check.Name) + } + } + require.Equal(t, []string{"Codex Plugin", "Codex Plugin Version"}, names) + assert.Equal(t, 1, strings.Count(readCodexSetupCalls(t, logPath), "plugin list --available --json")) +} + +func TestCheckCodexIntegrationWarnsOnVersionMismatch(t *testing.T) { + installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + app, _ := setupDoctorTestApp(t, "") + original := version.Version + version.Version = "0.8.0" + t.Cleanup(func() { version.Version = original }) + + checks := runDoctorChecks(context.Background(), app, false) + for _, check := range checks { + if check.Name == "Codex Plugin Version" { + assert.Equal(t, "warn", check.Status) + assert.Contains(t, check.Hint, "basecamp setup codex") + return + } + } + t.Fatal("Codex Plugin Version check not found") +} + +func TestBuildDoctorBreadcrumbs_Codex(t *testing.T) { + checks := []Check{ + {Name: "Codex Plugin", Status: "fail"}, + {Name: "Codex Plugin Version", Status: "warn"}, + } + + breadcrumbs := buildDoctorBreadcrumbs(checks) + + require.Len(t, breadcrumbs, 1) + assert.Equal(t, "basecamp setup codex", breadcrumbs[0].Cmd) +} + func TestCheckLegacyInstall_SkipsKeyringWhenNoKeyring(t *testing.T) { t.Setenv("BASECAMP_NO_KEYRING", "1") t.Setenv("XDG_CACHE_HOME", t.TempDir()) diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index c69d8af8..fc3fe8bf 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -2,6 +2,7 @@ package commands import ( "context" + "errors" "fmt" "os" "os/exec" @@ -19,11 +20,19 @@ import ( // agentSetupHandler describes what a single agent's setup step does and how to run it. type agentSetupHandler struct { Labels []string // what this will do - Confirm string // confirmation prompt Run func(cmd *cobra.Command, styles *tui.Styles) error // interactive setup RunNonInteractive func(cmd *cobra.Command) error // non-interactive setup } +// agentSetupError is a setup failure with manual remediation commands the +// user (or agent) can run themselves. +type agentSetupError struct { + Summary string + Manual []string +} + +func (e *agentSetupError) Error() string { return e.Summary } + // agentSetupHandlers maps agent ID → setup handler. var agentSetupHandlers = map[string]agentSetupHandler{ "claude": { @@ -31,10 +40,17 @@ var agentSetupHandlers = map[string]agentSetupHandler{ "Add basecamp/claude-plugins marketplace to Claude Code", "Install the basecamp plugin for Claude Code", }, - Confirm: "Set up Basecamp for your coding agents?", Run: runClaudeSetup, RunNonInteractive: runClaudeSetupNonInteractive, }, + "codex": { + Labels: []string{ + "Add the 37signals marketplace to Codex", + "Install the basecamp plugin for Codex", + }, + Run: runCodexSetup, + RunNonInteractive: runCodexSetupNonInteractive, + }, } // runClaudeSetup performs the Claude Code-specific setup steps @@ -438,6 +454,7 @@ func newSetupAgentCmds() []*cobra.Command { _, skillErr := installSkillFiles() var setupErrors []string + var manualCommands []string if skillErr != nil { setupErrors = append(setupErrors, fmt.Sprintf("skill install: %s", skillErr)) } @@ -446,6 +463,10 @@ func newSetupAgentCmds() []*cobra.Command { if h.RunNonInteractive != nil { if err := h.RunNonInteractive(cmd); err != nil { setupErrors = append(setupErrors, err.Error()) + var setupErr *agentSetupError + if errors.As(err, &setupErr) { + manualCommands = setupErr.Manual + } } } } else { @@ -498,12 +519,24 @@ func newSetupAgentCmds() []*cobra.Command { summary = agent.Name + " plugin not installed" } } + if len(manualCommands) > 0 { + result["manual_commands"] = manualCommands + } + + breadcrumbs := []output.Breadcrumb{ + {Action: "doctor", Cmd: "basecamp doctor", Description: "Check CLI health"}, + } + for i, manual := range manualCommands { + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ + Action: fmt.Sprintf("manual_step_%d", i+1), + Cmd: manual, + Description: "Manual setup step", + }) + } return app.OK(result, output.WithSummary(summary), - output.WithBreadcrumbs( - output.Breadcrumb{Action: "doctor", Cmd: "basecamp doctor", Description: "Check CLI health"}, - ), + output.WithBreadcrumbs(breadcrumbs...), ) }, }) diff --git a/internal/commands/wizard_codex.go b/internal/commands/wizard_codex.go new file mode 100644 index 00000000..521cc27b --- /dev/null +++ b/internal/commands/wizard_codex.go @@ -0,0 +1,166 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/harness" + "github.com/basecamp/basecamp-cli/internal/tui" +) + +const ( + codexMarketplaceTimeout = 20 * time.Second + codexInstallTimeout = 20 * time.Second + codexVerifyTimeout = 5 * time.Second +) + +// runCodexSetupCommand runs a codex subcommand, capturing stdout for --json +// parsing while streaming stderr (progress, prompts, errors) to the given +// writer. Captured stderr is returned separately so structured stdout parsing +// is never defeated by stderr noise, while failure sniffing still sees +// everything the command said. +var runCodexSetupCommand = func(ctx context.Context, stderr io.Writer, path string, args ...string) (stdoutOutput, stderrOutput []byte, err error) { + command := exec.CommandContext(ctx, path, args...) //nolint:gosec // path comes from FindCodexBinary + var stdout, captured bytes.Buffer + command.Stdout = &stdout + if stderr != nil { + command.Stderr = io.MultiWriter(stderr, &captured) + } else { + command.Stderr = &captured + } + err = command.Run() + return stdout.Bytes(), captured.Bytes(), err +} + +// runCodexSetup performs the Codex setup steps for the interactive wizard. +// Failures warn and continue — Codex setup never aborts `basecamp setup`. +func runCodexSetup(cmd *cobra.Command, styles *tui.Styles) error { + w := cmd.OutOrStdout() + progress := func(message string) { + fmt.Fprintln(w, styles.Muted.Render(" "+message)) + } + + if err := installCodexPlugin(cmd.Context(), cmd.ErrOrStderr(), progress); err != nil { + fmt.Fprintln(w, styles.Warning.Render(" Codex plugin setup failed: "+err.Error())) + var setupErr *agentSetupError + if errors.As(err, &setupErr) && len(setupErr.Manual) > 0 { + fmt.Fprintln(w, styles.Muted.Render(" Try manually:")) + for _, manual := range setupErr.Manual { + fmt.Fprintln(w, styles.Bold.Render(" "+manual)) + } + } + fmt.Fprintln(w, styles.Muted.Render(" Then verify with: basecamp doctor")) + return nil + } + + fmt.Fprintln(w, styles.RenderStatus(true, "37signals marketplace ready")) + fmt.Fprintln(w, styles.RenderStatus(true, "Codex plugin installed and enabled")) + fmt.Fprintln(w) + fmt.Fprintln(w, styles.Muted.Render(" Start a new Codex thread to load the Basecamp skills.")) + return nil +} + +func runCodexSetupNonInteractive(cmd *cobra.Command) error { + return installCodexPlugin(cmd.Context(), cmd.ErrOrStderr(), func(string) {}) +} + +func installCodexPlugin(parent context.Context, stderr io.Writer, progress func(string)) error { + codexPath := harness.FindCodexBinary() + if codexPath == "" { + // No Codex subcommands here — they would be impossible to run. + return &agentSetupError{ + Summary: "Codex executable not found — install Codex, then re-run setup", + Manual: []string{"basecamp setup codex"}, + } + } + + progress("Registering 37signals marketplace…") + stdout, stderrOutput, err := runCodexStep(parent, stderr, codexMarketplaceTimeout, codexPath, + "plugin", "marketplace", "add", harness.CodexMarketplaceSource, "--json") + alreadyAdded := codexMarketplaceAlreadyAdded(stdout, stderrOutput) + if err != nil && !alreadyAdded { + return codexSetupError("marketplace add failed: " + codexCommandFailure(stdout, stderrOutput, err)) + } + if alreadyAdded { + progress("Refreshing 37signals marketplace…") + upgradeStdout, upgradeStderr, upgradeErr := runCodexStep(parent, stderr, codexMarketplaceTimeout, codexPath, + "plugin", "marketplace", "upgrade", harness.CodexMarketplaceName, "--json") + if upgradeErr != nil { + return codexSetupError("marketplace upgrade failed: " + codexCommandFailure(upgradeStdout, upgradeStderr, upgradeErr)) + } + } + + progress("Installing basecamp plugin…") + stdout, stderrOutput, err = runCodexStep(parent, stderr, codexInstallTimeout, codexPath, + "plugin", "add", harness.CodexExpectedPluginKey, "--json") + if err != nil && !codexPluginAlreadyInstalled(stdout, stderrOutput) { + return codexSetupError("plugin add failed: " + codexCommandFailure(stdout, stderrOutput, err)) + } + + progress("Verifying installation…") + verifyCtx, cancel := context.WithTimeout(parent, codexVerifyTimeout) + defer cancel() + check := harness.CheckCodexPluginContext(verifyCtx) + if check.Status != "pass" { + detail := check.Message + if check.Hint != "" { + detail += "; " + check.Hint + } + return codexSetupError("plugin verification failed: " + detail) + } + return nil +} + +func runCodexStep(parent context.Context, stderr io.Writer, timeout time.Duration, path string, args ...string) (stdoutOutput, stderrOutput []byte, err error) { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + return runCodexSetupCommand(ctx, stderr, path, args...) +} + +func codexMarketplaceAlreadyAdded(stdout, stderr []byte) bool { + var result struct { + AlreadyAdded bool `json:"alreadyAdded"` + } + if json.Unmarshal(stdout, &result) == nil && result.AlreadyAdded { + return true + } + message := strings.ToLower(string(stdout) + " " + string(stderr)) + return strings.Contains(message, "already") && + (strings.Contains(message, "registered") || strings.Contains(message, "configured") || strings.Contains(message, "exists")) +} + +func codexPluginAlreadyInstalled(stdout, stderr []byte) bool { + message := strings.ToLower(string(stdout) + " " + string(stderr)) + return strings.Contains(message, "already") && strings.Contains(message, "installed") +} + +func codexCommandFailure(stdout, stderr []byte, err error) string { + message := strings.TrimSpace(strings.TrimSpace(string(stdout)) + "\n" + strings.TrimSpace(string(stderr))) + if len(message) > 500 { + message = message[:500] + } + if message == "" { + return err.Error() + } + return message +} + +func codexSetupError(summary string) *agentSetupError { + return &agentSetupError{ + Summary: summary, + Manual: []string{ + fmt.Sprintf("codex plugin marketplace add %s", harness.CodexMarketplaceSource), + fmt.Sprintf("codex plugin marketplace upgrade %s", harness.CodexMarketplaceName), + fmt.Sprintf("codex plugin add %s", harness.CodexExpectedPluginKey), + }, + } +} diff --git a/internal/commands/wizard_codex_test.go b/internal/commands/wizard_codex_test.go new file mode 100644 index 00000000..0a3aedb8 --- /dev/null +++ b/internal/commands/wizard_codex_test.go @@ -0,0 +1,292 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/tui" +) + +func TestNewSetupCmdHasCodexSubcommand(t *testing.T) { + cmd := NewSetupCmd() + + assert.NotNil(t, findSubcommand(cmd, "codex")) +} + +func findSubcommand(cmd interface{ Commands() []*cobra.Command }, name string) *cobra.Command { + for _, child := range cmd.Commands() { + if child.Name() == name { + return child + } + } + return nil +} + +func TestSetupCodexFreshInstallCommandOrder(t *testing.T) { + logPath := installCodexStub(t, codexStubOptions{}) + + envelope := runSetupCodexJSON(t) + + assert.True(t, envelope.Data.PluginInstalled) + assert.True(t, envelope.Data.AgentDetected) + assert.Empty(t, envelope.Data.Errors) + calls := readCodexSetupCalls(t, logPath) + assertCallOrder(t, calls, + "plugin marketplace add basecamp/claude-plugins --json", + "plugin add basecamp@37signals --json", + "plugin list --available --json", + ) +} + +func TestSetupCodexAlreadyAddedRefreshesMarketplace(t *testing.T) { + logPath := installCodexStub(t, codexStubOptions{marketplaceAlreadyAdded: true}) + + envelope := runSetupCodexJSON(t) + + assert.True(t, envelope.Data.PluginInstalled) + calls := readCodexSetupCalls(t, logPath) + assertCallOrder(t, calls, + "plugin marketplace add basecamp/claude-plugins --json", + "plugin marketplace upgrade 37signals --json", + "plugin add basecamp@37signals --json", + ) +} + +func TestSetupCodexIdempotentAddRefreshesMarketplace(t *testing.T) { + logPath := installCodexStub(t, codexStubOptions{marketplaceAlreadyAddedSuccess: true}) + + envelope := runSetupCodexJSON(t) + + assert.True(t, envelope.Data.PluginInstalled) + calls := readCodexSetupCalls(t, logPath) + assertCallOrder(t, calls, + "plugin marketplace add basecamp/claude-plugins --json", + "plugin marketplace upgrade 37signals --json", + "plugin add basecamp@37signals --json", + ) +} + +func TestSetupCodexAlreadyInstalledIsIdempotent(t *testing.T) { + logPath := installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + + envelope := runSetupCodexJSON(t) + + assert.True(t, envelope.Data.PluginInstalled) + assert.Empty(t, envelope.Data.Errors) + assert.Contains(t, readCodexSetupCalls(t, logPath), "plugin add basecamp@37signals --json") +} + +func TestSetupCodexMissingBinaryReturnsManualCommands(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", home) + + envelope := runSetupCodexJSON(t) + + assert.False(t, envelope.Data.AgentDetected) + assert.False(t, envelope.Data.PluginInstalled) + require.NotEmpty(t, envelope.Data.Errors) + assert.Contains(t, envelope.Data.Errors[0], "Codex executable not found") + assert.Contains(t, envelope.Data.Errors[0], "install Codex") + // Without a codex binary, codex subcommands are impossible — remediation + // must not suggest them. + assert.Equal(t, []string{"basecamp setup codex"}, envelope.Data.ManualCommands) + + breadcrumbCmds := make([]string, 0, len(envelope.Breadcrumbs)) + for _, breadcrumb := range envelope.Breadcrumbs { + breadcrumbCmds = append(breadcrumbCmds, breadcrumb.Cmd) + assert.NotContains(t, breadcrumb.Cmd, "codex plugin") + } + assert.Contains(t, breadcrumbCmds, "basecamp doctor") + assert.Contains(t, breadcrumbCmds, "basecamp setup codex") +} + +func TestSetupCodexMarketplaceFailureStopsInstall(t *testing.T) { + logPath := installCodexStub(t, codexStubOptions{marketplaceFailure: true}) + + envelope := runSetupCodexJSON(t) + + assert.False(t, envelope.Data.PluginInstalled) + require.NotEmpty(t, envelope.Data.Errors) + assert.Contains(t, envelope.Data.Errors[0], "marketplace add") + assert.NotEmpty(t, envelope.Data.ManualCommands) + assert.NotContains(t, readCodexSetupCalls(t, logPath), "plugin add basecamp@37signals --json") +} + +func TestSetupCodexPluginFailureReturnsStructuredError(t *testing.T) { + installCodexStub(t, codexStubOptions{pluginFailure: true}) + + envelope := runSetupCodexJSON(t) + + assert.False(t, envelope.Data.PluginInstalled) + require.NotEmpty(t, envelope.Data.Errors) + assert.Contains(t, envelope.Data.Errors[0], "plugin add") +} + +func TestSetupCodexFailedVerificationReturnsStructuredError(t *testing.T) { + installCodexStub(t, codexStubOptions{verificationMissing: true}) + + envelope := runSetupCodexJSON(t) + + assert.False(t, envelope.Data.PluginInstalled) + require.NotEmpty(t, envelope.Data.Errors) + assert.Contains(t, envelope.Data.Errors[0], "verification") +} + +func TestRunCodexSetupInteractiveExplainsNextSteps(t *testing.T) { + installCodexStub(t, codexStubOptions{}) + cmd := &cobra.Command{} + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(context.Background()) + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + + require.NoError(t, runCodexSetup(cmd, styles)) + + assert.Contains(t, output.String(), "Registering 37signals marketplace") + assert.Contains(t, output.String(), "Installing basecamp plugin") + assert.Contains(t, output.String(), "Start a new Codex thread") + // No hooks ship yet — setup must not point users at /hooks trust. + assert.NotContains(t, output.String(), "/hooks") +} + +func TestRunCodexSetupInteractiveFailureWarnsAndContinues(t *testing.T) { + installCodexStub(t, codexStubOptions{marketplaceFailure: true}) + cmd := &cobra.Command{} + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(context.Background()) + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + + require.NoError(t, runCodexSetup(cmd, styles), "codex setup failure must not abort the wizard") + + assert.Contains(t, output.String(), "Codex plugin setup failed") + assert.Contains(t, output.String(), "codex plugin marketplace add basecamp/claude-plugins") + assert.Contains(t, output.String(), "codex plugin add basecamp@37signals") + assert.Contains(t, output.String(), "basecamp doctor") +} + +func TestInstallCodexPluginReturnsStructuredError(t *testing.T) { + installCodexStub(t, codexStubOptions{marketplaceFailure: true}) + + err := installCodexPlugin(context.Background(), nil, func(string) {}) + + var setupErr *agentSetupError + require.ErrorAs(t, err, &setupErr) + assert.Contains(t, setupErr.Summary, "marketplace add failed") + assert.Equal(t, []string{ + "codex plugin marketplace add basecamp/claude-plugins", + "codex plugin marketplace upgrade 37signals", + "codex plugin add basecamp@37signals", + }, setupErr.Manual) +} + +type setupCodexEnvelope struct { + Summary string `json:"summary"` + Data struct { + PluginInstalled bool `json:"plugin_installed"` + AgentDetected bool `json:"agent_detected"` + Errors []string `json:"errors"` + ManualCommands []string `json:"manual_commands"` + } `json:"data"` + Breadcrumbs []struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` +} + +func runSetupCodexJSON(t *testing.T) setupCodexEnvelope { + t.Helper() + app, output := setupQuickstartTestApp(t, "", "") + app.Flags.JSON = true + app.Flags.Hints = true + t.Cleanup(app.Close) + + cmd := NewSetupCmd() + cmd.SetArgs([]string{"codex"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope setupCodexEnvelope + require.NoError(t, json.Unmarshal(output.Bytes(), &envelope), output.String()) + return envelope +} + +type codexStubOptions struct { + marketplaceAlreadyAdded bool + marketplaceAlreadyAddedSuccess bool + marketplaceFailure bool + pluginAlreadyInstalled bool + pluginFailure bool + verificationMissing bool +} + +func installCodexStub(t *testing.T, options codexStubOptions) string { + t.Helper() + home := t.TempDir() + binDir := filepath.Join(home, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + statePath := filepath.Join(home, "installed") + if options.pluginAlreadyInstalled { + require.NoError(t, os.WriteFile(statePath, []byte("installed"), 0o644)) + } + logPath := filepath.Join(home, "codex-calls.log") + boolShell := func(value bool) string { + if value { + return "1" + } + return "0" + } + script := "#!/bin/sh\n" + + "echo \"$*\" >> \"" + logPath + "\"\n" + + "case \"$*\" in\n" + + " \"plugin marketplace add basecamp/claude-plugins --json\")\n" + + " if [ " + boolShell(options.marketplaceFailure) + " = 1 ]; then echo 'network failure' >&2; exit 1; fi\n" + + " if [ " + boolShell(options.marketplaceAlreadyAdded) + " = 1 ]; then echo 'marketplace 37signals already registered' >&2; exit 1; fi\n" + + " if [ " + boolShell(options.marketplaceAlreadyAddedSuccess) + " = 1 ]; then echo '{\"marketplaceName\":\"37signals\",\"alreadyAdded\":true}'; exit 0; fi\n" + + " echo '{\"name\":\"37signals\"}'; exit 0 ;;\n" + + " \"plugin marketplace upgrade 37signals --json\") echo '{\"name\":\"37signals\"}'; exit 0 ;;\n" + + " \"plugin add basecamp@37signals --json\")\n" + + " if [ " + boolShell(options.pluginFailure) + " = 1 ]; then echo 'plugin failure' >&2; exit 1; fi\n" + + " : > \"" + statePath + "\"; echo '{\"installed\":true}'; exit 0 ;;\n" + + " \"plugin list --available --json\")\n" + + " if [ " + boolShell(options.verificationMissing) + " = 1 ]; then echo '{\"installed\":[],\"available\":[{\"pluginId\":\"basecamp@37signals\",\"version\":\"0.7.2\",\"installed\":false,\"enabled\":false}]}'; exit 0; fi\n" + + " if [ -f \"" + statePath + "\" ]; then echo '{\"installed\":[{\"pluginId\":\"basecamp@37signals\",\"version\":\"0.7.2\",\"installed\":true,\"enabled\":true}],\"available\":[]}'; else echo '{\"installed\":[],\"available\":[]}'; fi; exit 0 ;;\n" + + " *) echo 'unexpected command' >&2; exit 1 ;;\n" + + "esac\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "codex"), []byte(script), 0o755)) //nolint:gosec // test executable + t.Setenv("HOME", home) + t.Setenv("PATH", binDir) + return logPath +} + +func readCodexSetupCalls(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +func assertCallOrder(t *testing.T, calls string, expected ...string) { + t.Helper() + position := -1 + for _, call := range expected { + next := strings.Index(calls[position+1:], call) + require.NotEqual(t, -1, next, "missing call %q in:\n%s", call, calls) + position += next + 1 + } +} diff --git a/internal/harness/agent.go b/internal/harness/agent.go index 5821f700..1a3f686a 100644 --- a/internal/harness/agent.go +++ b/internal/harness/agent.go @@ -1,13 +1,21 @@ package harness -import "sync" +import ( + "context" + "sync" +) // AgentInfo describes a coding agent integration. type AgentInfo struct { Name string // "Claude Code" ID string // "claude" Detect func() bool // returns true if the agent is installed - Checks func() []*StatusCheck // health checks for doctor + Checks func() []*StatusCheck // cheap health checks gating setup wizard behavior + + // Diagnostics returns the full doctor check suite, including checks that + // are too slow or noisy for the wizard (e.g. version comparisons). + // When nil, doctor falls back to Checks. + Diagnostics func(ctx context.Context) []*StatusCheck } var ( diff --git a/internal/harness/claude.go b/internal/harness/claude.go index c14b734d..d94e900e 100644 --- a/internal/harness/claude.go +++ b/internal/harness/claude.go @@ -1,6 +1,7 @@ package harness import ( + "context" "encoding/json" "fmt" "os" @@ -15,20 +16,25 @@ func init() { Name: "Claude Code", ID: "claude", Detect: DetectClaude, - Checks: func() []*StatusCheck { - checks := []*StatusCheck{CheckClaudePlugin()} - // Only check the skill link if ~/.claude exists (i.e. Claude is dir-detected) - home, err := os.UserHomeDir() - if err == nil { - if info, statErr := os.Stat(filepath.Join(home, ".claude")); statErr == nil && info.IsDir() { - checks = append(checks, CheckClaudeSkillLink()) - } - } - return checks + Checks: claudeChecks, + Diagnostics: func(_ context.Context) []*StatusCheck { + return append(claudeChecks(), CheckClaudePluginVersion()) }, }) } +func claudeChecks() []*StatusCheck { + checks := []*StatusCheck{CheckClaudePlugin()} + // Only check the skill link if ~/.claude exists (i.e. Claude is dir-detected) + home, err := os.UserHomeDir() + if err == nil { + if info, statErr := os.Stat(filepath.Join(home, ".claude")); statErr == nil && info.IsDir() { + checks = append(checks, CheckClaudeSkillLink()) + } + } + return checks +} + // ClaudeMarketplaceSource is the marketplace repository for the Basecamp plugin. // Migrating from basecamp/basecamp-cli → basecamp/claude-plugins. const ClaudeMarketplaceSource = "basecamp/claude-plugins" diff --git a/internal/harness/codex.go b/internal/harness/codex.go new file mode 100644 index 00000000..6f54412e --- /dev/null +++ b/internal/harness/codex.go @@ -0,0 +1,253 @@ +package harness + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/basecamp/basecamp-cli/internal/version" +) + +const ( + // CodexMarketplaceSource is the Git marketplace repository containing Basecamp. + CodexMarketplaceSource = "basecamp/claude-plugins" + // CodexPluginName is the plugin identifier to install. + CodexPluginName = "basecamp" + // CodexMarketplaceName is the marketplace name published by 37signals. + CodexMarketplaceName = "37signals" + // CodexExpectedPluginKey is the fully qualified Basecamp plugin ID. + CodexExpectedPluginKey = CodexPluginName + "@" + CodexMarketplaceName +) + +var ( + codexLookPath = exec.LookPath + runCodexCommand = func(ctx context.Context, path string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, path, args...).Output() //nolint:gosec // path comes from exec.LookPath + } +) + +var ( + errCodexBinaryMissing = errors.New("codex executable not found") + errCodexParse = errors.New("parse Codex plugin list") +) + +type codexPluginState struct { + PluginID string `json:"pluginId"` + Name string `json:"name"` + Marketplace string `json:"marketplaceName"` + Version string `json:"version"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` +} + +func init() { + RegisterAgent(AgentInfo{ + Name: "Codex", + ID: "codex", + Detect: DetectCodex, + Checks: func() []*StatusCheck { + return []*StatusCheck{CheckCodexPlugin()} + }, + Diagnostics: CheckCodexPluginDiagnosticsContext, + }) +} + +// DetectCodex returns true when Codex has a home directory or executable. +func DetectCodex() bool { + home, err := os.UserHomeDir() + if err == nil { + info, statErr := os.Stat(filepath.Join(filepath.Clean(home), ".codex")) + if statErr == nil && info.IsDir() { + return true + } + } + return FindCodexBinary() != "" +} + +// FindCodexBinary returns the Codex executable path, or an empty string. +func FindCodexBinary() string { + if path, err := codexLookPath("codex"); err == nil { + return path + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + candidate := filepath.Join(filepath.Clean(home), ".local", "bin", "codex") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} + +// CheckCodexPlugin verifies that Basecamp is installed and enabled in Codex. +func CheckCodexPlugin() *StatusCheck { + return CheckCodexPluginContext(context.Background()) +} + +// CheckCodexPluginContext verifies the plugin using the caller's context. +func CheckCodexPluginContext(ctx context.Context) *StatusCheck { + state, found, err := queryCodexPlugin(ctx) + return codexPluginCheck(state, found, err) +} + +// CheckCodexPluginDiagnosticsContext checks plugin health and version from one Codex query. +func CheckCodexPluginDiagnosticsContext(ctx context.Context) []*StatusCheck { + state, found, err := queryCodexPlugin(ctx) + return []*StatusCheck{ + codexPluginCheck(state, found, err), + codexPluginVersionCheck(state, found, err), + } +} + +func codexPluginCheck(state codexPluginState, found bool, err error) *StatusCheck { + if err != nil { + return codexQueryFailure("Codex Plugin", err) + } + if !found || !state.Installed { + return &StatusCheck{ + Name: "Codex Plugin", + Status: "fail", + Message: "Plugin not installed", + Hint: "Run: basecamp setup codex", + } + } + if !state.Enabled { + return &StatusCheck{ + Name: "Codex Plugin", + Status: "fail", + Message: "Installed but disabled", + Hint: "Run: basecamp setup codex", + } + } + return &StatusCheck{ + Name: "Codex Plugin", + Status: "pass", + Message: "Installed and enabled", + } +} + +// CheckCodexPluginVersion compares the installed plugin and CLI versions. +func CheckCodexPluginVersion() *StatusCheck { + return CheckCodexPluginVersionContext(context.Background()) +} + +// CheckCodexPluginVersionContext compares plugin and CLI versions using the caller's context. +func CheckCodexPluginVersionContext(ctx context.Context) *StatusCheck { + state, found, err := queryCodexPlugin(ctx) + return codexPluginVersionCheck(state, found, err) +} + +func codexPluginVersionCheck(state codexPluginState, found bool, err error) *StatusCheck { + if err != nil { + return codexQueryFailure("Codex Plugin Version", err) + } + if !found || !state.Installed { + return &StatusCheck{ + Name: "Codex Plugin Version", + Status: "skip", + Message: "Skipped (plugin not installed)", + } + } + if state.Version == "" { + return &StatusCheck{ + Name: "Codex Plugin Version", + Status: "fail", + Message: "Installed plugin version unavailable", + Hint: "Run: basecamp setup codex", + } + } + if version.Version == "dev" { + return &StatusCheck{ + Name: "Codex Plugin Version", + Status: "pass", + Message: fmt.Sprintf("Installed (%s, dev build)", state.Version), + } + } + if state.Version == version.Version { + return &StatusCheck{ + Name: "Codex Plugin Version", + Status: "pass", + Message: fmt.Sprintf("Up to date (%s)", state.Version), + } + } + return &StatusCheck{ + Name: "Codex Plugin Version", + Status: "warn", + Message: fmt.Sprintf("Mismatched (plugin %s, CLI %s)", state.Version, version.Version), + Hint: "Run: basecamp setup codex", + } +} + +func queryCodexPlugin(parent context.Context) (codexPluginState, bool, error) { + path := FindCodexBinary() + if path == "" { + return codexPluginState{}, false, errCodexBinaryMissing + } + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + data, err := runCodexCommand(ctx, path, "plugin", "list", "--available", "--json") + if err != nil { + return codexPluginState{}, false, fmt.Errorf("query Codex plugins: %w", err) + } + + var envelope struct { + Installed *[]codexPluginState `json:"installed"` + Available *[]codexPluginState `json:"available"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return codexPluginState{}, false, fmt.Errorf("%w: %w", errCodexParse, err) + } + if envelope.Installed == nil && envelope.Available == nil { + return codexPluginState{}, false, fmt.Errorf("%w: missing installed and available fields", errCodexParse) + } + if envelope.Installed != nil { + for _, plugin := range *envelope.Installed { + if plugin.PluginID == CodexExpectedPluginKey { + return plugin, true, nil + } + } + } + if envelope.Available != nil { + for _, plugin := range *envelope.Available { + if plugin.PluginID == CodexExpectedPluginKey { + return plugin, true, nil + } + } + } + return codexPluginState{}, false, nil +} + +func codexQueryFailure(name string, err error) *StatusCheck { + if errors.Is(err, errCodexBinaryMissing) { + return &StatusCheck{ + Name: name, + Status: "fail", + Message: "Codex executable not found", + Hint: "Install Codex, then run: basecamp setup codex", + } + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return &StatusCheck{ + Name: name, + Status: "fail", + Message: "Cannot query Codex plugins", + Hint: "Run `codex plugin list --available --json`, then: basecamp setup codex", + } + } + message := "Cannot query Codex plugins" + if errors.Is(err, errCodexParse) { + message = "Cannot parse Codex plugin list" + } + return &StatusCheck{ + Name: name, + Status: "fail", + Message: message, + Hint: "Run `codex plugin list --available --json`, then: basecamp setup codex", + } +} diff --git a/internal/harness/codex_test.go b/internal/harness/codex_test.go new file mode 100644 index 00000000..c0235065 --- /dev/null +++ b/internal/harness/codex_test.go @@ -0,0 +1,203 @@ +package harness + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/version" +) + +func TestDetectCodexDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + stubCodexLookPath(t, "", exec.ErrNotFound) + + assert.False(t, DetectCodex()) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o755)) + assert.True(t, DetectCodex()) +} + +func TestDetectCodexBinary(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + stubCodexLookPath(t, "/usr/local/bin/codex", nil) + + assert.True(t, DetectCodex()) + assert.Equal(t, "/usr/local/bin/codex", FindCodexBinary()) +} + +func TestCheckCodexPluginMissingBinary(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + stubCodexLookPath(t, "", exec.ErrNotFound) + + check := CheckCodexPlugin() + + assert.Equal(t, "fail", check.Status) + assert.Contains(t, check.Message, "Codex executable") + assert.Contains(t, check.Hint, "basecamp setup codex") +} + +func TestCheckCodexPluginMissing(t *testing.T) { + stubCodexList(t, `{"installed":[],"available":[{"pluginId":"basecamp@37signals","name":"basecamp","marketplaceName":"37signals","version":"0.7.2","installed":false,"enabled":false}]}`, nil) + + check := CheckCodexPlugin() + + assert.Equal(t, "fail", check.Status) + assert.Equal(t, "Plugin not installed", check.Message) + assert.Equal(t, "Run: basecamp setup codex", check.Hint) +} + +func TestCheckCodexPluginDisabled(t *testing.T) { + stubCodexList(t, codexListFixture("0.7.2", true, false), nil) + + check := CheckCodexPlugin() + + assert.Equal(t, "fail", check.Status) + assert.Equal(t, "Installed but disabled", check.Message) + assert.Contains(t, check.Hint, "basecamp setup codex") +} + +func TestCheckCodexPluginInstalledAndEnabled(t *testing.T) { + stubCodexList(t, codexListFixture("0.7.2", true, true), nil) + + check := CheckCodexPlugin() + + assert.Equal(t, "pass", check.Status) + assert.Equal(t, "Installed and enabled", check.Message) +} + +func TestCheckCodexPluginMalformedJSON(t *testing.T) { + stubCodexList(t, `not json`, nil) + + check := CheckCodexPlugin() + + assert.Equal(t, "fail", check.Status) + assert.Contains(t, check.Message, "Cannot parse") + assert.Contains(t, check.Hint, "basecamp setup codex") +} + +func TestCheckCodexPluginCommandFailure(t *testing.T) { + stubCodexList(t, "", errors.New("exit status 1")) + + check := CheckCodexPlugin() + + assert.Equal(t, "fail", check.Status) + assert.Contains(t, check.Message, "Cannot query") + assert.Contains(t, check.Hint, "codex plugin list --available --json") +} + +func TestCheckCodexPluginVersionMatching(t *testing.T) { + original := version.Version + version.Version = "0.7.2" + t.Cleanup(func() { version.Version = original }) + stubCodexList(t, codexListFixture("0.7.2", true, true), nil) + + check := CheckCodexPluginVersion() + + assert.Equal(t, "pass", check.Status) + assert.Equal(t, "Up to date (0.7.2)", check.Message) +} + +func TestCheckCodexPluginVersionMismatch(t *testing.T) { + original := version.Version + version.Version = "0.8.0" + t.Cleanup(func() { version.Version = original }) + stubCodexList(t, codexListFixture("0.7.2", true, true), nil) + + check := CheckCodexPluginVersion() + + assert.Equal(t, "warn", check.Status) + assert.Contains(t, check.Message, "plugin 0.7.2, CLI 0.8.0") + assert.Equal(t, "Run: basecamp setup codex", check.Hint) +} + +func TestCheckCodexPluginVersionSkipsMissingPlugin(t *testing.T) { + stubCodexList(t, `{"installed":[],"available":[]}`, nil) + + check := CheckCodexPluginVersion() + + assert.Equal(t, "skip", check.Status) + assert.Equal(t, "Skipped (plugin not installed)", check.Message) + assert.Empty(t, check.Hint) +} + +func TestCheckCodexPluginUsesSupportedJSONCommand(t *testing.T) { + stubCodexLookPath(t, "/usr/local/bin/codex", nil) + var gotPath string + var gotArgs []string + original := runCodexCommand + runCodexCommand = func(_ context.Context, path string, args ...string) ([]byte, error) { + gotPath = path + gotArgs = append([]string(nil), args...) + return []byte(codexListFixture("0.7.2", true, true)), nil + } + t.Cleanup(func() { runCodexCommand = original }) + + check := CheckCodexPlugin() + + assert.Equal(t, "pass", check.Status) + assert.Equal(t, "/usr/local/bin/codex", gotPath) + assert.Equal(t, []string{"plugin", "list", "--available", "--json"}, gotArgs) +} + +func TestCodexAgentInfoWiring(t *testing.T) { + resetRegistry() + defer resetRegistry() + + stubCodexList(t, codexListFixture("0.7.2", true, true), nil) + RegisterAgent(AgentInfo{ + Name: "Codex", + ID: "codex", + Detect: DetectCodex, + Checks: func() []*StatusCheck { return []*StatusCheck{CheckCodexPlugin()} }, + }) + + found := FindAgent("codex") + require.NotNil(t, found) + assert.Equal(t, "Codex", found.Name) + assert.NotNil(t, found.Detect) + assert.NotNil(t, found.Checks) + checks := found.Checks() + require.Len(t, checks, 1) + assert.Equal(t, "Codex Plugin", checks[0].Name) +} + +func stubCodexLookPath(t *testing.T, path string, err error) { + t.Helper() + original := codexLookPath + codexLookPath = func(name string) (string, error) { + assert.Equal(t, "codex", name) + return path, err + } + t.Cleanup(func() { codexLookPath = original }) +} + +func stubCodexList(t *testing.T, output string, commandErr error) { + t.Helper() + stubCodexLookPath(t, "/usr/local/bin/codex", nil) + original := runCodexCommand + runCodexCommand = func(_ context.Context, path string, args ...string) ([]byte, error) { + assert.Equal(t, "/usr/local/bin/codex", path) + assert.Equal(t, []string{"plugin", "list", "--available", "--json"}, args) + return []byte(output), commandErr + } + t.Cleanup(func() { runCodexCommand = original }) +} + +func codexListFixture(pluginVersion string, installed, enabled bool) string { + return `{"installed":[{"pluginId":"basecamp@37signals","name":"basecamp","marketplaceName":"37signals","version":"` + pluginVersion + `","installed":` + boolJSON(installed) + `,"enabled":` + boolJSON(enabled) + `}],"available":[]}` +} + +func boolJSON(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/internal/release/manifests_test.go b/internal/release/manifests_test.go new file mode 100644 index 00000000..de52143c --- /dev/null +++ b/internal/release/manifests_test.go @@ -0,0 +1,275 @@ +// Package release_test verifies the agent plugin manifests and release wiring. +// +// The repository root is the live plugin payload for both Claude Code and +// Codex: the 37signals marketplace sources basecamp/basecamp-cli directly. +// These tests keep the two thin per-agent manifests consistent with each +// other and with the files they reference. Marketplace-shape enforcement +// (allowed fields, exact values) belongs to each agent's own validator. +package release_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// semverPattern is the strict semver 2.0.0 grammar (semver.org). Go's \d is +// ASCII-only, so non-ASCII digits are rejected. +var semverPattern = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)` + + `(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?` + + `(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$`) + +type codexInterface struct { + DisplayName string `json:"displayName"` + ShortDescription string `json:"shortDescription"` + LongDescription string `json:"longDescription"` + DeveloperName string `json:"developerName"` + Category string `json:"category"` + ComposerIcon string `json:"composerIcon"` + Logo string `json:"logo"` +} + +type pluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Skills string `json:"skills"` + Interface *codexInterface `json:"interface"` +} + +func TestManifestsParseWithMatchingIdentity(t *testing.T) { + root := repositoryRoot(t) + claude := readManifest(t, filepath.Join(root, ".claude-plugin", "plugin.json")) + codex := readManifest(t, filepath.Join(root, ".codex-plugin", "plugin.json")) + + assert.Equal(t, "basecamp", claude.Name) + assert.Equal(t, "basecamp", codex.Name) + assert.Regexp(t, semverPattern, claude.Version) + assert.Regexp(t, semverPattern, codex.Version) + assert.Equal(t, claude.Version, codex.Version, "manifest versions must stay in lockstep") +} + +func TestSemverPatternIsStrict(t *testing.T) { + tests := []struct { + version string + valid bool + }{ + {version: "1.2.3", valid: true}, + {version: "1.2.3-alpha.1+build.5", valid: true}, + {version: "1.2.3-0", valid: true}, + {version: "1.2.3-01", valid: false}, + {version: "1.2.3-alpha..1", valid: false}, + {version: "1.2.3٠", valid: false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + assert.Equal(t, tt.valid, semverPattern.MatchString(tt.version)) + }) + } +} + +func TestCodexManifestReferencesExistingPaths(t *testing.T) { + root := repositoryRoot(t) + manifest := readManifest(t, filepath.Join(root, ".codex-plugin", "plugin.json")) + + require.NotEmpty(t, manifest.Skills, "codex manifest must point at the skills directory") + skillsDir := requireRepoRelative(t, root, manifest.Skills) + info, err := os.Stat(skillsDir) + require.NoError(t, err) + assert.True(t, info.IsDir()) + + require.NotNil(t, manifest.Interface) + for name, path := range map[string]string{ + "composerIcon": manifest.Interface.ComposerIcon, + "logo": manifest.Interface.Logo, + } { + require.NotEmpty(t, path, name) + _, err := os.Stat(requireRepoRelative(t, root, path)) + assert.NoError(t, err, name) + } + + for _, skill := range []string{ + filepath.Join("skills", "basecamp", "SKILL.md"), + filepath.Join("skills", "basecamp-doctor", "SKILL.md"), + } { + _, err := os.Stat(filepath.Join(root, skill)) + assert.NoError(t, err, skill) + } +} + +func TestCodexManifestInterfaceIsPresentable(t *testing.T) { + root := repositoryRoot(t) + manifest := readManifest(t, filepath.Join(root, ".codex-plugin", "plugin.json")) + + require.NotNil(t, manifest.Interface) + assert.NotEmpty(t, manifest.Interface.DisplayName) + assert.NotEmpty(t, manifest.Interface.ShortDescription) + assert.NotEmpty(t, manifest.Interface.LongDescription) + assert.NotEmpty(t, manifest.Interface.DeveloperName) + assert.NotEmpty(t, manifest.Interface.Category) +} + +// TestHooksFileCommandsInvokeBasecamp tolerates absence: hooks/hooks.json is +// deliberately not shipped yet. When it lands, every hook leaf must invoke the +// basecamp CLI (directly or through the missing-binary wrapper) so both agents +// run only first-party hook commands. +func TestHooksFileCommandsInvokeBasecamp(t *testing.T) { + root := repositoryRoot(t) + data, err := os.ReadFile(filepath.Join(root, "hooks", "hooks.json")) + if os.IsNotExist(err) { + t.Skip("hooks/hooks.json not present (expected until hooks ship)") + } + require.NoError(t, err) + + var config struct { + Hooks map[string][]struct { + Hooks []struct { + Type string `json:"type"` + Command string `json:"command"` + } `json:"hooks"` + } `json:"hooks"` + } + require.NoError(t, json.Unmarshal(data, &config)) + require.NotEmpty(t, config.Hooks) + + for event, matchers := range config.Hooks { + require.NotEmpty(t, matchers, event) + for _, matcher := range matchers { + require.NotEmpty(t, matcher.Hooks, event) + for _, hook := range matcher.Hooks { + assert.Equal(t, "command", hook.Type, event) + validPrefix := strings.HasPrefix(hook.Command, "basecamp ") || + strings.Contains(hook.Command, "exec basecamp ") + assert.True(t, validPrefix, "%s hook must invoke basecamp: %q", event, hook.Command) + } + } + } +} + +func TestStampCodexPluginVersionDoesNotChangeClaudeManifest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("release scripts require a POSIX shell") + } + root := repositoryRoot(t) + fixture := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(fixture, ".codex-plugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(fixture, ".claude-plugin"), 0o755)) + copyFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), filepath.Join(fixture, ".codex-plugin", "plugin.json")) + copyFile(t, filepath.Join(root, ".claude-plugin", "plugin.json"), filepath.Join(fixture, ".claude-plugin", "plugin.json")) + claudeBefore, err := os.ReadFile(filepath.Join(fixture, ".claude-plugin", "plugin.json")) + require.NoError(t, err) + + cmd := exec.CommandContext(context.Background(), filepath.Join(root, "scripts", "stamp-codex-plugin-version.sh"), "1.2.3") + cmd.Dir = fixture + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + assert.Equal(t, "1.2.3", readManifest(t, filepath.Join(fixture, ".codex-plugin", "plugin.json")).Version) + claudeAfter, err := os.ReadFile(filepath.Join(fixture, ".claude-plugin", "plugin.json")) + require.NoError(t, err) + assert.Equal(t, claudeBefore, claudeAfter) +} + +func TestStampCodexPluginVersionCleansTemporaryFileAfterFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("release scripts require a POSIX shell") + } + root := repositoryRoot(t) + fixture := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(fixture, ".codex-plugin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(fixture, ".codex-plugin", "plugin.json"), []byte("not json\n"), 0o644)) + + cmd := exec.CommandContext(context.Background(), filepath.Join(root, "scripts", "stamp-codex-plugin-version.sh"), "1.2.3") + cmd.Dir = fixture + require.Error(t, cmd.Run()) + + temporaryFiles, err := filepath.Glob(filepath.Join(fixture, ".codex-plugin", "plugin.json.tmp*")) + require.NoError(t, err) + assert.Empty(t, temporaryFiles) +} + +func TestReleaseWiringStampsBothStableManifests(t *testing.T) { + root := repositoryRoot(t) + goreleaser := readFile(t, filepath.Join(root, ".goreleaser.yaml")) + releaseScript := readFile(t, filepath.Join(root, "scripts", "release.sh")) + + assert.Contains(t, goreleaser, "scripts/stamp-plugin-version.sh {{ .Version }}") + assert.Contains(t, goreleaser, "scripts/stamp-codex-plugin-version.sh {{ .Version }}") + assert.Contains(t, releaseScript, `scripts/stamp-plugin-version.sh "${VERSION}"`) + assert.Contains(t, releaseScript, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`) + assert.Contains(t, releaseScript, "git add nix/package.nix .claude-plugin/plugin.json .codex-plugin/plugin.json") +} + +func TestReleaseWiringSkipsPluginStampsForPrereleases(t *testing.T) { + root := repositoryRoot(t) + goreleaser := readFile(t, filepath.Join(root, ".goreleaser.yaml")) + releaseScript := readFile(t, filepath.Join(root, "scripts", "release.sh")) + + assert.Contains(t, goreleaser, "if .Prerelease") + assert.Contains(t, goreleaser, "Skipping plugin version stamps for prerelease") + + // Both stampers must run only inside the stable (non-prerelease) branch: + // no invocation before the PRERELEASE guard. + guard := strings.Index(releaseScript, `if [[ "${PRERELEASE}" == "true" ]]`) + require.NotEqual(t, -1, guard) + beforeGuard := releaseScript[:guard] + assert.NotContains(t, beforeGuard, "scripts/stamp-plugin-version.sh") + assert.NotContains(t, beforeGuard, "scripts/stamp-codex-plugin-version.sh") + afterGuard := releaseScript[guard:] + assert.Contains(t, afterGuard, `scripts/stamp-plugin-version.sh "${VERSION}"`) + assert.Contains(t, afterGuard, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`) +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func readManifest(t *testing.T, path string) pluginManifest { + t.Helper() + var manifest pluginManifest + require.NoError(t, json.Unmarshal([]byte(readFile(t, path)), &manifest)) + return manifest +} + +// requireRepoRelative resolves a manifest path against the repository root and +// fails if it escapes it. Symlinks are resolved first so a link pointing +// outside the repository cannot pass a purely lexical containment check. +func requireRepoRelative(t *testing.T, root, path string) string { + t.Helper() + resolvedRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + joined := filepath.Join(root, filepath.FromSlash(path)) + resolved, err := filepath.EvalSymlinks(joined) + require.NoError(t, err, "manifest path must exist: %s", path) + rel, err := filepath.Rel(resolvedRoot, resolved) + require.NoError(t, err) + require.False(t, rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)), + "manifest path escapes repository root: %s", path) + return joined +} + +func copyFile(t *testing.T, source, destination string) { + t.Helper() + data, err := os.ReadFile(source) + require.NoError(t, err) + require.NoError(t, os.WriteFile(destination, data, 0o644)) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} diff --git a/scripts/release.sh b/scripts/release.sh index 63b3dee7..ae1a5482 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -85,6 +85,7 @@ if [[ "${PRERELEASE}" == "true" ]]; then info "Skipping stable release metadata for prerelease" echo " nix flake: unchanged" echo " Claude plugin metadata: unchanged" + echo " Codex plugin metadata: unchanged" else # --- Update Nix flake --- info "Updating Nix flake" @@ -108,12 +109,13 @@ else echo " (skipped — dry run)" else scripts/stamp-plugin-version.sh "${VERSION}" + scripts/stamp-codex-plugin-version.sh "${VERSION}" fi fi # --- Commit release prep --- if [[ "${PRERELEASE}" != "true" && "${DRY_RUN}" != "true" && "${DRY_RUN}" != "1" ]]; then - git add nix/package.nix .claude-plugin/plugin.json + git add nix/package.nix .claude-plugin/plugin.json .codex-plugin/plugin.json if ! git diff --cached --quiet; then STAGED=$(git diff --cached --name-only) HAS_NIX=false @@ -121,7 +123,7 @@ if [[ "${PRERELEASE}" != "true" && "${DRY_RUN}" != "true" && "${DRY_RUN}" != "1" if echo "${STAGED}" | grep -q "^nix/package\.nix$"; then HAS_NIX=true fi - if echo "${STAGED}" | grep -q "^\.claude-plugin/plugin\.json$"; then + if echo "${STAGED}" | grep -qE "^\.(claude|codex)-plugin/plugin\.json$"; then HAS_PLUGIN=true fi if [[ "${HAS_NIX}" == "true" && "${HAS_PLUGIN}" == "true" ]]; then diff --git a/scripts/stamp-codex-plugin-version.sh b/scripts/stamp-codex-plugin-version.sh new file mode 100755 index 00000000..59eb69ca --- /dev/null +++ b/scripts/stamp-codex-plugin-version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq is required but not found. Install with your package manager." >&2 + exit 1 +fi + +# Stamps the CLI release version into the Codex plugin manifest. +# Kept separate from the Claude stamper so each integration remains isolated. + +VERSION="${1:?Usage: stamp-codex-plugin-version.sh VERSION}" +PLUGIN_JSON=".codex-plugin/plugin.json" +TEMP_FILE=$(mktemp "${PLUGIN_JSON}.tmp.XXXXXX") +trap 'rm -f "${TEMP_FILE}"' EXIT + +jq --arg v "$VERSION" '.version = $v' "$PLUGIN_JSON" > "${TEMP_FILE}" +mv "${TEMP_FILE}" "$PLUGIN_JSON" +trap - EXIT diff --git a/skills/basecamp-doctor/SKILL.md b/skills/basecamp-doctor/SKILL.md new file mode 100644 index 00000000..9c476edc --- /dev/null +++ b/skills/basecamp-doctor/SKILL.md @@ -0,0 +1,28 @@ +--- +name: basecamp-doctor +description: Diagnose Basecamp CLI, authentication, and agent-plugin health. +--- + +# Basecamp Doctor + +Run the structured diagnostic: + +```bash +basecamp doctor --json +``` + +Interpret every check by status: + +- `pass`: working correctly. +- `warn`: usable, but follow-up is recommended. +- `skip`: not run because it is unauthenticated or not applicable. +- `fail`: broken and needs attention. + +Report failures and warnings with their `hint` fields. Also inspect the top-level `breadcrumbs` array and preserve its structured `cmd` next steps, because a breadcrumb can provide a more specific action than a check hint. Use these common remediations when relevant: + +- Basecamp authentication: `basecamp auth login` +- Agent plugin installation or version: `basecamp setup` +- Codex plugin specifically: `basecamp setup codex` +- Claude Code plugin specifically: `basecamp setup claude` + +Do not read, print, or request credential files. If every check passes, say that Basecamp and its agent integration are ready.