diff --git a/.goreleaser.full.yml b/.goreleaser.full.yml
index 2c82a96..730969f 100644
--- a/.goreleaser.full.yml
+++ b/.goreleaser.full.yml
@@ -15,6 +15,7 @@ builds:
- CGO_ENABLED=0
ldflags:
- -s -w
+ - -X codemap/internal/buildinfo.version={{.Version}}
goos:
- darwin
- linux
diff --git a/.goreleaser.yml b/.goreleaser.yml
index d176ffb..00973e3 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,6 +14,7 @@ builds:
- CGO_ENABLED=0
ldflags:
- -s -w
+ - -X codemap/internal/buildinfo.version={{.Version}}
goos:
- darwin
- linux
diff --git a/README.md b/README.md
index ad96450..f1ac72e 100644
--- a/README.md
+++ b/README.md
@@ -71,14 +71,20 @@ cd /path/to/your/project
codemap setup
```
-`codemap setup` is the default onboarding path and configures the pieces that make codemap most useful with Claude:
+`codemap setup` configures Claude Code and Codex by default:
- creates `.codemap/config.json` (if missing) with auto-detected language filters
-- installs codemap hooks into `.claude/settings.local.json` (project-local by default)
+- merges hooks into `.claude/settings.local.json` and `.codex/hooks.json`
+- configures MCP in `.mcp.json` and `.codex/config.toml`
- hooks automatically start/read daemon state on session start
-Use global Claude settings instead of project-local settings:
+Managed entries use the verified absolute path of the running `codemap`, so
+agents do not depend on your shell `PATH`. Rerun setup if that path changes.
+
+Configure one agent only, or use global settings:
```bash
+codemap setup --agent claude
+codemap setup --agent codex
codemap setup --global
```
@@ -97,9 +103,9 @@ Optional helper scripts (mainly for contributors running from this repo):
## Verify Setup
-1. Restart Claude Code or open a new session.
-2. At session start, you should see codemap project context.
-3. Edit a file and confirm pre/post edit hook context appears.
+1. Run `codemap doctor` (or select `--agent claude|codex`).
+2. Trust Codex project hooks from `/hooks` in CLI or Settings > Hooks in Desktop, then start a new session/task.
+3. Confirm Codemap appears in `/mcp`, then edit a file and verify hook context.
## Daily Commands
@@ -111,7 +117,9 @@ codemap --deps . # Dependency flow (requires ast-grep)
codemap skill list # Show available skills
codemap context # Universal JSON context for any AI tool
codemap mcp # Run Codemap MCP server on stdio
-codemap plugin install # Install the Codemap Codex plugin
+codemap --version # Show the installed build version
+codemap plugin install # Install/update and activate the Codemap plugin through Codex CLI
+codemap doctor # Validate installed Claude/Codex integrations
codemap serve # HTTP API for non-MCP integrations
```
@@ -234,6 +242,50 @@ codemap blast-radius --json --ref main .
codemap blast-radius --text --ref main .
```
+## Codex Integration
+
+Plain `codemap setup` already configures Claude Code and Codex. Use
+`codemap setup --agent codex` to configure only Codex project hooks and MCP.
+Add `--no-config --no-mcp` when the Codex plugin owns MCP and only hooks are needed.
+Use `codemap plugin install` to install/update the bundled MCP and skills and
+activate them through Codex CLI. Both commands are
+idempotent and report when a new Codex task/session is needed. Use
+`--no-activate` only for staging; legacy `--activate` is deprecated because
+activation is now the default. Use `codemap doctor --agent codex` to validate
+the shared project integration and report CLI/Desktop runtimes independently.
+
+Managed MCP entries record the Codemap build version. After an upgrade, rerun
+setup or plugin installation if MCP reports a mismatch. Codex CLI and Desktop
+may use different runtime releases; doctor reports them independently.
+
+### After a Codemap upgrade
+
+Agent integrations do not update themselves. Codex users should refresh the
+global plugin first; all users should then refresh each project's managed hooks
+and MCP entries:
+
+```bash
+# After upgrading the codemap binary
+# Codex only, once for each Codex environment:
+codemap plugin install
+
+# Claude and Codex: repeat in each configured project
+cd /path/to/project
+codemap setup
+codemap doctor
+```
+
+`codemap plugin install` updates the global plugin and migrates the current
+project when run inside one, but it does not discover every configured project.
+One installation refreshes the plugin for CLI and Desktop when they share the
+same Codex environment. Repeat it for another host or `CODEX_HOME`; it does not
+upgrade the Codex applications themselves.
+Claude users skip that command but still rerun `codemap setup --agent claude`
+and `codemap doctor --agent claude` in each configured project.
+Rerun `codemap setup --global` separately if you use global agent settings.
+After plugin or managed command changes, start a new task in Desktop or a new
+session in CLI, and review hook trust again if Codex asks.
+
## Claude Integration
**Hooks (Recommended)** — Automatic context at session start, before/after edits, and more.
diff --git a/cmd/codemap-mcp/main.go b/cmd/codemap-mcp/main.go
index db3d5da..7beeeb4 100644
--- a/cmd/codemap-mcp/main.go
+++ b/cmd/codemap-mcp/main.go
@@ -1,16 +1,13 @@
package main
import (
- "context"
- "fmt"
"os"
- codemapmcp "codemap/mcp"
+ "codemap/cmd"
)
func main() {
- if err := codemapmcp.Run(context.Background()); err != nil {
- fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
- os.Exit(1)
+ if code := cmd.RunMCP(os.Args[1:]); code != 0 {
+ os.Exit(code)
}
}
diff --git a/cmd/doctor.go b/cmd/doctor.go
new file mode 100644
index 0000000..23d3c9f
--- /dev/null
+++ b/cmd/doctor.go
@@ -0,0 +1,617 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "codemap/internal/buildinfo"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/pelletier/go-toml/v2"
+)
+
+var doctorLookPath = exec.LookPath
+
+var (
+ doctorVersionTimeout = 5 * time.Second
+ doctorMCPTimeout = 5 * time.Second
+ doctorWaitDelay = 100 * time.Millisecond
+ doctorVersionProbe = probeDoctorVersion
+ doctorMCPProbe = probeDoctorMCP
+ doctorRuntimeGOOS = runtime.GOOS
+ doctorDesktopCodexCandidates = []string{
+ "/Applications/ChatGPT.app/Contents/Resources/codex",
+ "/Applications/Codex.app/Contents/Resources/codex",
+ }
+ doctorRuntimeVersionProbe = probeDoctorCodexRuntimeVersion
+)
+
+const doctorProbeOutputLimit = 8 * 1024
+
+type doctorManagedLaunch struct {
+ command string
+ args []string
+ configuredVersion string
+ integration string
+}
+
+// RunDoctor validates the selected local or global agent integrations without
+// changing project or user configuration. Its return value is a process exit
+// code: zero only when every integration selected for validation is usable.
+func RunDoctor(args []string, defaultRoot string) int {
+ fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ agent := fs.String("agent", "", "Agent integration to check (claude or codex; default: installed agents)")
+ global := fs.Bool("global", false, "Check user-scoped agent configuration")
+ if err := fs.Parse(args); err != nil || fs.NArg() > 1 {
+ fmt.Fprintln(os.Stderr, "Usage: codemap doctor [--global] [--agent claude|codex] [path]")
+ return 2
+ }
+ selected := strings.ToLower(strings.TrimSpace(*agent))
+ if selected != "" && selected != "claude" && selected != "codex" {
+ fmt.Fprintln(os.Stderr, "Error: --agent must be claude or codex")
+ return 2
+ }
+
+ root := defaultRoot
+ if fs.NArg() == 1 {
+ root = fs.Arg(0)
+ }
+ root, err := filepath.Abs(root)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
+ return 1
+ }
+
+ failures := 0
+ checkExecutable := func(label, name string, required bool) (bool, string) {
+ if path, err := doctorLookPath(name); err == nil {
+ fmt.Printf("OK %s executable is installed\n", label)
+ return true, path
+ }
+ if required {
+ fmt.Printf("MISS %s executable is not installed\n", label)
+ failures++
+ } else {
+ fmt.Printf("SKIP %s executable is not installed\n", label)
+ }
+ return false, ""
+ }
+ checkFile := func(label, path string, validate func(string) error) {
+ if err := validate(path); err != nil {
+ fmt.Printf("MISS %s: %s (%v)\n", label, path, err)
+ failures++
+ return
+ }
+ fmt.Printf("OK %s: %s\n", label, path)
+ }
+
+ checkFile("project config", filepath.Join(root, ".codemap", "config.json"), validateJSONFile)
+ claudeSettings, claudeSettingsErr := claudeSettingsPath(root, *global)
+ claudeMCP, claudeMCPErr := claudeMCPPath(root, *global)
+ codexHooks, codexHooksErr := codexHooksPath(root, *global)
+ codexMCP, codexMCPErr := codexConfigPath(root, *global)
+ validateEffectiveCodexMCP := validateCodexMCP
+ if !*global && codexMCPErr == nil && !codexProjectMCPOverridesPlugin(codexMCP) {
+ if pluginMCP, ok := activeCodexPluginMCPPath(); ok {
+ codexMCP = pluginMCP
+ validateEffectiveCodexMCP = validateCodexPluginMCP
+ }
+ }
+ claudeConfigured := doctorAnyConfigured(claudeSettings, claudeSettingsErr, claudeMCP, claudeMCPErr)
+ codexConfigured := doctorAnyConfigured(codexHooks, codexHooksErr, codexMCP, codexMCPErr)
+ anyConfigured := claudeConfigured || codexConfigured
+
+ claudeAvailable := false
+ codexAvailable := false
+ codexDesktopAvailable := false
+ if selected == "" || selected == "claude" {
+ claudeAvailable, _ = checkExecutable("Claude", "claude", selected == "claude")
+ }
+ if selected == "" || selected == "codex" {
+ var codexCLIPath string
+ codexAvailable, codexCLIPath = checkExecutable("Codex", "codex", selected == "codex")
+ codexDesktopAvailable = reportCodexRuntimeVersions(codexCLIPath)
+ }
+ if selected == "" && !claudeAvailable && !codexAvailable && !codexDesktopAvailable && !claudeConfigured && !codexConfigured {
+ fmt.Println("MISS no supported coding agent is installed or configured")
+ failures++
+ }
+
+ if selected == "claude" || (selected == "" && (claudeConfigured || (!anyConfigured && claudeAvailable))) {
+ checkResolvedFile("Claude hooks", claudeSettings, claudeSettingsErr, func(path string) error {
+ return validateHooks(path, recommendedClaudeHooks)
+ }, checkFile, &failures)
+ checkResolvedFile("Claude MCP", claudeMCP, claudeMCPErr, validateClaudeMCP, checkFile, &failures)
+ }
+ if selected == "codex" || (selected == "" && (codexConfigured || (!anyConfigured && (codexAvailable || codexDesktopAvailable)))) {
+ checkResolvedFile("Codex hooks", codexHooks, codexHooksErr, func(path string) error {
+ return validateHooks(path, recommendedCodexHooks)
+ }, checkFile, &failures)
+ if codexAvailable && codexHooksErr == nil && validateHooks(codexHooks, recommendedCodexHooks) == nil {
+ if err := validateCodexHookTrust(root); err != nil {
+ fmt.Printf("MISS Codex hook trust: %v\n", err)
+ failures++
+ } else {
+ fmt.Println("OK Codex hook trust: all Codemap hooks are enabled and runnable")
+ }
+ }
+ checkResolvedFile("Codex MCP", codexMCP, codexMCPErr, validateEffectiveCodexMCP, checkFile, &failures)
+ }
+
+ if failures > 0 {
+ fmt.Printf("\n%d check(s) need attention.\n", failures)
+ return 1
+ }
+ fmt.Println("\nCodemap integration prerequisites are valid.")
+ return 0
+}
+
+func reportCodexRuntimeVersions(cliPath string) bool {
+ if doctorRuntimeGOOS != "darwin" {
+ return false
+ }
+ desktopPaths := make([]string, 0, len(doctorDesktopCodexCandidates))
+ for _, candidate := range doctorDesktopCodexCandidates {
+ if _, err := validateIntegrationExecutable(candidate, "darwin"); err == nil {
+ desktopPaths = append(desktopPaths, candidate)
+ }
+ }
+ if len(desktopPaths) == 0 {
+ return false
+ }
+
+ cliVersion := ""
+ var cliErr error
+ if cliPath != "" {
+ cliVersion, cliErr = doctorRuntimeVersionProbe(cliPath)
+ if cliErr != nil {
+ fmt.Printf("WARN Codex CLI runtime: %s (%v)\n", cliPath, cliErr)
+ } else {
+ fmt.Printf("OK Codex CLI runtime: %s (%s)\n", cliPath, cliVersion)
+ }
+ }
+ for _, desktopPath := range desktopPaths {
+ desktopVersion, err := doctorRuntimeVersionProbe(desktopPath)
+ if err != nil {
+ fmt.Printf("WARN Codex Desktop runtime: %s (%v)\n", desktopPath, err)
+ continue
+ }
+ fmt.Printf("OK Codex Desktop runtime: %s (%s)\n", desktopPath, desktopVersion)
+ }
+ return true
+}
+
+func activeCodexPluginMCPPath() (string, bool) {
+ out, err := runCodexPluginCommand("", "plugin", "list", "--json")
+ if err != nil {
+ return "", false
+ }
+ var list codexPluginList
+ if err := json.Unmarshal(out, &list); err != nil {
+ return "", false
+ }
+ for _, plugin := range list.Installed {
+ if strings.HasPrefix(plugin.PluginID, "codemap@") && plugin.Installed && plugin.Enabled && filepath.IsAbs(plugin.Source.Path) {
+ return filepath.Join(plugin.Source.Path, ".mcp.json"), true
+ }
+ }
+ return "", false
+}
+
+func codexProjectMCPOverridesPlugin(path string) bool {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return false
+ }
+ if err != nil {
+ return true
+ }
+ payload := map[string]any{}
+ if err := toml.Unmarshal(data, &payload); err != nil {
+ return true
+ }
+ servers, _ := payload["mcp_servers"].(map[string]any)
+ _, exists := servers["codemap"]
+ return exists
+}
+
+func doctorAnyConfigured(firstPath string, firstErr error, secondPath string, secondErr error) bool {
+ for _, candidate := range []struct {
+ path string
+ err error
+ }{{firstPath, firstErr}, {secondPath, secondErr}} {
+ if candidate.err == nil {
+ if _, statErr := os.Stat(candidate.path); statErr == nil {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func checkResolvedFile(label, path string, pathErr error, validate func(string) error, check func(string, string, func(string) error), failures *int) {
+ if pathErr != nil {
+ fmt.Printf("MISS %s: could not resolve path (%v)\n", label, pathErr)
+ (*failures)++
+ return
+ }
+ check(label, path, validate)
+}
+
+func validateJSONFile(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ return nil
+}
+
+func validateHooks(path string, specs []claudeHookSpec) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var root map[string]any
+ if err := json.Unmarshal(data, &root); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ raw, ok := root["hooks"]
+ if !ok {
+ return fmt.Errorf("missing hooks object")
+ }
+ encoded, err := json.Marshal(raw)
+ if err != nil {
+ return err
+ }
+ hooks := map[string][]claudeHookEntry{}
+ if err := json.Unmarshal(encoded, &hooks); err != nil {
+ return fmt.Errorf("invalid hooks: %w", err)
+ }
+ for _, spec := range specs {
+ if !hasHookSpec(hooks[spec.Event], spec) {
+ return fmt.Errorf("missing %s hook %q", spec.Event, spec.Command)
+ }
+ }
+ return nil
+}
+
+func validateClaudeMCP(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ payload := map[string]any{}
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ servers, ok := payload["mcpServers"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("field 'mcpServers' must be an object")
+ }
+ server, ok := servers["codemap"]
+ if !ok {
+ return fmt.Errorf("missing codemap MCP server; repair with `codemap setup --agent claude`")
+ }
+ return validateDoctorMCPServer(server, "claude")
+}
+
+func validateCodexMCP(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ payload := map[string]any{}
+ if err := toml.Unmarshal(data, &payload); err != nil {
+ return fmt.Errorf("invalid TOML: %w", err)
+ }
+ servers, ok := payload["mcp_servers"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("missing mcp_servers table")
+ }
+ server, ok := servers["codemap"]
+ if !ok {
+ return fmt.Errorf("missing codemap MCP server; repair with `codemap setup --agent codex`")
+ }
+ return validateDoctorMCPServer(server, "codex")
+}
+
+func validateCodexPluginMCP(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ payload := map[string]any{}
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ servers, ok := payload["mcpServers"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("field 'mcpServers' must be an object")
+ }
+ server, ok := servers["codemap"]
+ if !ok {
+ return fmt.Errorf("missing codemap MCP server; repair with `codemap plugin install`")
+ }
+ return validateDoctorMCPServer(server, "codex")
+}
+
+func validateDoctorMCPServer(raw any, agent string) error {
+ launch, err := parseDoctorManagedLaunch(raw, agent)
+ if err != nil {
+ return fmt.Errorf("%w; repair with `%s`", err, doctorRepairCommand(agent, launch.integration))
+ }
+ if _, err := validateDoctorExecutable(launch.command, runtime.GOOS); err != nil {
+ return fmt.Errorf("%w; repair with `%s`", err, doctorRepairCommand(agent, launch.integration))
+ }
+ if err := doctorVersionProbe(launch); err != nil {
+ return fmt.Errorf("version probe failed for %q: %w; repair with `%s`", launch.command, err, doctorRepairCommand(agent, launch.integration))
+ }
+ if err := doctorMCPProbe(launch); err != nil {
+ return fmt.Errorf("MCP initialize probe failed for %q: %w; repair with `%s`", launch.command, err, doctorRepairCommand(agent, launch.integration))
+ }
+ return nil
+}
+
+func parseDoctorManagedLaunch(raw any, agent string) (doctorManagedLaunch, error) {
+ server, ok := raw.(map[string]any)
+ if !ok {
+ return doctorManagedLaunch{}, fmt.Errorf("codemap MCP server must be an object")
+ }
+ command, ok := server["command"].(string)
+ if !ok || strings.TrimSpace(command) == "" {
+ return doctorManagedLaunch{}, fmt.Errorf("codemap MCP command must be a non-empty string")
+ }
+ args := mcpServerArgs(server)
+ launch := doctorManagedLaunch{command: command, args: args, integration: doctorManagedIntegration(args)}
+ if command == "codemap" && stringSlicesEqual(args, []string{"mcp"}) {
+ return launch, fmt.Errorf("legacy PATH-relative codemap MCP definition is stale")
+ }
+ if len(args) != 5 || args[0] != "mcp" || args[1] != "--configured-version" || args[2] == "" || args[3] != "--integration" {
+ return launch, fmt.Errorf("unrecognized codemap MCP arguments")
+ }
+ launch.configuredVersion = args[2]
+ if !filepath.IsAbs(command) {
+ return launch, fmt.Errorf("codemap MCP command is not absolute: %q", command)
+ }
+ switch agent {
+ case "claude":
+ if launch.integration != "claude-setup" {
+ return launch, fmt.Errorf("unrecognized Claude integration %q", launch.integration)
+ }
+ case "codex":
+ if launch.integration != "codex-setup" && launch.integration != "codex-plugin" {
+ return launch, fmt.Errorf("unrecognized Codex integration %q", launch.integration)
+ }
+ default:
+ return launch, fmt.Errorf("unsupported agent %q", agent)
+ }
+ return launch, nil
+}
+
+func doctorManagedIntegration(args []string) string {
+ if len(args) >= 5 && args[0] == "mcp" && args[1] == "--configured-version" && args[3] == "--integration" {
+ switch args[4] {
+ case "claude-setup", "codex-setup", "codex-plugin":
+ return args[4]
+ }
+ }
+ return ""
+}
+
+func validateDoctorExecutable(path, goos string) (os.FileInfo, error) {
+ info, err := validateIntegrationExecutable(path, goos)
+ if err != nil {
+ return nil, err
+ }
+ if goos == "windows" {
+ ext := strings.ToLower(filepath.Ext(path))
+ if ext != ".exe" && ext != ".com" {
+ return nil, fmt.Errorf("codemap executable %q does not have a Windows executable extension", path)
+ }
+ }
+ return info, nil
+}
+
+func doctorRepairCommand(agent, integration string) string {
+ if agent == "codex" && integration == "codex-plugin" {
+ return "codemap plugin install"
+ }
+ return "codemap setup --agent " + agent
+}
+
+func probeDoctorVersion(launch doctorManagedLaunch) error {
+ version, err := probeDoctorExecutableVersion(launch.command, "codemap")
+ if err != nil {
+ return err
+ }
+ if !buildinfo.Equal(version, launch.configuredVersion) {
+ return fmt.Errorf("configured version %q does not match executable version %q", launch.configuredVersion, version)
+ }
+ return nil
+}
+
+func probeDoctorCodexRuntimeVersion(command string) (string, error) {
+ return probeDoctorExecutableVersion(command, "codex-cli")
+}
+
+func probeDoctorExecutableVersion(command, product string) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), doctorVersionTimeout)
+ defer cancel()
+ cmd := newDoctorCommand(ctx, command, "--version")
+ var stdout, stderr doctorBoundedBuffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("start: %w", err)
+ }
+ err := cmd.Wait()
+ _ = killDoctorProcess(cmd)
+ if ctx.Err() == context.DeadlineExceeded {
+ return "", fmt.Errorf("timed out after %s%s", doctorVersionTimeout, doctorStderrDetail(&stderr))
+ }
+ if errors.Is(err, exec.ErrWaitDelay) {
+ return "", fmt.Errorf("timed out waiting for process output after %s%s", doctorWaitDelay, doctorStderrDetail(&stderr))
+ }
+ if err != nil {
+ return "", fmt.Errorf("%v%s", err, doctorStderrDetail(&stderr))
+ }
+ fields := strings.Fields(stdout.String())
+ if len(fields) != 2 || fields[0] != product {
+ return "", fmt.Errorf("unexpected stdout %q", strings.TrimSpace(stdout.String()))
+ }
+ return fields[1], nil
+}
+
+func probeDoctorMCP(launch doctorManagedLaunch) error {
+ ctx, cancel := context.WithTimeout(context.Background(), doctorMCPTimeout)
+ defer cancel()
+ cmd := newDoctorCommand(ctx, launch.command, launch.args...)
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("open stdout: %w", err)
+ }
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ _ = stdout.Close()
+ return fmt.Errorf("open stdin: %w", err)
+ }
+ var stderr doctorBoundedBuffer
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ _ = stdin.Close()
+ _ = stdout.Close()
+ return fmt.Errorf("start: %w", err)
+ }
+ waited := false
+ cleanup := func(force bool) error {
+ _ = stdin.Close()
+ _ = stdout.Close()
+ if force {
+ _ = killDoctorProcess(cmd)
+ }
+ if waited {
+ return nil
+ }
+ waited = true
+ err := cmd.Wait()
+ _ = killDoctorProcess(cmd)
+ return err
+ }
+ defer func() {
+ if !waited {
+ _ = cleanup(true)
+ }
+ }()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "codemap-doctor", Version: buildinfo.Current()}, nil)
+ reader := &doctorBoundedReader{reader: stdout, remaining: doctorProbeOutputLimit}
+ session, err := client.Connect(ctx, &mcp.IOTransport{Reader: reader, Writer: stdin}, nil)
+ if err != nil {
+ _ = cleanup(true)
+ if ctx.Err() == context.DeadlineExceeded {
+ return fmt.Errorf("timed out after %s%s", doctorMCPTimeout, doctorStderrDetail(&stderr))
+ }
+ return fmt.Errorf("%w%s", err, doctorStderrDetail(&stderr))
+ }
+ if err := session.Close(); err != nil {
+ _ = cleanup(true)
+ return fmt.Errorf("close session: %w%s", err, doctorStderrDetail(&stderr))
+ }
+ if err := cleanup(false); err != nil {
+ if ctx.Err() == context.DeadlineExceeded || errors.Is(err, exec.ErrWaitDelay) {
+ return fmt.Errorf("timed out after %s%s", doctorMCPTimeout, doctorStderrDetail(&stderr))
+ }
+ return fmt.Errorf("wait: %w%s", err, doctorStderrDetail(&stderr))
+ }
+ return nil
+}
+
+func newDoctorCommand(ctx context.Context, command string, args ...string) *exec.Cmd {
+ cmd := exec.CommandContext(ctx, command, args...)
+ cmd.WaitDelay = doctorWaitDelay
+ configureDoctorProcess(cmd)
+ return cmd
+}
+
+type doctorBoundedBuffer struct {
+ head []byte
+ tail []byte
+ total int
+}
+
+func (b *doctorBoundedBuffer) Write(p []byte) (int, error) {
+ n := len(p)
+ b.total += n
+ headLimit := doctorProbeOutputLimit / 2
+ if len(b.head) < headLimit {
+ keep := headLimit - len(b.head)
+ if keep > len(p) {
+ keep = len(p)
+ }
+ b.head = append(b.head, p[:keep]...)
+ p = p[keep:]
+ }
+ if len(p) > 0 {
+ tailLimit := doctorProbeOutputLimit - headLimit
+ if len(p) >= tailLimit {
+ b.tail = append(b.tail[:0], p[len(p)-tailLimit:]...)
+ } else {
+ overflow := len(b.tail) + len(p) - tailLimit
+ if overflow > 0 {
+ copy(b.tail, b.tail[overflow:])
+ b.tail = b.tail[:len(b.tail)-overflow]
+ }
+ b.tail = append(b.tail, p...)
+ }
+ }
+ return n, nil
+}
+
+func (b *doctorBoundedBuffer) String() string {
+ if b.total <= len(b.head)+len(b.tail) {
+ return string(append(append([]byte(nil), b.head...), b.tail...))
+ }
+ return string(b.head) + fmt.Sprintf("\n... %d bytes of output truncated ...\n", b.total-len(b.head)-len(b.tail)) + string(b.tail)
+}
+
+func doctorStderrDetail(stderr *doctorBoundedBuffer) string {
+ text := strings.TrimSpace(stderr.String())
+ if text == "" {
+ return ""
+ }
+ return ": stderr: " + text
+}
+
+type doctorBoundedReader struct {
+ reader io.ReadCloser
+ remaining int
+}
+
+func (r *doctorBoundedReader) Read(p []byte) (int, error) {
+ if r.remaining <= 0 {
+ return 0, fmt.Errorf("MCP stdout exceeded %d bytes", doctorProbeOutputLimit)
+ }
+ if len(p) > r.remaining {
+ p = p[:r.remaining]
+ }
+ n, err := r.reader.Read(p)
+ r.remaining -= n
+ return n, err
+}
+
+func (r *doctorBoundedReader) Close() error { return r.reader.Close() }
diff --git a/cmd/doctor_codex_hooks.go b/cmd/doctor_codex_hooks.go
new file mode 100644
index 0000000..d186b2d
--- /dev/null
+++ b/cmd/doctor_codex_hooks.go
@@ -0,0 +1,225 @@
+package cmd
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "runtime"
+ "time"
+
+ "codemap/internal/buildinfo"
+)
+
+type codexHookError struct {
+ Message string `json:"message"`
+ Path string `json:"path"`
+}
+
+type codexHookMetadata struct {
+ Command string `json:"command"`
+ Enabled bool `json:"enabled"`
+ HandlerType string `json:"handlerType"`
+ TrustStatus string `json:"trustStatus"`
+}
+
+type codexHooksListEntry struct {
+ CWD string `json:"cwd"`
+ Errors []codexHookError `json:"errors"`
+ Hooks []codexHookMetadata `json:"hooks"`
+}
+
+var doctorCodexHooksTimeout = 5 * time.Second
+var doctorCodexHooksProbe = probeDoctorCodexHooks
+
+const doctorCodexHookMessageLimit = 256 * 1024
+
+func validateCodexHookTrust(root string) error {
+ entry, err := doctorCodexHooksProbe(root)
+ if err != nil {
+ return fmt.Errorf("codex hooks/list probe failed: %w; %s", err, codexHookTrustGuidance(root))
+ }
+ if len(entry.Errors) > 0 {
+ return fmt.Errorf("codex reported %s (%s); %s", entry.Errors[0].Message, entry.Errors[0].Path, codexHookTrustGuidance(root))
+ }
+
+ found := make([]bool, len(recommendedCodexHooks))
+ for _, hook := range entry.Hooks {
+ if hook.HandlerType != "command" {
+ continue
+ }
+ owned := -1
+ for i, spec := range recommendedCodexHooks {
+ if _, ok := migrateOwnedHookCommand(hook.Command, spec.Command); ok {
+ owned = i
+ break
+ }
+ }
+ if owned < 0 {
+ continue
+ }
+ found[owned] = true
+ if !hook.Enabled {
+ return fmt.Errorf("codemap hook %q is disabled; %s", recommendedCodexHooks[owned].Event, codexHookTrustGuidance(root))
+ }
+ if hook.TrustStatus != "trusted" && hook.TrustStatus != "managed" {
+ return fmt.Errorf("codemap hook %q is %s; %s", recommendedCodexHooks[owned].Event, hook.TrustStatus, codexHookTrustGuidance(root))
+ }
+ }
+ for i, present := range found {
+ if !present {
+ return fmt.Errorf("codemap hook %q is missing from Codex hooks/list; %s", recommendedCodexHooks[i].Event, codexHookTrustGuidance(root))
+ }
+ }
+ return nil
+}
+
+func codexHookTrustGuidance(root string) string {
+ return fmt.Sprintf("open the project in Codex CLI (`codex -C %s`) or Desktop, trust Codemap hooks from `/hooks` in CLI or Settings > Hooks in Desktop, then start a new Codex task/session", quoteHookExecutable(root, runtime.GOOS))
+}
+
+func probeDoctorCodexHooks(root string) (codexHooksListEntry, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), doctorCodexHooksTimeout)
+ defer cancel()
+ cmd := newDoctorCommand(ctx, "codex", "-C", root, "app-server", "--stdio")
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("open app-server stdout: %w", err)
+ }
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ _ = stdout.Close()
+ return codexHooksListEntry{}, fmt.Errorf("open app-server stdin: %w", err)
+ }
+ var stderr doctorBoundedBuffer
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ _ = stdin.Close()
+ _ = stdout.Close()
+ return codexHooksListEntry{}, fmt.Errorf("start app-server: %w", err)
+ }
+ waited := false
+ cleanup := func(force bool) error {
+ _ = stdin.Close()
+ _ = stdout.Close()
+ if force {
+ _ = killDoctorProcess(cmd)
+ }
+ if waited {
+ return nil
+ }
+ waited = true
+ err := cmd.Wait()
+ _ = killDoctorProcess(cmd)
+ return err
+ }
+ defer func() {
+ if !waited {
+ _ = cleanup(true)
+ }
+ }()
+
+ entry, exchangeErr := exchangeDoctorCodexHooks(stdout, stdin, root)
+ if exchangeErr != nil {
+ _ = cleanup(true)
+ if ctx.Err() == context.DeadlineExceeded {
+ return codexHooksListEntry{}, fmt.Errorf("timed out after %s%s", doctorCodexHooksTimeout, doctorStderrDetail(&stderr))
+ }
+ return codexHooksListEntry{}, fmt.Errorf("app-server protocol: %w%s", exchangeErr, doctorStderrDetail(&stderr))
+ }
+ if err := cleanup(false); err != nil && !errors.Is(err, exec.ErrWaitDelay) {
+ if ctx.Err() == context.DeadlineExceeded {
+ return codexHooksListEntry{}, fmt.Errorf("timed out after %s%s", doctorCodexHooksTimeout, doctorStderrDetail(&stderr))
+ }
+ return codexHooksListEntry{}, fmt.Errorf("wait for app-server: %w%s", err, doctorStderrDetail(&stderr))
+ }
+ return entry, nil
+}
+
+func exchangeDoctorCodexHooks(reader io.Reader, writer io.Writer, root string) (codexHooksListEntry, error) {
+ encoder := json.NewEncoder(writer)
+ responses := bufio.NewReaderSize(reader, 32*1024)
+ initialize := map[string]any{"id": 1, "method": "initialize", "params": map[string]any{"clientInfo": map[string]any{"name": "codemap-doctor", "version": buildinfo.Current()}, "capabilities": map[string]any{"experimentalApi": true}}}
+ if err := encoder.Encode(initialize); err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("send initialize: %w", err)
+ }
+ if _, err := readDoctorCodexResponse(responses, 1); err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("initialize: %w", err)
+ }
+ if err := encoder.Encode(map[string]any{"method": "initialized", "params": map[string]any{}}); err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("send initialized: %w", err)
+ }
+ if err := encoder.Encode(map[string]any{"id": 2, "method": "hooks/list", "params": map[string]any{"cwds": []string{root}}}); err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("send hooks/list: %w", err)
+ }
+ resultJSON, err := readDoctorCodexResponse(responses, 2)
+ if err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("hooks/list: %w", err)
+ }
+ var result struct {
+ Data []codexHooksListEntry `json:"data"`
+ }
+ if err := json.Unmarshal(resultJSON, &result); err != nil {
+ return codexHooksListEntry{}, fmt.Errorf("decode response: %w", err)
+ }
+ if len(result.Data) != 1 {
+ return codexHooksListEntry{}, fmt.Errorf("returned %d working directories", len(result.Data))
+ }
+ return result.Data[0], nil
+}
+
+func readDoctorCodexResponse(reader *bufio.Reader, wantID int) (json.RawMessage, error) {
+ for {
+ line, err := readDoctorCodexJSONLine(reader)
+ if err != nil {
+ return nil, err
+ }
+ var response struct {
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(line, &response); err != nil {
+ return nil, fmt.Errorf("invalid JSON: %w", err)
+ }
+ if response.ID != wantID {
+ continue
+ }
+ if response.Error != nil {
+ return nil, errors.New(response.Error.Message)
+ }
+ if len(response.Result) == 0 {
+ return nil, fmt.Errorf("response %d has no result", wantID)
+ }
+ return response.Result, nil
+ }
+}
+
+func readDoctorCodexJSONLine(reader *bufio.Reader) ([]byte, error) {
+ line := make([]byte, 0, 4096)
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(line)+len(fragment) > doctorCodexHookMessageLimit {
+ return nil, fmt.Errorf("app-server JSON message exceeds %d bytes", doctorCodexHookMessageLimit)
+ }
+ line = append(line, fragment...)
+ switch err {
+ case nil:
+ return line, nil
+ case bufio.ErrBufferFull:
+ continue
+ case io.EOF:
+ if len(line) > 0 {
+ return line, nil
+ }
+ return nil, io.EOF
+ default:
+ return nil, err
+ }
+ }
+}
diff --git a/cmd/doctor_process_other.go b/cmd/doctor_process_other.go
new file mode 100644
index 0000000..2b9ca0a
--- /dev/null
+++ b/cmd/doctor_process_other.go
@@ -0,0 +1,16 @@
+//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
+
+package cmd
+
+import "os/exec"
+
+func configureDoctorProcess(cmd *exec.Cmd) {
+ cmd.Cancel = func() error { return killDoctorProcess(cmd) }
+}
+
+func killDoctorProcess(cmd *exec.Cmd) error {
+ if cmd.Process == nil {
+ return nil
+ }
+ return cmd.Process.Kill()
+}
diff --git a/cmd/doctor_process_unix.go b/cmd/doctor_process_unix.go
new file mode 100644
index 0000000..6c7b92a
--- /dev/null
+++ b/cmd/doctor_process_unix.go
@@ -0,0 +1,25 @@
+//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
+
+package cmd
+
+import (
+ "errors"
+ "os/exec"
+ "syscall"
+)
+
+func configureDoctorProcess(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error { return killDoctorProcess(cmd) }
+}
+
+func killDoctorProcess(cmd *exec.Cmd) error {
+ if cmd.Process == nil {
+ return nil
+ }
+ err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ if errors.Is(err, syscall.ESRCH) {
+ return nil
+ }
+ return err
+}
diff --git a/cmd/doctor_process_windows.go b/cmd/doctor_process_windows.go
new file mode 100644
index 0000000..a0a4c39
--- /dev/null
+++ b/cmd/doctor_process_windows.go
@@ -0,0 +1,16 @@
+//go:build windows
+
+package cmd
+
+import "os/exec"
+
+func configureDoctorProcess(cmd *exec.Cmd) {
+ cmd.Cancel = func() error { return killDoctorProcess(cmd) }
+}
+
+func killDoctorProcess(cmd *exec.Cmd) error {
+ if cmd.Process == nil {
+ return nil
+ }
+ return cmd.Process.Kill()
+}
diff --git a/cmd/hooks.go b/cmd/hooks.go
index f835111..1484412 100644
--- a/cmd/hooks.go
+++ b/cmd/hooks.go
@@ -2,6 +2,7 @@ package cmd
import (
"bufio"
+ "crypto/sha256"
"encoding/json"
"errors"
"fmt"
@@ -34,6 +35,9 @@ const (
DefaultHookTimeout = 8 * time.Second
daemonRestartStaleAfter = 2 * time.Hour
diffCaptureSlackBytes = 4 * 1024
+ sessionLeaseTTL = 24 * time.Hour
+ sessionLockStaleAfter = 30 * time.Second
+ sessionLockWait = time.Second
)
var isOwnedDaemonProcess = watch.IsOwnedDaemon
@@ -227,22 +231,62 @@ func waitForDaemonState(root string, timeout time.Duration) *watch.State {
// RunHook executes the named hook with the given project root
func RunHook(hookName, root string) error {
+ var fn func() error
switch hookName {
case "session-start":
- return hookSessionStart(root)
+ fn = func() error { return hookSessionStart(root) }
case "pre-edit":
- return hookPreEdit(root)
+ fn = func() error { return hookPreEdit(root) }
case "post-edit":
- return hookPostEdit(root)
+ fn = func() error { return hookPostEdit(root) }
case "prompt-submit":
- return hookPromptSubmit(root)
+ fn = func() error { return hookPromptSubmit(root) }
case "pre-compact":
- return hookPreCompact(root)
+ fn = func() error { return hookPreCompact(root) }
case "session-stop":
- return hookSessionStop(root)
+ fn = func() error { return hookSessionStop(root) }
default:
return fmt.Errorf("unknown hook: %s\nAvailable: session-start, pre-edit, post-edit, prompt-submit, pre-compact, session-stop", hookName)
}
+ return runHookOutput(hookName, fn)
+}
+
+func runHookOutput(hookName string, fn func() error) error {
+ if os.Getenv("CODEX") != "1" {
+ return fn()
+ }
+ if hookName == "session-stop" {
+ return runHookWithoutStdout(fn)
+ }
+
+ event := ""
+ switch hookName {
+ case "session-start":
+ event = "SessionStart"
+ case "prompt-submit":
+ event = "UserPromptSubmit"
+ case "pre-compact":
+ event = "PreCompact"
+ case "pre-edit":
+ event = "PreToolUse"
+ case "post-edit":
+ event = "PostToolUse"
+ default:
+ return fn()
+ }
+ return emitCodexHookContext(event, fn)
+}
+
+func runHookWithoutStdout(fn func() error) error {
+ devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
+ if err != nil {
+ return err
+ }
+ defer devNull.Close()
+ original := os.Stdout
+ os.Stdout = devNull
+ defer func() { os.Stdout = original }()
+ return fn()
}
// hookSessionStart shows project structure, starts daemon, and shows hub warnings
@@ -266,15 +310,7 @@ func hookSessionStart(root string) error {
// Check for previous session context before starting new daemon
lastSessionEvents := getLastSessionEvents(root)
- // Restart stale daemons so long-lived background processes do not drift.
- if shouldRestartDaemon(root, time.Now()) {
- stopDaemon(root)
- }
-
- // Start the watch daemon in background (if not already running)
- if !watch.IsRunning(root) {
- startDaemon(root)
- }
+ ensureSessionDaemon(root, hookSessionIDFromStdin())
fmt.Println("📍 Project Context:")
fmt.Println()
@@ -648,22 +684,72 @@ func startDaemon(root string) {
// hookPreEdit warns before editing hub files (reads JSON from stdin)
func hookPreEdit(root string) error {
- filePath, err := extractFilePathFromStdin()
- if err != nil || filePath == "" {
+ filePaths, err := extractFilePathsFromStdin()
+ if err != nil || len(filePaths) == 0 {
return nil // silently skip if no file path
}
-
- return checkFileImporters(root, filePath)
+ for _, filePath := range filePaths {
+ if err := checkFileImporters(root, filePath); err != nil {
+ return err
+ }
+ }
+ return nil
}
// hookPostEdit shows impact after editing (reads JSON from stdin)
func hookPostEdit(root string) error {
- filePath, err := extractFilePathFromStdin()
- if err != nil || filePath == "" {
+ filePaths, err := extractFilePathsFromStdin()
+ if err != nil || len(filePaths) == 0 {
return nil
}
- return checkFileImporters(root, filePath)
+ for _, filePath := range filePaths {
+ if err := checkFileImporters(root, filePath); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func emitCodexHookContext(event string, fn func() error) error {
+ if os.Getenv("CODEX") != "1" {
+ return fn()
+ }
+ original := os.Stdout
+ reader, writer, err := os.Pipe()
+ if err != nil {
+ return err
+ }
+ type readResult struct {
+ output []byte
+ err error
+ }
+ readDone := make(chan readResult, 1)
+ go func() {
+ output, readErr := io.ReadAll(reader)
+ readDone <- readResult{output: output, err: readErr}
+ }()
+ os.Stdout = writer
+ callErr := fn()
+ closeErr := writer.Close()
+ os.Stdout = original
+ result := <-readDone
+ _ = reader.Close()
+ if callErr != nil {
+ return callErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ if result.err != nil || len(strings.TrimSpace(string(result.output))) == 0 {
+ return result.err
+ }
+ return json.NewEncoder(original).Encode(map[string]any{
+ "hookSpecificOutput": map[string]any{
+ "hookEventName": event,
+ "additionalContext": string(result.output),
+ },
+ })
}
// hookPromptSubmit analyzes user prompt with code intelligence and shows context
@@ -1113,11 +1199,11 @@ func hookPreCompact(root string) error {
// hookSessionStop summarizes what changed in the session and stops the daemon
func hookSessionStop(root string) error {
+ sessionID := hookSessionIDFromStdin()
// Read state BEFORE stopping daemon (includes timeline)
state := watch.ReadState(root)
- // Stop the watch daemon
- stopDaemon(root)
+ finishSessionDaemon(root, sessionID)
fmt.Println()
fmt.Println("📊 Session Summary")
@@ -1334,6 +1420,145 @@ func gitSymbolicRef(root, ref string) (string, bool) {
return value, true
}
+func hookSessionIDFromStdin() string {
+ info, err := os.Stdin.Stat()
+ if err == nil && info.Mode()&os.ModeCharDevice != 0 {
+ return ""
+ }
+ input, err := io.ReadAll(os.Stdin)
+ if err != nil || len(strings.TrimSpace(string(input))) == 0 {
+ return ""
+ }
+ var payload map[string]any
+ if json.Unmarshal(input, &payload) != nil {
+ return ""
+ }
+ for _, key := range []string{"session_id", "sessionId"} {
+ if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func ensureSessionDaemon(root, sessionID string) {
+ manage := func(_ int) {
+ if shouldRestartDaemon(root, time.Now()) {
+ stopDaemon(root)
+ }
+ if !hookWatchIsRunning(root) {
+ startDaemon(root)
+ }
+ }
+ if sessionID == "" || updateSessionLease(root, sessionID, true, time.Now(), manage) != nil {
+ manage(0)
+ }
+}
+
+func finishSessionDaemon(root, sessionID string) {
+ if sessionID == "" {
+ stopDaemon(root)
+ return
+ }
+ if err := updateSessionLease(root, sessionID, false, time.Now(), func(active int) {
+ if active == 0 {
+ stopDaemon(root)
+ }
+ }); err != nil {
+ stopDaemon(root)
+ }
+}
+
+func updateSessionLease(root, sessionID string, active bool, now time.Time, action func(int)) error {
+ if strings.TrimSpace(sessionID) == "" {
+ if action != nil {
+ action(0)
+ }
+ return nil
+ }
+ codemapDir := filepath.Join(root, ".codemap")
+ if err := os.MkdirAll(codemapDir, 0o755); err != nil {
+ return err
+ }
+ lockPath := filepath.Join(codemapDir, "sessions.lock")
+ if err := acquireSessionLock(lockPath); err != nil {
+ return err
+ }
+ defer os.Remove(lockPath)
+
+ leaseDir := filepath.Join(codemapDir, "sessions")
+ if err := os.MkdirAll(leaseDir, 0o755); err != nil {
+ return err
+ }
+ entries, err := os.ReadDir(leaseDir)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ path := filepath.Join(leaseDir, entry.Name())
+ info, infoErr := entry.Info()
+ if infoErr == nil && now.Sub(info.ModTime()) > sessionLeaseTTL {
+ _ = os.Remove(path)
+ }
+ }
+
+ leaseName := fmt.Sprintf("%x.json", sha256.Sum256([]byte(detectAgentID()+"\x00"+sessionID)))
+ leasePath := filepath.Join(leaseDir, leaseName)
+ if active {
+ payload, err := json.Marshal(map[string]any{
+ "agent": detectAgentID(),
+ "updated_at": now.UTC().Format(time.RFC3339Nano),
+ })
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(leasePath, append(payload, '\n'), 0o600); err != nil {
+ return err
+ }
+ } else if err := os.Remove(leasePath); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ entries, err = os.ReadDir(leaseDir)
+ if err != nil {
+ return err
+ }
+ count := 0
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ count++
+ }
+ }
+ if action != nil {
+ action(count)
+ }
+ return nil
+}
+
+func acquireSessionLock(lockPath string) error {
+ deadline := time.Now().Add(sessionLockWait)
+ for {
+ err := os.Mkdir(lockPath, 0o700)
+ if err == nil {
+ return nil
+ }
+ if !os.IsExist(err) {
+ return err
+ }
+ if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > sessionLockStaleAfter {
+ _ = os.RemoveAll(lockPath)
+ continue
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("timed out waiting for session lock %s", lockPath)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
// stopDaemon stops the watch daemon
func stopDaemon(root string) {
if !hookWatchIsRunning(root) {
@@ -1347,30 +1572,71 @@ func stopDaemon(root string) {
cmd.Run()
}
-// extractFilePathFromStdin reads JSON from stdin and extracts file_path
-func extractFilePathFromStdin() (string, error) {
+// extractFilePathsFromStdin reads Claude or Codex hook JSON from stdin and
+// returns every edited file once, preserving patch order.
+func extractFilePathsFromStdin() ([]string, error) {
input, err := io.ReadAll(os.Stdin)
if err != nil {
- return "", err
+ return nil, err
}
var data map[string]interface{}
if err := json.Unmarshal(input, &data); err != nil {
// Try regex fallback for non-JSON or partial JSON
re := regexp.MustCompile(`"file_path"\s*:\s*"([^"]+)"`)
- matches := re.FindSubmatch(input)
- if len(matches) >= 2 {
- return string(matches[1]), nil
+ matches := re.FindAllSubmatch(input, -1)
+ paths := make([]string, 0, len(matches))
+ for _, match := range matches {
+ if len(match) >= 2 {
+ paths = appendUniquePath(paths, string(match[1]))
+ }
+ }
+ if len(paths) > 0 {
+ return paths, nil
+ }
+ return nil, err
+ }
+
+ if filePath, ok := data["file_path"].(string); ok {
+ return []string{filePath}, nil
+ }
+ // Codex applies file edits through apply_patch. Its hook payload stores the
+ // patch text under tool_input.command rather than a Claude-style file_path.
+ if toolInput, ok := data["tool_input"].(map[string]interface{}); ok {
+ if command, ok := toolInput["command"].(string); ok {
+ matches := regexp.MustCompile(`(?m)^\*\*\* (?:Update|Add|Delete) File: (.+)$`).FindAllStringSubmatch(command, -1)
+ paths := make([]string, 0, len(matches))
+ for _, match := range matches {
+ if len(match) == 2 {
+ paths = appendUniquePath(paths, strings.TrimSpace(match[1]))
+ }
+ }
+ return paths, nil
}
- return "", err
}
- filePath, ok := data["file_path"].(string)
- if !ok {
- return "", nil
+ return nil, nil
+}
+
+func extractFilePathFromStdin() (string, error) {
+ paths, err := extractFilePathsFromStdin()
+ if err != nil || len(paths) == 0 {
+ return "", err
}
+ return paths[0], nil
+}
- return filePath, nil
+func appendUniquePath(paths []string, path string) []string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return paths
+ }
+ for _, existing := range paths {
+ if existing == path {
+ return paths
+ }
+ }
+ return append(paths, path)
}
// checkFileImporters checks if a file is a hub and shows its importers
diff --git a/cmd/integration_command.go b/cmd/integration_command.go
new file mode 100644
index 0000000..a675ff1
--- /dev/null
+++ b/cmd/integration_command.go
@@ -0,0 +1,85 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+var (
+ integrationOSExecutable = os.Executable
+ integrationArgs0 = func() string { return os.Args[0] }
+ integrationLookPath = exec.LookPath
+)
+
+func resolveIntegrationExecutable() (string, error) {
+ running, err := integrationOSExecutable()
+ if err != nil {
+ return "", fmt.Errorf("resolve running codemap executable: %w", err)
+ }
+
+ invoked := integrationArgs0()
+ if invoked != "" && !filepath.IsAbs(invoked) {
+ if resolved, lookErr := integrationLookPath(invoked); lookErr == nil {
+ invoked = resolved
+ } else {
+ invoked = ""
+ }
+ }
+ return resolveIntegrationExecutableFrom(running, invoked, runtime.GOOS)
+}
+
+func resolveIntegrationExecutableFrom(running, invoked, goos string) (string, error) {
+ running, err := filepath.Abs(running)
+ if err != nil {
+ return "", fmt.Errorf("make running codemap executable absolute: %w", err)
+ }
+ runningInfo, err := validateIntegrationExecutable(running, goos)
+ if err != nil {
+ return "", err
+ }
+
+ if invoked == "" {
+ return running, nil
+ }
+ invoked, err = filepath.Abs(invoked)
+ if err != nil {
+ return running, nil
+ }
+ invokedInfo, err := validateIntegrationExecutable(invoked, goos)
+ if err == nil && os.SameFile(runningInfo, invokedInfo) {
+ return invoked, nil
+ }
+ return running, nil
+}
+
+func validateIntegrationExecutable(path, goos string) (os.FileInfo, error) {
+ if !filepath.IsAbs(path) {
+ return nil, fmt.Errorf("codemap executable path is not absolute: %q", path)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("inspect codemap executable %q: %w", path, err)
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("codemap executable %q is not a regular file", path)
+ }
+ if goos != "windows" && info.Mode().Perm()&0o111 == 0 {
+ return nil, fmt.Errorf("codemap executable %q is not executable", path)
+ }
+ return info, nil
+}
+
+func quoteHookExecutable(path, goos string) string {
+ if goos == "windows" {
+ return `"` + strings.ReplaceAll(path, `"`, `\"`) + `"`
+ }
+ return `'` + strings.ReplaceAll(path, `'`, `'"'"'`) + `'`
+}
+
+func managedMCPArgs(version, integration string) []string {
+ return []string{"mcp", "--configured-version", version, "--integration", integration}
+}
diff --git a/cmd/mcp.go b/cmd/mcp.go
new file mode 100644
index 0000000..49f7005
--- /dev/null
+++ b/cmd/mcp.go
@@ -0,0 +1,23 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ codemapmcp "codemap/mcp"
+)
+
+// RunMCP parses MCP runtime options and runs the shared stdio server.
+func RunMCP(args []string) int {
+ options, err := codemapmcp.ParseRuntimeOptions(args)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Invalid MCP options: %v\n", err)
+ return 2
+ }
+ if err := codemapmcp.Run(context.Background(), options); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ return 1
+ }
+ return 0
+}
diff --git a/cmd/plugin.go b/cmd/plugin.go
index 71f7fbd..4fe8c19 100644
--- a/cmd/plugin.go
+++ b/cmd/plugin.go
@@ -1,16 +1,104 @@
package cmd
import (
+ "encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
+ "os/exec"
"path/filepath"
+ "strings"
+ "codemap/internal/buildinfo"
pluginbundle "codemap/plugins"
+ "github.com/pelletier/go-toml/v2"
)
+type pluginActivationStatus string
+
+type codexPluginList struct {
+ Installed []struct {
+ PluginID string `json:"pluginId"`
+ Version string `json:"version"`
+ Installed bool `json:"installed"`
+ Enabled bool `json:"enabled"`
+ Source struct {
+ Path string `json:"path"`
+ } `json:"source"`
+ } `json:"installed"`
+}
+
+const (
+ pluginActivationInstalled pluginActivationStatus = "installed"
+ pluginActivationUpdated pluginActivationStatus = "updated"
+ pluginActivationUnchanged pluginActivationStatus = "unchanged"
+)
+
+var runCodexPluginCommand = func(home string, args ...string) ([]byte, error) {
+ cmd := exec.Command("codex", args...)
+ if home != "" {
+ env := make([]string, 0, len(os.Environ())+3)
+ for _, entry := range os.Environ() {
+ name, _, _ := strings.Cut(entry, "=")
+ if strings.EqualFold(name, "HOME") || strings.EqualFold(name, "USERPROFILE") || strings.EqualFold(name, "CODEX_HOME") {
+ continue
+ }
+ env = append(env, entry)
+ }
+ cmd.Env = append(env, "HOME="+home, "USERPROFILE="+home, "CODEX_HOME="+filepath.Join(home, ".codex"))
+ }
+ out, err := cmd.Output()
+ return out, codexCommandError(err)
+}
+
+func codexCommandError(err error) error {
+ if err == nil {
+ return nil
+ }
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ if detail := strings.TrimSpace(string(exitErr.Stderr)); detail != "" {
+ return fmt.Errorf("%w: %s", err, detail)
+ }
+ }
+ return err
+}
+
+var activateCodexPlugin = func(home, reference, desiredVersion string, sourceChanged bool) (pluginActivationStatus, error) {
+ out, err := runCodexPluginCommand(home, "plugin", "list", "--json")
+ if err != nil {
+ return "", fmt.Errorf("inspect installed Codex plugins: %w", err)
+ }
+ var list codexPluginList
+ if err := json.Unmarshal(out, &list); err != nil {
+ return "", fmt.Errorf("parse installed Codex plugins: %w", err)
+ }
+
+ alreadyInstalled := false
+ alreadyActive := false
+ installedVersion := ""
+ for _, plugin := range list.Installed {
+ if plugin.PluginID == reference && plugin.Installed {
+ alreadyInstalled = true
+ alreadyActive = plugin.Enabled
+ installedVersion = plugin.Version
+ break
+ }
+ }
+ if alreadyActive && installedVersion == desiredVersion && !sourceChanged {
+ return pluginActivationUnchanged, nil
+ }
+ if _, err := runCodexPluginCommand(home, "plugin", "add", reference, "--json"); err != nil {
+ return "", fmt.Errorf("install Codex plugin: %w", err)
+ }
+ if alreadyInstalled {
+ return pluginActivationUpdated, nil
+ }
+ return pluginActivationInstalled, nil
+}
+
// RunPlugin handles the "codemap plugin" subcommand.
func RunPlugin(args []string) {
subCmd := ""
@@ -25,7 +113,7 @@ func RunPlugin(args []string) {
fmt.Println("Usage: codemap plugin install")
fmt.Println()
fmt.Println("Commands:")
- fmt.Println(" install Install the Codemap plugin into ~/.agents/plugins and ~/plugins")
+ fmt.Println(" install Install or update and activate the Codemap plugin")
}
}
@@ -35,37 +123,74 @@ func runPluginInstall(args []string) {
homeDir := fs.String("home", "", "Override the home directory used for plugin installation")
pluginPath := fs.String("plugin-path", "", "Override the installed plugin path")
marketplacePath := fs.String("marketplace-path", "", "Override the marketplace manifest path")
+ noActivate := fs.Bool("no-activate", false, "Install the plugin files without activating them in Codex")
+ deprecatedActivate := fs.Bool("activate", false, "Deprecated: activation is now the default")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
- fmt.Println("Usage: codemap plugin install [--home
] [--plugin-path ] [--marketplace-path ]")
+ fmt.Println("Usage: codemap plugin install [--no-activate] [--home ] [--plugin-path ] [--marketplace-path ]")
+ fmt.Println("Activation is the default; use --no-activate to skip it. The legacy --activate is accepted but deprecated.")
return
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- fmt.Fprintln(os.Stderr, "Usage: codemap plugin install [--home ] [--plugin-path ] [--marketplace-path ]")
+ fmt.Fprintln(os.Stderr, "Usage: codemap plugin install [--no-activate] [--home ] [--plugin-path ] [--marketplace-path ]")
os.Exit(2)
}
if fs.NArg() != 0 {
- fmt.Fprintln(os.Stderr, "Usage: codemap plugin install [--home ] [--plugin-path ] [--marketplace-path ]")
+ fmt.Fprintln(os.Stderr, "Usage: codemap plugin install [--no-activate] [--home ] [--plugin-path ] [--marketplace-path ]")
os.Exit(1)
}
+ if *deprecatedActivate {
+ fmt.Fprintln(os.Stderr, "Warning: --activate is deprecated; activation is now the default. Omit --activate.")
+ }
+ executable, err := resolveIntegrationExecutable()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Plugin install failed: %v\n", err)
+ os.Exit(1)
+ }
result, err := pluginbundle.InstallCodemapPlugin(pluginbundle.InstallOptions{
HomeDir: *homeDir,
PluginPath: *pluginPath,
MarketplacePath: *marketplacePath,
+ ExecutablePath: executable,
+ BinaryVersion: buildinfo.Current(),
})
if err != nil {
fmt.Fprintf(os.Stderr, "Plugin install failed: %v\n", err)
os.Exit(1)
}
+ hookReviewRoot := ""
+ customMarketplaceStaging := *marketplacePath != ""
+ if *homeDir == "" {
+ migrated, err := migrateExistingCodexProjectIntegration(executable)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Plugin files installed, but project integration migration failed: %v\n", err)
+ os.Exit(1)
+ }
+ if migrated > 0 {
+ fmt.Printf("Project integration files migrated: %d\n", migrated)
+ }
+ if cwd, err := os.Getwd(); err == nil {
+ if root := existingGitRepositoryRoot(cwd); root != "" {
+ if owned, _ := hasOwnedCodexHooks(filepath.Join(root, ".codex", "hooks.json"), executable); owned && !*noActivate && !customMarketplaceStaging {
+ if err := validateCodexHookTrust(root); err != nil {
+ hookReviewRoot = root
+ }
+ }
+ }
+ }
+ }
absPluginPath, _ := filepath.Abs(result.PluginPath)
absMarketplacePath, _ := filepath.Abs(result.MarketplacePath)
- fmt.Println("codemap plugin install")
fmt.Printf("Plugin: %s\n", absPluginPath)
fmt.Printf("Marketplace: %s\n", absMarketplacePath)
+ fmt.Printf("Marketplace name: %s\n", result.MarketplaceName)
fmt.Printf("Files written: %d\n", result.FilesWritten)
+ if result.FilesRemoved > 0 {
+ fmt.Printf("Files removed: %d\n", result.FilesRemoved)
+ }
if result.FilesUnchanged > 0 {
fmt.Printf("Files unchanged: %d\n", result.FilesUnchanged)
}
@@ -77,9 +202,189 @@ func runPluginInstall(args []string) {
default:
fmt.Println("Marketplace entry: already configured")
}
+
+ sourceChanged := result.FilesWritten > 0 || result.FilesRemoved > 0 || result.CreatedMarketplace || result.UpdatedMarketplace
+ pendingPath := filepath.Join(filepath.Dir(result.PluginPath), ".codemap-activation-pending")
+ activationPending := sourceChanged
+ if sourceChanged {
+ if err := os.WriteFile(pendingPath, []byte("pending\n"), 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "Plugin files installed, but activation state could not be recorded: %v\n", err)
+ os.Exit(1)
+ }
+ } else if _, err := os.Stat(pendingPath); err == nil {
+ activationPending = true
+ } else if !os.IsNotExist(err) {
+ fmt.Fprintf(os.Stderr, "Plugin files installed, but activation state could not be read: %v\n", err)
+ os.Exit(1)
+ }
+ if !*noActivate && !customMarketplaceStaging {
+ desiredVersion, err := codemapPluginVersion(result.PluginPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Plugin files installed, but the plugin version could not be read: %v\n", err)
+ os.Exit(1)
+ }
+ status, err := activateCodexPlugin(*homeDir, pluginbundle.CodemapPluginName+"@"+result.MarketplaceName, desiredVersion, activationPending)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Plugin files installed, but Codex activation failed: %v\n", err)
+ os.Exit(1)
+ }
+ if err := os.Remove(pendingPath); err != nil && !os.IsNotExist(err) {
+ fmt.Fprintf(os.Stderr, "Warning: Codemap was activated, but activation state could not be cleared: %v\n", err)
+ }
+ fmt.Println()
+ switch status {
+ case pluginActivationInstalled:
+ fmt.Println("Codemap installed and activated in Codex.")
+ case pluginActivationUpdated:
+ fmt.Println("Codemap plugin updated and activated in Codex.")
+ case pluginActivationUnchanged:
+ if hookReviewRoot == "" {
+ fmt.Println("Codemap plugin is already current and active. No restart needed.")
+ } else {
+ fmt.Println("Codemap plugin is already current and active.")
+ }
+ }
+ if hookReviewRoot != "" {
+ fmt.Println("Next:")
+ printCodexHookReviewInstructions(hookReviewRoot)
+ } else if status != pluginActivationUnchanged {
+ fmt.Println("Start a new Codex task/session.")
+ }
+ return
+ }
+
fmt.Println()
- fmt.Println("Next:")
- fmt.Println(" 1. Restart Codex or reload plugins.")
- fmt.Println(" 2. Verify the Codemap plugin appears in your plugin list.")
- fmt.Println(" 3. If Codemap was installed before this release, update it so `codemap mcp` is available.")
+ if customMarketplaceStaging && !*noActivate {
+ fmt.Println("Plugin files installed; Codex activation skipped for custom --marketplace-path.")
+ fmt.Println("Omit --marketplace-path to install and activate in Codex automatically.")
+ return
+ }
+ if activationPending {
+ fmt.Println("Plugin files installed; Codex activation skipped (--no-activate).")
+ fmt.Printf("Activate later with: codex plugin add %s@%s\n", pluginbundle.CodemapPluginName, result.MarketplaceName)
+ } else {
+ fmt.Println("Plugin files are already current; Codex activation skipped (--no-activate).")
+ }
+}
+
+func migrateExistingCodexProjectIntegration(executable string) (int, error) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return 0, err
+ }
+ root := existingGitRepositoryRoot(cwd)
+ if root == "" {
+ return 0, nil
+ }
+ migrated := 0
+ configPath := filepath.Join(root, ".codex", "config.toml")
+ if owned, err := hasOwnedCodexMCP(configPath); err != nil {
+ return migrated, err
+ } else if owned {
+ changed, err := ensureCodexMCPWithExecutable(configPath, executable)
+ if err != nil {
+ return migrated, err
+ }
+ if changed {
+ migrated++
+ }
+ }
+ hooksPath := filepath.Join(root, ".codex", "hooks.json")
+ if owned, err := hasOwnedCodexHooks(hooksPath, executable); err != nil {
+ return migrated, err
+ } else if owned {
+ result, err := ensureCodexHooksWithExecutable(hooksPath, false, executable)
+ if err != nil {
+ return migrated, err
+ }
+ if result.WroteFile {
+ migrated++
+ }
+ }
+ return migrated, nil
+}
+
+func existingGitRepositoryRoot(start string) string {
+ for current := start; ; current = filepath.Dir(current) {
+ if _, err := os.Stat(filepath.Join(current, ".git")); err == nil {
+ return current
+ }
+ parent := filepath.Dir(current)
+ if parent == current {
+ return ""
+ }
+ }
+}
+
+func hasOwnedCodexMCP(path string) (bool, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ payload := map[string]any{}
+ if err := toml.Unmarshal(data, &payload); err != nil {
+ return false, nil
+ }
+ servers, _ := payload["mcp_servers"].(map[string]any)
+ return isOwnedCodemapMCPServer(servers["codemap"], "codex-setup"), nil
+}
+
+func hasOwnedCodexHooks(path, executable string) (bool, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ root := map[string]any{}
+ if err := json.Unmarshal(data, &root); err != nil {
+ return false, nil
+ }
+ hooksByEvent, _ := root["hooks"].(map[string]any)
+ specs := generatedCodexHooks(executable)
+ aliases := codexHookCommandAliases(specs)
+ for event := range hooksByEvent {
+ entries, err := rawHookEntries(hooksByEvent, event)
+ if err != nil {
+ return false, nil
+ }
+ for _, rawEntry := range entries {
+ entry, _ := rawEntry.(map[string]any)
+ hooks, _ := entry["hooks"].([]any)
+ for _, rawHook := range hooks {
+ hook, _ := rawHook.(map[string]any)
+ command, _ := hook["command"].(string)
+ if _, ok := aliases[strings.TrimSpace(command)]; ok {
+ return true, nil
+ }
+ for _, spec := range specs {
+ if _, ok := migrateOwnedHookCommand(command, spec.Command); ok {
+ return true, nil
+ }
+ }
+ }
+ }
+ }
+ return false, nil
+}
+
+func codemapPluginVersion(pluginPath string) (string, error) {
+ data, err := os.ReadFile(filepath.Join(pluginPath, ".codex-plugin", "plugin.json"))
+ if err != nil {
+ return "", err
+ }
+ var manifest struct {
+ Version string `json:"version"`
+ }
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(manifest.Version) == "" {
+ return "", errors.New("plugin manifest has no version")
+ }
+ return manifest.Version, nil
}
diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go
index eade4fd..f2ee9c8 100644
--- a/cmd/plugin_test.go
+++ b/cmd/plugin_test.go
@@ -11,11 +11,10 @@ func TestRunPluginInstall(t *testing.T) {
home := t.TempDir()
out := captureOutput(func() {
- RunPlugin([]string{"install", "--home", home})
+ RunPlugin([]string{"install", "--home", home, "--no-activate"})
})
checks := []string{
- "codemap plugin install",
"Plugin:",
"Marketplace:",
"Marketplace entry: created",
@@ -26,7 +25,7 @@ func TestRunPluginInstall(t *testing.T) {
}
}
- pluginJSON := filepath.Join(home, "plugins", "codemap", ".codex-plugin", "plugin.json")
+ pluginJSON := filepath.Join(home, ".codex", "plugins", "codemap", ".codex-plugin", "plugin.json")
if _, err := os.Stat(pluginJSON); err != nil {
t.Fatalf("expected installed plugin manifest to exist: %v", err)
}
diff --git a/cmd/setup.go b/cmd/setup.go
index f82686c..2c06373 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -8,9 +8,12 @@ import (
"io"
"os"
"path/filepath"
+ "runtime"
"strings"
"codemap/config"
+ "codemap/internal/buildinfo"
+ "github.com/pelletier/go-toml/v2"
)
type claudeHookSpec struct {
@@ -39,13 +42,45 @@ type ensureHooksResult struct {
TargetIsGlobal bool
}
-var recommendedClaudeHooks = []claudeHookSpec{
- {Event: "SessionStart", Command: "codemap hook session-start"},
- {Event: "PreToolUse", Matcher: "Edit|Write", Command: "codemap hook pre-edit"},
- {Event: "PostToolUse", Matcher: "Edit|Write", Command: "codemap hook post-edit"},
- {Event: "UserPromptSubmit", Command: "codemap hook prompt-submit"},
- {Event: "PreCompact", Command: "codemap hook pre-compact"},
- {Event: "SessionEnd", Command: "codemap hook session-stop"},
+type setupAgent string
+
+const (
+ setupAgentBoth setupAgent = "both"
+ setupAgentClaude setupAgent = "claude"
+ setupAgentCodex setupAgent = "codex"
+)
+
+var recommendedClaudeHooks = generatedClaudeHooks(defaultIntegrationExecutable())
+
+var recommendedCodexHooks = generatedCodexHooks(defaultIntegrationExecutable())
+
+func defaultIntegrationExecutable() string {
+ path, _ := resolveIntegrationExecutable()
+ return path
+}
+
+func generatedClaudeHooks(executable string) []claudeHookSpec {
+ command := quoteHookExecutable(executable, runtime.GOOS)
+ return []claudeHookSpec{
+ {Event: "SessionStart", Command: command + " hook session-start --integration=claude-setup"},
+ {Event: "PreToolUse", Matcher: "Edit|Write", Command: command + " hook pre-edit --integration=claude-setup"},
+ {Event: "PostToolUse", Matcher: "Edit|Write", Command: command + " hook post-edit --integration=claude-setup"},
+ {Event: "UserPromptSubmit", Command: command + " hook prompt-submit --integration=claude-setup"},
+ {Event: "PreCompact", Command: command + " hook pre-compact --integration=claude-setup"},
+ {Event: "SessionEnd", Command: command + " hook session-stop --integration=claude-setup"},
+ }
+}
+
+func generatedCodexHooks(executable string) []claudeHookSpec {
+ command := quoteHookExecutable(executable, runtime.GOOS)
+ return []claudeHookSpec{
+ {Event: "SessionStart", Command: command + " hook session-start --agent=codex --integration=codex-setup"},
+ {Event: "PreToolUse", Matcher: "apply_patch|Edit|Write", Command: command + " hook pre-edit --agent=codex --integration=codex-setup"},
+ {Event: "PostToolUse", Matcher: "apply_patch|Edit|Write", Command: command + " hook post-edit --agent=codex --integration=codex-setup"},
+ {Event: "UserPromptSubmit", Command: command + " hook prompt-submit --agent=codex --integration=codex-setup"},
+ {Event: "PreCompact", Command: command + " hook pre-compact --agent=codex --integration=codex-setup"},
+ {Event: "Stop", Command: command + " hook session-stop --agent=codex --integration=codex-setup"},
+ }
}
// RunSetup configures codemap for the recommended hooks-first workflow.
@@ -55,24 +90,34 @@ var recommendedClaudeHooks = []claudeHookSpec{
// - /.claude/settings.local.json codemap hook entries
//
// Use --global to target ~/.claude/settings.json for hooks.
-func RunSetup(args []string, defaultRoot string) {
+func RunSetup(args []string, defaultRoot string) int {
fs := flag.NewFlagSet("setup", flag.ContinueOnError)
fs.SetOutput(io.Discard)
- useGlobalHooks := fs.Bool("global", false, "Install hooks into ~/.claude/settings.json instead of project-local .claude/settings.local.json")
+ useGlobalHooks := fs.Bool("global", false, "Install hooks into global agent settings instead of project-local settings")
+ agent := fs.String("agent", "", "Install hooks for only claude or codex (default: both)")
skipConfig := fs.Bool("no-config", false, "Skip creating .codemap/config.json")
- skipHooks := fs.Bool("no-hooks", false, "Skip writing Claude hook settings")
+ skipHooks := fs.Bool("no-hooks", false, "Skip writing agent hook settings")
+ skipMCP := fs.Bool("no-mcp", false, "Skip writing agent MCP settings")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
- fmt.Println("Usage: codemap setup [--global] [--no-config] [--no-hooks] [path]")
- return
+ fmt.Println("Usage: codemap setup [--global] [--agent claude|codex] [--no-config] [--no-hooks] [--no-mcp] [path]")
+ return 0
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--no-config] [--no-hooks] [path]")
- os.Exit(2)
+ fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--agent claude|codex] [--no-config] [--no-hooks] [--no-mcp] [path]")
+ return 2
}
if fs.NArg() > 1 {
- fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--no-config] [--no-hooks] [path]")
- os.Exit(1)
+ fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--agent claude|codex] [--no-config] [--no-hooks] [--no-mcp] [path]")
+ return 2
+ }
+ selectedAgent := setupAgentBoth
+ if value := strings.ToLower(strings.TrimSpace(*agent)); value != "" {
+ selectedAgent = setupAgent(value)
+ }
+ if selectedAgent != setupAgentClaude && selectedAgent != setupAgentCodex && strings.TrimSpace(*agent) != "" {
+ fmt.Fprintln(os.Stderr, "Error: --agent must be claude or codex")
+ return 2
}
root := defaultRoot
@@ -82,7 +127,12 @@ func RunSetup(args []string, defaultRoot string) {
absRoot, err := filepath.Abs(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
- os.Exit(1)
+ return 1
+ }
+ executable, err := resolveIntegrationExecutable()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error resolving codemap executable: %v\n", err)
+ return 1
}
if !*skipConfig {
@@ -91,7 +141,6 @@ func RunSetup(args []string, defaultRoot string) {
}
}
- fmt.Println("codemap setup")
fmt.Printf("Project: %s\n", absRoot)
fmt.Println()
@@ -104,7 +153,7 @@ func RunSetup(args []string, defaultRoot string) {
fmt.Printf("Config: already exists (%s)\n", config.ConfigPath(absRoot))
case err != nil:
fmt.Fprintf(os.Stderr, "Config: failed (%v)\n", err)
- os.Exit(1)
+ return 1
default:
if len(cfgResult.TopExts) == 0 {
fmt.Printf("Config: created %s (no code extensions detected)\n", cfgResult.Path)
@@ -116,32 +165,103 @@ func RunSetup(args []string, defaultRoot string) {
if *skipHooks {
fmt.Println("Hooks: skipped (--no-hooks)")
- } else {
- hookPath, err := claudeSettingsPath(absRoot, *useGlobalHooks)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Hooks: failed to resolve settings path (%v)\n", err)
- os.Exit(1)
+ }
+ if *skipMCP {
+ fmt.Println("MCP: skipped (--no-mcp)")
+ }
+ failed := false
+ if selectedAgent == setupAgentBoth || selectedAgent == setupAgentClaude {
+ if !*skipHooks && configureHooks("Claude", claudeSettingsPath, func(path string, global bool) (ensureHooksResult, error) {
+ return ensureClaudeHooksWithExecutable(path, global, executable)
+ }, absRoot, *useGlobalHooks) != nil {
+ failed = true
}
- hookResult, err := ensureClaudeHooks(hookPath, *useGlobalHooks)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Hooks: failed (%v)\n", err)
- os.Exit(1)
+ if !*skipMCP && configureMCP("Claude", claudeMCPPath, func(path string) (bool, error) {
+ return ensureClaudeMCPWithExecutable(path, executable)
+ }, absRoot, *useGlobalHooks) != nil {
+ failed = true
}
- switch {
- case hookResult.AddedHooks == 0:
- fmt.Printf("Hooks: already configured (%s)\n", hookResult.SettingsPath)
- case hookResult.CreatedFile:
- fmt.Printf("Hooks: created %s (+%d codemap hooks)\n", hookResult.SettingsPath, hookResult.AddedHooks)
- default:
- fmt.Printf("Hooks: updated %s (+%d codemap hooks)\n", hookResult.SettingsPath, hookResult.AddedHooks)
+ }
+ if selectedAgent == setupAgentBoth || selectedAgent == setupAgentCodex {
+ if !*skipHooks && configureHooks("Codex", codexHooksPath, func(path string, global bool) (ensureHooksResult, error) {
+ return ensureCodexHooksWithExecutable(path, global, executable)
+ }, absRoot, *useGlobalHooks) != nil {
+ failed = true
}
+ if !*skipMCP && configureMCP("Codex", codexConfigPath, func(path string) (bool, error) {
+ return ensureCodexMCPWithExecutable(path, executable)
+ }, absRoot, *useGlobalHooks) != nil {
+ failed = true
+ }
+ }
+ if failed {
+ fmt.Fprintln(os.Stderr, "Setup incomplete: one or more agent integrations failed.")
+ return 1
}
fmt.Println()
fmt.Println("Next:")
- fmt.Println(" 1. Restart Claude Code (or open a new session).")
- fmt.Println(" 2. Verify hook output appears at session start.")
- fmt.Println(" 3. Tune .codemap/config.json if you want narrower context.")
+ codexHooksRelevant := !*skipHooks && (selectedAgent == setupAgentBoth || selectedAgent == setupAgentCodex)
+ if codexHooksRelevant && validateCodexHookTrust(absRoot) != nil {
+ printCodexHookReviewInstructions(absRoot)
+ fmt.Println(" 4. Verify hook output appears at session start.")
+ fmt.Println(" 5. Tune .codemap/config.json if you want narrower context.")
+ } else if codexHooksRelevant {
+ fmt.Println(" 1. Start a new Codex task/session.")
+ fmt.Println(" 2. Verify hook output appears at session start.")
+ fmt.Println(" 3. Tune .codemap/config.json if you want narrower context.")
+ } else {
+ fmt.Println(" 1. Restart the configured coding agent (or open a new session).")
+ fmt.Println(" 2. Verify hook output appears at session start.")
+ fmt.Println(" 3. Tune .codemap/config.json if you want narrower context.")
+ }
+ return 0
+}
+
+func printCodexHookReviewInstructions(root string) {
+ fmt.Printf(" 1. Open the project in Codex CLI (`codex -C %s`) or Desktop.\n", quoteHookExecutable(root, runtime.GOOS))
+ fmt.Println(" 2. Trust Codemap hooks from `/hooks` in CLI or Settings > Hooks in Desktop.")
+ fmt.Println(" 3. Start a new Codex task/session.")
+}
+
+func configureHooks(label string, pathFor func(string, bool) (string, error), ensure func(string, bool) (ensureHooksResult, error), root string, global bool) error {
+ path, err := pathFor(root, global)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s hooks: failed to resolve settings path (%v)\n", label, err)
+ return err
+ }
+ result, err := ensure(path, global)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s hooks: failed (%v)\n", label, err)
+ return err
+ }
+ if result.AddedHooks == 0 {
+ fmt.Printf("%s hooks: already configured (%s)\n", label, result.SettingsPath)
+ } else if result.CreatedFile {
+ fmt.Printf("%s hooks: created %s (+%d codemap hooks)\n", label, result.SettingsPath, result.AddedHooks)
+ } else {
+ fmt.Printf("%s hooks: updated %s (+%d codemap hooks)\n", label, result.SettingsPath, result.AddedHooks)
+ }
+ return nil
+}
+
+func configureMCP(label string, pathFor func(string, bool) (string, error), ensure func(string) (bool, error), root string, global bool) error {
+ path, err := pathFor(root, global)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s MCP: failed to resolve configuration path (%v)\n", label, err)
+ return err
+ }
+ added, err := ensure(path)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s MCP: failed (%v)\n", label, err)
+ return err
+ }
+ if added {
+ fmt.Printf("%s MCP: configured (%s)\n", label, path)
+ } else {
+ fmt.Printf("%s MCP: already configured (%s)\n", label, path)
+ }
+ return nil
}
func claudeSettingsPath(projectRoot string, global bool) (string, error) {
@@ -156,10 +276,197 @@ func claudeSettingsPath(projectRoot string, global bool) (string, error) {
return filepath.Join(homeDir, ".claude", "settings.json"), nil
}
+func claudeMCPPath(projectRoot string, global bool) (string, error) {
+ if !global {
+ return filepath.Join(projectRoot, ".mcp.json"), nil
+ }
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(homeDir, ".claude.json"), nil
+}
+
+func codexHooksPath(projectRoot string, global bool) (string, error) {
+ if !global {
+ return filepath.Join(projectRoot, ".codex", "hooks.json"), nil
+ }
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(homeDir, ".codex", "hooks.json"), nil
+}
+
+func codexConfigPath(projectRoot string, global bool) (string, error) {
+ if !global {
+ return filepath.Join(projectRoot, ".codex", "config.toml"), nil
+ }
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(homeDir, ".codex", "config.toml"), nil
+}
+
+func ensureClaudeMCPWithExecutable(path, executable string) (bool, error) {
+ payload := map[string]any{}
+ data, err := os.ReadFile(path)
+ switch {
+ case err == nil:
+ if len(strings.TrimSpace(string(data))) > 0 {
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return false, fmt.Errorf("parse %s: %w", path, err)
+ }
+ if payload == nil {
+ return false, fmt.Errorf("parse %s: root must be an object", path)
+ }
+ }
+ case os.IsNotExist(err):
+ default:
+ return false, fmt.Errorf("read %s: %w", path, err)
+ }
+ var servers map[string]any
+ if raw, exists := payload["mcpServers"]; exists {
+ var ok bool
+ servers, ok = raw.(map[string]any)
+ if !ok {
+ return false, fmt.Errorf("%s field 'mcpServers' must be an object", path)
+ }
+ } else {
+ servers = map[string]any{}
+ payload["mcpServers"] = servers
+ }
+ if server, exists := servers["codemap"]; exists {
+ if !isOwnedCodemapMCPServer(server, "claude-setup") {
+ return false, fmt.Errorf("%s already defines a conflicting codemap MCP server", path)
+ }
+ serverMap := server.(map[string]any)
+ args := managedMCPArgs(buildinfo.Current(), "claude-setup")
+ if serverMap["command"] == executable && stringSlicesEqual(mcpServerArgs(serverMap), args) {
+ return false, nil
+ }
+ serverMap["command"] = executable
+ serverMap["args"] = args
+ } else {
+ servers["codemap"] = map[string]any{"command": executable, "args": managedMCPArgs(buildinfo.Current(), "claude-setup")}
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return false, err
+ }
+ out, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return false, err
+ }
+ return true, os.WriteFile(path, append(out, '\n'), 0o644)
+}
+
+func ensureCodexMCPWithExecutable(path, executable string) (bool, error) {
+ data, err := os.ReadFile(path)
+ if err != nil && !os.IsNotExist(err) {
+ return false, fmt.Errorf("read %s: %w", path, err)
+ }
+ payload := map[string]any{}
+ if len(strings.TrimSpace(string(data))) > 0 {
+ if err := toml.Unmarshal(data, &payload); err != nil {
+ return false, fmt.Errorf("parse %s: %w", path, err)
+ }
+ }
+ if rawServers, exists := payload["mcp_servers"]; exists {
+ servers, ok := rawServers.(map[string]any)
+ if !ok {
+ return false, fmt.Errorf("%s field 'mcp_servers' must be a table", path)
+ }
+ if server, exists := servers["codemap"]; exists {
+ if !isOwnedCodemapMCPServer(server, "codex-setup") {
+ return false, fmt.Errorf("%s already defines a conflicting codemap MCP server", path)
+ }
+ serverMap := server.(map[string]any)
+ args := managedMCPArgs(buildinfo.Current(), "codex-setup")
+ if serverMap["command"] == executable && stringSlicesEqual(mcpServerArgs(serverMap), args) {
+ return false, nil
+ }
+ updated, err := replaceCodexMCPCommand(data, executable, args)
+ if err != nil {
+ return false, fmt.Errorf("update %s: %w", path, err)
+ }
+ return true, writeValidatedTOML(path, updated)
+ }
+ }
+ const section = "[mcp_servers.codemap]"
+ body := section + "\ncommand = " + tomlString(executable) + "\nargs = " + tomlStringArray(managedMCPArgs(buildinfo.Current(), "codex-setup")) + "\n"
+ addition := "\n" + body
+ if len(data) == 0 {
+ addition = body
+ } else if data[len(data)-1] == '\n' {
+ addition = body
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return false, err
+ }
+ return true, writeValidatedTOML(path, append(data, []byte(addition)...))
+}
+
+func writeValidatedTOML(path string, data []byte) error {
+ var parsed map[string]any
+ if err := toml.Unmarshal(data, &parsed); err != nil {
+ return fmt.Errorf("refuse to write invalid TOML: %w", err)
+ }
+ return os.WriteFile(path, data, 0o644)
+}
+
+func isOwnedCodemapMCPServer(raw any, integration string) bool {
+ server, ok := raw.(map[string]any)
+ if !ok {
+ return false
+ }
+ command, ok := server["command"].(string)
+ if !ok {
+ return false
+ }
+ args := mcpServerArgs(server)
+ if command == "codemap" && stringSlicesEqual(args, []string{"mcp"}) {
+ return true
+ }
+ return isAbsoluteIntegrationPath(command) && len(args) == 5 && args[0] == "mcp" && args[1] == "--configured-version" && args[2] != "" && args[3] == "--integration" && args[4] == integration
+}
+
func ensureClaudeHooks(settingsPath string, global bool) (ensureHooksResult, error) {
+ executable, err := resolveIntegrationExecutable()
+ if err != nil {
+ return ensureHooksResult{}, err
+ }
+ return ensureClaudeHooksWithExecutable(settingsPath, global, executable)
+}
+
+func ensureClaudeHooksWithExecutable(settingsPath string, global bool, executable string) (ensureHooksResult, error) {
+ return ensureHooks(settingsPath, global, generatedClaudeHooks(executable), nil)
+}
+
+func ensureCodexHooksWithExecutable(settingsPath string, global bool, executable string) (ensureHooksResult, error) {
+ specs := generatedCodexHooks(executable)
+ return ensureHooks(settingsPath, global, specs, codexHookCommandAliases(specs))
+}
+
+func codexHookCommandAliases(specs []claudeHookSpec) map[string]string {
+ return map[string]string{
+ "CODEX=1 codemap hook session-start": specs[0].Command,
+ "CODEX=1 codemap hook pre-edit": specs[1].Command,
+ "CODEX=1 codemap hook post-edit": specs[2].Command,
+ "CODEX=1 codemap hook prompt-submit": specs[3].Command,
+ "CODEX=1 codemap hook pre-compact": specs[4].Command,
+ "CODEX=1 codemap hook session-stop >/dev/null": specs[5].Command,
+ "CODEX=1 codemap hook session-stop > /dev/null": specs[5].Command,
+ "CODEX=1 codemap hook session-stop 2>/dev/null": specs[5].Command,
+ "CODEX=1 codemap hook session-stop 2> /dev/null": specs[5].Command,
+ "CODEX=1 codemap hook session-stop >/dev/null 2>&1": specs[5].Command,
+ }
+}
+
+func ensureHooks(settingsPath string, global bool, specs []claudeHookSpec, commandAliases map[string]string) (ensureHooksResult, error) {
result := ensureHooksResult{
SettingsPath: settingsPath,
- TotalCodemap: len(recommendedClaudeHooks),
+ TotalCodemap: len(specs),
TargetIsGlobal: global,
}
@@ -173,6 +480,9 @@ func ensureClaudeHooks(settingsPath string, global bool) (ensureHooksResult, err
if err := json.Unmarshal(data, &root); err != nil {
return result, fmt.Errorf("parse %s: %w", settingsPath, err)
}
+ if root == nil {
+ return result, fmt.Errorf("parse %s: root must be an object", settingsPath)
+ }
}
case os.IsNotExist(err):
result.CreatedFile = true
@@ -180,39 +490,40 @@ func ensureClaudeHooks(settingsPath string, global bool) (ensureHooksResult, err
return result, fmt.Errorf("read %s: %w", settingsPath, err)
}
- hooksByEvent := make(map[string][]claudeHookEntry)
+ hooksByEvent := make(map[string]any)
if raw, ok := root["hooks"]; ok && raw != nil {
- rawJSON, err := json.Marshal(raw)
- if err != nil {
- return result, fmt.Errorf("encode existing hooks in %s: %w", settingsPath, err)
- }
- if string(rawJSON) != "null" {
- if err := json.Unmarshal(rawJSON, &hooksByEvent); err != nil {
- return result, fmt.Errorf("parse hooks in %s: %w", settingsPath, err)
- }
+ var ok bool
+ hooksByEvent, ok = raw.(map[string]any)
+ if !ok {
+ return result, fmt.Errorf("parse hooks in %s: hooks must be an object", settingsPath)
}
}
+ migrated, err := migrateRawHookCommands(hooksByEvent, commandAliases, specs)
+ if err != nil {
+ return result, fmt.Errorf("parse hooks in %s: %w", settingsPath, err)
+ }
- for _, spec := range recommendedClaudeHooks {
- if hasHookSpec(hooksByEvent[spec.Event], spec) {
+ for _, spec := range specs {
+ entries, err := rawHookEntries(hooksByEvent, spec.Event)
+ if err != nil {
+ return result, fmt.Errorf("parse hooks in %s: %w", settingsPath, err)
+ }
+ if hasRawHookSpec(entries, spec) {
result.ExistingHooks++
continue
}
- entry := claudeHookEntry{
- Matcher: spec.Matcher,
- Hooks: []claudeHookCommand{
- {
- Type: "command",
- Command: spec.Command,
- },
- },
+ entry := map[string]any{
+ "hooks": []any{map[string]any{"type": "command", "command": spec.Command}},
}
- hooksByEvent[spec.Event] = append(hooksByEvent[spec.Event], entry)
+ if spec.Matcher != "" {
+ entry["matcher"] = spec.Matcher
+ }
+ hooksByEvent[spec.Event] = append(entries, entry)
result.AddedHooks++
}
// Preserve no-op behavior when settings already contain all recommended hooks.
- if settingsExisted && result.AddedHooks == 0 {
+ if settingsExisted && result.AddedHooks == 0 && !migrated {
return result, nil
}
@@ -236,6 +547,96 @@ func ensureClaudeHooks(settingsPath string, global bool) (ensureHooksResult, err
return result, nil
}
+func migrateRawHookCommands(hooksByEvent map[string]any, aliases map[string]string, specs []claudeHookSpec) (bool, error) {
+ migrated := false
+ for event := range hooksByEvent {
+ entries, err := rawHookEntries(hooksByEvent, event)
+ if err != nil {
+ return false, err
+ }
+ for entryIndex, rawEntry := range entries {
+ entry, ok := rawEntry.(map[string]any)
+ if !ok {
+ return false, fmt.Errorf("event %q entry %d must be an object", event, entryIndex)
+ }
+ rawHooks, exists := entry["hooks"]
+ if !exists || rawHooks == nil {
+ continue
+ }
+ hooks, ok := rawHooks.([]any)
+ if !ok {
+ return false, fmt.Errorf("event %q entry %d hooks must be an array", event, entryIndex)
+ }
+ for hookIndex, rawHook := range hooks {
+ hook, ok := rawHook.(map[string]any)
+ if !ok {
+ return false, fmt.Errorf("event %q entry %d hook %d must be an object", event, entryIndex, hookIndex)
+ }
+ typeName, _ := hook["type"].(string)
+ if !strings.EqualFold(strings.TrimSpace(typeName), "command") {
+ continue
+ }
+ command, _ := hook["command"].(string)
+ if replacement, ok := aliases[strings.TrimSpace(command)]; ok {
+ hook["command"] = replacement
+ migrated = true
+ continue
+ }
+ for _, spec := range specs {
+ if replacement, ok := migrateOwnedHookCommand(command, spec.Command); ok {
+ if command != replacement {
+ hook["command"] = replacement
+ migrated = true
+ }
+ break
+ }
+ }
+ }
+ }
+ }
+ return migrated, nil
+}
+
+func rawHookEntries(hooksByEvent map[string]any, event string) ([]any, error) {
+ raw, exists := hooksByEvent[event]
+ if !exists || raw == nil {
+ return nil, nil
+ }
+ entries, ok := raw.([]any)
+ if !ok {
+ return nil, fmt.Errorf("event %q must be an array", event)
+ }
+ return entries, nil
+}
+
+func hasRawHookSpec(entries []any, spec claudeHookSpec) bool {
+ targetCommand := strings.TrimSpace(spec.Command)
+ requiredMatcher := strings.TrimSpace(spec.Matcher)
+ for _, rawEntry := range entries {
+ entry, ok := rawEntry.(map[string]any)
+ if !ok {
+ continue
+ }
+ matcher, _ := entry["matcher"].(string)
+ if requiredMatcher != "" && !strings.EqualFold(strings.TrimSpace(matcher), requiredMatcher) {
+ continue
+ }
+ hooks, _ := entry["hooks"].([]any)
+ for _, rawHook := range hooks {
+ hook, ok := rawHook.(map[string]any)
+ if !ok {
+ continue
+ }
+ typeName, _ := hook["type"].(string)
+ command, _ := hook["command"].(string)
+ if strings.EqualFold(strings.TrimSpace(typeName), "command") && strings.TrimSpace(command) == targetCommand {
+ return true
+ }
+ }
+ }
+ return false
+}
+
func hasHookSpec(entries []claudeHookEntry, spec claudeHookSpec) bool {
targetCommand := strings.TrimSpace(spec.Command)
requiredMatcher := strings.TrimSpace(spec.Matcher)
@@ -251,3 +652,225 @@ func hasHookSpec(entries []claudeHookEntry, spec claudeHookSpec) bool {
}
return false
}
+
+func mcpServerArgs(server map[string]any) []string {
+ switch args := server["args"].(type) {
+ case []string:
+ return args
+ case []any:
+ out := make([]string, len(args))
+ for i, value := range args {
+ text, ok := value.(string)
+ if !ok {
+ return nil
+ }
+ out[i] = text
+ }
+ return out
+ default:
+ return nil
+ }
+}
+
+func stringSlicesEqual(left, right []string) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ for i := range left {
+ if left[i] != right[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func isAbsoluteIntegrationPath(path string) bool {
+ if filepath.IsAbs(path) {
+ return true
+ }
+ return len(path) >= 3 && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && path[1] == ':' && (path[2] == '\\' || path[2] == '/')
+}
+
+func tomlString(value string) string {
+ data, _ := json.Marshal(value)
+ return string(data)
+}
+
+func tomlStringArray(values []string) string {
+ quoted := make([]string, len(values))
+ for i, value := range values {
+ quoted[i] = tomlString(value)
+ }
+ return "[" + strings.Join(quoted, ", ") + "]"
+}
+
+func replaceCodexMCPCommand(data []byte, executable string, args []string) ([]byte, error) {
+ lines := strings.SplitAfter(string(data), "\n")
+ start, end := -1, len(lines)
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(strings.TrimSuffix(line, "\n"))
+ if start < 0 {
+ header := strings.TrimSpace(tomlValueWithoutComment(trimmed))
+ if header == "[mcp_servers.codemap]" || header == `[mcp_servers."codemap"]` || header == "[mcp_servers.'codemap']" {
+ start = i
+ }
+ continue
+ }
+ if strings.HasPrefix(trimmed, "[") {
+ end = i
+ break
+ }
+ }
+ if start < 0 {
+ return nil, errors.New("recognized codemap table could not be located")
+ }
+ commandCount, argsCount := 0, 0
+ for i := start + 1; i < end; i++ {
+ line := lines[i]
+ newline := ""
+ if strings.HasSuffix(line, "\n") {
+ line = strings.TrimSuffix(line, "\n")
+ newline = "\n"
+ }
+ equals := strings.IndexByte(line, '=')
+ if equals < 0 {
+ continue
+ }
+ key := strings.TrimSpace(line[:equals])
+ var replacement string
+ switch key {
+ case "command":
+ commandCount++
+ replacement = tomlString(executable)
+ case "args":
+ argsCount++
+ replacement = tomlStringArray(args)
+ default:
+ continue
+ }
+ if !isSingleLineTOMLValue(line[equals+1:]) {
+ return nil, fmt.Errorf("recognized codemap %s assignment spans multiple lines", key)
+ }
+ comment := tomlInlineComment(line[equals+1:])
+ lines[i] = line[:equals+1] + " " + replacement + comment + newline
+ }
+ if commandCount != 1 || argsCount != 1 {
+ return nil, errors.New("recognized codemap table must contain one command and one args assignment")
+ }
+ updated := []byte(strings.Join(lines, ""))
+ var reparsed map[string]any
+ if err := toml.Unmarshal(updated, &reparsed); err != nil {
+ return nil, fmt.Errorf("generated invalid TOML: %w", err)
+ }
+ return updated, nil
+}
+
+func tomlInlineComment(value string) string {
+ if index := tomlCommentIndex(value); index >= 0 {
+ return " " + strings.TrimSpace(value[index:])
+ }
+ return ""
+}
+
+func tomlValueWithoutComment(value string) string {
+ if index := tomlCommentIndex(value); index >= 0 {
+ return value[:index]
+ }
+ return value
+}
+
+func tomlCommentIndex(value string) int {
+ var quote byte
+ escaped := false
+ for i := 0; i < len(value); i++ {
+ char := value[i]
+ if escaped {
+ escaped = false
+ continue
+ }
+ if quote == '"' && char == '\\' {
+ escaped = true
+ continue
+ }
+ if quote != 0 {
+ if char == quote {
+ quote = 0
+ }
+ continue
+ }
+ if char == '"' || char == '\'' {
+ quote = char
+ continue
+ }
+ if char == '#' {
+ return i
+ }
+ }
+ return -1
+}
+
+func isSingleLineTOMLValue(value string) bool {
+ value = strings.TrimSpace(tomlValueWithoutComment(value))
+ if value == "" {
+ return false
+ }
+ var parsed map[string]any
+ return toml.Unmarshal([]byte("value = "+value+"\n"), &parsed) == nil
+}
+
+func migrateOwnedHookCommand(existing, target string) (string, bool) {
+ existing = strings.TrimSpace(existing)
+ target = strings.TrimSpace(target)
+ targetHook := strings.Index(target, " hook ")
+ if targetHook < 0 {
+ return "", false
+ }
+ suffix := target[targetHook:]
+ marker := " --integration="
+ markerIndex := strings.LastIndex(suffix, marker)
+ if markerIndex < 0 {
+ return "", false
+ }
+ legacySuffix := suffix[:markerIndex]
+ if existing == "codemap"+legacySuffix {
+ return target, true
+ }
+ if existing == target {
+ return target, true
+ }
+ if strings.HasSuffix(existing, legacySuffix) {
+ prefix := strings.TrimSpace(strings.TrimSuffix(existing, legacySuffix))
+ path, ok := unquoteHookExecutable(prefix)
+ if ok && isAbsoluteIntegrationPath(path) && isLegacyCodemapHookExecutable(path) {
+ return target, true
+ }
+ }
+ if !strings.HasSuffix(existing, suffix) {
+ return "", false
+ }
+ prefix := strings.TrimSpace(strings.TrimSuffix(existing, suffix))
+ path, ok := unquoteHookExecutable(prefix)
+ if !ok || !isAbsoluteIntegrationPath(path) {
+ return "", false
+ }
+ return target, true
+}
+
+func isLegacyCodemapHookExecutable(path string) bool {
+ normalized := strings.ReplaceAll(path, `\`, "/")
+ name := normalized[strings.LastIndex(normalized, "/")+1:]
+ return name == "codemap" || name == "codemap.exe"
+}
+
+func unquoteHookExecutable(value string) (string, bool) {
+ if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' {
+ return strings.ReplaceAll(value[1:len(value)-1], `'"'"'`, `'`), true
+ }
+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
+ return strings.ReplaceAll(value[1:len(value)-1], `\"`, `"`), true
+ }
+ if !strings.ContainsAny(value, " \t\r\n") {
+ return value, true
+ }
+ return "", false
+}
diff --git a/cmd/setup_run_test.go b/cmd/setup_run_test.go
index 758c008..50965da 100644
--- a/cmd/setup_run_test.go
+++ b/cmd/setup_run_test.go
@@ -33,7 +33,6 @@ func TestRunSetupNoConfigNoHooks(t *testing.T) {
out := captureOutput(func() { RunSetup([]string{"--no-config", "--no-hooks", root}, ".") })
checks := []string{
- "codemap setup",
"Config: skipped (--no-config)",
"Hooks: skipped (--no-hooks)",
"Next:",
@@ -45,6 +44,26 @@ func TestRunSetupNoConfigNoHooks(t *testing.T) {
}
}
+func TestRunSetupNoMCP(t *testing.T) {
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ out := captureOutput(func() {
+ RunSetup([]string{"--agent", "codex", "--no-config", "--no-mcp", root}, ".")
+ })
+ if !strings.Contains(out, "MCP: skipped (--no-mcp)") {
+ t.Fatalf("expected MCP skip output, got:\n%s", out)
+ }
+ if _, err := os.Stat(filepath.Join(root, ".codex", "hooks.json")); err != nil {
+ t.Fatalf("expected Codex hooks to exist: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, ".codex", "config.toml")); !os.IsNotExist(err) {
+ t.Fatalf("expected Codex MCP config to be absent, got err=%v", err)
+ }
+}
+
func TestRunSetupCreatesConfigAndHooks(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
@@ -58,7 +77,7 @@ func TestRunSetupCreatesConfigAndHooks(t *testing.T) {
}
first := captureOutput(func() { RunSetup([]string{root}, ".") })
- if !strings.Contains(first, "Config: created") || !strings.Contains(first, "Hooks: created") {
+ if !strings.Contains(first, "Config: created") || !strings.Contains(first, "Claude hooks: created") || !strings.Contains(first, "Codex hooks: created") {
t.Fatalf("unexpected first setup output:\n%s", first)
}
@@ -72,7 +91,7 @@ func TestRunSetupCreatesConfigAndHooks(t *testing.T) {
}
second := captureOutput(func() { RunSetup([]string{root}, ".") })
- if !strings.Contains(second, "Config: already exists") || !strings.Contains(second, "Hooks: already configured") {
+ if !strings.Contains(second, "Config: already exists") || !strings.Contains(second, "Claude hooks: already configured") || !strings.Contains(second, "Codex hooks: already configured") {
t.Fatalf("unexpected second setup output:\n%s", second)
}
diff --git a/cmd/setup_test.go b/cmd/setup_test.go
index 9b0e5f3..00b627a 100644
--- a/cmd/setup_test.go
+++ b/cmd/setup_test.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"os"
"path/filepath"
- "strings"
"testing"
)
@@ -96,10 +95,11 @@ func TestEnsureClaudeHooksPreservesFieldsAndAvoidsDuplicates(t *testing.T) {
if len(hooks["SessionStart"]) == 0 {
t.Fatal("expected SessionStart hooks to exist")
}
+ want := recommendedClaudeHooks[0].Command
count := 0
for _, entry := range hooks["SessionStart"] {
for _, hook := range entry.Hooks {
- if hook.Command == "codemap hook session-start" {
+ if hook.Command == want {
count++
}
}
@@ -158,15 +158,17 @@ func TestEnsureClaudeHooksAddsMatcherScopedHookWhenMatcherMissing(t *testing.T)
hooks := readHooksMap(t, settings)
preToolUseEntries := hooks["PreToolUse"]
+ var recommended claudeHookSpec
+ for _, spec := range recommendedClaudeHooks {
+ if spec.Event == "PreToolUse" {
+ recommended = spec
+ break
+ }
+ }
foundRecommended := false
for _, entry := range preToolUseEntries {
- if strings.TrimSpace(entry.Matcher) != "Edit|Write" {
- continue
- }
- for _, hook := range entry.Hooks {
- if strings.TrimSpace(hook.Command) == "codemap hook pre-edit" && strings.EqualFold(strings.TrimSpace(hook.Type), "command") {
- foundRecommended = true
- }
+ if hasHookSpec([]claudeHookEntry{entry}, recommended) {
+ foundRecommended = true
}
}
if !foundRecommended {
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
index c3b176c..3539358 100644
--- a/docs/HOOKS.md
+++ b/docs/HOOKS.md
@@ -1,6 +1,6 @@
-# Codemap Hooks for Claude Code
+# Codemap Hooks
-Turn Claude into a codebase-aware assistant. These hooks give Claude automatic context at every step - like GPS navigation for your code.
+Hooks give Claude Code and Codex automatic codebase context throughout a session or task.
## The Full Experience
@@ -28,7 +28,7 @@ cd /path/to/your/project
codemap setup
```
-Global Claude settings instead of project-local:
+Global settings instead of project-local:
```bash
codemap setup --global
@@ -36,9 +36,13 @@ codemap setup --global
This command:
- creates `.codemap/config.json` when missing
-- inserts codemap hook commands into Claude settings (without removing existing hooks)
+- merges codemap hooks into Claude and Codex settings without removing existing hooks
- keeps hook setup idempotent (safe to run more than once)
+Use `--agent claude` or `--agent codex` to configure only one integration.
+Managed commands use the verified absolute path of the running `codemap`; rerun
+setup if that path changes. `codemap doctor` validates without rewriting.
+
Important: run `codemap setup` from the git repo root. Hook commands run relative to the current working directory; starting Claude from a nested folder can prevent codemap from finding `.git` and `.codemap`.
### Manual Hook JSON (advanced)
@@ -410,7 +414,8 @@ The 8-second hook timeout prevents any single hook from blocking Claude.
## Verify It Works
1. Run `codemap setup` in your project
-2. Restart Claude Code (or start a new session)
-3. You should see project structure at the top
-4. Ask Claude to edit a core file - watch for hub warnings
-5. End your session and see the summary
+2. For Codex, trust project hooks from `/hooks` in CLI or Settings > Hooks in Desktop
+3. Start a new Claude Code or Codex session/task
+4. You should see project structure at the top
+5. Ask the agent to edit a core file - watch for hub warnings
+6. End your session and see the summary
diff --git a/docs/MCP.md b/docs/MCP.md
index 94e07bb..8cad8c4 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -1,11 +1,25 @@
# Codemap MCP Server
-Run codemap as an MCP (Model Context Protocol) server for deep Claude integration.
+Run codemap as an MCP server for Claude Code, Codex, or another MCP client.
## Setup
Preferred when `codemap` is already installed:
+```bash
+codemap setup # Configure Claude Code and Codex
+codemap setup --agent claude # Configure only Claude Code
+codemap setup --agent codex # Configure only Codex
+```
+
+Setup writes a managed, versioned absolute executable path. After upgrading or
+moving Codemap, Codex plugin users should run `codemap plugin install` first,
+then rerun setup in each configured project. Claude users skip plugin
+installation but still rerun setup. Use `codemap doctor` for strict validation;
+it reports Codex CLI and Desktop runtimes independently. Agent integrations do
+not automatically refresh the generated local plugin or per-project setup.
+For a manual, PATH-dependent Claude definition:
+
```bash
claude mcp add --transport stdio codemap -- codemap mcp
```
diff --git a/go.mod b/go.mod
index ebec3d7..449e4c3 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/fsnotify/fsnotify v1.9.0
github.com/modelcontextprotocol/go-sdk v1.1.0
+ github.com/pelletier/go-toml/v2 v2.2.4
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
golang.org/x/term v0.37.0
gopkg.in/yaml.v3 v3.0.1
diff --git a/go.sum b/go.sum
index da4a643..70804bd 100644
--- a/go.sum
+++ b/go.sum
@@ -39,6 +39,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go
new file mode 100644
index 0000000..9ee9f08
--- /dev/null
+++ b/internal/buildinfo/version.go
@@ -0,0 +1,16 @@
+package buildinfo
+
+import "strings"
+
+var version = "dev"
+
+func Current() string {
+ if version == "" {
+ return "dev"
+ }
+ return version
+}
+
+func Equal(a, b string) bool {
+ return strings.TrimPrefix(a, "v") == strings.TrimPrefix(b, "v")
+}
diff --git a/main.go b/main.go
index 62749b9..984212e 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,6 @@
package main
import (
- "context"
"encoding/json"
"errors"
"flag"
@@ -17,8 +16,8 @@ import (
"codemap/cmd"
"codemap/config"
"codemap/handoff"
+ "codemap/internal/buildinfo"
"codemap/limits"
- codemapmcp "codemap/mcp"
"codemap/render"
"codemap/scanner"
"codemap/watch"
@@ -46,6 +45,11 @@ var (
)
func main() {
+ if len(os.Args) >= 2 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-version") {
+ fmt.Printf("codemap %s\n", buildinfo.Current())
+ return
+ }
+
// Handle "watch" subcommand before flag parsing
if len(os.Args) >= 2 && os.Args[1] == "watch" {
subCmd := "status"
@@ -69,8 +73,29 @@ func main() {
}
hookName := os.Args[2]
root, _ := os.Getwd()
- if len(os.Args) >= 4 {
- root = os.Args[3]
+ hookAgent := "claude"
+ hookIntegration := ""
+ for _, arg := range os.Args[3:] {
+ switch {
+ case arg == "--agent=codex":
+ hookAgent = "codex"
+ _ = os.Setenv("CODEX", "1")
+ case strings.HasPrefix(arg, "--agent="):
+ fmt.Fprintf(os.Stderr, "Unsupported hook agent: %s\n", strings.TrimPrefix(arg, "--agent="))
+ os.Exit(2)
+ case strings.HasPrefix(arg, "--integration="):
+ hookIntegration = strings.TrimPrefix(arg, "--integration=")
+ default:
+ root = arg
+ }
+ }
+ if hookIntegration != "" {
+ valid := (hookIntegration == "claude-setup" && hookAgent == "claude") ||
+ (hookIntegration == "codex-setup" && hookAgent == "codex")
+ if !valid {
+ fmt.Fprintf(os.Stderr, "Unsupported hook integration: %s for agent %s\n", hookIntegration, hookAgent)
+ os.Exit(2)
+ }
}
if err := cmd.RunHookWithTimeout(hookName, root, cmd.HookTimeoutFromEnv(os.Getenv)); err != nil {
var timeoutErr *cmd.HookTimeoutError
@@ -102,15 +127,24 @@ func main() {
// Handle "setup" subcommand before global flag parsing
if len(os.Args) >= 2 && os.Args[1] == "setup" {
root, _ := os.Getwd()
- cmd.RunSetup(os.Args[2:], root)
+ if code := cmd.RunSetup(os.Args[2:], root); code != 0 {
+ os.Exit(code)
+ }
+ return
+ }
+
+ if len(os.Args) >= 2 && os.Args[1] == "doctor" {
+ root, _ := os.Getwd()
+ if code := cmd.RunDoctor(os.Args[2:], root); code != 0 {
+ os.Exit(code)
+ }
return
}
// Handle "mcp" subcommand before global flag parsing
if len(os.Args) >= 2 && os.Args[1] == "mcp" {
- if err := codemapmcp.Run(context.Background()); err != nil {
- fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
- os.Exit(1)
+ if code := cmd.RunMCP(os.Args[2:]); code != 0 {
+ os.Exit(code)
}
return
}
@@ -179,6 +213,7 @@ func main() {
fmt.Println()
fmt.Println("Options:")
fmt.Println(" --help Show this help message")
+ fmt.Println(" --version Show build version")
fmt.Println(" --skyline City skyline visualization")
fmt.Println(" --animate Animated skyline (use with --skyline)")
fmt.Println(" --deps Dependency flow map (functions & imports)")
@@ -225,7 +260,8 @@ func main() {
fmt.Println(" codemap config show # Show current project config")
fmt.Println()
fmt.Println("Plugin management:")
- fmt.Println(" codemap plugin install # Install the Codemap plugin into your home plugin marketplace")
+ fmt.Println(" codemap plugin install # Install/update and activate the Codemap plugin")
+ fmt.Println(" codemap doctor # Check Codex or Claude integration prerequisites")
fmt.Println()
fmt.Println("MCP server:")
fmt.Println(" codemap mcp # Run Codemap MCP server on stdio")
diff --git a/mcp/main.go b/mcp/main.go
index c99f002..9f6c02d 100644
--- a/mcp/main.go
+++ b/mcp/main.go
@@ -5,7 +5,9 @@ import (
"bytes"
"context"
"encoding/json"
+ "flag"
"fmt"
+ "io"
"os"
"path/filepath"
"regexp"
@@ -15,6 +17,7 @@ import (
"time"
"codemap/handoff"
+ "codemap/internal/buildinfo"
"codemap/limits"
"codemap/render"
"codemap/scanner"
@@ -30,6 +33,68 @@ var (
watchersMu sync.RWMutex
)
+const (
+ IntegrationClaudeSetup = "claude-setup"
+ IntegrationCodexSetup = "codex-setup"
+ IntegrationCodexPlugin = "codex-plugin"
+)
+
+type RuntimeOptions struct {
+ ConfiguredVersion string
+ Integration string
+}
+
+func ParseRuntimeOptions(args []string) (RuntimeOptions, error) {
+ var options RuntimeOptions
+ flags := flag.NewFlagSet("mcp", flag.ContinueOnError)
+ flags.SetOutput(io.Discard)
+ flags.StringVar(&options.ConfiguredVersion, "configured-version", "", "version recorded by the managed integration")
+ flags.StringVar(&options.Integration, "integration", "", "managed integration kind")
+ if err := flags.Parse(args); err != nil {
+ return RuntimeOptions{}, err
+ }
+ if flags.NArg() != 0 {
+ return RuntimeOptions{}, fmt.Errorf("unexpected MCP arguments: %s", strings.Join(flags.Args(), " "))
+ }
+ if options.ConfiguredVersion == "" && options.Integration == "" {
+ return options, nil
+ }
+ if options.ConfiguredVersion == "" || options.Integration == "" {
+ return RuntimeOptions{}, fmt.Errorf("--configured-version and --integration must be provided together")
+ }
+ switch options.Integration {
+ case IntegrationClaudeSetup, IntegrationCodexSetup, IntegrationCodexPlugin:
+ return options, nil
+ default:
+ return RuntimeOptions{}, fmt.Errorf("unsupported integration %q", options.Integration)
+ }
+}
+
+func (options RuntimeOptions) upgradeGuidance() string {
+ if options.ConfiguredVersion == "" || options.Integration == "" || buildinfo.Equal(options.ConfiguredVersion, buildinfo.Current()) {
+ return ""
+ }
+
+ var repairCommand string
+ switch options.Integration {
+ case IntegrationClaudeSetup:
+ repairCommand = "codemap setup --agent claude"
+ case IntegrationCodexSetup:
+ repairCommand = "codemap setup --agent codex"
+ case IntegrationCodexPlugin:
+ repairCommand = "codemap plugin install"
+ default:
+ return ""
+ }
+
+ return fmt.Sprintf(
+ "Upgrade guidance: this Codemap integration was configured for version %s, but the running server is version %s. Run `%s` to refresh it.",
+ options.ConfiguredVersion,
+ buildinfo.Current(),
+ repairCommand,
+ )
+}
+
// Input types for tools
type PathInput struct {
Path string `json:"path" jsonschema:"Path to the project directory to analyze"`
@@ -82,11 +147,13 @@ type HandoffInput struct {
File string `json:"file,omitempty" jsonschema:"Load detailed context for one changed file path from handoff delta"`
}
-func NewServer() *mcp.Server {
+func NewServer(options RuntimeOptions) *mcp.Server {
+ guidance := options.upgradeGuidance()
+
server := mcp.NewServer(&mcp.Implementation{
Name: "codemap",
- Version: "2.0.0",
- }, nil)
+ Version: buildinfo.Current(),
+ }, &mcp.ServerOptions{Instructions: guidance})
// Tool: get_structure - Get project tree view
mcp.AddTool(server, &mcp.Tool{
@@ -122,7 +189,7 @@ func NewServer() *mcp.Server {
mcp.AddTool(server, &mcp.Tool{
Name: "status",
Description: "Check codemap MCP server status. Returns version and confirms local filesystem access is available.",
- }, handleStatus)
+ }, statusHandler(guidance))
// Tool: list_projects - Discover projects in a directory
mcp.AddTool(server, &mcp.Tool{
@@ -193,8 +260,8 @@ func NewServer() *mcp.Server {
return server
}
-func Run(ctx context.Context) error {
- return NewServer().Run(ctx, &mcp.StdioTransport{})
+func Run(ctx context.Context, options RuntimeOptions) error {
+ return NewServer(options).Run(ctx, &mcp.StdioTransport{})
}
func textResult(text string) *mcp.CallToolResult {
@@ -398,7 +465,7 @@ func handleStatus(ctx context.Context, req *mcp.CallToolRequest, input EmptyInpu
watchStatus = fmt.Sprintf("%d active: %s", activeWatchers, strings.Join(watchedPaths, ", "))
}
- return textResult(fmt.Sprintf(`codemap MCP server v2.1.0
+ return textResult(fmt.Sprintf(`codemap MCP server v%s
Status: connected
Local filesystem access: enabled
Working directory: %s
@@ -417,7 +484,21 @@ Available tools:
Live watch tools:
start_watch - Start watching a project for changes
stop_watch - Stop watching a project
- get_activity - See recent coding activity (hot files, edits, timeline)`, cwd, home, watchStatus)), nil, nil
+ get_activity - See recent coding activity (hot files, edits, timeline)`, buildinfo.Current(), cwd, home, watchStatus)), nil, nil
+}
+
+func statusHandler(guidance string) func(context.Context, *mcp.CallToolRequest, EmptyInput) (*mcp.CallToolResult, any, error) {
+ return func(ctx context.Context, req *mcp.CallToolRequest, input EmptyInput) (*mcp.CallToolResult, any, error) {
+ result, output, err := handleStatus(ctx, req, input)
+ if err != nil || guidance == "" {
+ return result, output, err
+ }
+ content, ok := result.Content[0].(*mcp.TextContent)
+ if ok {
+ content.Text += "\n\n" + guidance
+ }
+ return result, output, nil
+ }
}
func handleListProjects(ctx context.Context, req *mcp.CallToolRequest, input ListProjectsInput) (*mcp.CallToolResult, any, error) {
diff --git a/mcp_entrypoints_test.go b/mcp_entrypoints_test.go
new file mode 100644
index 0000000..c7d2a83
--- /dev/null
+++ b/mcp_entrypoints_test.go
@@ -0,0 +1,137 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+func TestMCPEntrypointsMatch(t *testing.T) {
+ binDir := t.TempDir()
+ cli := buildTestBinary(t, filepath.Join(binDir, "codemap"), ".")
+ standalone := buildTestBinary(t, filepath.Join(binDir, "codemap-mcp"), "./cmd/codemap-mcp")
+
+ tests := []struct {
+ name string
+ args []string
+ }{
+ {name: "no options"},
+ {name: "managed options", args: []string{"--configured-version", "test-version", "--integration", "codex-setup"}},
+ {name: "partial options", args: []string{"--configured-version", "test-version"}},
+ {name: "invalid option", args: []string{"--invalid"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cliResult := runTestBinary(t, cli, append([]string{"mcp"}, tt.args...)...)
+ standaloneResult := runTestBinary(t, standalone, tt.args...)
+ if cliResult != standaloneResult {
+ t.Fatalf("entrypoints differ:\ncodemap mcp: %+v\ncodemap-mcp: %+v", cliResult, standaloneResult)
+ }
+ })
+ }
+
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{"only":["go"]}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "schema.proto"), []byte("syntax = \"proto3\";\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ managedArgs := []string{"--configured-version", "test-version", "--integration", "codex-setup"}
+ cliSnapshot := probeMCP(t, cli, append([]string{"mcp"}, managedArgs...), root)
+ standaloneSnapshot := probeMCP(t, standalone, managedArgs, root)
+ if cliSnapshot != standaloneSnapshot {
+ t.Fatalf("MCP protocol behavior differs:\ncodemap mcp:\n%s\ncodemap-mcp:\n%s", cliSnapshot, standaloneSnapshot)
+ }
+}
+
+type testCommandResult struct {
+ ExitCode int
+ Stdout string
+ Stderr string
+}
+
+func buildTestBinary(t *testing.T, output, pkg string) string {
+ t.Helper()
+ command := exec.Command("go", "build", "-o", output, pkg)
+ command.Env = os.Environ()
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("build %s: %v\n%s", pkg, err, output)
+ }
+ return output
+}
+
+func runTestBinary(t *testing.T, binary string, args ...string) testCommandResult {
+ t.Helper()
+ var stdout, stderr bytes.Buffer
+ command := exec.Command(binary, args...)
+ command.Stdin = bytes.NewReader(nil)
+ command.Stdout = &stdout
+ command.Stderr = &stderr
+ err := command.Run()
+ exitCode := 0
+ if err != nil {
+ var exitErr *exec.ExitError
+ if !errors.As(err, &exitErr) {
+ t.Fatalf("run %s: %v", binary, err)
+ }
+ exitCode = exitErr.ExitCode()
+ }
+ return testCommandResult{ExitCode: exitCode, Stdout: stdout.String(), Stderr: stderr.String()}
+}
+
+func probeMCP(t *testing.T, binary string, args []string, root string) string {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ client := mcp.NewClient(&mcp.Implementation{Name: "entrypoint-parity-test", Version: "1"}, nil)
+ session, err := client.Connect(ctx, &mcp.CommandTransport{Command: exec.Command(binary, args...)}, nil)
+ if err != nil {
+ t.Fatalf("connect %s: %v", binary, err)
+ }
+ defer session.Close()
+
+ tools, err := session.ListTools(ctx, nil)
+ if err != nil {
+ t.Fatalf("list tools from %s: %v", binary, err)
+ }
+ toolNames := make([]string, 0, len(tools.Tools))
+ for _, tool := range tools.Tools {
+ toolNames = append(toolNames, tool.Name+":"+tool.Description)
+ }
+ status, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "status"})
+ if err != nil {
+ t.Fatalf("status from %s: %v", binary, err)
+ }
+ find, err := session.CallTool(ctx, &mcp.CallToolParams{
+ Name: "find_file",
+ Arguments: map[string]any{"path": root, "pattern": "schema"},
+ })
+ if err != nil {
+ t.Fatalf("find_file from %s: %v", binary, err)
+ }
+ return strings.Join(toolNames, "\n") + "\n--- status ---\n" + mcpResultText(t, status) + "\n--- find_file ---\n" + mcpResultText(t, find)
+}
+
+func mcpResultText(t *testing.T, result *mcp.CallToolResult) string {
+ t.Helper()
+ var text []string
+ for _, content := range result.Content {
+ if item, ok := content.(*mcp.TextContent); ok {
+ text = append(text, item.Text)
+ }
+ }
+ return strings.Join(text, "\n")
+}
diff --git a/plugins/codemap/.codex-plugin/plugin.json b/plugins/codemap/.codex-plugin/plugin.json
index 707134a..38004fb 100644
--- a/plugins/codemap/.codex-plugin/plugin.json
+++ b/plugins/codemap/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "codemap",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Analyze codebase structure, dependencies, changes, risk, matched skills, and cross-agent handoffs.",
"author": {
"name": "JordanCoin",
diff --git a/plugins/codemap/.mcp.json b/plugins/codemap/.mcp.json
index 6328e38..d4ff335 100644
--- a/plugins/codemap/.mcp.json
+++ b/plugins/codemap/.mcp.json
@@ -1,9 +1,13 @@
{
"mcpServers": {
"codemap": {
- "command": "/bin/bash",
+ "command": "codemap",
"args": [
- "./scripts/run-codemap-mcp.sh"
+ "mcp",
+ "--configured-version",
+ "dev",
+ "--integration",
+ "codex-plugin"
]
}
}
diff --git a/plugins/codemap/README.md b/plugins/codemap/README.md
index 5221a8d..9b75414 100644
--- a/plugins/codemap/README.md
+++ b/plugins/codemap/README.md
@@ -5,15 +5,13 @@ This is a Codex plugin bundle for Codemap.
It bundles:
- the Codemap skill under `./skills/`
-- a local MCP configuration in [`.mcp.json`](./.mcp.json)
-- a launcher script that prefers `codemap mcp` and falls back to `codemap-mcp`
+- an MCP configuration generated at install time
- packaged logo/icon assets under `./assets/`
Install/runtime expectations:
-- `codemap` should be installed on `PATH`
-- preferred runtime is a Codemap build that supports `codemap mcp`
-- fallback runtime is a separate `codemap-mcp` binary on `PATH`
+- the installer records the absolute path and version of the running `codemap`
+- rerun installation after upgrades if the recorded path or version changes
Install globally with:
@@ -21,6 +19,18 @@ Install globally with:
codemap plugin install
```
-That writes the plugin bundle to `~/plugins/codemap` and updates `~/.agents/plugins/marketplace.json`.
+Run the same command after upgrading the Codemap binary. It refreshes the
+global plugin and migrates managed Codex files in the current project only;
+run `codemap setup` and `codemap doctor` in each configured project afterward.
+Codex does not automatically update this generated local plugin. After
+installation, start a new task in Desktop or a new session in CLI.
+
+That writes the plugin to `~/.codex/plugins/codemap`, updates the personal
+marketplace, and installs or refreshes it through Codex CLI. The installed
+plugin is available to CLI and Desktop when they share that Codex environment.
+Repeat installation for another host or `CODEX_HOME`; this does not upgrade the
+Codex applications or other projects. Use `--no-activate` only when preparing
+files without invoking Codex CLI. Legacy `--activate` is deprecated because
+activation is now the default.
For repo-local discovery while developing, pair this plugin with `.agents/plugins/marketplace.json` in the repo root.
diff --git a/plugins/codemap/scripts/run-codemap-mcp.sh b/plugins/codemap/scripts/run-codemap-mcp.sh
deleted file mode 100755
index 2f840fa..0000000
--- a/plugins/codemap/scripts/run-codemap-mcp.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-if command -v codemap >/dev/null 2>&1 && codemap --help 2>/dev/null | grep -q "codemap mcp"; then
- exec codemap mcp "$@"
-fi
-
-if command -v codemap-mcp >/dev/null 2>&1; then
- exec codemap-mcp "$@"
-fi
-
-echo "Codemap MCP is unavailable. Install a codemap build that supports 'codemap mcp' or add 'codemap-mcp' to PATH." >&2
-exit 1
diff --git a/plugins/install.go b/plugins/install.go
index 642809b..223e90a 100644
--- a/plugins/install.go
+++ b/plugins/install.go
@@ -27,12 +27,16 @@ type InstallOptions struct {
HomeDir string
PluginPath string
MarketplacePath string
+ ExecutablePath string
+ BinaryVersion string
}
type InstallResult struct {
PluginPath string
MarketplacePath string
+ MarketplaceName string
FilesWritten int
+ FilesRemoved int
FilesUnchanged int
CreatedMarketplace bool
UpdatedMarketplace bool
@@ -47,11 +51,17 @@ func InstallCodemapPlugin(opts InstallOptions) (InstallResult, error) {
opts.HomeDir = homeDir
}
if opts.PluginPath == "" {
- opts.PluginPath = filepath.Join(opts.HomeDir, "plugins", CodemapPluginName)
+ opts.PluginPath = filepath.Join(opts.HomeDir, ".codex", "plugins", CodemapPluginName)
}
if opts.MarketplacePath == "" {
opts.MarketplacePath = filepath.Join(opts.HomeDir, ".agents", "plugins", "marketplace.json")
}
+ if !filepath.IsAbs(opts.ExecutablePath) {
+ return InstallResult{}, fmt.Errorf("codemap executable path is not absolute: %q", opts.ExecutablePath)
+ }
+ if strings.TrimSpace(opts.BinaryVersion) == "" {
+ return InstallResult{}, fmt.Errorf("codemap binary version is empty")
+ }
result := InstallResult{
PluginPath: opts.PluginPath,
@@ -65,6 +75,12 @@ func InstallCodemapPlugin(opts InstallOptions) (InstallResult, error) {
if err := writeBundle(bundleRoot, opts.PluginPath, &result); err != nil {
return result, err
}
+ if err := removeRetiredLauncher(opts.PluginPath, &result); err != nil {
+ return result, err
+ }
+ if err := writeGeneratedMCP(opts.PluginPath, opts.ExecutablePath, opts.BinaryVersion, &result); err != nil {
+ return result, err
+ }
created, updated, err := ensureMarketplaceEntry(opts.MarketplacePath, opts.PluginPath)
if err != nil {
@@ -72,10 +88,148 @@ func InstallCodemapPlugin(opts InstallOptions) (InstallResult, error) {
}
result.CreatedMarketplace = created
result.UpdatedMarketplace = updated
+ result.MarketplaceName, err = marketplaceName(opts.MarketplacePath)
+ if err != nil {
+ return result, err
+ }
return result, nil
}
+func removeRetiredLauncher(pluginPath string, result *InstallResult) error {
+ path := filepath.Join(pluginPath, "scripts", "run-codemap-mcp.sh")
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("remove retired launcher %s: %w", path, err)
+ }
+ result.FilesRemoved++
+ return nil
+}
+
+func writeGeneratedMCP(pluginPath, executablePath, binaryVersion string, result *InstallResult) error {
+ targetPath := filepath.Join(pluginPath, ".mcp.json")
+ payload := map[string]any{}
+ existing, readErr := os.ReadFile(targetPath)
+ switch {
+ case readErr == nil:
+ if err := json.Unmarshal(existing, &payload); err != nil {
+ return fmt.Errorf("parse installed MCP configuration %s: %w", targetPath, err)
+ }
+ if payload == nil {
+ return fmt.Errorf("parse installed MCP configuration %s: root must be an object", targetPath)
+ }
+ case os.IsNotExist(readErr):
+ existing = nil
+ default:
+ return fmt.Errorf("read installed MCP configuration %s: %w", targetPath, readErr)
+ }
+ servers := map[string]any{}
+ if raw, exists := payload["mcpServers"]; exists {
+ var ok bool
+ servers, ok = raw.(map[string]any)
+ if !ok {
+ return fmt.Errorf("%s field mcpServers must be an object", targetPath)
+ }
+ } else if _, legacy := payload[CodemapPluginName]; legacy {
+ // Releases before Codex adopted the companion-file schema wrote server
+ // entries at the root. Migrate that owned shape without dropping peers.
+ servers = payload
+ payload = map[string]any{"mcpServers": servers}
+ } else if len(payload) != 0 {
+ return fmt.Errorf("%s is not a Codex MCP companion configuration", targetPath)
+ } else {
+ payload["mcpServers"] = servers
+ }
+ server := map[string]any{}
+ if raw, exists := servers[CodemapPluginName]; exists {
+ var ok bool
+ server, ok = raw.(map[string]any)
+ if !ok || !isOwnedCodemapPluginMCP(server) {
+ return fmt.Errorf("%s defines a conflicting codemap MCP server", targetPath)
+ }
+ }
+ server["command"] = executablePath
+ server["args"] = []string{"mcp", "--configured-version", binaryVersion, "--integration", "codex-plugin"}
+ servers[CodemapPluginName] = server
+ data, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode installed MCP configuration: %w", err)
+ }
+ data = append(data, '\n')
+ if bytes.Equal(existing, data) {
+ result.FilesUnchanged++
+ return nil
+ }
+ if err := os.WriteFile(targetPath, data, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", targetPath, err)
+ }
+ result.FilesWritten++
+ return nil
+}
+
+func isOwnedCodemapPluginMCP(server map[string]any) bool {
+ command, ok := server["command"].(string)
+ if !ok || strings.TrimSpace(command) == "" {
+ return false
+ }
+ args := stringArray(server["args"])
+ if command == "codemap" && stringSlicesEqual(args, []string{"mcp"}) {
+ return true
+ }
+ return len(args) == 5 && args[0] == "mcp" && args[1] == "--configured-version" && args[2] != "" && args[3] == "--integration" && args[4] == "codex-plugin"
+}
+
+func stringArray(raw any) []string {
+ values, ok := raw.([]any)
+ if !ok {
+ valuesAsStrings, ok := raw.([]string)
+ if !ok {
+ return nil
+ }
+ return valuesAsStrings
+ }
+ result := make([]string, len(values))
+ for i, value := range values {
+ var ok bool
+ result[i], ok = value.(string)
+ if !ok {
+ return nil
+ }
+ }
+ return result
+}
+
+func stringSlicesEqual(left, right []string) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ for i := range left {
+ if left[i] != right[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func marketplaceName(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("read marketplace name from %s: %w", path, err)
+ }
+ var payload struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return "", fmt.Errorf("parse marketplace name from %s: %w", path, err)
+ }
+ if strings.TrimSpace(payload.Name) == "" {
+ return "", fmt.Errorf("marketplace %s has no name", path)
+ }
+ return payload.Name, nil
+}
+
func writeBundle(bundleRoot fs.FS, targetRoot string, result *InstallResult) error {
return fs.WalkDir(bundleRoot, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -84,6 +238,9 @@ func writeBundle(bundleRoot fs.FS, targetRoot string, result *InstallResult) err
if path == "." {
return os.MkdirAll(targetRoot, 0o755)
}
+ if path == ".mcp.json" {
+ return nil
+ }
targetPath := filepath.Join(targetRoot, filepath.FromSlash(path))
if d.IsDir() {
diff --git a/plugins/install_test.go b/plugins/install_test.go
index e451f00..63e9149 100644
--- a/plugins/install_test.go
+++ b/plugins/install_test.go
@@ -11,7 +11,7 @@ import (
func TestInstallCodemapPluginCreatesBundleAndMarketplace(t *testing.T) {
home := t.TempDir()
- result, err := InstallCodemapPlugin(InstallOptions{HomeDir: home})
+ result, err := InstallCodemapPlugin(testInstallOptions(t, home))
if err != nil {
t.Fatalf("InstallCodemapPlugin returned error: %v", err)
}
@@ -24,11 +24,10 @@ func TestInstallCodemapPluginCreatesBundleAndMarketplace(t *testing.T) {
}
checks := []string{
- filepath.Join(home, "plugins", "codemap", ".codex-plugin", "plugin.json"),
- filepath.Join(home, "plugins", "codemap", ".mcp.json"),
- filepath.Join(home, "plugins", "codemap", "README.md"),
- filepath.Join(home, "plugins", "codemap", "assets", "icon.png"),
- filepath.Join(home, "plugins", "codemap", "scripts", "run-codemap-mcp.sh"),
+ filepath.Join(home, ".codex", "plugins", "codemap", ".codex-plugin", "plugin.json"),
+ filepath.Join(home, ".codex", "plugins", "codemap", ".mcp.json"),
+ filepath.Join(home, ".codex", "plugins", "codemap", "README.md"),
+ filepath.Join(home, ".codex", "plugins", "codemap", "assets", "icon.png"),
filepath.Join(home, ".agents", "plugins", "marketplace.json"),
}
for _, path := range checks {
@@ -37,15 +36,7 @@ func TestInstallCodemapPluginCreatesBundleAndMarketplace(t *testing.T) {
}
}
- scriptInfo, err := os.Stat(filepath.Join(home, "plugins", "codemap", "scripts", "run-codemap-mcp.sh"))
- if err != nil {
- t.Fatalf("stat plugin script: %v", err)
- }
- if scriptInfo.Mode().Perm() != 0o755 {
- t.Fatalf("expected script mode 0755, got %o", scriptInfo.Mode().Perm())
- }
-
- pluginData, err := os.ReadFile(filepath.Join(home, "plugins", "codemap", ".codex-plugin", "plugin.json"))
+ pluginData, err := os.ReadFile(filepath.Join(home, ".codex", "plugins", "codemap", ".codex-plugin", "plugin.json"))
if err != nil {
t.Fatalf("read plugin.json: %v", err)
}
@@ -70,8 +61,8 @@ func TestInstallCodemapPluginCreatesBundleAndMarketplace(t *testing.T) {
t.Fatalf("marketplace entry name = %#v, want codemap", entry["name"])
}
source := entry["source"].(map[string]any)
- if source["path"] != "./plugins/codemap" {
- t.Fatalf("marketplace source path = %#v, want ./plugins/codemap", source["path"])
+ if source["path"] != "./.codex/plugins/codemap" {
+ t.Fatalf("marketplace source path = %#v, want ./.codex/plugins/codemap", source["path"])
}
}
@@ -106,7 +97,8 @@ func TestInstallCodemapPluginIsIdempotentAndPreservesMarketplaceMetadata(t *test
t.Fatal(err)
}
- first, err := InstallCodemapPlugin(InstallOptions{HomeDir: home})
+ opts := testInstallOptions(t, home)
+ first, err := InstallCodemapPlugin(opts)
if err != nil {
t.Fatalf("first install error: %v", err)
}
@@ -114,7 +106,7 @@ func TestInstallCodemapPluginIsIdempotentAndPreservesMarketplaceMetadata(t *test
t.Fatal("expected first install to update marketplace")
}
- second, err := InstallCodemapPlugin(InstallOptions{HomeDir: home})
+ second, err := InstallCodemapPlugin(opts)
if err != nil {
t.Fatalf("second install error: %v", err)
}
@@ -152,10 +144,9 @@ func TestInstallCodemapPluginUsesInstalledPluginPathInMarketplace(t *testing.T)
home := t.TempDir()
pluginPath := filepath.Join(home, "custom-plugins", "codemap")
- result, err := InstallCodemapPlugin(InstallOptions{
- HomeDir: home,
- PluginPath: pluginPath,
- })
+ opts := testInstallOptions(t, home)
+ opts.PluginPath = pluginPath
+ result, err := InstallCodemapPlugin(opts)
if err != nil {
t.Fatalf("InstallCodemapPlugin returned error: %v", err)
}
@@ -179,3 +170,16 @@ func TestInstallCodemapPluginUsesInstalledPluginPathInMarketplace(t *testing.T)
t.Fatalf("marketplace source path = %#v, want ./custom-plugins/codemap", source["path"])
}
}
+
+func testInstallOptions(t *testing.T, home string) InstallOptions {
+ t.Helper()
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return InstallOptions{
+ HomeDir: home,
+ ExecutablePath: executable,
+ BinaryVersion: "test",
+ }
+}