From c8833bb758f9e60850491137fc6f841cd3b72c61 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:38:18 -0300 Subject: [PATCH] Setup: Install or Enable the contr plugin after a confirm (#713 S3, D283) --- docs/DECISIONS.md | 1 + docs/spec/SettingsSetup.md | 14 +- internal/app/settings/settings.go | 5 + internal/app/settings/settings_plugin.go | 120 ++++++++++++++ internal/app/settings/settings_plugin_test.go | 153 ++++++++++++++++++ internal/app/settings/settings_setup.go | 10 +- internal/app/settings/sheet_test.go | 13 +- internal/setup/setup.go | 32 ++++ internal/setup/setup_test.go | 32 ++++ 9 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 internal/app/settings/settings_plugin.go create mode 100644 internal/app/settings/settings_plugin_test.go diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a14052c6..687dd149 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -255,3 +255,4 @@ replaces it. | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | | D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | | D282 | 2026-11-11 | A Claude worker gets the PATH of the process that spawns it (#728). `hc update` restarts the core under launchd, whose PATH is short, and each terminal inherits the PATH of the core, so `claude` was not found and the worker ended at once. Spawn now sets `CONTR_PATH` in the terminal: the PATH of the spawning process, then `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`, without doubles. The clean wrapper (D216) adds these folders after the PATH of the core and removes the variable, so the PATH of the core still comes first. A program that does not start through the clean wrapper (not Claude) gets no change. | Seen on 2026-11-11 after the first `Update hc` (D277): two spawns ended with "claude: not found". | +| D283 | 2026-11-11 | The contr plugin row of Settings → Setup gets one action (#713, S3; owner decision D65). `Install` when the plugin is not installed, `Enable` when it is disabled. A click opens a confirm with the exact command, the same text as the copy line; only `Run` runs it, off the UI thread, with a 2 min limit, and only these two lines with the `claude` the checks find. A spinner shows while it runs; a failure shows the last stderr line under the row; a success runs a recheck. The copy line stays. No other row gets an action (meter D66, gh and hc copy only); contr starts no hc core here and edits no file in Claude Code's folders. | Before, the owner had to copy the line to a terminal. The confirm keeps the run explicit. | diff --git a/docs/spec/SettingsSetup.md b/docs/spec/SettingsSetup.md index 20ad49b3..112356b1 100644 --- a/docs/spec/SettingsSetup.md +++ b/docs/spec/SettingsSetup.md @@ -16,6 +16,10 @@ Shared rules: [README.md](README.md). 4. A row with a step has a copy line under it, in line with the name: the command in mono (`ink-88`) on `hover`, 1px `input-line`, card radius, and `Copy` (plain button) at the right. After a click it says `Copied` for 1.5 s. + The `contr plugin` row also has an action (outlined button) right of `Copy` (D283): + `Install` when the plugin is not installed, `Enable` when it is disabled. While its command + runs, a 14px spinner takes its place. After a failure, the last stderr line shows under the + copy line, in line with the name (`blocked`, one line with an ellipsis). 5. Foot, `hairline` above: `Done` (plain button). | Glyph | State | @@ -47,7 +51,13 @@ Shared rules: [README.md](README.md). Settings opens on the `Setup` tab and `setupSeen` is stored. It never opens by itself again. - A command that times out shows `timed out`; output in another format shows `unexpected … output`. -- No check reads files in Claude Code's or Codex's folders. contr runs no command for the owner - here and does not edit Claude Code's settings. +- No check reads files in Claude Code's or Codex's folders. contr does not edit Claude Code's + settings or files. +- Plugin action (D283): a click on `Install` or `Enable` opens a confirm, `Install the contr + plugin?` or `Enable the contr plugin?`, with `contr runs this command:` over the exact copy + line. `Cancel` and Escape run nothing. `Run` runs the line with the `claude` that the checks + find, off the UI thread, with a 2 min limit (`no answer after 2m0s` when it ends). On success a + recheck runs. No other row has an action: the meter (D66), gh and hc stay copy only, and + contr starts or stops no hc core here. - While a required check is not ready, the `Setup` tab and the Settings button of the sidebar have the 6px `ink-100` dot (D146); the button is named `Settings, setup steps left`. diff --git a/internal/app/settings/settings.go b/internal/app/settings/settings.go index 937e9e42..56c35430 100644 --- a/internal/app/settings/settings.go +++ b/internal/app/settings/settings.go @@ -165,12 +165,16 @@ type Settings struct { Setup *setup.Env // SetupRun runs the checks; nil runs setup.Run. Tests give a fake. SetupRun func(context.Context, setup.Env) []setup.Result + // PluginRun runs the plugin line of the Setup tab (D283); nil runs setup.RunPlugin. Tests give + // a fake. + PluginRun func(ctx context.Context, e setup.Env, line string) error // tab is the tab that shows: SettingsTabs[tab]. tab int ag agentsState hc hcState su setupState + pl pluginState // tintWin and tintPan are the values the sliders edit; the Seen ones are what the last build showed. tintWin, tintPan, tintSeenWin, tintSeenPan float64 @@ -602,6 +606,7 @@ func (s *Settings) View(c *ui.Context) { panel.Children(func() { s.body(c) }) }) s.hcConfirm(c) + s.pluginConfirm(c) if d.Dismissed() || !open { s.open = false s.Env.Redraw() diff --git a/internal/app/settings/settings_plugin.go b/internal/app/settings/settings_plugin.go new file mode 100644 index 00000000..ef9260f7 --- /dev/null +++ b/internal/app/settings/settings_plugin.go @@ -0,0 +1,120 @@ +package settings + +// The one action of the Setup tab (D283): the contr plugin row gets Install or Enable. A click +// opens a confirm with the exact command; only its Run button runs it, off the UI thread, with +// a time limit. On success a recheck runs; on failure the row shows the last stderr line. + +import ( + "context" + "errors" + "time" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/app/kit" + "github.com/contursofran/contr/internal/setup" +) + +// The texts of the plugin action and its confirm. +const ( + SetupInstall = "Install" + SetupEnable = "Enable" + TextPluginConfirmBody = "contr runs this command:" + LabelPluginCancel = "Cancel" + LabelPluginRun = "Run" + // LabelPluginBusy names the spinner while the command runs. + LabelPluginBusy = "plugin command running" +) + +// PluginTimeout is the time limit of the plugin command. Tests make it short. +var PluginTimeout = 2 * time.Minute + +// pluginState is the state of the plugin action. +type pluginState struct { + // confirm is the command the open confirm shows; empty while it is closed. + confirm string + busy bool + err string +} + +// pluginAction is the button of a row: Install or Enable on the plugin row with a step, else "". +func pluginAction(r setup.Result) string { + if r.ID != setup.IDPlugin { + return "" + } + switch r.Copy { + case setup.CopyPlugin: + return SetupInstall + case setup.CopyEnable: + return SetupEnable + } + return "" +} + +// pluginConfirm builds the confirm while it is open. Cancel and Escape run nothing. +func (s *Settings) pluginConfirm(c *ui.Context) { + line := s.pl.confirm + if line == "" { + return + } + open := true + choice := ui.AlertDialog(c, &open, pluginTitle(line), TextPluginConfirmBody+"\n"+line, LabelPluginCancel, LabelPluginRun) + switch { + case choice == 1: + s.pl.confirm = "" + s.runPlugin(line) + case choice == 0 || choice == ui.AlertDismissed || !open: + s.pl.confirm = "" + s.Env.Redraw() + } +} + +// pluginTitle is "Install the contr plugin?" or "Enable the contr plugin?". +func pluginTitle(line string) string { + if line == setup.CopyEnable { + return "Enable the contr plugin?" + } + return "Install the contr plugin?" +} + +// runPlugin runs the line off the UI thread; on success it runs a recheck. +func (s *Settings) runPlugin(line string) { + if s.Setup == nil || s.pl.busy { + return + } + env, run := *s.Setup, s.PluginRun + if run == nil { + run = setup.RunPlugin + } + s.pl = pluginState{busy: true} + s.Env.Redraw() + s.Env.Do(func() func() { + ctx, cancel := context.WithTimeout(context.Background(), PluginTimeout) + defer cancel() + err := run(ctx, env, line) + if ctx.Err() == context.DeadlineExceeded { + err = errors.New("no answer after " + PluginTimeout.String()) + } + return func() { + s.pl = pluginState{} + if err != nil { + s.pl.err = setup.LastLine(err.Error()) + s.Env.Redraw() + return + } + s.recheck() + } + }) +} + +// pluginButton is the action at the right of the copy line: a spinner while the command runs. +func (s *Settings) pluginButton(c *ui.Context, label, line string) { + if s.pl.busy { + ui.Spinner(c).Label(LabelPluginBusy).Size(14, 14) + return + } + SettingsButton(c, label, kit.ButtonOutlined, func() { + s.pl.confirm = line + s.Env.Redraw() + }) +} diff --git a/internal/app/settings/settings_plugin_test.go b/internal/app/settings/settings_plugin_test.go new file mode 100644 index 00000000..33ac3b87 --- /dev/null +++ b/internal/app/settings/settings_plugin_test.go @@ -0,0 +1,153 @@ +package settings + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/setup" +) + +// pluginEnv is setupEnv with the contr plugin in the list given as JSON. +func pluginEnv(t *testing.T, list string, runs *int) *setup.Env { + env := setupEnv(t, nil, runs) + run := env.Run + env.Run = func(ctx context.Context, name string, args ...string) ([]byte, error) { + if name == "claude" { + *runs++ + return []byte(list), nil + } + return run(ctx, name, args...) + } + return env +} + +const ( + listMissing = `[]` + listDisabled = `[{"id":"contr@contr","version":"0.1.0","enabled":false}]` +) + +// pluginRig opens the Setup tab with the plugin in list and PluginRun set to run. +func pluginRig(t *testing.T, list string, runs *int, run func(context.Context, setup.Env, string) error) *settingsRig { + t.Helper() + r := newSettingsRig(t, nil) + r.s.PluginRun = run + r.openSetup(pluginEnv(t, list, runs), true) + return r +} + +// clickPlugin clicks the action, checks the confirm shows line, and clicks button. +func clickPlugin(t *testing.T, r *settingsRig, action, line, button string) { + t.Helper() + if err := r.tt.Click(action); err != nil { + t.Fatal(err) + } + r.tt.Frame() + if !r.tt.HasText(TextPluginConfirmBody + "\n" + line) { + t.Fatalf("no confirm with %q: texts %q", line, r.texts()) + } + if button == "" { + return + } + if err := r.tt.Click(button); err != nil { + t.Fatal(err) + } + r.tt.Frame() +} + +func TestPluginEnableRunsTheLineAndRechecks(t *testing.T) { + checks, ran := 0, []string{} + r := pluginRig(t, listDisabled, &checks, func(_ context.Context, _ setup.Env, line string) error { + ran = append(ran, line) + return nil + }) + before := checks + clickPlugin(t, r, SetupEnable, setup.CopyEnable, LabelPluginRun) + if !slices.Equal(ran, []string{setup.CopyEnable}) || checks <= before { + t.Fatalf("ran %q, checks %d then %d", ran, before, checks) + } +} + +func TestPluginInstallShowsTheLastErrorLine(t *testing.T) { + checks, ran := 0, []string{} + r := pluginRig(t, listMissing, &checks, func(_ context.Context, _ setup.Env, line string) error { + ran = append(ran, line) + return errors.New("first\nmarketplace contr not found") + }) + before := checks + clickPlugin(t, r, SetupInstall, setup.CopyPlugin, LabelPluginRun) + if !slices.Equal(ran, []string{setup.CopyPlugin}) || checks != before || + !r.hasText("marketplace contr not found") || !r.has(SetupCopy) { + t.Fatalf("ran %q, checks %d then %d, texts %q", ran, before, checks, r.texts()) + } +} + +func TestPluginTimeout(t *testing.T) { + old := PluginTimeout + PluginTimeout = 20 * time.Millisecond + t.Cleanup(func() { PluginTimeout = old }) + checks := 0 + r := pluginRig(t, listMissing, &checks, func(ctx context.Context, _ setup.Env, _ string) error { + <-ctx.Done() // the fake hangs until the time limit + return ctx.Err() + }) + clickPlugin(t, r, SetupInstall, setup.CopyPlugin, LabelPluginRun) + if !r.hasText("no answer after " + PluginTimeout.String()) { + t.Fatalf("texts %q", r.texts()) + } +} + +func TestPluginCancelAndEscapeRunNothing(t *testing.T) { + checks, ran := 0, 0 + r := pluginRig(t, listDisabled, &checks, func(context.Context, setup.Env, string) error { ran++; return nil }) + clickPlugin(t, r, SetupEnable, setup.CopyEnable, LabelPluginCancel) + clickPlugin(t, r, SetupEnable, setup.CopyEnable, "") + r.tt.Key(0, ui.KeyEscape) + r.tt.Frame() + if ran != 0 || r.s.pl.confirm != "" || r.s.pl.busy { + t.Fatalf("ran %d, state %+v", ran, r.s.pl) + } +} + +func TestPluginSpinnerWhileRunning(t *testing.T) { + checks := 0 + r := pluginRig(t, listDisabled, &checks, func(context.Context, setup.Env, string) error { return nil }) + var held func() + r.s.Env.Run.Go = func(fn func()) { held = fn } + clickPlugin(t, r, SetupEnable, setup.CopyEnable, LabelPluginRun) + if !r.has(LabelPluginBusy) || r.has(SetupEnable) { + t.Fatalf("no spinner: texts %q", r.texts()) + } + r.s.Env.Run.Go = nil + held() + r.tt.Frame() + if r.has(LabelPluginBusy) { + t.Fatal("spinner stays after the run") + } +} + +func TestPluginNoButtonOnReadyAndCopyOnlyRows(t *testing.T) { + checks := 0 + r := newSettingsRig(t, nil) + r.openSetup(setupEnv(t, map[string]bool{"gh": true, "codex": true}, &checks), true) + for _, l := range []string{SetupInstall, SetupEnable} { + if r.has(l) { + t.Fatalf("%s shows with the plugin ready: texts %q", l, r.texts()) + } + } + if !r.has(SetupCopy) { + t.Fatal("copy-only rows lost Copy") + } + for _, res := range []setup.Result{ + {ID: setup.IDMeter, Copy: setup.CopyMeter}, {ID: setup.IDGH, Copy: setup.CopyGHLogin}, + {ID: setup.IDHC, Copy: setup.CopyHC}, {ID: setup.IDPlugin}, + } { + if a := pluginAction(res); a != "" { + t.Fatalf("%s gets %q", res.ID, a) + } + } +} diff --git a/internal/app/settings/settings_setup.go b/internal/app/settings/settings_setup.go index 75fc176f..19687c26 100644 --- a/internal/app/settings/settings_setup.go +++ b/internal/app/settings/settings_setup.go @@ -2,8 +2,8 @@ package settings // The Setup tab of Settings (D278): a read-only checklist of what contr needs. The checks run off // the UI thread when Settings opens, when the app becomes active while the tab shows, and on -// Recheck; never while Settings is closed. Rows with a step show a line to copy: contr runs no -// command for the owner here. +// Recheck; never while Settings is closed. Rows with a step show a line to copy. The contr plugin +// row also gets Install or Enable (D283, settings_plugin.go); no other row runs a command. import ( "context" @@ -226,7 +226,13 @@ func (s *Settings) setupRow(c *ui.Context, r setup.Result) { } id, text := r.ID, r.Copy SettingsButton(c, label, kit.ButtonPlain, func() { s.copyLine(id, text) }) + if a := pluginAction(r); a != "" { + s.pluginButton(c, a, r.Copy) + } }) + if r.ID == setup.IDPlugin && s.pl.err != "" { + ui.Text(c, s.pl.err).Margin(0, 0, 0, 22).SingleLine().Ellipsis("…").MinWidth(0).TextColor(p.Blocked) + } }) } diff --git a/internal/app/settings/sheet_test.go b/internal/app/settings/sheet_test.go index 5df4b34c..3c4acb4a 100644 --- a/internal/app/settings/sheet_test.go +++ b/internal/app/settings/sheet_test.go @@ -1,7 +1,8 @@ package settings // The SettingsSetup part of the spec sheet (D278): the Setup tab with each row state. It has no -// spec image yet: the spec is docs/spec/SettingsSetup.md. +// spec image yet: the spec is docs/spec/SettingsSetup.md. The SettingsSetupPlugin part is the +// contr plugin row after a failed Install, with the confirm of Install open (D283). // // The SettingsUpdates part of the spec sheet (D179, D277): the Updates tab after an hc update // that restarted the core, then the tab while an hc update runs, then the confirm of "Update hc" @@ -53,8 +54,16 @@ func TestSpecSheet(t *testing.T) { {ID: setup.IDCodex, Name: "codex", State: setup.Optional, Detail: "not installed", Copy: setup.CopyCodex}, {ID: setup.IDContr, Name: "contr command", Detail: "linked"}, } + // The plugin row after a failed Install, with the confirm of the next click open (D283). + pl := &Settings{Setup: &setup.Env{}, CoreVersion: s.CoreVersion, + pl: pluginState{err: "marketplace contr not found", confirm: setup.CopyPlugin}} + pl.su.results = []setup.Result{ + {ID: setup.IDHC, Name: "hc", Required: true, Detail: "hc 0.1.18"}, + {ID: setup.IDPlugin, Name: "contr plugin", Required: true, State: setup.Missing, Detail: "not installed", Copy: setup.CopyPlugin}, + } apptest.SpecSheet(t, []apptest.SheetPart{ + {Name: "SettingsSetupPlugin", W: int(theme.ModalWidth), H: 360, View: func(c *ui.Context) { pl.setupTab(c); pl.pluginConfirm(c) }}, {Name: "SettingsSetup", W: int(theme.ModalWidth), H: 620, View: func(c *ui.Context) { s.setupTab(c) }}, {Name: "SettingsUpdates", W: sheetUpdatesW, H: sheetUpdatesH, View: sheetUpdates}, - }, []string{"SettingsSetup", "SettingsUpdates"}) + }, []string{"SettingsSetup", "SettingsSetupPlugin", "SettingsUpdates"}) } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 981f9bc6..6be60f89 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -306,3 +306,35 @@ func (e Env) contr() Result { r.Detail = "linked" return r } + +// RunPlugin runs a plugin line of the contr plugin row (CopyPlugin or CopyEnable) with the +// claude that the checks find (D283). Any other line is an error: contr runs no other command +// here. The error of a failed run is the last line on stderr. +func RunPlugin(ctx context.Context, e Env, line string) error { + if line != CopyPlugin && line != CopyEnable { + return errors.New("not a plugin command: " + line) + } + p, err := e.find("claude") + if err != nil { + return err + } + args := strings.Fields(line)[1:] + run := e.Run + if run == nil { + run = execRun + } + _, err = run(ctx, p, args...) + var ee *exec.ExitError + if errors.As(err, &ee) { + if l := LastLine(string(ee.Stderr)); l != "" { + return errors.New(l) + } + } + return err +} + +// LastLine is the last line of s that is not blank, trimmed. +func LastLine(s string) string { + ls := strings.Split(strings.TrimSpace(s), "\n") + return strings.TrimSpace(ls[len(ls)-1]) +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index e07adf30..d497381c 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -3,6 +3,8 @@ package setup import ( "context" "errors" + "os/exec" + "strings" "testing" "time" @@ -169,3 +171,33 @@ func TestCoreAndRequired(t *testing.T) { t.Fatalf("Required = %d, want 2", n) } } + +func TestRunPluginRunsOnlyThePluginLines(t *testing.T) { + var got []string + e := Env{Find: func(string) (string, error) { return "/bin/claude", nil }, + Run: func(_ context.Context, name string, args ...string) ([]byte, error) { + got = append([]string{name}, args...) + return nil, nil + }} + if err := RunPlugin(context.Background(), e, CopyEnable); err != nil || strings.Join(got, " ") != "/bin/claude plugin enable contr@contr" { + t.Fatalf("err %v, ran %q", err, got) + } + got = nil + if err := RunPlugin(context.Background(), e, "rm -rf x"); err == nil || got != nil { + t.Fatalf("ran another line: %v %q", err, got) + } + e.Find = func(string) (string, error) { return "", errors.New("claude not found") } + if err := RunPlugin(context.Background(), e, CopyPlugin); err == nil || got != nil { + t.Fatalf("ran without claude: %v", err) + } +} + +func TestRunPluginErrorIsTheLastStderrLine(t *testing.T) { + e := Env{Find: func(string) (string, error) { return "/bin/sh", nil }, + Run: func(ctx context.Context, _ string, _ ...string) ([]byte, error) { + return exec.CommandContext(ctx, "/bin/sh", "-c", "echo one >&2; echo 'two last' >&2; exit 1").Output() + }} + if err := RunPlugin(context.Background(), e, CopyPlugin); err == nil || err.Error() != "two last" { + t.Fatalf("err %v", err) + } +}