From 56f12bf1afa1ab35d83602bfc05df0e707c130d2 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:47:10 +0200 Subject: [PATCH 1/4] feat(codex): add first-class Codex integration Co-Authored-By: GPT-5.6 Sol --- .goreleaser.full.yml | 1 + .goreleaser.yml | 1 + cmd/codemap-mcp/main.go | 2 +- cmd/doctor.go | 617 +++++++++++++++++ cmd/doctor_codex_hooks.go | 225 +++++++ cmd/doctor_process_other.go | 16 + cmd/doctor_process_unix.go | 25 + cmd/doctor_process_windows.go | 16 + cmd/hooks.go | 336 +++++++++- cmd/integration_command.go | 85 +++ cmd/plugin.go | 323 ++++++++- cmd/plugin_test.go | 5 +- cmd/setup.go | 739 +++++++++++++++++++-- cmd/setup_run_test.go | 5 +- cmd/setup_test.go | 20 +- go.mod | 1 + go.sum | 2 + internal/buildinfo/version.go | 16 + main.go | 54 +- mcp/main.go | 97 ++- plugins/codemap/.codex-plugin/plugin.json | 2 +- plugins/codemap/.mcp.json | 8 +- plugins/codemap/scripts/run-codemap-mcp.sh | 13 - plugins/install.go | 159 ++++- plugins/install_test.go | 50 +- 25 files changed, 2645 insertions(+), 173 deletions(-) create mode 100644 cmd/doctor.go create mode 100644 cmd/doctor_codex_hooks.go create mode 100644 cmd/doctor_process_other.go create mode 100644 cmd/doctor_process_unix.go create mode 100644 cmd/doctor_process_windows.go create mode 100644 cmd/integration_command.go create mode 100644 internal/buildinfo/version.go delete mode 100755 plugins/codemap/scripts/run-codemap-mcp.sh 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/cmd/codemap-mcp/main.go b/cmd/codemap-mcp/main.go index db3d5da..8f3a964 100644 --- a/cmd/codemap-mcp/main.go +++ b/cmd/codemap-mcp/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - if err := codemapmcp.Run(context.Background()); err != nil { + if err := codemapmcp.Run(context.Background(), codemapmcp.RuntimeOptions{}); err != nil { fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) os.Exit(1) } 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/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..7b770bd 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,33 @@ 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") 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] [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] [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] [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 +126,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 +140,6 @@ func RunSetup(args []string, defaultRoot string) { } } - fmt.Println("codemap setup") fmt.Printf("Project: %s\n", absRoot) fmt.Println() @@ -104,7 +152,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 +164,100 @@ 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) + } + 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 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 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 +272,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 +476,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 +486,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 +543,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 +648,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..5ce97b7 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:", @@ -58,7 +57,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 +71,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/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..725f8ed 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "codemap/cmd" "codemap/config" "codemap/handoff" + "codemap/internal/buildinfo" "codemap/limits" codemapmcp "codemap/mcp" "codemap/render" @@ -46,6 +47,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 +75,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,13 +129,28 @@ 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 { + options, err := codemapmcp.ParseRuntimeOptions(os.Args[2:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid MCP options: %v\n", err) + os.Exit(2) + } + if err := codemapmcp.Run(context.Background(), options); err != nil { fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) os.Exit(1) } @@ -179,6 +221,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 +268,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/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/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", + } +} From dc14b6e9139e8f60dc9b121fddae64551792cc35 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:02:07 +0200 Subject: [PATCH 2/4] fix(mcp): unify executable entrypoints Co-Authored-By: GPT-5.6 Sol --- cmd/codemap-mcp/main.go | 9 +-- cmd/mcp.go | 23 +++++++ main.go | 12 +--- mcp_entrypoints_test.go | 137 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 cmd/mcp.go create mode 100644 mcp_entrypoints_test.go diff --git a/cmd/codemap-mcp/main.go b/cmd/codemap-mcp/main.go index 8f3a964..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(), codemapmcp.RuntimeOptions{}); 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/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/main.go b/main.go index 725f8ed..984212e 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/json" "errors" "flag" @@ -19,7 +18,6 @@ import ( "codemap/handoff" "codemap/internal/buildinfo" "codemap/limits" - codemapmcp "codemap/mcp" "codemap/render" "codemap/scanner" "codemap/watch" @@ -145,14 +143,8 @@ func main() { // Handle "mcp" subcommand before global flag parsing if len(os.Args) >= 2 && os.Args[1] == "mcp" { - options, err := codemapmcp.ParseRuntimeOptions(os.Args[2:]) - if err != nil { - fmt.Fprintf(os.Stderr, "Invalid MCP options: %v\n", err) - os.Exit(2) - } - if err := codemapmcp.Run(context.Background(), options); 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 } 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") +} From 14bf9ebcc35ca650cbf181493de37d62b0439f35 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:18:51 +0200 Subject: [PATCH 3/4] docs(codex): document first-class integration Co-Authored-By: GPT-5.6 Sol --- README.md | 65 ++++++++++++++++++++++++++++++++++----- docs/HOOKS.md | 21 ++++++++----- docs/MCP.md | 16 +++++++++- plugins/codemap/README.md | 22 +++++++++---- 4 files changed, 102 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ad96450..8af6e2a 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,49 @@ 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. +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/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/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. From 1e80ca9eaf28e3ec0e3aefaaf551ace3aebeb959 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:47:20 +0200 Subject: [PATCH 4/4] feat(setup): allow hooks-only configuration Co-Authored-By: GPT-5.6 Sol --- README.md | 1 + cmd/setup.go | 14 +++++++++----- cmd/setup_run_test.go | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8af6e2a..f1ac72e 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ codemap blast-radius --text --ref main . 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 diff --git a/cmd/setup.go b/cmd/setup.go index 7b770bd..2c06373 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -97,17 +97,18 @@ func RunSetup(args []string, defaultRoot string) int { 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 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] [--agent claude|codex] [--no-config] [--no-hooks] [path]") + 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] [--agent claude|codex] [--no-config] [--no-hooks] [path]") + 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] [--agent claude|codex] [--no-config] [--no-hooks] [path]") + fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--agent claude|codex] [--no-config] [--no-hooks] [--no-mcp] [path]") return 2 } selectedAgent := setupAgentBoth @@ -165,6 +166,9 @@ func RunSetup(args []string, defaultRoot string) int { if *skipHooks { fmt.Println("Hooks: skipped (--no-hooks)") } + 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) { @@ -172,7 +176,7 @@ func RunSetup(args []string, defaultRoot string) int { }, absRoot, *useGlobalHooks) != nil { failed = true } - if configureMCP("Claude", claudeMCPPath, func(path string) (bool, error) { + if !*skipMCP && configureMCP("Claude", claudeMCPPath, func(path string) (bool, error) { return ensureClaudeMCPWithExecutable(path, executable) }, absRoot, *useGlobalHooks) != nil { failed = true @@ -184,7 +188,7 @@ func RunSetup(args []string, defaultRoot string) int { }, absRoot, *useGlobalHooks) != nil { failed = true } - if configureMCP("Codex", codexConfigPath, func(path string) (bool, error) { + if !*skipMCP && configureMCP("Codex", codexConfigPath, func(path string) (bool, error) { return ensureCodexMCPWithExecutable(path, executable) }, absRoot, *useGlobalHooks) != nil { failed = true diff --git a/cmd/setup_run_test.go b/cmd/setup_run_test.go index 5ce97b7..50965da 100644 --- a/cmd/setup_run_test.go +++ b/cmd/setup_run_test.go @@ -44,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 {