chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
// Package codex implements the Gortex init integration for the
|
||||
// OpenAI Codex CLI. Codex stores MCP server definitions in a TOML
|
||||
// file — ~/.codex/config.toml for the default scope — under the
|
||||
// [mcp_servers.<name>] table:
|
||||
//
|
||||
// [mcp_servers.gortex]
|
||||
// command = "gortex"
|
||||
// args = ["mcp", "--index", ".", "--watch"]
|
||||
// [mcp_servers.gortex.env]
|
||||
// GORTEX_INDEX_WORKERS = "8"
|
||||
//
|
||||
// Docs: https://github.com/openai/codex/blob/main/docs/config.md
|
||||
package codex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/internalutil"
|
||||
)
|
||||
|
||||
const Name = "codex"
|
||||
const DocsURL = "https://developers.openai.com/codex/mcp"
|
||||
|
||||
const codexSessionStartMatcher = "startup|resume|clear|compact"
|
||||
const codexSessionStartMessage = "IMPORTANT: Prefer Gortex MCP tools (search_symbols, get_callers, get_file_summary, edit_file) over Read/Grep/Glob/Edit."
|
||||
const codexSessionStartCommand = "printf '%s\\n' '" + codexSessionStartMessage + "'"
|
||||
const codexSessionStartWindowsCommand = "powershell -NoProfile -Command \"Write-Output '" + codexSessionStartMessage + "'\""
|
||||
const codexPreToolUseMatcher = "^Bash$"
|
||||
const codexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$"
|
||||
const codexPostToolUseMatcher = "^Bash$"
|
||||
const codexHookTimeoutSeconds = 5
|
||||
|
||||
type Adapter struct{}
|
||||
|
||||
func New() *Adapter { return &Adapter{} }
|
||||
func (a *Adapter) Name() string { return Name }
|
||||
func (a *Adapter) DocsURL() string { return DocsURL }
|
||||
|
||||
// Detect checks for the codex CLI on PATH or ~/.codex/.
|
||||
func (a *Adapter) Detect(env agents.Env) (bool, error) {
|
||||
if p, err := exec.LookPath("codex"); err == nil && p != "" {
|
||||
return true, nil
|
||||
}
|
||||
if env.Home == "" {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(env.Home, ".codex")); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
|
||||
p := &agents.Plan{}
|
||||
if env.Home != "" {
|
||||
keys := []string{"mcp_servers"}
|
||||
if env.InstallHooks {
|
||||
keys = append(keys, "hooks")
|
||||
}
|
||||
p.Files = append(p.Files, agents.FileAction{
|
||||
Path: filepath.Join(env.Home, ".codex", "config.toml"),
|
||||
Action: agents.ActionWouldMerge,
|
||||
Keys: keys,
|
||||
})
|
||||
}
|
||||
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
|
||||
p.Files = append(p.Files, agents.FileAction{
|
||||
Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge,
|
||||
Keys: []string{"communities-block"},
|
||||
})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
|
||||
res := &agents.Result{Name: Name, DocsURL: DocsURL}
|
||||
detected, _ := a.Detect(env)
|
||||
res.Detected = detected
|
||||
if !detected && !opts.ForceDetect {
|
||||
internalutil.Logf(env.Stderr, "[gortex init] skip Codex setup (codex not detected)")
|
||||
return res, nil
|
||||
}
|
||||
if env.Home == "" {
|
||||
return res, fmt.Errorf("codex: requires a resolved home directory")
|
||||
}
|
||||
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenAI Codex CLI integration...")
|
||||
|
||||
path := filepath.Join(env.Home, ".codex", "config.toml")
|
||||
action, err := agents.MergeTOML(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
|
||||
changed := false
|
||||
servers, ok := root["mcp_servers"].(map[string]any)
|
||||
if !ok {
|
||||
servers = make(map[string]any)
|
||||
}
|
||||
if _, exists := servers["gortex"]; !exists || opts.Force {
|
||||
servers["gortex"] = map[string]any{
|
||||
"command": "gortex",
|
||||
"args": []string{"mcp"},
|
||||
"env": map[string]any{
|
||||
"GORTEX_INDEX_WORKERS": "8",
|
||||
},
|
||||
}
|
||||
root["mcp_servers"] = servers
|
||||
changed = true
|
||||
}
|
||||
|
||||
if env.InstallHooks {
|
||||
if upsertCodexHooks(root, env, opts) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Files = append(res.Files, action)
|
||||
|
||||
// Repo-local community routing → AGENTS.md (also read by
|
||||
// OpenCode; both adapters upsert the same marker-guarded block,
|
||||
// so repeat runs converge). Skipped in global mode (AGENTS.md
|
||||
// is per-repo) and when no communities were generated.
|
||||
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
|
||||
agentsMdPath := filepath.Join(env.Root, "AGENTS.md")
|
||||
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMdPath, env.SkillsRouting,
|
||||
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Files = append(res.Files, routingAction)
|
||||
}
|
||||
|
||||
res.Configured = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func upsertSessionStartHook(root map[string]any, opts agents.ApplyOpts) bool {
|
||||
return upsertCodexHook(root, "SessionStart", codexHookEntryIsGortexSessionStart, codexSessionStartHookEntry(), opts)
|
||||
}
|
||||
|
||||
func upsertPreToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
|
||||
desired := []map[string]any{
|
||||
codexPreToolUseHookEntry(env),
|
||||
codexMCPReadPreToolUseHookEntry(env),
|
||||
}
|
||||
return upsertCodexHookSet(root, "PreToolUse", codexHookEntryIsGortexPreToolUse, desired, opts)
|
||||
}
|
||||
|
||||
func upsertPostToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
|
||||
return upsertCodexHook(root, "PostToolUse", codexHookEntryIsGortexPostToolUse, codexPostToolUseHookEntry(env), opts)
|
||||
}
|
||||
|
||||
func upsertUserPromptSubmitHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
|
||||
return upsertCodexHook(root, "UserPromptSubmit", codexHookEntryIsGortexUserPromptSubmit, codexUserPromptSubmitHookEntry(env), opts)
|
||||
}
|
||||
|
||||
// InstallHooksOnly refreshes the Codex lifecycle hooks in configPath without
|
||||
// touching MCP server entries, AGENTS.md, or any other Codex adapter surface.
|
||||
func InstallHooksOnly(w io.Writer, configPath string, env agents.Env, opts agents.ApplyOpts) (agents.FileAction, error) {
|
||||
action, err := agents.MergeTOML(w, configPath, func(root map[string]any, _ bool) (bool, error) {
|
||||
return upsertCodexHooks(root, env, opts), nil
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return agents.FileAction{}, err
|
||||
}
|
||||
if action.Action != agents.ActionSkip {
|
||||
action.Keys = []string{"hooks"}
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func upsertCodexHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
|
||||
sessionChanged := upsertSessionStartHook(root, opts)
|
||||
preChanged := upsertPreToolUseHook(root, env, opts)
|
||||
postChanged := upsertPostToolUseHook(root, env, opts)
|
||||
promptChanged := upsertUserPromptSubmitHook(root, env, opts)
|
||||
return sessionChanged || preChanged || postChanged || promptChanged
|
||||
}
|
||||
|
||||
func upsertCodexHook(root map[string]any, event string, isGortex func(any) bool, desired map[string]any, opts agents.ApplyOpts) bool {
|
||||
hooks, ok := root["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
if _, exists := root["hooks"]; exists {
|
||||
return false
|
||||
}
|
||||
hooks = make(map[string]any)
|
||||
}
|
||||
|
||||
entries, ok := codexHookList(hooks[event])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
found := false
|
||||
kept := make([]any, 0, len(entries)+1)
|
||||
for _, entry := range entries {
|
||||
if isGortex(entry) {
|
||||
found = true
|
||||
if opts.Force {
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
if found && !opts.Force {
|
||||
return false
|
||||
}
|
||||
|
||||
hooks[event] = append(kept, desired)
|
||||
root["hooks"] = hooks
|
||||
return true
|
||||
}
|
||||
|
||||
func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bool, desired []map[string]any, opts agents.ApplyOpts) bool {
|
||||
hooks, ok := root["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
if _, exists := root["hooks"]; exists {
|
||||
return false
|
||||
}
|
||||
hooks = make(map[string]any)
|
||||
}
|
||||
|
||||
entries, ok := codexHookList(hooks[event])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
found := make([]bool, len(desired))
|
||||
kept := make([]any, 0, len(entries)+len(desired))
|
||||
for _, entry := range entries {
|
||||
if isGortex(entry) {
|
||||
if opts.Force {
|
||||
continue
|
||||
}
|
||||
for i, want := range desired {
|
||||
if !found[i] && codexHookEntryMatchesDesired(entry, want) {
|
||||
found[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, want := range desired {
|
||||
if opts.Force || !found[i] {
|
||||
kept = append(kept, want)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
hooks[event] = kept
|
||||
root["hooks"] = hooks
|
||||
return true
|
||||
}
|
||||
|
||||
func codexHookEntryMatchesDesired(entry any, desired map[string]any) bool {
|
||||
matcher, _ := desired["matcher"].(string)
|
||||
return codexHookEntryHasMatcher(entry, matcher) && codexHookEntryInvokesCodexHook(entry)
|
||||
}
|
||||
|
||||
func codexHookEntryHasMatcher(entry any, matcher string) bool {
|
||||
group, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
got, _ := group["matcher"].(string)
|
||||
return got == matcher
|
||||
}
|
||||
|
||||
func codexHookList(v any) ([]any, bool) {
|
||||
if v == nil {
|
||||
return nil, true
|
||||
}
|
||||
switch list := v.(type) {
|
||||
case []any:
|
||||
return append([]any(nil), list...), true
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(list))
|
||||
for _, entry := range list {
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func codexHookEntryIsGortexSessionStart(entry any) bool {
|
||||
group, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
handlers, ok := codexHookList(group["hooks"])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
hm, ok := handler.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cmd, _ := hm["command"].(string); cmd == codexSessionStartCommand {
|
||||
return true
|
||||
}
|
||||
if cmd, _ := hm["command_windows"].(string); cmd == codexSessionStartWindowsCommand {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codexHookEntryIsGortexPreToolUse(entry any) bool {
|
||||
return codexHookEntryInvokesCodexHook(entry)
|
||||
}
|
||||
|
||||
func codexHookEntryIsGortexPostToolUse(entry any) bool {
|
||||
return codexHookEntryInvokesCodexHook(entry)
|
||||
}
|
||||
|
||||
func codexHookEntryIsGortexUserPromptSubmit(entry any) bool {
|
||||
return codexHookEntryInvokesCodexHook(entry)
|
||||
}
|
||||
|
||||
func codexHookEntryInvokesCodexHook(entry any) bool {
|
||||
group, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
handlers, ok := codexHookList(group["hooks"])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
hm, ok := handler.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cmd, _ := hm["command"].(string); codexCommandInvokesCodexHook(cmd) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codexCommandInvokesCodexHook(cmd string) bool {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(cmd)
|
||||
if !strings.Contains(lower, "gortex") || !strings.Contains(lower, "hook") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(cmd, "--agent=codex") || strings.Contains(cmd, "--agent codex")
|
||||
}
|
||||
|
||||
func codexSessionStartHookEntry() map[string]any {
|
||||
return map[string]any{
|
||||
"matcher": codexSessionStartMatcher,
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": codexSessionStartCommand,
|
||||
"command_windows": codexSessionStartWindowsCommand,
|
||||
"timeout": codexHookTimeoutSeconds,
|
||||
"statusMessage": "Loading Gortex graph orientation...",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func codexPreToolUseHookEntry(env agents.Env) map[string]any {
|
||||
return map[string]any{
|
||||
"matcher": codexPreToolUseMatcher,
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": codexPreToolUseCommand(env),
|
||||
"timeout": codexHookTimeoutSeconds,
|
||||
"statusMessage": "Loading Gortex Bash guidance...",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func codexMCPReadPreToolUseHookEntry(env agents.Env) map[string]any {
|
||||
return map[string]any{
|
||||
"matcher": codexMCPReadPreToolUseMatcher,
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": codexPreToolUseCommand(env),
|
||||
"timeout": codexHookTimeoutSeconds,
|
||||
"statusMessage": "Loading Gortex read guidance...",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func codexPostToolUseHookEntry(env agents.Env) map[string]any {
|
||||
return map[string]any{
|
||||
"matcher": codexPostToolUseMatcher,
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": codexHookCommand(env),
|
||||
"timeout": codexHookTimeoutSeconds,
|
||||
"statusMessage": "Loading Gortex Bash output context...",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// codexUserPromptSubmitHookEntry fires on every user turn — Codex's
|
||||
// UserPromptSubmit event takes no matcher (it can't filter by tool name), so
|
||||
// the entry omits one. The handler probes the graph for symbols relevant to
|
||||
// the prompt and injects them as additionalContext, re-surfacing Gortex on
|
||||
// every turn instead of relying on the SessionStart orientation to persist.
|
||||
func codexUserPromptSubmitHookEntry(env agents.Env) map[string]any {
|
||||
return map[string]any{
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": codexHookCommand(env),
|
||||
"timeout": codexHookTimeoutSeconds,
|
||||
"statusMessage": "Surfacing Gortex graph context for your prompt...",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func codexPreToolUseCommand(env agents.Env) string {
|
||||
return codexHookCommand(env)
|
||||
}
|
||||
|
||||
func codexHookCommand(env agents.Env) string {
|
||||
base := strings.TrimSpace(env.HookCommand)
|
||||
if base == "" {
|
||||
base = "gortex hook"
|
||||
}
|
||||
return base + " --agent=codex --mode=enrich"
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
package codex
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/agentstest"
|
||||
)
|
||||
|
||||
const testCodexHookCommand = "/tmp/test-gortex hook --agent=codex --mode=enrich"
|
||||
|
||||
// TestCodexWritesMcpServersTOMLTable verifies we produce the
|
||||
// documented [mcp_servers.gortex] table — not a legacy
|
||||
// [mcp.gortex] or [mcpServers.gortex].
|
||||
func TestCodexWritesMcpServersTOMLTable(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
// Detection sentinel: ~/.codex/ exists.
|
||||
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New()
|
||||
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
// Two creates: ~/.codex/config.toml for MCP plus AGENTS.md, the
|
||||
// per-repo instructions file Codex CLI reads on every task.
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(env.Home, ".codex", "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.toml: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
if !strings.Contains(got, "mcp_servers") {
|
||||
t.Fatalf("expected mcp_servers table: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "gortex") {
|
||||
t.Fatalf("expected gortex entry: %s", got)
|
||||
}
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("expected one Gortex SessionStart hook, got %d: %#v", count, cfg["hooks"])
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("expected one Gortex PostToolUse hook, got %d: %#v", count, cfg["hooks"])
|
||||
}
|
||||
|
||||
agentstest.AssertIdempotent(t, a, env)
|
||||
}
|
||||
|
||||
func TestCodexInstallsSessionStartHook(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
a := New()
|
||||
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
entries := sessionStartEntries(t, cfg)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("SessionStart entries=%d want 1: %#v", len(entries), entries)
|
||||
}
|
||||
entry := entries[0].(map[string]any)
|
||||
if entry["matcher"] != codexSessionStartMatcher {
|
||||
t.Fatalf("matcher=%v want %q", entry["matcher"], codexSessionStartMatcher)
|
||||
}
|
||||
handlers, ok := codexHookList(entry["hooks"])
|
||||
if !ok || len(handlers) != 1 {
|
||||
t.Fatalf("handlers=%#v", entry["hooks"])
|
||||
}
|
||||
handler := handlers[0].(map[string]any)
|
||||
if handler["type"] != "command" {
|
||||
t.Errorf("hook type=%v want command", handler["type"])
|
||||
}
|
||||
if handler["command"] != codexSessionStartCommand {
|
||||
t.Errorf("command=%v want %q", handler["command"], codexSessionStartCommand)
|
||||
}
|
||||
if handler["command_windows"] != codexSessionStartWindowsCommand {
|
||||
t.Errorf("command_windows=%v want %q", handler["command_windows"], codexSessionStartWindowsCommand)
|
||||
}
|
||||
command := handler["command"].(string)
|
||||
if !strings.Contains(command, "IMPORTANT: Prefer Gortex MCP tools") {
|
||||
t.Errorf("command should emit the graph-tools orientation: %v", handler["command"])
|
||||
}
|
||||
if !strings.Contains(command, "edit_file") {
|
||||
t.Errorf("command should mention edit_file: %v", handler["command"])
|
||||
}
|
||||
if strings.Contains(command, "[Gortex]") {
|
||||
t.Errorf("command should not duplicate the Gortex label: %v", handler["command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallsPreToolUseHook(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
a := New()
|
||||
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
entries := preToolUseEntries(t, cfg)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("PreToolUse entries=%d want Bash+MCP read entries: %#v", len(entries), entries)
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
|
||||
bashHandler := requireHookEntry(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand)
|
||||
mcpHandler := requireHookEntry(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand)
|
||||
for name, handler := range map[string]map[string]any{"Bash": bashHandler, "MCP read": mcpHandler} {
|
||||
if handler["type"] != "command" {
|
||||
t.Errorf("%s hook type=%v want command", name, handler["type"])
|
||||
}
|
||||
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
|
||||
t.Errorf("%s timeout=%v want %d", name, handler["timeout"], codexHookTimeoutSeconds)
|
||||
}
|
||||
}
|
||||
if bashHandler["statusMessage"] != "Loading Gortex Bash guidance..." {
|
||||
t.Errorf("Bash statusMessage=%v", bashHandler["statusMessage"])
|
||||
}
|
||||
if mcpHandler["statusMessage"] != "Loading Gortex read guidance..." {
|
||||
t.Errorf("MCP read statusMessage=%v", mcpHandler["statusMessage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallsPostToolUseHook(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
a := New()
|
||||
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
entries := postToolUseEntries(t, cfg)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("PostToolUse entries=%d want 1: %#v", len(entries), entries)
|
||||
}
|
||||
entry := entries[0].(map[string]any)
|
||||
if entry["matcher"] != codexPostToolUseMatcher {
|
||||
t.Fatalf("matcher=%v want %q", entry["matcher"], codexPostToolUseMatcher)
|
||||
}
|
||||
handlers, ok := codexHookList(entry["hooks"])
|
||||
if !ok || len(handlers) != 1 {
|
||||
t.Fatalf("handlers=%#v", entry["hooks"])
|
||||
}
|
||||
handler := handlers[0].(map[string]any)
|
||||
if handler["type"] != "command" {
|
||||
t.Errorf("hook type=%v want command", handler["type"])
|
||||
}
|
||||
command := handler["command"].(string)
|
||||
if command != testCodexHookCommand {
|
||||
t.Errorf("command=%v want test hook command with --agent=codex --mode=enrich", command)
|
||||
}
|
||||
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
|
||||
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallsUserPromptSubmitHook(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
a := New()
|
||||
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
entries := userPromptSubmitEntries(t, cfg)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("UserPromptSubmit entries=%d want 1: %#v", len(entries), entries)
|
||||
}
|
||||
entry := entries[0].(map[string]any)
|
||||
// UserPromptSubmit takes no matcher — Codex can't filter it by tool name.
|
||||
if _, hasMatcher := entry["matcher"]; hasMatcher {
|
||||
t.Fatalf("UserPromptSubmit entry should carry no matcher: %#v", entry)
|
||||
}
|
||||
handlers, ok := codexHookList(entry["hooks"])
|
||||
if !ok || len(handlers) != 1 {
|
||||
t.Fatalf("handlers=%#v", entry["hooks"])
|
||||
}
|
||||
handler := handlers[0].(map[string]any)
|
||||
if handler["type"] != "command" {
|
||||
t.Errorf("hook type=%v want command", handler["type"])
|
||||
}
|
||||
if handler["command"] != testCodexHookCommand {
|
||||
t.Errorf("command=%v want %q", handler["command"], testCodexHookCommand)
|
||||
}
|
||||
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
|
||||
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
|
||||
}
|
||||
if count := gortexUserPromptSubmitHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("Gortex UserPromptSubmit hooks=%d want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallHooksOnlyCreatesOnlyHooks(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
path := codexConfigPath(env)
|
||||
|
||||
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("install hooks only: %v", err)
|
||||
}
|
||||
if action.Action != agents.ActionCreate {
|
||||
t.Fatalf("action=%s want create", action.Action)
|
||||
}
|
||||
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
|
||||
t.Fatalf("keys=%#v want hooks only", action.Keys)
|
||||
}
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
if _, ok := cfg["mcp_servers"]; ok {
|
||||
t.Fatalf("hooks-only should not write mcp_servers: %#v", cfg["mcp_servers"])
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("hooks-only should not write AGENTS.md, stat err=%v", err)
|
||||
}
|
||||
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("SessionStart hooks=%d want 1", count)
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("PostToolUse hooks=%d want 1", count)
|
||||
}
|
||||
|
||||
action, err = InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("second install hooks only: %v", err)
|
||||
}
|
||||
if action.Action != agents.ActionSkip {
|
||||
t.Fatalf("second action=%s want skip", action.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallHooksOnlyPreservesExistingConfig(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
path := codexConfigPath(env)
|
||||
seed := `model = "gpt-5-codex"
|
||||
|
||||
[mcp_servers.gortex]
|
||||
command = "custom-gortex"
|
||||
args = ["mcp", "--custom"]
|
||||
|
||||
[mcp_servers.other]
|
||||
command = "other"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-pretooluse"
|
||||
statusMessage = "User PreToolUse"
|
||||
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-posttooluse"
|
||||
statusMessage = "User PostToolUse"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("install hooks only: %v", err)
|
||||
}
|
||||
if action.Action != agents.ActionMerge {
|
||||
t.Fatalf("action=%s want merge", action.Action)
|
||||
}
|
||||
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
|
||||
t.Fatalf("keys=%#v want hooks only", action.Keys)
|
||||
}
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
if cfg["model"] != "gpt-5-codex" {
|
||||
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
|
||||
}
|
||||
servers := cfg["mcp_servers"].(map[string]any)
|
||||
gortexServer := servers["gortex"].(map[string]any)
|
||||
if gortexServer["command"] != "custom-gortex" {
|
||||
t.Fatalf("hooks-only rewrote mcp_servers.gortex: %#v", gortexServer)
|
||||
}
|
||||
if _, ok := servers["other"]; !ok {
|
||||
t.Fatalf("existing MCP server was clobbered: %#v", servers)
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
|
||||
t.Fatalf("user PreToolUse hook was not preserved: %#v", preToolUseEntries(t, cfg))
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
|
||||
t.Fatalf("user PostToolUse hook was not preserved: %#v", postToolUseEntries(t, cfg))
|
||||
}
|
||||
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("SessionStart hooks=%d want 1", count)
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("PostToolUse hooks=%d want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallHooksOnlyForceReplacesOnlyGortexHooks(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
path := codexConfigPath(env)
|
||||
seed := `[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-pretooluse"
|
||||
statusMessage = "User PreToolUse"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
|
||||
statusMessage = "Old Gortex PreToolUse"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
|
||||
statusMessage = "Old Gortex MCP Read PreToolUse"
|
||||
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-posttooluse"
|
||||
statusMessage = "User PostToolUse"
|
||||
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
|
||||
statusMessage = "Old Gortex PostToolUse"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{Force: true}); err != nil {
|
||||
t.Fatalf("install hooks only: %v", err)
|
||||
}
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
|
||||
t.Fatalf("Force removed user PreToolUse hook: %#v", preToolUseEntries(t, cfg))
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
|
||||
t.Fatalf("Force removed user PostToolUse hook: %#v", postToolUseEntries(t, cfg))
|
||||
}
|
||||
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
|
||||
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
|
||||
}
|
||||
if hasHookCommand(t, cfg, "PostToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
|
||||
t.Fatalf("Force kept stale Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
|
||||
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PostToolUse", testCodexHookCommand) {
|
||||
t.Fatalf("Force did not install current Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexInstallHooksOnlyDryRunDoesNotWrite(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
path := codexConfigPath(env)
|
||||
|
||||
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("install hooks only dry-run: %v", err)
|
||||
}
|
||||
if action.Action != agents.ActionWouldCreate {
|
||||
t.Fatalf("action=%s want would-create", action.Action)
|
||||
}
|
||||
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
|
||||
t.Fatalf("keys=%#v want hooks only", action.Keys)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run should not write config.toml, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexPreToolUseCommandFallsBackToGortexHook(t *testing.T) {
|
||||
command := codexPreToolUseCommand(agents.Env{})
|
||||
if command != "gortex hook --agent=codex --mode=enrich" {
|
||||
t.Fatalf("fallback command=%q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexSessionStartHookIdempotent(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
a := New()
|
||||
|
||||
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
|
||||
t.Fatalf("first apply: %v", err)
|
||||
}
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionSkip: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("re-run duplicated Gortex SessionStart hook: got %d", count)
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("re-run duplicated Gortex PostToolUse hook: got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexSessionStartHookPreservesExistingConfig(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
path := codexConfigPath(env)
|
||||
seed := `model = "gpt-5-codex"
|
||||
|
||||
[mcp_servers.other]
|
||||
command = "other"
|
||||
|
||||
[[hooks.SessionStart]]
|
||||
matcher = "startup"
|
||||
|
||||
[[hooks.SessionStart.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-session-start"
|
||||
statusMessage = "User hook"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-pretooluse"
|
||||
statusMessage = "User PreToolUse"
|
||||
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-posttooluse"
|
||||
statusMessage = "User PostToolUse"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
a := New()
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
if cfg["model"] != "gpt-5-codex" {
|
||||
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
|
||||
}
|
||||
servers := cfg["mcp_servers"].(map[string]any)
|
||||
if _, ok := servers["other"]; !ok {
|
||||
t.Fatalf("existing MCP server was clobbered: %#v", servers)
|
||||
}
|
||||
if _, ok := servers["gortex"]; !ok {
|
||||
t.Fatalf("gortex MCP server missing after merge: %#v", servers)
|
||||
}
|
||||
entries := sessionStartEntries(t, cfg)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("SessionStart entries=%d want user+gortex entries: %#v", len(entries), entries)
|
||||
}
|
||||
if !hasSessionStartCommand(t, cfg, "echo user-session-start") {
|
||||
t.Fatalf("user SessionStart hook was not preserved: %#v", entries)
|
||||
}
|
||||
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("Gortex SessionStart hooks=%d want 1", count)
|
||||
}
|
||||
preEntries := preToolUseEntries(t, cfg)
|
||||
if len(preEntries) != 3 {
|
||||
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
|
||||
t.Fatalf("user PreToolUse hook was not preserved: %#v", preEntries)
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
postEntries := postToolUseEntries(t, cfg)
|
||||
if len(postEntries) != 2 {
|
||||
t.Fatalf("PostToolUse entries=%d want user+gortex entries: %#v", len(postEntries), postEntries)
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
|
||||
t.Fatalf("user PostToolUse hook was not preserved: %#v", postEntries)
|
||||
}
|
||||
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
|
||||
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexForceReplacesOnlyGortexPreToolUseHook(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
path := codexConfigPath(env)
|
||||
seed := `[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo user-pretooluse"
|
||||
statusMessage = "User PreToolUse"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
|
||||
statusMessage = "Old Gortex PreToolUse"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
|
||||
statusMessage = "Old Gortex MCP Read PreToolUse"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
a := New()
|
||||
res, err := a.Apply(env, agents.ApplyOpts{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
|
||||
|
||||
cfg := readCodexConfig(t, env)
|
||||
preEntries := preToolUseEntries(t, cfg)
|
||||
if len(preEntries) != 3 {
|
||||
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
|
||||
t.Fatalf("Force removed user PreToolUse hook: %#v", preEntries)
|
||||
}
|
||||
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
|
||||
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preEntries)
|
||||
}
|
||||
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
|
||||
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preEntries)
|
||||
}
|
||||
assertGortexPreToolUseHooks(t, cfg)
|
||||
}
|
||||
|
||||
func TestCodexNoHooksSkipsSessionStartHook(t *testing.T) {
|
||||
env := codexGlobalEnv(t)
|
||||
env.InstallHooks = false
|
||||
a := New()
|
||||
|
||||
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
cfg := readCodexConfig(t, env)
|
||||
if _, ok := cfg["hooks"]; ok {
|
||||
t.Fatalf("--no-hooks should not write Codex hooks: %#v", cfg["hooks"])
|
||||
}
|
||||
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
|
||||
t.Fatal("mcp_servers.gortex should still be written under --no-hooks")
|
||||
}
|
||||
|
||||
plan, err := a.Plan(env)
|
||||
if err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
if len(plan.Files) != 1 {
|
||||
t.Fatalf("plan files=%d want 1", len(plan.Files))
|
||||
}
|
||||
for _, key := range plan.Files[0].Keys {
|
||||
if key == "hooks" {
|
||||
t.Fatalf("Plan should not report hooks under --no-hooks: %#v", plan.Files[0].Keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func codexGlobalEnv(t *testing.T) agents.Env {
|
||||
t.Helper()
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
env.Mode = agents.ModeGlobal
|
||||
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func codexConfigPath(env agents.Env) string {
|
||||
return filepath.Join(env.Home, ".codex", "config.toml")
|
||||
}
|
||||
|
||||
func readCodexConfig(t *testing.T, env agents.Env) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(codexConfigPath(env))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.toml: %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := toml.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("parse config.toml: %v\n%s", err, data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sessionStartEntries(t *testing.T, cfg map[string]any) []any {
|
||||
t.Helper()
|
||||
return hookEntries(t, cfg, "SessionStart")
|
||||
}
|
||||
|
||||
func preToolUseEntries(t *testing.T, cfg map[string]any) []any {
|
||||
t.Helper()
|
||||
return hookEntries(t, cfg, "PreToolUse")
|
||||
}
|
||||
|
||||
func postToolUseEntries(t *testing.T, cfg map[string]any) []any {
|
||||
t.Helper()
|
||||
return hookEntries(t, cfg, "PostToolUse")
|
||||
}
|
||||
|
||||
func userPromptSubmitEntries(t *testing.T, cfg map[string]any) []any {
|
||||
t.Helper()
|
||||
return hookEntries(t, cfg, "UserPromptSubmit")
|
||||
}
|
||||
|
||||
func hookEntries(t *testing.T, cfg map[string]any, event string) []any {
|
||||
t.Helper()
|
||||
hooks, ok := cfg["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing hooks map: %#v", cfg)
|
||||
}
|
||||
entries, ok := codexHookList(hooks[event])
|
||||
if !ok {
|
||||
t.Fatalf("hooks.%s has unexpected shape: %#v", event, hooks[event])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func gortexSessionStartHookCount(t *testing.T, cfg map[string]any) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for _, entry := range sessionStartEntries(t, cfg) {
|
||||
if codexHookEntryIsGortexSessionStart(entry) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func gortexPreToolUseHookCount(t *testing.T, cfg map[string]any) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for _, entry := range preToolUseEntries(t, cfg) {
|
||||
if codexHookEntryIsGortexPreToolUse(entry) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func assertGortexPreToolUseHooks(t *testing.T, cfg map[string]any) {
|
||||
t.Helper()
|
||||
if count := gortexPreToolUseHookCount(t, cfg); count != 2 {
|
||||
t.Fatalf("Gortex PreToolUse hooks=%d want Bash+MCP read hooks: %#v", count, preToolUseEntries(t, cfg))
|
||||
}
|
||||
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand); count != 1 {
|
||||
t.Fatalf("Bash PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
|
||||
}
|
||||
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand); count != 1 {
|
||||
t.Fatalf("MCP read PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
func gortexPostToolUseHookCount(t *testing.T, cfg map[string]any) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for _, entry := range postToolUseEntries(t, cfg) {
|
||||
if codexHookEntryIsGortexPostToolUse(entry) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func gortexUserPromptSubmitHookCount(t *testing.T, cfg map[string]any) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for _, entry := range userPromptSubmitEntries(t, cfg) {
|
||||
if codexHookEntryIsGortexUserPromptSubmit(entry) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func requireHookEntry(t *testing.T, cfg map[string]any, event, matcher, command string) map[string]any {
|
||||
t.Helper()
|
||||
for _, entry := range hookEntries(t, cfg, event) {
|
||||
group, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
gotMatcher, _ := group["matcher"].(string)
|
||||
if gotMatcher != matcher {
|
||||
continue
|
||||
}
|
||||
handlers, ok := codexHookList(group["hooks"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
hm, ok := handler.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got, _ := hm["command"].(string); got == command {
|
||||
return hm
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s hook matcher=%q command=%q in %#v", event, matcher, command, hookEntries(t, cfg, event))
|
||||
return nil
|
||||
}
|
||||
|
||||
func hookMatcherCommandCount(t *testing.T, cfg map[string]any, event, matcher, command string) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for _, entry := range hookEntries(t, cfg, event) {
|
||||
group, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
gotMatcher, _ := group["matcher"].(string)
|
||||
if gotMatcher != matcher {
|
||||
continue
|
||||
}
|
||||
handlers, ok := codexHookList(group["hooks"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
hm, ok := handler.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got, _ := hm["command"].(string); got == command {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func hasSessionStartCommand(t *testing.T, cfg map[string]any, command string) bool {
|
||||
t.Helper()
|
||||
return hasHookCommand(t, cfg, "SessionStart", command)
|
||||
}
|
||||
|
||||
func hasHookCommand(t *testing.T, cfg map[string]any, event string, command string) bool {
|
||||
t.Helper()
|
||||
for _, entry := range hookEntries(t, cfg, event) {
|
||||
group, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
handlers, ok := codexHookList(group["hooks"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
hm, ok := handler.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got, _ := hm["command"].(string); got == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user