package claudecode import ( "os" "path/filepath" "strings" "testing" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/agents/agentstest" ) // TestClaudeCodeProjectModeCreatesCanonicalArtifacts is the // acceptance test for the most important adapter. It asserts that // a fresh project gets: // - .mcp.json with our server stanza // - .claude/settings.json with MCP permissions // - .claude/settings.local.json with the three hook events // - CLAUDE.md with the marker-guarded communities block (since // the test env seeds SkillsRouting) // - .claude/skills/generated//SKILL.md (one per // GeneratedSkill) // // Slash commands and the curated GlobalSkills are NOT written in // project mode anymore — they're user-level artifacts installed by // `gortex install`. TestClaudeCodeGlobalModeWritesUserFiles covers // those. // // Re-running must be a no-op (idempotent contract). func TestClaudeCodeProjectModeCreatesCanonicalArtifacts(t *testing.T) { env, _ := agentstest.NewEnv(t) a := New() res, err := a.Apply(env, agents.ApplyOpts{}) if err != nil { t.Fatalf("apply: %v", err) } if !res.Configured { t.Fatal("expected Configured=true") } expected := []string{ filepath.Join(env.Root, ".mcp.json"), filepath.Join(env.Root, ".claude", "settings.json"), filepath.Join(env.Root, ".claude", "settings.local.json"), filepath.Join(env.Root, "CLAUDE.md"), } for _, s := range env.GeneratedSkills { expected = append(expected, filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md")) } for _, p := range expected { if _, err := os.Stat(p); err != nil { t.Errorf("missing artifact %s: %v", p, err) } } // Project-mode must NOT touch the user-level slash commands or // curated skills — those live in install mode now. for name := range SlashCommands { if _, err := os.Stat(filepath.Join(env.Home, ".claude", "commands", name)); err == nil { t.Errorf("project mode unexpectedly wrote user-level slash command %s", name) } if _, err := os.Stat(filepath.Join(env.Root, ".claude", "commands", name)); err == nil { t.Errorf("project mode unexpectedly wrote project-level slash command %s", name) } } for name := range GlobalSkills { if _, err := os.Stat(filepath.Join(env.Home, ".claude", "skills", name, "SKILL.md")); err == nil { t.Errorf("project mode unexpectedly wrote user-level skill %s", name) } } for name := range SubAgents { if _, err := os.Stat(filepath.Join(env.Home, ".claude", "agents", name)); err == nil { t.Errorf("project mode unexpectedly wrote user-level sub-agent %s", name) } if _, err := os.Stat(filepath.Join(env.Root, ".claude", "agents", name)); err == nil { t.Errorf("project mode unexpectedly wrote project-level sub-agent %s", name) } } // CLAUDE.md must contain the communities-block markers (since // the stub SkillsRouting routes through UpsertMarkedBlock). claudeMd, _ := os.ReadFile(filepath.Join(env.Root, "CLAUDE.md")) if !strings.Contains(string(claudeMd), agents.CommunitiesStartMarker) { t.Fatalf("CLAUDE.md missing communities start marker: %s", claudeMd) } // Hooks file must reference our test hook command. hooksFile, _ := os.ReadFile(filepath.Join(env.Root, ".claude", "settings.local.json")) if !strings.Contains(string(hooksFile), "PreToolUse") { t.Fatalf("settings.local.json missing PreToolUse: %s", hooksFile) } // Idempotent re-run: every file should report skip. res2, err := a.Apply(env, agents.ApplyOpts{}) if err != nil { t.Fatalf("second apply: %v", err) } for _, f := range res2.Files { if f.Action != agents.ActionSkip { t.Errorf("expected skip on re-run for %s, got %s", f.Path, f.Action) } } } // TestClaudeCodeGlobalModeWritesUserFiles verifies that global mode // (entered via `gortex install`) writes to ~/.claude.json, user-level // hooks, and the user-level slash-commands + curated skills trees, // while leaving the per-repo tree alone. func TestClaudeCodeGlobalModeWritesUserFiles(t *testing.T) { env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal env.InstallGlobalInstructions = true a := New() res, err := a.Apply(env, agents.ApplyOpts{}) if err != nil { t.Fatalf("apply: %v", err) } if !res.Configured { t.Fatal("expected Configured=true in global mode") } // User-level files exist. expected := []string{ filepath.Join(env.Home, ".claude.json"), filepath.Join(env.Home, ".claude", "settings.json"), filepath.Join(env.Home, ".claude", "settings.local.json"), } for name := range SlashCommands { expected = append(expected, filepath.Join(env.Home, ".claude", "commands", name)) } for name := range GlobalSkills { expected = append(expected, filepath.Join(env.Home, ".claude", "skills", name, "SKILL.md")) } for name := range SubAgents { expected = append(expected, filepath.Join(env.Home, ".claude", "agents", name)) } for _, p := range expected { if _, err := os.Stat(p); err != nil { t.Errorf("missing user-level artifact %s: %v", p, err) } } // settings.json must contain the mcp__gortex__* permission rule // so MCP tool calls don't prompt for approval each session. settingsPath := filepath.Join(env.Home, ".claude", "settings.json") body, err := os.ReadFile(settingsPath) if err != nil { t.Fatalf("read %s: %v", settingsPath, err) } if !strings.Contains(string(body), "mcp__gortex__*") { t.Errorf("expected mcp__gortex__* in %s, got:\n%s", settingsPath, body) } // CLAUDE.md must contain the marker-fenced rule block when // InstallGlobalInstructions is true. claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md") mdBody, err := os.ReadFile(claudeMd) if err != nil { t.Fatalf("read %s: %v", claudeMd, err) } if !strings.Contains(string(mdBody), agents.GlobalRulesStartMarker) || !strings.Contains(string(mdBody), agents.GlobalRulesEndMarker) { t.Errorf("expected gortex marker block in %s, got:\n%s", claudeMd, mdBody) } if !strings.Contains(string(mdBody), "MANDATORY: Use Gortex MCP tools") { t.Errorf("expected rule body in %s, got:\n%s", claudeMd, mdBody) } // Per-repo files should *not* exist under global mode. for _, p := range []string{ filepath.Join(env.Root, ".mcp.json"), filepath.Join(env.Root, "CLAUDE.md"), filepath.Join(env.Root, ".claude", "settings.local.json"), } { if _, err := os.Stat(p); err == nil { t.Errorf("global mode unexpectedly wrote per-repo file %s", p) } } } func TestClaudeCodeGlobalModeHonorsClaudeConfigDir(t *testing.T) { env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal env.InstallGlobalInstructions = true configDir := filepath.Join(t.TempDir(), "work-profile") t.Setenv("CLAUDE_CONFIG_DIR", configDir) a := New() res, err := a.Apply(env, agents.ApplyOpts{}) if err != nil { t.Fatalf("apply: %v", err) } if !res.Configured { t.Fatal("expected Configured=true in global mode") } expected := []string{ filepath.Join(configDir, ".claude.json"), filepath.Join(configDir, "settings.json"), filepath.Join(configDir, "settings.local.json"), filepath.Join(configDir, "CLAUDE.md"), } for name := range SlashCommands { expected = append(expected, filepath.Join(configDir, "commands", name)) } for name := range GlobalSkills { expected = append(expected, filepath.Join(configDir, "skills", name, "SKILL.md")) } for name := range SubAgents { expected = append(expected, filepath.Join(configDir, "agents", name)) } for _, p := range expected { if _, err := os.Stat(p); err != nil { t.Errorf("missing CLAUDE_CONFIG_DIR artifact %s: %v", p, err) } } for _, p := range []string{ filepath.Join(env.Home, ".claude.json"), filepath.Join(env.Home, ".claude"), } { if _, err := os.Stat(p); err == nil { t.Errorf("global mode unexpectedly wrote HOME artifact %s", p) } } plan, err := a.Plan(env) if err != nil { t.Fatalf("plan: %v", err) } for _, f := range plan.Files { if !strings.HasPrefix(f.Path, configDir+string(os.PathSeparator)) { t.Errorf("planned path %s did not use CLAUDE_CONFIG_DIR %s", f.Path, configDir) } } } // TestClaudeCodeConfigDirOverrideBeatsEnv verifies the explicit // override (set by `gortex install --claude-config-dir`) wins over // $CLAUDE_CONFIG_DIR, and that neither the env dir nor HOME is touched // when the override is active. func TestClaudeCodeConfigDirOverrideBeatsEnv(t *testing.T) { env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal env.InstallGlobalInstructions = true envDir := filepath.Join(t.TempDir(), "env-profile") overrideDir := filepath.Join(t.TempDir(), "flag-profile") t.Setenv("CLAUDE_CONFIG_DIR", envDir) SetConfigDirOverride(overrideDir) t.Cleanup(func() { SetConfigDirOverride("") }) a := New() if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { t.Fatalf("apply: %v", err) } for _, p := range []string{ filepath.Join(overrideDir, ".claude.json"), filepath.Join(overrideDir, "settings.json"), filepath.Join(overrideDir, "CLAUDE.md"), } { if _, err := os.Stat(p); err != nil { t.Errorf("missing override artifact %s: %v", p, err) } } if _, err := os.Stat(envDir); err == nil { t.Errorf("env-profile dir should be untouched when the override is set") } if _, err := os.Stat(filepath.Join(env.Home, ".claude")); err == nil { t.Errorf("HOME .claude should be untouched when the override is set") } } // TestClaudeCodeRemoveGlobal is the round-trip contract for `gortex // uninstall --global`: after an install, RemoveGlobal must delete the // owned skills/commands/agents and strip the Gortex portion of every // merged file while preserving the user's own content (other MCP // servers, personal CLAUDE.md prose). GlobalArtifacts must report an // empty footprint afterward. func TestClaudeCodeRemoveGlobal(t *testing.T) { env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal env.InstallGlobalInstructions = true configDir := filepath.Join(env.Home, ".claude") if err := os.MkdirAll(configDir, 0o755); err != nil { t.Fatal(err) } // A non-gortex MCP server and personal CLAUDE.md prose must survive. if err := os.WriteFile(filepath.Join(env.Home, ".claude.json"), []byte(`{"mcpServers":{"other":{"command":"x"}}}`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(configDir, "CLAUDE.md"), []byte("# My rules\n\nBe terse.\n"), 0o644); err != nil { t.Fatal(err) } a := New() if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { t.Fatalf("apply: %v", err) } if got := GlobalArtifacts(env.Home); len(got) == 0 { t.Fatal("expected a non-empty footprint after install") } removed, failures := a.RemoveGlobal(env, agents.ApplyOpts{}) if len(failures) != 0 { t.Fatalf("RemoveGlobal failures: %v", failures) } if removed == 0 { t.Fatal("expected RemoveGlobal to remove at least one artifact") } // Owned files deleted. for name := range GlobalSkills { if _, err := os.Stat(filepath.Join(configDir, "skills", name)); err == nil { t.Errorf("skill %s not removed", name) } } for name := range SlashCommands { if _, err := os.Stat(filepath.Join(configDir, "commands", name)); err == nil { t.Errorf("command %s not removed", name) } } for name := range SubAgents { if _, err := os.Stat(filepath.Join(configDir, "agents", name)); err == nil { t.Errorf("sub-agent %s not removed", name) } } // CLAUDE.md: gortex block stripped, personal prose preserved. md, _ := os.ReadFile(filepath.Join(configDir, "CLAUDE.md")) if strings.Contains(string(md), agents.GlobalRulesStartMarker) { t.Errorf("CLAUDE.md still carries the gortex marker:\n%s", md) } if !strings.Contains(string(md), "Be terse.") { t.Errorf("CLAUDE.md lost personal content:\n%s", md) } // settings.json: gortex permission gone. if settings, _ := os.ReadFile(filepath.Join(configDir, "settings.json")); strings.Contains(string(settings), "mcp__gortex__") { t.Errorf("settings.json still allows mcp__gortex__:\n%s", settings) } // settings.local.json: gortex hooks gone. if local, _ := os.ReadFile(filepath.Join(configDir, "settings.local.json")); strings.Contains(string(local), "gortex") { t.Errorf("settings.local.json still references gortex:\n%s", local) } // .claude.json: gortex server gone, the user's other server kept. mcp, _ := os.ReadFile(filepath.Join(env.Home, ".claude.json")) if strings.Contains(string(mcp), `"gortex"`) { t.Errorf(".claude.json still has the gortex server:\n%s", mcp) } if !strings.Contains(string(mcp), `"other"`) { t.Errorf(".claude.json lost the user's other server:\n%s", mcp) } // Nothing left to clean. if got := GlobalArtifacts(env.Home); len(got) != 0 { t.Errorf("GlobalArtifacts should be empty after RemoveGlobal, got %v", got) } } // TestClaudeCodeGlobalMode_NoClaudeMd skips the rule block when the // caller opts out via InstallGlobalInstructions=false (i.e. // `gortex install --no-claude-md`). func TestClaudeCodeGlobalMode_NoClaudeMd(t *testing.T) { env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal env.InstallGlobalInstructions = false a := New() if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { t.Fatalf("apply: %v", err) } claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md") if _, err := os.Stat(claudeMd); err == nil { t.Errorf("--no-claude-md ⇒ %s should not exist", claudeMd) } } // TestClaudeCodeGlobalMode_PreservesUserContent verifies the rule // block is merged with marker fences without clobbering anything // that already lives in ~/.claude/CLAUDE.md. func TestClaudeCodeGlobalMode_PreservesUserContent(t *testing.T) { env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal env.InstallGlobalInstructions = true claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md") if err := os.MkdirAll(filepath.Dir(claudeMd), 0o755); err != nil { t.Fatal(err) } pre := "# My personal Claude rules\n\nAlways respond in haiku.\n" if err := os.WriteFile(claudeMd, []byte(pre), 0o644); err != nil { t.Fatal(err) } a := New() if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { t.Fatalf("apply: %v", err) } body, err := os.ReadFile(claudeMd) if err != nil { t.Fatalf("read: %v", err) } if !strings.Contains(string(body), "Always respond in haiku.") { t.Errorf("user content was clobbered, got:\n%s", body) } if !strings.Contains(string(body), agents.GlobalRulesStartMarker) { t.Errorf("rule block missing, got:\n%s", body) } } // TestClaudeCodeDryRunWritesNothing is the contract for --dry-run: // Result must still classify every would-be write, but no bytes // touch disk. func TestClaudeCodeDryRunWritesNothing(t *testing.T) { env, _ := agentstest.NewEnv(t) a := New() res, err := a.Apply(env, agents.ApplyOpts{DryRun: true}) if err != nil { t.Fatalf("apply: %v", err) } if len(res.Files) == 0 { t.Fatal("dry-run should still enumerate planned files") } // No actual files created. for _, f := range res.Files { if _, err := os.Stat(f.Path); err == nil { t.Errorf("dry-run wrote %s", f.Path) } } } // TestProjectModeSkipsMCPWhenUserScopeRegistered is the regression // guard for issue #201: a project `gortex init` must NOT create a // project .mcp.json when gortex is already registered at user scope — // that double-registration is what trips Claude Code's "conflicting // scopes" diagnostic. --force overrides the skip (so a maintainer can // still commit a .mcp.json for teammates without a global install). func TestProjectModeSkipsMCPWhenUserScopeRegistered(t *testing.T) { SetConfigDirOverride("") env, buf := agentstest.NewEnv(t) a := New() // Seed a user-scope gortex registration in ~/.claude.json. claudeJSON := userClaudeJSONPath(env.Home) if err := os.MkdirAll(filepath.Dir(claudeJSON), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } seed := `{"mcpServers":{"gortex":{"command":"gortex","args":["mcp"],"env":{}}}}` if err := os.WriteFile(claudeJSON, []byte(seed), 0o644); err != nil { t.Fatalf("seed user config: %v", err) } mcpPath := filepath.Join(env.Root, ".mcp.json") // Without --force: the project .mcp.json must be skipped and the // user warned. if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { t.Fatalf("apply: %v", err) } if _, err := os.Stat(mcpPath); err == nil { t.Errorf("project .mcp.json was written despite a user-scope gortex registration") } if !strings.Contains(buf.String(), "already registered at user scope") { t.Errorf("expected a conflicting-scopes warning, got: %q", buf.String()) } // With --force: the project .mcp.json is written anyway. if _, err := a.Apply(env, agents.ApplyOpts{Force: true}); err != nil { t.Fatalf("apply --force: %v", err) } if _, err := os.Stat(mcpPath); err != nil { t.Errorf("--force should still write project .mcp.json: %v", err) } } // TestProjectModeWritesMCPWhenNoUserScope confirms the common path is // unchanged: with no user-scope gortex registration, the project // .mcp.json is created as before. func TestProjectModeWritesMCPWhenNoUserScope(t *testing.T) { SetConfigDirOverride("") env, _ := agentstest.NewEnv(t) a := New() if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { t.Fatalf("apply: %v", err) } if _, err := os.Stat(filepath.Join(env.Root, ".mcp.json")); err != nil { t.Errorf("project .mcp.json should be written when no user-scope entry exists: %v", err) } }