Skip to content

Commit 93e6889

Browse files
aksOpsclaude
andcommitted
refactor(claude): make ctm pure-overlay; never mutate Claude config files
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) <noreply@anthropic.com>
1 parent ea9565b commit 93e6889

17 files changed

Lines changed: 384 additions & 925 deletions

cmd/attach.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli
9393
Tmux: tc,
9494
Store: store,
9595
OverlayPath: claude.OverlayPathIfExists(config.ClaudeOverlayPath()),
96-
EnvFilePath: claude.EnvFilePathIfExists(config.EnvFilePath()),
96+
EnvExports: config.ClaudeEnvExports(),
9797
})
9898
if err != nil {
9999
return fmt.Errorf("createAndAttach spawn: %w", err)
@@ -149,7 +149,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t
149149
tmuxResult := health.CheckTmuxSession(tc, sess.Name)
150150
if !tmuxResult.Passed() {
151151
out.Warn("tmux session %q missing — recreating", sess.Name)
152-
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath()))
152+
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), config.ClaudeEnvExports())
153153
if err := tc.NewSession(sess.Name, sess.Workdir, shellCmd); err != nil {
154154
return fmt.Errorf("recreating tmux session: %w", err)
155155
}
@@ -173,7 +173,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t
173173
if !claudeResult.Passed() {
174174
out.Debug(Verbose, "claude not running, restarting with --resume")
175175
out.Warn("claude process dead — respawning")
176-
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath()))
176+
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), config.ClaudeEnvExports())
177177
if err := tc.RespawnPane(sess.Name, shellCmd); err != nil {
178178
return fmt.Errorf("respawning pane: %w", err)
179179
}

cmd/bootstrap.go

Lines changed: 5 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"path/filepath"
77
"strings"
88

9-
"github.com/RandomCodeSpace/ctm/internal/claude"
109
"github.com/RandomCodeSpace/ctm/internal/config"
1110
"github.com/RandomCodeSpace/ctm/internal/logrotate"
1211
"github.com/RandomCodeSpace/ctm/internal/migrate"
@@ -24,7 +23,7 @@ import (
2423
// - creates ~/.config/ctm/ if missing
2524
// - writes config.json with defaults if missing
2625
// - regenerates tmux.conf on every call so new defaults reach upgraded users
27-
// - writes claude-overlay.json + env.sh + logs/ dir if missing
26+
// - writes claude-overlay.json + claude-env.json + logs/ dir if missing
2827
// - injects shell aliases into ~/.bashrc and ~/.zshrc if markers not present
2928
func ensureSetup() (*config.Config, error) {
3029
if err := os.MkdirAll(config.Dir(), 0755); err != nil {
@@ -45,9 +44,6 @@ func ensureSetup() (*config.Config, error) {
4544
}
4645
_ = ensureOverlaySidecars()
4746
_ = ensureAliases()
48-
_ = ensureClaudeRemoteControlDefault()
49-
_ = ensureClaudeTUIFullscreenDefault()
50-
_ = ensureClaudeViewModeFocusDefault()
5147
_ = pruneSessionLogs(cfg)
5248
return &cfg, nil
5349
}
@@ -100,55 +96,12 @@ func runStateMigrations() error {
10096
return nil
10197
}
10298

103-
// ensureClaudeRemoteControlDefault opts new Claude Code installs into Remote
104-
// Control by default. Never creates ~/.claude.json, never overwrites an
105-
// explicit user choice (true or false) — only fills in the key when it is
106-
// absent. See internal/claude.EnsureRemoteControlAtStartup for the full
107-
// contract. Errors are swallowed; this is convenience, not correctness.
108-
func ensureClaudeRemoteControlDefault() error {
109-
path, err := claude.ClaudeJSONPath()
110-
if err != nil {
111-
return err
112-
}
113-
return claude.EnsureRemoteControlAtStartup(path)
114-
}
115-
116-
// ensureClaudeTUIFullscreenDefault pins Claude Code's TUI renderer to
117-
// "fullscreen" in ~/.claude/settings.json when the key is absent or set to
118-
// "default". Any other explicit value (e.g., "compact") is treated as a
119-
// deliberate user choice and left alone. See
120-
// internal/claude.EnsureTUIFullscreen for the full contract.
121-
func ensureClaudeTUIFullscreenDefault() error {
122-
path, err := claude.SettingsJSONPath()
123-
if err != nil {
124-
return err
125-
}
126-
return claude.EnsureTUIFullscreen(path)
127-
}
128-
129-
// ensureClaudeViewModeFocusDefault pins Claude Code's default transcript
130-
// view mode to "focus" in ~/.claude/settings.json when the key is absent
131-
// or set to "default". Any other explicit value ("verbose" or a future
132-
// mode) is treated as a deliberate user choice and left alone. See
133-
// internal/claude.EnsureViewModeFocus for the full contract.
134-
//
135-
// Pairs with ensureClaudeTUIFullscreenDefault — focus view only renders
136-
// under the fullscreen TUI, so we set both together to land on the
137-
// mobile-first default ctm is optimised for.
138-
func ensureClaudeViewModeFocusDefault() error {
139-
path, err := claude.SettingsJSONPath()
140-
if err != nil {
141-
return err
142-
}
143-
return claude.EnsureViewModeFocus(path)
144-
}
145-
146-
// ensureOverlaySidecars writes claude-overlay.json, env.sh, and the
147-
// per-session logs dir if any are missing. Leaves existing files alone —
148-
// user edits to overlay/env always win.
99+
// ensureOverlaySidecars writes claude-overlay.json, claude-env.json, and
100+
// the per-session logs dir if any are missing. Leaves existing files
101+
// alone — user edits to overlay/env always win.
149102
func ensureOverlaySidecars() error {
150103
_ = os.MkdirAll(sessionLogDir(), 0755)
151-
_ = writeEnvFile(config.EnvFilePath())
104+
_ = writeClaudeEnv(config.ClaudeEnvPath())
152105

153106
overlay := config.ClaudeOverlayPath()
154107
if _, err := os.Stat(overlay); err == nil {

cmd/bootstrap_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestEnsureSetupCreatesAllArtifacts(t *testing.T) {
3636
config.ConfigPath(),
3737
config.TmuxConfPath(),
3838
config.ClaudeOverlayPath(),
39-
config.EnvFilePath(),
39+
config.ClaudeEnvPath(),
4040
}
4141
for _, p := range wantFiles {
4242
if _, err := os.Stat(p); err != nil {
@@ -133,7 +133,7 @@ func TestOverlayAndEnvFilePermsAre0600(t *testing.T) {
133133
}
134134
for _, path := range []string{
135135
config.ClaudeOverlayPath(),
136-
config.EnvFilePath(),
136+
config.ClaudeEnvPath(),
137137
} {
138138
info, err := os.Stat(path)
139139
if err != nil {

cmd/install.go

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -90,23 +90,12 @@ func runInstall(cmd *cobra.Command, args []string) error {
9090
}
9191
}
9292

93-
// 6. Claude-side defaults (idempotent, conservative).
94-
//
95-
// These mirror ensureSetup()'s claude-side bootstrap so `ctm install`
96-
// is a full explicit setup, not just a partial one. Each helper is
97-
// strictly no-op when the relevant key is absent or explicitly
98-
// "default"; any other user value is respected. See
99-
// internal/claude.{EnsureRemoteControlAtStartup,EnsureTUIFullscreen,
100-
// EnsureViewModeFocus} for the per-key contracts.
101-
if err := ensureClaudeRemoteControlDefault(); err == nil {
102-
out.Success("Claude remote control: default on (~/.claude.json)")
103-
}
104-
if err := ensureClaudeTUIFullscreenDefault(); err == nil {
105-
out.Success("Claude TUI: fullscreen (~/.claude/settings.json)")
106-
}
107-
if err := ensureClaudeViewModeFocusDefault(); err == nil {
108-
out.Success("Claude viewMode: focus (~/.claude/settings.json)")
109-
}
93+
// 6. Claude-side defaults are now expressed entirely in
94+
// ~/.config/ctm/claude-overlay.json (created by step 6 of ensureSetup
95+
// via writeOverlayFile). ctm never mutates ~/.claude.json or
96+
// ~/.claude/settings.json — the overlay is merged in via
97+
// `claude --settings` only when claude is launched through ctm,
98+
// leaving direct `claude` invocations completely unaffected.
11099

111100
// 7. Print summary
112101
fmt.Println()

cmd/overlay.go

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,17 @@ func statuslineHookCommand() string { return ctmSubcommand("statusline") }
8585
// Both hook commands are resolved to the ctm binary at write time so they
8686
// keep working even if PATH changes.
8787
//
88-
// Note: env vars like CLAUDE_CODE_NO_FLICKER cannot go here — claude reads
89-
// them too early in startup for settings.json's env key to take effect.
90-
// They live in ~/.config/ctm/env.sh (see sampleEnvFile) and are sourced
91-
// by the shell before claude launches.
88+
// `tui`, `viewMode`, and `remoteControlAtStartup` live here (not in
89+
// ~/.claude/settings.json or ~/.claude.json) so ctm never mutates any
90+
// Claude-owned config file on disk. The overlay is merged on top of
91+
// settings.json only when claude is launched via ctm — direct `claude`
92+
// invocations are completely unaffected by ctm's defaults.
93+
//
94+
// Note: env vars like CLAUDE_CODE_NO_FLICKER cannot go here — claude
95+
// reads them too early in startup for settings.json's env key to take
96+
// effect. They live in ~/.config/ctm/claude-env.json (see
97+
// sampleClaudeEnvJSON) and are exported by the shell before claude
98+
// launches via config.ClaudeEnvExports().
9299
func buildSampleOverlay(statuslineCmd, logHookCmd string) string {
93100
return fmt.Sprintf(`{
94101
"reduceMotion": false,
@@ -98,6 +105,9 @@ func buildSampleOverlay(statuslineCmd, logHookCmd string) string {
98105
"command": %q
99106
},
100107
"theme": "dark",
108+
"tui": "fullscreen",
109+
"viewMode": "focus",
110+
"remoteControlAtStartup": true,
101111
"env": {
102112
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
103113
},
@@ -118,42 +128,48 @@ func buildSampleOverlay(statuslineCmd, logHookCmd string) string {
118128
`, statuslineCmd, logHookCmd)
119129
}
120130

121-
// sampleEnvFile is the bash env script sourced by the tmux shell before
122-
// claude launches. Use this for env vars that claude reads DURING CLI
123-
// startup, which are too early for settings.json's env key to affect.
124-
// Most env vars (including CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) can
125-
// go in claude-overlay.json's env block instead and should — settings
126-
// is the canonical home per Claude Code's docs.
127-
const sampleEnvFile = `# ctm-managed env file — sourced by the shell that spawns claude.
128-
# Only affects claude processes launched via ctm. Direct 'claude' calls
129-
# outside ctm are unaffected (this file is never sourced then).
130-
#
131-
# Use this file only for env vars that claude reads too early in
132-
# startup for settings.json's "env" block to take effect. For anything
133-
# else, prefer the overlay at ~/.config/ctm/claude-overlay.json.
131+
// sampleClaudeEnvJSON is the JSON env file ctm reads at every claude
132+
// launch and exports into the shell BEFORE exec'ing claude. Use this
133+
// for env vars claude reads during CLI startup, which is too early for
134+
// the overlay's `env` block to take effect. Most env vars (including
135+
// CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) belong in claude-overlay.json's
136+
// `env` block instead and should be put there.
137+
//
138+
// Pre-seeded with the two ctm-default vars:
139+
// - CLAUDE_CODE_NO_FLICKER: flicker-free streaming markdown rendering
140+
// - CTM_STATUSLINE_DUMP: where `ctm statusline` writes per-session
141+
// quota dumps; `{uuid}` is substituted by
142+
// the statusline subcommand at render time.
143+
const sampleClaudeEnvJSON = `{
144+
"_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.",
145+
"env": {
146+
"CLAUDE_CODE_NO_FLICKER": "1",
147+
"CTM_STATUSLINE_DUMP": "/tmp/ctm-statusline/{uuid}.json"
148+
}
149+
}
134150
`
135151

136-
// writeEnvFile writes the default env.sh to path, creating parent dirs.
137-
// Uses O_EXCL so parallel invocations don't clobber each other, and leaves
138-
// an existing env file untouched (so user edits survive).
139-
func writeEnvFile(path string) error {
152+
// writeClaudeEnv writes the default claude-env.json to path, creating
153+
// parent dirs. Uses O_EXCL so parallel invocations don't clobber each
154+
// other, and leaves an existing file untouched (so user edits survive).
155+
func writeClaudeEnv(path string) error {
140156
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
141157
return fmt.Errorf(errCreatingConfigDirFmt, err)
142158
}
143-
// 0600: env.sh is sourced by the shell that spawns claude and is a
144-
// natural place for users to park secrets (API keys, tokens). Default
145-
// to owner-only so a user who drops a secret in doesn't leak it to
146-
// other users on a shared host.
159+
// 0600: claude-env.json is exported by the shell that spawns claude
160+
// and is a natural place for users to park secrets (API keys,
161+
// tokens). Default to owner-only so a user who drops a secret in
162+
// doesn't leak it to other users on a shared host.
147163
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
148164
if err != nil {
149165
if os.IsExist(err) {
150166
return nil // keep user edits intact
151167
}
152-
return fmt.Errorf("creating env file: %w", err)
168+
return fmt.Errorf("creating claude-env.json: %w", err)
153169
}
154170
defer f.Close()
155-
if _, err := f.WriteString(sampleEnvFile); err != nil {
156-
return fmt.Errorf("writing env file: %w", err)
171+
if _, err := f.WriteString(sampleClaudeEnvJSON); err != nil {
172+
return fmt.Errorf("writing claude-env.json: %w", err)
157173
}
158174
return nil
159175
}
@@ -165,7 +181,7 @@ func runOverlayStatus(cmd *cobra.Command, args []string) error {
165181
out.Success("overlay active: %s", path)
166182
out.Dim(dimStatusLineFmt, statuslineHookCommand())
167183
out.Dim("PostToolUse: %s", logToolUseHookCommand())
168-
envPath := config.EnvFilePath()
184+
envPath := config.ClaudeEnvPath()
169185
if _, err := os.Stat(envPath); err == nil {
170186
out.Dim(dimEnvFileFmt, envPath)
171187
}
@@ -179,14 +195,14 @@ func runOverlayStatus(cmd *cobra.Command, args []string) error {
179195
func runOverlayInit(cmd *cobra.Command, args []string) error {
180196
out := output.Stdout()
181197
path := config.ClaudeOverlayPath()
182-
envPath := config.EnvFilePath()
198+
envPath := config.ClaudeEnvPath()
183199
slCmd := statuslineHookCommand()
184200
logCmd := logToolUseHookCommand()
185201

186202
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
187203
return fmt.Errorf(errCreatingConfigDirFmt, err)
188204
}
189-
if err := writeEnvFile(envPath); err != nil {
205+
if err := writeClaudeEnv(envPath); err != nil {
190206
return err
191207
}
192208
if err := os.MkdirAll(sessionLogDir(), 0755); err != nil {
@@ -222,7 +238,7 @@ func runOverlayInit(cmd *cobra.Command, args []string) error {
222238
func runOverlayEdit(cmd *cobra.Command, args []string) error {
223239
out := output.Stdout()
224240
path := config.ClaudeOverlayPath()
225-
envPath := config.EnvFilePath()
241+
envPath := config.ClaudeEnvPath()
226242
slCmd := statuslineHookCommand()
227243
logCmd := logToolUseHookCommand()
228244

@@ -242,7 +258,7 @@ func runOverlayEdit(cmd *cobra.Command, args []string) error {
242258
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
243259
return fmt.Errorf(errCreatingConfigDirFmt, err)
244260
}
245-
if err := writeEnvFile(envPath); err != nil {
261+
if err := writeClaudeEnv(envPath); err != nil {
246262
return err
247263
}
248264
_ = os.MkdirAll(sessionLogDir(), 0755)

0 commit comments

Comments
 (0)