diff --git a/README.md b/README.md index fbc7d0b..be625ca 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx # 3. Switch. claudecm switch work --yes +# In an interactive terminal, bare `claudecm switch` opens a fuzzy +# profile selector. Scripts should keep using `claudecm switch --yes`. # 4. Confirm what's live. claudecm current @@ -65,6 +67,7 @@ See [docs/quickstart.md](docs/quickstart.md) for a longer walk-through with expe | `claudecm list` | List every profile with the active one marked. | | `claudecm current` | Compact per-tool summary of the active profile. | | `claudecm switch ` | Two-phase commit both tool files to the named profile. | +| `claudecm switch` | Optional terminal-only fuzzy selector; non-TTY scripts keep the v1 usage error. | | `claudecm explain ` | Full per-tool resolution chain (winning + shadowed layers). | | `claudecm import claude-code\|codex` | Seed a profile from existing on-disk tool config. | | `claudecm edit ` | Open profile in `$EDITOR`, or use `--set key=value`. | diff --git a/cmd/switch.go b/cmd/switch.go index a1006ee..aa5f6c8 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -58,6 +58,7 @@ import ( "github.com/a2d2-dev/claudecm/internal/commit" "github.com/a2d2-dev/claudecm/internal/config" "github.com/a2d2-dev/claudecm/internal/storage" + "github.com/a2d2-dev/claudecm/internal/tui" "github.com/a2d2-dev/claudecm/internal/writepath" ) @@ -82,6 +83,36 @@ var ( switchToolFlag string ) +type switchProfileSelector interface { + SelectProfile(cmd *cobra.Command, resv *storage.Resolver, reveal bool) (string, error) +} + +type noSwitchSelectionError struct{} + +func (noSwitchSelectionError) Error() string { return "interactive switch canceled" } + +type interactiveSwitchSelector struct { + Terminal tui.Terminal +} + +func (s interactiveSwitchSelector) SelectProfile(cmd *cobra.Command, resv *storage.Resolver, reveal bool) (string, error) { + if s.Terminal == nil { + s.Terminal = tui.XTerm{} + } + if err := tui.CheckCapabilities(s.Terminal, os.Stdout); err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), err.Error()) + return "", cmd.Help() + } + return tui.SelectProfile(context.Background(), resv, tui.Selector{ + Terminal: s.Terminal, + Loader: loadSwitchSelectorProfiles, + Stdin: os.Stdin, + Stdout: os.Stdout, + Writer: cmd.OutOrStdout(), + Reveal: reveal, + }) +} + // switchCmd is the cobra binding. The RunE closure wraps runSwitch so // commit.PartialFailure can be mapped to exit code 2 without leaking // os.Exit into the tested inner body — the wrapper lives here at the @@ -119,7 +150,7 @@ EXAMPLES # Emit machine-readable JSON claudecm switch prod --output json --dry-run`, - Args: cobra.ExactArgs(1), + Args: switchArgs, ValidArgsFunction: profileNamesCompletion, RunE: func(cmd *cobra.Command, args []string) error { err := runSwitch(cmd, args) @@ -162,6 +193,9 @@ func init() { // CLI wrapper to exit with switchExitPartialFailure (2); every other // non-nil return maps to cobra's default exit 1. func runSwitch(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return runBareSwitch(cmd, interactiveSwitchSelector{Terminal: tui.XTerm{}}) + } profileName := strings.TrimSpace(args[0]) if profileName == "" { return fmt.Errorf("profile name cannot be empty") @@ -326,6 +360,72 @@ func runSwitch(cmd *cobra.Command, args []string) error { return renderSuccess(cmd.OutOrStdout(), format, profileName, report, preCommitDiff) } +func switchArgs(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + return nil + } + if len(args) == 0 && CanOpenInteractiveSwitchSelector(os.Stdin, os.Stdout) { + return nil + } + return cobra.ExactArgs(1)(cmd, args) +} + +func runBareSwitch(cmd *cobra.Command, selector switchProfileSelector) error { + if !CanOpenInteractiveSwitchSelector(os.Stdin, os.Stdout) { + return fmt.Errorf("accepts 1 arg(s), received 0") + } + if selector == nil { + return fmt.Errorf("interactive switch selector is not configured") + } + resv, err := resolverFromGlobals() + if err != nil { + return fmt.Errorf("failed to resolve HOME: %w", err) + } + if err := storage.Bootstrap(resv); err != nil { + return fmt.Errorf("failed to bootstrap ~/.claudecm layout: %w", err) + } + name, err := selector.SelectProfile(cmd, resv, globalRevealActive(false)) + if err != nil { + var cancel noSwitchSelectionError + if errors.As(err, &cancel) || errors.Is(err, tui.ErrCanceled) { + if switchOutputFlag == "" || trimAndLower(switchOutputFlag) == string(switchOutputText) { + fmt.Fprintln(cmd.OutOrStdout(), "interactive switch canceled; no changes made.") + } + return nil + } + if errors.Is(err, tui.ErrAlreadyActive) { + name = strings.TrimSpace(name) + if name == "" { + fmt.Fprintln(cmd.OutOrStdout(), "Selected profile is already active; no switch needed.") + } else { + fmt.Fprintf(cmd.OutOrStdout(), "%q is already active; no switch needed.\n", name) + } + return nil + } + return err + } + name = strings.TrimSpace(name) + if name == "" { + if switchOutputFlag == "" || trimAndLower(switchOutputFlag) == string(switchOutputText) { + fmt.Fprintln(cmd.OutOrStdout(), "interactive switch canceled; no changes made.") + } + return nil + } + return runSwitch(cmd, []string{name}) +} + +func loadSwitchSelectorProfiles(resv *storage.Resolver) ([]*config.Profile, string, error) { + profiles, err := loadAllProfilesStrict(resv) + if err != nil { + return nil, "", err + } + active, err := readActiveName(resv) + if err != nil { + return nil, "", fmt.Errorf("failed to read active profile: %w", err) + } + return profiles, active, nil +} + // parseSwitchOutput validates and normalises the --output flag. // Mirrors parseExplainOutput / parseCurrentOutput for consistency. func parseSwitchOutput(raw string) (switchOutputFormat, error) { @@ -535,6 +635,10 @@ func SetIsTerminalForTest(fn func(*os.File) bool) func() { return func() { isTerminalFn = prev } } +func CanOpenInteractiveSwitchSelector(stdin, stdout *os.File) bool { + return isTerminal(stdin) && isTerminal(stdout) +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- diff --git a/cmd/switch_test.go b/cmd/switch_test.go index f759298..79ebae8 100644 --- a/cmd/switch_test.go +++ b/cmd/switch_test.go @@ -36,9 +36,21 @@ import ( "github.com/a2d2-dev/claudecm/internal/config" "github.com/a2d2-dev/claudecm/internal/presets" "github.com/a2d2-dev/claudecm/internal/storage" + "github.com/a2d2-dev/claudecm/internal/tui" "github.com/a2d2-dev/claudecm/internal/writepath" ) +type fakeSwitchSelector struct { + name string + err error + calls int +} + +func (s *fakeSwitchSelector) SelectProfile(cmd *cobra.Command, resv *storage.Resolver, reveal bool) (string, error) { + s.calls++ + return s.name, s.err +} + // resetSwitchFlags restores the package-level flag vars to their init() // defaults. Every test calls this before mutating them. func resetSwitchFlags() { @@ -71,6 +83,23 @@ func runSwitchInner(t *testing.T, args ...string) (stdout, stderr string, err er return out.String(), errBuf.String(), err } +func runSwitchCommandInner(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errBuf bytes.Buffer + cmd := &cobra.Command{ + Use: "switch [profile-name]", + Args: switchArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs(args) + err = cmd.Execute() + return out.String(), errBuf.String(), err +} + // seedCodexRaw stamps a codex Tools.Raw overlay onto an already-saved // profile so the codex Plan renders non-empty owned bytes into // config.toml. Retained (post Flatten(nil) hotfix) for tests that @@ -171,6 +200,117 @@ func TestSwitch_HappyBothTools(t *testing.T) { } } +func TestSwitchArgsBareNonTTYPreservesUsageError(t *testing.T) { + newSwitchHarness(t) + defer SetIsTerminalForTest(func(*os.File) bool { return false })() + + _, _, err := runSwitchCommandInner(t) + if err == nil { + t.Fatalf("bare switch non-TTY err=nil; want usage error") + } + if !strings.Contains(err.Error(), "accepts 1 arg(s), received 0") { + t.Fatalf("err = %v; want exact args error", err) + } +} + +func TestSwitchBareTTYInvokesSelector(t *testing.T) { + h := newSwitchHarness(t) + h.saveProfile("prod", "sk-prodtoken-1234abcd", "https://prod.example.com", "prod-model") + defer SetIsTerminalForTest(func(*os.File) bool { return true })() + selector := &fakeSwitchSelector{name: "prod"} + + switchDryRunFlag = true + var out, errBuf bytes.Buffer + cmd := &cobra.Command{Use: "switch"} + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + err := runBareSwitch(cmd, selector) + stdout := out.String() + if err != nil { + t.Fatalf("bare runSwitch err=%v stdout=%s", err, stdout) + } + if selector.calls != 1 { + t.Fatalf("selector calls = %d; want 1", selector.calls) + } + if !strings.Contains(stdout, "Pre-apply diff:") { + t.Fatalf("stdout missing named switch pipeline diff:\n%s", stdout) + } +} + +func TestSwitchNamedBypassesSelector(t *testing.T) { + h := newSwitchHarness(t) + h.saveProfile("prod", "sk-prodtoken-1234abcd", "https://prod.example.com", "prod-model") + selector := &fakeSwitchSelector{name: "prod"} + switchDryRunFlag = true + + stdout, _, err := runSwitchInner(t, "prod") + if err != nil { + t.Fatalf("named runSwitch err=%v stdout=%s", err, stdout) + } + if selector.calls != 0 { + t.Fatalf("named switch selector calls = %d; want 0", selector.calls) + } +} + +func TestSwitchBareTTYCancellationNoWrites(t *testing.T) { + h := newSwitchHarness(t) + h.saveProfile("prod", "sk-prodtoken-1234abcd", "https://prod.example.com", "prod-model") + defer SetIsTerminalForTest(func(*os.File) bool { return true })() + + var out, errBuf bytes.Buffer + cmd := &cobra.Command{Use: "switch"} + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + err := runBareSwitch(cmd, &fakeSwitchSelector{err: noSwitchSelectionError{}}) + stdout := out.String() + if err != nil { + t.Fatalf("canceled bare switch err=%v", err) + } + if !strings.Contains(stdout, "interactive switch canceled; no changes made.") { + t.Fatalf("stdout missing cancel message:\n%s", stdout) + } + state, err := h.store.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if state.CurrentProfile != "" { + t.Fatalf("state.CurrentProfile = %q; want empty", state.CurrentProfile) + } + if _, err := os.Stat(claudecodeadapter.SettingsPath(h.resv)); !os.IsNotExist(err) { + t.Fatalf("settings.json exists after cancellation; err=%v", err) + } +} + +func TestSwitchBareTTYSelectingActiveNoWrites(t *testing.T) { + h := newSwitchHarness(t) + h.saveProfile("prod", "sk-prodtoken-1234abcd", "https://prod.example.com", "prod-model") + h.activate("prod") + defer SetIsTerminalForTest(func(*os.File) bool { return true })() + + var out, errBuf bytes.Buffer + cmd := &cobra.Command{Use: "switch"} + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + err := runBareSwitch(cmd, &fakeSwitchSelector{name: "prod", err: tui.ErrAlreadyActive}) + stdout := out.String() + if err != nil { + t.Fatalf("active selection err=%v", err) + } + if !strings.Contains(stdout, `"prod" is already active; no switch needed.`) { + t.Fatalf("stdout missing already-active message:\n%s", stdout) + } + if _, err := os.Stat(claudecodeadapter.SettingsPath(h.resv)); !os.IsNotExist(err) { + t.Fatalf("settings.json exists after already-active selection; err=%v", err) + } + state, err := h.store.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if state.CurrentProfile != "prod" { + t.Fatalf("state.CurrentProfile = %q; want prod", state.CurrentProfile) + } +} + // TestSwitch_MissingProfileErrors: switch to nonexistent profile → error // (exit non-zero at the CLI, error at the runSwitch layer). func TestSwitch_MissingProfileErrors(t *testing.T) { diff --git a/docs/quickstart.md b/docs/quickstart.md index 18f883d..9f4e664 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -94,6 +94,8 @@ Expected: a pre-apply diff summary, followed by `Switched to "work".`. Behind th > **First switch.** The first `switch` for each tool creates the first entry in `~/.claudecm/backups/`. `claudecm restore --list` will surface them. +> **Interactive switch.** In a real terminal, bare `claudecm switch` opens an optional fuzzy profile selector with a redacted preview. This is terminal-only convenience UX; scripts, CI, non-TTY stdin/stdout, and `claudecm switch --yes` keep the stable v1 command behavior. + ## 5. Verify ```bash diff --git a/internal/tui/profile_selector.go b/internal/tui/profile_selector.go new file mode 100644 index 0000000..50d8a00 --- /dev/null +++ b/internal/tui/profile_selector.go @@ -0,0 +1,522 @@ +package tui + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/a2d2-dev/claudecm/internal/adapter" + "github.com/a2d2-dev/claudecm/internal/config" + "github.com/a2d2-dev/claudecm/internal/resolver" + "github.com/a2d2-dev/claudecm/internal/storage" + "golang.org/x/term" +) + +var ( + ErrCanceled = errors.New("interactive selector canceled") + ErrAlreadyActive = errors.New("selected profile is already active") +) + +type ProfileItem struct { + Name string + Description string + Provider string + BaseURL string + Model string + Active bool + Profile config.Profile +} + +type ProfileLoader func(*storage.Resolver) ([]*config.Profile, string, error) + +type Selector struct { + Terminal Terminal + Loader ProfileLoader + Stdin *os.File + Stdout *os.File + Writer io.Writer + Reveal bool +} + +func BuildProfileItems(profiles []*config.Profile, active string) ([]ProfileItem, error) { + if len(profiles) == 0 { + return nil, fmt.Errorf("no profiles found; add a profile before using interactive switch") + } + items := make([]ProfileItem, 0, len(profiles)) + activeFound := active == "" + for _, p := range profiles { + if p == nil { + continue + } + item := ProfileItem{ + Name: p.Name, + Description: p.Description, + Provider: p.Core.Provider, + BaseURL: p.Core.BaseURL, + Model: p.Core.Model, + Active: p.Name == active, + Profile: *p.Clone(), + } + if item.Active { + activeFound = true + } + items = append(items, item) + } + if len(items) == 0 { + return nil, fmt.Errorf("no profiles found; add a profile before using interactive switch") + } + sort.SliceStable(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + if !activeFound { + return nil, fmt.Errorf("active profile %q could not be loaded", active) + } + return items, nil +} + +func FilterProfileItems(items []ProfileItem, query string) []ProfileItem { + query = strings.TrimSpace(query) + if query == "" { + out := make([]ProfileItem, len(items)) + copy(out, items) + return out + } + type scored struct { + item ProfileItem + score int + index int + } + var matches []scored + for i, item := range items { + score, ok := profileFuzzyScore(item, query) + if !ok { + continue + } + matches = append(matches, scored{item: item, score: score, index: i}) + } + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].score != matches[j].score { + return matches[i].score < matches[j].score + } + return matches[i].index < matches[j].index + }) + out := make([]ProfileItem, 0, len(matches)) + for _, m := range matches { + out = append(out, m.item) + } + return out +} + +func profileFuzzyScore(item ProfileItem, query string) (int, bool) { + fields := []string{ + item.Name, + item.Description, + item.Provider, + item.BaseURL, + item.Model, + } + best := 0 + matched := false + for _, field := range fields { + score, ok := fuzzyScore(field, query) + if !ok { + continue + } + if !matched || score < best { + best = score + } + matched = true + } + return best, matched +} + +func fuzzyScore(haystack, query string) (int, bool) { + haystack = strings.ToLower(haystack) + query = strings.ToLower(query) + if query == "" { + return 0, true + } + pos := 0 + first := -1 + last := -1 + gaps := 0 + for _, qr := range query { + found := false + for pos < len(haystack) { + hr, size := utf8.DecodeRuneInString(haystack[pos:]) + if hr == qr { + if first == -1 { + first = pos + } + if last >= 0 { + gaps += pos - last + } + last = pos + pos += size + found = true + break + } + pos += size + } + if !found { + return 0, false + } + } + return first + gaps, true +} + +func SelectProfile(ctx context.Context, r *storage.Resolver, opts Selector) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + term := opts.Terminal + if term == nil { + term = XTerm{} + } + stdin := opts.Stdin + if stdin == nil { + stdin = os.Stdin + } + stdout := opts.Stdout + if stdout == nil { + stdout = os.Stdout + } + writer := opts.Writer + if writer == nil { + writer = stdout + } + if err := CheckCapabilities(term, stdout); err != nil { + return "", err + } + loader := opts.Loader + if loader == nil { + return "", fmt.Errorf("interactive selector loader is not configured") + } + profiles, active, err := loader(r) + if err != nil { + return "", err + } + items, err := BuildProfileItems(profiles, active) + if err != nil { + return "", err + } + + oldState, err := term.MakeRaw(stdin) + if err != nil { + return "", fmt.Errorf("enter raw terminal mode: %w", err) + } + return withRestoredTerminal(term, stdin, oldState, func() (string, error) { + fmt.Fprint(writer, "\x1b[?25l") + defer fmt.Fprint(writer, "\x1b[?25h\x1b[0m\n") + + state := selectorState{items: items, filtered: FilterProfileItems(items, ""), selected: 0, reveal: opts.Reveal} + reader := bufio.NewReader(stdin) + for { + if err := ctx.Err(); err != nil { + return "", err + } + width, height, err := term.Size(stdout) + if err != nil { + return "", fmt.Errorf("read terminal size: %w", err) + } + renderSelector(writer, state, r, width, height) + key, err := readKey(reader) + if err != nil { + if errors.Is(err, io.EOF) { + return "", ErrCanceled + } + return "", fmt.Errorf("read selector input: %w", err) + } + switch key.kind { + case keyCancel: + return "", ErrCanceled + case keyEnter: + if len(state.filtered) == 0 { + continue + } + chosen := state.filtered[state.selected] + if chosen.Active { + return chosen.Name, ErrAlreadyActive + } + return chosen.Name, nil + case keyBackspace: + state.query = dropLastRune(state.query) + state.filtered = FilterProfileItems(state.items, state.query) + state.selected = clampSelection(state.selected, len(state.filtered)) + case keyRune: + if !unicode.IsControl(key.r) { + state.query += string(key.r) + state.filtered = FilterProfileItems(state.items, state.query) + state.selected = 0 + } + case keyUp: + if state.selected > 0 { + state.selected-- + } + case keyDown: + if state.selected < len(state.filtered)-1 { + state.selected++ + } + } + } + }) +} + +func withRestoredTerminal(tty Terminal, stdin *os.File, oldState *term.State, fn func() (string, error)) (selected string, err error) { + defer func() { + if restoreErr := tty.Restore(stdin, oldState); restoreErr != nil { + restoreErr = fmt.Errorf("restore terminal mode: %w", restoreErr) + if err == nil { + err = restoreErr + } else { + err = errors.Join(err, restoreErr) + } + } + }() + return fn() +} + +type selectorState struct { + items []ProfileItem + filtered []ProfileItem + query string + selected int + reveal bool +} + +func renderSelector(w io.Writer, state selectorState, r *storage.Resolver, width, height int) { + if width <= 0 { + width = minTerminalWidth + } + if height <= 0 { + height = minTerminalHeight + } + fmt.Fprint(w, "\x1b[H\x1b[2J") + fmt.Fprintln(w, truncate(fmt.Sprintf("Select profile: %s", state.query), width)) + fmt.Fprintln(w, truncate("Type to filter, Up/Down to move, Enter to switch, Esc/Ctrl-C to cancel", width)) + fmt.Fprintln(w) + previewLines := renderPreviewLines(state, r) + rows := height - 4 - len(previewLines) + if rows < 1 { + rows = 1 + } + if len(state.filtered) == 0 { + fmt.Fprintln(w, truncate(" no matching profiles", width)) + return + } + start := 0 + if state.selected >= rows { + start = state.selected - rows + 1 + } + end := start + rows + if end > len(state.filtered) { + end = len(state.filtered) + } + for i := start; i < end; i++ { + item := state.filtered[i] + cursor := " " + if i == state.selected { + cursor = ">" + } + active := " " + if item.Active { + active = "*" + } + context := strings.TrimSpace(strings.Join(nonEmptyStrings(item.Provider, item.Model), " / ")) + if context != "" { + context = " " + context + } + fmt.Fprintln(w, truncate(fmt.Sprintf("%s %s %s%s", cursor, active, item.Name, context), width)) + } + if len(previewLines) > 0 { + fmt.Fprintln(w) + for _, line := range previewLines { + fmt.Fprintln(w, truncate(line, width)) + } + } +} + +func renderPreviewLines(state selectorState, r *storage.Resolver) []string { + if len(state.filtered) == 0 || state.selected >= len(state.filtered) { + return nil + } + item := state.filtered[state.selected] + return BuildPreviewLines(context.Background(), r, item.Profile, item.Active, state.reveal) +} + +func BuildPreviewLines(ctx context.Context, r *storage.Resolver, profile config.Profile, active bool, reveal bool) []string { + lines := []string{"Preview:"} + marker := "" + if active { + marker = " (active)" + } + lines = append(lines, fmt.Sprintf(" Profile: %s%s", profile.Name, marker)) + if profile.Description != "" { + lines = append(lines, " Notes: "+profile.Description) + } + if profile.Core.Provider != "" { + lines = append(lines, " Provider: "+profile.Core.Provider) + } + if profile.Core.BaseURL != "" { + lines = append(lines, " Base URL: "+profile.Core.BaseURL) + } + if profile.Core.Model != "" { + lines = append(lines, " Model: "+profile.Core.Model) + } + if r == nil { + return lines + } + view, err := resolver.Resolve(ctx, r, adapter.DefaultRegistry, profile, resolver.Filter{}) + if err != nil { + return append(lines, " Preview error: "+err.Error()) + } + for _, tv := range view.Tools { + lines = append(lines, fmt.Sprintf(" %s:", tv.Tool)) + if len(tv.Errors) > 0 { + for _, te := range tv.Errors { + lines = append(lines, fmt.Sprintf(" Preview error: %s: %s", te.Kind, te.Message)) + } + continue + } + fields := append([]adapter.EffectiveField(nil), tv.Effective.Fields...) + adapter.SortFields(fields) + if len(fields) == 0 { + lines = append(lines, " (no effective fields)") + continue + } + for _, field := range fields { + if !previewField(field.Key) { + continue + } + lines = append(lines, fmt.Sprintf(" %s: %s", field.Key, previewValue(field.Value, field.Secret, reveal))) + } + } + return lines +} + +func previewField(key string) bool { + lower := strings.ToLower(key) + return strings.Contains(lower, "provider") || + strings.Contains(lower, "base_url") || + strings.Contains(lower, "api_key") || + strings.Contains(lower, "auth_token") || + strings.Contains(lower, "token") || + strings.Contains(lower, "model") +} + +func previewValue(value any, secret bool, reveal bool) string { + if secret && !reveal { + return redactPreviewValue(value) + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func redactPreviewValue(value any) string { + if value == nil { + return "***" + } + s := fmt.Sprint(value) + if len(s) >= 8 { + return s[:4] + "***" + s[len(s)-4:] + } + return "***" +} + +func nonEmptyStrings(values ...string) []string { + out := make([]string, 0, len(values)) + for _, v := range values { + if strings.TrimSpace(v) != "" { + out = append(out, v) + } + } + return out +} + +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= width { + return s + } + if width <= 3 { + return string(runes[:width]) + } + return string(runes[:width-3]) + "..." +} + +func dropLastRune(s string) string { + if s == "" { + return "" + } + _, size := utf8.DecodeLastRuneInString(s) + return s[:len(s)-size] +} + +func clampSelection(selected, count int) int { + if count <= 0 { + return 0 + } + if selected >= count { + return count - 1 + } + if selected < 0 { + return 0 + } + return selected +} + +type keyKind int + +const ( + keyUnknown keyKind = iota + keyRune + keyUp + keyDown + keyEnter + keyBackspace + keyCancel +) + +type keyEvent struct { + kind keyKind + r rune +} + +func readKey(r *bufio.Reader) (keyEvent, error) { + ch, _, err := r.ReadRune() + if err != nil { + return keyEvent{}, err + } + switch ch { + case '\r', '\n': + return keyEvent{kind: keyEnter}, nil + case 0x03, 0x1b: + if ch == 0x1b && r.Buffered() >= 2 { + b1, _ := r.ReadByte() + b2, _ := r.ReadByte() + if b1 == '[' { + switch b2 { + case 'A': + return keyEvent{kind: keyUp}, nil + case 'B': + return keyEvent{kind: keyDown}, nil + } + } + } + return keyEvent{kind: keyCancel}, nil + case 0x7f, '\b': + return keyEvent{kind: keyBackspace}, nil + default: + return keyEvent{kind: keyRune, r: ch}, nil + } +} diff --git a/internal/tui/profile_selector_test.go b/internal/tui/profile_selector_test.go new file mode 100644 index 0000000..42ff64f --- /dev/null +++ b/internal/tui/profile_selector_test.go @@ -0,0 +1,210 @@ +package tui + +import ( + "bufio" + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/a2d2-dev/claudecm/internal/config" + "github.com/a2d2-dev/claudecm/internal/storage" +) + +func testProfile(name, provider, baseURL, model, description string) *config.Profile { + p := config.NewProfile(name, baseURL, "sk-"+name+"-token") + p.Core.Provider = provider + p.Core.Model = model + p.Description = description + return p +} + +func TestBuildProfileItemsSortsAndMarksActive(t *testing.T) { + items, err := BuildProfileItems([]*config.Profile{ + testProfile("relay-b", "deepseek", "https://b.example.com", "deepseek-chat", ""), + testProfile("official", "anthropic", "https://api.anthropic.com", "claude-sonnet", "default"), + testProfile("relay-a", "moonshot", "https://a.example.com", "kimi-k2", ""), + }, "relay-a") + if err != nil { + t.Fatalf("BuildProfileItems err=%v", err) + } + gotNames := []string{items[0].Name, items[1].Name, items[2].Name} + wantNames := []string{"official", "relay-a", "relay-b"} + for i := range wantNames { + if gotNames[i] != wantNames[i] { + t.Fatalf("names = %v; want %v", gotNames, wantNames) + } + } + if !items[1].Active { + t.Fatalf("relay-a not marked active: %#v", items) + } +} + +func TestBuildProfileItemsRejectsEmptyAndMissingActive(t *testing.T) { + if _, err := BuildProfileItems(nil, ""); err == nil || !strings.Contains(err.Error(), "no profiles") { + t.Fatalf("empty err=%v; want no profiles", err) + } + if _, err := BuildProfileItems([]*config.Profile{ + testProfile("relay-a", "moonshot", "https://a.example.com", "kimi-k2", ""), + }, "missing"); err == nil || !strings.Contains(err.Error(), "active profile") { + t.Fatalf("missing active err=%v; want active profile error", err) + } +} + +func TestFilterProfileItemsMatchesUsefulContext(t *testing.T) { + items, err := BuildProfileItems([]*config.Profile{ + testProfile("official", "anthropic", "https://api.anthropic.com", "claude-sonnet", "work"), + testProfile("relay-a", "moonshot", "https://relay.example.com", "kimi-k2", "fast"), + testProfile("deep", "deepseek", "https://deep.example.com", "deepseek-chat", ""), + }, "") + if err != nil { + t.Fatalf("BuildProfileItems err=%v", err) + } + cases := []struct { + query string + want []string + }{ + {query: "", want: []string{"deep", "official", "relay-a"}}, + {query: "relay", want: []string{"relay-a"}}, + {query: "kimi", want: []string{"relay-a"}}, + {query: "anth", want: []string{"official"}}, + {query: "fast", want: []string{"relay-a"}}, + } + for _, tc := range cases { + gotItems := FilterProfileItems(items, tc.query) + got := make([]string, 0, len(gotItems)) + for _, item := range gotItems { + got = append(got, item.Name) + } + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Fatalf("FilterProfileItems(%q) = %v; want %v", tc.query, got, tc.want) + } + } +} + +func TestRenderSelectorShowsActiveMarkerAndTruncates(t *testing.T) { + items, err := BuildProfileItems([]*config.Profile{ + testProfile("official", "anthropic", "https://api.anthropic.com", "claude-sonnet", ""), + testProfile("relay-a", "moonshot", "https://relay.example.com", "kimi-k2", ""), + }, "official") + if err != nil { + t.Fatalf("BuildProfileItems err=%v", err) + } + state := selectorState{items: items, filtered: items, selected: 0} + var buf bytes.Buffer + renderSelector(&buf, state, nil, 34, 12) + out := buf.String() + if !strings.Contains(out, "> * official") { + t.Fatalf("render missing selected active marker:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "\x1b") { + continue + } + if len([]rune(line)) > 34 { + t.Fatalf("line longer than width: %q", line) + } + } +} + +func TestBuildPreviewLinesRedactsSecretsAndShowsContext(t *testing.T) { + p := testProfile("relay-a", "moonshot", "https://relay.example.com", "kimi-k2", "fast relay") + lines := BuildPreviewLines(context.Background(), nil, *p, true, false) + out := strings.Join(lines, "\n") + for _, want := range []string{ + "Profile: relay-a (active)", + "Notes: fast relay", + "Provider: moonshot", + "Base URL: https://relay.example.com", + "Model: kimi-k2", + } { + if !strings.Contains(out, want) { + t.Fatalf("preview missing %q:\n%s", want, out) + } + } + if strings.Contains(out, p.Core.APIKey) { + t.Fatalf("preview leaked api key:\n%s", out) + } + if got := previewValue("sk-secret-token", true, false); got != "sk-s***oken" { + t.Fatalf("previewValue redacted = %q", got) + } + if got := previewValue("sk-secret-token", true, true); got != "sk-secret-token" { + t.Fatalf("previewValue reveal = %q", got) + } +} + +func TestReadKey(t *testing.T) { + cases := []struct { + in string + want keyKind + }{ + {in: "\n", want: keyEnter}, + {in: "\x7f", want: keyBackspace}, + {in: "\x1b", want: keyCancel}, + {in: "\x1b[A", want: keyUp}, + {in: "\x1b[B", want: keyDown}, + {in: "a", want: keyRune}, + } + for _, tc := range cases { + key, err := readKey(bufioReader(tc.in)) + if err != nil { + t.Fatalf("readKey(%q) err=%v", tc.in, err) + } + if key.kind != tc.want { + t.Fatalf("readKey(%q) = %v; want %v", tc.in, key.kind, tc.want) + } + } +} + +func TestSelectProfileReturnsRestoreError(t *testing.T) { + stdin := selectorInputFile(t, "\x1b") + stdout := selectorInputFile(t, "") + restoreErr := errors.New("restore failed") + + _, err := SelectProfile(context.Background(), nil, Selector{ + Terminal: fakeTerminal{width: 80, height: 24, restoreErr: restoreErr}, + Stdin: stdin, + Stdout: stdout, + Writer: &bytes.Buffer{}, + Loader: func(*storage.Resolver) ([]*config.Profile, string, error) { + return []*config.Profile{ + testProfile("official", "anthropic", "https://api.anthropic.com", "claude-sonnet", ""), + }, "official", nil + }, + }) + if !errors.Is(err, ErrCanceled) { + t.Fatalf("SelectProfile err=%v; want ErrCanceled", err) + } + if !errors.Is(err, restoreErr) { + t.Fatalf("SelectProfile err=%v; want restoreErr", err) + } + if !strings.Contains(err.Error(), "restore terminal mode: restore failed") { + t.Fatalf("SelectProfile err=%v; want restore error", err) + } +} + +func bufioReader(s string) *bufio.Reader { + return bufio.NewReader(strings.NewReader(s)) +} + +func selectorInputFile(t *testing.T, content string) *os.File { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "selector-*") + if err != nil { + t.Fatalf("create selector input: %v", err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatalf("write selector input: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("seek selector input: %v", err) + } + t.Cleanup(func() { + if err := f.Close(); err != nil { + t.Fatalf("close selector input: %v", err) + } + }) + return f +} diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go new file mode 100644 index 0000000..d2635a3 --- /dev/null +++ b/internal/tui/terminal.go @@ -0,0 +1,90 @@ +package tui + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +const ( + minTerminalWidth = 40 + minTerminalHeight = 8 +) + +// Terminal isolates the process TTY operations used by the switch selector. +// Tests provide a fake implementation; production uses XTerm. +type Terminal interface { + IsTerminal(f *os.File) bool + Size(f *os.File) (width int, height int, err error) + MakeRaw(f *os.File) (*term.State, error) + Restore(f *os.File, state *term.State) error + Env(name string) string +} + +// XTerm is the production Terminal implementation backed by x/term. +type XTerm struct{} + +func (XTerm) IsTerminal(f *os.File) bool { + if f == nil { + return false + } + return term.IsTerminal(int(f.Fd())) +} + +func (XTerm) Size(f *os.File) (int, int, error) { + if f == nil { + return 0, 0, fmt.Errorf("terminal file is nil") + } + return term.GetSize(int(f.Fd())) +} + +func (XTerm) MakeRaw(f *os.File) (*term.State, error) { + if f == nil { + return nil, fmt.Errorf("terminal file is nil") + } + return term.MakeRaw(int(f.Fd())) +} + +func (XTerm) Restore(f *os.File, state *term.State) error { + if f == nil { + return fmt.Errorf("terminal file is nil") + } + if state == nil { + return nil + } + return term.Restore(int(f.Fd()), state) +} + +func (XTerm) Env(name string) string { + return os.Getenv(name) +} + +// CanOpenSelector reports whether bare switch may attempt the interactive +// selector. It deliberately checks both stdin and stdout. +func CanOpenSelector(t Terminal, stdin, stdout *os.File) bool { + if t == nil { + return false + } + return t.IsTerminal(stdin) && t.IsTerminal(stdout) +} + +// CheckCapabilities validates terminal properties before raw mode is entered. +// A failure here should fall back to non-interactive usage output. +func CheckCapabilities(t Terminal, stdout *os.File) error { + if t == nil { + return fmt.Errorf("interactive selector unavailable: terminal probe is not configured") + } + if strings.EqualFold(strings.TrimSpace(t.Env("TERM")), "dumb") { + return fmt.Errorf("interactive selector unavailable: TERM=dumb does not support cursor controls") + } + width, height, err := t.Size(stdout) + if err != nil { + return fmt.Errorf("interactive selector unavailable: terminal size could not be detected: %w", err) + } + if width < minTerminalWidth || height < minTerminalHeight { + return fmt.Errorf("interactive selector unavailable: terminal size %dx%d is too small", width, height) + } + return nil +} diff --git a/internal/tui/terminal_test.go b/internal/tui/terminal_test.go new file mode 100644 index 0000000..761171e --- /dev/null +++ b/internal/tui/terminal_test.go @@ -0,0 +1,73 @@ +package tui + +import ( + "errors" + "os" + "strings" + "testing" + + "golang.org/x/term" +) + +type fakeTerminal struct { + tty bool + width int + height int + sizeErr error + restoreErr error + env map[string]string +} + +func (f fakeTerminal) IsTerminal(*os.File) bool { return f.tty } + +func (f fakeTerminal) Size(*os.File) (int, int, error) { + if f.sizeErr != nil { + return 0, 0, f.sizeErr + } + return f.width, f.height, nil +} + +func (f fakeTerminal) MakeRaw(*os.File) (*term.State, error) { return nil, nil } + +func (f fakeTerminal) Restore(*os.File, *term.State) error { return f.restoreErr } + +func (f fakeTerminal) Env(name string) string { return f.env[name] } + +func TestCanOpenSelectorRequiresBothTTYs(t *testing.T) { + if CanOpenSelector(nil, os.Stdin, os.Stdout) { + t.Fatal("nil terminal opened selector") + } + if !CanOpenSelector(fakeTerminal{tty: true}, os.Stdin, os.Stdout) { + t.Fatal("both TTYs should open selector") + } + if CanOpenSelector(fakeTerminal{tty: false}, os.Stdin, os.Stdout) { + t.Fatal("non-TTY should not open selector") + } +} + +func TestCheckCapabilities(t *testing.T) { + t.Run("ok", func(t *testing.T) { + err := CheckCapabilities(fakeTerminal{width: 80, height: 24}, os.Stdout) + if err != nil { + t.Fatalf("CheckCapabilities ok err=%v", err) + } + }) + t.Run("dumb", func(t *testing.T) { + err := CheckCapabilities(fakeTerminal{width: 80, height: 24, env: map[string]string{"TERM": "dumb"}}, os.Stdout) + if err == nil || !strings.Contains(err.Error(), "TERM=dumb") { + t.Fatalf("CheckCapabilities dumb err=%v", err) + } + }) + t.Run("no size", func(t *testing.T) { + err := CheckCapabilities(fakeTerminal{sizeErr: errors.New("no tty")}, os.Stdout) + if err == nil || !strings.Contains(err.Error(), "terminal size could not be detected") { + t.Fatalf("CheckCapabilities size err=%v", err) + } + }) + t.Run("too small", func(t *testing.T) { + err := CheckCapabilities(fakeTerminal{width: 20, height: 4}, os.Stdout) + if err == nil || !strings.Contains(err.Error(), "too small") { + t.Fatalf("CheckCapabilities small err=%v", err) + } + }) +} diff --git a/internal/tui/writepath_boundary_test.go b/internal/tui/writepath_boundary_test.go new file mode 100644 index 0000000..d2557dc --- /dev/null +++ b/internal/tui/writepath_boundary_test.go @@ -0,0 +1,33 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSelectorPackageDoesNotImportWritepathOrCommit(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + body, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + text := string(body) + for _, forbidden := range []string{ + "github.com/a2d2-dev/claudecm/internal/writepath", + "github.com/a2d2-dev/claudecm/internal/commit", + } { + if strings.Contains(text, forbidden) { + t.Fatalf("%s imports forbidden write pipeline package %s", file, forbidden) + } + } + } +}