1+ // Pure helpers shared by the yolo / yolo! / safe runners. The cobra
2+ // wiring + RunE bodies live in yolo_runners.go so the tmux- and
3+ // integration-bound code can be excluded from the SonarCloud coverage
4+ // gate (it's not unit-testable without a live tmux server).
5+ //
6+ // Everything in this file is deliberately side-effect-free or
7+ // surgically scoped (one store call, one tmux client call) so it can
8+ // be exercised by yolo_helpers_test.go.
9+
110package cmd
211
312import (
413 "fmt"
514 "os"
6- "os/exec"
7- "path/filepath"
8- "time"
915
10- "github.com/spf13/cobra"
11- "github.com/RandomCodeSpace/ctm/internal/claude"
12- "github.com/RandomCodeSpace/ctm/internal/config"
1316 "github.com/RandomCodeSpace/ctm/internal/output"
14- "github.com/RandomCodeSpace/ctm/internal/prompt"
15- "github.com/RandomCodeSpace/ctm/internal/serve/proc"
1617 "github.com/RandomCodeSpace/ctm/internal/session"
17- "github.com/RandomCodeSpace/ctm/internal/shell"
1818 "github.com/RandomCodeSpace/ctm/internal/tmux"
1919)
2020
21- func init () {
22- rootCmd .AddCommand (yoloCmd )
23- rootCmd .AddCommand (yoloBangCmd )
24- rootCmd .AddCommand (safeCmd )
25- }
26-
2721// shouldResumeExisting reports whether a stored session should be resumed via
2822// preflight rather than torn down and recreated. A session is resumable iff
2923// its recorded mode matches the requested mode — tmux liveness is irrelevant
@@ -37,224 +31,130 @@ func shouldResumeExisting(sess *session.Session, requestedMode string) bool {
3731 return sess != nil && sess .Mode == requestedMode
3832}
3933
40- var yoloCmd = & cobra.Command {
41- Use : "yolo [name] [path]" ,
42- Short : "Launch or relaunch a session in YOLO (unrestricted) mode" ,
43- Args : cobra .MaximumNArgs (2 ),
44- ValidArgsFunction : shell .SessionNameCompletion (),
45- RunE : runYolo ,
46- }
47-
48- var yoloBangCmd = & cobra.Command {
49- Use : "yolo! [name]" ,
50- Short : "Force kill and relaunch a session in YOLO mode" ,
51- Args : cobra .MaximumNArgs (1 ),
52- ValidArgsFunction : shell .SessionNameCompletion (),
53- RunE : runYoloBang ,
54- }
55-
56- var safeCmd = & cobra.Command {
57- Use : "safe [name]" ,
58- Short : "Launch or relaunch a session in safe mode" ,
59- Args : cobra .MaximumNArgs (1 ),
60- ValidArgsFunction : shell .SessionNameCompletion (),
61- RunE : runSafe ,
62- }
63-
64- func runYolo (cmd * cobra.Command , args []string ) error {
65- proc .EnsureServeRunning (cmd .Context ())
66- out := output .Stdout ()
67- cfgPtr , err := ensureSetup ()
68- if err != nil {
69- return err
70- }
71- cfg := * cfgPtr
72-
73- store := session .NewStore (config .SessionsPath ())
74- tc := tmux .NewClient (config .TmuxConfPath ())
75-
76- var name , workdir string
77-
78- switch len (args ) {
79- case 0 :
80- cwd , err := os .Getwd ()
81- if err != nil {
82- return fmt .Errorf ("getting working directory: %w" , err )
83- }
84- name = session .SanitizeName (filepath .Base (cwd ))
85- workdir = cwd
86- case 1 :
87- name = args [0 ]
88- // If session exists use its workdir, else prompt
89- if sess , err := store .Get (name ); err == nil {
90- workdir = sess .Workdir
91- } else {
92- p , err := prompt .AskPath ("Working directory: " )
93- if err != nil {
94- return fmt .Errorf ("prompting for path: %w" , err )
95- }
96- resolved , err := prompt .ResolvePath (p )
97- if err != nil {
98- return fmt .Errorf ("resolving path: %w" , err )
99- }
100- workdir = resolved
101- }
102- case 2 :
103- name = args [0 ]
104- resolved , err := prompt .ResolvePath (args [1 ])
105- if err != nil {
106- return fmt .Errorf ("resolving path: %w" , err )
107- }
108- workdir = resolved
109- }
34+ // modeDecision is the action a yolo/safe launch must take given the
35+ // state of the store at launch time.
36+ type modeDecision int
37+
38+ const (
39+ // decisionFresh: no stored session — create from scratch.
40+ decisionFresh modeDecision = iota
41+ // decisionResume: stored session matches requested mode — preflight + reattach.
42+ decisionResume
43+ // decisionRecreate: stored session is in a different mode — kill+delete then create.
44+ decisionRecreate
45+ )
11046
111- if err := session .ValidateName (name ); err != nil {
112- return err
47+ // decideModeAction maps the (store-lookup result, requested mode) pair to
48+ // one of three actions. Pure function — easy to unit-test.
49+ func decideModeAction (sess * session.Session , getErr error , requestedMode string ) modeDecision {
50+ if getErr != nil {
51+ return decisionFresh
11352 }
114-
115- if cfg .GitCheckpointBeforeYolo {
116- out .Debug (Verbose , "git checkpoint for %s" , workdir )
117- gitCheckpoint (workdir , out )
118- }
119-
120- out .Magenta (">>> YOLO MODE" )
121- {
122- intent := yoloIntent (store , name , workdir , "yolo" )
123- fireHook ("on_yolo" , intent )
124- fireServeEvent ("on_yolo" , intent )
53+ if shouldResumeExisting (sess , requestedMode ) {
54+ return decisionResume
12555 }
56+ return decisionRecreate
57+ }
12658
127- // If session exists and mode matches → preflight. preflight handles both
128- // live tmux (plain reattach) and dead tmux (recreate with --resume UUID),
129- // so the session's claude history survives `claude` exiting on its own.
130- // Only kill/delete when the mode actually changes (safe → yolo) or when
131- // the user forces fresh state via `ctm yolo!` / `ctm kill`.
132- if sess , err := store .Get (name ); err == nil {
133- if shouldResumeExisting (sess , "yolo" ) {
134- out .Debug (Verbose , "existing yolo session %q — running pre-flight" , name )
135- return preflight (sess , cfg , store , tc , out )
136- }
137- // Mode change: drop tmux + store record so a fresh UUID is minted.
138- if tc .HasSession (name ) {
139- if err := tc .KillSession (name ); err != nil {
140- out .Warn ("could not kill existing session: %v" , err )
141- }
142- }
143- if err := store .Delete (name ); err != nil {
144- out .Warn ("could not remove session from store: %v" , err )
59+ // bannerFor returns the banner text and styling flag for a given launch mode.
60+ // magenta=true → out.Magenta; false → out.Success (green). Unknown modes fall
61+ // back to safe-style green so the screen never goes silent.
62+ func bannerFor (mode string ) (text string , magenta bool ) {
63+ if mode == "yolo" {
64+ return ">>> YOLO MODE" , true
65+ }
66+ upper := make ([]byte , 0 , len (mode ))
67+ for i := 0 ; i < len (mode ); i ++ {
68+ c := mode [i ]
69+ if c >= 'a' && c <= 'z' {
70+ upper = append (upper , c - 32 )
71+ } else {
72+ upper = append (upper , c )
14573 }
14674 }
147-
148- out .Debug (Verbose , "creating yolo session: %s" , name )
149- return createAndAttach (name , workdir , "yolo" , store , tc , out )
75+ return fmt .Sprintf (">>> %s MODE" , string (upper )), false
15076}
15177
152- func runYoloBang (cmd * cobra.Command , args []string ) error {
153- proc .EnsureServeRunning (cmd .Context ())
154- out := output .Stdout ()
155- cfgPtr , err := ensureSetup ()
156- if err != nil {
157- return err
78+ // eventsFor returns the (user-hook event, serve-hub event) pair for a mode.
79+ // Yolo fires "on_yolo" to both. Safe fires "on_safe" to user hooks but maps
80+ // to "session_attached" on the serve hub — the hub does not model a separate
81+ // safe-mode lifecycle, only the attach transition.
82+ func eventsFor (mode string ) (hookEvent , serveEvent string ) {
83+ if mode == "yolo" {
84+ return "on_yolo" , "on_yolo"
15885 }
159- cfg := * cfgPtr
86+ return "on_" + mode , "session_attached"
87+ }
16088
161- store := session .NewStore (config .SessionsPath ())
162- tc := tmux .NewClient (config .TmuxConfPath ())
89+ // fireLaunchEvents fires both the user-defined shell hook and the serve-hub
90+ // event for a launch in the given mode. Failures inside fireHook /
91+ // fireServeEvent are already swallowed; this wrapper just composes them.
92+ func fireLaunchEvents (store * session.Store , name , workdir , mode string ) {
93+ hookEvent , serveEvent := eventsFor (mode )
94+ intent := yoloIntent (store , name , workdir , mode )
95+ fireHook (hookEvent , intent )
96+ fireServeEvent (serveEvent , intent )
97+ }
16398
164- name := "claude"
99+ // resolveSimpleName returns args[0] when present, else "claude". This is the
100+ // name-resolution rule shared by `ctm yolo!` and `ctm safe`. (`ctm yolo` has a
101+ // richer rule that also handles 2-arg form and prompts for a path, so it
102+ // stays inline.)
103+ func resolveSimpleName (args []string ) string {
165104 if len (args ) > 0 {
166- name = args [0 ]
105+ return args [0 ]
167106 }
107+ return "claude"
108+ }
109+
110+ // resolveModeTarget produces the (name, workdir) pair used by `ctm yolo!` and
111+ // `ctm safe`. Validates the name and resolves the workdir from the store, the
112+ // running tmux pane, or the current working directory in that order.
113+ func resolveModeTarget (args []string , store * session.Store , tc * tmux.Client ) (string , string , error ) {
114+ name := resolveSimpleName (args )
168115 if err := session .ValidateName (name ); err != nil {
169- return err
116+ return "" , "" , err
170117 }
171-
172- // Get workdir from existing session or pane path
173118 workdir , err := resolveWorkdir (name , store , tc )
174119 if err != nil {
175- return err
176- }
177-
178- if cfg .GitCheckpointBeforeYolo {
179- gitCheckpoint (workdir , out )
180- }
181-
182- out .Magenta (">>> YOLO MODE" )
183- {
184- intent := yoloIntent (store , name , workdir , "yolo" )
185- fireHook ("on_yolo" , intent )
186- fireServeEvent ("on_yolo" , intent )
120+ return "" , "" , err
187121 }
122+ return name , workdir , nil
123+ }
188124
125+ // tearDownForRecreate drops the tmux session and store record so that a fresh
126+ // UUID can be minted. Used when the requested mode differs from the stored
127+ // mode, or when `ctm yolo!` forces fresh state.
128+ //
129+ // loudOnDeleteErr controls the original yolo/safe asymmetry: `ctm yolo`
130+ // warns on a store.Delete failure; `ctm yolo!` swallows the error (it's a
131+ // force-reset path). Preserved verbatim so this is a pure refactor.
132+ func tearDownForRecreate (name string , store * session.Store , tc * tmux.Client , out * output.Printer , loudOnDeleteErr bool ) {
189133 if tc .HasSession (name ) {
190134 if err := tc .KillSession (name ); err != nil {
191135 out .Warn ("could not kill existing session: %v" , err )
192136 }
193137 }
194138 if err := store .Delete (name ); err != nil {
195- // ignore "not found" errors
139+ if loudOnDeleteErr {
140+ out .Warn ("could not remove session from store: %v" , err )
141+ }
142+ // Silent branch: `ctm yolo!` ignores not-found and IO errors here.
196143 _ = err
197144 }
198-
199- return createAndAttach (name , workdir , "yolo" , store , tc , out )
200145}
201146
202- func runSafe (cmd * cobra.Command , args []string ) error {
203- proc .EnsureServeRunning (cmd .Context ())
204- out := output .Stdout ()
205- cfgPtr , err := ensureSetup ()
206- if err != nil {
207- return err
208- }
209- cfg := * cfgPtr
210-
211- store := session .NewStore (config .SessionsPath ())
212- tc := tmux .NewClient (config .TmuxConfPath ())
213-
214- name := "claude"
215- if len (args ) > 0 {
216- name = args [0 ]
147+ // printBanner prints the launch banner using the appropriate color for mode.
148+ // We pass the text via `%s` so the banner string is never treated as a format
149+ // string — defensive against future refactors where the banner becomes
150+ // data-driven (silences `go vet` non-constant format string warnings).
151+ func printBanner (out * output.Printer , mode string ) {
152+ text , magenta := bannerFor (mode )
153+ if magenta {
154+ out .Magenta ("%s" , text )
155+ } else {
156+ out .Success ("%s" , text )
217157 }
218- if err := session .ValidateName (name ); err != nil {
219- return err
220- }
221-
222- // Get workdir from existing session or pane path
223- workdir , err := resolveWorkdir (name , store , tc )
224- if err != nil {
225- return err
226- }
227-
228- out .Success (">>> SAFE MODE" )
229- {
230- intent := yoloIntent (store , name , workdir , "safe" )
231- fireHook ("on_safe" , intent )
232- // Map "on_safe" to a serve session_attached — the hub doesn't
233- // model safe-mode separately, only the lifecycle transition.
234- fireServeEvent ("session_attached" , intent )
235- }
236-
237- // If session exists and mode matches → preflight. preflight handles both
238- // live tmux (plain reattach) and dead tmux (recreate with --resume UUID),
239- // so the session's claude history survives `claude` exiting on its own.
240- // Force-fresh escape hatches: `ctm kill <name>` / `ctm forget <name>`.
241- if sess , err := store .Get (name ); err == nil {
242- if shouldResumeExisting (sess , "safe" ) {
243- out .Debug (Verbose , "existing safe session %q — running pre-flight" , name )
244- return preflight (sess , cfg , store , tc , out )
245- }
246- // Mode change: drop tmux + store record so a fresh UUID is minted.
247- if tc .HasSession (name ) {
248- if err := tc .KillSession (name ); err != nil {
249- out .Warn ("could not kill existing session: %v" , err )
250- }
251- }
252- if err := store .Delete (name ); err != nil {
253- _ = err
254- }
255- }
256-
257- return createAndAttach (name , workdir , "safe" , store , tc , out )
258158}
259159
260160// resolveWorkdir returns the workdir for name: from store if present, else from
@@ -274,23 +174,3 @@ func resolveWorkdir(name string, store *session.Store, tc *tmux.Client) (string,
274174 }
275175 return cwd , nil
276176}
277-
278- // gitCheckpoint creates a git checkpoint commit in workdir before yolo mode.
279- func gitCheckpoint (workdir string , out * output.Printer ) {
280- check := exec .Command ("git" , "-C" , workdir , "rev-parse" , "--is-inside-work-tree" )
281- if err := check .Run (); err != nil {
282- out .Dim ("(not a git repo — skipping checkpoint)" )
283- return
284- }
285-
286- exec .Command ("git" , "-C" , workdir , "add" , "-A" ).Run () //nolint:errcheck
287-
288- ts := time .Now ().Format ("2006-01-02T15:04:05" )
289- msg := fmt .Sprintf ("checkpoint: pre-yolo %s" , ts )
290- exec .Command ("git" , "-C" , workdir , "commit" , "-m" , msg , "--allow-empty" , "-q" ).Run () //nolint:errcheck
291-
292- out .Dim ("git checkpoint created — to rollback: git -C %s reset --hard HEAD~1" , workdir )
293- }
294-
295- // Ensure shell import is used (completion helper comes from shell package).
296- var _ = claude .BuildCommand
0 commit comments