on main help sign in
refactor: migrate run and login commands to use client API instead of direct app access
commite44211db2573aeda9100334cfc76908f918ad0bc authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 13 Mar 2026, 12:42 (7 months ago) parent344eb32
4 files changed+375−503
internal/cmd/login.go+28−17 internal/cmd/root.go+22−112 internal/cmd/run.go+325−4 internal/workspace/app_workspace.go−370
internal/cmd/login.go+28−17
@@ -10,10 +10,12 @@ import (
1010 "charm.land/lipgloss/v2"
1111 "github.com/atotto/clipboard"
1212 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
13+ "github.com/charmbracelet/crush/internal/client"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/oauth"
1516 "github.com/charmbracelet/crush/internal/oauth/copilot"
1617 "github.com/charmbracelet/crush/internal/oauth/hyper"
18+ "github.com/charmbracelet/x/ansi"
1719 "github.com/pkg/browser"
1820 "github.com/spf13/cobra"
1921)
@@ -40,11 +42,17 @@ crush login copilot
4042 },
4143 Args: cobra.MaximumNArgs(1),
4244 RunE: func(cmd *cobra.Command, args []string) error {
43− app, err := setupAppWithProgressBar(cmd)
45+ c, ws, cleanup, err := connectToServer(cmd)
4446 if err != nil {
4547 return err
4648 }
47− defer app.Shutdown()
49+ defer cleanup()
50+
51+ progressEnabled := ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
52+ if progressEnabled && supportsProgressBar() {
53+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
54+ defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
55+ }
4856
4957 provider := "hyper"
5058 if len(args) > 0 {
@@ -52,16 +60,16 @@ crush login copilot
5260 }
5361 switch provider {
5462 case "hyper":
55− return loginHyper(app.Store())
63+ return loginHyper(c, ws.ID)
5664 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Store())
65+ return loginCopilot(cmd.Context(), c, ws.ID)
5866 default:
5967 return fmt.Errorf("unknown platform: %s", args[0])
6068 }
6169 },
6270}
6371
64−func loginHyper(cfg *config.ConfigStore) error {
72+func loginHyper(c *client.Client, wsID string) error {
6573 if !hyperp.Enabled() {
6674 return fmt.Errorf("hyper not enabled")
6775 }
@@ -112,8 +120,8 @@ func loginHyper(cfg *config.ConfigStore) error {
112120 }
113121
114122 if err := cmp.Or(
115− cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
123+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
124+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.oauth", token),
117125 ); err != nil {
118126 return err
119127 }
@@ -123,12 +131,15 @@ func loginHyper(cfg *config.ConfigStore) error {
123131 return nil
124132}
125133
126−func loginCopilot(cfg *config.ConfigStore) error {
127− ctx := getLoginContext()
134+func loginCopilot(ctx context.Context, c *client.Client, wsID string) error {
135+ loginCtx := getLoginContext()
128136
129− if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
130− fmt.Println("You are already logged in to GitHub Copilot.")
131− return nil
137+ cfg, err := c.GetConfig(ctx, wsID)
138+ if err == nil && cfg != nil {
139+ if pc, ok := cfg.Providers.Get("copilot"); ok && pc.OAuthToken != nil {
140+ fmt.Println("You are already logged in to GitHub Copilot.")
141+ return nil
142+ }
132143 }
133144
134145 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
@@ -138,14 +149,14 @@ func loginCopilot(cfg *config.ConfigStore) error {
138149 case hasDiskToken:
139150 fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
140151
141− t, err := copilot.RefreshToken(ctx, diskToken)
152+ t, err := copilot.RefreshToken(loginCtx, diskToken)
142153 if err != nil {
143154 return fmt.Errorf("unable to refresh token from disk: %w", err)
144155 }
145156 token = t
146157 default:
147158 fmt.Println("Requesting device code from GitHub...")
148− dc, err := copilot.RequestDeviceCode(ctx)
159+ dc, err := copilot.RequestDeviceCode(loginCtx)
149160 if err != nil {
150161 return err
151162 }
@@ -159,7 +170,7 @@ func loginCopilot(cfg *config.ConfigStore) error {
159170 fmt.Println()
160171 fmt.Println("Waiting for authorization...")
161172
162− t, err := copilot.PollForToken(ctx, dc)
173+ t, err := copilot.PollForToken(loginCtx, dc)
163174 if err == copilot.ErrNotAvailable {
164175 fmt.Println()
165176 fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
@@ -177,8 +188,8 @@ func loginCopilot(cfg *config.ConfigStore) error {
177188 }
178189
179190 if err := cmp.Or(
180− cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
191+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
192+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.oauth", token),
182193 ); err != nil {
183194 return err
184195 }
internal/cmd/root.go+22−112
@@ -20,12 +20,9 @@ import (
2020 tea "charm.land/bubbletea/v2"
2121 "charm.land/lipgloss/v2"
2222 "github.com/charmbracelet/colorprofile"
23− "github.com/charmbracelet/crush/internal/app"
2423 "github.com/charmbracelet/crush/internal/client"
2524 "github.com/charmbracelet/crush/internal/config"
26− "github.com/charmbracelet/crush/internal/db"
2725 "github.com/charmbracelet/crush/internal/event"
28− "github.com/charmbracelet/crush/internal/projects"
2926 "github.com/charmbracelet/crush/internal/proto"
3027 "github.com/charmbracelet/crush/internal/server"
3128 "github.com/charmbracelet/crush/internal/ui/common"
@@ -34,7 +31,6 @@ import (
3431 "github.com/charmbracelet/crush/internal/workspace"
3532 "github.com/charmbracelet/fang"
3633 uv "github.com/charmbracelet/ultraviolet"
37− "github.com/charmbracelet/x/ansi"
3834 "github.com/charmbracelet/x/exp/charmtone"
3935 "github.com/charmbracelet/x/term"
4036 "github.com/spf13/cobra"
@@ -46,10 +42,9 @@ func init() {
4642 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
4743 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
4844 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
45+ rootCmd.PersistentFlags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
46+ rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
4947 rootCmd.Flags().BoolP("help", "h", false, "Help")
50− rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
51−
52− rootCmd.Flags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
5348
5449 rootCmd.AddCommand(
5550 runCmd,
@@ -87,20 +82,11 @@ crush --yolo
8782crush --data-dir /path/to/custom/.crush
8883 `,
8984 RunE: func(cmd *cobra.Command, args []string) error {
90− hostURL, err := server.ParseHostURL(clientHost)
91− if err != nil {
92− return fmt.Errorf("invalid host URL: %v", err)
93− }
94−
95− if err := ensureServer(cmd, hostURL); err != nil {
96− return err
97− }
98−
99− c, ws, err := setupClientApp(cmd, hostURL)
85+ c, ws, cleanup, err := connectToServer(cmd)
10086 if err != nil {
10187 return err
10288 }
103− defer func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }()
89+ defer cleanup()
10490
10591 event.AppInitialized()
10692
@@ -188,25 +174,18 @@ func supportsProgressBar() bool {
188174 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
189175}
190176
191−func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
192− app, err := setupApp(cmd)
177+// connectToServer ensures the server is running, creates a client and
178+// workspace, and returns a cleanup function that deletes the workspace.
179+func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
180+ hostURL, err := server.ParseHostURL(clientHost)
193181 if err != nil {
194− return nil, err
182+ return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
195183 }
196184
197− // Check if progress bar is enabled in config (defaults to true if nil)
198− progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
199− if progressEnabled && supportsProgressBar() {
200− _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
201− defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
185+ if err := ensureServer(cmd, hostURL); err != nil {
186+ return nil, nil, nil, err
202187 }
203188
204− return app, nil
205−}
206−
207−// setupApp handles the common setup logic for both interactive and non-interactive modes.
208−// It returns the app instance, config, cleanup function, and any error.
209−func setupApp(cmd *cobra.Command) (*app.App, error) {
210189 debug, _ := cmd.Flags().GetBool("debug")
211190 yolo, _ := cmd.Flags().GetBool("yolo")
212191 dataDir, _ := cmd.Flags().GetString("data-dir")
@@ -214,95 +193,40 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
214193
215194 cwd, err := ResolveCwd(cmd)
216195 if err != nil {
217− return nil, err
218− }
219−
220− store, err := config.Init(cwd, dataDir, debug)
221− if err != nil {
222− return nil, err
223− }
224−
225− store.Overrides().SkipPermissionRequests = yolo
226− cfg := store.Config()
227−
228− if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
229− return nil, err
230− }
231−
232− // Register this project in the centralized projects list.
233− if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
234− slog.Warn("Failed to register project", "error", err)
235− // Non-fatal: continue even if registration fails
236− }
237−
238− // Connect to DB; this will also run migrations.
239− conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
240− if err != nil {
241− return nil, err
242− }
243−
244− appInstance, err := app.New(ctx, conn, store)
245− if err != nil {
246− slog.Error("Failed to create app instance", "error", err)
247− return nil, err
248− }
249−
250− if shouldEnableMetrics(cfg) {
251− event.Init()
252− }
253−
254− return appInstance, nil
255−}
256−
257−// setupClientApp sets up a client-based workspace via the server. It
258−// auto-starts a detached server process if the socket does not exist.
259−func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
260− debug, _ := cmd.Flags().GetBool("debug")
261− yolo, _ := cmd.Flags().GetBool("yolo")
262− dataDir, _ := cmd.Flags().GetString("data-dir")
263− ctx := cmd.Context()
264−
265− cwd, err := ResolveCwd(cmd)
266− if err != nil {
267− return nil, nil, err
196+ return nil, nil, nil, err
268197 }
269198
270199 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
271200 if err != nil {
272− return nil, nil, err
201+ return nil, nil, nil, err
273202 }
274203
275− ws, err := c.CreateWorkspace(ctx, proto.Workspace{
204+ wsReq := proto.Workspace{
276205 Path: cwd,
277206 DataDir: dataDir,
278207 Debug: debug,
279208 YOLO: yolo,
280209 Version: version.Version,
281210 Env: os.Environ(),
282− })
211+ }
212+
213+ ws, err := c.CreateWorkspace(ctx, wsReq)
283214 if err != nil {
284215 // The server socket may exist before the HTTP handler is ready.
285216 // Retry a few times with a short backoff.
286217 for range 5 {
287218 select {
288219 case <-ctx.Done():
289− return nil, nil, ctx.Err()
220+ return nil, nil, nil, ctx.Err()
290221 case <-time.After(200 * time.Millisecond):
291222 }
292− ws, err = c.CreateWorkspace(ctx, proto.Workspace{
293− Path: cwd,
294− DataDir: dataDir,
295− Debug: debug,
296− YOLO: yolo,
297− Version: version.Version,
298− Env: os.Environ(),
299− })
223+ ws, err = c.CreateWorkspace(ctx, wsReq)
300224 if err == nil {
301225 break
302226 }
303227 }
304228 if err != nil {
305− return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
229+ return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
306230 }
307231 }
308232
@@ -310,7 +234,8 @@ func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *prot
310234 event.Init()
311235 }
312236
313− return c, ws, nil
237+ cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
238+ return c, ws, cleanup, nil
314239}
315240
316241// ensureServer auto-starts a detached server if the socket file does not
@@ -488,18 +413,3 @@ func ResolveCwd(cmd *cobra.Command) (string, error) {
488413 }
489414 return cwd, nil
490415}
491−
492−func createDotCrushDir(dir string) error {
493− if err := os.MkdirAll(dir, 0o700); err != nil {
494− return fmt.Errorf("failed to create data directory: %q %w", dir, err)
495− }
496−
497− gitIgnorePath := filepath.Join(dir, ".gitignore")
498− if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
499− if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
500− return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
501− }
502− }
503−
504− return nil
505−}
internal/cmd/run.go+325−4
@@ -7,9 +7,21 @@ import (
77 "os"
88 "os/signal"
99 "strings"
10+ "time"
1011
12+ "charm.land/lipgloss/v2"
1113 "charm.land/log/v2"
14+ "github.com/charmbracelet/crush/internal/client"
15+ "github.com/charmbracelet/crush/internal/config"
1216 "github.com/charmbracelet/crush/internal/event"
17+ "github.com/charmbracelet/crush/internal/format"
18+ "github.com/charmbracelet/crush/internal/proto"
19+ "github.com/charmbracelet/crush/internal/pubsub"
20+ "github.com/charmbracelet/crush/internal/ui/anim"
21+ "github.com/charmbracelet/crush/internal/ui/styles"
22+ "github.com/charmbracelet/x/ansi"
23+ "github.com/charmbracelet/x/exp/charmtone"
24+ "github.com/charmbracelet/x/term"
1325 "github.com/spf13/cobra"
1426)
1527
@@ -47,13 +59,13 @@ crush run --verbose "Generate a README for this project"
4759 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
4860 defer cancel()
4961
50− app, err := setupApp(cmd)
62+ c, ws, cleanup, err := connectToServer(cmd)
5163 if err != nil {
5264 return err
5365 }
54− defer app.Shutdown()
66+ defer cleanup()
5567
56− if !app.Config().IsConfigured() {
68+ if !ws.Config.IsConfigured() {
5769 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
5870 }
5971
@@ -76,7 +88,7 @@ crush run --verbose "Generate a README for this project"
7688 event.SetNonInteractive(true)
7789 event.AppInitialized()
7890
79− return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
91+ return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose)
8092 },
8193}
8294
@@ -86,3 +98,312 @@ func init() {
8698 runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
8799 runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
88100}
101+
102+// runNonInteractive executes the agent via the server and streams output
103+// to stdout.
104+func runNonInteractive(
105+ ctx context.Context,
106+ c *client.Client,
107+ ws *proto.Workspace,
108+ prompt, largeModel, smallModel string,
109+ hideSpinner bool,
110+) error {
111+ slog.Info("Running in non-interactive mode")
112+
113+ ctx, cancel := context.WithCancel(ctx)
114+ defer cancel()
115+
116+ if largeModel != "" || smallModel != "" {
117+ if err := overrideModels(ctx, c, ws, largeModel, smallModel); err != nil {
118+ return fmt.Errorf("failed to override models: %w", err)
119+ }
120+ }
121+
122+ var (
123+ spinner *format.Spinner
124+ stdoutTTY bool
125+ stderrTTY bool
126+ stdinTTY bool
127+ progress bool
128+ )
129+
130+ stdoutTTY = term.IsTerminal(os.Stdout.Fd())
131+ stderrTTY = term.IsTerminal(os.Stderr.Fd())
132+ stdinTTY = term.IsTerminal(os.Stdin.Fd())
133+ progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
134+
135+ if !hideSpinner && stderrTTY {
136+ t := styles.DefaultStyles()
137+
138+ hasDarkBG := true
139+ if stdinTTY && stdoutTTY {
140+ hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
141+ }
142+ defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
143+
144+ spinner = format.NewSpinner(ctx, cancel, anim.Settings{
145+ Size: 10,
146+ Label: "Generating",
147+ LabelColor: defaultFG,
148+ GradColorA: t.Primary,
149+ GradColorB: t.Secondary,
150+ CycleColors: true,
151+ })
152+ spinner.Start()
153+ }
154+
155+ stopSpinner := func() {
156+ if !hideSpinner && spinner != nil {
157+ spinner.Stop()
158+ spinner = nil
159+ }
160+ }
161+
162+ // Wait for the agent to become ready (MCP init, etc).
163+ if err := waitForAgent(ctx, c, ws.ID); err != nil {
164+ stopSpinner()
165+ return fmt.Errorf("agent not ready: %w", err)
166+ }
167+
168+ // Force-update agent models so MCP tools are loaded.
169+ if err := c.UpdateAgent(ctx, ws.ID); err != nil {
170+ slog.Warn("Failed to update agent", "error", err)
171+ }
172+
173+ defer stopSpinner()
174+
175+ sess, err := c.CreateSession(ctx, ws.ID, "non-interactive")
176+ if err != nil {
177+ return fmt.Errorf("failed to create session: %w", err)
178+ }
179+ slog.Info("Created session for non-interactive run", "session_id", sess.ID)
180+
181+ events, err := c.SubscribeEvents(ctx, ws.ID)
182+ if err != nil {
183+ return fmt.Errorf("failed to subscribe to events: %w", err)
184+ }
185+
186+ if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
187+ return fmt.Errorf("failed to send message: %w", err)
188+ }
189+
190+ messageReadBytes := make(map[string]int)
191+ var printed bool
192+
193+ defer func() {
194+ if progress && stderrTTY {
195+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
196+ }
197+ _, _ = fmt.Fprintln(os.Stdout)
198+ }()
199+
200+ for {
201+ if progress && stderrTTY {
202+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
203+ }
204+
205+ select {
206+ case ev, ok := <-events:
207+ if !ok {
208+ stopSpinner()
209+ return nil
210+ }
211+
212+ switch e := ev.(type) {
213+ case pubsub.Event[proto.Message]:
214+ msg := e.Payload
215+ if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
216+ continue
217+ }
218+ stopSpinner()
219+
220+ content := msg.Content().String()
221+ readBytes := messageReadBytes[msg.ID]
222+
223+ if len(content) < readBytes {
224+ slog.Error("Non-interactive: message content shorter than read bytes",
225+ "message_length", len(content), "read_bytes", readBytes)
226+ return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
227+ }
228+
229+ part := content[readBytes:]
230+ if readBytes == 0 {
231+ part = strings.TrimLeft(part, " \t")
232+ }
233+ if printed || strings.TrimSpace(part) != "" {
234+ printed = true
235+ fmt.Fprint(os.Stdout, part)
236+ }
237+ messageReadBytes[msg.ID] = len(content)
238+
239+ if msg.IsFinished() {
240+ return nil
241+ }
242+
243+ case pubsub.Event[proto.AgentEvent]:
244+ if e.Payload.Error != nil {
245+ stopSpinner()
246+ return fmt.Errorf("agent error: %w", e.Payload.Error)
247+ }
248+ }
249+
250+ case <-ctx.Done():
251+ stopSpinner()
252+ return ctx.Err()
253+ }
254+ }
255+}
256+
257+// waitForAgent polls GetAgentInfo until the agent is ready, with a
258+// timeout.
259+func waitForAgent(ctx context.Context, c *client.Client, wsID string) error {
260+ timeout := time.After(30 * time.Second)
261+ for {
262+ info, err := c.GetAgentInfo(ctx, wsID)
263+ if err == nil && info.IsReady {
264+ return nil
265+ }
266+ select {
267+ case <-timeout:
268+ if err != nil {
269+ return fmt.Errorf("timeout waiting for agent: %w", err)
270+ }
271+ return fmt.Errorf("timeout waiting for agent readiness")
272+ case <-ctx.Done():
273+ return ctx.Err()
274+ case <-time.After(200 * time.Millisecond):
275+ }
276+ }
277+}
278+
279+// overrideModels resolves model strings and updates the workspace
280+// configuration via the server.
281+func overrideModels(
282+ ctx context.Context,
283+ c *client.Client,
284+ ws *proto.Workspace,
285+ largeModel, smallModel string,
286+) error {
287+ cfg, err := c.GetConfig(ctx, ws.ID)
288+ if err != nil {
289+ return fmt.Errorf("failed to get config: %w", err)
290+ }
291+
292+ providers := cfg.Providers.Copy()
293+
294+ largeMatches, smallMatches := findModelMatches(providers, largeModel, smallModel)
295+
296+ var largeProviderID string
297+
298+ if largeModel != "" {
299+ found, err := validateModelMatches(largeMatches, largeModel, "large")
300+ if err != nil {
301+ return err
302+ }
303+ largeProviderID = found.provider
304+ slog.Info("Overriding large model", "provider", found.provider, "model", found.modelID)
305+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeLarge, config.SelectedModel{
306+ Provider: found.provider,
307+ Model: found.modelID,
308+ }); err != nil {
309+ return fmt.Errorf("failed to set large model: %w", err)
310+ }
311+ }
312+
313+ switch {
314+ case smallModel != "":
315+ found, err := validateModelMatches(smallMatches, smallModel, "small")
316+ if err != nil {
317+ return err
318+ }
319+ slog.Info("Overriding small model", "provider", found.provider, "model", found.modelID)
320+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, config.SelectedModel{
321+ Provider: found.provider,
322+ Model: found.modelID,
323+ }); err != nil {
324+ return fmt.Errorf("failed to set small model: %w", err)
325+ }
326+
327+ case largeModel != "":
328+ sm, err := c.GetDefaultSmallModel(ctx, ws.ID, largeProviderID)
329+ if err != nil {
330+ slog.Warn("Failed to get default small model", "error", err)
331+ } else if sm != nil {
332+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, *sm); err != nil {
333+ return fmt.Errorf("failed to set small model: %w", err)
334+ }
335+ }
336+ }
337+
338+ return c.UpdateAgent(ctx, ws.ID)
339+}
340+
341+type modelMatch struct {
342+ provider string
343+ modelID string
344+}
345+
346+// findModelMatches searches providers for matching large/small model
347+// strings.
348+func findModelMatches(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch) {
349+ largeFilter, largeID := parseModelString(largeModel)
350+ smallFilter, smallID := parseModelString(smallModel)
351+
352+ var largeMatches, smallMatches []modelMatch
353+ for name, provider := range providers {
354+ if provider.Disable {
355+ continue
356+ }
357+ for _, m := range provider.Models {
358+ if matchesModel(largeID, largeFilter, m.ID, name) {
359+ largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
360+ }
361+ if matchesModel(smallID, smallFilter, m.ID, name) {
362+ smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
363+ }
364+ }
365+ }
366+ return largeMatches, smallMatches
367+}
368+
369+// parseModelString splits "provider/model" into (provider, model) or
370+// ("", model).
371+func parseModelString(s string) (string, string) {
372+ if s == "" {
373+ return "", ""
374+ }
375+ if idx := strings.Index(s, "/"); idx >= 0 {
376+ return s[:idx], s[idx+1:]
377+ }
378+ return "", s
379+}
380+
381+// matchesModel returns true if the model ID matches the filter
382+// criteria.
383+func matchesModel(wantID, wantProvider, modelID, providerName string) bool {
384+ if wantID == "" {
385+ return false
386+ }
387+ if wantProvider != "" && wantProvider != providerName {
388+ return false
389+ }
390+ return strings.EqualFold(modelID, wantID)
391+}
392+
393+// validateModelMatches ensures exactly one match exists.
394+func validateModelMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
395+ switch {
396+ case len(matches) == 0:
397+ return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
398+ case len(matches) > 1:
399+ names := make([]string, len(matches))
400+ for i, m := range matches {
401+ names[i] = m.provider
402+ }
403+ return modelMatch{}, fmt.Errorf(
404+ "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
405+ label, modelID, strings.Join(names, ", "),
406+ )
407+ }
408+ return matches[0], nil
409+}
internal/workspace/app_workspace.godeleted−370
@@ -1,370 +0,0 @@
1−package workspace
2−
3−import (
4− "context"
5− "log/slog"
6− "time"
7−
8− tea "charm.land/bubbletea/v2"
9− "github.com/charmbracelet/crush/internal/agent"
10− mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
11− "github.com/charmbracelet/crush/internal/app"
12− "github.com/charmbracelet/crush/internal/commands"
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/history"
15− "github.com/charmbracelet/crush/internal/log"
16− "github.com/charmbracelet/crush/internal/lsp"
17− "github.com/charmbracelet/crush/internal/message"
18− "github.com/charmbracelet/crush/internal/oauth"
19− "github.com/charmbracelet/crush/internal/permission"
20− "github.com/charmbracelet/crush/internal/pubsub"
21− "github.com/charmbracelet/crush/internal/session"
22−)
23−
24−// AppWorkspace wraps an in-process app.App to satisfy the Workspace
25−// interface. This is the default mode when no server is involved.
26−type AppWorkspace struct {
27− app *app.App
28−}
29−
30−// NewAppWorkspace creates a Workspace backed by a local app.App.
31−func NewAppWorkspace(a *app.App) *AppWorkspace {
32− return &AppWorkspace{app: a}
33−}
34−
35−// App returns the underlying app.App for callers that still need
36−// direct access during the migration period.
37−func (w *AppWorkspace) App() *app.App {
38− return w.app
39−}
40−
41−// -- Sessions --
42−
43−func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
44− return w.app.Sessions.Create(ctx, title)
45−}
46−
47−func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
48− return w.app.Sessions.Get(ctx, sessionID)
49−}
50−
51−func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
52− return w.app.Sessions.List(ctx)
53−}
54−
55−func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
56− return w.app.Sessions.Save(ctx, sess)
57−}
58−
59−func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
60− return w.app.Sessions.Delete(ctx, sessionID)
61−}
62−
63−func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
64− return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
65−}
66−
67−func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
68− return w.app.Sessions.ParseAgentToolSessionID(sessionID)
69−}
70−
71−// -- Messages --
72−
73−func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
74− return w.app.Messages.List(ctx, sessionID)
75−}
76−
77−func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
78− return w.app.Messages.ListUserMessages(ctx, sessionID)
79−}
80−
81−func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
82− return w.app.Messages.ListAllUserMessages(ctx)
83−}
84−
85−// -- Agent --
86−
87−func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
88− if w.app.AgentCoordinator == nil {
89− return nil
90− }
91− _, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
92− return err
93−}
94−
95−func (w *AppWorkspace) AgentCancel(sessionID string) {
96− if w.app.AgentCoordinator != nil {
97− w.app.AgentCoordinator.Cancel(sessionID)
98− }
99−}
100−
101−func (w *AppWorkspace) AgentIsBusy() bool {
102− if w.app.AgentCoordinator == nil {
103− return false
104− }
105− return w.app.AgentCoordinator.IsBusy()
106−}
107−
108−func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
109− if w.app.AgentCoordinator == nil {
110− return false
111− }
112− return w.app.AgentCoordinator.IsSessionBusy(sessionID)
113−}
114−
115−func (w *AppWorkspace) AgentModel() AgentModel {
116− if w.app.AgentCoordinator == nil {
117− return AgentModel{}
118− }
119− m := w.app.AgentCoordinator.Model()
120− return AgentModel{
121− CatwalkCfg: m.CatwalkCfg,
122− ModelCfg: m.ModelCfg,
123− }
124−}
125−
126−func (w *AppWorkspace) AgentIsReady() bool {
127− return w.app.AgentCoordinator != nil
128−}
129−
130−func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
131− if w.app.AgentCoordinator == nil {
132− return 0
133− }
134− return w.app.AgentCoordinator.QueuedPrompts(sessionID)
135−}
136−
137−func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
138− if w.app.AgentCoordinator == nil {
139− return nil
140− }
141− return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
142−}
143−
144−func (w *AppWorkspace) AgentClearQueue(sessionID string) {
145− if w.app.AgentCoordinator != nil {
146− w.app.AgentCoordinator.ClearQueue(sessionID)
147− }
148−}
149−
150−func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
151− if w.app.AgentCoordinator == nil {
152− return nil
153− }
154− return w.app.AgentCoordinator.Summarize(ctx, sessionID)
155−}
156−
157−func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
158− return w.app.UpdateAgentModel(ctx)
159−}
160−
161−func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
162− return w.app.InitCoderAgent(ctx)
163−}
164−
165−func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
166− return w.app.GetDefaultSmallModel(providerID)
167−}
168−
169−// -- Permissions --
170−
171−func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
172− w.app.Permissions.Grant(perm)
173−}
174−
175−func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
176− w.app.Permissions.GrantPersistent(perm)
177−}
178−
179−func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
180− w.app.Permissions.Deny(perm)
181−}
182−
183−func (w *AppWorkspace) PermissionSkipRequests() bool {
184− return w.app.Permissions.SkipRequests()
185−}
186−
187−func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
188− w.app.Permissions.SetSkipRequests(skip)
189−}
190−
191−// -- FileTracker --
192−
193−func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
194− w.app.FileTracker.RecordRead(ctx, sessionID, path)
195−}
196−
197−func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
198− return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
199−}
200−
201−func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
202− return w.app.FileTracker.ListReadFiles(ctx, sessionID)
203−}
204−
205−// -- History --
206−
207−func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
208− return w.app.History.ListBySession(ctx, sessionID)
209−}
210−
211−// -- LSP --
212−
213−func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
214− w.app.LSPManager.Start(ctx, path)
215−}
216−
217−func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
218− w.app.LSPManager.StopAll(ctx)
219−}
220−
221−func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
222− states := app.GetLSPStates()
223− result := make(map[string]LSPClientInfo, len(states))
224− for k, v := range states {
225− result[k] = LSPClientInfo{
226− Name: v.Name,
227− State: v.State,
228− Error: v.Error,
229− DiagnosticCount: v.DiagnosticCount,
230− ConnectedAt: v.ConnectedAt,
231− }
232− }
233− return result
234−}
235−
236−func (w *AppWorkspace) LSPGetClient(name string) (*lsp.Client, bool) {
237− info, ok := app.GetLSPState(name)
238− if !ok {
239− return nil, false
240− }
241− return info.Client, true
242−}
243−
244−// -- Config (read-only) --
245−
246−func (w *AppWorkspace) Config() *config.Config {
247− return w.app.Config()
248−}
249−
250−func (w *AppWorkspace) WorkingDir() string {
251− return w.app.Store().WorkingDir()
252−}
253−
254−func (w *AppWorkspace) Resolver() config.VariableResolver {
255− return w.app.Store().Resolver()
256−}
257−
258−// -- Config mutations --
259−
260−func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
261− return w.app.Store().UpdatePreferredModel(scope, modelType, model)
262−}
263−
264−func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
265− return w.app.Store().SetCompactMode(scope, enabled)
266−}
267−
268−func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
269− return w.app.Store().SetProviderAPIKey(scope, providerID, apiKey)
270−}
271−
272−func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
273− return w.app.Store().SetConfigField(scope, key, value)
274−}
275−
276−func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
277− return w.app.Store().RemoveConfigField(scope, key)
278−}
279−
280−func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
281− return w.app.Store().ImportCopilot()
282−}
283−
284−func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
285− return w.app.Store().RefreshOAuthToken(ctx, scope, providerID)
286−}
287−
288−// -- Project lifecycle --
289−
290−func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
291− return config.ProjectNeedsInitialization(w.app.Store())
292−}
293−
294−func (w *AppWorkspace) MarkProjectInitialized() error {
295− return config.MarkProjectInitialized(w.app.Store())
296−}
297−
298−func (w *AppWorkspace) InitializePrompt() (string, error) {
299− return agent.InitializePrompt(w.app.Store())
300−}
301−
302−// -- MCP operations --
303−
304−func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
305− return mcptools.GetStates()
306−}
307−
308−func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
309− mcptools.RefreshPrompts(ctx, name)
310−}
311−
312−func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
313− mcptools.RefreshResources(ctx, name)
314−}
315−
316−func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
317− mcptools.RefreshTools(ctx, w.app.Store(), name)
318−}
319−
320−func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
321− contents, err := mcptools.ReadResource(ctx, w.app.Store(), name, uri)
322− if err != nil {
323− return nil, err
324− }
325− result := make([]MCPResourceContents, len(contents))
326− for i, c := range contents {
327− result[i] = MCPResourceContents{
328− URI: c.URI,
329− MIMEType: c.MIMEType,
330− Text: c.Text,
331− Blob: c.Blob,
332− }
333− }
334− return result, nil
335−}
336−
337−func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
338− return commands.GetMCPPrompt(w.app.Store(), clientID, promptID, args)
339−}
340−
341−// -- Lifecycle --
342−
343−func (w *AppWorkspace) Subscribe(program *tea.Program) {
344− defer log.RecoverPanic("AppWorkspace.Subscribe", func() {
345− slog.Info("TUI subscription panic: attempting graceful shutdown")
346− program.Quit()
347− })
348−
349− for msg := range w.app.Events() {
350− switch ev := msg.(type) {
351− case pubsub.Event[app.LSPEvent]:
352− program.Send(pubsub.Event[LSPEvent]{
353− Type: ev.Type,
354− Payload: LSPEvent{
355− Type: LSPEventType(ev.Payload.Type),
356− Name: ev.Payload.Name,
357− State: ev.Payload.State,
358− Error: ev.Payload.Error,
359− DiagnosticCount: ev.Payload.DiagnosticCount,
360− },
361− })
362− default:
363− program.Send(msg)
364− }
365− }
366−}
367−
368−func (w *AppWorkspace) Shutdown() {
369− w.app.Shutdown()
370−}