Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
57 changes: 5 additions & 52 deletions cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -45,9 +44,6 @@ func ensureSetup() (*config.Config, error) {
}
_ = ensureOverlaySidecars()
_ = ensureAliases()
_ = ensureClaudeRemoteControlDefault()
_ = ensureClaudeTUIFullscreenDefault()
_ = ensureClaudeViewModeFocusDefault()
_ = pruneSessionLogs(cfg)
return &cfg, nil
}
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 6 additions & 17 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
82 changes: 49 additions & 33 deletions cmd/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
},
Expand All @@ -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
}
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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()

Expand All @@ -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)
Expand Down
Loading
Loading