From a1dd94b16076e062f9204bb3fe5591f67777f364 Mon Sep 17 00:00:00 2001 From: aksops Date: Sat, 9 May 2026 11:14:15 +0000 Subject: [PATCH] refactor(claude): make ctm pure-overlay; never mutate Claude config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctm previously wrote three Claude-owned config files at install / first-run time: - ~/.claude.json: remoteControlAtStartup=true - ~/.claude/settings.json: tui="fullscreen", viewMode="focus" - ~/.config/ctm/env.sh: sourced by the launching shell This commit reduces the surface to zero. ctm now never writes to any Claude-owned config. All ctm-side defaults are delivered via ~/.config/ctm/claude-overlay.json (passed via 'claude --settings') and ~/.config/ctm/claude-env.json (read by ctm and exported into the launch shell as a Go-built export prelude). Direct 'claude' invocations outside ctm are now completely unaffected. (1) Move tui, viewMode, remoteControlAtStartup into the overlay template (cmd/overlay.go: buildSampleOverlay). Delete: - claude.EnsureTUIFullscreen + EnsureViewModeFocus - claude.EnsureRemoteControlAtStartup + ClaudeJSONPath - claude.patchJSONFile (no remaining callers) plus their tests. Drop the matching ensureClaude*Default helpers and call sites in cmd/bootstrap.go + cmd/install.go. (2) Replace bash-script env.sh with JSON-shaped claude-env.json. New internal/config/claude_env.go provides: - LoadClaudeEnv(path) — strict JSON load, key-name validation - (ClaudeEnvFile).ShellExports() — alphabetised, single-quote- escaped 'export K1=V1 K2=V2' string - ClaudeEnvExports() — one-call convenience for the launch path BuildCommand's last param changes from envFilePath to envExports (a pre-built shell prelude); EnvFilePathIfExists removed. Sample file pre-seeds CLAUDE_CODE_NO_FLICKER and CTM_STATUSLINE_DUMP (the same two vars the old env.sh shipped). claude.SettingsJSONPath + ReadEffortLevel are kept — read-only, used by the statusline renderer. Existing users: ctm auto-creates claude-env.json on next launch via ensureOverlaySidecars; their stale env.sh becomes inert (per the hard-cutover plan; not auto-deleted). Verified: go vet -tags sqlite_fts5 ./..., go build, and go test -tags sqlite_fts5 -race ./... — all 27 packages green. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/attach.go | 6 +- cmd/bootstrap.go | 57 +--- cmd/bootstrap_test.go | 4 +- cmd/install.go | 23 +- cmd/overlay.go | 82 +++--- cmd/overlay_test.go | 31 +- internal/claude/command.go | 46 ++- internal/claude/command_test.go | 60 +--- internal/claude/jsonpatch.go | 58 ---- internal/claude/remote_control.go | 51 ---- internal/claude/remote_control_test.go | 172 ----------- internal/claude/tui.go | 67 +---- internal/claude/tui_test.go | 380 ------------------------- internal/config/claude_env.go | 117 ++++++++ internal/config/claude_env_test.go | 128 +++++++++ internal/config/config.go | 18 +- internal/session/spawn.go | 9 +- 17 files changed, 384 insertions(+), 925 deletions(-) delete mode 100644 internal/claude/jsonpatch.go delete mode 100644 internal/claude/remote_control.go delete mode 100644 internal/claude/remote_control_test.go create mode 100644 internal/config/claude_env.go create mode 100644 internal/config/claude_env_test.go diff --git a/cmd/attach.go b/cmd/attach.go index da970b4..5573831 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -93,7 +93,7 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli Tmux: tc, Store: store, OverlayPath: claude.OverlayPathIfExists(config.ClaudeOverlayPath()), - EnvFilePath: claude.EnvFilePathIfExists(config.EnvFilePath()), + EnvExports: config.ClaudeEnvExports(), }) if err != nil { return fmt.Errorf("createAndAttach spawn: %w", err) @@ -149,7 +149,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t tmuxResult := health.CheckTmuxSession(tc, sess.Name) if !tmuxResult.Passed() { out.Warn("tmux session %q missing — recreating", sess.Name) - shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath())) + shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), config.ClaudeEnvExports()) if err := tc.NewSession(sess.Name, sess.Workdir, shellCmd); err != nil { return fmt.Errorf("recreating tmux session: %w", err) } @@ -173,7 +173,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t if !claudeResult.Passed() { out.Debug(Verbose, "claude not running, restarting with --resume") out.Warn("claude process dead — respawning") - shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath())) + shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), config.ClaudeEnvExports()) if err := tc.RespawnPane(sess.Name, shellCmd); err != nil { return fmt.Errorf("respawning pane: %w", err) } diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index f5f63a2..7af0957 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" - "github.com/RandomCodeSpace/ctm/internal/claude" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/logrotate" "github.com/RandomCodeSpace/ctm/internal/migrate" @@ -24,7 +23,7 @@ import ( // - creates ~/.config/ctm/ if missing // - writes config.json with defaults if missing // - regenerates tmux.conf on every call so new defaults reach upgraded users -// - writes claude-overlay.json + env.sh + logs/ dir if missing +// - writes claude-overlay.json + claude-env.json + logs/ dir if missing // - injects shell aliases into ~/.bashrc and ~/.zshrc if markers not present func ensureSetup() (*config.Config, error) { if err := os.MkdirAll(config.Dir(), 0755); err != nil { @@ -45,9 +44,6 @@ func ensureSetup() (*config.Config, error) { } _ = ensureOverlaySidecars() _ = ensureAliases() - _ = ensureClaudeRemoteControlDefault() - _ = ensureClaudeTUIFullscreenDefault() - _ = ensureClaudeViewModeFocusDefault() _ = pruneSessionLogs(cfg) return &cfg, nil } @@ -100,55 +96,12 @@ func runStateMigrations() error { return nil } -// ensureClaudeRemoteControlDefault opts new Claude Code installs into Remote -// Control by default. Never creates ~/.claude.json, never overwrites an -// explicit user choice (true or false) — only fills in the key when it is -// absent. See internal/claude.EnsureRemoteControlAtStartup for the full -// contract. Errors are swallowed; this is convenience, not correctness. -func ensureClaudeRemoteControlDefault() error { - path, err := claude.ClaudeJSONPath() - if err != nil { - return err - } - return claude.EnsureRemoteControlAtStartup(path) -} - -// ensureClaudeTUIFullscreenDefault pins Claude Code's TUI renderer to -// "fullscreen" in ~/.claude/settings.json when the key is absent or set to -// "default". Any other explicit value (e.g., "compact") is treated as a -// deliberate user choice and left alone. See -// internal/claude.EnsureTUIFullscreen for the full contract. -func ensureClaudeTUIFullscreenDefault() error { - path, err := claude.SettingsJSONPath() - if err != nil { - return err - } - return claude.EnsureTUIFullscreen(path) -} - -// ensureClaudeViewModeFocusDefault pins Claude Code's default transcript -// view mode to "focus" in ~/.claude/settings.json when the key is absent -// or set to "default". Any other explicit value ("verbose" or a future -// mode) is treated as a deliberate user choice and left alone. See -// internal/claude.EnsureViewModeFocus for the full contract. -// -// Pairs with ensureClaudeTUIFullscreenDefault — focus view only renders -// under the fullscreen TUI, so we set both together to land on the -// mobile-first default ctm is optimised for. -func ensureClaudeViewModeFocusDefault() error { - path, err := claude.SettingsJSONPath() - if err != nil { - return err - } - return claude.EnsureViewModeFocus(path) -} - -// ensureOverlaySidecars writes claude-overlay.json, env.sh, and the -// per-session logs dir if any are missing. Leaves existing files alone — -// user edits to overlay/env always win. +// ensureOverlaySidecars writes claude-overlay.json, claude-env.json, and +// the per-session logs dir if any are missing. Leaves existing files +// alone — user edits to overlay/env always win. func ensureOverlaySidecars() error { _ = os.MkdirAll(sessionLogDir(), 0755) - _ = writeEnvFile(config.EnvFilePath()) + _ = writeClaudeEnv(config.ClaudeEnvPath()) overlay := config.ClaudeOverlayPath() if _, err := os.Stat(overlay); err == nil { diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index b8717bc..0a5f85e 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -36,7 +36,7 @@ func TestEnsureSetupCreatesAllArtifacts(t *testing.T) { config.ConfigPath(), config.TmuxConfPath(), config.ClaudeOverlayPath(), - config.EnvFilePath(), + config.ClaudeEnvPath(), } for _, p := range wantFiles { if _, err := os.Stat(p); err != nil { @@ -133,7 +133,7 @@ func TestOverlayAndEnvFilePermsAre0600(t *testing.T) { } for _, path := range []string{ config.ClaudeOverlayPath(), - config.EnvFilePath(), + config.ClaudeEnvPath(), } { info, err := os.Stat(path) if err != nil { diff --git a/cmd/install.go b/cmd/install.go index e4eb106..0a6a2c1 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -90,23 +90,12 @@ func runInstall(cmd *cobra.Command, args []string) error { } } - // 6. Claude-side defaults (idempotent, conservative). - // - // These mirror ensureSetup()'s claude-side bootstrap so `ctm install` - // is a full explicit setup, not just a partial one. Each helper is - // strictly no-op when the relevant key is absent or explicitly - // "default"; any other user value is respected. See - // internal/claude.{EnsureRemoteControlAtStartup,EnsureTUIFullscreen, - // EnsureViewModeFocus} for the per-key contracts. - if err := ensureClaudeRemoteControlDefault(); err == nil { - out.Success("Claude remote control: default on (~/.claude.json)") - } - if err := ensureClaudeTUIFullscreenDefault(); err == nil { - out.Success("Claude TUI: fullscreen (~/.claude/settings.json)") - } - if err := ensureClaudeViewModeFocusDefault(); err == nil { - out.Success("Claude viewMode: focus (~/.claude/settings.json)") - } + // 6. Claude-side defaults are now expressed entirely in + // ~/.config/ctm/claude-overlay.json (created by step 6 of ensureSetup + // via writeOverlayFile). ctm never mutates ~/.claude.json or + // ~/.claude/settings.json — the overlay is merged in via + // `claude --settings` only when claude is launched through ctm, + // leaving direct `claude` invocations completely unaffected. // 7. Print summary fmt.Println() diff --git a/cmd/overlay.go b/cmd/overlay.go index 4d38a84..ca6323b 100644 --- a/cmd/overlay.go +++ b/cmd/overlay.go @@ -85,10 +85,17 @@ func statuslineHookCommand() string { return ctmSubcommand("statusline") } // Both hook commands are resolved to the ctm binary at write time so they // keep working even if PATH changes. // -// Note: env vars like CLAUDE_CODE_NO_FLICKER cannot go here — claude reads -// them too early in startup for settings.json's env key to take effect. -// They live in ~/.config/ctm/env.sh (see sampleEnvFile) and are sourced -// by the shell before claude launches. +// `tui`, `viewMode`, and `remoteControlAtStartup` live here (not in +// ~/.claude/settings.json or ~/.claude.json) so ctm never mutates any +// Claude-owned config file on disk. The overlay is merged on top of +// settings.json only when claude is launched via ctm — direct `claude` +// invocations are completely unaffected by ctm's defaults. +// +// Note: env vars like CLAUDE_CODE_NO_FLICKER cannot go here — claude +// reads them too early in startup for settings.json's env key to take +// effect. They live in ~/.config/ctm/claude-env.json (see +// sampleClaudeEnvJSON) and are exported by the shell before claude +// launches via config.ClaudeEnvExports(). func buildSampleOverlay(statuslineCmd, logHookCmd string) string { return fmt.Sprintf(`{ "reduceMotion": false, @@ -98,6 +105,9 @@ func buildSampleOverlay(statuslineCmd, logHookCmd string) string { "command": %q }, "theme": "dark", + "tui": "fullscreen", + "viewMode": "focus", + "remoteControlAtStartup": true, "env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }, @@ -118,42 +128,48 @@ func buildSampleOverlay(statuslineCmd, logHookCmd string) string { `, statuslineCmd, logHookCmd) } -// sampleEnvFile is the bash env script sourced by the tmux shell before -// claude launches. Use this for env vars that claude reads DURING CLI -// startup, which are too early for settings.json's env key to affect. -// Most env vars (including CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) can -// go in claude-overlay.json's env block instead and should — settings -// is the canonical home per Claude Code's docs. -const sampleEnvFile = `# ctm-managed env file — sourced by the shell that spawns claude. -# Only affects claude processes launched via ctm. Direct 'claude' calls -# outside ctm are unaffected (this file is never sourced then). -# -# Use this file only for env vars that claude reads too early in -# startup for settings.json's "env" block to take effect. For anything -# else, prefer the overlay at ~/.config/ctm/claude-overlay.json. +// sampleClaudeEnvJSON is the JSON env file ctm reads at every claude +// launch and exports into the shell BEFORE exec'ing claude. Use this +// for env vars claude reads during CLI startup, which is too early for +// the overlay's `env` block to take effect. Most env vars (including +// CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) belong in claude-overlay.json's +// `env` block instead and should be put there. +// +// Pre-seeded with the two ctm-default vars: +// - CLAUDE_CODE_NO_FLICKER: flicker-free streaming markdown rendering +// - CTM_STATUSLINE_DUMP: where `ctm statusline` writes per-session +// quota dumps; `{uuid}` is substituted by +// the statusline subcommand at render time. +const sampleClaudeEnvJSON = `{ + "_comment": "ctm-managed env vars exported into the shell that spawns claude. Only affects claude processes launched via ctm; direct 'claude' calls outside ctm are unaffected. Use this for vars claude reads too early in startup for claude-overlay.json's 'env' block to take effect. For anything else, prefer the overlay's 'env' block.", + "env": { + "CLAUDE_CODE_NO_FLICKER": "1", + "CTM_STATUSLINE_DUMP": "/tmp/ctm-statusline/{uuid}.json" + } +} ` -// writeEnvFile writes the default env.sh to path, creating parent dirs. -// Uses O_EXCL so parallel invocations don't clobber each other, and leaves -// an existing env file untouched (so user edits survive). -func writeEnvFile(path string) error { +// writeClaudeEnv writes the default claude-env.json to path, creating +// parent dirs. Uses O_EXCL so parallel invocations don't clobber each +// other, and leaves an existing file untouched (so user edits survive). +func writeClaudeEnv(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf(errCreatingConfigDirFmt, err) } - // 0600: env.sh is sourced by the shell that spawns claude and is a - // natural place for users to park secrets (API keys, tokens). Default - // to owner-only so a user who drops a secret in doesn't leak it to - // other users on a shared host. + // 0600: claude-env.json is exported by the shell that spawns claude + // and is a natural place for users to park secrets (API keys, + // tokens). Default to owner-only so a user who drops a secret in + // doesn't leak it to other users on a shared host. f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { if os.IsExist(err) { return nil // keep user edits intact } - return fmt.Errorf("creating env file: %w", err) + return fmt.Errorf("creating claude-env.json: %w", err) } defer f.Close() - if _, err := f.WriteString(sampleEnvFile); err != nil { - return fmt.Errorf("writing env file: %w", err) + if _, err := f.WriteString(sampleClaudeEnvJSON); err != nil { + return fmt.Errorf("writing claude-env.json: %w", err) } return nil } @@ -165,7 +181,7 @@ func runOverlayStatus(cmd *cobra.Command, args []string) error { out.Success("overlay active: %s", path) out.Dim(dimStatusLineFmt, statuslineHookCommand()) out.Dim("PostToolUse: %s", logToolUseHookCommand()) - envPath := config.EnvFilePath() + envPath := config.ClaudeEnvPath() if _, err := os.Stat(envPath); err == nil { out.Dim(dimEnvFileFmt, envPath) } @@ -179,14 +195,14 @@ func runOverlayStatus(cmd *cobra.Command, args []string) error { func runOverlayInit(cmd *cobra.Command, args []string) error { out := output.Stdout() path := config.ClaudeOverlayPath() - envPath := config.EnvFilePath() + envPath := config.ClaudeEnvPath() slCmd := statuslineHookCommand() logCmd := logToolUseHookCommand() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf(errCreatingConfigDirFmt, err) } - if err := writeEnvFile(envPath); err != nil { + if err := writeClaudeEnv(envPath); err != nil { return err } if err := os.MkdirAll(sessionLogDir(), 0755); err != nil { @@ -222,7 +238,7 @@ func runOverlayInit(cmd *cobra.Command, args []string) error { func runOverlayEdit(cmd *cobra.Command, args []string) error { out := output.Stdout() path := config.ClaudeOverlayPath() - envPath := config.EnvFilePath() + envPath := config.ClaudeEnvPath() slCmd := statuslineHookCommand() logCmd := logToolUseHookCommand() @@ -242,7 +258,7 @@ func runOverlayEdit(cmd *cobra.Command, args []string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf(errCreatingConfigDirFmt, err) } - if err := writeEnvFile(envPath); err != nil { + if err := writeClaudeEnv(envPath); err != nil { return err } _ = os.MkdirAll(sessionLogDir(), 0755) diff --git a/cmd/overlay_test.go b/cmd/overlay_test.go index d756275..efd56d1 100644 --- a/cmd/overlay_test.go +++ b/cmd/overlay_test.go @@ -56,6 +56,9 @@ func TestBuildSampleOverlayContainsHookPaths(t *testing.T) { `"/usr/local/bin/ctm statusline"`, `"/usr/local/bin/ctm log-tool-use"`, `"theme": "dark"`, + `"tui": "fullscreen"`, + `"viewMode": "focus"`, + `"remoteControlAtStartup": true`, `"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"`, `"PostToolUse"`, `"matcher": "*"`, @@ -79,12 +82,12 @@ func TestBuildSampleOverlayEscapesPathsWithSpaces(t *testing.T) { } } -func TestWriteEnvFileCreatesAndIsIdempotent(t *testing.T) { +func TestWriteClaudeEnvCreatesAndIsIdempotent(t *testing.T) { tmp := t.TempDir() - path := filepath.Join(tmp, "nested", "dir", "env.sh") + path := filepath.Join(tmp, "nested", "dir", "claude-env.json") - if err := writeEnvFile(path); err != nil { - t.Fatalf("first writeEnvFile: %v", err) + if err := writeClaudeEnv(path); err != nil { + t.Fatalf("first writeClaudeEnv: %v", err) } info, err := os.Stat(path) @@ -92,16 +95,16 @@ func TestWriteEnvFileCreatesAndIsIdempotent(t *testing.T) { t.Fatalf("stat after first write: %v", err) } if mode := info.Mode().Perm(); mode != 0600 { - t.Errorf("env.sh perm = %v, want 0600", mode) + t.Errorf("claude-env.json perm = %v, want 0600", mode) } // User edit must survive a second call (O_EXCL bails out on EEXIST). - userEdit := []byte("# user edit\nexport FOO=bar\n") + userEdit := []byte(`{"env":{"FOO":"bar"}}`) if err := os.WriteFile(path, userEdit, 0600); err != nil { t.Fatalf("user edit: %v", err) } - if err := writeEnvFile(path); err != nil { - t.Fatalf("second writeEnvFile: %v", err) + if err := writeClaudeEnv(path); err != nil { + t.Fatalf("second writeClaudeEnv: %v", err) } got, err := os.ReadFile(path) if err != nil { @@ -112,7 +115,7 @@ func TestWriteEnvFileCreatesAndIsIdempotent(t *testing.T) { } } -func TestWriteEnvFileMkdirAllErrorPath(t *testing.T) { +func TestWriteClaudeEnvMkdirAllErrorPath(t *testing.T) { // Pointing the env file at a path whose parent is a regular file forces // MkdirAll to fail, exercising the error-return branch. tmp := t.TempDir() @@ -120,9 +123,9 @@ func TestWriteEnvFileMkdirAllErrorPath(t *testing.T) { if err := os.WriteFile(regularFile, []byte("x"), 0600); err != nil { t.Fatal(err) } - target := filepath.Join(regularFile, "child", "env.sh") + target := filepath.Join(regularFile, "child", "claude-env.json") - if err := writeEnvFile(target); err == nil { + if err := writeClaudeEnv(target); err == nil { t.Errorf("expected error when parent path component is a regular file") } } @@ -143,7 +146,7 @@ func TestRunOverlayStatusWithOverlay(t *testing.T) { if err := os.WriteFile(config.ClaudeOverlayPath(), []byte("{}\n"), 0600); err != nil { t.Fatal(err) } - if err := os.WriteFile(config.EnvFilePath(), []byte("# env\n"), 0600); err != nil { + if err := os.WriteFile(config.ClaudeEnvPath(), []byte("# env\n"), 0600); err != nil { t.Fatal(err) } @@ -184,7 +187,7 @@ func TestRunOverlayInitCreates(t *testing.T) { } // env file + log dir should also exist. - if _, err := os.Stat(config.EnvFilePath()); err != nil { + if _, err := os.Stat(config.ClaudeEnvPath()); err != nil { t.Errorf("env file not created: %v", err) } if st, err := os.Stat(sessionLogDir()); err != nil || !st.IsDir() { @@ -242,7 +245,7 @@ func TestRunOverlayEditCreatesSampleAndRunsEditor(t *testing.T) { if !strings.Contains(string(data), "statusLine") { t.Errorf("expected sample overlay content, got:\n%s", data) } - if _, err := os.Stat(config.EnvFilePath()); err != nil { + if _, err := os.Stat(config.ClaudeEnvPath()); err != nil { t.Errorf("expected env file, got err: %v", err) } } diff --git a/internal/claude/command.go b/internal/claude/command.go index 6654cb4..50fc9d9 100644 --- a/internal/claude/command.go +++ b/internal/claude/command.go @@ -9,23 +9,13 @@ import ( // OverlayPathIfExists returns overlayPath if the file exists and is readable, // otherwise returns empty string. Used to gate the --settings flag. func OverlayPathIfExists(overlayPath string) string { - return pathIfExists(overlayPath) -} - -// EnvFilePathIfExists returns envFilePath if the file exists and is readable, -// otherwise returns empty string. Used to gate env file sourcing. -func EnvFilePathIfExists(envFilePath string) string { - return pathIfExists(envFilePath) -} - -func pathIfExists(p string) string { - if p == "" { + if overlayPath == "" { return "" } - if _, err := os.Stat(p); err != nil { + if _, err := os.Stat(overlayPath); err != nil { return "" } - return p + return overlayPath } // shellQuote wraps s in single quotes, escaping any embedded single quotes. @@ -39,22 +29,24 @@ func shellQuote(s string) string { // session no longer exists. Claude is the pane process — when it exits, the // tmux session dies. // -// If envFilePath is non-empty, it is sourced via `. 'path'` at exec time, -// BEFORE claude runs. This lets ctm set real shell env vars (e.g. -// CLAUDE_CODE_NO_FLICKER) that claude reads during early startup, which is -// too early for settings.json's `env` key to take effect. +// If envExports is non-empty, it is prepended verbatim as a shell prelude +// — e.g. "export CLAUDE_CODE_NO_FLICKER='1' CTM_STATUSLINE_DUMP='/tmp/...'". +// The caller is responsible for loading ~/.config/ctm/claude-env.json (via +// config.ClaudeEnvExports) and producing this string. This lets ctm set +// real shell env vars that claude reads during early startup, which is +// too early for the overlay's `env` block to take effect. // // If overlayPath is non-empty, it is passed via --settings to layer ctm-only -// claude customizations (statusline, theme, etc.) on top of the user's global -// settings without modifying ~/.claude/settings.json. Both the env file check -// and the overlay check are TOCTOU-safe shell guards — `[ -r path ]` re- -// evaluates at exec time and falls back gracefully if the file vanished. +// claude customizations (statusline, theme, etc.) on top of the user's +// global settings without modifying ~/.claude/settings.json. The overlay +// check is a TOCTOU-safe shell guard — `[ -r path ]` re-evaluates at +// exec time and falls back gracefully if the file vanished. // // NOTE: The || fallback fires on ANY non-zero exit from `claude --resume`, // not just "session not found". A crash, auth error, or Ctrl-C will also // trigger a fresh session with the same UUID. This is intentional — it's // better to recover into a usable state than to leave the user stranded. -func BuildCommand(uuid, mode string, resume bool, overlayPath, envFilePath string) string { +func BuildCommand(uuid, mode string, resume bool, overlayPath, envExports string) string { var dangerFlag string if mode == "yolo" { dangerFlag = " --dangerously-skip-permissions" @@ -89,12 +81,10 @@ func BuildCommand(uuid, mode string, resume bool, overlayPath, envFilePath strin shellQuote(overlayPath), buildResume(true), buildResume(false)) } - // Optional env-file prefix: source it at exec time if present. - // Uses `.` (POSIX source) which works in bash/sh. The `{ ...; } || true` - // wrapper ensures a sourcing error doesn't prevent claude from launching. - if envFilePath != "" { - return fmt.Sprintf("{ [ -r %s ] && . %s; }; %s", - shellQuote(envFilePath), shellQuote(envFilePath), core) + // Optional env-export prelude: prepended verbatim. Empty when the + // caller had no claude-env.json or it was empty/malformed. + if envExports != "" { + return envExports + "; " + core } return core } diff --git a/internal/claude/command_test.go b/internal/claude/command_test.go index 488b5cd..5666678 100644 --- a/internal/claude/command_test.go +++ b/internal/claude/command_test.go @@ -79,41 +79,36 @@ func TestBuildCommandWithOverlayPathContainsSpaces(t *testing.T) { } } -func TestBuildCommandWithEnvFile(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "", "/home/u/.config/ctm/env.sh") - // Env file prefix: TOCTOU-safe source via `[ -r path ] && . path` - expectedPrefix := "{ [ -r '/home/u/.config/ctm/env.sh' ] && . '/home/u/.config/ctm/env.sh'; }; " +func TestBuildCommandWithEnvExports(t *testing.T) { + cmd := BuildCommand("abc-123", "safe", false, "", `export CLAUDE_CODE_NO_FLICKER='1'`) + // Env exports are prepended verbatim, then "; " then the core. + expectedPrefix := `export CLAUDE_CODE_NO_FLICKER='1'; ` if !strings.HasPrefix(cmd, expectedPrefix) { - t.Errorf("expected env source prefix, got: %s", cmd) + t.Errorf("expected env-exports prefix, got: %s", cmd) } if !strings.Contains(cmd, "claude --session-id abc-123") { - t.Errorf("expected claude invocation after env source, got: %s", cmd) + t.Errorf("expected claude invocation after env exports, got: %s", cmd) } } -func TestBuildCommandWithEnvFileAndOverlay(t *testing.T) { - cmd := BuildCommand("abc-123", "yolo", true, "/o.json", "/e.sh") - // Env prefix appears first - if !strings.HasPrefix(cmd, "{ [ -r '/e.sh' ] && . '/e.sh'; }; if [ -r '/o.json' ]; then ") { - t.Errorf("expected env then overlay-if prefix, got: %s", cmd) +func TestBuildCommandWithEnvExportsAndOverlay(t *testing.T) { + cmd := BuildCommand("abc-123", "yolo", true, "/o.json", `export X='1' Y='2'`) + // Env prefix appears first. + if !strings.HasPrefix(cmd, `export X='1' Y='2'; if [ -r '/o.json' ]; then `) { + t.Errorf("expected exports then overlay-if prefix, got: %s", cmd) } - // Overlay settings appear inside the then-branch if !strings.Contains(cmd, "--settings '/o.json'") { t.Errorf("expected --settings flag, got: %s", cmd) } - // Yolo flag preserved if !strings.Contains(cmd, "--dangerously-skip-permissions") { t.Errorf("expected yolo flag, got: %s", cmd) } } -func TestBuildCommandWithEnvFilePathContainsSpaces(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "", "/home/My User/.config/ctm/env.sh") - if !strings.Contains(cmd, "[ -r '/home/My User/.config/ctm/env.sh' ]") { - t.Errorf("env path with spaces lost quoting in test: %s", cmd) - } - if !strings.Contains(cmd, ". '/home/My User/.config/ctm/env.sh'") { - t.Errorf("env path with spaces lost quoting in source: %s", cmd) +func TestBuildCommandEmptyEnvExportsHasNoPrefix(t *testing.T) { + cmd := BuildCommand("abc-123", "safe", false, "", "") + if strings.HasPrefix(cmd, "export ") { + t.Errorf("empty envExports should produce no prefix, got: %s", cmd) } } @@ -159,28 +154,3 @@ func TestOverlayPathIfExists(t *testing.T) { }) } -func TestEnvFilePathIfExists(t *testing.T) { - dir := t.TempDir() - - t.Run("empty path returns empty", func(t *testing.T) { - if got := EnvFilePathIfExists(""); got != "" { - t.Errorf("got %q, want empty", got) - } - }) - - t.Run("missing file returns empty", func(t *testing.T) { - if got := EnvFilePathIfExists(filepath.Join(dir, "nope.sh")); got != "" { - t.Errorf("got %q, want empty", got) - } - }) - - t.Run("existing file returns path", func(t *testing.T) { - path := filepath.Join(dir, "env.sh") - if err := os.WriteFile(path, []byte("export FOO=bar"), 0755); err != nil { - t.Fatal(err) - } - if got := EnvFilePathIfExists(path); got != path { - t.Errorf("got %q, want %q", got, path) - } - }) -} diff --git a/internal/claude/jsonpatch.go b/internal/claude/jsonpatch.go deleted file mode 100644 index 6436a10..0000000 --- a/internal/claude/jsonpatch.go +++ /dev/null @@ -1,58 +0,0 @@ -package claude - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/RandomCodeSpace/ctm/internal/fsutil" -) - -// patchJSONFile reads path, applies patch to the top-level JSON object, and -// writes the result back atomically. Used to safely flip single keys inside -// JSON files owned by other tools (Claude Code's ~/.claude.json and -// ~/.claude/settings.json) without clobbering sibling keys. -// -// Contract: -// - Missing file → no-op. Never creates it; the owning tool controls -// lifecycle. Returns nil. -// - Invalid JSON → returns a parse error without modifying the file. -// - patch mutates the map in place and returns true to trigger a write, -// false to skip. Returning false is a valid no-op. -// - Writes are atomic (temp file in same dir + rename) and preserve the -// original file mode. -// -// Concurrency caveat: if the owning tool writes to path between our Read and -// Rename, that write is lost. Acceptable when this runs before the -// competing writer launches (ctm bootstrap runs before `claude` starts). -// -// Key ordering: the map round-trip sorts keys alphabetically on marshal. The -// file's semantics are preserved (JSON parsers ignore order) but visual -// layout changes on the first write that mutates the object. -func patchJSONFile(path string, patch func(obj map[string]json.RawMessage) bool) error { - info, err := os.Stat(path) - if err != nil { - return nil - } - - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("reading %s: %w", path, err) - } - - var obj map[string]json.RawMessage - if err := json.Unmarshal(data, &obj); err != nil { - return fmt.Errorf("parsing %s: %w", path, err) - } - - if !patch(obj) { - return nil - } - - out, err := json.MarshalIndent(obj, "", " ") - if err != nil { - return fmt.Errorf("marshalling %s: %w", path, err) - } - - return fsutil.AtomicWriteFile(path, out, info.Mode().Perm()) -} diff --git a/internal/claude/remote_control.go b/internal/claude/remote_control.go deleted file mode 100644 index 6c609b6..0000000 --- a/internal/claude/remote_control.go +++ /dev/null @@ -1,51 +0,0 @@ -package claude - -import ( - "encoding/json" - "os" - "path/filepath" -) - -// ClaudeJSONPath returns the canonical path to Claude Code's per-user config -// file (~/.claude.json). This file is owned by the Claude Code CLI — ctm -// only reads it, and writes only via EnsureRemoteControlAtStartup. -func ClaudeJSONPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".claude.json"), nil -} - -// EnsureRemoteControlAtStartup sets "remoteControlAtStartup": true in -// ~/.claude.json so Claude Code's Remote Control feature is on by default -// for any new Claude session — including those spawned by ctm on a freshly- -// bootstrapped machine. -// -// Semantics (strictly conservative — the file is Claude Code's state, not -// ours): -// - If the file does not exist, do nothing. Never create it. -// - If the key is already present (true, false, or JSON null) we treat -// that as a deliberate user choice and leave it alone. Only an absent -// key triggers the write. -// - Only when the key is absent do we write it true, preserving all other -// keys via json.RawMessage round-trip (values byte-exact; top-level -// key order becomes alphabetical — see patchJSONFile). -// -// The key `remoteControlAtStartup` was discovered empirically by toggling -// "Enable Remote Control for all sessions" in `/config` and diffing the -// resulting JSON. It is not a documented/stable API; if Claude Code renames -// it, future runs silently no-op (harmless). -// -// Errors are returned so callers can log; callers in ctm's boot path should -// swallow — remote-control defaults are convenience, not correctness, and -// must never block claude launch. -func EnsureRemoteControlAtStartup(path string) error { - return patchJSONFile(path, func(obj map[string]json.RawMessage) bool { - if _, present := obj["remoteControlAtStartup"]; present { - return false - } - obj["remoteControlAtStartup"] = json.RawMessage("true") - return true - }) -} diff --git a/internal/claude/remote_control_test.go b/internal/claude/remote_control_test.go deleted file mode 100644 index 282dcc8..0000000 --- a/internal/claude/remote_control_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package claude - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestEnsureRemoteControlAtStartup(t *testing.T) { - tests := []struct { - name string - initial string // "" means file does not exist - wantExists bool - // wantKey: "" means key must be absent from the result, - // otherwise the JSON-encoded value ("true" / "false"). - wantKey string - }{ - { - name: "missing file = no-op (do not create)", - initial: "", - wantExists: false, - wantKey: "", - }, - { - name: "key absent = added as true", - initial: `{"foo":"bar"}`, - wantExists: true, - wantKey: "true", - }, - { - name: "key already true = left alone", - initial: `{"remoteControlAtStartup":true,"foo":"bar"}`, - wantExists: true, - wantKey: "true", - }, - { - name: "key false = respected (user opted out)", - initial: `{"remoteControlAtStartup":false,"foo":"bar"}`, - wantExists: true, - wantKey: "false", - }, - { - name: "empty JSON object = key added", - initial: `{}`, - wantExists: true, - wantKey: "true", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, ".claude.json") - - if tt.initial != "" { - if err := os.WriteFile(path, []byte(tt.initial), 0600); err != nil { - t.Fatalf("setup: %v", err) - } - } - - if err := EnsureRemoteControlAtStartup(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - _, err := os.Stat(path) - exists := err == nil - if exists != tt.wantExists { - t.Fatalf("file exists = %v, want %v", exists, tt.wantExists) - } - if !exists { - return - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading result: %v", err) - } - var obj map[string]json.RawMessage - if err := json.Unmarshal(data, &obj); err != nil { - t.Fatalf("parsing result: %v", err) - } - - got, present := obj["remoteControlAtStartup"] - switch { - case tt.wantKey == "" && present: - t.Errorf("key unexpectedly present = %s", string(got)) - case tt.wantKey != "" && !present: - t.Errorf("key missing, want %q", tt.wantKey) - case tt.wantKey != "" && string(got) != tt.wantKey: - t.Errorf("value = %s, want %s", string(got), tt.wantKey) - } - - if raw, ok := obj["foo"]; ok { - if string(raw) != `"bar"` { - t.Errorf("sibling key foo = %s, want \"bar\"", string(raw)) - } - } - }) - } -} - -func TestEnsureRemoteControlAtStartup_PreservesFileMode(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, ".claude.json") - if err := os.WriteFile(path, []byte(`{}`), 0600); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureRemoteControlAtStartup(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat: %v", err) - } - if mode := info.Mode().Perm(); mode != 0600 { - t.Errorf("mode = %v, want 0600", mode) - } -} - -func TestEnsureRemoteControlAtStartup_InvalidJSONReturnsError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, ".claude.json") - if err := os.WriteFile(path, []byte(`{not json`), 0600); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureRemoteControlAtStartup(path); err == nil { - t.Fatal("expected error on invalid JSON, got nil") - } - - // Ensure we didn't clobber the user's (broken) file. - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading after error: %v", err) - } - if string(data) != `{not json` { - t.Errorf("original file modified on parse error: %q", string(data)) - } -} - -func TestEnsureRemoteControlAtStartup_Idempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, ".claude.json") - if err := os.WriteFile(path, []byte(`{"foo":"bar"}`), 0600); err != nil { - t.Fatalf("setup: %v", err) - } - - // First run adds the key. - if err := EnsureRemoteControlAtStartup(path); err != nil { - t.Fatalf("run 1: %v", err) - } - first, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read 1: %v", err) - } - - // Second run must not change the file (key already present). - if err := EnsureRemoteControlAtStartup(path); err != nil { - t.Fatalf("run 2: %v", err) - } - second, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read 2: %v", err) - } - - if string(first) != string(second) { - t.Errorf("second run modified file:\nfirst: %s\nsecond: %s", first, second) - } -} diff --git a/internal/claude/tui.go b/internal/claude/tui.go index 2cf0436..c24ebc9 100644 --- a/internal/claude/tui.go +++ b/internal/claude/tui.go @@ -10,6 +10,12 @@ import ( // settings file (~/.claude/settings.json). Unlike ~/.claude.json (which // stores per-user runtime state), this file holds the documented // user-overridable configuration. +// +// ctm reads this file (e.g., for the "effortLevel" key consumed by the +// statusline renderer) but never writes to it. UI-shaping defaults +// (tui, viewMode) live in claude-overlay.json — see buildSampleOverlay +// in cmd/overlay.go — so ctm stays additive, not invasive, on the +// user's per-user Claude Code config. func SettingsJSONPath() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -45,64 +51,3 @@ func ReadEffortLevel(path string) string { } return s } - -// EnsureTUIFullscreen pins "tui": "fullscreen" in Claude Code's settings.json -// when the key is absent OR explicitly set to "default". Any other value is -// treated as an intentional user choice and left untouched. -// -// Rationale: at the time of writing, Claude Code's "default" renderer IS the -// fullscreen renderer — `/tui fullscreen` reports "Already using the -// fullscreen renderer" when the setting is "default". Pinning to -// "fullscreen" is a forward-looking hedge so new machines keep the -// fullscreen UI even if Claude Code later redefines what "default" means. -// -// Semantics: -// - Missing settings.json → no-op. Never create it. -// - Invalid JSON → return error; never clobber the user's file. -// - Absent key OR value == "default" → write "fullscreen". -// - Any other value (including JSON null, "compact", or a custom mode) -// is respected as an explicit user choice and left alone. -// - Atomic write via temp + rename; preserves original file mode. -// -// Errors are returned so callers can log; ctm's boot path swallows them. -func EnsureTUIFullscreen(path string) error { - return patchJSONFile(path, func(obj map[string]json.RawMessage) bool { - cur, present := obj["tui"] - if present && string(cur) != `"default"` { - return false - } - obj["tui"] = json.RawMessage(`"fullscreen"`) - return true - }) -} - -// EnsureViewModeFocus pins "viewMode": "focus" in Claude Code's -// settings.json when the key is absent OR explicitly set to "default". -// Any other value ("verbose", or a future mode) is treated as an -// intentional user choice and left alone. -// -// Context: `viewMode` is the documented startup default for the transcript -// view (default | verbose | focus). Setting it to "focus" yields the -// streamlined "last prompt + tool-call summaries + final response" layout -// that is otherwise toggled at runtime via `/focus`. This pairs with -// EnsureTUIFullscreen — the focus view only renders under the fullscreen -// TUI, so pinning both makes the intended mobile-first view the default. -// -// Semantics mirror EnsureTUIFullscreen: -// - Missing settings.json → no-op. Never create it. -// - Invalid JSON → return error. -// - Absent key OR value == "default" → write "focus". -// - Any other value is respected as an explicit user choice. -// - Atomic write; preserves file mode. -// -// Reference: https://code.claude.com/docs/en/settings (viewMode). -func EnsureViewModeFocus(path string) error { - return patchJSONFile(path, func(obj map[string]json.RawMessage) bool { - cur, present := obj["viewMode"] - if present && string(cur) != `"default"` { - return false - } - obj["viewMode"] = json.RawMessage(`"focus"`) - return true - }) -} diff --git a/internal/claude/tui_test.go b/internal/claude/tui_test.go index d273e14..2b82e53 100644 --- a/internal/claude/tui_test.go +++ b/internal/claude/tui_test.go @@ -1,391 +1,11 @@ package claude import ( - "encoding/json" "os" "path/filepath" "testing" ) -func TestEnsureTUIFullscreen(t *testing.T) { - tests := []struct { - name string - initial string - wantExists bool - // wantTUI: "" means key must be absent from result; - // otherwise the JSON-encoded string value (including quotes). - wantTUI string - }{ - { - name: "missing file = no-op (do not create)", - initial: "", - wantExists: false, - wantTUI: "", - }, - { - name: "key absent = set to fullscreen", - initial: `{"theme":"dark"}`, - wantExists: true, - wantTUI: `"fullscreen"`, - }, - { - name: `key "default" = upgraded to fullscreen`, - initial: `{"tui":"default","theme":"dark"}`, - wantExists: true, - wantTUI: `"fullscreen"`, - }, - { - name: `key already "fullscreen" = left alone`, - initial: `{"tui":"fullscreen"}`, - wantExists: true, - wantTUI: `"fullscreen"`, - }, - { - name: `key "compact" = respected (user opted out of fullscreen)`, - initial: `{"tui":"compact"}`, - wantExists: true, - wantTUI: `"compact"`, - }, - { - name: `key "custom-mode" = respected (unknown explicit choice)`, - initial: `{"tui":"custom-mode"}`, - wantExists: true, - wantTUI: `"custom-mode"`, - }, - { - name: "empty JSON object = key added", - initial: `{}`, - wantExists: true, - wantTUI: `"fullscreen"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - if tt.initial != "" { - if err := os.WriteFile(path, []byte(tt.initial), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - } - - if err := EnsureTUIFullscreen(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - _, err := os.Stat(path) - exists := err == nil - if exists != tt.wantExists { - t.Fatalf("file exists = %v, want %v", exists, tt.wantExists) - } - if !exists { - return - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading result: %v", err) - } - var obj map[string]json.RawMessage - if err := json.Unmarshal(data, &obj); err != nil { - t.Fatalf("parsing result: %v", err) - } - - got, present := obj["tui"] - switch { - case tt.wantTUI == "" && present: - t.Errorf("tui unexpectedly present = %s", string(got)) - case tt.wantTUI != "" && !present: - t.Errorf("tui missing, want %s", tt.wantTUI) - case tt.wantTUI != "" && string(got) != tt.wantTUI: - t.Errorf("tui = %s, want %s", string(got), tt.wantTUI) - } - - if raw, ok := obj["theme"]; ok { - if string(raw) != `"dark"` { - t.Errorf("sibling key theme = %s, want \"dark\"", string(raw)) - } - } - }) - } -} - -func TestEnsureTUIFullscreen_PreservesFileMode(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte(`{}`), 0640); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureTUIFullscreen(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat: %v", err) - } - if mode := info.Mode().Perm(); mode != 0640 { - t.Errorf("mode = %v, want 0640", mode) - } -} - -func TestEnsureTUIFullscreen_InvalidJSONReturnsError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - original := `{not json` - if err := os.WriteFile(path, []byte(original), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureTUIFullscreen(path); err == nil { - t.Fatal("expected error on invalid JSON, got nil") - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading after error: %v", err) - } - if string(data) != original { - t.Errorf("original file modified on parse error: %q", string(data)) - } -} - -func TestEnsureTUIFullscreen_Idempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte(`{"theme":"dark"}`), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureTUIFullscreen(path); err != nil { - t.Fatalf("run 1: %v", err) - } - first, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read 1: %v", err) - } - - if err := EnsureTUIFullscreen(path); err != nil { - t.Fatalf("run 2: %v", err) - } - second, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read 2: %v", err) - } - - if string(first) != string(second) { - t.Errorf("second run modified file:\nfirst: %s\nsecond: %s", first, second) - } -} - -// TestEnsureTUIFullscreen_IdempotentFromDefault locks in that the -// "default" → "fullscreen" upgrade path is itself idempotent: run 1 -// upgrades, run 2 must be a no-op because the value is no longer "default". -// The generic Idempotent test above only covers the absent-key path. -func TestEnsureTUIFullscreen_IdempotentFromDefault(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte(`{"tui":"default","theme":"dark"}`), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureTUIFullscreen(path); err != nil { - t.Fatalf("run 1: %v", err) - } - first, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read 1: %v", err) - } - - var firstObj map[string]json.RawMessage - if err := json.Unmarshal(first, &firstObj); err != nil { - t.Fatalf("parsing run 1: %v", err) - } - if got := string(firstObj["tui"]); got != `"fullscreen"` { - t.Fatalf("after run 1: tui = %s, want \"fullscreen\"", got) - } - - if err := EnsureTUIFullscreen(path); err != nil { - t.Fatalf("run 2: %v", err) - } - second, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read 2: %v", err) - } - - if string(first) != string(second) { - t.Errorf("second run modified file:\nfirst: %s\nsecond: %s", first, second) - } -} - -func TestEnsureViewModeFocus(t *testing.T) { - tests := []struct { - name string - initial string // "" = file does not exist - wantExists bool - wantKey string // "" = key must be absent in result - }{ - { - name: "missing file = no-op (do not create)", - initial: "", - wantExists: false, - wantKey: "", - }, - { - name: "key absent = added as focus", - initial: `{"theme":"dark"}`, - wantExists: true, - wantKey: `"focus"`, - }, - { - name: "key default = upgraded to focus", - initial: `{"viewMode":"default","theme":"dark"}`, - wantExists: true, - wantKey: `"focus"`, - }, - { - name: "key focus = left alone (already target)", - initial: `{"viewMode":"focus","theme":"dark"}`, - wantExists: true, - wantKey: `"focus"`, - }, - { - name: "key verbose = respected (user chose verbose)", - initial: `{"viewMode":"verbose","theme":"dark"}`, - wantExists: true, - wantKey: `"verbose"`, - }, - { - name: "custom mode = respected", - initial: `{"viewMode":"custom","theme":"dark"}`, - wantExists: true, - wantKey: `"custom"`, - }, - { - name: "empty JSON object = key added", - initial: `{}`, - wantExists: true, - wantKey: `"focus"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - if tt.initial != "" { - if err := os.WriteFile(path, []byte(tt.initial), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - } - - if err := EnsureViewModeFocus(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - _, err := os.Stat(path) - exists := err == nil - if exists != tt.wantExists { - t.Fatalf("file exists = %v, want %v", exists, tt.wantExists) - } - if !exists { - return - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading result: %v", err) - } - var obj map[string]json.RawMessage - if err := json.Unmarshal(data, &obj); err != nil { - t.Fatalf("parsing result: %v", err) - } - - got, present := obj["viewMode"] - switch { - case tt.wantKey == "" && present: - t.Errorf("key unexpectedly present = %s", string(got)) - case tt.wantKey != "" && !present: - t.Errorf("key missing, want %s", tt.wantKey) - case tt.wantKey != "" && string(got) != tt.wantKey: - t.Errorf("value = %s, want %s", string(got), tt.wantKey) - } - - if raw, ok := obj["theme"]; ok { - if string(raw) != `"dark"` { - t.Errorf("sibling key theme = %s, want \"dark\"", string(raw)) - } - } - }) - } -} - -func TestEnsureViewModeFocus_InvalidJSONReturnsError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte(`{not json`), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureViewModeFocus(path); err == nil { - t.Fatal("expected error on invalid JSON, got nil") - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading after error: %v", err) - } - if string(data) != `{not json` { - t.Errorf("original file modified on parse error: %q", string(data)) - } -} - -func TestEnsureViewModeFocus_PreservesFileMode(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte(`{"theme":"dark"}`), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureViewModeFocus(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat: %v", err) - } - if mode := info.Mode().Perm(); mode != 0644 { - t.Errorf("mode = %v, want 0644", mode) - } -} - -func TestEnsureViewModeFocus_IdempotentFromDefault(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte(`{"viewMode":"default","theme":"dark"}`), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - - if err := EnsureViewModeFocus(path); err != nil { - t.Fatalf("run 1: %v", err) - } - first, _ := os.ReadFile(path) - - if err := EnsureViewModeFocus(path); err != nil { - t.Fatalf("run 2: %v", err) - } - second, _ := os.ReadFile(path) - - if string(first) != string(second) { - t.Errorf("second run modified file:\nfirst: %s\nsecond: %s", first, second) - } -} - func TestReadEffortLevel(t *testing.T) { tests := []struct { name string diff --git a/internal/config/claude_env.go b/internal/config/claude_env.go new file mode 100644 index 0000000..779ffa9 --- /dev/null +++ b/internal/config/claude_env.go @@ -0,0 +1,117 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// ClaudeEnvFile is the on-disk shape of ~/.config/ctm/claude-env.json. +// +// ctm exports these vars into the shell that spawns claude. This is the +// canonical home for env vars claude reads too early in startup for the +// overlay's `env` block to apply (e.g., CLAUDE_CODE_NO_FLICKER). Most +// env vars belong in claude-overlay.json's `env` block instead. +// +// The Comment field is JSON-convention `_comment` and is preserved on +// round-trip but otherwise unused. +type ClaudeEnvFile struct { + Comment string `json:"_comment,omitempty"` + Env map[string]string `json:"env"` +} + +// envKeyRe matches POSIX-portable shell variable names: leading letter or +// underscore, then letters/digits/underscores. Anything else (spaces, +// dashes, shell metachars) would be a corrupt file and we refuse to +// emit shell from it. +var envKeyRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// LoadClaudeEnv reads claude-env.json from path. Returns the zero +// ClaudeEnvFile and a nil error when the file does not exist — a missing +// file is treated as "no extra env vars to export," same graceful +// degradation the previous env.sh sourcing had. +// +// Returns a non-nil error on: +// - a present but malformed JSON file (loud failure: corrupt config +// should not silently drop env vars) +// - any env key that is not a portable shell variable name +// +// On error the returned ClaudeEnvFile is the zero value. +func LoadClaudeEnv(path string) (ClaudeEnvFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return ClaudeEnvFile{}, nil + } + return ClaudeEnvFile{}, fmt.Errorf("reading %s: %w", path, err) + } + var f ClaudeEnvFile + if err := json.Unmarshal(data, &f); err != nil { + return ClaudeEnvFile{}, fmt.Errorf("parsing %s: %w", path, err) + } + for k := range f.Env { + if !envKeyRe.MatchString(k) { + return ClaudeEnvFile{}, fmt.Errorf("%s: invalid env key %q (must match [A-Za-z_][A-Za-z0-9_]*)", path, k) + } + } + return f, nil +} + +// ShellExports returns a single-line `export KEY1='val1' KEY2='val2'` +// clause suitable for prepending to the claude launch command. Returns +// "" when there are no entries so callers can branch on emptiness +// without producing a stray "export " in the shell. +// +// Keys are emitted alphabetically so the launch command is deterministic +// across runs (handy for diffing process trees and tests). +// +// Values are wrapped in single quotes with embedded single quotes +// escaped as '\'' — the standard POSIX-safe quoting that handles +// arbitrary characters including spaces, $, `, !, and the literal +// `{uuid}` placeholder consumed downstream by `ctm statusline`. +func (f ClaudeEnvFile) ShellExports() string { + if len(f.Env) == 0 { + return "" + } + keys := make([]string, 0, len(f.Env)) + for k := range f.Env { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("export ") + for i, k := range keys { + if i > 0 { + b.WriteByte(' ') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(shellQuoteValue(f.Env[k])) + } + return b.String() +} + +// shellQuoteValue wraps s in single quotes, escaping embedded single +// quotes as '\''. Mirrors internal/claude.shellQuote — kept local here +// to avoid an import cycle (config is leaf, claude depends on config). +func shellQuoteValue(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// ClaudeEnvExports is the one-call convenience: load the file at +// ClaudeEnvPath() and return its ShellExports. Errors are swallowed — +// returning empty string preserves the legacy env.sh behavior of +// "missing file is fine" while letting the caller stay one-liner clean. +// +// Callers that want loud failure on a malformed file should call +// LoadClaudeEnv directly. +func ClaudeEnvExports() string { + f, err := LoadClaudeEnv(ClaudeEnvPath()) + if err != nil { + return "" + } + return f.ShellExports() +} diff --git a/internal/config/claude_env_test.go b/internal/config/claude_env_test.go new file mode 100644 index 0000000..bd280c0 --- /dev/null +++ b/internal/config/claude_env_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadClaudeEnv_MissingFileIsZeroValueNoError(t *testing.T) { + dir := t.TempDir() + got, err := LoadClaudeEnv(filepath.Join(dir, "absent.json")) + if err != nil { + t.Fatalf("missing file should be silent: %v", err) + } + if len(got.Env) != 0 { + t.Errorf("missing file should yield empty Env, got %v", got.Env) + } +} + +func TestLoadClaudeEnv_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "claude-env.json") + body := `{ + "_comment": "ctm-managed env vars", + "env": { + "CLAUDE_CODE_NO_FLICKER": "1", + "CTM_STATUSLINE_DUMP": "/tmp/{uuid}.json" + } +}` + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + got, err := LoadClaudeEnv(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Env["CLAUDE_CODE_NO_FLICKER"] != "1" { + t.Errorf("missing/wrong CLAUDE_CODE_NO_FLICKER: %v", got.Env) + } + if got.Env["CTM_STATUSLINE_DUMP"] != "/tmp/{uuid}.json" { + t.Errorf("{uuid} placeholder not preserved verbatim: %v", got.Env) + } + if got.Comment == "" { + t.Errorf("_comment lost on round-trip") + } +} + +func TestLoadClaudeEnv_MalformedJSONReturnsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadClaudeEnv(path); err == nil { + t.Errorf("expected error on malformed JSON") + } +} + +func TestLoadClaudeEnv_RejectsInvalidKey(t *testing.T) { + dir := t.TempDir() + for _, bad := range []string{ + `{"env":{"BAD KEY":"1"}}`, + `{"env":{"BAD-KEY":"1"}}`, + `{"env":{"1LEADING_DIGIT":"1"}}`, + `{"env":{"with;semicolon":"1"}}`, + } { + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte(bad), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadClaudeEnv(path); err == nil { + t.Errorf("expected error for %s", bad) + } + } +} + +func TestShellExports_EmptyIsEmptyString(t *testing.T) { + if got := (ClaudeEnvFile{}).ShellExports(); got != "" { + t.Errorf("empty file should produce no exports, got %q", got) + } +} + +func TestShellExports_DeterministicOrder(t *testing.T) { + f := ClaudeEnvFile{Env: map[string]string{ + "ZED": "z", + "ALF": "a", + "MID": "m", + }} + got := f.ShellExports() + want := `export ALF='a' MID='m' ZED='z'` + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } +} + +func TestShellExports_QuotesAwkwardValues(t *testing.T) { + f := ClaudeEnvFile{Env: map[string]string{ + "WITH_SPACE": "hello world", + "WITH_QUOTE": `it's`, + "WITH_DOLLAR": "$HOME/$PATH", + "WITH_UUID": "/tmp/{uuid}.json", + }} + got := f.ShellExports() + wants := []string{ + `WITH_SPACE='hello world'`, + `WITH_QUOTE='it'\''s'`, + `WITH_DOLLAR='$HOME/$PATH'`, + `WITH_UUID='/tmp/{uuid}.json'`, + } + for _, w := range wants { + if !contains(got, w) { + t.Errorf("expected %q in output, got: %s", w, got) + } + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/internal/config/config.go b/internal/config/config.go index b95a5f0..d2234cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -242,13 +242,17 @@ func ClaudeOverlayPath() string { return filepath.Join(Dir(), "claude-overlay.json") } -// EnvFilePath returns the path to the optional ctm-managed env file. -// When this file exists, ctm sources it in the shell before spawning claude. -// Use this for env vars that must exist as real shell environment (e.g. -// CLAUDE_CODE_NO_FLICKER) rather than inside claude's settings.json env key, -// which is evaluated too late in claude's startup. -func EnvFilePath() string { - return filepath.Join(Dir(), "env.sh") +// ClaudeEnvPath returns the path to the ctm-managed JSON env file. +// When this file exists, ctm reads it at every claude-launching command +// and exports its `env` block into the shell BEFORE exec'ing claude. +// Use this for env vars claude reads too early in startup for the +// overlay's `env` block to apply (e.g., CLAUDE_CODE_NO_FLICKER). +// +// Replaces the older bash-script env.sh — JSON keeps the format +// consistent with the rest of ctm's user config (config.json, +// sessions.json, claude-overlay.json, .bestpractices.json). +func ClaudeEnvPath() string { + return filepath.Join(Dir(), "claude-env.json") } // Load reads Config from path. If the file does not exist it creates it diff --git a/internal/session/spawn.go b/internal/session/spawn.go index 36434a4..0c5f3c1 100644 --- a/internal/session/spawn.go +++ b/internal/session/spawn.go @@ -27,13 +27,18 @@ type Saver interface { // SpawnOpts bundles the tmux client and store so Yolo can be driven // from either CLI or daemon without depending on config globals. +// +// EnvExports is a pre-built shell-export prelude (e.g. +// "export CLAUDE_CODE_NO_FLICKER='1' CTM_STATUSLINE_DUMP='/tmp/...'") +// produced by config.ClaudeEnvExports(). Empty when claude-env.json +// is absent or has no entries. type SpawnOpts struct { Name string Workdir string Tmux TmuxSpawner Store Saver OverlayPath string - EnvFilePath string + EnvExports string } // Yolo creates a detached tmux session, launches claude in yolo mode, @@ -59,7 +64,7 @@ func Yolo(opts SpawnOpts) (Session, error) { } uid := newUUIDv4() - shellCmd := claude.BuildCommand(uid, "yolo", false, opts.OverlayPath, opts.EnvFilePath) + shellCmd := claude.BuildCommand(uid, "yolo", false, opts.OverlayPath, opts.EnvExports) if err := opts.Tmux.NewSession(opts.Name, opts.Workdir, shellCmd); err != nil { return Session{}, fmt.Errorf("tmux new-session: %w", err)