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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ Local settings override project settings field-by-field. When you run `entire st

### Agent-Specific Steps & Limitations

- When enabling Entire for Codex, the command will also create or update `.codex/config.toml` with `hooks = true` to enable Codex hooks. If you configure Codex manually, make sure this flag is set in your `.codex/config.toml`. Or select Codex from the interactive agent picker when running `entire enable`.
- Codex hooks are enabled by default (codex-cli 0.124.0+), so enabling Entire for Codex only installs `.codex/hooks.json` — no `config.toml` is needed and Entire never creates one. If an older Entire version left a `.codex/config.toml` behind and your repo lives inside `~/.codex/agents`, delete that file to stop Codex's "malformed agent role definition" startup warning.
- Entire supports Cursor IDE and Cursor Agent CLI tool, but `entire rewind` is not available at this time. Other commands (`doctor`, `status` etc.) work the same as all other agents.
- Entire supports Copilot CLI, but not Copilot in VS Code, in other IDEs, or on github.com.
- Entire supports Pi coding agent (Preview). Pi uses a TypeScript extension instead of a JSON hook config. Subagent capture is not currently available.
Expand Down
97 changes: 5 additions & 92 deletions cmd/entire/cli/agent/codex/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
Expand Down Expand Up @@ -118,11 +117,6 @@ func (c *CodexAgent) InstallHooks(ctx context.Context, localDev bool, force bool
}

if count == 0 {
// Still ensure the feature flag is configured even if hooks
// were already present (e.g., manually installed).
if err := ensureProjectFeatureEnabled(repoRoot); err != nil {
return 0, fmt.Errorf("failed to enable codex_hooks feature: %w", err)
}
return 0, nil
}

Expand Down Expand Up @@ -158,12 +152,11 @@ func (c *CodexAgent) InstallHooks(ctx context.Context, localDev bool, force bool
return 0, fmt.Errorf("failed to write hooks.json: %w", err)
}

// Enable the codex_hooks feature in the project-level .codex/config.toml.
// This keeps the feature flag per-repo rather than global.
if err := ensureProjectFeatureEnabled(repoRoot); err != nil {
return count, fmt.Errorf("failed to enable codex_hooks feature: %w", err)
}

// No .codex/config.toml is written: hooks are enabled by default in
// Codex (since 0.124.0), and a TOML file inside Codex's reserved
// <CODEX_HOME>/agents tree would be rejected by its agent-role scanner
// at every startup (entireio/cli#842). A leftover config.toml written
// by an older entire version must be removed manually.
return count, nil
}

Expand Down Expand Up @@ -358,83 +351,3 @@ func removeEntireHooks(groups []MatcherGroup) []MatcherGroup {
}
return result
}

// configFileName is the Codex config file name.
const configFileName = "config.toml"

// featureLine is the TOML line that enables the hooks feature. The flag was
// renamed from `codex_hooks` to `hooks` in Codex 0.129.0; the old name is
// still accepted as a legacy alias but emits a deprecation warning at
// every startup. ensureProjectFeatureEnabled rewrites the legacy form when
// it sees it.
const (
featureLine = "hooks = true"
legacyFeatureLine = "codex_hooks = true"
)

// ensureProjectFeatureEnabled writes features.hooks = true to the
// project-level .codex/config.toml. This keeps the feature flag per-repo.
// Replaces the deprecated codex_hooks = true line if it's present.
func ensureProjectFeatureEnabled(repoRoot string) error {
configPath := filepath.Join(repoRoot, ".codex", configFileName)

data, err := os.ReadFile(configPath) //nolint:gosec // path constructed from repo root
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read config.toml: %w", err)
}

content := string(data)
hasNew := containsFeatureLine(content, featureLine)
hasLegacy := containsFeatureLine(content, legacyFeatureLine)
switch {
case hasNew && hasLegacy:
content = stripLegacyFeatureLine(content)
case hasNew:
return nil
case hasLegacy:
content = strings.Replace(content, legacyFeatureLine, featureLine, 1)
case strings.Contains(content, "[features]"):
content = strings.Replace(content, "[features]", "[features]\n"+featureLine, 1)
default:
if len(content) > 0 && !strings.HasSuffix(content, "\n") {
content += "\n"
}
content += "\n[features]\n" + featureLine + "\n"
}

if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil {
return fmt.Errorf("failed to create .codex directory: %w", err)
}
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { //nolint:gosec // path constructed from repo root
return fmt.Errorf("failed to write config.toml: %w", err)
}
return nil
}

// containsFeatureLine checks for an exact line match. A plain
// strings.Contains is wrong because "hooks = true" is a substring of
// "codex_hooks = true" — without the line-boundary anchor we'd treat the
// legacy form as if the new form was already present.
func containsFeatureLine(content, line string) bool {
for _, raw := range strings.Split(content, "\n") {
if strings.TrimSpace(raw) == line {
return true
}
}
return false
}

// stripLegacyFeatureLine removes the deprecated `codex_hooks = true` line
// from a TOML config string, dropping a trailing blank line so the file
// stays tidy. The new `hooks = true` is added separately by the caller.
func stripLegacyFeatureLine(content string) string {
idx := strings.Index(content, legacyFeatureLine)
if idx < 0 {
return content
}
end := idx + len(legacyFeatureLine)
if end < len(content) && content[end] == '\n' {
end++
}
return content[:idx] + content[end:]
}
73 changes: 38 additions & 35 deletions cmd/entire/cli/agent/codex/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func setupTestEnv(t *testing.T) string {
return tempDir
}

func TestInstallHooks_CreatesConfig(t *testing.T) {
func TestInstallHooks_CreatesHooksJSONOnly(t *testing.T) {
tempDir := setupTestEnv(t)

ag := &CodexAgent{}
Expand All @@ -42,14 +42,13 @@ func TestInstallHooks_CreatesConfig(t *testing.T) {
assertHookCommand(t, hooksFile.Hooks.Stop, agentpkg.WrapProductionSilentHookCommand("entire hooks codex stop"), "Stop")
assertHookCommand(t, hooksFile.Hooks.PostToolUse, agentpkg.WrapProductionSilentHookCommand("entire hooks codex post-tool-use"), "PostToolUse")

// Verify project-level config.toml enables the hooks feature (per-repo)
projectConfig := filepath.Join(tempDir, ".codex", configFileName)
projectData, err := os.ReadFile(projectConfig)
require.NoError(t, err)
require.Contains(t, string(projectData), "hooks = true")
require.NotContains(t, string(projectData), "codex_hooks = true",
"deprecated codex_hooks line must not be written by fresh installs")
require.Contains(t, string(projectData), "[features]")
// Hooks are enabled by default in Codex, so no .codex/config.toml is
// written. A TOML file there is actively harmful when the repo lives
Comment thread
gtrrz-victor marked this conversation as resolved.
// inside <CODEX_HOME>/agents, where Codex's agent-role scanner rejects
// it at startup (entireio/cli#842).
projectConfig := filepath.Join(tempDir, ".codex", "config.toml")
_, err = os.Stat(projectConfig)
require.True(t, os.IsNotExist(err), "install must not create .codex/config.toml")
}

func TestInstallHooks_WindowsWrapperProbeSuccessKeepsWrappedCommands(t *testing.T) {
Expand Down Expand Up @@ -350,42 +349,46 @@ func TestInstallHooks_DoesNotModifyUserConfig(t *testing.T) {

require.NoError(t, os.MkdirAll(codexHome, 0o750))
existingConfig := "model = \"gpt-4.1\"\n"
require.NoError(t, os.WriteFile(filepath.Join(codexHome, configFileName), []byte(existingConfig), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(codexHome, "config.toml"), []byte(existingConfig), 0o600))

ag := &CodexAgent{}
_, err := ag.InstallHooks(context.Background(), false, false)
require.NoError(t, err)

configData, err := os.ReadFile(filepath.Join(codexHome, configFileName))
configData, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
require.NoError(t, err)
require.Contains(t, string(configData), "model = \"gpt-4.1\"")
require.NotContains(t, string(configData), `trust_level = "trusted"`)
}

// TestInstallHooks_RewritesLegacyFeatureLine pins the rule that an existing
// `codex_hooks = true` line — written by older entire CLI versions — must
// be rewritten to the new `hooks = true` form on the next install. Codex
// 0.129.0 still accepts the legacy alias but prints a deprecation warning
// at every startup; rewriting silences it without forcing the user to
// touch their .codex/config.toml.
func TestInstallHooks_RewritesLegacyFeatureLine(t *testing.T) {
tempDir := setupTestEnv(t)

codexDir := filepath.Join(tempDir, ".codex")
require.NoError(t, os.MkdirAll(codexDir, 0o750))
existingConfig := "[features]\ncodex_hooks = true\n"
configPath := filepath.Join(codexDir, configFileName)
require.NoError(t, os.WriteFile(configPath, []byte(existingConfig), 0o600))

ag := &CodexAgent{}
_, err := ag.InstallHooks(context.Background(), false, false)
require.NoError(t, err)

configData, err := os.ReadFile(configPath)
require.NoError(t, err)
require.Contains(t, string(configData), "hooks = true")
require.NotContains(t, string(configData), "codex_hooks = true",
"legacy codex_hooks line must be replaced, not left alongside the new form")
// TestInstallHooks_LeavesExistingLocalConfigUntouched pins that install
// never reads, rewrites, or deletes a project-local .codex/config.toml —
// whether it's a user's own file or a feature-flag leftover from an older
// entire version. The CLI no longer manages that file at all; leftovers
// under <CODEX_HOME>/agents must be removed manually (entireio/cli#842).
func TestInstallHooks_LeavesExistingLocalConfigUntouched(t *testing.T) {
contents := map[string]string{
"old entire leftover": "[features]\nhooks = true\n",
"user file": "model = \"gpt-4.1\"\n",
}
for name, content := range contents {
t.Run(name, func(t *testing.T) {
tempDir := setupTestEnv(t)

codexDir := filepath.Join(tempDir, ".codex")
require.NoError(t, os.MkdirAll(codexDir, 0o750))
configPath := filepath.Join(codexDir, "config.toml")
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600))

ag := &CodexAgent{}
_, err := ag.InstallHooks(context.Background(), false, false)
require.NoError(t, err)

data, err := os.ReadFile(configPath)
require.NoError(t, err)
require.Equal(t, content, string(data), "install must not touch an existing .codex/config.toml")
})
}
}

// assertHookCommand verifies that one of the hook entries in groups contains the expected command.
Expand Down
Loading