Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

838 lines
32 KiB
Go

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=<name> 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/<name>/); 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-<ts> 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...)
}