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
1 change: 0 additions & 1 deletion .claude/worktrees/plan-review-edits
Submodule plan-review-edits deleted from 98a386
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ coverage.*
.DS_Store
.idea/
.vscode/
/.claude/worktrees/
/CLAUDE.md
/PLAN.md
24 changes: 0 additions & 24 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,6 @@ func (m Model) validateDefaultAgent(candidate string) string {
}
return candidate
}
func NewWizard(st *store.Store, reg *adapter.Registry) Model {
m := NewWithDeps(st, reg)
m.wizard = true
return m
}

func (m Model) Init() tea.Cmd {
return tea.Batch(m.loadSessionsCmd(), refreshTick(), peekTick(), prRefreshTick(100*time.Millisecond))
}
Expand Down Expand Up @@ -1063,9 +1057,6 @@ func (m Model) resumeSelectedCmd() tea.Cmd {
return m.loadSessionsCmd()()
}
}
func (m Model) stopSelectedCmd(remove bool) tea.Cmd {
return m.stopTargetCmd("", remove)
}

// persistDefaultAgent persists the default-agent choice. On failure it surfaces
// the error in the status line instead of swallowing it; on success it returns a
Expand Down Expand Up @@ -1594,21 +1585,6 @@ func sessionGlyph(s adapter.Session) (string, lipgloss.Style) {
}
}

func stateGlyph(s adapter.State) (string, lipgloss.Style) {
switch s {
case adapter.Active:
return "⟳", liveGlyphStyle
case adapter.Failed:
return "✕", failGlyphStyle
default:
return "•", hintStyle
}
}

func stateLabel(s adapter.State) string {
return strings.ToLower(string(s))
}

// prStatusDot returns a distinct glyph per PR status (not color-only) so the PR
// state survives a monochrome terminal or a screen scrape: open=hollow circle,
// merged=filled circle, draft=half circle, closed=cross (F26).
Expand Down
5 changes: 1 addition & 4 deletions internal/app/app_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,6 @@ func TestModelCommandFactories(t *testing.T) {
if New().defaultAgent == "" {
t.Fatal("New default empty")
}
if !NewWizard(st, m.service.Registry).wizard {
t.Fatal("NewWizard not wizard")
}
if m.Init() == nil {
t.Fatal("Init returned nil")
}
Expand All @@ -98,7 +95,7 @@ func TestModelCommandFactories(t *testing.T) {
if msg := m.pinSelectedCmd()(); msg.(sessionsLoadedMsg).err != nil {
t.Fatalf("pin msg=%+v", msg)
}
if msg := m.stopSelectedCmd(false)(); msg.(sessionsLoadedMsg).err != nil {
if msg := m.stopTargetCmd("", false)(); msg.(sessionsLoadedMsg).err != nil {
t.Fatalf("stop msg=%+v", msg)
}
if msg := m.persistOrderCmd()(); msg.(sessionsLoadedMsg).err != nil {
Expand Down
5 changes: 1 addition & 4 deletions internal/app/app_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func assertDispatchParsing(t *testing.T, m Model) {

func assertViewHelpers(t *testing.T) {
t.Helper()
if stateLabel(adapter.Active) != "active" || prStatusDot(adapter.PRMerged) == " " {
if prStatusDot(adapter.PRMerged) == " " {
t.Fatal("status helpers bad")
}
if truncate("abcdef", 4) != "abc…" || trimLines("a\nb\nc", 2) != "b\nc" {
Expand Down Expand Up @@ -431,9 +431,6 @@ func TestInputWindowAndStateBranches(t *testing.T) {
if start < 0 || end < start || end > len(m.sessions) {
t.Fatalf("bad window %d:%d", start, end)
}
if stateLabel(adapter.Active) != "active" || stateLabel(adapter.Failed) != "failed" {
t.Fatal("state labels not covered")
}
}

func TestRenameEditingBranches(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion internal/app/loadsessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ func TestLoadSessionsRefreshDoesNotClobberConcurrentPin(t *testing.T) {
live := map[string]adapter.Session{store.Key("fake", liveA.ID): liveA}
svc.mergeStoredSessions(live, cfgStale, time.Now())
updates := svc.refreshSessionRecords(context.Background(), live, &cfgStale)
svc.persistRefresh(updates)
if err := svc.persistRefresh(updates); err != nil {
t.Fatal(err)
}

cfg, err := st.Load()
if err != nil {
Expand Down
10 changes: 4 additions & 6 deletions internal/app/render_cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,8 @@ func TestStateGlyphDistinguishesResumableFromFailed(t *testing.T) {
if live == resumable {
t.Fatalf("a live session and a resumable dead session must use distinct glyphs (both %q)", live)
}
failGlyph, _ := stateGlyph(adapter.Failed)
if resumable == failGlyph {
t.Fatalf("a reboot-survivor resumable session must not use the red Failed glyph %q", failGlyph)
if resumable == "✕" {
t.Fatal("a reboot-survivor resumable session must not use the failure glyph")
}
_ = closed
}
Expand All @@ -192,9 +191,8 @@ func TestRenderRowResumableSessionNotMarkedFailed(t *testing.T) {
if strings.Contains(strings.ToLower(row), "failed") {
t.Fatalf("resumable row should not show the literal \"failed\": %q", row)
}
failGlyph, _ := stateGlyph(adapter.Failed)
if strings.Contains(row, failGlyph) {
t.Fatalf("resumable row should not show the red Failed glyph %q: %q", failGlyph, row)
if strings.Contains(row, "✕") {
t.Fatalf("resumable row should not show the failure glyph: %q", row)
}
}

Expand Down
63 changes: 60 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,29 @@ import (
func Main() {
flag.Usage = Usage
flag.Parse()
args := flag.Args()

// Help and version are deliberately independent from both the cache logger
// and the persistent store. They must remain usable when either location is
// unavailable (for example during installation diagnostics).
if handled, err := runBeforeLogger(args); handled {
if err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintf(os.Stderr, "uam: %v\n", err)
os.Exit(1)
}
return
}

closer, err := log.Init()
if err != nil {
log.UseStderr(os.Stderr)
fmt.Fprintf(os.Stderr, "uam: failed to initialize logger: %v\n", err)
os.Exit(1)
} else {
defer func() { _ = closer.Close() }()
}
defer func() { _ = closer.Close() }()

ctx := context.Background()
if err := Run(ctx, flag.Args()); err != nil && !errors.Is(err, context.Canceled) {
if err := Run(ctx, args); err != nil && !errors.Is(err, context.Canceled) {
log.Error("run exited with error", "err", err)
fmt.Fprintf(os.Stderr, "uam: %v\n", err)
os.Exit(1)
Expand Down Expand Up @@ -69,6 +82,9 @@ func Run(ctx context.Context, args []string) error {

// RunWithTUI executes the CLI with an injectable TUI runner for tests.
func RunWithTUI(ctx context.Context, args []string, runTUI func(context.Context, tea.Model) error) error {
if handled, err := runWithoutStore(ctx, args); handled {
return err
}
st, err := store.Open(store.DefaultPath())
if err != nil {
return err
Expand All @@ -86,6 +102,47 @@ func RunWithTUI(ctx context.Context, args []string, runTUI func(context.Context,
return runCommand(ctx, svc, args, runTUI)
}

// runBeforeLogger handles commands that have no cache, store, or session
// dependency. Main invokes it before log.Init; RunWithTUI reaches the same
// behavior through runWithoutStore when called directly by tests/embedders.
func runBeforeLogger(args []string) (bool, error) {
if len(args) == 0 {
return false, nil
}
switch args[0] {
case "-h", "--help", "help":
Usage()
return true, nil
case "version", "--version":
fmt.Println(version.String())
return true, nil
default:
return false, nil
}
}

// runWithoutStore handles commands whose behavior does not depend on
// sessions.json. Keeping these ahead of store.Open makes recovery commands
// available even when the config directory is damaged or unwritable.
func runWithoutStore(ctx context.Context, args []string) (bool, error) {
if handled, err := runBeforeLogger(args); handled {
return true, err
}
if len(args) == 0 {
return false, nil
}
switch args[0] {
case "kill-all":
return true, runKillAll(ctx, session.NewClient().KillAll)
case "__host":
return true, session.RunHost(args[1:])
case "__attach":
return true, session.RunAttach(args[1:])
default:
return false, nil
}
}

func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI func(context.Context, tea.Model) error) error {
switch args[0] {
case "-h", "--help", "help":
Expand Down
93 changes: 92 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"bytes"
"context"
"errors"
"flag"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -147,6 +149,86 @@ func TestRunWithTUIHelpVersionAndDefault(t *testing.T) {
}
}

func TestRunWithTUIStateFreeCommandsDoNotOpenStore(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("UAM_CONFIG_DIR", blocked)
t.Setenv("UAM_SESSION_DIR", secureSessionDir(t))

for _, args := range [][]string{{"help"}, {"version"}, {"kill-all"}} {
if err := RunWithTUI(context.Background(), args, noopRunTUI); err != nil {
t.Fatalf("RunWithTUI(%q) opened the store: %v", args, err)
}
}
if err := RunWithTUI(context.Background(), []string{"__host", "--name", "bad name", "--", "/bin/true"}, noopRunTUI); err == nil || !strings.Contains(err.Error(), "invalid session name") {
t.Fatalf("__host must be routed before the store and return its own validation error, got %v", err)
}
if err := RunWithTUI(context.Background(), []string{"__attach"}, noopRunTUI); err == nil || !strings.Contains(err.Error(), "attach requires a session name") {
t.Fatalf("__attach must be routed before the store and return its own validation error, got %v", err)
}
}

func TestMainStatelessCommandsSkipLoggerAndStore(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
for _, command := range []string{"help", "version"} {
cmd := cliMainSubprocess(t, command, blocked, blocked)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("uam %s: %v\n%s", command, err, output)
}
if strings.Contains(string(output), "failed to initialize logger") {
t.Fatalf("uam %s initialized the logger: %s", command, output)
}
}
}

func TestMainFallsBackToStderrWhenFileLoggerFails(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
cmd := cliMainSubprocess(t, "kill-all", blocked, filepath.Join(t.TempDir(), "config"))
cmd.Env = append(cmd.Env, "UAM_SESSION_DIR="+secureSessionDir(t))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("uam kill-all must continue with stderr logging: %v\n%s", err, output)
}
text := string(output)
if strings.Count(text, "failed to initialize logger") != 1 {
t.Fatalf("logger fallback warning count = %d, want 1: %s", strings.Count(text, "failed to initialize logger"), text)
}
if !strings.Contains(text, "all uam sessions stopped") {
t.Fatalf("kill-all did not run after logger fallback: %s", text)
}
}

func TestCLIMainHelperProcess(t *testing.T) {
command := os.Getenv("UAM_TEST_MAIN_COMMAND")
if command == "" {
return
}
flag.CommandLine = flag.NewFlagSet("uam", flag.ContinueOnError)
os.Args = []string{"uam", command}
Main()
os.Exit(0)
}

func cliMainSubprocess(t *testing.T, command, cacheDir, configDir string) *exec.Cmd {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestCLIMainHelperProcess$")
cmd.Env = append(os.Environ(),
"UAM_TEST_MAIN_COMMAND="+command,
"UAM_CACHE_DIR="+cacheDir,
"UAM_CONFIG_DIR="+configDir,
)
return cmd
}

func TestRunCommandAttachLastAndNew(t *testing.T) {
svc, fake := newCLITestService(t)
id := dispatchAndCaptureID(t, svc, []string{"fake", "attachable"})
Expand Down Expand Up @@ -296,6 +378,15 @@ func writeCLIExecutable(t *testing.T, path string) {
}
}

func secureSessionDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatal(err)
}
return dir
}

func must(t *testing.T, err error) {
t.Helper()
if err != nil {
Expand All @@ -315,7 +406,7 @@ func firstNonEmpty(values ...string) string {
// The internal __host/__attach subcommands are routed through runCommand;
// invalid input must surface as errors rather than silently doing nothing.
func TestRunCommandInternalSubcommands(t *testing.T) {
t.Setenv("UAM_SESSION_DIR", t.TempDir())
t.Setenv("UAM_SESSION_DIR", secureSessionDir(t))
svc, _ := newCLITestService(t)
noTUI := func(context.Context, tea.Model) error { return nil }
if err := runCommand(context.Background(), svc, []string{"__host", "--name", "bad name", "--", "/bin/true"}, noTUI); err == nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/killall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func TestRunKillAllPropagatesError(t *testing.T) {
// real sessions: KillAll over zero sessions is an idempotent success.
func TestRunCommandKillAllDispatches(t *testing.T) {
svc, _ := newCLITestService(t)
t.Setenv("UAM_SESSION_DIR", t.TempDir())
t.Setenv("UAM_SESSION_DIR", secureSessionDir(t))

out := captureCLIStdout(t, func() {
if err := runCommand(context.Background(), svc, []string{"kill-all"}, func(context.Context, tea.Model) error { return nil }); err != nil {
Expand Down
Loading
Loading