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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --yes`.

# 4. Confirm what's live.
claudecm current
Expand All @@ -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 <name>` | 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 <name>` | Full per-tool resolution chain (winning + shadowed layers). |
| `claudecm import claude-code\|codex` | Seed a profile from existing on-disk tool config. |
| `claudecm edit <name>` | Open profile in `$EDITOR`, or use `--set key=value`. |
Expand Down
106 changes: 105 additions & 1 deletion cmd/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
140 changes: 140 additions & 0 deletions cmd/switch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --yes` keep the stable v1 command behavior.

## 5. Verify

```bash
Expand Down
Loading
Loading