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

505 lines
17 KiB
Go

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/<DirName>/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)
}
}