package claudecode import ( "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/profiles" ) // Name is the stable identifier for this adapter, matching the // --agents= CLI flag. const Name = "claude-code" // DocsURL is the page we point users at when something about our // Claude Code integration surprises them. Used in the --json report. const DocsURL = "https://docs.claude.com/en/docs/claude-code/overview" // Adapter implements agents.Adapter for Claude Code. type Adapter struct{} // New returns the Claude Code adapter. Callers register it via // `agents.Registry.Register(claudecode.New())`. func New() *Adapter { return &Adapter{} } func (a *Adapter) Name() string { return Name } func (a *Adapter) DocsURL() string { return DocsURL } // Detect always returns true. Claude Code is the "home" agent for // `gortex init` — a project may not be opened in Claude Code today // but we always want the integration files on disk so the team's // next contributor is set up. Other adapters gate on detection // because their artifacts make no sense without the IDE installed. func (a *Adapter) Detect(env agents.Env) (bool, error) { return true, nil } // Plan reports the full set of files the adapter would touch for // the given Env. Mode branches between project (`gortex init`) and // global (`gortex install`) surfaces; InstallHooks elides hook files // without affecting anything else. func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) { p := &agents.Plan{} if env.Mode == agents.ModeGlobal { // User-level artifacts — machine-wide. Slash commands and // curated Gortex tool-usage skills both belong here: they're // codebase-agnostic, so duplicating them into every repo is // wasted disk and drift risk. p.Files = append(p.Files, agents.FileAction{Path: userClaudeJSONPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}}) p.Files = append(p.Files, agents.FileAction{Path: userSettingsPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}}) if env.InstallHooks { p.Files = append(p.Files, agents.FileAction{Path: userSettingsLocalPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}}) } if env.InstallGlobalInstructions { p.Files = append(p.Files, agents.FileAction{Path: userClaudeMdPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"gortex-rules-block"}}) } configDir := userClaudeConfigDir(env.Home) if configDir != "" { for name := range GlobalSkills { p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "skills", name, "SKILL.md"), Action: agents.ActionWouldCreate}) } for name := range SlashCommands { p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "commands", name), Action: agents.ActionWouldCreate}) } for name := range SubAgents { p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "agents", name), Action: agents.ActionWouldCreate}) } } return p, nil } // Project mode — only genuinely repo-specific artifacts. No // tool-usage duplication: that lives at ~/.claude/skills/ // (installed by `gortex install`). CLAUDE.md gets a // marker-guarded block only when --analyze or --skills produce // codebase-derived content. p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".mcp.json"), Action: agents.ActionWouldCreate, Keys: []string{"mcpServers"}}) p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.json"), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}}) if env.InstallHooks { p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.local.json"), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}}) } if env.AnalyzedOverview != "" || env.SkillsRouting != "" { p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, "CLAUDE.md"), Action: agents.ActionWouldMerge, Keys: []string{"communities-block"}}) } for _, s := range env.GeneratedSkills { p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md"), Action: agents.ActionWouldCreate}) } return p, nil } // Apply performs the actual writes. Errors mid-way do not abort the // whole adapter — we log each failure and continue, matching the // pre-refactor behaviour where Kiro/Cursor/etc. setup failures only // emit warnings. Claude Code's .mcp.json, CLAUDE.md, and hook install // are the exception: they're the core integration and propagate // failures upward. func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) { w := env.Stderr res := &agents.Result{Name: Name, Detected: true, DocsURL: DocsURL} if env.Mode == agents.ModeGlobal { if err := a.applyGlobal(env, opts, res); err != nil { return res, err } res.Configured = true return res, nil } // 1. Project .mcp.json — create if absent, skip otherwise. // // If gortex is already registered at user scope (~/.claude.json), a // project .mcp.json adds a second registration under the same name. // Claude Code keys OAuth tokens per endpoint and flags this as a // "conflicting scopes" diagnostic. The user-scope entry already // serves this repo machine-wide, so skip the project file unless // --force. (A pre-existing .mcp.json is left in place — we never // delete it — but we warn so the user can resolve the duplication.) mcpPath := filepath.Join(env.Root, ".mcp.json") if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && !pathExists(mcpPath) { logWarn(w, "gortex is already registered at user scope (%s); skipping project .mcp.json to avoid a Claude Code \"conflicting scopes\" warning. Re-run with --force to write it anyway (e.g. for teammates without a global install).", userClaudeJSONPath(env.Home)) res.Files = append(res.Files, agents.FileAction{Path: mcpPath, Action: agents.ActionSkip, Reason: "gortex already registered at user scope"}) } else { if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && pathExists(mcpPath) { logWarn(w, "gortex is registered at both user scope (%s) and project scope (%s); Claude Code may warn about conflicting scopes — keep one with `claude mcp remove gortex -s user` or `-s project`.", userClaudeJSONPath(env.Home), mcpPath) } mcpAction, err := agents.WriteIfNotExists(w, mcpPath, ProjectMCPJSON, opts) if err != nil { return res, fmt.Errorf(".mcp.json: %w", err) } res.Files = append(res.Files, mcpAction) } // 2. MCP permissions in .claude/settings.json — merge, not create. permAction, err := installPermissions(w, filepath.Join(env.Root, ".claude", "settings.json"), opts) if err != nil { logWarn(w, "could not install permissions: %v", err) } res.Files = append(res.Files, permAction) // 3. Hooks in .claude/settings.local.json — merge with healing. if env.InstallHooks { hookAction, err := InstallHookWithMode(w, filepath.Join(env.Root, ".claude", "settings.local.json"), env.HookMode, opts) if err != nil { logWarn(w, "could not install hook: %v", err) } res.Files = append(res.Files, hookAction) } else { logf(w, "[gortex init] skipping hook installation (--no-hooks)") } // 4. CLAUDE.md — only written when there's genuinely // codebase-specific content to place there: either the // --analyze overview, the --skills community routing, or both. // Generic tool-usage moved to user-level ~/.claude/skills/ // (installed by `gortex install`). if env.AnalyzedOverview != "" || env.SkillsRouting != "" { claudeMdPath := filepath.Join(env.Root, "CLAUDE.md") var body strings.Builder if env.AnalyzedOverview != "" { body.WriteString(env.AnalyzedOverview) if !strings.HasSuffix(env.AnalyzedOverview, "\n") { body.WriteString("\n") } } if env.SkillsRouting != "" { if body.Len() > 0 { body.WriteString("\n") } body.WriteString(env.SkillsRouting) } claudeAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, body.String(), agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts) if err != nil { return res, fmt.Errorf("CLAUDE.md: %w", err) } res.Files = append(res.Files, claudeAction) } // 5. Generated community skills — per-community SKILL.md files // under .claude/skills/generated/. Claude Code auto-discovers // them next to the repo-local CLAUDE.md. Regenerated each init // run so they track the current graph. for _, s := range env.GeneratedSkills { path := filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md") action, err := agents.WriteOwnedFile(w, path, s.Content, opts) if err != nil { logWarn(w, "could not write generated skill %s: %v", s.DirName, err) continue } res.Files = append(res.Files, action) } res.Configured = true return res, nil } // applyGlobal handles Mode=ModeGlobal writes (entered via `gortex // install`). Everything here is codebase-agnostic user-level // machinery: MCP config pointing at `gortex mcp`, user-level // hooks, curated Gortex tool-usage skills, and Gortex slash // commands. No per-repo artifacts. func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) error { w := env.Stderr if env.Home == "" { return fmt.Errorf("global mode requires a resolved home directory") } // 1. ~/.claude.json — MCP stanza pointing at `gortex mcp`. mcpPath := userClaudeJSONPath(env.Home) action, err := upsertGlobalMCPConfig(w, mcpPath, opts) if err != nil { return fmt.Errorf("global MCP config: %w", err) } res.Files = append(res.Files, action) // 2. ~/.claude/settings.json — user-level MCP permission allowlist. // Mirrors the project-mode call so `mcp__gortex__*` is auto-allowed // machine-wide; without this every Gortex tool call shows an // approval prompt until the user adds the rule by hand. permAction, err := installPermissions(w, userSettingsPath(env.Home), opts) if err != nil { logWarn(w, "could not install global permissions: %v", err) } res.Files = append(res.Files, permAction) // 3. ~/.claude/settings.local.json — user-level hooks. if env.InstallHooks { hookAction, err := InstallHookWithMode(w, userSettingsLocalPath(env.Home), env.HookMode, opts) if err != nil { return fmt.Errorf("global hooks: %w", err) } res.Files = append(res.Files, hookAction) } // 4. Instruction profiles + ~/.claude/CLAUDE.md pointer block. // The generated profiles live under the gortex home; CLAUDE.md // carries only a thin marker-fenced block that @-includes the // active profile, so `gortex instructions switch` changes // guidance depth without ever rewriting CLAUDE.md. Without the // block, the rule only surfaces at deny-time (PreToolUse) which // is late: the agent has already wasted a turn on a forbidden // tool. insDir := instructionsDir(env) if env.InstallGlobalInstructions { insAction := agents.FileAction{Path: insDir, Action: agents.ActionMerge, Keys: []string{"instruction-profiles"}} if opts.DryRun { insAction.Action = agents.ActionWouldMerge res.Files = append(res.Files, insAction) } else if err := profiles.Generate(insDir); err != nil { logWarn(w, "could not generate instruction profiles: %v", err) } else { logf(w, "[gortex install] wrote instruction profiles to %s", insDir) res.Files = append(res.Files, insAction) } claudeMdPath := userClaudeMdPath(env.Home) mdAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, agents.GlobalPointerBody(insDir), agents.GlobalRulesStartMarker, agents.GlobalRulesEndMarker, opts) if err != nil { logWarn(w, "could not install global CLAUDE.md: %v", err) } else { logf(w, "[gortex install] wrote rule block to %s", claudeMdPath) } // UpsertMarkedBlock is shared with the per-repo communities // block, so it labels every action with "communities-block". // Relabel here so the install report distinguishes the two. if mdAction.Keys != nil { mdAction.Keys = []string{"gortex-rules-block"} } res.Files = append(res.Files, mdAction) } // 3. ~/.claude/skills/gortex-*/SKILL.md — curated tool-usage // skills, reconciled against the active instruction profile's // subset (nil = all shipped skills). Existing files are never // overwritten so user edits survive; out-of-profile skills are // removed only when they still match a shipped body. skillActions, err := SyncGlobalSkills(w, env.Home, profiles.Active(insDir).Skills, opts) if err != nil { logWarn(w, "could not install user-level skills: %v", err) } res.Files = append(res.Files, skillActions...) // 4. ~/.claude/commands/gortex-*.md — slash commands, also // codebase-agnostic and user-level. Claude Code discovers // user-level commands alongside project-level ones. cmdActions, err := installGlobalSlashCommands(w, env.Home, opts) if err != nil { logWarn(w, "could not install user-level slash commands: %v", err) } res.Files = append(res.Files, cmdActions...) // 5. ~/.claude/agents/gortex-*.md — sub-agent definitions. Claude // Code auto-routes user prompts to sub-agents based on their // frontmatter description, so shipping these gives gortex a // delegation surface beyond skills + slash commands. The tool // allowlist in each file pins sub-agents to gortex graph tools // only — Bash / Grep / Glob are unavailable by construction. agentActions, err := installGlobalSubAgents(w, env.Home, opts) if err != nil { logWarn(w, "could not install user-level sub-agents: %v", err) } res.Files = append(res.Files, agentActions...) return nil } // RemoveGlobal undoes applyGlobal: it strips the Gortex footprint // from the user-level Claude Code config — the MCP server stanza, the // permission allow-entry, the hook entries, the CLAUDE.md rule block, // and the curated skills / commands / sub-agents. Merged files keep // every non-Gortex key (other MCP servers, the user's own // permissions / hooks, hand-written CLAUDE.md prose); owned files // (skills / commands / agents) are deleted outright. The config root // honors the --claude-config-dir override / $CLAUDE_CONFIG_DIR / // the ~/.claude default. Returns the number of artifacts // removed-or-cleaned and any per-artifact failures — a partial clean // still reports rather than aborting. Invoked by `gortex uninstall // --global`. func (a *Adapter) RemoveGlobal(env agents.Env, opts agents.ApplyOpts) (removed int, failures []string) { w := env.Stderr if env.Home == "" { return 0, []string{"global cleanup requires a resolved home directory"} } count := func(action agents.FileAction, err error, label string) { if err != nil { failures = append(failures, fmt.Sprintf("%s: %v", label, err)) return } if action.Action != agents.ActionSkip { removed++ } } // 1. ~/.claude.json — drop the "gortex" MCP server stanza. mcpPath := userClaudeJSONPath(env.Home) mcpAction, err := removeGlobalMCPConfig(w, mcpPath, opts) count(mcpAction, err, mcpPath) // 2. settings.json — drop the mcp__gortex__* permission entry. settingsPath := userSettingsPath(env.Home) permAction, err := removeGlobalPermissions(w, settingsPath, opts) count(permAction, err, settingsPath) // 3. settings.local.json — drop the Gortex hook entries. localPath := userSettingsLocalPath(env.Home) hookAction, err := removeGlobalHooks(w, localPath, opts) count(hookAction, err, localPath) // 4. CLAUDE.md — strip the marker-fenced rule block. An empty // body makes UpsertMarkedBlock remove the block in place, // preserving any surrounding user prose. mdPath := userClaudeMdPath(env.Home) mdAction, err := agents.UpsertMarkedBlock(w, mdPath, "", agents.GlobalRulesStartMarker, agents.GlobalRulesEndMarker, opts) count(mdAction, err, mdPath) // 5. skills / commands / agents — delete the owned files. ownedRemoved, ownedFailures := removeGlobalOwnedFiles(w, env.Home, opts) removed += ownedRemoved failures = append(failures, ownedFailures...) // 6. Generated instruction profiles — gortex-owned generated // files (plus the tiny active-state record), safe to delete. insDir := instructionsDir(env) if _, err := os.Stat(insDir); err == nil { if opts.DryRun { removed++ } else if err := profiles.Remove(insDir); err != nil { failures = append(failures, fmt.Sprintf("%s: %v", insDir, err)) } else { logf(w, "[gortex] removed instruction profiles at %s", insDir) removed++ } } return removed, failures } // GlobalArtifacts returns the user-level Claude Code paths gortex // install manages that currently carry a Gortex footprint — the MCP // config, settings files, and CLAUDE.md when they contain Gortex // entries, plus every installed skill / command / sub-agent. Sorted // for stable output. The config root honors the --claude-config-dir // override / $CLAUDE_CONFIG_DIR. Used by `gortex uninstall --global` // to preview the blast radius before deleting. func GlobalArtifacts(home string) []string { configDir := userClaudeConfigDir(home) var present []string // Merged files: list only when they actually carry a Gortex // footprint, so the preview (and its count) matches what // RemoveGlobal will really touch. if fileContains(userClaudeJSONPath(home), `"gortex"`) { present = append(present, userClaudeJSONPath(home)) } if fileContains(userSettingsPath(home), "mcp__gortex__") { present = append(present, userSettingsPath(home)) } if fileContains(userSettingsLocalPath(home), "gortex") { present = append(present, userSettingsLocalPath(home)) } if fileContains(userClaudeMdPath(home), agents.GlobalRulesStartMarker) { present = append(present, userClaudeMdPath(home)) } // Owned files: present on disk == installed by us. for name := range GlobalSkills { if p := filepath.Join(configDir, "skills", name); pathExists(p) { present = append(present, p) } } for name := range SlashCommands { if p := filepath.Join(configDir, "commands", name); pathExists(p) { present = append(present, p) } } for name := range SubAgents { if p := filepath.Join(configDir, "agents", name); pathExists(p) { present = append(present, p) } } sort.Strings(present) return present } // removeGlobalMCPConfig drops the gortex MCP server from a // {"mcpServers": {...}} config, leaving the user's other servers // intact. A missing file or absent stanza is a no-op skip. func removeGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) { return agents.MergeJSON(w, path, func(root map[string]any, _ bool) (bool, error) { return agents.RemoveMCPServer(root, "gortex"), nil }, opts) } // removeGlobalPermissions drops the mcp__gortex__* entry from // permissions.allow, pruning the allow list / permissions map when // removal leaves them empty. User-added allow entries survive. func removeGlobalPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) { return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) { perms, ok := settings["permissions"].(map[string]any) if !ok { return false, nil } allow, ok := perms["allow"].([]any) if !ok { return false, nil } kept := make([]any, 0, len(allow)) removedAny := false for _, entry := range allow { if s, ok := entry.(string); ok && strings.Contains(s, "mcp__gortex__") { removedAny = true continue } kept = append(kept, entry) } if !removedAny { return false, nil } if len(kept) == 0 { delete(perms, "allow") } else { perms["allow"] = kept } if len(perms) == 0 { delete(settings, "permissions") } return true, nil }, opts) } // removeGlobalHooks drops every Gortex-owned hook entry across the // events the installer writes, pruning the hooks map when removal // leaves it empty. Hooks owned by other tools survive. func removeGlobalHooks(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) { return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) { hooks, ok := settings["hooks"].(map[string]any) if !ok { return false, nil } removed := 0 for _, event := range []string{"PreToolUse", "PreCompact", "PostToolUse", "Stop", "SessionStart", "UserPromptSubmit"} { removed += removeGortexHookEntries(hooks, event) } if removed == 0 { return false, nil } if len(hooks) == 0 { delete(settings, "hooks") } return true, nil }, opts) } // removeGlobalOwnedFiles deletes the user-level skills / commands / // sub-agents the installer owns. Skills are directories // ($DIR/skills//); commands and agents are single files. A // not-yet-installed artifact is silently skipped; DryRun counts the // target without touching disk. func removeGlobalOwnedFiles(w io.Writer, home string, opts agents.ApplyOpts) (removed int, failures []string) { configDir := userClaudeConfigDir(home) type target struct { path string dir bool } var targets []target for name := range GlobalSkills { targets = append(targets, target{filepath.Join(configDir, "skills", name), true}) } for name := range SlashCommands { targets = append(targets, target{filepath.Join(configDir, "commands", name), false}) } for name := range SubAgents { targets = append(targets, target{filepath.Join(configDir, "agents", name), false}) } for _, t := range targets { if !pathExists(t.path) { continue // not installed — nothing to remove } if opts.DryRun { removed++ continue } var err error if t.dir { err = os.RemoveAll(t.path) } else { err = os.Remove(t.path) } if err != nil { failures = append(failures, fmt.Sprintf("%s: %v", t.path, err)) continue } logf(w, "[gortex uninstall] removed %s", t.path) removed++ } return removed, failures } func fileContains(path, needle string) bool { data, err := os.ReadFile(path) if err != nil { return false } return strings.Contains(string(data), needle) } func pathExists(path string) bool { _, err := os.Stat(path) return err == nil } // installPermissions merges an {"permissions": {"allow": // ["mcp__gortex__*"]}} stanza into settings.json. Preserves any // user-added entries; short-circuits when a gortex rule is already // present. func installPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) { return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) { // Bail early if a gortex rule is already present. if perms, ok := settings["permissions"].(map[string]any); ok { if allow, ok := perms["allow"].([]any); ok { for _, entry := range allow { if s, ok := entry.(string); ok && strings.Contains(s, "mcp__gortex__") { return false, nil } } } } if _, ok := settings["permissions"]; !ok { settings["permissions"] = make(map[string]any) } perms := settings["permissions"].(map[string]any) if _, ok := perms["allow"]; !ok { perms["allow"] = []any{} } allow := perms["allow"].([]any) perms["allow"] = append(allow, "mcp__gortex__*") return true, nil }, opts) } // instructionsDir resolves where the generated instruction profiles // live for this install run: the Env override (tests) or the machine // default shared with the daemon and the `gortex instructions` verb. func instructionsDir(env agents.Env) string { if env.InstructionsDir != "" { return env.InstructionsDir } return profiles.DefaultDir() } // SyncGlobalSkills reconciles ~/.claude/skills/gortex-* with the // allowed subset (nil = every shipped skill): // // - allowed skills are installed when missing; an existing file is // never overwritten, so user edits survive; // - shipped skills OUTSIDE the subset are removed only when their // on-disk SKILL.md is still byte-identical to the shipped body — // a customised copy is kept (ActionSkip) with a warning, because // deleting user work to enforce a profile is never worth it. // // Also used by `gortex instructions switch` to re-shape the installed // skill surface when the active profile changes. func SyncGlobalSkills(w io.Writer, home string, allowed []string, opts agents.ApplyOpts) ([]agents.FileAction, error) { var allowedSet map[string]bool if allowed != nil { allowedSet = make(map[string]bool, len(allowed)) for _, name := range allowed { allowedSet[name] = true } } out := make([]agents.FileAction, 0, len(GlobalSkills)) skillsDir := filepath.Join(userClaudeConfigDir(home), "skills") for name, content := range GlobalSkills { dir := filepath.Join(skillsDir, name) path := filepath.Join(dir, "SKILL.md") if allowedSet != nil && !allowedSet[name] { existing, err := os.ReadFile(path) if err != nil { // Not installed (or unreadable): nothing to prune. out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "outside-profile"}) continue } if string(existing) != content { logWarn(w, "keeping customised skill %s (outside the active profile)", path) out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "customised"}) continue } if opts.DryRun { out = append(out, agents.FileAction{Path: path, Action: agents.ActionWouldDelete, Keys: []string{"skill"}}) continue } if err := os.RemoveAll(dir); err != nil { return out, err } logf(w, "[gortex] removed skill %s (outside the active profile)", path) out = append(out, agents.FileAction{Path: path, Action: agents.ActionDelete, Keys: []string{"skill"}}) continue } action, err := agents.WriteIfNotExists(w, path, content, opts) if err != nil { return out, err } out = append(out, action) } return out, nil } // installGlobalSlashCommands writes ~/.claude/commands/gortex-*.md // for each entry in SlashCommands. Skips existing files so users // keep any local tweaks. Mirrors installGlobalSkills — both are // user-level, codebase-agnostic artifacts installed by // `gortex install`. func installGlobalSlashCommands(w io.Writer, home string, opts agents.ApplyOpts) ([]agents.FileAction, error) { out := make([]agents.FileAction, 0, len(SlashCommands)) dir := filepath.Join(userClaudeConfigDir(home), "commands") for name, content := range SlashCommands { path := filepath.Join(dir, name) action, err := agents.WriteIfNotExists(w, path, content, opts) if err != nil { return out, err } out = append(out, action) } return out, nil } // installGlobalSubAgents writes ~/.claude/agents/gortex-*.md for each // entry in SubAgents. Skips existing files so user tweaks to the // frontmatter (description tuning, tool allowlist edits) survive // re-installs. Mirrors installGlobalSkills / installGlobalSlashCommands. func installGlobalSubAgents(w io.Writer, home string, opts agents.ApplyOpts) ([]agents.FileAction, error) { out := make([]agents.FileAction, 0, len(SubAgents)) dir := filepath.Join(userClaudeConfigDir(home), "agents") for name, content := range SubAgents { path := filepath.Join(dir, name) action, err := agents.WriteIfNotExists(w, path, content, opts) if err != nil { return out, err } out = append(out, action) } return out, nil } // upsertGlobalMCPConfig is the user-level (~/.claude.json) MCP stanza // installer. Unlike the project-level .mcp.json we never write from // scratch with a static string here — we always merge so we don't // clobber a user's other MCP servers or their permissions. If the // existing file is malformed JSON, it's backed up before we // overwrite. func upsertGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) { // Prefer the bare "gortex" command when it resolves on PATH to the // binary we're running, so this user-scope entry matches the // portable project .mcp.json template byte-for-byte. Claude Code // keys OAuth tokens per endpoint, so a user-scope stanza that // disagrees with a project-scope one trips its "conflicting scopes" // diagnostic. Falls back to the absolute path only when gortex is // not on PATH (e.g. a Windows install whose dir isn't on PATH). entry := map[string]any{ "command": agents.ResolveGortexCommand(), "args": []string{"mcp"}, "env": map[string]any{}, } // Try a direct merge first. MergeJSON handles malformed JSON with a // timestamped backup already. UpsertMCPServerWithMigration rewrites // a stale Gortex-authored stanza (including the older absolute-path // form) in place without clobbering a user's hand-rolled wrapper. action, err := agents.MergeJSON(w, path, func(root map[string]any, existed bool) (bool, error) { _ = existed return agents.UpsertMCPServerWithMigration(root, "gortex", entry, agents.ApplyOpts{Force: opts.Force}), nil }, opts) if err != nil { return agents.FileAction{}, err } // Historical behaviour: if the file existed but was malformed, // rename it to a .bak- to preserve the original. MergeJSON // uses a plain ".bak" name; we mirror the timestamp-suffix // convention for global mode because the user is more likely // to have edited ~/.claude.json than a project .mcp.json. if existing, statErr := os.Stat(path + ".bak"); statErr == nil && !existing.IsDir() { if err := os.Rename(path+".bak", fmt.Sprintf("%s.bak-%d", path, time.Now().Unix())); err != nil { logWarn(w, "could not timestamp malformed-config backup: %v", err) } } return action, nil } // userScopeGortexRegistered reports whether ~/.claude.json already // registers a "gortex" MCP server at user scope. A project .mcp.json // written on top of that produces a second registration under the same // name, which Claude Code flags as a "conflicting scopes" warning // because it stores OAuth tokens per endpoint. A missing or malformed // file is treated as "not registered" — we only suppress the project // write on positive evidence of a user-scope entry. func userScopeGortexRegistered(home string) bool { data, err := os.ReadFile(userClaudeJSONPath(home)) if err != nil { return false } var root map[string]any if err := json.Unmarshal(data, &root); err != nil { return false } servers, ok := root["mcpServers"].(map[string]any) if !ok { return false } _, ok = servers["gortex"] return ok } // Paths — user-level files. func userClaudeJSONPath(home string) string { if dir := claudeConfigDirOverride(); dir != "" { return filepath.Join(dir, ".claude.json") } return filepath.Join(home, ".claude.json") } func userSettingsLocalPath(home string) string { return filepath.Join(userClaudeConfigDir(home), "settings.local.json") } // userSettingsPath is the user-level counterpart to // `.claude/settings.json` in a project. Permissions live here (not // in settings.local.json) so they survive when the user wipes the // "local" overrides file. func userSettingsPath(home string) string { return filepath.Join(userClaudeConfigDir(home), "settings.json") } // userClaudeMdPath is the machine-wide CLAUDE.md Claude Code reads on // every session, regardless of cwd. We merge a marker-fenced rule // block into it so the agent sees the Gortex rules from turn one. func userClaudeMdPath(home string) string { return filepath.Join(userClaudeConfigDir(home), "CLAUDE.md") } // UserClaudeMdPath is the resolved user-level CLAUDE.md path Claude // Code reads every session, honoring the --claude-config-dir override // / $CLAUDE_CONFIG_DIR / the ~/.claude default. Exported so the // installer banner names the real destination instead of a hardcoded // ~/.claude path. func UserClaudeMdPath(home string) string { return userClaudeMdPath(home) } func userClaudeConfigDir(home string) string { if dir := claudeConfigDirOverride(); dir != "" { return dir } return filepath.Join(home, ".claude") } // configDirOverride, when non-empty, pins the Claude Code config root // for the rest of the process regardless of $CLAUDE_CONFIG_DIR. It is // set by `gortex install --claude-config-dir` / `gortex uninstall // --global --claude-config-dir` so an operator can target a // non-active profile or a CI sandbox without exporting the env var. // Precedence: flag override > $CLAUDE_CONFIG_DIR > ~/.claude default. var configDirOverride string // SetConfigDirOverride pins the Claude Code config root for the rest // of this process. An empty string clears the override so the env var // / default resume. func SetConfigDirOverride(dir string) { configDirOverride = strings.TrimSpace(dir) } func claudeConfigDirOverride() string { if configDirOverride != "" { return configDirOverride } return strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")) } func logf(w io.Writer, format string, args ...any) { if w == nil { return } fmt.Fprintf(w, format+"\n", args...) } func logWarn(w io.Writer, format string, args ...any) { if w == nil { return } fmt.Fprintf(w, "[gortex init] warning: "+format+"\n", args...) }