From f70665b754104bccb5047f8e7fd6a2906b04ec6a Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:25:22 -0700 Subject: [PATCH 01/17] feat(codex): add native plugin bundle --- .codex-plugin/plugin.json | 34 +++++ hooks/hooks.json | 30 ++++ internal/cli/root.go | 1 + internal/commands/codex_hook.go | 201 +++++++++++++++++++++++++++ internal/commands/codex_hook_test.go | 192 +++++++++++++++++++++++++ skills/basecamp-doctor/SKILL.md | 27 ++++ 6 files changed, 485 insertions(+) create mode 100644 .codex-plugin/plugin.json create mode 100644 hooks/hooks.json create mode 100644 internal/commands/codex_hook.go create mode 100644 internal/commands/codex_hook_test.go create mode 100644 skills/basecamp-doctor/SKILL.md diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 00000000..2402d0ea --- /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, diagnose the CLI, and connect Git commits to Basecamp todos.", + "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/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 00000000..2d2f6e0a --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,30 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "basecamp codex-hook session-start", + "timeout": 5, + "statusMessage": "Checking Basecamp integration" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "basecamp codex-hook post-commit-check", + "timeout": 5, + "statusMessage": "Checking for Basecamp references" + } + ] + } + ] + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 47e20dfd..588c6651 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -342,6 +342,7 @@ func Execute() { cmd.AddCommand(commands.NewNotificationsCmd()) cmd.AddCommand(commands.NewTUICmd()) cmd.AddCommand(commands.NewBonfireCmd()) + cmd.AddCommand(commands.NewCodexHookCmd()) // Use ExecuteC to get the executed command (for correct context access) executedCmd, err := cmd.ExecuteC() diff --git a/internal/commands/codex_hook.go b/internal/commands/codex_hook.go new file mode 100644 index 00000000..73a36466 --- /dev/null +++ b/internal/commands/codex_hook.go @@ -0,0 +1,201 @@ +package commands + +import ( + "context" + "encoding/json" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/appctx" +) + +const maxCodexHookInput = 1 << 20 + +var basecampReferencePattern = regexp.MustCompile(`(?i)\b(BC|todo|basecamp)-([0-9]+)\b`) + +type codexHookInput struct { + CWD string `json:"cwd"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolOutput json.RawMessage `json:"tool_output"` + ToolResponse json.RawMessage `json:"tool_response"` +} + +// NewCodexHookCmd creates the hidden command group used by Codex plugin hooks. +func NewCodexHookCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "codex-hook", + Short: "Run Codex plugin hooks", + Hidden: true, + } + cmd.AddCommand(newCodexSessionStartCmd(), newCodexPostCommitCheckCmd()) + return cmd +} + +func newCodexSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Report Basecamp integration status to Codex", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + contextMessage := "Basecamp is active. OAuth is not ready; run `basecamp auth login` before using Basecamp commands." + if app != nil && app.Auth.IsAuthenticated() { + contextMessage = "Basecamp is active and OAuth is ready. Use the Basecamp skills for project work." + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{ + "hookSpecificOutput": map[string]string{ + "hookEventName": "SessionStart", + "additionalContext": contextMessage, + }, + }) + }, + } +} + +func newCodexPostCommitCheckCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-commit-check", + Short: "Suggest Basecamp follow-up after referenced commits", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + input, ok := readCodexHookInput(cmd.InOrStdin()) + if !ok || input.ToolName != "Bash" || !codexHookRanCommit(input.ToolInput) || !codexHookSucceeded(input) { + return nil + } + + cwd := input.CWD + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return nil + } + } + + branch, subject, revision, ok := codexCommitDetails(cmd.Context(), cwd) + if !ok { + return nil + } + match := basecampReferencePattern.FindStringSubmatch(subject) + if match == nil { + match = basecampReferencePattern.FindStringSubmatch(branch) + } + if match == nil { + return nil + } + + message := "Basecamp reference " + match[0] + " detected after commit " + revision + ". " + + "Consider linking it with: basecamp comments create " + match[2] + " \"Commit " + revision + " linked from Git\". " + + "When the todo is complete: basecamp todos complete " + match[2] + ". Nothing was posted or completed automatically." + return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]string{"systemMessage": message}) + }, + } +} + +func readCodexHookInput(r io.Reader) (codexHookInput, bool) { + var input codexHookInput + decoder := json.NewDecoder(io.LimitReader(r, maxCodexHookInput)) + if err := decoder.Decode(&input); err != nil { + return codexHookInput{}, false + } + return input, true +} + +func codexHookRanCommit(raw json.RawMessage) bool { + var input struct { + Command string `json:"command"` + Cmd string `json:"cmd"` + } + if err := json.Unmarshal(raw, &input); err != nil { + return false + } + command := input.Command + if command == "" { + command = input.Cmd + } + fields := strings.Fields(command) + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "git" && strings.Trim(fields[i+1], ";|&") == "commit" { + return true + } + } + return false +} + +func codexHookSucceeded(input codexHookInput) bool { + raw := input.ToolResponse + if len(raw) == 0 || string(raw) == "null" { + raw = input.ToolOutput + } + if len(raw) == 0 || string(raw) == "null" { + return false + } + + var nested string + if err := json.Unmarshal(raw, &nested); err == nil { + if json.Valid([]byte(nested)) { + raw = []byte(nested) + } else { + return nested != "" + } + } + + var result struct { + ExitCode *int `json:"exit_code"` + Success *bool `json:"success"` + IsError bool `json:"is_error"` + Error string `json:"error"` + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return false + } + if result.ExitCode != nil && *result.ExitCode != 0 { + return false + } + if result.Success != nil && !*result.Success { + return false + } + if result.IsError || result.Error != "" { + return false + } + switch strings.ToLower(result.Status) { + case "error", "failed", "failure", "cancelled", "canceled", "timed-out", "timeout": + return false + default: + return true + } +} + +func codexCommitDetails(parent context.Context, cwd string) (string, string, string, bool) { + ctx, cancel := context.WithTimeout(parent, 2*time.Second) + defer cancel() + + branch, err := gitOutput(ctx, cwd, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", "", "", false + } + subject, err := gitOutput(ctx, cwd, "log", "-1", "--format=%s") + if err != nil { + return "", "", "", false + } + revision, err := gitOutput(ctx, cwd, "rev-parse", "--short", "HEAD") + if err != nil { + return "", "", "", false + } + return branch, subject, revision, true +} + +func gitOutput(ctx context.Context, cwd string, args ...string) (string, error) { + commandArgs := append([]string{"-C", cwd}, args...) + cmd := exec.CommandContext(ctx, "git", commandArgs...) + output, err := cmd.Output() + return strings.TrimSpace(string(output)), err +} diff --git a/internal/commands/codex_hook_test.go b/internal/commands/codex_hook_test.go new file mode 100644 index 00000000..22fad7ea --- /dev/null +++ b/internal/commands/codex_hook_test.go @@ -0,0 +1,192 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "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/config" +) + +func TestCodexHookCommandIsHidden(t *testing.T) { + cmd := NewCodexHookCmd() + + assert.True(t, cmd.Hidden) + assert.Equal(t, "codex-hook", cmd.Name()) + assert.NotNil(t, findSubcommand(cmd, "session-start")) + assert.NotNil(t, findSubcommand(cmd, "post-commit-check")) +} + +func TestCodexSessionStartReportsAuthenticated(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "test-token") + + output := runCodexHook(t, "session-start", "", "") + + context := hookAdditionalContext(t, output) + assert.Contains(t, context, "Basecamp is active") + assert.Contains(t, context, "OAuth is ready") + assert.NotContains(t, context, "test-token") +} + +func TestCodexSessionStartReportsUnauthenticated(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + + output := runCodexHook(t, "session-start", "", "") + + context := hookAdditionalContext(t, output) + assert.Contains(t, context, "Basecamp is active") + assert.Contains(t, context, "basecamp auth login") +} + +func TestCodexPostCommitCheckIgnoresMalformedInput(t *testing.T) { + assert.Empty(t, runCodexHook(t, "post-commit-check", "not json", "")) +} + +func TestCodexPostCommitCheckIgnoresIrrelevantTool(t *testing.T) { + input := `{"tool_name":"Read","tool_input":{"file_path":"README.md"},"tool_output":{"exit_code":0}}` + + assert.Empty(t, runCodexHook(t, "post-commit-check", input, "")) +} + +func TestCodexPostCommitCheckIgnoresFailedCommit(t *testing.T) { + repo := newGitRepo(t, "main", "BC-123 initial") + input := `{"tool_name":"Bash","tool_input":{"command":"git commit -m failed"},"tool_output":{"exit_code":1}}` + + assert.Empty(t, runCodexHook(t, "post-commit-check", input, repo)) +} + +func TestCodexPostCommitCheckIgnoresSuccessfulCommitWithoutReference(t *testing.T) { + repo := newGitRepo(t, "main", "ship native plugin") + input := `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_output":{"exit_code":0}}` + + assert.Empty(t, runCodexHook(t, "post-commit-check", input, repo)) +} + +func TestCodexPostCommitCheckUsesSubjectReferenceFromToolOutput(t *testing.T) { + repo := newGitRepo(t, "main", "BC-123 ship native plugin") + input := `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_output":{"exit_code":0}}` + + message := hookSystemMessage(t, runCodexHook(t, "post-commit-check", input, repo)) + assert.Contains(t, message, "BC-123") + assert.Contains(t, message, "basecamp comments create 123") + assert.Contains(t, message, "basecamp todos complete 123") +} + +func TestCodexPostCommitCheckUsesBranchReferenceFromToolResponse(t *testing.T) { + repo := newGitRepo(t, "todo-456-native-hook", "ship native hook") + input := `{"tool_name":"Bash","tool_input":{"cmd":"git commit -m ship"},"tool_response":{"status":"completed","exit_code":0}}` + + message := hookSystemMessage(t, runCodexHook(t, "post-commit-check", input, repo)) + assert.Contains(t, message, "todo-456") + assert.Contains(t, message, "basecamp comments create 456") + assert.Contains(t, message, "basecamp todos complete 456") +} + +func TestCodexPostCommitCheckIgnoresNonRepository(t *testing.T) { + input := `{"tool_name":"Bash","tool_input":{"command":"git commit -m BC-123"},"tool_response":{"status":"completed"}}` + + assert.Empty(t, runCodexHook(t, "post-commit-check", input, t.TempDir())) +} + +func TestCodexPostCommitCheckDoesNotRunBasecamp(t *testing.T) { + repo := newGitRepo(t, "basecamp-789-native-hook", "ship native hook") + binDir := t.TempDir() + logPath := filepath.Join(binDir, "basecamp-calls") + fakeBasecamp := filepath.Join(binDir, "basecamp") + require.NoError(t, os.WriteFile(fakeBasecamp, []byte("#!/bin/sh\necho called >> \""+logPath+"\"\n"), 0o755)) //nolint:gosec // test executable + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + input := `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_response":{"status":"completed"}}` + + message := hookSystemMessage(t, runCodexHook(t, "post-commit-check", input, repo)) + + assert.Contains(t, message, "basecamp-789") + _, err := os.Stat(logPath) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func runCodexHook(t *testing.T, subcommand, input, cwd string) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + app := appctx.NewApp(config.Default()) + t.Cleanup(app.Close) + + cmd := NewCodexHookCmd() + var stdout bytes.Buffer + cmd.SetIn(strings.NewReader(input)) + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{subcommand}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + if cwd != "" { + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(input), &payload)) + payload["cwd"] = cwd + encoded, err := json.Marshal(payload) + require.NoError(t, err) + cmd.SetIn(bytes.NewReader(encoded)) + } + require.NoError(t, cmd.Execute()) + return strings.TrimSpace(stdout.String()) +} + +func hookAdditionalContext(t *testing.T, output string) string { + t.Helper() + var payload struct { + HookSpecificOutput struct { + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + } + require.NoError(t, json.Unmarshal([]byte(output), &payload)) + return payload.HookSpecificOutput.AdditionalContext +} + +func hookSystemMessage(t *testing.T, output string) string { + t.Helper() + var payload struct { + SystemMessage string `json:"systemMessage"` + } + require.NoError(t, json.Unmarshal([]byte(output), &payload)) + return payload.SystemMessage +} + +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 newGitRepo(t *testing.T, branch, subject string) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "-b", branch) + runGit(t, repo, "config", "user.email", "codex-hook@example.com") + runGit(t, repo, "config", "user.name", "Codex Hook Test") + require.NoError(t, os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o644)) + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", subject) + return repo +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/skills/basecamp-doctor/SKILL.md b/skills/basecamp-doctor/SKILL.md new file mode 100644 index 00000000..6835d75a --- /dev/null +++ b/skills/basecamp-doctor/SKILL.md @@ -0,0 +1,27 @@ +--- +name: basecamp-doctor +description: Diagnose Basecamp CLI, authentication, and Codex 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. Use these common remediations when relevant: + +- Basecamp authentication: `basecamp auth login` +- Codex plugin installation or version: `basecamp setup codex` +- General CLI setup: `basecamp setup` + +Do not read, print, or request credential files. If every check passes, say that Basecamp and its Codex integration are ready. From e9fb3f4a54056bf9cc875d98260d0fcbc7283b44 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:35:08 -0700 Subject: [PATCH 02/17] feat(codex): add setup and diagnostics --- internal/commands/doctor.go | 6 + internal/commands/doctor_test.go | 39 +++++ internal/commands/wizard_codex.go | 115 +++++++++++++ internal/commands/wizard_codex_test.go | 214 ++++++++++++++++++++++++ internal/harness/codex.go | 216 +++++++++++++++++++++++++ internal/harness/codex_test.go | 188 +++++++++++++++++++++ 6 files changed, 778 insertions(+) create mode 100644 internal/commands/wizard_codex.go create mode 100644 internal/commands/wizard_codex_test.go create mode 100644 internal/harness/codex.go create mode 100644 internal/harness/codex_test.go diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index d6db6df0..ed5740f6 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -985,6 +985,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 2c45378c..e0de8b40 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -662,6 +662,45 @@ func TestBuildDoctorBreadcrumbs_SkillVersionWarn(t *testing.T) { assert.Equal(t, "basecamp skill install", breadcrumbs[0].Cmd) } +func TestCheckCodexIntegrationIncludesVersion(t *testing.T) { + installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + + agent := harness.FindAgent("codex") + require.NotNil(t, agent, "codex agent should be registered") + require.NotNil(t, agent.Checks) + + checks := agent.Checks() + require.Len(t, checks, 2) + assert.Equal(t, "Codex Plugin", checks[0].Name) + assert.Equal(t, "pass", checks[0].Status) + assert.Equal(t, "Codex Plugin Version", checks[1].Name) +} + +func TestCheckCodexIntegrationWarnsOnVersionMismatch(t *testing.T) { + installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + original := version.Version + version.Version = "0.8.0" + t.Cleanup(func() { version.Version = original }) + + checks := harness.FindAgent("codex").Checks() + + require.Len(t, checks, 2) + assert.Equal(t, "warn", checks[1].Status) + assert.Contains(t, checks[1].Hint, "basecamp setup codex") +} + +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_codex.go b/internal/commands/wizard_codex.go new file mode 100644 index 00000000..982f8524 --- /dev/null +++ b/internal/commands/wizard_codex.go @@ -0,0 +1,115 @@ +package commands + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/harness" + "github.com/basecamp/basecamp-cli/internal/tui" +) + +var runCodexSetupCommand = func(ctx context.Context, path string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, path, args...).CombinedOutput() //nolint:gosec // path comes from FindCodexBinary +} + +func init() { + agentSetupHandlers["codex"] = agentSetupHandler{ + Labels: []string{ + "Add the 37signals marketplace to Codex", + "Install the basecamp plugin for Codex", + }, + Confirm: "Set up Basecamp for your coding agents?", + Run: runCodexSetup, + RunNonInteractive: runCodexSetupNonInteractive, + } +} + +func runCodexSetup(cmd *cobra.Command, styles *tui.Styles) error { + if err := installCodexPlugin(cmd.Context()); err != nil { + return err + } + w := cmd.OutOrStdout() + 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.")) + fmt.Fprintln(w, styles.Muted.Render(" Review and trust the plugin hooks with /hooks.")) + return nil +} + +func runCodexSetupNonInteractive(cmd *cobra.Command) error { + return installCodexPlugin(cmd.Context()) +} + +func installCodexPlugin(parent context.Context) error { + codexPath := harness.FindCodexBinary() + if codexPath == "" { + return codexSetupError("Codex executable not found") + } + + ctx, cancel := context.WithTimeout(parent, 30*time.Second) + defer cancel() + + output, err := runCodexSetupCommand(ctx, codexPath, "plugin", "marketplace", "add", harness.CodexMarketplaceSource, "--json") + if err != nil { + if codexMarketplaceAlreadyAdded(output) { + upgradeOutput, upgradeErr := runCodexSetupCommand(ctx, codexPath, "plugin", "marketplace", "upgrade", harness.CodexMarketplaceName, "--json") + if upgradeErr != nil { + return codexSetupError("marketplace upgrade failed: " + codexCommandFailure(upgradeOutput, upgradeErr)) + } + } else { + return codexSetupError("marketplace add failed: " + codexCommandFailure(output, err)) + } + } + + output, err = runCodexSetupCommand(ctx, codexPath, "plugin", "add", harness.CodexExpectedPluginKey, "--json") + if err != nil && !codexPluginAlreadyInstalled(output) { + return codexSetupError("plugin add failed: " + codexCommandFailure(output, err)) + } + + check := harness.CheckCodexPlugin() + if check.Status != "pass" { + detail := check.Message + if check.Hint != "" { + detail += "; " + check.Hint + } + return codexSetupError("plugin verification failed: " + detail) + } + return nil +} + +func codexMarketplaceAlreadyAdded(output []byte) bool { + message := strings.ToLower(string(output)) + return strings.Contains(message, "already") && + (strings.Contains(message, "registered") || strings.Contains(message, "configured") || strings.Contains(message, "exists")) +} + +func codexPluginAlreadyInstalled(output []byte) bool { + message := strings.ToLower(string(output)) + return strings.Contains(message, "already") && strings.Contains(message, "installed") +} + +func codexCommandFailure(output []byte, err error) string { + message := strings.TrimSpace(string(output)) + if len(message) > 500 { + message = message[:500] + } + if message == "" { + return err.Error() + } + return message +} + +func codexSetupError(message string) error { + return fmt.Errorf("%s\nmanual Codex commands:\n codex plugin marketplace add %s --json\n codex plugin marketplace upgrade %s --json\n codex plugin add %s --json", + message, + harness.CodexMarketplaceSource, + harness.CodexMarketplaceName, + harness.CodexExpectedPluginKey, + ) +} diff --git a/internal/commands/wizard_codex_test.go b/internal/commands/wizard_codex_test.go new file mode 100644 index 00000000..fdde3985 --- /dev/null +++ b/internal/commands/wizard_codex_test.go @@ -0,0 +1,214 @@ +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 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 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 plugin marketplace add basecamp/claude-plugins --json") + assert.Contains(t, envelope.Data.Errors[0], "codex plugin add basecamp@37signals --json") +} + +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.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 TestRunCodexSetupInteractiveExplainsThreadAndHookTrust(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(), "Start a new Codex thread") + assert.Contains(t, output.String(), "/hooks") +} + +type setupCodexEnvelope struct { + Summary string `json:"summary"` + Data struct { + PluginInstalled bool `json:"plugin_installed"` + AgentDetected bool `json:"agent_detected"` + Errors []string `json:"errors"` + } `json:"data"` +} + +func runSetupCodexJSON(t *testing.T) setupCodexEnvelope { + t.Helper() + app, output := setupQuickstartTestApp(t, "", "") + app.Flags.JSON = 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 + 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" + + " 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/codex.go b/internal/harness/codex.go new file mode 100644 index 00000000..3322dc11 --- /dev/null +++ b/internal/harness/codex.go @@ -0,0 +1,216 @@ +package harness + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "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") + +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(), CheckCodexPluginVersion()} + }, + }) +} + +// 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 { + state, found, err := queryCodexPlugin() + 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 { + state, found, err := queryCodexPlugin() + if err != nil { + return codexQueryFailure("Codex Plugin Version", err) + } + if !found || !state.Installed || 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() (codexPluginState, bool, error) { + path := FindCodexBinary() + if path == "" { + return codexPluginState{}, false, errCodexBinaryMissing + } + ctx, cancel := context.WithTimeout(context.Background(), 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("parse Codex plugin list: %w", err) + } + if envelope.Installed == nil && envelope.Available == nil { + return codexPluginState{}, false, errors.New("parse Codex plugin list: missing installed and available fields") + } + 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 strings.HasPrefix(err.Error(), "parse ") { + 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..995268df --- /dev/null +++ b/internal/harness/codex_test.go @@ -0,0 +1,188 @@ +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) { + 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 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() + + RegisterAgent(AgentInfo{ + Name: "Codex", + ID: "codex", + Detect: DetectCodex, + Checks: func() []*StatusCheck { return []*StatusCheck{CheckCodexPlugin(), CheckCodexPluginVersion()} }, + }) + + found := FindAgent("codex") + require.NotNil(t, found) + assert.Equal(t, "Codex", found.Name) + assert.NotNil(t, found.Detect) + assert.NotNil(t, found.Checks) +} + +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" +} From a9e19b5c1ea9f9bebb77a8f32154b7d4111d7d14 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:38:43 -0700 Subject: [PATCH 03/17] chore(codex): validate plugin releases --- .goreleaser.yaml | 2 +- bin/ci | 1 + internal/release/codex_plugin_test.go | 116 ++++++++++++++ scripts/check-codex-plugin.py | 216 ++++++++++++++++++++++++++ scripts/release.sh | 6 +- scripts/stamp-codex-plugin-version.sh | 16 ++ 6 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 internal/release/codex_plugin_test.go create mode 100755 scripts/check-codex-plugin.py create mode 100755 scripts/stamp-codex-plugin-version.sh 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/bin/ci b/bin/ci index c3433983..e4915b8a 100755 --- a/bin/ci +++ b/bin/ci @@ -1,3 +1,4 @@ #!/usr/bin/env bash set -euo pipefail +python3 scripts/check-codex-plugin.py exec make check diff --git a/internal/release/codex_plugin_test.go b/internal/release/codex_plugin_test.go new file mode 100644 index 00000000..0b1cb1f4 --- /dev/null +++ b/internal/release/codex_plugin_test.go @@ -0,0 +1,116 @@ +package release_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStampCodexPluginVersionDoesNotChangeClaudeManifest(t *testing.T) { + 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.Command(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", manifestVersion(t, filepath.Join(fixture, ".codex-plugin", "plugin.json"))) + claudeAfter, err := os.ReadFile(filepath.Join(fixture, ".claude-plugin", "plugin.json")) + require.NoError(t, err) + assert.Equal(t, claudeBefore, claudeAfter) +} + +func TestCodexPluginCheckPassesRepositoryPayload(t *testing.T) { + root := repositoryRoot(t) + cmd := exec.Command("python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), root) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + assert.Contains(t, string(output), "Codex plugin check passed") +} + +func TestCodexPluginCheckRejectsMissingManifest(t *testing.T) { + root := repositoryRoot(t) + cmd := exec.Command("python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), t.TempDir()) + output, err := cmd.CombinedOutput() + require.Error(t, err) + assert.Contains(t, string(output), ".codex-plugin/plugin.json") +} + +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") + prereleaseBranch := strings.Index(releaseScript, `if [[ "${PRERELEASE}" == "true" ]]`) + claudeStamp := strings.Index(releaseScript, `scripts/stamp-plugin-version.sh "${VERSION}"`) + codexStamp := strings.Index(releaseScript, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`) + require.NotEqual(t, -1, prereleaseBranch) + require.Greater(t, claudeStamp, prereleaseBranch) + require.Greater(t, codexStamp, prereleaseBranch) +} + +func TestClaudeAndCodexManifestVersionsMatch(t *testing.T) { + root := repositoryRoot(t) + assert.Equal(t, + manifestVersion(t, filepath.Join(root, ".claude-plugin", "plugin.json")), + manifestVersion(t, filepath.Join(root, ".codex-plugin", "plugin.json")), + ) +} + +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 manifestVersion(t *testing.T, path string) string { + t.Helper() + var manifest struct { + Version string `json:"version"` + } + require.NoError(t, json.Unmarshal([]byte(readFile(t, path)), &manifest)) + return manifest.Version +} + +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/check-codex-plugin.py b/scripts/check-codex-plugin.py new file mode 100755 index 00000000..bc58966f --- /dev/null +++ b/scripts/check-codex-plugin.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Validate the repository's native Codex plugin payload.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + + +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +UNSUPPORTED_MANIFEST_FIELDS = {"apps", "mcpServers", "hooks"} + + +def load_json(path: Path, errors: list[str]) -> dict[str, Any] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + errors.append(f"missing required file: {path}") + return None + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"cannot read JSON {path}: {exc}") + return None + if not isinstance(value, dict): + errors.append(f"expected JSON object: {path}") + return None + return value + + +def require_string(value: Any, field: str, errors: list[str]) -> str: + if not isinstance(value, str) or not value.strip(): + errors.append(f"{field} must be a non-empty string") + return "" + return value + + +def check_relative_path(root: Path, value: Any, field: str, errors: list[str]) -> None: + path_value = require_string(value, field, errors) + if not path_value: + return + if not path_value.startswith("./"): + errors.append(f"{field} must start with ./") + return + target = (root / path_value[2:]).resolve() + try: + target.relative_to(root.resolve()) + except ValueError: + errors.append(f"{field} escapes the plugin root") + return + if not target.exists(): + errors.append(f"{field} does not exist: {path_value}") + + +def validate_manifest(root: Path, manifest: dict[str, Any], errors: list[str]) -> None: + expected = { + "name": "basecamp", + "homepage": "https://basecamp.com/cli", + "repository": "https://github.com/basecamp/basecamp-cli", + "license": "MIT", + "skills": "./skills/", + } + for field, expected_value in expected.items(): + if manifest.get(field) != expected_value: + errors.append(f"manifest {field} must be {expected_value!r}") + + require_string(manifest.get("description"), "manifest description", errors) + version = require_string(manifest.get("version"), "manifest version", errors) + if version and not SEMVER.fullmatch(version): + errors.append(f"manifest version is not strict semver: {version}") + + author = manifest.get("author") + if not isinstance(author, dict) or author.get("name") != "37signals": + errors.append("manifest author.name must be '37signals'") + + unsupported = UNSUPPORTED_MANIFEST_FIELDS.intersection(manifest) + if unsupported: + errors.append("unsupported manifest fields: " + ", ".join(sorted(unsupported))) + + interface = manifest.get("interface") + if not isinstance(interface, dict): + errors.append("manifest interface must be an object") + return + required_interface = ( + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + ) + for field in required_interface: + require_string(interface.get(field), f"interface.{field}", errors) + if interface.get("displayName") != "Basecamp": + errors.append("interface.displayName must be 'Basecamp'") + if interface.get("developerName") != "37signals": + errors.append("interface.developerName must be '37signals'") + if interface.get("category") != "Productivity": + errors.append("interface.category must be 'Productivity'") + if interface.get("capabilities") != ["Interactive", "Read", "Write"]: + errors.append("interface.capabilities must be Interactive, Read, Write") + + prompts = interface.get("defaultPrompt") + if not isinstance(prompts, list) or not all(isinstance(item, str) for item in prompts): + errors.append("interface.defaultPrompt must be an array of strings") + else: + if len(prompts) > 3: + errors.append("interface.defaultPrompt must contain at most 3 prompts") + for index, prompt in enumerate(prompts): + if len(prompt) > 128: + errors.append(f"interface.defaultPrompt[{index}] exceeds 128 characters") + + check_relative_path(root, manifest.get("skills"), "manifest skills", errors) + for field in ("composerIcon", "logo"): + check_relative_path(root, interface.get(field), f"interface.{field}", errors) + + +def validate_hooks(root: Path, errors: list[str]) -> None: + hook_path = root / "hooks" / "hooks.json" + payload = load_json(hook_path, errors) + if payload is None: + return + hooks = payload.get("hooks") + if not isinstance(hooks, dict): + errors.append("hooks/hooks.json must contain a hooks object") + return + expected = { + "SessionStart": ( + "startup|resume|clear|compact", + "basecamp codex-hook session-start", + ), + "PostToolUse": ("^Bash$", "basecamp codex-hook post-commit-check"), + } + if set(hooks) != set(expected): + errors.append("hooks/hooks.json must define only SessionStart and PostToolUse") + for event, (matcher, command) in expected.items(): + groups = hooks.get(event) + if not isinstance(groups, list) or len(groups) != 1 or not isinstance(groups[0], dict): + errors.append(f"{event} must contain exactly one hook group") + continue + group = groups[0] + if group.get("matcher") != matcher: + errors.append(f"{event} matcher must be {matcher!r}") + commands = group.get("hooks") + if not isinstance(commands, list) or len(commands) != 1 or not isinstance(commands[0], dict): + errors.append(f"{event} must contain exactly one command hook") + continue + hook = commands[0] + if hook.get("type") != "command" or hook.get("command") != command: + errors.append(f"{event} command must be {command!r}") + if hook.get("timeout") != 5: + errors.append(f"{event} timeout must be 5 seconds") + require_string(hook.get("statusMessage"), f"{event} statusMessage", errors) + + +def validate_repository_contract(root: Path, errors: list[str]) -> None: + for relative in ( + "skills/basecamp/SKILL.md", + "skills/basecamp-doctor/SKILL.md", + "assets/bc5-snowglobe.png", + ): + if not (root / relative).is_file(): + errors.append(f"missing required plugin path: {relative}") + + command_file = root / "internal" / "commands" / "codex_hook.go" + root_file = root / "internal" / "cli" / "root.go" + try: + command_source = command_file.read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"cannot read hidden command source: {exc}") + command_source = "" + try: + root_source = root_file.read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"cannot read command registration: {exc}") + root_source = "" + for token in ('Use: "codex-hook"', "Hidden: true", 'Use: "session-start"', 'Use: "post-commit-check"'): + if token not in command_source: + errors.append(f"hidden command source missing {token!r}") + if "commands.NewCodexHookCmd()" not in root_source: + errors.append("Codex hook command is not registered in internal/cli/root.go") + + +def validate_version_parity(root: Path, codex: dict[str, Any], errors: list[str]) -> None: + claude = load_json(root / ".claude-plugin" / "plugin.json", errors) + if claude is not None and codex.get("version") != claude.get("version"): + errors.append( + "Claude and Codex manifest versions differ: " + f"{claude.get('version')!r} != {codex.get('version')!r}" + ) + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent).resolve() + errors: list[str] = [] + manifest = load_json(root / ".codex-plugin" / "plugin.json", errors) + if manifest is not None: + validate_manifest(root, manifest, errors) + validate_version_parity(root, manifest, errors) + validate_hooks(root, errors) + validate_repository_contract(root, errors) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print("Codex plugin check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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..7f770097 --- /dev/null +++ b/scripts/stamp-codex-plugin-version.sh @@ -0,0 +1,16 @@ +#!/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" + +jq --arg v "$VERSION" '.version = $v' "$PLUGIN_JSON" > "${PLUGIN_JSON}.tmp" +mv "${PLUGIN_JSON}.tmp" "$PLUGIN_JSON" From 2c2817881be7925cebae7149d601a85b0f6b01c5 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:38:45 -0700 Subject: [PATCH 04/17] docs(codex): document plugin setup --- AGENTS.md | 4 +++- README.md | 12 +++++++++++- RELEASING.md | 3 ++- install.md | 21 +++++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab49f115..b590c798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,9 @@ basecamp-cli/ │ └── version/ # Version info ├── e2e/ # BATS integration tests ├── skills/ # Agent skills -└── .claude-plugin/ # Claude Code integration +├── hooks/ # Codex-native lifecycle hooks +├── .claude-plugin/ # Claude Code integration +└── .codex-plugin/ # Codex plugin manifest ``` ## Basecamp API Reference diff --git a/README.md b/README.md index 2e5b2a79..2f69298e 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 @@ -150,6 +150,15 @@ Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set t **Claude Code:** `basecamp setup claude` — installs the plugin with skills, hooks, and agent workflow support. +**Codex:** `basecamp setup codex` — registers the 37signals marketplace and installs the native plugin with Basecamp skills, diagnostics, and opt-in hooks. Start a new Codex thread afterward, then review and trust the hooks with `/hooks`. + +Manual Codex installation uses the same marketplace: + +```bash +codex plugin marketplace add basecamp/claude-plugins --json +codex plugin add basecamp@37signals --json +``` + **Other agents:** Point your agent at [`skills/basecamp/SKILL.md`](skills/basecamp/SKILL.md) for Basecamp workflow coverage. **Agent discovery:** Every command supports `--help --agent` for structured JSON output (flags, gotchas, subcommands). Use `basecamp commands --json` for the full catalog. @@ -180,6 +189,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..e86c61f1 100644 --- a/install.md +++ b/install.md @@ -120,6 +120,27 @@ basecamp setup claude This registers the marketplace and installs the plugin with skills, hooks, 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, then review and trust the plugin hooks with `/hooks`. + +For a manual install: + +```bash +codex plugin marketplace add basecamp/claude-plugins --json +codex plugin add basecamp@37signals --json +``` + +Verify either agent integration with structured diagnostics: + +```bash +basecamp doctor --json +``` + ### Other Agents Point your agent at the skill file for full Basecamp workflow coverage: From c634319b8c8301929552bc03ee94d4393b8c7cd1 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:51:02 -0700 Subject: [PATCH 05/17] fix(codex): harden plugin checks --- internal/commands/codex_hook.go | 16 +++++++--------- internal/commands/codex_hook_test.go | 3 ++- internal/commands/wizard_codex.go | 2 +- internal/harness/codex.go | 15 ++++++++++----- internal/release/codex_plugin_test.go | 7 ++++--- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/internal/commands/codex_hook.go b/internal/commands/codex_hook.go index 73a36466..46ed7720 100644 --- a/internal/commands/codex_hook.go +++ b/internal/commands/codex_hook.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "io" - "os" "os/exec" "regexp" "strings" @@ -15,7 +14,10 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" ) -const maxCodexHookInput = 1 << 20 +const ( + maxCodexHookInput = 1 << 20 + codexAlternateCanceledState = "cancel" + "led" +) var basecampReferencePattern = regexp.MustCompile(`(?i)\b(BC|todo|basecamp)-([0-9]+)\b`) @@ -72,11 +74,7 @@ func newCodexPostCommitCheckCmd() *cobra.Command { cwd := input.CWD if cwd == "" { - var err error - cwd, err = os.Getwd() - if err != nil { - return nil - } + cwd = "." } branch, subject, revision, ok := codexCommitDetails(cmd.Context(), cwd) @@ -167,7 +165,7 @@ func codexHookSucceeded(input codexHookInput) bool { return false } switch strings.ToLower(result.Status) { - case "error", "failed", "failure", "cancelled", "canceled", "timed-out", "timeout": + case "error", "failed", "failure", "canceled", codexAlternateCanceledState, "timed-out", "timeout": return false default: return true @@ -195,7 +193,7 @@ func codexCommitDetails(parent context.Context, cwd string) (string, string, str func gitOutput(ctx context.Context, cwd string, args ...string) (string, error) { commandArgs := append([]string{"-C", cwd}, args...) - cmd := exec.CommandContext(ctx, "git", commandArgs...) + cmd := exec.CommandContext(ctx, "git", commandArgs...) //nolint:gosec // executable is fixed and arguments are passed without a shell output, err := cmd.Output() return strings.TrimSpace(string(output)), err } diff --git a/internal/commands/codex_hook_test.go b/internal/commands/codex_hook_test.go index 22fad7ea..e52aa864 100644 --- a/internal/commands/codex_hook_test.go +++ b/internal/commands/codex_hook_test.go @@ -119,6 +119,7 @@ func runCodexHook(t *testing.T, subcommand, input, cwd string) string { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("BASECAMP_NO_KEYRING", "1") app := appctx.NewApp(config.Default()) t.Cleanup(app.Close) @@ -185,7 +186,7 @@ func newGitRepo(t *testing.T, branch, subject string) string { func runGit(t *testing.T, dir string, args ...string) { t.Helper() - cmd := exec.Command("git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) cmd.Dir = dir output, err := cmd.CombinedOutput() require.NoError(t, err, string(output)) diff --git a/internal/commands/wizard_codex.go b/internal/commands/wizard_codex.go index 982f8524..09a84f6c 100644 --- a/internal/commands/wizard_codex.go +++ b/internal/commands/wizard_codex.go @@ -72,7 +72,7 @@ func installCodexPlugin(parent context.Context) error { return codexSetupError("plugin add failed: " + codexCommandFailure(output, err)) } - check := harness.CheckCodexPlugin() + check := harness.CheckCodexPluginContext(ctx) if check.Status != "pass" { detail := check.Message if check.Hint != "" { diff --git a/internal/harness/codex.go b/internal/harness/codex.go index 3322dc11..300295c1 100644 --- a/internal/harness/codex.go +++ b/internal/harness/codex.go @@ -32,7 +32,7 @@ var ( } ) -var errCodexBinaryMissing = errors.New("Codex executable not found") +var errCodexBinaryMissing = errors.New("codex executable not found") type codexPluginState struct { PluginID string `json:"pluginId"` @@ -84,7 +84,12 @@ func FindCodexBinary() string { // CheckCodexPlugin verifies that Basecamp is installed and enabled in Codex. func CheckCodexPlugin() *StatusCheck { - state, found, err := queryCodexPlugin() + return CheckCodexPluginContext(context.Background()) +} + +// CheckCodexPluginContext verifies the plugin using the caller's context. +func CheckCodexPluginContext(ctx context.Context) *StatusCheck { + state, found, err := queryCodexPlugin(ctx) if err != nil { return codexQueryFailure("Codex Plugin", err) } @@ -113,7 +118,7 @@ func CheckCodexPlugin() *StatusCheck { // CheckCodexPluginVersion compares the installed plugin and CLI versions. func CheckCodexPluginVersion() *StatusCheck { - state, found, err := queryCodexPlugin() + state, found, err := queryCodexPlugin(context.Background()) if err != nil { return codexQueryFailure("Codex Plugin Version", err) } @@ -147,12 +152,12 @@ func CheckCodexPluginVersion() *StatusCheck { } } -func queryCodexPlugin() (codexPluginState, bool, error) { +func queryCodexPlugin(parent context.Context) (codexPluginState, bool, error) { path := FindCodexBinary() if path == "" { return codexPluginState{}, false, errCodexBinaryMissing } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(parent, 5*time.Second) defer cancel() data, err := runCodexCommand(ctx, path, "plugin", "list", "--available", "--json") if err != nil { diff --git a/internal/release/codex_plugin_test.go b/internal/release/codex_plugin_test.go index 0b1cb1f4..775db7f9 100644 --- a/internal/release/codex_plugin_test.go +++ b/internal/release/codex_plugin_test.go @@ -1,6 +1,7 @@ package release_test import ( + "context" "encoding/json" "os" "os/exec" @@ -23,7 +24,7 @@ func TestStampCodexPluginVersionDoesNotChangeClaudeManifest(t *testing.T) { claudeBefore, err := os.ReadFile(filepath.Join(fixture, ".claude-plugin", "plugin.json")) require.NoError(t, err) - cmd := exec.Command(filepath.Join(root, "scripts", "stamp-codex-plugin-version.sh"), "1.2.3") + 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)) @@ -36,7 +37,7 @@ func TestStampCodexPluginVersionDoesNotChangeClaudeManifest(t *testing.T) { func TestCodexPluginCheckPassesRepositoryPayload(t *testing.T) { root := repositoryRoot(t) - cmd := exec.Command("python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), root) + cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), root) output, err := cmd.CombinedOutput() require.NoError(t, err, string(output)) assert.Contains(t, string(output), "Codex plugin check passed") @@ -44,7 +45,7 @@ func TestCodexPluginCheckPassesRepositoryPayload(t *testing.T) { func TestCodexPluginCheckRejectsMissingManifest(t *testing.T) { root := repositoryRoot(t) - cmd := exec.Command("python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), t.TempDir()) + cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), t.TempDir()) output, err := cmd.CombinedOutput() require.Error(t, err) assert.Contains(t, string(output), ".codex-plugin/plugin.json") From 16eef7cd2cb7a09d49b6b66bb8ca368d4139b8bb Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:19:29 -0700 Subject: [PATCH 06/17] fix(codex): harden hook follow-ups --- internal/commands/codex_hook.go | 113 ++++++++++++++++++++++++--- internal/commands/codex_hook_test.go | 37 +++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/internal/commands/codex_hook.go b/internal/commands/codex_hook.go index 46ed7720..428efc9a 100644 --- a/internal/commands/codex_hook.go +++ b/internal/commands/codex_hook.go @@ -118,15 +118,103 @@ func codexHookRanCommit(raw json.RawMessage) bool { if command == "" { command = input.Cmd } - fields := strings.Fields(command) - for i := 0; i+1 < len(fields); i++ { - if fields[i] == "git" && strings.Trim(fields[i+1], ";|&") == "commit" { + segments, ok := shellCommandSegments(command) + if !ok { + return false + } + for _, segment := range segments { + if codexGitSubcommand(strings.Fields(segment)) == "commit" { return true } } return false } +func shellCommandSegments(command string) ([]string, bool) { + segments := make([]string, 0, 2) + start := 0 + var quote byte + escaped := false + for index := 0; index < len(command); index++ { + char := command[index] + if escaped { + escaped = false + continue + } + if char == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + } + continue + } + if char == '\'' || char == '"' { + quote = char + continue + } + if char == ';' || char == '\n' || char == '&' || char == '|' { + if segment := strings.TrimSpace(command[start:index]); segment != "" { + segments = append(segments, segment) + } + start = index + 1 + } + } + if quote != 0 || escaped { + return nil, false + } + if segment := strings.TrimSpace(command[start:]); segment != "" { + segments = append(segments, segment) + } + return segments, true +} + +func codexGitSubcommand(fields []string) string { + if len(fields) < 2 || !isGitExecutable(fields[0]) { + return "" + } + for index := 1; index < len(fields); { + argument := strings.Trim(fields[index], `"'`) + switch argument { + case "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--exec-path": + if index+1 >= len(fields) { + return "" + } + index += 2 + case "--no-pager", "--paginate", "-p", "-P", "--bare", "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs": + index++ + case "--": + if index+1 >= len(fields) { + return "" + } + return strings.Trim(fields[index+1], `"'`) + default: + if strings.HasPrefix(argument, "-C") || strings.HasPrefix(argument, "-c") || + strings.HasPrefix(argument, "--git-dir=") || strings.HasPrefix(argument, "--work-tree=") || + strings.HasPrefix(argument, "--namespace=") || strings.HasPrefix(argument, "--config-env=") || + strings.HasPrefix(argument, "--exec-path=") { + index++ + continue + } + if strings.HasPrefix(argument, "-") { + return "" + } + return argument + } + } + return "" +} + +func isGitExecutable(field string) bool { + executable := strings.Trim(field, `"'`) + if separator := strings.LastIndexAny(executable, `/\`); separator >= 0 { + executable = executable[separator+1:] + } + return executable == "git" || strings.EqualFold(executable, "git.exe") +} + func codexHookSucceeded(input codexHookInput) bool { raw := input.ToolResponse if len(raw) == 0 || string(raw) == "null" { @@ -141,7 +229,7 @@ func codexHookSucceeded(input codexHookInput) bool { if json.Valid([]byte(nested)) { raw = []byte(nested) } else { - return nested != "" + return false } } @@ -155,20 +243,27 @@ func codexHookSucceeded(input codexHookInput) bool { if err := json.Unmarshal(raw, &result); err != nil { return false } - if result.ExitCode != nil && *result.ExitCode != 0 { + if result.IsError || result.Error != "" { return false } if result.Success != nil && !*result.Success { return false } - if result.IsError || result.Error != "" { - return false - } switch strings.ToLower(result.Status) { case "error", "failed", "failure", "canceled", codexAlternateCanceledState, "timed-out", "timeout": return false - default: + } + if result.ExitCode != nil { + return *result.ExitCode == 0 + } + if result.Success != nil { + return *result.Success + } + switch strings.ToLower(result.Status) { + case "completed", "success", "succeeded": return true + default: + return false } } diff --git a/internal/commands/codex_hook_test.go b/internal/commands/codex_hook_test.go index e52aa864..9a11315c 100644 --- a/internal/commands/codex_hook_test.go +++ b/internal/commands/codex_hook_test.go @@ -65,6 +65,43 @@ func TestCodexPostCommitCheckIgnoresFailedCommit(t *testing.T) { assert.Empty(t, runCodexHook(t, "post-commit-check", input, repo)) } +func TestCodexPostCommitCheckIgnoresUnrecognizedSuccessPayloads(t *testing.T) { + repo := newGitRepo(t, "main", "BC-123 initial") + inputs := []string{ + `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_output":"command output"}`, + `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_output":{}}`, + `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_response":{"status":"unknown"}}`, + `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_response":{"exit_code":0,"success":false}}`, + } + + for _, input := range inputs { + assert.Empty(t, runCodexHook(t, "post-commit-check", input, repo)) + } +} + +func TestCodexHookCommitDetection(t *testing.T) { + tests := []struct { + name string + command string + want bool + }{ + {name: "direct", command: "git commit -m ship", want: true}, + {name: "chained", command: "git add . && git commit -m ship", want: true}, + {name: "git options", command: "git -C . --no-pager commit -m ship", want: true}, + {name: "mere mention", command: "echo git commit", want: false}, + {name: "quoted mention", command: `echo "git commit"`, want: false}, + {name: "different subcommand", command: "git status # git commit", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := json.Marshal(map[string]string{"command": tt.command}) + require.NoError(t, err) + assert.Equal(t, tt.want, codexHookRanCommit(raw)) + }) + } +} + func TestCodexPostCommitCheckIgnoresSuccessfulCommitWithoutReference(t *testing.T) { repo := newGitRepo(t, "main", "ship native plugin") input := `{"tool_name":"Bash","tool_input":{"command":"git commit -m ship"},"tool_output":{"exit_code":0}}` From 96138d1f473dc17d1302d68a0a0a0cfe44e61413 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:19:45 -0700 Subject: [PATCH 07/17] fix(codex): refresh existing marketplace --- README.md | 3 ++- install.md | 3 ++- internal/commands/wizard_codex.go | 23 +++++++++++++++-------- internal/commands/wizard_codex_test.go | 26 +++++++++++++++++++++----- skills/basecamp-doctor/SKILL.md | 2 +- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2f69298e..6c7fa48f 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,8 @@ Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set t Manual Codex installation uses the same marketplace: ```bash -codex plugin marketplace add basecamp/claude-plugins --json +codex plugin marketplace add basecamp/claude-plugins --json || \ + codex plugin marketplace upgrade 37signals --json codex plugin add basecamp@37signals --json ``` diff --git a/install.md b/install.md index e86c61f1..cabf3667 100644 --- a/install.md +++ b/install.md @@ -131,7 +131,8 @@ This installs the shared Basecamp skill, registers the 37signals Codex marketpla For a manual install: ```bash -codex plugin marketplace add basecamp/claude-plugins --json +codex plugin marketplace add basecamp/claude-plugins --json || \ + codex plugin marketplace upgrade 37signals --json codex plugin add basecamp@37signals --json ``` diff --git a/internal/commands/wizard_codex.go b/internal/commands/wizard_codex.go index 09a84f6c..482a9022 100644 --- a/internal/commands/wizard_codex.go +++ b/internal/commands/wizard_codex.go @@ -2,6 +2,7 @@ package commands import ( "context" + "encoding/json" "fmt" "os/exec" "strings" @@ -56,14 +57,14 @@ func installCodexPlugin(parent context.Context) error { defer cancel() output, err := runCodexSetupCommand(ctx, codexPath, "plugin", "marketplace", "add", harness.CodexMarketplaceSource, "--json") - if err != nil { - if codexMarketplaceAlreadyAdded(output) { - upgradeOutput, upgradeErr := runCodexSetupCommand(ctx, codexPath, "plugin", "marketplace", "upgrade", harness.CodexMarketplaceName, "--json") - if upgradeErr != nil { - return codexSetupError("marketplace upgrade failed: " + codexCommandFailure(upgradeOutput, upgradeErr)) - } - } else { - return codexSetupError("marketplace add failed: " + codexCommandFailure(output, err)) + alreadyAdded := codexMarketplaceAlreadyAdded(output) + if err != nil && !alreadyAdded { + return codexSetupError("marketplace add failed: " + codexCommandFailure(output, err)) + } + if alreadyAdded { + upgradeOutput, upgradeErr := runCodexSetupCommand(ctx, codexPath, "plugin", "marketplace", "upgrade", harness.CodexMarketplaceName, "--json") + if upgradeErr != nil { + return codexSetupError("marketplace upgrade failed: " + codexCommandFailure(upgradeOutput, upgradeErr)) } } @@ -84,6 +85,12 @@ func installCodexPlugin(parent context.Context) error { } func codexMarketplaceAlreadyAdded(output []byte) bool { + var result struct { + AlreadyAdded bool `json:"alreadyAdded"` + } + if json.Unmarshal(output, &result) == nil && result.AlreadyAdded { + return true + } message := strings.ToLower(string(output)) return strings.Contains(message, "already") && (strings.Contains(message, "registered") || strings.Contains(message, "configured") || strings.Contains(message, "exists")) diff --git a/internal/commands/wizard_codex_test.go b/internal/commands/wizard_codex_test.go index fdde3985..f587a2b6 100644 --- a/internal/commands/wizard_codex_test.go +++ b/internal/commands/wizard_codex_test.go @@ -53,6 +53,20 @@ func TestSetupCodexAlreadyAddedRefreshesMarketplace(t *testing.T) { ) } +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}) @@ -151,11 +165,12 @@ func runSetupCodexJSON(t *testing.T) setupCodexEnvelope { } type codexStubOptions struct { - marketplaceAlreadyAdded bool - marketplaceFailure bool - pluginAlreadyInstalled bool - pluginFailure bool - verificationMissing bool + marketplaceAlreadyAdded bool + marketplaceAlreadyAddedSuccess bool + marketplaceFailure bool + pluginAlreadyInstalled bool + pluginFailure bool + verificationMissing bool } func installCodexStub(t *testing.T, options codexStubOptions) string { @@ -180,6 +195,7 @@ func installCodexStub(t *testing.T, options codexStubOptions) string { " \"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" + diff --git a/skills/basecamp-doctor/SKILL.md b/skills/basecamp-doctor/SKILL.md index 6835d75a..14e82514 100644 --- a/skills/basecamp-doctor/SKILL.md +++ b/skills/basecamp-doctor/SKILL.md @@ -18,7 +18,7 @@ Interpret every check by status: - `skip`: not run because it is unauthenticated or not applicable. - `fail`: broken and needs attention. -Report failures and warnings with their `hint` fields. Use these common remediations when relevant: +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` - Codex plugin installation or version: `basecamp setup codex` From 7539eb0faa833c553780d44cf3c90309a768432c Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:20:02 -0700 Subject: [PATCH 08/17] fix(codex): harden release validation --- internal/release/codex_plugin_test.go | 93 +++++++++++++++++++++++++-- scripts/check-codex-plugin.py | 19 ++++-- scripts/stamp-codex-plugin-version.sh | 7 +- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/internal/release/codex_plugin_test.go b/internal/release/codex_plugin_test.go index 775db7f9..6e1dc4f9 100644 --- a/internal/release/codex_plugin_test.go +++ b/internal/release/codex_plugin_test.go @@ -35,6 +35,21 @@ func TestStampCodexPluginVersionDoesNotChangeClaudeManifest(t *testing.T) { assert.Equal(t, claudeBefore, claudeAfter) } +func TestStampCodexPluginVersionCleansTemporaryFileAfterFailure(t *testing.T) { + 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 TestCodexPluginCheckPassesRepositoryPayload(t *testing.T) { root := repositoryRoot(t) cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), root) @@ -51,6 +66,59 @@ func TestCodexPluginCheckRejectsMissingManifest(t *testing.T) { assert.Contains(t, string(output), ".codex-plugin/plugin.json") } +func TestCodexPluginCheckUsesStrictSemver(t *testing.T) { + root := repositoryRoot(t) + checker := filepath.Join(root, "scripts", "check-codex-plugin.py") + probe := `import importlib.util, sys; spec = importlib.util.spec_from_file_location("checker", sys.argv[1]); module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module); raise SystemExit(0 if module.SEMVER.fullmatch(sys.argv[2]) else 1)` + 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\u0660", valid: false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + cmd := exec.CommandContext(context.Background(), "python3", "-c", probe, checker, tt.version) + err := cmd.Run() + assert.Equal(t, tt.valid, err == nil) + }) + } +} + +func TestCodexPluginCheckerAcceptsWhitespaceIndependentCommands(t *testing.T) { + root := repositoryRoot(t) + fixture := t.TempDir() + for _, relative := range []string{ + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + "hooks/hooks.json", + "skills/basecamp/SKILL.md", + "skills/basecamp-doctor/SKILL.md", + "assets/bc5-snowglobe.png", + "internal/commands/codex_hook.go", + } { + copyFixtureFile(t, root, fixture, relative) + } + commandPath := filepath.Join(fixture, "internal", "commands", "codex_hook.go") + commandSource := readFile(t, commandPath) + commandSource = strings.ReplaceAll(commandSource, "Use: \"codex-hook\"", "Use:\t\"codex-hook\"") + commandSource = strings.ReplaceAll(commandSource, "Use: \"session-start\"", "Use:\t\t\"session-start\"") + require.NoError(t, os.WriteFile(commandPath, []byte(commandSource), 0o644)) + rootPath := filepath.Join(fixture, "internal", "cli", "root.go") + require.NoError(t, os.MkdirAll(filepath.Dir(rootPath), 0o755)) + require.NoError(t, os.WriteFile(rootPath, []byte("package cli\n\nvar _ = commands . NewCodexHookCmd ( )\n"), 0o644)) + + cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), fixture) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + func TestReleaseWiringStampsBothStableManifests(t *testing.T) { root := repositoryRoot(t) goreleaser := readFile(t, filepath.Join(root, ".goreleaser.yaml")) @@ -70,12 +138,18 @@ func TestReleaseWiringSkipsPluginStampsForPrereleases(t *testing.T) { assert.Contains(t, goreleaser, "if .Prerelease") assert.Contains(t, goreleaser, "Skipping plugin version stamps for prerelease") - prereleaseBranch := strings.Index(releaseScript, `if [[ "${PRERELEASE}" == "true" ]]`) - claudeStamp := strings.Index(releaseScript, `scripts/stamp-plugin-version.sh "${VERSION}"`) - codexStamp := strings.Index(releaseScript, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`) - require.NotEqual(t, -1, prereleaseBranch) - require.Greater(t, claudeStamp, prereleaseBranch) - require.Greater(t, codexStamp, prereleaseBranch) + prereleaseStart := strings.Index(releaseScript, `if [[ "${PRERELEASE}" == "true" ]]`) + require.NotEqual(t, -1, prereleaseStart) + stableStart := strings.Index(releaseScript[prereleaseStart:], "\nelse\n # --- Update Nix flake ---") + require.NotEqual(t, -1, stableStart) + stableEnd := strings.Index(releaseScript[prereleaseStart+stableStart:], "\nfi\n\n# --- Commit release prep ---") + require.NotEqual(t, -1, stableEnd) + prereleaseBlock := releaseScript[prereleaseStart : prereleaseStart+stableStart] + stableBlock := releaseScript[prereleaseStart+stableStart : prereleaseStart+stableStart+stableEnd] + assert.NotContains(t, prereleaseBlock, "stamp-plugin-version.sh") + assert.NotContains(t, prereleaseBlock, "stamp-codex-plugin-version.sh") + assert.Contains(t, stableBlock, `scripts/stamp-plugin-version.sh "${VERSION}"`) + assert.Contains(t, stableBlock, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`) } func TestClaudeAndCodexManifestVersionsMatch(t *testing.T) { @@ -109,6 +183,13 @@ func copyFile(t *testing.T, source, destination string) { require.NoError(t, os.WriteFile(destination, data, 0o644)) } +func copyFixtureFile(t *testing.T, sourceRoot, destinationRoot, relative string) { + t.Helper() + destination := filepath.Join(destinationRoot, relative) + require.NoError(t, os.MkdirAll(filepath.Dir(destination), 0o755)) + copyFile(t, filepath.Join(sourceRoot, relative), destination) +} + func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) diff --git a/scripts/check-codex-plugin.py b/scripts/check-codex-plugin.py index bc58966f..e78e187c 100755 --- a/scripts/check-codex-plugin.py +++ b/scripts/check-codex-plugin.py @@ -10,9 +10,10 @@ from typing import Any +SEMVER_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" SEMVER = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + rf"(?:-{SEMVER_IDENTIFIER}(?:\.{SEMVER_IDENTIFIER})*)?" r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" ) UNSUPPORTED_MANIFEST_FIELDS = {"apps", "mcpServers", "hooks"} @@ -178,10 +179,16 @@ def validate_repository_contract(root: Path, errors: list[str]) -> None: except OSError as exc: errors.append(f"cannot read command registration: {exc}") root_source = "" - for token in ('Use: "codex-hook"', "Hidden: true", 'Use: "session-start"', 'Use: "post-commit-check"'): - if token not in command_source: - errors.append(f"hidden command source missing {token!r}") - if "commands.NewCodexHookCmd()" not in root_source: + command_patterns = { + r'\bUse\s*:\s*"codex-hook"': 'Use: "codex-hook"', + r"\bHidden\s*:\s*true\b": "Hidden: true", + r'\bUse\s*:\s*"session-start"': 'Use: "session-start"', + r'\bUse\s*:\s*"post-commit-check"': 'Use: "post-commit-check"', + } + for pattern, description in command_patterns.items(): + if re.search(pattern, command_source) is None: + errors.append(f"hidden command source missing {description!r}") + if re.search(r"\bcommands\s*\.\s*NewCodexHookCmd\s*\(\s*\)", root_source) is None: errors.append("Codex hook command is not registered in internal/cli/root.go") diff --git a/scripts/stamp-codex-plugin-version.sh b/scripts/stamp-codex-plugin-version.sh index 7f770097..59eb69ca 100755 --- a/scripts/stamp-codex-plugin-version.sh +++ b/scripts/stamp-codex-plugin-version.sh @@ -11,6 +11,9 @@ fi 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" > "${PLUGIN_JSON}.tmp" -mv "${PLUGIN_JSON}.tmp" "$PLUGIN_JSON" +jq --arg v "$VERSION" '.version = $v' "$PLUGIN_JSON" > "${TEMP_FILE}" +mv "${TEMP_FILE}" "$PLUGIN_JSON" +trap - EXIT From fc34b347b1bf52e2e8cc74e9c3becedeff4c817b Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:38:22 -0700 Subject: [PATCH 09/17] test(codex): isolate missing binary home --- internal/harness/codex_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/harness/codex_test.go b/internal/harness/codex_test.go index 995268df..99e4a5e1 100644 --- a/internal/harness/codex_test.go +++ b/internal/harness/codex_test.go @@ -34,6 +34,7 @@ func TestDetectCodexBinary(t *testing.T) { } func TestCheckCodexPluginMissingBinary(t *testing.T) { + t.Setenv("HOME", t.TempDir()) stubCodexLookPath(t, "", exec.ErrNotFound) check := CheckCodexPlugin() From 3c572afdf21d294fc05ad99ee2a12998bad09084 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:50:45 -0700 Subject: [PATCH 10/17] fix(codex): parse shell commit commands --- internal/commands/codex_hook.go | 93 +++++++++++++++++++++++----- internal/commands/codex_hook_test.go | 5 ++ 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/internal/commands/codex_hook.go b/internal/commands/codex_hook.go index 428efc9a..f88622a3 100644 --- a/internal/commands/codex_hook.go +++ b/internal/commands/codex_hook.go @@ -118,60 +118,89 @@ func codexHookRanCommit(raw json.RawMessage) bool { if command == "" { command = input.Cmd } - segments, ok := shellCommandSegments(command) + segments, ok := shellCommandFields(command) if !ok { return false } - for _, segment := range segments { - if codexGitSubcommand(strings.Fields(segment)) == "commit" { + for _, fields := range segments { + if codexGitSubcommand(fields) == "commit" { return true } } return false } -func shellCommandSegments(command string) ([]string, bool) { - segments := make([]string, 0, 2) - start := 0 +func shellCommandFields(command string) ([][]string, bool) { + segments := make([][]string, 0, 2) + fields := make([]string, 0, 4) + var word strings.Builder var quote byte escaped := false + started := false + flushWord := func() { + if !started { + return + } + fields = append(fields, word.String()) + word.Reset() + started = false + } + flushSegment := func() { + flushWord() + if len(fields) == 0 { + return + } + segments = append(segments, fields) + fields = make([]string, 0, 4) + } + for index := 0; index < len(command); index++ { char := command[index] if escaped { + word.WriteByte(char) + started = true escaped = false continue } if char == '\\' && quote != '\'' { escaped = true + started = true continue } if quote != 0 { if char == quote { quote = 0 + } else { + word.WriteByte(char) } + started = true continue } if char == '\'' || char == '"' { quote = char + started = true + continue + } + if char == ' ' || char == '\t' || char == '\r' { + flushWord() continue } if char == ';' || char == '\n' || char == '&' || char == '|' { - if segment := strings.TrimSpace(command[start:index]); segment != "" { - segments = append(segments, segment) - } - start = index + 1 + flushSegment() + continue } + word.WriteByte(char) + started = true } if quote != 0 || escaped { return nil, false } - if segment := strings.TrimSpace(command[start:]); segment != "" { - segments = append(segments, segment) - } + flushSegment() return segments, true } func codexGitSubcommand(fields []string) string { + fields = shellCommandExecutable(fields) if len(fields) < 2 || !isGitExecutable(fields[0]) { return "" } @@ -207,12 +236,46 @@ func codexGitSubcommand(fields []string) string { return "" } +func shellCommandExecutable(fields []string) []string { + for len(fields) > 0 && isShellAssignment(fields[0]) { + fields = fields[1:] + } + if len(fields) == 0 || !isNamedExecutable(fields[0], "env") { + return fields + } + + fields = fields[1:] + for len(fields) > 0 && isShellAssignment(fields[0]) { + fields = fields[1:] + } + return fields +} + +func isShellAssignment(field string) bool { + separator := strings.IndexByte(field, '=') + if separator < 1 { + return false + } + for index := 0; index < separator; index++ { + char := field[index] + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && char != '_' && + (index == 0 || char < '0' || char > '9') { + return false + } + } + return true +} + func isGitExecutable(field string) bool { - executable := strings.Trim(field, `"'`) + return isNamedExecutable(field, "git") +} + +func isNamedExecutable(field, name string) bool { + executable := field if separator := strings.LastIndexAny(executable, `/\`); separator >= 0 { executable = executable[separator+1:] } - return executable == "git" || strings.EqualFold(executable, "git.exe") + return executable == name || strings.EqualFold(executable, name+".exe") } func codexHookSucceeded(input codexHookInput) bool { diff --git a/internal/commands/codex_hook_test.go b/internal/commands/codex_hook_test.go index 9a11315c..1c298fae 100644 --- a/internal/commands/codex_hook_test.go +++ b/internal/commands/codex_hook_test.go @@ -88,7 +88,12 @@ func TestCodexHookCommitDetection(t *testing.T) { {name: "direct", command: "git commit -m ship", want: true}, {name: "chained", command: "git add . && git commit -m ship", want: true}, {name: "git options", command: "git -C . --no-pager commit -m ship", want: true}, + {name: "quoted git option", command: `git -C "repo with spaces" commit -m ship`, want: true}, + {name: "environment assignment", command: "GIT_AUTHOR_NAME=Bot git commit -m ship", want: true}, + {name: "quoted environment assignment", command: `GIT_AUTHOR_NAME="Build Bot" git commit -m ship`, want: true}, + {name: "env wrapper", command: "env GIT_AUTHOR_NAME=Bot git commit -m ship", want: true}, {name: "mere mention", command: "echo git commit", want: false}, + {name: "assignment before mention", command: "MODE=test echo git commit", want: false}, {name: "quoted mention", command: `echo "git commit"`, want: false}, {name: "different subcommand", command: "git status # git commit", want: false}, } From 3984fb98da39831a95f005bfd41e85cf76c2b03d Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:04:41 -0700 Subject: [PATCH 11/17] fix(codex): model shell execution status --- internal/commands/codex_hook.go | 99 +++++++++++++++++++++++----- internal/commands/codex_hook_test.go | 12 ++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/internal/commands/codex_hook.go b/internal/commands/codex_hook.go index f88622a3..c8f22dce 100644 --- a/internal/commands/codex_hook.go +++ b/internal/commands/codex_hook.go @@ -29,6 +29,11 @@ type codexHookInput struct { ToolResponse json.RawMessage `json:"tool_response"` } +type shellCommandSegment struct { + fields []string + separatorBefore string +} + // NewCodexHookCmd creates the hidden command group used by Codex plugin hooks. func NewCodexHookCmd() *cobra.Command { cmd := &cobra.Command{ @@ -122,19 +127,24 @@ func codexHookRanCommit(raw json.RawMessage) bool { if !ok { return false } - for _, fields := range segments { - if codexGitSubcommand(fields) == "commit" { - return true + commitProven := false + for _, segment := range segments { + if segment.separatorBefore != "&&" { + commitProven = false + } + if codexGitSubcommand(segment.fields) == "commit" { + commitProven = true } } - return false + return commitProven } -func shellCommandFields(command string) ([][]string, bool) { - segments := make([][]string, 0, 2) +func shellCommandFields(command string) ([]shellCommandSegment, bool) { + segments := make([]shellCommandSegment, 0, 2) fields := make([]string, 0, 4) var word strings.Builder var quote byte + separator := "" escaped := false started := false flushWord := func() { @@ -145,13 +155,14 @@ func shellCommandFields(command string) ([][]string, bool) { word.Reset() started = false } - flushSegment := func() { + flushSegment := func() bool { flushWord() if len(fields) == 0 { - return + return false } - segments = append(segments, fields) + segments = append(segments, shellCommandSegment{fields: fields, separatorBefore: separator}) fields = make([]string, 0, 4) + return true } for index := 0; index < len(command); index++ { @@ -163,6 +174,14 @@ func shellCommandFields(command string) ([][]string, bool) { continue } if char == '\\' && quote != '\'' { + if quote == '"' && index+1 < len(command) { + next := command[index+1] + if next != '$' && next != '`' && next != '"' && next != '\\' && next != '\n' { + word.WriteByte(char) + started = true + continue + } + } escaped = true started = true continue @@ -185,8 +204,33 @@ func shellCommandFields(command string) ([][]string, bool) { flushWord() continue } - if char == ';' || char == '\n' || char == '&' || char == '|' { - flushSegment() + if char == '|' { + return nil, false + } + if char == '&' { + if (index > 0 && command[index-1] == '>') || (index+1 < len(command) && command[index+1] == '>') { + word.WriteByte(char) + started = true + continue + } + if index+1 >= len(command) || command[index+1] != '&' || !flushSegment() { + return nil, false + } + separator = "&&" + index++ + continue + } + if char == ';' { + if !flushSegment() { + return nil, false + } + separator = ";" + continue + } + if char == '\n' { + if flushSegment() { + separator = "\n" + } continue } word.WriteByte(char) @@ -195,7 +239,12 @@ func shellCommandFields(command string) ([][]string, bool) { if quote != 0 || escaped { return nil, false } - flushSegment() + flushWord() + if len(fields) > 0 { + segments = append(segments, shellCommandSegment{fields: fields, separatorBefore: separator}) + } else if separator == "&&" { + return nil, false + } return segments, true } @@ -245,10 +294,30 @@ func shellCommandExecutable(fields []string) []string { } fields = fields[1:] - for len(fields) > 0 && isShellAssignment(fields[0]) { - fields = fields[1:] + for len(fields) > 0 { + switch argument := fields[0]; { + case isShellAssignment(argument), argument == "-i", argument == "--ignore-environment": + fields = fields[1:] + case argument == "-u", argument == "--unset", argument == "-C", argument == "--chdir": + if len(fields) < 2 { + return nil + } + fields = fields[2:] + case strings.HasPrefix(argument, "--unset="), strings.HasPrefix(argument, "--chdir="): + fields = fields[1:] + case argument == "--": + fields = fields[1:] + for len(fields) > 0 && isShellAssignment(fields[0]) { + fields = fields[1:] + } + return fields + case strings.HasPrefix(argument, "-"): + return nil + default: + return fields + } } - return fields + return nil } func isShellAssignment(field string) bool { diff --git a/internal/commands/codex_hook_test.go b/internal/commands/codex_hook_test.go index 1c298fae..e63e6434 100644 --- a/internal/commands/codex_hook_test.go +++ b/internal/commands/codex_hook_test.go @@ -92,10 +92,22 @@ func TestCodexHookCommitDetection(t *testing.T) { {name: "environment assignment", command: "GIT_AUTHOR_NAME=Bot git commit -m ship", want: true}, {name: "quoted environment assignment", command: `GIT_AUTHOR_NAME="Build Bot" git commit -m ship`, want: true}, {name: "env wrapper", command: "env GIT_AUTHOR_NAME=Bot git commit -m ship", want: true}, + {name: "env flag", command: "env -i git commit -m ship", want: true}, + {name: "env option value", command: "env -u TOKEN git commit -m ship", want: true}, + {name: "env chdir", command: `env -C "repo with spaces" git commit -m ship`, want: true}, + {name: "Windows executable", command: `"C:\Program Files\Git\bin\git.exe" commit -m ship`, want: true}, + {name: "commit after sequential command", command: "echo ready; git commit -m ship", want: true}, + {name: "stderr redirection", command: "git commit -m ship 2>&1", want: true}, + {name: "combined redirection", command: "git commit -m ship &>/dev/null", want: true}, {name: "mere mention", command: "echo git commit", want: false}, {name: "assignment before mention", command: "MODE=test echo git commit", want: false}, {name: "quoted mention", command: `echo "git commit"`, want: false}, {name: "different subcommand", command: "git status # git commit", want: false}, + {name: "skipped or branch", command: "git diff --quiet || git commit -m ship", want: false}, + {name: "commit before sequential command", command: "git commit -m ship; echo done", want: false}, + {name: "ambiguous pipeline", command: "git commit -m ship | cat", want: false}, + {name: "background commit", command: "git commit -m ship &", want: false}, + {name: "unknown env option", command: "env --unknown git commit -m ship", want: false}, } for _, tt := range tests { From c09c86630da1a047747625d43483a7aca107d530 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:04:42 -0700 Subject: [PATCH 12/17] test(codex): make release checks portable --- internal/release/codex_plugin_test.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/internal/release/codex_plugin_test.go b/internal/release/codex_plugin_test.go index 6e1dc4f9..e4e29276 100644 --- a/internal/release/codex_plugin_test.go +++ b/internal/release/codex_plugin_test.go @@ -15,6 +15,9 @@ import ( ) 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)) @@ -36,6 +39,9 @@ func TestStampCodexPluginVersionDoesNotChangeClaudeManifest(t *testing.T) { } 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)) @@ -52,7 +58,7 @@ func TestStampCodexPluginVersionCleansTemporaryFileAfterFailure(t *testing.T) { func TestCodexPluginCheckPassesRepositoryPayload(t *testing.T) { root := repositoryRoot(t) - cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), root) + cmd := exec.CommandContext(context.Background(), requirePython3(t), filepath.Join(root, "scripts", "check-codex-plugin.py"), root) output, err := cmd.CombinedOutput() require.NoError(t, err, string(output)) assert.Contains(t, string(output), "Codex plugin check passed") @@ -60,7 +66,7 @@ func TestCodexPluginCheckPassesRepositoryPayload(t *testing.T) { func TestCodexPluginCheckRejectsMissingManifest(t *testing.T) { root := repositoryRoot(t) - cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), t.TempDir()) + cmd := exec.CommandContext(context.Background(), requirePython3(t), filepath.Join(root, "scripts", "check-codex-plugin.py"), t.TempDir()) output, err := cmd.CombinedOutput() require.Error(t, err) assert.Contains(t, string(output), ".codex-plugin/plugin.json") @@ -68,6 +74,7 @@ func TestCodexPluginCheckRejectsMissingManifest(t *testing.T) { func TestCodexPluginCheckUsesStrictSemver(t *testing.T) { root := repositoryRoot(t) + python := requirePython3(t) checker := filepath.Join(root, "scripts", "check-codex-plugin.py") probe := `import importlib.util, sys; spec = importlib.util.spec_from_file_location("checker", sys.argv[1]); module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module); raise SystemExit(0 if module.SEMVER.fullmatch(sys.argv[2]) else 1)` tests := []struct { @@ -84,7 +91,7 @@ func TestCodexPluginCheckUsesStrictSemver(t *testing.T) { for _, tt := range tests { t.Run(tt.version, func(t *testing.T) { - cmd := exec.CommandContext(context.Background(), "python3", "-c", probe, checker, tt.version) + cmd := exec.CommandContext(context.Background(), python, "-c", probe, checker, tt.version) err := cmd.Run() assert.Equal(t, tt.valid, err == nil) }) @@ -93,6 +100,7 @@ func TestCodexPluginCheckUsesStrictSemver(t *testing.T) { func TestCodexPluginCheckerAcceptsWhitespaceIndependentCommands(t *testing.T) { root := repositoryRoot(t) + python := requirePython3(t) fixture := t.TempDir() for _, relative := range []string{ ".codex-plugin/plugin.json", @@ -114,7 +122,7 @@ func TestCodexPluginCheckerAcceptsWhitespaceIndependentCommands(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Dir(rootPath), 0o755)) require.NoError(t, os.WriteFile(rootPath, []byte("package cli\n\nvar _ = commands . NewCodexHookCmd ( )\n"), 0o644)) - cmd := exec.CommandContext(context.Background(), "python3", filepath.Join(root, "scripts", "check-codex-plugin.py"), fixture) + cmd := exec.CommandContext(context.Background(), python, filepath.Join(root, "scripts", "check-codex-plugin.py"), fixture) output, err := cmd.CombinedOutput() require.NoError(t, err, string(output)) } @@ -167,6 +175,15 @@ func repositoryRoot(t *testing.T) string { return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) } +func requirePython3(t *testing.T) string { + t.Helper() + path, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is required for the Codex plugin checker") + } + return path +} + func manifestVersion(t *testing.T, path string) string { t.Helper() var manifest struct { From 833e34ceeab7b5a132f3fcf6f5f1b9ca49e52bcc Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:17:10 -0700 Subject: [PATCH 13/17] fix(codex): parse hooks with bash AST --- go.mod | 1 + go.sum | 6 ++ internal/commands/codex_hook.go | 155 ++++++++------------------- internal/commands/codex_hook_test.go | 2 + 4 files changed, 51 insertions(+), 113 deletions(-) diff --git a/go.mod b/go.mod index bbd79743..539b591b 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( golang.org/x/sys v0.47.0 golang.org/x/text v0.40.0 gopkg.in/yaml.v3 v3.0.1 + mvdan.cc/sh/v3 v3.13.1 ) require ( diff --git a/go.sum b/go.sum index eae34b6a..e1231f8e 100644 --- a/go.sum +++ b/go.sum @@ -87,10 +87,14 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -184,3 +188,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/internal/commands/codex_hook.go b/internal/commands/codex_hook.go index c8f22dce..793f64af 100644 --- a/internal/commands/codex_hook.go +++ b/internal/commands/codex_hook.go @@ -10,6 +10,7 @@ import ( "time" "github.com/spf13/cobra" + "mvdan.cc/sh/v3/syntax" "github.com/basecamp/basecamp-cli/internal/appctx" ) @@ -29,11 +30,6 @@ type codexHookInput struct { ToolResponse json.RawMessage `json:"tool_response"` } -type shellCommandSegment struct { - fields []string - separatorBefore string -} - // NewCodexHookCmd creates the hidden command group used by Codex plugin hooks. func NewCodexHookCmd() *cobra.Command { cmd := &cobra.Command{ @@ -123,129 +119,62 @@ func codexHookRanCommit(raw json.RawMessage) bool { if command == "" { command = input.Cmd } - segments, ok := shellCommandFields(command) - if !ok { + file, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "") + if err != nil || len(file.Stmts) == 0 { return false } - commitProven := false - for _, segment := range segments { - if segment.separatorBefore != "&&" { - commitProven = false - } - if codexGitSubcommand(segment.fields) == "commit" { - commitProven = true - } - } - return commitProven + return codexStatementProvesCommit(file.Stmts[len(file.Stmts)-1]) } -func shellCommandFields(command string) ([]shellCommandSegment, bool) { - segments := make([]shellCommandSegment, 0, 2) - fields := make([]string, 0, 4) - var word strings.Builder - var quote byte - separator := "" - escaped := false - started := false - flushWord := func() { - if !started { - return - } - fields = append(fields, word.String()) - word.Reset() - started = false +func codexStatementProvesCommit(statement *syntax.Stmt) bool { + if statement == nil || statement.Negated || statement.Background || statement.Coprocess || statement.Disown { + return false } - flushSegment := func() bool { - flushWord() - if len(fields) == 0 { + switch command := statement.Cmd.(type) { + case *syntax.CallExpr: + fields, ok := staticShellWords(command.Args) + return ok && codexGitSubcommand(fields) == "commit" + case *syntax.BinaryCmd: + if command.Op != syntax.AndStmt { return false } - segments = append(segments, shellCommandSegment{fields: fields, separatorBefore: separator}) - fields = make([]string, 0, 4) - return true + return codexStatementProvesCommit(command.X) || codexStatementProvesCommit(command.Y) + default: + return false } +} - for index := 0; index < len(command); index++ { - char := command[index] - if escaped { - word.WriteByte(char) - started = true - escaped = false - continue - } - if char == '\\' && quote != '\'' { - if quote == '"' && index+1 < len(command) { - next := command[index+1] - if next != '$' && next != '`' && next != '"' && next != '\\' && next != '\n' { - word.WriteByte(char) - started = true - continue - } - } - escaped = true - started = true - continue - } - if quote != 0 { - if char == quote { - quote = 0 - } else { - word.WriteByte(char) - } - started = true - continue - } - if char == '\'' || char == '"' { - quote = char - started = true - continue - } - if char == ' ' || char == '\t' || char == '\r' { - flushWord() - continue - } - if char == '|' { +func staticShellWords(words []*syntax.Word) ([]string, bool) { + fields := make([]string, 0, len(words)) + for _, word := range words { + var value strings.Builder + if !appendStaticShellParts(&value, word.Parts) { return nil, false } - if char == '&' { - if (index > 0 && command[index-1] == '>') || (index+1 < len(command) && command[index+1] == '>') { - word.WriteByte(char) - started = true - continue - } - if index+1 >= len(command) || command[index+1] != '&' || !flushSegment() { - return nil, false - } - separator = "&&" - index++ - continue - } - if char == ';' { - if !flushSegment() { - return nil, false + fields = append(fields, value.String()) + } + return fields, true +} + +func appendStaticShellParts(value *strings.Builder, parts []syntax.WordPart) bool { + for _, part := range parts { + switch part := part.(type) { + case *syntax.Lit: + value.WriteString(part.Value) + case *syntax.SglQuoted: + if part.Dollar { + return false } - separator = ";" - continue - } - if char == '\n' { - if flushSegment() { - separator = "\n" + value.WriteString(part.Value) + case *syntax.DblQuoted: + if part.Dollar || !appendStaticShellParts(value, part.Parts) { + return false } - continue + default: + return false } - word.WriteByte(char) - started = true - } - if quote != 0 || escaped { - return nil, false } - flushWord() - if len(fields) > 0 { - segments = append(segments, shellCommandSegment{fields: fields, separatorBefore: separator}) - } else if separator == "&&" { - return nil, false - } - return segments, true + return true } func codexGitSubcommand(fields []string) string { diff --git a/internal/commands/codex_hook_test.go b/internal/commands/codex_hook_test.go index e63e6434..94e1865a 100644 --- a/internal/commands/codex_hook_test.go +++ b/internal/commands/codex_hook_test.go @@ -99,6 +99,7 @@ func TestCodexHookCommitDetection(t *testing.T) { {name: "commit after sequential command", command: "echo ready; git commit -m ship", want: true}, {name: "stderr redirection", command: "git commit -m ship 2>&1", want: true}, {name: "combined redirection", command: "git commit -m ship &>/dev/null", want: true}, + {name: "comment after and", command: "git commit -m ship && # note\necho done", want: true}, {name: "mere mention", command: "echo git commit", want: false}, {name: "assignment before mention", command: "MODE=test echo git commit", want: false}, {name: "quoted mention", command: `echo "git commit"`, want: false}, @@ -107,6 +108,7 @@ func TestCodexHookCommitDetection(t *testing.T) { {name: "commit before sequential command", command: "git commit -m ship; echo done", want: false}, {name: "ambiguous pipeline", command: "git commit -m ship | cat", want: false}, {name: "background commit", command: "git commit -m ship &", want: false}, + {name: "escaped redirection before background", command: `git commit -m ship foo\>& true`, want: false}, {name: "unknown env option", command: "env --unknown git commit -m ship", want: false}, } From 2df57a777cd945e4f97999cb6e8bb45e3af0c2dd Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:17:11 -0700 Subject: [PATCH 14/17] fix(codex): avoid duplicate checker errors --- internal/release/codex_plugin_test.go | 23 +++++++++++++++++++++++ scripts/check-codex-plugin.py | 13 +++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/internal/release/codex_plugin_test.go b/internal/release/codex_plugin_test.go index e4e29276..a2b68d9e 100644 --- a/internal/release/codex_plugin_test.go +++ b/internal/release/codex_plugin_test.go @@ -127,6 +127,29 @@ func TestCodexPluginCheckerAcceptsWhitespaceIndependentCommands(t *testing.T) { require.NoError(t, err, string(output)) } +func TestCodexPluginCheckerDoesNotDuplicateUnreadableSourceErrors(t *testing.T) { + root := repositoryRoot(t) + fixture := t.TempDir() + for _, relative := range []string{ + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + "hooks/hooks.json", + "skills/basecamp/SKILL.md", + "skills/basecamp-doctor/SKILL.md", + "assets/bc5-snowglobe.png", + } { + copyFixtureFile(t, root, fixture, relative) + } + + cmd := exec.CommandContext(context.Background(), requirePython3(t), filepath.Join(root, "scripts", "check-codex-plugin.py"), fixture) + output, err := cmd.CombinedOutput() + require.Error(t, err) + assert.Contains(t, string(output), "cannot read hidden command source") + assert.Contains(t, string(output), "cannot read command registration") + assert.NotContains(t, string(output), "hidden command source missing") + assert.NotContains(t, string(output), "Codex hook command is not registered") +} + func TestReleaseWiringStampsBothStableManifests(t *testing.T) { root := repositoryRoot(t) goreleaser := readFile(t, filepath.Join(root, ".goreleaser.yaml")) diff --git a/scripts/check-codex-plugin.py b/scripts/check-codex-plugin.py index e78e187c..b11adeff 100755 --- a/scripts/check-codex-plugin.py +++ b/scripts/check-codex-plugin.py @@ -169,26 +169,31 @@ def validate_repository_contract(root: Path, errors: list[str]) -> None: command_file = root / "internal" / "commands" / "codex_hook.go" root_file = root / "internal" / "cli" / "root.go" + command_read = True try: command_source = command_file.read_text(encoding="utf-8") except OSError as exc: errors.append(f"cannot read hidden command source: {exc}") command_source = "" + command_read = False + root_read = True try: root_source = root_file.read_text(encoding="utf-8") except OSError as exc: errors.append(f"cannot read command registration: {exc}") root_source = "" + root_read = False command_patterns = { r'\bUse\s*:\s*"codex-hook"': 'Use: "codex-hook"', r"\bHidden\s*:\s*true\b": "Hidden: true", r'\bUse\s*:\s*"session-start"': 'Use: "session-start"', r'\bUse\s*:\s*"post-commit-check"': 'Use: "post-commit-check"', } - for pattern, description in command_patterns.items(): - if re.search(pattern, command_source) is None: - errors.append(f"hidden command source missing {description!r}") - if re.search(r"\bcommands\s*\.\s*NewCodexHookCmd\s*\(\s*\)", root_source) is None: + if command_read: + for pattern, description in command_patterns.items(): + if re.search(pattern, command_source) is None: + errors.append(f"hidden command source missing {description!r}") + if root_read and re.search(r"\bcommands\s*\.\s*NewCodexHookCmd\s*\(\s*\)", root_source) is None: errors.append("Codex hook command is not registered in internal/cli/root.go") From 053a0cd35cd6d5b7584509650d70bfefd9c7cb76 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:27:12 -0700 Subject: [PATCH 15/17] fix(codex): handle invalid checker encoding --- internal/release/codex_plugin_test.go | 32 +++++++++++++++++++++++++++ scripts/check-codex-plugin.py | 6 ++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/release/codex_plugin_test.go b/internal/release/codex_plugin_test.go index a2b68d9e..cf30bd20 100644 --- a/internal/release/codex_plugin_test.go +++ b/internal/release/codex_plugin_test.go @@ -150,6 +150,38 @@ func TestCodexPluginCheckerDoesNotDuplicateUnreadableSourceErrors(t *testing.T) assert.NotContains(t, string(output), "Codex hook command is not registered") } +func TestCodexPluginCheckerReportsInvalidUTF8(t *testing.T) { + root := repositoryRoot(t) + fixture := t.TempDir() + for _, relative := range []string{ + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + "hooks/hooks.json", + "skills/basecamp/SKILL.md", + "skills/basecamp-doctor/SKILL.md", + "assets/bc5-snowglobe.png", + "internal/commands/codex_hook.go", + "internal/cli/root.go", + } { + copyFixtureFile(t, root, fixture, relative) + } + for _, relative := range []string{ + ".codex-plugin/plugin.json", + "internal/commands/codex_hook.go", + "internal/cli/root.go", + } { + require.NoError(t, os.WriteFile(filepath.Join(fixture, relative), []byte{0xff}, 0o644)) + } + + cmd := exec.CommandContext(context.Background(), requirePython3(t), filepath.Join(root, "scripts", "check-codex-plugin.py"), fixture) + output, err := cmd.CombinedOutput() + require.Error(t, err) + assert.Contains(t, string(output), "cannot read JSON") + assert.Contains(t, string(output), "cannot read hidden command source") + assert.Contains(t, string(output), "cannot read command registration") + assert.NotContains(t, string(output), "Traceback") +} + func TestReleaseWiringStampsBothStableManifests(t *testing.T) { root := repositoryRoot(t) goreleaser := readFile(t, filepath.Join(root, ".goreleaser.yaml")) diff --git a/scripts/check-codex-plugin.py b/scripts/check-codex-plugin.py index b11adeff..a779c6fc 100755 --- a/scripts/check-codex-plugin.py +++ b/scripts/check-codex-plugin.py @@ -25,7 +25,7 @@ def load_json(path: Path, errors: list[str]) -> dict[str, Any] | None: except FileNotFoundError: errors.append(f"missing required file: {path}") return None - except (OSError, json.JSONDecodeError) as exc: + except (OSError, UnicodeError, json.JSONDecodeError) as exc: errors.append(f"cannot read JSON {path}: {exc}") return None if not isinstance(value, dict): @@ -172,14 +172,14 @@ def validate_repository_contract(root: Path, errors: list[str]) -> None: command_read = True try: command_source = command_file.read_text(encoding="utf-8") - except OSError as exc: + except (OSError, UnicodeError) as exc: errors.append(f"cannot read hidden command source: {exc}") command_source = "" command_read = False root_read = True try: root_source = root_file.read_text(encoding="utf-8") - except OSError as exc: + except (OSError, UnicodeError) as exc: errors.append(f"cannot read command registration: {exc}") root_source = "" root_read = False From 3b5bf5afbbf3487228923a6a4c8d6e90b7b52939 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:40:19 -0700 Subject: [PATCH 16/17] fix(codex): keep version checks doctor-only --- internal/commands/doctor.go | 12 ++++++++++++ internal/commands/doctor_test.go | 33 ++++++++++++++++++-------------- internal/harness/codex.go | 9 +++++++-- internal/harness/codex_test.go | 13 ++++--------- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index ed5740f6..61e125df 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -230,6 +230,18 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check }) } + // 14. Codex plugin version (doctor-only, not part of generic agent checks + // which gate setup wizard behavior) + if harness.DetectCodex() { + pvc := harness.CheckCodexPluginVersionContext(ctx) + checks = append(checks, Check{ + Name: pvc.Name, + Status: pvc.Status, + Message: pvc.Message, + Hint: pvc.Hint, + }) + } + return checks } diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index e0de8b40..8ffcafdd 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -664,29 +664,34 @@ func TestBuildDoctorBreadcrumbs_SkillVersionWarn(t *testing.T) { func TestCheckCodexIntegrationIncludesVersion(t *testing.T) { installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + app, _ := setupDoctorTestApp(t, "") - agent := harness.FindAgent("codex") - require.NotNil(t, agent, "codex agent should be registered") - require.NotNil(t, agent.Checks) - - checks := agent.Checks() - require.Len(t, checks, 2) - assert.Equal(t, "Codex Plugin", checks[0].Name) - assert.Equal(t, "pass", checks[0].Status) - assert.Equal(t, "Codex Plugin Version", checks[1].Name) + 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) } 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 := harness.FindAgent("codex").Checks() - - require.Len(t, checks, 2) - assert.Equal(t, "warn", checks[1].Status) - assert.Contains(t, checks[1].Hint, "basecamp setup codex") + 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) { diff --git a/internal/harness/codex.go b/internal/harness/codex.go index 300295c1..0f21e711 100644 --- a/internal/harness/codex.go +++ b/internal/harness/codex.go @@ -49,7 +49,7 @@ func init() { ID: "codex", Detect: DetectCodex, Checks: func() []*StatusCheck { - return []*StatusCheck{CheckCodexPlugin(), CheckCodexPluginVersion()} + return []*StatusCheck{CheckCodexPlugin()} }, }) } @@ -118,7 +118,12 @@ func CheckCodexPluginContext(ctx context.Context) *StatusCheck { // CheckCodexPluginVersion compares the installed plugin and CLI versions. func CheckCodexPluginVersion() *StatusCheck { - state, found, err := queryCodexPlugin(context.Background()) + 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) if err != nil { return codexQueryFailure("Codex Plugin Version", err) } diff --git a/internal/harness/codex_test.go b/internal/harness/codex_test.go index 99e4a5e1..2b68b6ea 100644 --- a/internal/harness/codex_test.go +++ b/internal/harness/codex_test.go @@ -138,21 +138,16 @@ func TestCheckCodexPluginUsesSupportedJSONCommand(t *testing.T) { } func TestCodexAgentInfoWiring(t *testing.T) { - resetRegistry() - defer resetRegistry() - - RegisterAgent(AgentInfo{ - Name: "Codex", - ID: "codex", - Detect: DetectCodex, - Checks: func() []*StatusCheck { return []*StatusCheck{CheckCodexPlugin(), CheckCodexPluginVersion()} }, - }) + stubCodexList(t, codexListFixture("0.7.2", true, true), nil) 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) { From 63c5feb0806a3e3d8a48651608924fbb5e92da89 Mon Sep 17 00:00:00 2001 From: Yigit Konur <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:48:53 -0700 Subject: [PATCH 17/17] fix(codex): harden doctor diagnostics --- internal/commands/doctor.go | 34 ++++++++++++-------------------- internal/commands/doctor_test.go | 4 +++- internal/harness/codex.go | 26 +++++++++++++++++++++++- internal/harness/codex_test.go | 19 ++++++++++++++++++ 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 61e125df..b4398891 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -206,15 +206,19 @@ 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.ID == "codex" { + agentChecks = harness.CheckCodexPluginDiagnosticsContext(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, + }) } } @@ -230,18 +234,6 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check }) } - // 14. Codex plugin version (doctor-only, not part of generic agent checks - // which gate setup wizard behavior) - if harness.DetectCodex() { - pvc := harness.CheckCodexPluginVersionContext(ctx) - checks = append(checks, Check{ - Name: pvc.Name, - Status: pvc.Status, - Message: pvc.Message, - Hint: pvc.Hint, - }) - } - return checks } diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index 8ffcafdd..ff75496d 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" @@ -663,7 +664,7 @@ func TestBuildDoctorBreadcrumbs_SkillVersionWarn(t *testing.T) { } func TestCheckCodexIntegrationIncludesVersion(t *testing.T) { - installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) + logPath := installCodexStub(t, codexStubOptions{pluginAlreadyInstalled: true}) app, _ := setupDoctorTestApp(t, "") checks := runDoctorChecks(context.Background(), app, false) @@ -674,6 +675,7 @@ func TestCheckCodexIntegrationIncludesVersion(t *testing.T) { } } 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) { diff --git a/internal/harness/codex.go b/internal/harness/codex.go index 0f21e711..37236304 100644 --- a/internal/harness/codex.go +++ b/internal/harness/codex.go @@ -90,6 +90,19 @@ func CheckCodexPlugin() *StatusCheck { // 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) } @@ -124,10 +137,21 @@ func CheckCodexPluginVersion() *StatusCheck { // 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 || state.Version == "" { + 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", diff --git a/internal/harness/codex_test.go b/internal/harness/codex_test.go index 2b68b6ea..c0235065 100644 --- a/internal/harness/codex_test.go +++ b/internal/harness/codex_test.go @@ -118,6 +118,16 @@ func TestCheckCodexPluginVersionMismatch(t *testing.T) { 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 @@ -138,7 +148,16 @@ func TestCheckCodexPluginUsesSupportedJSONCommand(t *testing.T) { } 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)