diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 171599f..b4c16dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.5" + GO_VERSION: "1.26.6" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index a863797..63e5c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,44 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). This project ad - asciinema export — `trace session export --format asciinema` renders a transcript as a playable v2 cast. ### Changed +- **README truth fixes** — Quick Start now describes both composition + surfaces accurately (library embedded as `hawk trace ...`, plus the + buildable-but-unreleased `cmd/trace` standalone entrypoint), workflow + examples note the `hawk trace` prefix, the Configuration section documents + the legacy `.entire/` read compat, and the Development section points at + `docs/architecture.md` instead of the GitNexus-boilerplate CLAUDE.md. +- **ogen generator pin aligned with go.mod** — the `//go:generate` directive + in `internal/coreapi/gen.go` now pins ogen@v1.23.0 (go.mod requires + v1.23.0; the directive previously pinned v1.20.3). Regenerating the + oas_*.go files is still pending (large diff, tracked separately). +- **Settings moved to `.trace/` with a read-compat shim** — the canonical + settings files are now `.trace/settings.json` and + `.trace/settings.local.json` (matching the README), replacing the + pre-rebrand `.entire/` locations. Reads fall back to the legacy `.entire/` + files when the canonical ones are absent (including setup detection via + `IsSetUp`/`IsSetUpAny` and the raw read-modify-write API), and the first + save copies the legacy content to the canonical location — a lazy, + non-destructive migration that never deletes or rewrites the legacy files. + Session data keeps using `.entire/metadata` on the checkpoint branch, and + both directories are excluded from checkpoints and cleaned up by + `uninstall --force`. +- **Hook binary resolution survives the rebrand and hawk embedding** — the + default git-hook prefix and the production wrapper probes no longer hardcode + the literal `entire`. They resolve through the new `agent.HookCommandPrefix` + in priority order: the basename of the running executable when it is a known + name (`trace`, `entire`, or `hawk`, which embeds this CLI as `hawk trace`), + then `trace` when reachable on PATH, then legacy `entire`, with `entire` as + the final fallback so already-installed setups keep working. Under hawk + distributions (no `entire` on PATH) wrappers now probe and exec `hawk trace` + instead of silently exiting and disabling capture. Wrapper recognition + (uninstall/upgrade) accepts every binary-name form, including wrappers + installed by older builds. +- **Root command rebranded to `trace`** — `cli.NewRootCmd()` now reports + `Use: "trace"` (was `entire`), `Short` matches the README tagline ("Git-native + session capture for AI coding agents"), the getting-started help no longer + points at the defunct `docs.entire.io` docs, the version banner reads + `Trace CLI`, hidden-alias deprecation hints name `trace …` commands, and the + agent-help drill-down pointer derives from the live root command name. - **Version re-baselined to `0.1.0`** in `cli/versioninfo/versioninfo.go`. Aligns trace with the rest of the hawk-eco ecosystem (`hawk`, `tok`, `eyrie`, `yaad`, `sight`, diff --git a/README.md b/README.md index 809a6e2..64f7304 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ individual strategy, checkpoint, agent, storage, or UI packages. Changes to ## Quick Start -Trace is a **library**, not a standalone binary. Its full command tree is built by -`cli.NewRootCmd()` and surfaced inside the **Hawk** CLI as `hawk trace ...` (Hawk is in -development — no public install yet). There is no separate `trace` binary to install. +Trace is a **library first**: its full command tree is built by `cli.NewRootCmd()` +and surfaced inside the **Hawk** CLI as `hawk trace ...` (Hawk is in development — +no public install yet). A minimal standalone entrypoint also exists at +`cmd/trace/main.go`; it is not built or released by the Makefile, but contributors +can produce a local `trace` binary with `go build ./cmd/trace`. **Contributors — build/test the library from source:** @@ -118,6 +120,9 @@ Your Branch trace/checkpoints/v1 ## Typical Workflow +The examples below use the standalone `trace` binary; under Hawk the same +commands run as `hawk trace ...`. + ### 1. Enable ```bash @@ -205,7 +210,10 @@ Run `trace --help` for detailed usage. ## Configuration -Trace stores config in `.trace/` at the repo root. +Trace stores config in `.trace/` at the repo root. Repositories set up before +the rebrand keep working: a legacy `.entire/settings.json` (or +`.entire/settings.local.json`) is still read when the `.trace/` file is absent, +and its content is copied to the new location on the first save. ### Project settings (`.trace/settings.json`) @@ -315,7 +323,7 @@ mise run test:ci mise run fmt && mise run lint ``` -See [CLAUDE.md](CLAUDE.md) for architecture details. +See [docs/architecture.md](docs/architecture.md) for architecture details. --- diff --git a/cli/agent/binary.go b/cli/agent/binary.go new file mode 100644 index 0000000..122a875 --- /dev/null +++ b/cli/agent/binary.go @@ -0,0 +1,154 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Binary names for hook-command generation and recognition. +// +// These live in the shared agent package (not package cli) because both the +// strategy layer (git hooks) and every agent integration (wrapper probes) +// need them, while package cli imports both of those and cannot be imported +// by them without a cycle. +const ( + // BinaryName is the canonical name of this CLI's binary. It is the name + // hooks assume when nothing more specific can be resolved, and the name + // the root cobra command reports (see cli.NewRootCmd). + BinaryName = "trace" + + // LegacyBinaryName is the pre-rebrand binary name. Hook commands and + // config files installed by older builds still reference it, so it is + // kept as a recognition and fallback name. + LegacyBinaryName = "entire" + + // EmbeddedHostPrefix is the invocation prefix used when this CLI runs + // inside the hawk binary, which embeds the root command as its `trace` + // subcommand (hawk's cmd/trace.go: rootCmd.AddCommand(tracecli.NewRootCmd())). + EmbeddedHostPrefix = "hawk trace" +) + +// hookBinaryExecutable and hookBinaryLookPath are indirected so tests can +// simulate the running binary and the searchable PATH. Override them via +// SetHookBinaryResolutionForTesting. +var ( + hookBinaryExecutable = os.Executable + hookBinaryLookPath = exec.LookPath +) + +// SetHookBinaryResolutionForTesting overrides how HookCommandPrefix observes +// the running executable and the PATH, and returns a restore function. +// Test-only; non-parallel tests only (parallel tests read these vars). +func SetHookBinaryResolutionForTesting(exe func() (string, error), lookPath func(string) (string, error)) func() { + oldExe, oldLookPath := hookBinaryExecutable, hookBinaryLookPath + hookBinaryExecutable = exe + hookBinaryLookPath = lookPath + return func() { + hookBinaryExecutable = oldExe + hookBinaryLookPath = oldLookPath + } +} + +// PinLegacyHookBinaryForTesting pins hook-binary resolution to the legacy +// name so tests asserting the historical wrapper strings stay deterministic +// regardless of what trace/entire binaries happen to be on the host PATH +// (e.g. macOS ships an unrelated /usr/bin/trace). Test-only; non-parallel +// tests only. +func PinLegacyHookBinaryForTesting() func() { + notFound := func(string) (string, error) { + return "", &os.PathError{Op: "exec.LookPath", Path: "x", Err: os.ErrNotExist} + } + return SetHookBinaryResolutionForTesting(func() (string, error) { + return "/usr/local/bin/entire", nil + }, notFound) +} + +// HookCommandPrefix returns the command prefix installed hooks should use to +// invoke this CLI ("trace", "entire", or "hawk trace"), resolved in priority +// order: +// +// 1. the basename of the running executable, when it is a known name +// (trace, entire, or the hawk host that embeds this CLI); +// 2. BinaryName ("trace"), when a binary of that name is reachable on PATH; +// 3. LegacyBinaryName ("entire"), when only the legacy name is on PATH; +// 4. LegacyBinaryName — the final fallback, matching the historical baked +// default so resolution is never worse than older builds' behavior. +// +// Callers that offer an explicit override (the local-dev launcher or +// --absolute-git-hook-path) apply it before consulting this function. +// +// Like UseWindowsProductionHooks, this is deliberately not memoized: it runs +// once per hook install, and re-probing lets a host that gains or loses a +// binary migrate its hooks on the next install. +func HookCommandPrefix() string { + if exe, err := hookBinaryExecutable(); err == nil { + if prefix, ok := knownExecutableHookPrefix(filepath.Base(exe)); ok { + return prefix + } + } + if _, err := hookBinaryLookPath(BinaryName); err == nil { + return BinaryName + } + if _, err := hookBinaryLookPath(LegacyBinaryName); err == nil { + return LegacyBinaryName + } + return LegacyBinaryName +} + +// knownExecutableHookPrefix maps a known executable basename to the hook +// invocation prefix for it. Windows basenames keep their ".exe" suffix. +func knownExecutableHookPrefix(base string) (string, bool) { + base = strings.TrimSuffix(base, ".exe") + switch base { + case BinaryName: + return BinaryName, true + case LegacyBinaryName: + return LegacyBinaryName, true + case "hawk": + return EmbeddedHostPrefix, true + } + return "", false +} + +// hookBinaryProbeNames lists every binary name that generated wrappers may +// probe for. Recognition uses the full set so hooks installed under any name +// (including by older builds) stay recognizable for removal and upgrade. +func hookBinaryProbeNames() []string { + return []string{BinaryName, LegacyBinaryName, "hawk"} +} + +// managedBinaryCommandPrefixes are command prefixes for every binary-name +// form hooks may be invoked through, independent of caller-supplied prefix +// lists. They keep uninstall/upgrade flows recognizing hooks after the rebrand +// renamed the binary (trace) and hawk embedding introduced the "hawk trace" +// form, without requiring every agent package to extend its own prefix list. +func managedBinaryCommandPrefixes() []string { + return []string{ + BinaryName + " ", + LegacyBinaryName + " ", + EmbeddedHostPrefix + " ", + } +} + +// productionHookWrapperTarget resolves the binary a production wrapper should +// probe and exec. The wrapped command's leading legacy binary token (agents +// still build commands as "entire hooks ") is rewritten to the +// resolved invocation prefix; commands that do not start with the legacy +// binary name (local-dev launchers, absolute paths) pass through unchanged. +func productionHookWrapperTarget(command string) (probe, adaptedCommand string) { + prefix := HookCommandPrefix() + adapted := command + if prefix != LegacyBinaryName && strings.HasPrefix(command, LegacyBinaryName+" ") { + adapted = prefix + command[len(LegacyBinaryName):] + } + return hookCommandProbeName(prefix), adapted +} + +// hookCommandProbeName returns the single PATH entry name a wrapper probes +// for the given invocation prefix ("hawk trace" probes "hawk"). +func hookCommandProbeName(prefix string) string { + name, _, _ := strings.Cut(prefix, " ") + return name +} diff --git a/cli/agent/binary_test.go b/cli/agent/binary_test.go new file mode 100644 index 0000000..7ecf0a9 --- /dev/null +++ b/cli/agent/binary_test.go @@ -0,0 +1,220 @@ +package agent + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fakeExe returns an executable resolver pointing at a binary named name. +func fakeExe(name string) func() (string, error) { + return func() (string, error) { return filepath.Join("/usr/local/bin", name), nil } +} + +// failingExe simulates an os.Executable that cannot resolve the running binary. +func failingExe() func() (string, error) { + return func() (string, error) { return "", errors.New("unavailable") } +} + +// notOnPath simulates LookPath finding nothing. +func notOnPath(string) (string, error) { + return "", &os.PathError{Op: "exec.LookPath", Path: "x", Err: os.ErrNotExist} +} + +// onPath returns a LookPath stub that resolves exactly the given names. +func onPath(names ...string) func(string) (string, error) { + return func(name string) (string, error) { + for _, want := range names { + if name == want { + return filepath.Join("/bin", name), nil + } + } + return "", &os.PathError{Op: "exec.LookPath", Path: name, Err: os.ErrNotExist} + } +} + +// TestHookCommandPrefix_ResolutionOrder covers the priority order with +// injected executable/PATH probes: +// +// 1. known executable basename (trace / entire / hawk, ".exe" stripped) +// 2. "trace" reachable on PATH +// 3. legacy "entire" reachable on PATH +// 4. legacy "entire" as final fallback (the historical baked default) +func TestHookCommandPrefix_ResolutionOrder(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + cases := []struct { + name string + exe func() (string, error) + lookPath func(string) (string, error) + want string + }{ + {"trace executable wins over PATH", fakeExe("trace"), onPath("entire"), "trace"}, + {"entire executable keeps legacy name", fakeExe("entire"), notOnPath, "entire"}, + {"hawk executable embeds as hawk trace", fakeExe("hawk"), notOnPath, "hawk trace"}, + {"windows exe suffix is stripped", fakeExe("hawk.exe"), notOnPath, "hawk trace"}, + {"unknown executable falls back to trace on PATH", fakeExe("cli.test"), onPath("trace"), "trace"}, + {"unknown executable prefers trace over legacy entire", fakeExe("cli.test"), onPath("trace", "entire"), "trace"}, + {"unknown executable falls back to legacy entire", fakeExe("cli.test"), onPath("entire"), "entire"}, + {"nothing resolvable keeps historical default", failingExe(), notOnPath, "entire"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + restore := SetHookBinaryResolutionForTesting(tc.exe, tc.lookPath) + defer restore() + if got := HookCommandPrefix(); got != tc.want { + t.Fatalf("HookCommandPrefix() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestHookCommandPrefix_FakePATH exercises the PATH-probing steps with the +// real exec.LookPath against a controlled PATH containing fake binaries. +func TestHookCommandPrefix_FakePATH(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam and PATH. + + makeBinDir := func(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("writing fake %s: %v", name, err) + } + } + return dir + } + + cases := []struct { + name string + bins []string + want string + }{ + {"trace on PATH", []string{"trace"}, "trace"}, + {"only legacy entire on PATH", []string{"entire"}, "entire"}, + {"both on PATH prefers trace", []string{"trace", "entire"}, "trace"}, + {"empty PATH keeps legacy default", nil, "entire"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PATH", makeBinDir(t, tc.bins...)) + restore := SetHookBinaryResolutionForTesting(failingExe(), exec.LookPath) + defer restore() + if got := HookCommandPrefix(); got != tc.want { + t.Fatalf("HookCommandPrefix() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestWrapProductionSilentHookCommand_AdaptsResolvedBinary verifies the +// wrapper probes and execs the resolved binary instead of the legacy literal. +func TestWrapProductionSilentHookCommand_AdaptsResolvedBinary(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("hawk"), notOnPath) + defer restore() + + command := WrapProductionSilentHookCommand("entire hooks claude-code stop") + + if !strings.Contains(command, "command -v hawk >/dev/null 2>&1") { + t.Fatalf("wrapper should probe the embedding host binary, got %q", command) + } + if !strings.Contains(command, "exec hawk trace hooks claude-code stop") { + t.Fatalf("wrapper should exec the embedded command path, got %q", command) + } + if strings.Contains(command, "exec entire") { + t.Fatalf("wrapper must not exec the legacy binary once resolved, got %q", command) + } +} + +// TestWrapWindowsProductionSilentHookCommand_AdaptsResolvedBinary is the +// cmd.exe-wrapper counterpart of the adaptation check. +func TestWrapWindowsProductionSilentHookCommand_AdaptsResolvedBinary(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("trace"), notOnPath) + defer restore() + + command := WrapWindowsProductionSilentHookCommand("entire hooks codex stop") + + if !strings.Contains(command, "where.exe trace >nul 2>nul") { + t.Fatalf("windows wrapper should probe the resolved binary, got %q", command) + } + if !strings.Contains(command, "(trace hooks codex stop)") { + t.Fatalf("windows wrapper should exec the resolved binary, got %q", command) + } +} + +// TestWrapProductionSilentHookCommand_KeepsNonLegacyCommands verifies local-dev +// and absolute-path commands pass through the wrapper unmodified. +func TestWrapProductionSilentHookCommand_KeepsNonLegacyCommands(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("hawk"), notOnPath) + defer restore() + + command := WrapProductionSilentHookCommand(`"$(git rev-parse --show-toplevel)"/scripts/entire-dev hooks cursor stop`) + + if !strings.Contains(command, `exec "$(git rev-parse --show-toplevel)"/scripts/entire-dev hooks cursor stop`) { + t.Fatalf("non-legacy command should pass through unchanged, got %q", command) + } +} + +// TestWrapProductionSilentHookCommand_LegacyResolutionIsUnchanged pins the +// historical output when resolution lands on the legacy binary. +func TestWrapProductionSilentHookCommand_LegacyResolutionIsUnchanged(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("entire"), notOnPath) + defer restore() + + command := WrapProductionSilentHookCommand("entire hooks codex stop") + want := `sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks codex stop'` + if command != want { + t.Fatalf("legacy resolution should reproduce the historical wrapper:\ngot: %s\nwant: %s", command, want) + } +} + +// TestIsManagedHookCommand_RecognizesRebrandedForms verifies uninstall/upgrade +// recognition keeps working for hooks baked with the new binary names even +// when the caller's prefix list still names only the legacy binary. +func TestIsManagedHookCommand_RecognizesRebrandedForms(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("hawk"), notOnPath) + defer restore() + legacyOnly := []string{"entire "} + + direct := []string{ + "trace hooks codex stop", + "hawk trace hooks claude-code session-start", + "entire hooks codex stop", // legacy direct form still recognized + } + for _, command := range direct { + if !IsManagedHookCommand(command, legacyOnly) { + t.Errorf("expected direct command to be managed: %q", command) + } + } + + wrapped := []string{ + WrapProductionSilentHookCommand("entire hooks cursor stop"), + WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", WarningFormatSingleLine), + WrapWindowsProductionSilentHookCommand("entire hooks codex stop"), + } + for _, command := range wrapped { + if !IsManagedHookCommand(command, legacyOnly) { + t.Errorf("expected rebranded wrapper to be managed: %q", command) + } + } + + // Legacy wrappers written by older builds remain recognized. + legacyWrapper := `sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks codex stop'` + if !IsManagedHookCommand(legacyWrapper, legacyOnly) { + t.Error("expected legacy wrapper to remain managed") + } +} diff --git a/cli/agent/codex/hooks_test.go b/cli/agent/codex/hooks_test.go index 9e93ae3..052b692 100644 --- a/cli/agent/codex/hooks_test.go +++ b/cli/agent/codex/hooks_test.go @@ -18,6 +18,10 @@ func setupTestEnv(t *testing.T) string { tempDir := t.TempDir() t.Chdir(tempDir) t.Setenv("CODEX_HOME", filepath.Join(tempDir, ".codex-home")) + // Pin hook-binary resolution to the legacy name so assertions on the + // installed wrapper strings are deterministic regardless of any trace / + // entire binaries on the host PATH. + t.Cleanup(agentpkg.PinLegacyHookBinaryForTesting()) return tempDir } diff --git a/cli/agent/hook_command.go b/cli/agent/hook_command.go index 54da385..f8a02dc 100644 --- a/cli/agent/hook_command.go +++ b/cli/agent/hook_command.go @@ -39,16 +39,19 @@ func MissingEntireWarning(format WarningFormat) string { const LocalDevHookScript = `"$(git rev-parse --show-toplevel)"/scripts/entire-dev` // WrapProductionSilentHookCommand exits successfully without output when the -// Entire CLI is missing from PATH. +// CLI binary is missing from PATH. The binary probed and exec'd is resolved +// from the running executable / PATH (see HookCommandPrefix). func WrapProductionSilentHookCommand(command string) string { + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec %s'`, + `sh -c 'if ! command -v %s >/dev/null 2>&1; then exit 0; fi; exec %s'`, + probe, command, ) } // WrapProductionJSONWarningHookCommand emits a JSON hook response with a -// systemMessage field on stdout when the Entire CLI is missing from PATH. +// systemMessage field on stdout when the CLI binary is missing from PATH. func WrapProductionJSONWarningHookCommand(command string, format WarningFormat) string { payload, err := jsonutil.MarshalWithNoHTMLEscape(struct { SystemMessage string `json:"systemMessage,omitempty"` @@ -60,8 +63,10 @@ func WrapProductionJSONWarningHookCommand(command string, format WarningFormat) return WrapProductionPlainTextWarningHookCommand(command, format) } + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `sh -c 'if ! command -v entire >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, + `sh -c 'if ! command -v %s >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, + probe, string(payload), command, ) @@ -73,25 +78,27 @@ func WrapProductionJSONWarningHookCommand(command string, format WarningFormat) // command to fall through to and correctness does NOT depend on `exit /b` // aborting the whole `cmd /c` line — behavior that is underspecified when the // `exit /b` sits inside a parenthesized block. Semantics: -// - entire present → `where.exe` succeeds (errorlevel 0) → else branch runs +// - binary present → `where.exe` succeeds (errorlevel 0) → else branch runs // the wrapped command and its exit code propagates (parity with the POSIX // `exec entire …` form). -// - entire absent → `where.exe` fails (errorlevel ≥ 1) → the if branch runs +// - binary absent → `where.exe` fails (errorlevel ≥ 1) → the if branch runs // (silently via `ver>nul`, or echoing the warning) and the line exits 0. // WrapWindowsProductionSilentHookCommand exits successfully without output when -// the Entire CLI is missing from PATH. It avoids sh so Codex hooks still work +// the CLI binary is missing from PATH. It avoids sh so Codex hooks still work // from native Windows shells. func WrapWindowsProductionSilentHookCommand(command string) string { + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `cmd.exe /d /s /c "where.exe entire >nul 2>nul & if errorlevel 1 (ver>nul) else (%s)"`, + `cmd.exe /d /s /c "where.exe %s >nul 2>nul & if errorlevel 1 (ver>nul) else (%s)"`, + probe, command, ) } // WrapWindowsProductionJSONWarningHookCommand emits a JSON hook response with a -// systemMessage field on stdout when the Entire CLI is missing from PATH. It -// avoids sh so Codex hooks still work from native Windows shells. Codex already +// systemMessage field on stdout when the CLI binary is missing from PATH. +// It avoids sh so Codex hooks still work from native Windows shells. Codex already // runs hook commands through cmd.exe /C, so this JSON-bearing command uses that // shell directly instead of adding a second quote-parsing layer. func WrapWindowsProductionJSONWarningHookCommand(command string, format WarningFormat) string { @@ -104,8 +111,10 @@ func WrapWindowsProductionJSONWarningHookCommand(command string, format WarningF return WrapWindowsProductionPlainTextWarningHookCommand(command, format) } + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `where.exe entire >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + `where.exe %s >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + probe, escapeWindowsCMD(string(payload)), command, ) @@ -114,56 +123,90 @@ func WrapWindowsProductionJSONWarningHookCommand(command string, format WarningF // WrapWindowsProductionPlainTextWarningHookCommand is the direct-shell fallback // for WrapWindowsProductionJSONWarningHookCommand when JSON marshaling fails. func WrapWindowsProductionPlainTextWarningHookCommand(command string, format WarningFormat) string { + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `where.exe entire >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + `where.exe %s >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + probe, escapeWindowsCMD(windowsPlainTextWarning(format)), command, ) } // WrapProductionPlainTextWarningHookCommand emits the warning as plain -// text to stdout when the Entire CLI is missing from PATH. +// text to stdout when the CLI binary is missing from PATH. func WrapProductionPlainTextWarningHookCommand(command string, format WarningFormat) string { + payload := MissingEntireWarning(format) + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `sh -c 'if ! command -v entire >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, - MissingEntireWarning(format), + `sh -c 'if ! command -v %s >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, + probe, + payload, command, ) } -const ( - productionHookWrapperPrefix = `sh -c 'if ! command -v entire >/dev/null 2>&1; then ` - windowsProductionHookWrapperPrefix = `where.exe entire >nul 2>nul & if errorlevel 1 ` - nestedWindowsProductionHookWrapperPrefix = `cmd.exe /d /s /c "where.exe entire >nul 2>nul & if errorlevel 1 ` -) +// shProductionHookWrapperPrefixes returns the wrapper-prefix forms generated +// for each probed binary name (see hookBinaryProbeNames). Recognition accepts +// the whole set so wrappers installed by any build stay recognizable. +func shProductionHookWrapperPrefixes() []string { + names := hookBinaryProbeNames() + prefixes := make([]string, 0, len(names)) + for _, name := range names { + prefixes = append(prefixes, fmt.Sprintf(`sh -c 'if ! command -v %s >/dev/null 2>&1; then `, name)) + } + return prefixes +} + +// windowsProductionHookWrapperPrefixes returns both native cmd.exe +// wrapper-prefix forms for each probed binary name (plain and nested inside +// an explicit cmd.exe /d /s /c shell). +func windowsProductionHookWrapperPrefixes() []string { + names := hookBinaryProbeNames() + prefixes := make([]string, 0, 2*len(names)) + for _, name := range names { + prefixes = append( + prefixes, + fmt.Sprintf(`where.exe %s >nul 2>nul & if errorlevel 1 `, name), + fmt.Sprintf(`cmd.exe /d /s /c "where.exe %s >nul 2>nul & if errorlevel 1 `, name), + ) + } + return prefixes +} // IsManagedHookCommand reports whether command is either a direct Entire hook -// command or one of Entire's production wrapper forms that exec that command. +// command or one of the production wrapper forms that exec that command. +// Beyond the caller-supplied prefixes, the binary-name forms (trace / entire / +// hawk trace) always count as managed, so hooks baked with any resolved binary +// name are recognized for removal and upgrade. func IsManagedHookCommand(command string, prefixes []string) bool { - if hasManagedHookPrefix(command, prefixes) { + all := append(append([]string{}, prefixes...), managedBinaryCommandPrefixes()...) + if hasManagedHookPrefix(command, all) { return true } - if strings.HasPrefix(command, productionHookWrapperPrefix) { - _, wrappedCommand, ok := strings.Cut(command, "; fi; exec ") - if !ok { - return false - } + for _, prefix := range shProductionHookWrapperPrefixes() { + if strings.HasPrefix(command, prefix) { + _, wrappedCommand, ok := strings.Cut(command, "; fi; exec ") + if !ok { + return false + } - return hasManagedHookPrefix(wrappedCommand, prefixes) + return hasManagedHookPrefix(wrappedCommand, all) + } } - if strings.HasPrefix(command, windowsProductionHookWrapperPrefix) || - strings.HasPrefix(command, nestedWindowsProductionHookWrapperPrefix) { - // The wrapped command lives in the `else ()` branch. Take the - // last ` else (` so a warning string containing the marker can't fool us. - const elseMarker = " else (" - idx := strings.LastIndex(command, elseMarker) - if idx < 0 { - return false + for _, prefix := range windowsProductionHookWrapperPrefixes() { + if strings.HasPrefix(command, prefix) { + // The wrapped command lives in the `else ()` branch. Take the + // last ` else (` so a warning string containing the marker can't fool us. + const elseMarker = " else (" + idx := strings.LastIndex(command, elseMarker) + if idx < 0 { + return false + } + wrappedCommand := command[idx+len(elseMarker):] + wrappedCommand = strings.TrimSuffix(wrappedCommand, `"`) + wrappedCommand = strings.TrimSuffix(wrappedCommand, `)`) + return hasManagedHookPrefix(wrappedCommand, all) } - wrappedCommand := command[idx+len(elseMarker):] - wrappedCommand = strings.TrimSuffix(wrappedCommand, `"`) - wrappedCommand = strings.TrimSuffix(wrappedCommand, `)`) - return hasManagedHookPrefix(wrappedCommand, prefixes) } return false } diff --git a/cli/agent/hook_command_test.go b/cli/agent/hook_command_test.go index 3680cae..a0f303a 100644 --- a/cli/agent/hook_command_test.go +++ b/cli/agent/hook_command_test.go @@ -36,7 +36,8 @@ func TestUseWindowsProductionHooks(t *testing.T) { } func TestWrapProductionJSONWarningHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", WarningFormatMultiLine) @@ -58,7 +59,8 @@ func TestWrapProductionJSONWarningHookCommand(t *testing.T) { } func TestWrapProductionPlainTextWarningHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapProductionPlainTextWarningHookCommand("entire hooks factoryai-droid session-start", WarningFormatSingleLine) @@ -77,7 +79,8 @@ func TestWrapProductionPlainTextWarningHookCommand(t *testing.T) { } func TestWrapWindowsProductionJSONWarningHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapWindowsProductionJSONWarningHookCommand("entire hooks codex session-start", WarningFormatSingleLine) @@ -102,7 +105,8 @@ func TestWrapWindowsProductionJSONWarningHookCommand(t *testing.T) { } func TestWrapWindowsProductionSilentHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapWindowsProductionSilentHookCommand("entire hooks codex stop") diff --git a/cli/agent_help_cmd.go b/cli/agent_help_cmd.go index 9a20410..0034bd6 100644 --- a/cli/agent_help_cmd.go +++ b/cli/agent_help_cmd.go @@ -327,7 +327,7 @@ func renderAgentHelpCommand(cmd *cobra.Command, repoLine string, trailsEnabled b names = append(names, sub.Name()) } fmt.Fprintf(&b, "\nSubcommands: %s\n", strings.Join(names, " · ")) - fmt.Fprintf(&b, "Next: entire agent-help %s \n", strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" ")) + fmt.Fprintf(&b, "Next: %s agent-help %s \n", cmd.Root().Name(), strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" ")) } return b.String() } diff --git a/cli/agent_help_cmd_test.go b/cli/agent_help_cmd_test.go index 864f736..3efc2fd 100644 --- a/cli/agent_help_cmd_test.go +++ b/cli/agent_help_cmd_test.go @@ -475,8 +475,8 @@ func TestRunAgentHelp_Dispatch(t *testing.T) { if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json output not valid JSON: %v\n%s", err, jsonOut) } - if parsed.Command != "entire trail" { - t.Errorf("json command = %q, want %q", parsed.Command, "entire trail") + if parsed.Command != "trace trail" { + t.Errorf("json command = %q, want %q", parsed.Command, "trace trail") } if parsed.Repo != agentHelpTestRepo { t.Errorf("json repo = %q, want %q", parsed.Repo, agentHelpTestRepo) @@ -537,7 +537,7 @@ func TestAgentHelpCmd_Execute(t *testing.T) { if err := json.Unmarshal(jbuf.Bytes(), &parsed); err != nil { t.Fatalf("output not valid JSON: %v\n%s", err, jbuf.String()) } - if parsed.Command != "entire status" { - t.Errorf("json command = %q, want %q", parsed.Command, "entire status") + if parsed.Command != "trace status" { + t.Errorf("json command = %q, want %q", parsed.Command, "trace status") } } diff --git a/cli/checkpoint/remote/util.go b/cli/checkpoint/remote/util.go index 2346050..ed37c03 100644 --- a/cli/checkpoint/remote/util.go +++ b/cli/checkpoint/remote/util.go @@ -210,7 +210,7 @@ func PushURL(ctx context.Context, pushRemoteName string) (string, bool, error) { ctx, "checkpoint-remote: ignoring checkpoint_remote that appears to belong to another owner; pushing checkpoints to the push remote instead", slog.String("checkpoint_repo", config.Repo), slog.String("reason", reason), - slog.String("hint", "if this checkpoint repo is yours, configure checkpoint_remote in .entire/settings.local.json"), + slog.String("hint", "if this checkpoint repo is yours, configure checkpoint_remote in .trace/settings.local.json"), ) return fallbackURL, false, nil } @@ -335,7 +335,7 @@ func GetPushURL(ctx context.Context, remoteName string) (string, error) { // looks like it belongs to an upstream project rather than to this developer, // along with a short reason for logging. // -// checkpoint_remote is normally committed in .entire/settings.json, so anyone who +// checkpoint_remote is normally committed in .trace/settings.json, so anyone who // forks or clones the project inherits it. Honoring it blindly would push a // contributor's session data into the upstream project's checkpoint repo — which // they typically cannot write to and should not be writing to. This is the check @@ -343,7 +343,7 @@ func GetPushURL(ctx context.Context, remoteName string) (string, error) { // // Ownership is decided from two local signals, no network: // -// 1. A checkpoint_remote in .entire/settings.local.json is gitignored and +// 1. A checkpoint_remote in .trace/settings.local.json is gitignored and // per-clone, so it cannot have been inherited — it is always ours. // 2. Otherwise, compare the CHECKPOINT repo's owner against ORIGIN's owner: // "am I working in a repo owned by whoever owns the checkpoint repo?" A fork diff --git a/cli/explain_summary_provider_test.go b/cli/explain_summary_provider_test.go index 4060775..cb1dfc4 100644 --- a/cli/explain_summary_provider_test.go +++ b/cli/explain_summary_provider_test.go @@ -546,14 +546,14 @@ func TestResolveDispatchSummaryProvider_ExplicitExternalProviderDoesNotWriteLoca testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true,"external_agents":false}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true,"external_agents":false}`), 0o644); err != nil { t.Fatalf("write settings.json: %v", err) } - localPath := filepath.Join(tmpDir, ".entire", "settings.local.json") + localPath := filepath.Join(tmpDir, ".trace", "settings.local.json") if tt.localContent != "" { if err := os.WriteFile(localPath, []byte(tt.localContent), 0o644); err != nil { t.Fatalf("write settings.local.json: %v", err) @@ -653,7 +653,7 @@ func TestResolveCheckpointSummaryProvider_SavesSingleInstalledProvider(t *testin // Auto-persist writes to settings.local.json (not tracked settings.json) // because provider selection is based on local PATH. - localPath := filepath.Join(tmpDir, ".entire", "settings.local.json") + localPath := filepath.Join(tmpDir, ".trace", "settings.local.json") s, err := settings.LoadFromFile(localPath) if err != nil { t.Fatalf("LoadFromFile() error = %v", err) @@ -666,7 +666,7 @@ func TestResolveCheckpointSummaryProvider_SavesSingleInstalledProvider(t *testin } // Tracked settings.json must not be dirtied. - projectPath := filepath.Join(tmpDir, ".entire", "settings.json") + projectPath := filepath.Join(tmpDir, ".trace", "settings.json") projectS, err := settings.LoadFromFile(projectPath) if err != nil { t.Fatalf("LoadFromFile(project) error = %v", err) @@ -800,10 +800,10 @@ func TestResolveCheckpointSummaryProvider_ConfiguredExternalProvider(t *testing. t.Chdir(tmpDir) const providerName = "external-summary-explain" - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true,"external_agents":true,"summary_generation":{"provider":"`+providerName+`","model":"external-model"}}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true,"external_agents":true,"summary_generation":{"provider":"`+providerName+`","model":"external-model"}}`), 0o644); err != nil { t.Fatalf("write settings: %v", err) } externalDir := t.TempDir() @@ -843,10 +843,10 @@ func TestPersistSummaryProviderSelection_ExternalFlipsFlagAndReturnsSignal(t *te testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { t.Fatalf("write settings: %v", err) } @@ -866,7 +866,7 @@ func TestPersistSummaryProviderSelection_ExternalFlipsFlagAndReturnsSignal(t *te t.Fatal("expected flagFlipped=true when external_agents was off and provider is external") } - s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".entire", "settings.local.json")) + s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".trace", "settings.local.json")) if err != nil { t.Fatalf("LoadFromFile() error = %v", err) } @@ -885,10 +885,10 @@ func TestPersistSummaryProviderSelection_BuiltInDoesNotFlipFlag(t *testing.T) { testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { t.Fatalf("write settings: %v", err) } @@ -900,7 +900,7 @@ func TestPersistSummaryProviderSelection_BuiltInDoesNotFlipFlag(t *testing.T) { t.Fatal("expected flagFlipped=false for a built-in provider") } - s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".entire", "settings.local.json")) + s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".trace", "settings.local.json")) if err != nil { t.Fatalf("LoadFromFile() error = %v", err) } @@ -920,10 +920,10 @@ func TestPersistSummaryProviderSelection_ExternalAlreadyEnabledNoSignal(t *testi testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.local.json"), []byte(`{"external_agents":true}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.local.json"), []byte(`{"external_agents":true}`), 0o644); err != nil { t.Fatalf("write settings.local.json: %v", err) } diff --git a/cli/investigate/cmd_internal_test.go b/cli/investigate/cmd_internal_test.go index 99e2368..5972765 100644 --- a/cli/investigate/cmd_internal_test.go +++ b/cli/investigate/cmd_internal_test.go @@ -16,8 +16,8 @@ import ( ) // TestSaveInvestigateConfig_WritesLocalFile verifies that -// saveInvestigateConfig persists into .entire/settings.local.json (not the -// committed .entire/settings.json). Mirrors the review-side behaviour so +// saveInvestigateConfig persists into .trace/settings.local.json (not the +// committed .trace/settings.json). Mirrors the review-side behaviour so // agent picker output stays out of project settings. // // NOTE: This test uses t.Chdir, which Go forbids combining with @@ -35,14 +35,14 @@ func TestSaveInvestigateConfig_WritesLocalFile(t *testing.T) { require.NoError(t, saveInvestigateConfig(context.Background(), cfg)) // settings.json should NOT contain investigate. - base, err := os.ReadFile(filepath.Join(tmp, ".entire/settings.json")) + base, err := os.ReadFile(filepath.Join(tmp, ".trace/settings.json")) if err == nil { require.NotContains(t, string(base), `"investigate"`, "investigate must not be written to project settings") } // settings.local.json should contain investigate. - local, err := os.ReadFile(filepath.Join(tmp, ".entire/settings.local.json")) + local, err := os.ReadFile(filepath.Join(tmp, ".trace/settings.local.json")) require.NoError(t, err) require.Contains(t, string(local), `"agents"`) require.Contains(t, string(local), `"claude-code"`) diff --git a/cli/investigate/cmd_test.go b/cli/investigate/cmd_test.go index 34f2f07..3e3a7b8 100644 --- a/cli/investigate/cmd_test.go +++ b/cli/investigate/cmd_test.go @@ -725,7 +725,7 @@ func TestNewCommand_ContinueWithMissingState(t *testing.T) { // --- helpers --------------------------------------------------------------- // saveInvestigateSettings writes an InvestigateConfig into the CWD's -// .entire/settings.json. +// .trace/settings.json. func saveInvestigateSettings(cfg *settings.InvestigateConfig) error { ctx := context.Background() s, err := settings.Load(ctx) @@ -786,9 +786,9 @@ func TestRunFresh_SkipsMultipickerWhenAgentsFlagPresent(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code","codex"]}}`), 0o644, )) @@ -822,9 +822,9 @@ func TestRunFresh_InvokesMultipickerWhenTwoAgentsAndNoFlag(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code","codex"]}}`), 0o644, )) @@ -867,9 +867,9 @@ func TestRunInvestigate_SoftWarnAcceptedRunsLoop(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code"],"max_turns":1}}`), 0o644, )) @@ -910,9 +910,9 @@ func TestRunInvestigate_SoftWarnSilentInNonInteractive(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code"],"max_turns":1}}`), 0o644, )) diff --git a/cli/investigate/picker.go b/cli/investigate/picker.go index 885cb46..8b35385 100644 --- a/cli/investigate/picker.go +++ b/cli/investigate/picker.go @@ -38,7 +38,7 @@ func ConfirmFirstRunSetup(ctx context.Context, out io.Writer) bool { fmt.Fprintln(out) fmt.Fprintln(out, "You'll pick which agents take turns during an investigation, and the") fmt.Fprintln(out, "max-turns / quorum the loop should use. The selection is saved to local") - fmt.Fprintln(out, "preferences (.entire/settings.local.json, not committed); edit later with `entire investigate --edit`.") + fmt.Fprintln(out, "preferences (.trace/settings.local.json, not committed); edit later with `trace investigate --edit`.") fmt.Fprintln(out, "After setup, the investigation will run with your selection.") fmt.Fprintln(out) diff --git a/cli/mcp_test.go b/cli/mcp_test.go index a515871..c156296 100644 --- a/cli/mcp_test.go +++ b/cli/mcp_test.go @@ -199,8 +199,8 @@ func TestMCPServer_AgentHelpToolCall_Subcommand(t *testing.T) { if err := json.Unmarshal([]byte(mcpResultText(t, resps[0].Result)), &doc); err != nil { t.Fatalf("agent_help subcommand should return JSON: %v", err) } - if doc.Command != "entire status" { - t.Errorf("agent_help command=status should drill into `entire status`, got %q", doc.Command) + if doc.Command != "trace status" { + t.Errorf("agent_help command=status should drill into `trace status`, got %q", doc.Command) } } diff --git a/cli/paths/paths.go b/cli/paths/paths.go index 4b9df92..e718423 100644 --- a/cli/paths/paths.go +++ b/cli/paths/paths.go @@ -14,7 +14,14 @@ import ( // Directory constants const ( - EntireDir = ".entire" + // EntireDir is the on-disk infrastructure directory (session metadata, + // tmp, logs). The name predates the trace rebrand; stored session data + // keeps using it so existing repositories stay readable. + EntireDir = ".entire" + // TraceDir is the canonical config directory holding the settings files + // (settings.json, settings.local.json). The legacy .entire location is + // still read when .trace files are missing (see the settings package). + TraceDir = ".trace" EntireTmpDir = ".entire/tmp" EntireMetadataDir = ".entire/metadata" @@ -123,12 +130,12 @@ func AbsPath(ctx context.Context, relPath string) (string, error) { } // IsInfrastructurePath returns true if the path is part of CLI infrastructure -// (i.e., inside the .entire directory). It is used only to EXCLUDE infra paths -// from checkpoints/tracking, so it matches case-insensitively on +// (i.e., inside the .entire or .trace directories). It is used only to EXCLUDE +// infra paths from checkpoints/tracking, so it matches case-insensitively on // case-insensitive filesystems via IsProtectedSubpath. Do not use it as a // containment/allow gate. func IsInfrastructurePath(path string) bool { - return IsProtectedSubpath(EntireDir, path) + return IsProtectedSubpath(EntireDir, path) || IsProtectedSubpath(TraceDir, path) } // IsSubpath reports whether child is lexically under parent (or equal to it). diff --git a/cli/remote_topology.go b/cli/remote_topology.go index 602975a..24be31c 100644 --- a/cli/remote_topology.go +++ b/cli/remote_topology.go @@ -174,7 +174,7 @@ func (t remoteTopology) describeCheckpointDestination(w io.Writer, header string } fmt.Fprintln(w, " To pin one repository for checkpoints, set checkpoint_remote in") - fmt.Fprintln(w, " .entire/settings.json (or .entire/settings.local.json to keep it to this clone).") + fmt.Fprintln(w, " .trace/settings.json (or .trace/settings.local.json to keep it to this clone).") } // unpinnedNames lists the remotes whose checkpoint destination is not already diff --git a/cli/resume.go b/cli/resume.go index 19fe144..2a30465 100644 --- a/cli/resume.go +++ b/cli/resume.go @@ -881,7 +881,7 @@ func checkRemoteMetadata( } else { fmt.Fprintf(errW, "Checkpoint '%s' found in commit but its metadata could not be fetched from the checkpoint remote.\n", checkpointID) } - fmt.Fprintf(errW, "Ensure you have access to the checkpoint remote configured in .entire/settings.json.\n") + fmt.Fprintf(errW, "Ensure you have access to the checkpoint remote configured in .trace/settings.json.\n") } else { fmt.Fprintf(errW, "Checkpoint '%s' found in commit but the entire/checkpoints/v1 branch is not available locally or on the remote.\n", checkpointID) fmt.Fprintf(errW, "This can happen if the metadata branch was not pushed. Try:\n") diff --git a/cli/root.go b/cli/root.go index b73cfbb..f046a1c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,6 +4,7 @@ import ( "fmt" "runtime" + "github.com/GrayCodeAI/trace/cli/agent" "github.com/GrayCodeAI/trace/cli/experimental" "github.com/GrayCodeAI/trace/cli/investigate" "github.com/GrayCodeAI/trace/cli/paths" @@ -18,10 +19,9 @@ import ( const gettingStarted = ` Getting Started: - To get started with Entire CLI, run 'entire enable' to enable - session tracking in your repository, then 'entire agent add ' - to install hooks for a specific agent. For more information, visit: - https://docs.entire.io/overview + To get started with Trace, run 'trace enable' to enable + session tracking in your repository, then 'trace agent add ' + to install hooks for a specific agent. ` @@ -51,9 +51,9 @@ func inGroup(c *cobra.Command, groupID string) *cobra.Command { func NewRootCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "entire", - Short: "Entire CLI", - Long: "The command-line interface for Entire" + gettingStarted + accessibilityHelp, + Use: agent.BinaryName, + Short: "Git-native session capture for AI coding agents", + Long: "The command-line interface for Trace" + gettingStarted + accessibilityHelp, Version: versioninfo.Version, // Let main.go handle error printing to avoid duplication SilenceErrors: true, @@ -102,9 +102,9 @@ func NewRootCmd() *cobra.Command { }, } - // Help groups; AddGroup order is display order in `entire --help`. + // Help groups; AddGroup order is display order in `trace --help`. cmd.AddGroup( - &cobra.Group{ID: groupSetup, Title: "Entire Setup:"}, + &cobra.Group{ID: groupSetup, Title: "Trace Setup:"}, &cobra.Group{ID: groupSessions, Title: "Sessions & Checkpoints:"}, &cobra.Group{ID: groupAccount, Title: "Account:"}, &cobra.Group{ID: groupControlPlane, Title: "Control Plane:"}, @@ -150,16 +150,16 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(inGroup(newCIInitCmd(), groupSetup)) // 'ci-init' — configure CI session auto-capture cmd.AddCommand(inGroup(newOplogCmd(), groupSessions)) // 'log' — show trace's operation log cmd.AddCommand(inGroup(newAPICmd(), groupControlPlane)) // authenticated passthrough to core/cell APIs - cmd.AddCommand(newAgentHelpCmd(cmd)) // visible: agents on transports without context injection discover it via `entire help` + cmd.AddCommand(newAgentHelpCmd(cmd)) // visible: agents on transports without context injection discover it via `trace help` // Hidden top-level shortcuts. Functional but print a deprecation hint. - cmd.AddCommand(hideAsAlias(newResumeCmd(), "entire session resume")) - cmd.AddCommand(hideAsAlias(newAttachCmd(), "entire session attach")) - cmd.AddCommand(hideAsAlias(newExplainCmd(), "entire checkpoint explain")) - cmd.AddCommand(hideAsAlias(newTraceCmd(), "entire doctor trace")) - experimental.Register(cmd, newSearchCmd()) // 'entire search' = 'checkpoint search' (experimental) + cmd.AddCommand(hideAsAlias(newResumeCmd(), "trace session resume")) + cmd.AddCommand(hideAsAlias(newAttachCmd(), "trace session attach")) + cmd.AddCommand(hideAsAlias(newExplainCmd(), "trace checkpoint explain")) + cmd.AddCommand(hideAsAlias(newTraceCmd(), "trace doctor trace")) + experimental.Register(cmd, newSearchCmd()) // 'trace search' = 'checkpoint search' (experimental) - // Experimental labs commands (listed via `entire labs`; not deprecation shortcuts). + // Experimental labs commands (listed via `trace labs`; not deprecation shortcuts). experimental.Register(cmd, newExpertsCmd()) // 'experts' (experimental); agent/workflow provenance // Deprecated top-level commands (functional; the constructors mark them @@ -187,7 +187,7 @@ func NewRootCmd() *cobra.Command { } func versionString() string { - return fmt.Sprintf("Entire CLI %s\nGo version: %s\nOS/Arch: %s/%s\n", + return fmt.Sprintf("Trace CLI %s\nGo version: %s\nOS/Arch: %s/%s\n", versioninfo.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) } diff --git a/cli/settings/settings.go b/cli/settings/settings.go index fe1e075..9d45383 100644 --- a/cli/settings/settings.go +++ b/cli/settings/settings.go @@ -27,12 +27,21 @@ import ( ) const ( - // EntireSettingsFile is the path to the Entire settings file - EntireSettingsFile = ".entire/settings.json" - // EntireSettingsLocalFile is the path to the local settings override file (not committed) - EntireSettingsLocalFile = ".entire/settings.local.json" + // EntireSettingsFile is the path to the settings file (canonical `.trace/` + // location). The name predates the trace rebrand and is kept for API + // compatibility. + EntireSettingsFile = ".trace/settings.json" + // EntireSettingsLocalFile is the path to the local settings override file (not committed). + EntireSettingsLocalFile = ".trace/settings.local.json" // ClonePreferencesFile is the path inside the git common dir for clone-local preferences. ClonePreferencesFile = "entire/preferences.json" + + // legacyEntireSettingsFile and legacyEntireSettingsLocalFile are the + // pre-rebrand settings locations. They are still READ when the canonical + // .trace file does not exist, and their content is copied to the canonical + // location on the first save (lazy migration — never a destructive move). + legacyEntireSettingsFile = ".entire/settings.json" + legacyEntireSettingsLocalFile = ".entire/settings.local.json" ) type worktreeRootContextKey struct{} @@ -461,9 +470,10 @@ func (s *EntireSettings) InvestigateConfig() *InvestigateConfig { return s.Investigate } -// Load loads the Entire settings from .entire/settings.json, then applies +// Load loads the settings from .trace/settings.json (falling back to the +// legacy .entire/settings.json when only that exists), then applies // clone-local preferences from the git common dir, then applies any overrides -// from .entire/settings.local.json if it exists. +// from .trace/settings.local.json (with the same legacy fallback) if it exists. // Returns default settings if no settings or preferences file exists. // Works correctly from any subdirectory within the repository. func Load(ctx context.Context) (*EntireSettings, error) { @@ -490,7 +500,8 @@ func Load(ctx context.Context) (*EntireSettings, error) { // settingsAbsPaths resolves the base and local settings file paths relative to // the current working directory, falling back to the relative path when -// absolute resolution fails. +// absolute resolution fails. Read paths additionally fall back to the legacy +// .entire location when the canonical .trace file does not exist. func settingsAbsPaths(ctx context.Context) (base, local string) { base, err := paths.AbsPath(ctx, EntireSettingsFile) if err != nil { @@ -500,13 +511,74 @@ func settingsAbsPaths(ctx context.Context) (base, local string) { if err != nil { local = EntireSettingsLocalFile // Fallback to relative } - return base, local + return readSettingsPath(base), readSettingsPath(local) } // worktreeSettingsPaths resolves the base and local settings file paths under -// an explicit worktree root. +// an explicit worktree root (with the same legacy read fallback as +// settingsAbsPaths). func worktreeSettingsPaths(worktreeRoot string) (base, local string) { - return filepath.Join(worktreeRoot, EntireSettingsFile), filepath.Join(worktreeRoot, EntireSettingsLocalFile) + return readSettingsPath(filepath.Join(worktreeRoot, EntireSettingsFile)), + readSettingsPath(filepath.Join(worktreeRoot, EntireSettingsLocalFile)) +} + +// legacySettingsPath maps a canonical .trace settings path to its pre-rebrand +// .entire counterpart. It returns "" for paths that are not one of the two +// settings files, so unrelated files are never remapped. +func legacySettingsPath(canonical string) string { + for _, pair := range [][2]string{ + {EntireSettingsFile, legacyEntireSettingsFile}, + {EntireSettingsLocalFile, legacyEntireSettingsLocalFile}, + } { + suffix := filepath.FromSlash(pair[0]) + if strings.HasSuffix(canonical, suffix) { + return strings.TrimSuffix(canonical, suffix) + filepath.FromSlash(pair[1]) + } + } + return "" +} + +// readSettingsPath returns the path a settings READ should use: the canonical +// .trace location when it exists, otherwise the legacy .entire location when +// that exists, otherwise the canonical path (callers treat missing files as +// defaults). +func readSettingsPath(canonical string) string { + if pathExists(canonical) { + return canonical + } + if legacy := legacySettingsPath(canonical); legacy != "" && pathExists(legacy) { + return legacy + } + return canonical +} + +// pathExists reports whether a file (any type) exists at path. +func pathExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +// migrateLegacySettingsFile lazily migrates a pre-rebrand .entire settings file +// to its canonical .trace location. When the canonical file does not exist but +// the legacy one does, the legacy bytes are copied to the canonical path before +// the caller's own write lands. This preserves the legacy content even if the +// subsequent write fails, and never deletes or rewrites the legacy file itself. +func migrateLegacySettingsFile(canonical string) { + if canonical == "" || pathExists(canonical) { + return + } + legacy := legacySettingsPath(canonical) + if legacy == "" { + return + } + data, err := os.ReadFile(legacy) //nolint:gosec // path derived from our own constants + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(canonical), 0o750); err != nil { + return + } + _ = os.WriteFile(canonical, data, 0o644) //nolint:gosec // best-effort pre-write copy } func loadForWorktreeRoot(ctx context.Context, worktreeRoot string) (*EntireSettings, error) { @@ -581,7 +653,8 @@ func LoadFromFile(filePath string) (*EntireSettings, error) { return loadFromFile(filePath) } -// LoadProjectRaw reads .entire/settings.json as a generic JSON object so +// LoadProjectRaw reads the project settings file (.trace/settings.json, with +// a read fallback to the legacy .entire/settings.json) as a generic JSON object so // callers can inspect or mutate individual keys without losing unrelated // fields to round-trip decoding. // @@ -612,13 +685,15 @@ func LoadLocalRaw(ctx context.Context) (path string, raw map[string]json.RawMess // loadRaw reads a settings file as a generic JSON object. label ("project" or // "local") only differentiates error wording so failures name the file -// actually being read. +// actually being read. The returned path is always the canonical .trace +// location (so paired saves migrate); the read itself falls back to the legacy +// .entire location when the canonical file is missing. func loadRaw(ctx context.Context, file, label string) (path string, raw map[string]json.RawMessage, exists bool, err error) { path, err = paths.AbsPath(ctx, file) if err != nil { path = file } - data, readErr := readConfined(path) + data, readErr := readConfined(readSettingsPath(path)) if readErr != nil { if errors.Is(readErr, fs.ErrNotExist) { return path, map[string]json.RawMessage{}, false, nil @@ -632,14 +707,14 @@ func loadRaw(ctx context.Context, file, label string) (path string, raw map[stri return path, raw, true, nil } -// SaveProjectRaw writes a generic JSON object back to .entire/settings.json -// atomically (temp file + rename). Callers should mutate the map returned by -// LoadProjectRaw and pass it back here so unrelated fields are preserved. +// SaveProjectRaw writes a generic JSON object back to the project settings +// file atomically (temp file + rename). Callers should mutate the map returned +// by LoadProjectRaw and pass it back here so unrelated fields are preserved. func SaveProjectRaw(path string, raw map[string]json.RawMessage) error { return saveRaw(path, "project", raw) } -// SaveLocalRaw writes a generic JSON object back to .entire/settings.local.json +// SaveLocalRaw writes a generic JSON object back to .trace/settings.local.json // atomically (temp file + rename). Mirrors SaveProjectRaw for the per-developer // overrides file; the only difference is the error wording, which says "local // settings" so failure messages match the file actually being written. @@ -652,7 +727,9 @@ func SaveLocalRaw(path string, raw map[string]json.RawMessage) error { } // saveRaw writes a generic JSON settings object atomically (temp file + -// rename). label matches loadRaw's error-wording convention. +// rename). label matches loadRaw's error-wording convention. Saves always +// target the canonical .trace path; a legacy .entire file is copied there +// first when the canonical file does not exist yet (lazy migration). func saveRaw(path, label string, raw map[string]json.RawMessage) error { data, err := jsonutil.MarshalIndentWithNewline(raw, "", " ") if err != nil { @@ -665,6 +742,7 @@ func saveRaw(path, label string, raw map[string]json.RawMessage) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("creating %s settings directory: %w", label, err) } + migrateLegacySettingsFile(path) if err := jsonutil.WriteFileAtomic(path, data, 0o644); err != nil { return fmt.Errorf("writing %s settings: %w", label, err) } @@ -1312,19 +1390,25 @@ func mergeStringMap(dst *map[string]string, raw json.RawMessage, field string) e } // IsSetUp returns true if Entire has been set up in the current repository. -// This checks if .entire/settings.json exists. +// This checks if the settings file exists (canonical .trace or legacy .entire). // Use this to avoid creating files/directories in repos where Entire was never enabled. func IsSetUp(ctx context.Context) bool { settingsFileAbs, err := paths.AbsPath(ctx, EntireSettingsFile) if err != nil { return false } - _, err = os.Lstat(settingsFileAbs) - return err == nil + if pathExists(settingsFileAbs) { + return true + } + if legacy := legacySettingsPath(settingsFileAbs); legacy != "" && pathExists(legacy) { + return true + } + return false } // IsSetUpAny returns true if Entire has been set up in the current repository, -// checking both .entire/settings.json and .entire/settings.local.json. +// checking both the settings file and the local settings override (canonical +// .trace paths plus their legacy .entire counterparts). // Use this to detect any prior setup, even if only local settings exist. func IsSetUpAny(ctx context.Context) bool { if IsSetUp(ctx) { @@ -1334,8 +1418,13 @@ func IsSetUpAny(ctx context.Context) bool { if err != nil { return false } - _, err = os.Lstat(localFileAbs) - return err == nil + if pathExists(localFileAbs) { + return true + } + if legacy := legacySettingsPath(localFileAbs); legacy != "" && pathExists(legacy) { + return true + } + return false } // IsSetUpAndEnabled returns true if Entire is both set up and enabled. @@ -1566,17 +1655,21 @@ func IsSignCheckpointCommitsEnabled(ctx context.Context) bool { return s.IsSignCheckpointCommitsEnabled() } -// Save saves the settings to .entire/settings.json. +// Save saves the settings to .trace/settings.json (migrating a legacy +// .entire/settings.json on the first save when .trace/settings.json is absent). func Save(ctx context.Context, settings *EntireSettings) error { return saveToFile(ctx, settings, EntireSettingsFile) } -// SaveLocal saves the settings to .entire/settings.local.json. +// SaveLocal saves the settings to .trace/settings.local.json (with the same +// legacy lazy-migration as Save). func SaveLocal(ctx context.Context, settings *EntireSettings) error { return saveToFile(ctx, settings, EntireSettingsLocalFile) } -// saveToFile saves settings to the specified file path. +// saveToFile saves settings to the specified file path. Saves always target +// the canonical .trace path; a legacy .entire file is copied there first when +// the canonical file does not exist yet (lazy migration). func saveToFile(ctx context.Context, settings *EntireSettings, filePath string) error { // Get absolute path for the file filePathAbs, err := paths.AbsPath(ctx, filePath) @@ -1590,6 +1683,8 @@ func saveToFile(ctx context.Context, settings *EntireSettings, filePath string) return fmt.Errorf("creating settings directory: %w", err) } + migrateLegacySettingsFile(filePathAbs) + data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ") if err != nil { return fmt.Errorf("marshaling settings: %w", err) diff --git a/cli/settings/settings_path_test.go b/cli/settings/settings_path_test.go new file mode 100644 index 0000000..9ee395e --- /dev/null +++ b/cli/settings/settings_path_test.go @@ -0,0 +1,180 @@ +package settings + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/trace/cli/paths" + "github.com/GrayCodeAI/trace/cli/testutil" +) + +// setupPathTestRepo creates a real git repo and chdirs into it, with a clean +// worktree-root cache so paths.AbsPath resolves inside the repo. +func setupPathTestRepo(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + t.Cleanup(paths.ClearWorktreeRootCache) + return tmpDir +} + +func writeLegacySettings(t *testing.T, repoDir, name, content string) { + t.Helper() + legacyDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(legacyDir, 0o755); err != nil { + t.Fatalf("failed to create legacy .entire dir: %v", err) + } + if err := os.WriteFile(filepath.Join(legacyDir, name), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write legacy %s: %v", name, err) + } +} + +// TestSettingsPaths_FreshInstallUsesTrace verifies a fresh repo has no legacy +// footprint: reads see defaults and saves land in .trace/. +func TestSettingsPaths_FreshInstallUsesTrace(t *testing.T) { + repoDir := setupPathTestRepo(t) + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() on fresh repo: %v", err) + } + if !s.Enabled { + t.Fatal("fresh repo should default to enabled") + } + + s.LogLevel = "debug" + if err := Save(context.Background(), s); err != nil { + t.Fatalf("Save() on fresh repo: %v", err) + } + + if _, err := os.Stat(filepath.Join(repoDir, ".trace", "settings.json")); err != nil { + t.Fatalf("save should write .trace/settings.json: %v", err) + } + if _, err := os.Stat(filepath.Join(repoDir, ".entire", "settings.json")); err == nil { + t.Fatal("fresh install must not create a legacy .entire/settings.json") + } +} + +// TestSettingsPaths_LegacyEntireIsRead verifies a pre-rebrand .entire +// settings file is still loaded (read compatibility, no migration on read). +func TestSettingsPaths_LegacyEntireIsRead(t *testing.T) { + repoDir := setupPathTestRepo(t) + writeLegacySettings(t, repoDir, "settings.json", `{"enabled": false, "log_level": "warn"}`) + writeLegacySettings(t, repoDir, "settings.local.json", `{"log_level": "error"}`) + + if !IsSetUp(context.Background()) { + t.Fatal("IsSetUp() should be true when only legacy .entire/settings.json exists") + } + if !IsSetUpAny(context.Background()) { + t.Fatal("IsSetUpAny() should be true when only legacy settings files exist") + } + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() with legacy settings: %v", err) + } + if s.Enabled { + t.Fatal("legacy enabled=false should be honored") + } + if s.LogLevel != "error" { + t.Fatalf("legacy local override should win, got log_level=%q", s.LogLevel) + } + + // Reading must not migrate anything. + if _, err := os.Stat(filepath.Join(repoDir, ".trace", "settings.json")); err == nil { + t.Fatal("Load() must not create .trace/settings.json") + } +} + +// TestSettingsPaths_LegacyLocalOnlyIsSetUp verifies a local-only legacy setup +// (enable --local wrote only settings.local.json) is still detected. +func TestSettingsPaths_LegacyLocalOnlyIsSetUp(t *testing.T) { + repoDir := setupPathTestRepo(t) + writeLegacySettings(t, repoDir, "settings.local.json", `{"enabled": true}`) + + if IsSetUp(context.Background()) { + t.Fatal("IsSetUp() should be false when no base settings file exists") + } + if !IsSetUpAny(context.Background()) { + t.Fatal("IsSetUpAny() should be true for a legacy local-only setup") + } +} + +// TestSettingsPaths_FirstSaveMigrates verifies the lazy migration: the first +// save copies the legacy content to the canonical .trace location (and then +// writes the saved settings there), while the legacy file is left untouched. +func TestSettingsPaths_FirstSaveMigrates(t *testing.T) { + repoDir := setupPathTestRepo(t) + const legacyContent = `{"enabled": true, "log_level": "warn"}` + writeLegacySettings(t, repoDir, "settings.json", legacyContent) + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load(): %v", err) + } + s.LogLevel = "debug" + if err := Save(context.Background(), s); err != nil { + t.Fatalf("Save(): %v", err) + } + + // Canonical file now exists and carries the saved (migrated) settings. + canonical := filepath.Join(repoDir, ".trace", "settings.json") + data, err := os.ReadFile(canonical) + if err != nil { + t.Fatalf("first save should create %s: %v", canonical, err) + } + var saved struct { + Enabled bool `json:"enabled"` + LogLevel string `json:"log_level"` + } + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("parsing saved settings: %v", err) + } + if !saved.Enabled || saved.LogLevel != "debug" { + t.Fatalf("saved settings should carry migrated content, got %s", data) + } + + // The legacy file is preserved verbatim. + legacyData, err := os.ReadFile(filepath.Join(repoDir, ".entire", "settings.json")) + if err != nil { + t.Fatalf("legacy file must be preserved: %v", err) + } + if string(legacyData) != legacyContent { + t.Fatalf("legacy file must be untouched, got %s", legacyData) + } + + // Subsequent loads prefer the canonical file. + s2, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() after migration: %v", err) + } + if s2.LogLevel != "debug" { + t.Fatalf("post-migration load should read canonical file, got log_level=%q", s2.LogLevel) + } +} + +// TestSettingsPaths_LoadProjectRawReturnsCanonicalPath verifies the raw +// read-modify-write pair reads legacy content but reports (and therefore +// saves to) the canonical path. +func TestSettingsPaths_LoadProjectRawReturnsCanonicalPath(t *testing.T) { + repoDir := setupPathTestRepo(t) + writeLegacySettings(t, repoDir, "settings.json", `{"enabled": true, "log_level": "warn"}`) + + path, raw, exists, err := LoadProjectRaw(context.Background()) + if err != nil || !exists { + t.Fatalf("LoadProjectRaw() = (%q, %v, %v, %v), want exists", path, raw, exists, err) + } + // Compare suffixes: git may resolve the macOS /var symlink to /private/var. + if !strings.HasSuffix(path, filepath.Join(".trace", "settings.json")) { + t.Fatalf("LoadProjectRaw() path = %q, want canonical .trace/settings.json under %q", path, repoDir) + } + if string(raw["log_level"]) != `"warn"` { + t.Fatalf("LoadProjectRaw() should read legacy content, got %v", raw) + } +} diff --git a/cli/setup.go b/cli/setup.go index cc07409..eac424d 100644 --- a/cli/setup.go +++ b/cli/setup.go @@ -30,8 +30,8 @@ import ( // Config path display strings const ( - configDisplayProject = ".entire/settings.json" - configDisplayLocal = ".entire/settings.local.json" + configDisplayProject = ".trace/settings.json" + configDisplayLocal = ".trace/settings.local.json" ) // Flag names used across setup commands. @@ -780,8 +780,8 @@ Examples: cmd.Flags().BoolVar(&opts.LocalDev, flagLocalDev, false, "Use go run instead of entire binary for hooks") cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above - cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .entire/settings.local.json instead of .entire/settings.json") - cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") + cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json") + cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists") cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Reinstall the Entire git hook") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)") @@ -925,8 +925,8 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above cmd.Flags().BoolVar(&ignoreUntracked, "ignore-untracked", false, "Commit all new files without tracking pre-existing untracked files") cmd.Flags().MarkHidden("ignore-untracked") //nolint:errcheck,gosec // flag is defined above - cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .entire/settings.local.json instead of .entire/settings.json") - cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") + cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json") + cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists") cmd.Flags().StringVar(&agentName, agentFlagName, "", "Agent to set up hooks for (e.g., "+strings.Join(agent.StringList(), ", ")+"; external agents on $PATH are also available). Enables non-interactive mode.") cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Force reinstall hooks (removes existing Entire hooks first)") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") @@ -1080,8 +1080,8 @@ To completely remove Entire integrations from this repository, use --uninstall: }, } - cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Update .entire/settings.local.json (the default) instead of .entire/settings.json") - cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .entire/settings.json instead of .entire/settings.local.json") + cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Update .trace/settings.local.json (the default) instead of .trace/settings.json") + cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .trace/settings.json instead of .trace/settings.local.json") cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Completely remove Entire from this repository") cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt (use with --uninstall)") @@ -1250,7 +1250,9 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent if err != nil { entireDirAbs = paths.EntireDir // Fallback to relative } - shouldUseLocal, showNotification := determineSettingsTarget(entireDirAbs, opts.UseLocalSettings, opts.UseProjectSettings) + shouldUseLocal, showNotification := determineSettingsTarget( + existingProjectSettingsPath(ctx, entireDirAbs), opts.UseLocalSettings, opts.UseProjectSettings, + ) if showNotification { fmt.Fprintln(w, "Info: Project settings exist. Saving to settings.local.json instead.") @@ -1994,10 +1996,12 @@ func validateSetupFlags(useLocal, useProject bool) error { } // determineSettingsTarget decides whether to write to settings.local.json based on: -// - Whether settings.json already exists +// - Whether the project settings file already exists // - The --local and --project flags -// Returns (useLocal, showNotification). -func determineSettingsTarget(entireDir string, useLocal, useProject bool) (bool, bool) { +// Returns (useLocal, showNotification). projectSettingsPath is the path the +// caller resolved for the project settings file (canonical .trace location, +// or the legacy .entire location when only that exists). +func determineSettingsTarget(projectSettingsPath string, useLocal, useProject bool) (bool, bool) { // Explicit --local flag always uses local settings if useLocal { return true, false @@ -2009,8 +2013,7 @@ func determineSettingsTarget(entireDir string, useLocal, useProject bool) (bool, } // No flags specified - check if settings file exists - settingsPath := filepath.Join(entireDir, paths.SettingsFileName) - if _, err := os.Lstat(settingsPath); err == nil { + if _, err := os.Lstat(projectSettingsPath); err == nil { // Settings file exists - auto-redirect to local with notification return true, true } @@ -2019,6 +2022,25 @@ func determineSettingsTarget(entireDir string, useLocal, useProject bool) (bool, return false, false } +// existingProjectSettingsPath returns the absolute path of the project +// settings file when it exists — the canonical .trace/settings.json, or the +// legacy .entire/settings.json when only that exists — defaulting to the +// canonical path when neither exists. +func existingProjectSettingsPath(ctx context.Context, entireDirAbs string) string { + projectSettingsAbs, err := paths.AbsPath(ctx, settings.EntireSettingsFile) + if err != nil { + projectSettingsAbs = settings.EntireSettingsFile // Fallback to relative + } + if _, statErr := os.Lstat(projectSettingsAbs); statErr == nil { + return projectSettingsAbs + } + legacy := filepath.Join(entireDirAbs, paths.SettingsFileName) + if _, statErr := os.Lstat(legacy); statErr == nil { + return legacy + } + return projectSettingsAbs +} + // setupEntireDirectory creates the .entire directory and gitignore. // Returns true if the directory was created, false if it already existed. func setupEntireDirectory(ctx context.Context) (bool, error) { //nolint:unparam // already present in codebase @@ -2455,14 +2477,18 @@ func countShadowBranches(ctx context.Context) int { return len(branches) } -// checkEntireDirExists checks if the .entire directory exists. +// checkEntireDirExists checks if the .entire or .trace directory exists. func checkEntireDirExists(ctx context.Context) bool { - entireDirAbs, err := paths.AbsPath(ctx, paths.EntireDir) - if err != nil { - entireDirAbs = paths.EntireDir + for _, dir := range []string{paths.EntireDir, paths.TraceDir} { + dirAbs, err := paths.AbsPath(ctx, dir) + if err != nil { + dirAbs = dir + } + if _, err := os.Lstat(dirAbs); err == nil { + return true + } } - _, err = os.Lstat(entireDirAbs) - return err == nil + return false } // removeAgentHooks removes hooks from all agents that support hooks. @@ -2517,14 +2543,16 @@ func removeAllSessionStates(ctx context.Context) (int, error) { return count, nil } -// removeEntireDirectory removes the .entire directory. +// removeEntireDirectory removes the .entire and .trace directories. func removeEntireDirectory(ctx context.Context) error { - entireDirAbs, err := paths.AbsPath(ctx, paths.EntireDir) - if err != nil { - entireDirAbs = paths.EntireDir - } - if err := os.RemoveAll(entireDirAbs); err != nil { - return fmt.Errorf("failed to remove .entire directory: %w", err) + for _, dir := range []string{paths.EntireDir, paths.TraceDir} { + dirAbs, err := paths.AbsPath(ctx, dir) + if err != nil { + dirAbs = dir + } + if err := os.RemoveAll(dirAbs); err != nil { + return fmt.Errorf("failed to remove %s directory: %w", dir, err) + } } return nil } diff --git a/cli/setup_test.go b/cli/setup_test.go index e529668..a2d8e44 100644 --- a/cli/setup_test.go +++ b/cli/setup_test.go @@ -1137,7 +1137,7 @@ func TestDetermineSettingsTarget_ExplicitLocalFlag(t *testing.T) { } // With --local flag, should always use local - useLocal, showNotification := determineSettingsTarget(tmpDir, true, false) + useLocal, showNotification := determineSettingsTarget(settingsPath, true, false) if !useLocal { t.Error("determineSettingsTarget() should return useLocal=true with --local flag") } @@ -1156,7 +1156,7 @@ func TestDetermineSettingsTarget_ExplicitProjectFlag(t *testing.T) { } // With --project flag, should always use project - useLocal, showNotification := determineSettingsTarget(tmpDir, false, true) + useLocal, showNotification := determineSettingsTarget(settingsPath, false, true) if useLocal { t.Error("determineSettingsTarget() should return useLocal=false with --project flag") } @@ -1175,7 +1175,7 @@ func TestDetermineSettingsTarget_SettingsExists_NoFlags(t *testing.T) { } // Without flags, should auto-redirect to local with notification - useLocal, showNotification := determineSettingsTarget(tmpDir, false, false) + useLocal, showNotification := determineSettingsTarget(settingsPath, false, false) if !useLocal { t.Error("determineSettingsTarget() should return useLocal=true when settings.json exists") } @@ -1190,7 +1190,8 @@ func TestDetermineSettingsTarget_SettingsNotExists_NoFlags(t *testing.T) { // No settings.json exists // Should use project settings (create new) - useLocal, showNotification := determineSettingsTarget(tmpDir, false, false) + missingPath := filepath.Join(tmpDir, paths.SettingsFileName) + useLocal, showNotification := determineSettingsTarget(missingPath, false, false) if useLocal { t.Error("determineSettingsTarget() should return useLocal=false when settings.json doesn't exist") } @@ -1219,23 +1220,33 @@ func TestRunUninstall_Force_NothingInstalled(t *testing.T) { func TestRunUninstall_Force_RemovesEntireDirectory(t *testing.T) { setupTestRepo(t) - // Create .entire directory with settings + // Create the settings directory (.trace) plus the infrastructure + // directory (.entire), mirroring what enable provisions. writeSettings(t, testSettingsEnabled) + if err := os.MkdirAll(paths.EntireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } - // Verify directory exists - entireDir := paths.EntireDir - if _, err := os.Stat(entireDir); os.IsNotExist(err) { + // Verify both directories exist + traceDir := paths.TraceDir + if _, err := os.Stat(traceDir); os.IsNotExist(err) { + t.Fatal(".trace directory should exist before uninstall") + } + if _, err := os.Stat(paths.EntireDir); os.IsNotExist(err) { t.Fatal(".entire directory should exist before uninstall") } var stdout, stderr bytes.Buffer err := runUninstall(context.Background(), &stdout, &stderr, true) if err != nil { - t.Fatalf("runUninstall() error = %v", err) + t.Fatalf("runUninstall() error: %v", err) } - // Verify directory is removed - if _, err := os.Stat(entireDir); !os.IsNotExist(err) { + // Verify both directories are removed + if _, err := os.Stat(traceDir); !os.IsNotExist(err) { + t.Error(".trace directory should be removed after uninstall") + } + if _, err := os.Stat(paths.EntireDir); !os.IsNotExist(err) { t.Error(".entire directory should be removed after uninstall") } @@ -2539,7 +2550,7 @@ func TestManageAgents_NoChanges_StillPersistsVercelSetting(t *testing.T) { if strings.Contains(buf.String(), "No changes made.") { t.Fatalf("did not expect no-op output when settings changed, got: %s", buf.String()) } - if !strings.Contains(buf.String(), ".entire/settings.json") { + if !strings.Contains(buf.String(), ".trace/settings.json") { t.Fatalf("expected settings update output, got: %s", buf.String()) } @@ -2757,7 +2768,7 @@ func TestMaybePromptVercelDeploymentDisable_SkipsPromptWhenAlreadyDisabledInVerc if promptCalled { t.Fatal("expected Vercel prompt to be skipped when already configured") } - if !strings.Contains(buf.String(), ".entire/settings.json") { + if !strings.Contains(buf.String(), ".trace/settings.json") { t.Fatalf("expected settings update output, got %q", buf.String()) } diff --git a/cli/strategy/common.go b/cli/strategy/common.go index 9cd9370..951464e 100644 --- a/cli/strategy/common.go +++ b/cli/strategy/common.go @@ -334,6 +334,7 @@ func checkpointInfosFromCommitted(committed []checkpoint.CheckpointInfo) []Check const ( entireGitignore = ".entire/.gitignore" entireDir = ".entire" + traceGitignore = ".trace/.gitignore" gitDir = ".git" shadowBranchPrefix = "entire/" ) @@ -1128,12 +1129,34 @@ func GetGitCommonDir(ctx context.Context) (string, error) { } // EnsureEntireGitignore ensures all required entries are in .entire/.gitignore -// Works correctly from any subdirectory within the repository. +// and .trace/.gitignore (the canonical settings dir's local override must stay +// untracked, mirroring the legacy layout). Works correctly from any +// subdirectory within the repository. func EnsureEntireGitignore(ctx context.Context) error { + if err := ensureGitignoreEntries(ctx, entireGitignore, entireDir, []string{ + "tmp/", + "settings.local.json", + "metadata/", + "logs/", + redact.RedactorsDirName + "/local/", + }); err != nil { + return err + } + // The .trace dir holds only the settings files; its local override is the + // sole entry that must be ignored there. + return ensureGitignoreEntries(ctx, traceGitignore, paths.TraceDir, []string{ + "settings.local.json", + }) +} + +// ensureGitignoreEntries appends any missing entries to the gitignore file at +// gitignorePath (resolved relative to the worktree root), creating its parent +// directory when needed. dirLabel names the directory in error messages. +func ensureGitignoreEntries(ctx context.Context, gitignorePath, dirLabel string, requiredEntries []string) error { // Get absolute path for the gitignore file - gitignoreAbs, err := paths.AbsPath(ctx, entireGitignore) + gitignoreAbs, err := paths.AbsPath(ctx, gitignorePath) if err != nil { - gitignoreAbs = entireGitignore // Fallback to relative + gitignoreAbs = gitignorePath // Fallback to relative } // Read existing content @@ -1142,15 +1165,6 @@ func EnsureEntireGitignore(ctx context.Context) error { content = string(data) } - // All entries that should be in .entire/.gitignore - requiredEntries := []string{ - "tmp/", - "settings.local.json", - "metadata/", - "logs/", - redact.RedactorsDirName + "/local/", - } - // Track what needs to be added var toAdd []string for _, entry := range requiredEntries { @@ -1164,9 +1178,9 @@ func EnsureEntireGitignore(ctx context.Context) error { return nil } - // Ensure .entire directory exists + // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(gitignoreAbs), 0o750); err != nil { - return fmt.Errorf("failed to create .entire directory: %w", err) + return fmt.Errorf("failed to create %s directory: %w", dirLabel, err) } // Append missing entries to gitignore diff --git a/cli/strategy/hooks.go b/cli/strategy/hooks.go index 043ed2e..d1858ee 100644 --- a/cli/strategy/hooks.go +++ b/cli/strategy/hooks.go @@ -12,6 +12,7 @@ import ( "sync" "syscall" + "github.com/GrayCodeAI/trace/cli/agent" "github.com/GrayCodeAI/trace/cli/settings" ) @@ -261,8 +262,8 @@ func gitHookCommand(cmdPrefix, args string, warnMissing bool) string { } func gitHookCommandAvailableTest(cmdPrefix string) (string, bool) { - if cmdPrefix == "entire" { - return "command -v entire >/dev/null 2>&1", true + if name, ok := plainCommandName(cmdPrefix); ok { + return fmt.Sprintf("command -v %s >/dev/null 2>&1", name), true } if isWindowsAbsoluteHookCommand(cmdPrefix) { return fmt.Sprintf("[ -f %s ]", cmdPrefix), true @@ -273,6 +274,26 @@ func gitHookCommandAvailableTest(cmdPrefix string) (string, bool) { return "", false } +// plainCommandName returns the leading token of cmdPrefix when that token is a +// plain PATH-resolvable command name (letters, digits, dashes, underscores — +// no separators or quotes). It covers the resolved hook binary names ("entire", +// "trace", or the "hawk" of "hawk trace") while leaving local-dev script paths +// ("./scripts/entire-dev"), absolute paths, and quoted paths to their branches. +func plainCommandName(cmdPrefix string) (string, bool) { + name, _, _ := strings.Cut(cmdPrefix, " ") + if name == "" { + return "", false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + default: + return "", false + } + } + return name, true +} + func isWindowsAbsoluteHookCommand(cmdPrefix string) bool { path := strings.TrimPrefix(cmdPrefix, "'") if len(path) < len("C:\\") || path[1] != ':' { @@ -467,6 +488,9 @@ fi // When absolutePath is true, resolves the full binary path via os.Executable() // and returns an error if resolution fails. This is needed for GUI git clients // (Xcode, Tower, etc.) that don't source shell profiles. +// Otherwise the prefix is resolved from the running executable and PATH (see +// agent.HookCommandPrefix): the embedding host's "hawk trace", the canonical +// "trace", or the legacy "entire" fallback so existing installs keep working. func hookCmdPrefix(localDev, absolutePath bool) (string, error) { if localDev { return localDevHookCmdPrefix, nil @@ -482,7 +506,7 @@ func hookCmdPrefix(localDev, absolutePath bool) (string, error) { } return shellQuote(resolved), nil } - return "entire", nil + return agent.HookCommandPrefix(), nil } // resolveHookExePath resolves exe through symlinks for embedding as an absolute diff --git a/cli/strategy/hooks_test.go b/cli/strategy/hooks_test.go index feb9661..07dbbfb 100644 --- a/cli/strategy/hooks_test.go +++ b/cli/strategy/hooks_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/trace/cli/agent" "github.com/GrayCodeAI/trace/cli/paths" ) @@ -65,6 +66,10 @@ func initHooksTestRepo(t *testing.T) (string, string) { t.Helper() tmpDir := t.TempDir() t.Chdir(tmpDir) + // Pin hook-binary resolution to the legacy name so assertions on the + // installed hook content are deterministic regardless of any trace / + // entire binaries on the host PATH. + t.Cleanup(agent.PinLegacyHookBinaryForTesting()) ctx := context.Background() cmd := exec.CommandContext(ctx, "git", "init") diff --git a/cli/strategy/manual_commit_opf_prompt_test.go b/cli/strategy/manual_commit_opf_prompt_test.go index 50ae938..800136d 100644 --- a/cli/strategy/manual_commit_opf_prompt_test.go +++ b/cli/strategy/manual_commit_opf_prompt_test.go @@ -87,13 +87,13 @@ func TestResolveOPFDecision_Precedence(t *testing.T) { // TestPersistOPFPromptDefaultAlways_WritesNestedField verifies that the // "Always" branch updates redaction.openai_privacy_filter.prompt_default -// in .entire/settings.local.json without disturbing other fields. +// in .trace/settings.local.json without disturbing other fields. // // Modifies process cwd (no t.Parallel), but uses t.Chdir so subsequent // tests see the reverted cwd. func TestPersistOPFPromptDefaultAlways_WritesNestedField(t *testing.T) { tempDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.EntireDir), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.TraceDir), 0o755)) // Seed an existing settings.local.json with some unrelated content // so we can verify it survives the write. existing := `{ @@ -104,7 +104,7 @@ func TestPersistOPFPromptDefaultAlways_WritesNestedField(t *testing.T) { } } }` - localPath := filepath.Join(tempDir, paths.EntireDir, "settings.local.json") + localPath := filepath.Join(tempDir, paths.TraceDir, "settings.local.json") require.NoError(t, os.WriteFile(localPath, []byte(existing), 0o644)) t.Chdir(tempDir) @@ -131,15 +131,15 @@ func TestPersistOPFPromptDefaultAlways_WritesNestedField(t *testing.T) { } // TestPersistOPFPromptDefaultAlways_CreatesFileFromScratch covers the -// fresh-install path where .entire/settings.local.json doesn't exist yet. +// fresh-install path where .trace/settings.local.json doesn't exist yet. func TestPersistOPFPromptDefaultAlways_CreatesFileFromScratch(t *testing.T) { tempDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.EntireDir), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.TraceDir), 0o755)) t.Chdir(tempDir) require.NoError(t, persistOPFPromptDefaultAlways(context.Background())) - localPath := filepath.Join(tempDir, paths.EntireDir, "settings.local.json") + localPath := filepath.Join(tempDir, paths.TraceDir, "settings.local.json") got, err := os.ReadFile(localPath) require.NoError(t, err, "settings.local.json should be created") diff --git a/cli/trace.go b/cli/trace.go index 483c715..8d52ca8 100644 --- a/cli/trace.go +++ b/cli/trace.go @@ -250,7 +250,7 @@ func renderTraceEntries(w io.Writer, entries []traceEntry) { if len(entries) == 0 { fmt.Fprintln(w, "No trace entries found.") fmt.Fprintln(w, `Traces are logged at DEBUG level. Make sure ENTIRE_LOG_LEVEL=DEBUG is set`) - fmt.Fprintln(w, `in your shell profile, or set log_level to "DEBUG" in .entire/settings.json.`) + fmt.Fprintln(w, `in your shell profile, or set log_level to "DEBUG" in .trace/settings.json.`) return } diff --git a/cli/trace_cmd.go b/cli/trace_cmd.go index 05364ba..45a03f0 100644 --- a/cli/trace_cmd.go +++ b/cli/trace_cmd.go @@ -20,7 +20,7 @@ func newTraceCmd() *cobra.Command { Traces are emitted at DEBUG log level. To enable them, either: - Set ENTIRE_LOG_LEVEL=DEBUG in your shell profile - - Add "log_level": "DEBUG" to .entire/settings.json + - Add "log_level": "DEBUG" to .trace/settings.json Examples: entire doctor trace Show the most recent hook trace diff --git a/go.mod b/go.mod index 6ea7451..194d2eb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/GrayCodeAI/trace -go 1.26.5 +go 1.26.6 require ( charm.land/bubbles/v2 v2.1.1 diff --git a/internal/coreapi/gen.go b/internal/coreapi/gen.go index e7acaf6..c87a3a8 100644 --- a/internal/coreapi/gen.go +++ b/internal/coreapi/gen.go @@ -21,5 +21,7 @@ package coreapi //go:generate go run spec/normalize.go -//go:generate go run github.com/ogen-go/ogen/cmd/ogen@v1.20.3 --target . --package coreapi --config ogen.yml --clean spec/core.gen.json +// NOTE: pinned to v1.23.0 to match go.mod; regenerating the oas_*.go files +// with it is still pending (large diff, tracked separately). +//go:generate go run github.com/ogen-go/ogen/cmd/ogen@v1.23.0 --target . --package coreapi --config ogen.yml --clean spec/core.gen.json //go:generate gofmt -s -w .