Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 52 additions & 12 deletions cmd/gortex/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func init() {
initCmd.Flags().BoolVar(&initAnalyze, "analyze", false, "index the repo to generate a richer CLAUDE.md with codebase overview")
initCmd.Flags().BoolVar(&initInstallHooks, "hooks", true, "install supported agent hooks; use --no-hooks to skip")
initCmd.Flags().BoolVar(&initNoHooks, "no-hooks", false, "skip installing supported agent hooks (inverse of --hooks)")
initCmd.Flags().BoolVar(&initHooksOnly, "hooks-only", false, "only install/update Claude Code hooks in .claude/settings.local.json, skip everything else")
initCmd.Flags().BoolVar(&initHooksOnly, "hooks-only", false, "only install/update supported agent hooks, skip everything else")
initCmd.Flags().StringVar(&initHookMode, "hook-mode", "deny",
"hook posture: 'deny' (PreToolUse redirects Grep/Glob/Read of indexed source) or 'enrich' "+
"(PreToolUse never denies; PostToolUse appends graph context after the tool runs)")
Expand Down Expand Up @@ -200,19 +200,10 @@ func runInit(cmd *cobra.Command, args []string) (err error) {
}
}

// --hooks-only short-circuit: install/heal Claude Code hooks
// --hooks-only short-circuit: install/heal supported agent hooks
// and exit. Everything else is a no-op.
if initHooksOnly {
settingsPath := filepath.Join(absRoot, ".claude", "settings.local.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return err
}
action, err := claudecode.InstallHookWithMode(cmd.ErrOrStderr(), settingsPath, initHookMode, agents.ApplyOpts{DryRun: initDryRun, Force: initForce})
if err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "[gortex init --hooks-only] %s %s\n", action.Action, action.Path)
return nil
return runInitHooksOnly(cmd, absRoot)
}

realStderr := cmd.ErrOrStderr()
Expand Down Expand Up @@ -350,6 +341,55 @@ func runInit(cmd *cobra.Command, args []string) (err error) {
return nil
}

func runInitHooksOnly(cmd *cobra.Command, absRoot string) error {
opts := agents.ApplyOpts{DryRun: initDryRun, Force: initForce}

registry := buildRegistry()
selected, err := registry.Filter(initAgents, initAgentsSkip)
if err != nil {
return err
}
selectedAgents := make(map[string]bool, len(selected))
for _, a := range selected {
selectedAgents[a.Name()] = true
}

if selectedAgents[claudecode.Name] {
settingsPath := filepath.Join(absRoot, ".claude", "settings.local.json")
claudeAction, err := claudecode.InstallHookWithMode(cmd.ErrOrStderr(), settingsPath, initHookMode, opts)
if err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "[gortex init --hooks-only] %s %s\n", claudeAction.Action, claudeAction.Path)
}

home, _ := os.UserHomeDir()
if home == "" || !selectedAgents[codex.Name] {
return nil
}
env := agents.Env{
Root: absRoot,
Home: home,
HookCommand: claudecode.ResolveHookCommand(cmd.ErrOrStderr()),
Mode: agents.ModeProject,
InstallHooks: true,
HookMode: initHookMode,
Stderr: cmd.ErrOrStderr(),
}
detected, _ := codex.New().Detect(env)
if !detected {
return nil
}

codexPath := filepath.Join(home, ".codex", "config.toml")
codexAction, err := codex.InstallHooksOnly(cmd.ErrOrStderr(), codexPath, env, opts)
if err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "[gortex init --hooks-only] %s %s\n", codexAction.Action, codexAction.Path)
return nil
}

// toEnvSkills converts the skills generator's output into the
// agents.GeneratedSkill payload carried on Env. The two shapes are
// identical today; the mirror keeps the agents package free of the
Expand Down
250 changes: 250 additions & 0 deletions cmd/gortex/init_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"

toml "github.com/pelletier/go-toml/v2"
)

// TestInitDryRunJSONReportShape is the end-to-end contract test for
Expand Down Expand Up @@ -212,6 +214,211 @@ func TestInitDryRunSkipsProjectMarker(t *testing.T) {
}
}

func TestInitHooksOnlyRefreshesClaudeAndCodexHooks(t *testing.T) {
restore := saveInitGlobals(t)
defer restore()

repo := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)

codexDir := filepath.Join(home, ".codex")
if err := os.MkdirAll(codexDir, 0o755); err != nil {
t.Fatal(err)
}
codexConfig := filepath.Join(codexDir, "config.toml")
seed := `model = "gpt-5-codex"

[mcp_servers.gortex]
command = "custom-gortex"
args = ["mcp", "--custom"]
`
if err := os.WriteFile(codexConfig, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}

initYes = true
initHooksOnly = true
initHookMode = "enrich"
initDryRun = false
initJSON = false
initAgents = ""

var stdout, stderr bytes.Buffer
initCmd.SetOut(&stdout)
initCmd.SetErr(&stderr)
t.Cleanup(func() {
initCmd.SetOut(nil)
initCmd.SetErr(nil)
})

if err := runInit(initCmd, []string{repo}); err != nil {
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
}

if _, err := os.Stat(filepath.Join(repo, ".claude", "settings.local.json")); err != nil {
t.Fatalf("expected Claude Code hooks to be refreshed: %v", err)
}
cfg := readTOMLFile(t, codexConfig)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("Codex hooks-only clobbered model: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
gortexServer := servers["gortex"].(map[string]any)
if gortexServer["command"] != "custom-gortex" {
t.Fatalf("Codex hooks-only rewrote mcp_servers.gortex: %#v", gortexServer)
}
hooks, ok := cfg["hooks"].(map[string]any)
if !ok {
t.Fatalf("Codex hooks-only did not write hooks: %#v", cfg)
}
for _, event := range []string{"SessionStart", "PreToolUse", "PostToolUse"} {
if _, ok := hooks[event]; !ok {
t.Fatalf("Codex hooks-only missing %s hook: %#v", event, hooks)
}
}

for _, p := range []string{
filepath.Join(repo, ".gortex"),
filepath.Join(repo, "AGENTS.md"),
filepath.Join(repo, ".claude", "skills"),
filepath.Join(home, ".gortex", "config.yaml"),
} {
if _, err := os.Stat(p); err == nil {
t.Fatalf("hooks-only should not create %s", p)
}
}
}

func TestInitHooksOnlyRespectsAgentsAllowlist(t *testing.T) {
restore := saveInitGlobals(t)
defer restore()

repo := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}

initYes = true
initHooksOnly = true
initDryRun = false
initJSON = false
initAgents = "claude-code"

var stdout, stderr bytes.Buffer
initCmd.SetOut(&stdout)
initCmd.SetErr(&stderr)
t.Cleanup(func() {
initCmd.SetOut(nil)
initCmd.SetErr(nil)
})

if err := runInit(initCmd, []string{repo}); err != nil {
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
}

if _, err := os.Stat(filepath.Join(repo, ".claude", "settings.local.json")); err != nil {
t.Fatalf("expected Claude Code hooks to be refreshed: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "config.toml")); !os.IsNotExist(err) {
t.Fatalf("--agents=claude-code should not write Codex config, stat err=%v", err)
}
}

func TestInitHooksOnlyRespectsAgentsSkip(t *testing.T) {
restore := saveInitGlobals(t)
defer restore()

repo := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
codexDir := filepath.Join(home, ".codex")
if err := os.MkdirAll(codexDir, 0o755); err != nil {
t.Fatal(err)
}
codexConfig := filepath.Join(codexDir, "config.toml")
seed := `model = "gpt-5-codex"

[mcp_servers.gortex]
command = "custom-gortex"
`
if err := os.WriteFile(codexConfig, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}

initYes = true
initHooksOnly = true
initDryRun = false
initJSON = false
initAgentsSkip = "codex"

var stdout, stderr bytes.Buffer
initCmd.SetOut(&stdout)
initCmd.SetErr(&stderr)
t.Cleanup(func() {
initCmd.SetOut(nil)
initCmd.SetErr(nil)
})

if err := runInit(initCmd, []string{repo}); err != nil {
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
}

if _, err := os.Stat(filepath.Join(repo, ".claude", "settings.local.json")); err != nil {
t.Fatalf("expected Claude Code hooks to be refreshed: %v", err)
}
cfg := readTOMLFile(t, codexConfig)
if _, ok := cfg["hooks"]; ok {
t.Fatalf("--agents-skip=codex should not write Codex hooks: %#v", cfg["hooks"])
}
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("Codex config was unexpectedly rewritten: %#v", cfg)
}
}

func TestInitHooksOnlyDryRunDoesNotWriteHooks(t *testing.T) {
restore := saveInitGlobals(t)
defer restore()

repo := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}

initYes = true
initHooksOnly = true
initDryRun = true
initJSON = false
initAgents = ""

var stdout, stderr bytes.Buffer
initCmd.SetOut(&stdout)
initCmd.SetErr(&stderr)
t.Cleanup(func() {
initCmd.SetOut(nil)
initCmd.SetErr(nil)
})

if err := runInit(initCmd, []string{repo}); err != nil {
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
}

for _, p := range []string{
filepath.Join(repo, ".claude", "settings.local.json"),
filepath.Join(home, ".codex", "config.toml"),
filepath.Join(repo, ".gortex"),
filepath.Join(repo, "AGENTS.md"),
} {
if _, err := os.Stat(p); err == nil {
t.Fatalf("dry-run hooks-only should not write %s", p)
}
}
}

func TestInitDryRunIntakeJSONDoesNotWrite(t *testing.T) {
saved := struct {
yes, dryRun, json, dryRunIntake bool
Expand Down Expand Up @@ -263,3 +470,46 @@ func TestInitDryRunIntakeJSONDoesNotWrite(t *testing.T) {
t.Fatal("dry-run-intake wrote .gortex/ — must be inspection-only")
}
}

func saveInitGlobals(t *testing.T) func() {
t.Helper()
saved := struct {
analyze, installHooks, noHooks, hooksOnly bool
hookMode string
skills, noSkills bool
skillsMinSize, skillsMaxSkills int
yes, interactive bool
agents, agentsSkip string
json, dryRun, dryRunIntake, force bool
}{
initAnalyze, initInstallHooks, initNoHooks, initHooksOnly,
initHookMode,
initSkills, initNoSkills,
initSkillsMinSize, initSkillsMaxSkills,
initYes, initInteractive,
initAgents, initAgentsSkip,
initJSON, initDryRun, initDryRunIntake, initForce,
}
return func() {
initAnalyze, initInstallHooks, initNoHooks, initHooksOnly = saved.analyze, saved.installHooks, saved.noHooks, saved.hooksOnly
initHookMode = saved.hookMode
initSkills, initNoSkills = saved.skills, saved.noSkills
initSkillsMinSize, initSkillsMaxSkills = saved.skillsMinSize, saved.skillsMaxSkills
initYes, initInteractive = saved.yes, saved.interactive
initAgents, initAgentsSkip = saved.agents, saved.agentsSkip
initJSON, initDryRun, initDryRunIntake, initForce = saved.json, saved.dryRun, saved.dryRunIntake, saved.force
}
}

func readTOMLFile(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out map[string]any
if err := toml.Unmarshal(data, &out); err != nil {
t.Fatalf("parse %s: %v\n%s", path, err, data)
}
return out
}
2 changes: 1 addition & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ gortex init --agents=claude-code,cursor # allow-list
gortex init --agents-skip=antigravity # block-list
gortex init --dry-run --json # plan, emit JSON report
gortex init --force # overwrite merge-preserved keys
gortex init --hooks-only # refresh Claude Code hooks only
gortex init --hooks-only # refresh supported agent hooks only

# Observe-only
gortex init doctor # read-only state report
Expand Down
Loading
Loading