Skip to content

Commit 9ff408c

Browse files
author
ctm
committed
feat: logs, env file, native Go replacements, tool-use hook
- Add ctm logs (list/view/tail) + hidden ctm log-tool-use PostToolUse hook - Add ~/.config/ctm/env.sh — shell env sourced before claude (fixes NO_FLICKER) - Replace google/uuid with crypto/rand newUUIDv4 - Replace pgrep with /proc walk, grep with filepath.WalkDir - Okabe-Ito colorblind-safe status line with 3 thin bars + token counts - Tmux: Alt-a prefix, Alt-[ direct copy-mode, OSC52 set-clipboard - Security: log files 0600, session-id sanitization, flock on concurrent writes - 164 tests, zero non-tmux runtime deps
1 parent 53b8214 commit 9ff408c

19 files changed

Lines changed: 1195 additions & 77 deletions

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
1+
# Built binary
12
ctm
3+
4+
# Go coverage / profiling
5+
*.out
6+
*.test
7+
coverage.*
8+
9+
# IDE
10+
.idea/
11+
.vscode/
12+
*.swp
13+
14+
# OS
15+
.DS_Store

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ Mobile-first session manager for [Claude Code](https://claude.com/claude-code) r
1010
- **Crash-safe state.** Atomic writes, flock-based locking, corruption recovery on `sessions.json`.
1111
- **Resume with fallback.** `claude --resume UUID || claude --session-id UUID` — recovers from missing session data.
1212
- **Claude overlay.** Drop a `claude-overlay.json` to apply ctm-only settings (statusline, theme, etc.) without touching your global `~/.claude/settings.json`.
13+
- **ctm-only shell env.** `~/.config/ctm/env.sh` is sourced before claude spawns — set env vars like `CLAUDE_CODE_NO_FLICKER=1` that claude reads during startup (too early for settings.json's `env` key).
14+
- **Tool-use logging.** Built-in PostToolUse hook (pure Go, no jq/bash deps) writes one JSONL entry per tool call to `~/.config/ctm/logs/<session>.jsonl`. View with `ctm logs`.
1315
- **YOLO mode.** Auto-commits a git checkpoint before bypassing permissions, so you can always roll back.
1416
- **Preflight health checks.** Env vars, PATH, workdir, tmux session, claude process — cached for 60s to keep mobile reconnects snappy.
1517
- **OSC52 clipboard sync.** Copy in tmux, paste anywhere.
18+
- **Zero non-tmux runtime dependencies.** Pure Go throughout — native UUID, `/proc` walk, `filepath.WalkDir`. No `jq`, `pgrep`, `grep`, or `uuidgen` required.
1619

1720
## Installation
1821

@@ -88,12 +91,23 @@ This generates `~/.config/ctm/config.json` and `~/.config/ctm/tmux.conf` and set
8891

8992
| Command | Description |
9093
|---|---|
91-
| `ctm overlay` | Show overlay status (active / missing). |
92-
| `ctm overlay init` | Create a sample `~/.config/ctm/claude-overlay.json` with statusline + theme examples. |
93-
| `ctm overlay edit` | Open the overlay in `$EDITOR` (creates sample if missing). |
94+
| `ctm overlay` | Show overlay status (active / missing) with paths to sidecar files. |
95+
| `ctm overlay init` | Create a sample `~/.config/ctm/claude-overlay.json` + `statusline.sh` + `env.sh` + hooks wiring. |
96+
| `ctm overlay edit` | Open the overlay in `$EDITOR` (creates sidecars if missing). |
9497
| `ctm overlay path` | Print the overlay file path. |
9598

96-
When the overlay file exists, ctm-spawned claude invocations get `--settings <path>` automatically. Direct `claude` invocations outside ctm are untouched.
99+
When the overlay file exists, ctm-spawned claude invocations get `--settings <path>` automatically, and `env.sh` is sourced by the shell before claude starts. Direct `claude` invocations outside ctm are untouched.
100+
101+
### Logs
102+
103+
| Command | Description |
104+
|---|---|
105+
| `ctm logs` | List sessions with tool-use logs, sorted by most recent. |
106+
| `ctm logs <session-id>` | Dump a session's formatted tool-use log. |
107+
| `ctm logs <session-id> -f` | Tail the log in real time. Handles rotation and truncation. |
108+
| `ctm logs <session-id> --raw` | Print raw JSONL lines (pipe to `jq` for scripting). |
109+
110+
Logs are populated by a PostToolUse hook registered in the overlay. Each entry contains the full Claude Code hook payload plus a UTC timestamp. File perms 0600, session-id sanitized to prevent path traversal, concurrent writes coordinated via advisory flock.
97111

98112
## Keybindings
99113

@@ -125,7 +139,10 @@ Claude Code's TUI uses alt-screen and has no built-in scroll history. The app-in
125139
- `~/.config/ctm/config.json` — main config (scrollback lines, required env vars, default mode, health check timeout, yolo checkpoint toggle)
126140
- `~/.config/ctm/sessions.json` — session state (atomically written, flock-locked)
127141
- `~/.config/ctm/tmux.conf` — generated tmux config (mobile-optimized, don't edit)
128-
- `~/.config/ctm/claude-overlay.json` — optional claude settings overlay
142+
- `~/.config/ctm/claude-overlay.json` — optional claude settings overlay (statusline, theme, hooks)
143+
- `~/.config/ctm/statusline.sh` — custom statusline script referenced by the overlay
144+
- `~/.config/ctm/env.sh` — shell env sourced before claude spawns (for early-binding env vars like `CLAUDE_CODE_NO_FLICKER`)
145+
- `~/.config/ctm/logs/<session-id>.jsonl` — per-session tool-use logs (0600)
129146

130147
## Upgrading
131148

cmd/attach.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func createAndAttach(name, workdir, mode string, store *session.Store, tc *tmux.
7676
out.Info("No session %q found — creating in %s", name, abs)
7777

7878
sess := session.New(name, abs, mode)
79-
shellCmd := claude.BuildCommand(sess.UUID, mode, false, claude.OverlayPathIfExists(config.ClaudeOverlayPath()))
79+
shellCmd := claude.BuildCommand(sess.UUID, mode, false, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath()))
8080

8181
if err := tc.NewSession(name, abs, shellCmd); err != nil {
8282
return fmt.Errorf("creating tmux session: %w", err)
@@ -134,7 +134,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t
134134
tmuxResult := health.CheckTmuxSession(tc, sess.Name)
135135
if !tmuxResult.Passed() {
136136
out.Warn("tmux session %q missing — recreating", sess.Name)
137-
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()))
137+
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath()))
138138
if err := tc.NewSession(sess.Name, sess.Workdir, shellCmd); err != nil {
139139
return fmt.Errorf("recreating tmux session: %w", err)
140140
}
@@ -156,7 +156,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t
156156
if !claudeResult.Passed() {
157157
out.Debug(Verbose, "claude not running, restarting with --resume")
158158
out.Warn("claude process dead — respawning")
159-
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()))
159+
shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), claude.EnvFilePathIfExists(config.EnvFilePath()))
160160
if err := tc.RespawnPane(sess.Name, shellCmd); err != nil {
161161
return fmt.Errorf("respawning pane: %w", err)
162162
}

cmd/log_tool_use.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"syscall"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
"github.com/RandomCodeSpace/ctm/internal/config"
14+
)
15+
16+
func init() {
17+
rootCmd.AddCommand(logToolUseCmd)
18+
}
19+
20+
// logToolUseCmd is the PostToolUse hook target. Claude invokes it for every
21+
// tool call and pipes the raw hook JSON on stdin. We parse it, add a
22+
// timestamp, and append one JSONL line to ~/.config/ctm/logs/<session>.jsonl.
23+
//
24+
// Hidden because it's an internal hook, not a user-facing command. Always
25+
// exits 0 — hook failures must never block the tool pipeline.
26+
var logToolUseCmd = &cobra.Command{
27+
Use: "log-tool-use",
28+
Short: "Internal PostToolUse hook — logs tool invocations (hidden)",
29+
Hidden: true,
30+
RunE: runLogToolUse,
31+
SilenceUsage: true,
32+
SilenceErrors: true,
33+
}
34+
35+
// sessionIDSafe matches only characters we allow in a log filename.
36+
// Claude's session IDs are UUIDs, but we sanitize defensively to prevent
37+
// path traversal or filesystem weirdness if the hook payload is malformed.
38+
var sessionIDSafe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
39+
40+
func sanitizeSessionID(id string) string {
41+
clean := sessionIDSafe.ReplaceAllString(id, "-")
42+
if clean == "" || len(clean) > 128 {
43+
return "unknown"
44+
}
45+
return clean
46+
}
47+
48+
func runLogToolUse(cmd *cobra.Command, args []string) error {
49+
// Read all of stdin. Hook payloads are small (<100KB typically).
50+
data, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20)) // 1 MiB cap
51+
if err != nil || len(data) == 0 {
52+
return nil
53+
}
54+
55+
// Parse into a generic map so we preserve all fields claude sends.
56+
var payload map[string]interface{}
57+
if err := json.Unmarshal(data, &payload); err != nil {
58+
return nil
59+
}
60+
61+
// Extract and sanitize session_id for the filename.
62+
sessionID := "unknown"
63+
if v, ok := payload["session_id"].(string); ok && v != "" {
64+
sessionID = sanitizeSessionID(v)
65+
}
66+
67+
// Add a ctm-side timestamp so the log is readable even if claude
68+
// doesn't include one in the payload.
69+
payload["ctm_timestamp"] = time.Now().UTC().Format(time.RFC3339)
70+
71+
logDir := filepath.Join(config.Dir(), "logs")
72+
// 0700 on the dir — tool payloads can contain file paths and contents.
73+
if err := os.MkdirAll(logDir, 0700); err != nil {
74+
return nil
75+
}
76+
logFile := filepath.Join(logDir, sessionID+".jsonl")
77+
78+
// 0600 on the file — same reasoning as the dir.
79+
f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
80+
if err != nil {
81+
return nil
82+
}
83+
defer f.Close()
84+
85+
line, err := json.Marshal(payload)
86+
if err != nil {
87+
return nil
88+
}
89+
line = append(line, '\n')
90+
91+
// Acquire an exclusive advisory lock before writing. O_APPEND is only
92+
// atomic up to PIPE_BUF (4096 bytes) on Linux; tool payloads can easily
93+
// exceed that (Read/Bash output). Without the lock, concurrent hook
94+
// invocations can interleave bytes and corrupt the JSONL stream.
95+
// Lock failure is non-fatal — write anyway rather than block the tool pipeline.
96+
fd := int(f.Fd())
97+
lockAcquired := false
98+
if err := syscall.Flock(fd, syscall.LOCK_EX); err == nil {
99+
lockAcquired = true
100+
defer syscall.Flock(fd, syscall.LOCK_UN) //nolint:errcheck
101+
}
102+
_ = lockAcquired
103+
104+
_, _ = f.Write(line)
105+
return nil
106+
}

cmd/log_tool_use_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestSanitizeSessionID(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
in string
9+
want string
10+
}{
11+
{"valid uuid", "f6489cb4-010f-4c96-940b-188014f746f0", "f6489cb4-010f-4c96-940b-188014f746f0"},
12+
{"simple alnum", "abc123", "abc123"},
13+
{"underscore ok", "a_b_c", "a_b_c"},
14+
{"dash ok", "a-b-c", "a-b-c"},
15+
{"path traversal attempt", "../../etc/passwd", "------etc-passwd"},
16+
{"absolute path", "/etc/passwd", "-etc-passwd"},
17+
{"dot", "a.b.c", "a-b-c"},
18+
{"spaces", "a b c", "a-b-c"},
19+
{"null byte", "abc\x00def", "abc-def"},
20+
{"empty string", "", "unknown"},
21+
{"only invalid chars", "////", "----"},
22+
{"really long", string(make([]byte, 200)), "unknown"}, // >128 after sanitize → fallback
23+
}
24+
25+
for _, tt := range tests {
26+
t.Run(tt.name, func(t *testing.T) {
27+
got := sanitizeSessionID(tt.in)
28+
if got != tt.want {
29+
t.Errorf("sanitizeSessionID(%q) = %q, want %q", tt.in, got, tt.want)
30+
}
31+
})
32+
}
33+
}
34+
35+
func TestSanitizeSessionIDNoEmptyResult(t *testing.T) {
36+
// Sanity: never returns "" — either a clean id or "unknown".
37+
inputs := []string{"", "...", "/////", string(make([]byte, 500))}
38+
for _, in := range inputs {
39+
got := sanitizeSessionID(in)
40+
if got == "" {
41+
t.Errorf("sanitizeSessionID(%q) returned empty string", in)
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)