on main help sign in
feat: add AppWorkspace implementation of Workspace interface gated behind CRUSH_CLIENT_SERVER env var
commit9a63da505a6a98fb3f7f8e36347b6e91785f4322 authorAyman Bagabas <ayman.bagabas@gmail.com> dateSun 22 Mar 2026, 12:40 (7 months ago) parent7bb3a7b
3 files changed+588−37
internal/cmd/root.go+155−13 internal/cmd/run.go+44−24 internal/workspace/app_workspace.go+389
internal/cmd/root.go+155−13
@@ -21,17 +21,22 @@ import (
2121 fang "charm.land/fang/v2"
2222 "charm.land/lipgloss/v2"
2323 "github.com/charmbracelet/colorprofile"
24+ "github.com/charmbracelet/crush/internal/app"
2425 "github.com/charmbracelet/crush/internal/client"
2526 "github.com/charmbracelet/crush/internal/config"
27+ "github.com/charmbracelet/crush/internal/db"
2628 "github.com/charmbracelet/crush/internal/event"
2729 crushlog "github.com/charmbracelet/crush/internal/log"
30+ "github.com/charmbracelet/crush/internal/projects"
2831 "github.com/charmbracelet/crush/internal/proto"
2932 "github.com/charmbracelet/crush/internal/server"
33+ "github.com/charmbracelet/crush/internal/session"
3034 "github.com/charmbracelet/crush/internal/ui/common"
3135 ui "github.com/charmbracelet/crush/internal/ui/model"
3236 "github.com/charmbracelet/crush/internal/version"
3337 "github.com/charmbracelet/crush/internal/workspace"
3438 uv "github.com/charmbracelet/ultraviolet"
39+ "github.com/charmbracelet/x/ansi"
3540 "github.com/charmbracelet/x/exp/charmtone"
3641 "github.com/charmbracelet/x/term"
3742 "github.com/spf13/cobra"
@@ -96,15 +101,14 @@ crush --continue
96101 sessionID, _ := cmd.Flags().GetString("session")
97102 continueLast, _ := cmd.Flags().GetBool("continue")
98103
99− c, ws, cleanup, err := connectToServer(cmd)
104+ ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
100105 if err != nil {
101106 return err
102107 }
103108 defer cleanup()
104109
105− // Resolve session ID if provided.
106110 if sessionID != "" {
107− sess, err := resolveSessionByID(cmd.Context(), c, ws.ID, sessionID)
111+ sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
108112 if err != nil {
109113 return err
110114 }
@@ -113,15 +117,7 @@ crush --continue
113117
114118 event.AppInitialized()
115119
116− clientWs := workspace.NewClientWorkspace(c, *ws)
117−
118− if ws.Config.IsConfigured() {
119− if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
120− slog.Error("Failed to initialize coder agent", "error", err)
121− }
122− }
123−
124− com := common.DefaultCommon(clientWs)
120+ com := common.DefaultCommon(ws)
125121 model := ui.New(com, sessionID, continueLast)
126122
127123 var env uv.Environ = os.Environ()
@@ -131,7 +127,7 @@ crush --continue
131127 tea.WithContext(cmd.Context()),
132128 tea.WithFilter(ui.MouseEventFilter),
133129 )
134− go clientWs.Subscribe(program)
130+ go ws.Subscribe(program)
135131
136132 if _, err := program.Run(); err != nil {
137133 event.Error(err)
@@ -197,6 +193,120 @@ func supportsProgressBar() bool {
197193 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
198194}
199195
196+// useClientServer returns true when the client/server architecture is
197+// enabled via the CRUSH_CLIENT_SERVER environment variable.
198+func useClientServer() bool {
199+ v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
200+ return v
201+}
202+
203+// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
204+// terminal progress bar shown during initialization.
205+func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
206+ showProgress := supportsProgressBar()
207+ if showProgress {
208+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
209+ }
210+
211+ ws, cleanup, err := setupWorkspace(cmd)
212+
213+ if showProgress {
214+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
215+ }
216+
217+ return ws, cleanup, err
218+}
219+
220+// setupWorkspace returns a Workspace and cleanup function. When
221+// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
222+// ClientWorkspace. Otherwise it creates an in-process app.App and
223+// returns an AppWorkspace.
224+func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
225+ if useClientServer() {
226+ return setupClientServerWorkspace(cmd)
227+ }
228+ return setupLocalWorkspace(cmd)
229+}
230+
231+// setupLocalWorkspace creates an in-process app.App and wraps it in an
232+// AppWorkspace.
233+func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
234+ debug, _ := cmd.Flags().GetBool("debug")
235+ yolo, _ := cmd.Flags().GetBool("yolo")
236+ dataDir, _ := cmd.Flags().GetString("data-dir")
237+ ctx := cmd.Context()
238+
239+ cwd, err := ResolveCwd(cmd)
240+ if err != nil {
241+ return nil, nil, err
242+ }
243+
244+ store, err := config.Init(cwd, dataDir, debug)
245+ if err != nil {
246+ return nil, nil, err
247+ }
248+
249+ cfg := store.Config()
250+ store.Overrides().SkipPermissionRequests = yolo
251+
252+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
253+ return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
254+ }
255+
256+ gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
257+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
258+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
259+ return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
260+ }
261+ }
262+
263+ if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
264+ slog.Warn("Failed to register project", "error", err)
265+ }
266+
267+ conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
268+ if err != nil {
269+ return nil, nil, err
270+ }
271+
272+ logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
273+ crushlog.Setup(logFile, debug)
274+
275+ appInstance, err := app.New(ctx, conn, store)
276+ if err != nil {
277+ _ = conn.Close()
278+ slog.Error("Failed to create app instance", "error", err)
279+ return nil, nil, err
280+ }
281+
282+ if shouldEnableMetrics(cfg) {
283+ event.Init()
284+ }
285+
286+ ws := workspace.NewAppWorkspace(appInstance, store)
287+ cleanup := func() { appInstance.Shutdown() }
288+ return ws, cleanup, nil
289+}
290+
291+// setupClientServerWorkspace connects to a server process and wraps the
292+// result in a ClientWorkspace.
293+func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
294+ c, protoWs, cleanupServer, err := connectToServer(cmd)
295+ if err != nil {
296+ return nil, nil, err
297+ }
298+
299+ clientWs := workspace.NewClientWorkspace(c, *protoWs)
300+
301+ if protoWs.Config.IsConfigured() {
302+ if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
303+ slog.Error("Failed to initialize coder agent", "error", err)
304+ }
305+ }
306+
307+ return clientWs, cleanupServer, nil
308+}
309+
200310// connectToServer ensures the server is running, creates a client and
201311// workspace, and returns a cleanup function that deletes the workspace.
202312func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
@@ -426,6 +536,38 @@ func MaybePrependStdin(prompt string) (string, error) {
426536 return string(bts) + "\n\n" + prompt, nil
427537}
428538
539+// resolveWorkspaceSessionID resolves a session ID that may be a full
540+// UUID, full hash, or hash prefix. Works against the Workspace
541+// interface so both local and client/server paths get hash prefix
542+// support.
543+func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
544+ if sess, err := ws.GetSession(ctx, id); err == nil {
545+ return sess, nil
546+ }
547+
548+ sessions, err := ws.ListSessions(ctx)
549+ if err != nil {
550+ return session.Session{}, err
551+ }
552+
553+ var matches []session.Session
554+ for _, s := range sessions {
555+ hash := session.HashID(s.ID)
556+ if hash == id || strings.HasPrefix(hash, id) {
557+ matches = append(matches, s)
558+ }
559+ }
560+
561+ switch len(matches) {
562+ case 0:
563+ return session.Session{}, fmt.Errorf("session not found: %s", id)
564+ case 1:
565+ return matches[0], nil
566+ default:
567+ return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
568+ }
569+}
570+
429571func ResolveCwd(cmd *cobra.Command) (string, error) {
430572 cwd, _ := cmd.Flags().GetString("cwd")
431573 if cwd != "" {
internal/cmd/run.go+44−24
@@ -20,6 +20,7 @@ import (
2020 "github.com/charmbracelet/crush/internal/session"
2121 "github.com/charmbracelet/crush/internal/ui/anim"
2222 "github.com/charmbracelet/crush/internal/ui/styles"
23+ "github.com/charmbracelet/crush/internal/workspace"
2324 "github.com/charmbracelet/x/ansi"
2425 "github.com/charmbracelet/x/exp/charmtone"
2526 "github.com/charmbracelet/x/term"
@@ -72,31 +73,9 @@ crush run --continue "Follow up on your last response"
7273 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
7374 defer cancel()
7475
75− c, ws, cleanup, err := connectToServer(cmd)
76− if err != nil {
77− return err
78− }
79− defer cleanup()
80−
81− if sessionID != "" {
82− sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
83− if err != nil {
84− return err
85− }
86− sessionID = sess.ID
87− }
88−
89− if !ws.Config.IsConfigured() {
90− return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
91− }
92−
93− if verbose {
94− slog.SetDefault(slog.New(log.New(os.Stderr)))
95− }
96−
9776 prompt := strings.Join(args, " ")
9877
99− prompt, err = MaybePrependStdin(prompt)
78+ prompt, err := MaybePrependStdin(prompt)
10079 if err != nil {
10180 slog.Error("Failed to read from stdin", "error", err)
10281 return err
@@ -116,7 +95,48 @@ crush run --continue "Follow up on your last response"
11695 event.SetContinueLastSession(true)
11796 }
11897
119− return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
98+ if useClientServer() {
99+ c, ws, cleanup, err := connectToServer(cmd)
100+ if err != nil {
101+ return err
102+ }
103+ defer cleanup()
104+
105+ if sessionID != "" {
106+ sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
107+ if err != nil {
108+ return err
109+ }
110+ sessionID = sess.ID
111+ }
112+
113+ if !ws.Config.IsConfigured() {
114+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
115+ }
116+
117+ if verbose {
118+ slog.SetDefault(slog.New(log.New(os.Stderr)))
119+ }
120+
121+ return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
122+ }
123+
124+ ws, cleanup, err := setupLocalWorkspace(cmd)
125+ if err != nil {
126+ return err
127+ }
128+ defer cleanup()
129+
130+ if !ws.Config().IsConfigured() {
131+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
132+ }
133+
134+ if verbose {
135+ slog.SetDefault(slog.New(log.New(os.Stderr)))
136+ }
137+
138+ appWs := ws.(*workspace.AppWorkspace)
139+ return appWs.App().RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
120140 },
121141}
122142
internal/workspace/app_workspace.gonew file+389
@@ -0,0 +1,389 @@
1+package workspace
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "time"
8+
9+ tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/agent"
11+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
12+ "github.com/charmbracelet/crush/internal/app"
13+ "github.com/charmbracelet/crush/internal/commands"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/history"
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/session"
21+)
22+
23+// AppWorkspace implements the Workspace interface by delegating
24+// directly to an in-process [app.App] instance. This is the default
25+// mode when the client/server architecture is not enabled.
26+type AppWorkspace struct {
27+ app *app.App
28+ store *config.ConfigStore
29+}
30+
31+// NewAppWorkspace creates a new AppWorkspace wrapping the given app
32+// and config store.
33+func NewAppWorkspace(a *app.App, store *config.ConfigStore) *AppWorkspace {
34+ return &AppWorkspace{
35+ app: a,
36+ store: store,
37+ }
38+}
39+
40+// -- Sessions --
41+
42+func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
43+ return w.app.Sessions.Create(ctx, title)
44+}
45+
46+func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
47+ return w.app.Sessions.Get(ctx, sessionID)
48+}
49+
50+func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
51+ return w.app.Sessions.List(ctx)
52+}
53+
54+func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
55+ return w.app.Sessions.Save(ctx, sess)
56+}
57+
58+func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
59+ return w.app.Sessions.Delete(ctx, sessionID)
60+}
61+
62+func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
63+ return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
64+}
65+
66+func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
67+ return w.app.Sessions.ParseAgentToolSessionID(sessionID)
68+}
69+
70+// -- Messages --
71+
72+func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
73+ return w.app.Messages.List(ctx, sessionID)
74+}
75+
76+func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
77+ return w.app.Messages.ListUserMessages(ctx, sessionID)
78+}
79+
80+func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
81+ return w.app.Messages.ListAllUserMessages(ctx)
82+}
83+
84+// -- Agent --
85+
86+func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
87+ if w.app.AgentCoordinator == nil {
88+ return errors.New("agent coordinator not initialized")
89+ }
90+ _, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
91+ return err
92+}
93+
94+func (w *AppWorkspace) AgentCancel(sessionID string) {
95+ if w.app.AgentCoordinator != nil {
96+ w.app.AgentCoordinator.Cancel(sessionID)
97+ }
98+}
99+
100+func (w *AppWorkspace) AgentIsBusy() bool {
101+ if w.app.AgentCoordinator == nil {
102+ return false
103+ }
104+ return w.app.AgentCoordinator.IsBusy()
105+}
106+
107+func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
108+ if w.app.AgentCoordinator == nil {
109+ return false
110+ }
111+ return w.app.AgentCoordinator.IsSessionBusy(sessionID)
112+}
113+
114+func (w *AppWorkspace) AgentModel() AgentModel {
115+ if w.app.AgentCoordinator == nil {
116+ return AgentModel{}
117+ }
118+ m := w.app.AgentCoordinator.Model()
119+ return AgentModel{
120+ CatwalkCfg: m.CatwalkCfg,
121+ ModelCfg: m.ModelCfg,
122+ }
123+}
124+
125+func (w *AppWorkspace) AgentIsReady() bool {
126+ return w.app.AgentCoordinator != nil
127+}
128+
129+func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
130+ if w.app.AgentCoordinator == nil {
131+ return 0
132+ }
133+ return w.app.AgentCoordinator.QueuedPrompts(sessionID)
134+}
135+
136+func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
137+ if w.app.AgentCoordinator == nil {
138+ return nil
139+ }
140+ return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
141+}
142+
143+func (w *AppWorkspace) AgentClearQueue(sessionID string) {
144+ if w.app.AgentCoordinator != nil {
145+ w.app.AgentCoordinator.ClearQueue(sessionID)
146+ }
147+}
148+
149+func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
150+ if w.app.AgentCoordinator == nil {
151+ return errors.New("agent coordinator not initialized")
152+ }
153+ return w.app.AgentCoordinator.Summarize(ctx, sessionID)
154+}
155+
156+func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
157+ return w.app.UpdateAgentModel(ctx)
158+}
159+
160+func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
161+ return w.app.InitCoderAgent(ctx)
162+}
163+
164+func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
165+ return w.app.GetDefaultSmallModel(providerID)
166+}
167+
168+// -- Permissions --
169+
170+func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
171+ w.app.Permissions.Grant(perm)
172+}
173+
174+func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
175+ w.app.Permissions.GrantPersistent(perm)
176+}
177+
178+func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
179+ w.app.Permissions.Deny(perm)
180+}
181+
182+func (w *AppWorkspace) PermissionSkipRequests() bool {
183+ return w.app.Permissions.SkipRequests()
184+}
185+
186+func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
187+ w.app.Permissions.SetSkipRequests(skip)
188+}
189+
190+// -- FileTracker --
191+
192+func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
193+ w.app.FileTracker.RecordRead(ctx, sessionID, path)
194+}
195+
196+func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
197+ return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
198+}
199+
200+func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
201+ return w.app.FileTracker.ListReadFiles(ctx, sessionID)
202+}
203+
204+// -- History --
205+
206+func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
207+ return w.app.History.ListBySession(ctx, sessionID)
208+}
209+
210+// -- LSP --
211+
212+func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
213+ w.app.LSPManager.Start(ctx, path)
214+}
215+
216+func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
217+ w.app.LSPManager.StopAll(ctx)
218+}
219+
220+func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
221+ states := app.GetLSPStates()
222+ result := make(map[string]LSPClientInfo, len(states))
223+ for k, v := range states {
224+ result[k] = LSPClientInfo{
225+ Name: v.Name,
226+ State: v.State,
227+ Error: v.Error,
228+ DiagnosticCount: v.DiagnosticCount,
229+ ConnectedAt: v.ConnectedAt,
230+ }
231+ }
232+ return result
233+}
234+
235+func (w *AppWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
236+ state, ok := app.GetLSPState(name)
237+ if !ok || state.Client == nil {
238+ return lsp.DiagnosticCounts{}
239+ }
240+ return state.Client.GetDiagnosticCounts()
241+}
242+
243+// -- Config (read-only) --
244+
245+func (w *AppWorkspace) Config() *config.Config {
246+ return w.store.Config()
247+}
248+
249+func (w *AppWorkspace) WorkingDir() string {
250+ return w.store.WorkingDir()
251+}
252+
253+func (w *AppWorkspace) Resolver() config.VariableResolver {
254+ return w.store.Resolver()
255+}
256+
257+// -- Config mutations --
258+
259+func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
260+ return w.store.UpdatePreferredModel(scope, modelType, model)
261+}
262+
263+func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
264+ return w.store.SetCompactMode(scope, enabled)
265+}
266+
267+func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
268+ return w.store.SetProviderAPIKey(scope, providerID, apiKey)
269+}
270+
271+func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
272+ return w.store.SetConfigField(scope, key, value)
273+}
274+
275+func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
276+ return w.store.RemoveConfigField(scope, key)
277+}
278+
279+func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
280+ return w.store.ImportCopilot()
281+}
282+
283+func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
284+ return w.store.RefreshOAuthToken(ctx, scope, providerID)
285+}
286+
287+// -- Project lifecycle --
288+
289+func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
290+ return config.ProjectNeedsInitialization(w.store)
291+}
292+
293+func (w *AppWorkspace) MarkProjectInitialized() error {
294+ return config.MarkProjectInitialized(w.store)
295+}
296+
297+func (w *AppWorkspace) InitializePrompt() (string, error) {
298+ return agent.InitializePrompt(w.store)
299+}
300+
301+// -- MCP operations --
302+
303+func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
304+ return mcptools.GetStates()
305+}
306+
307+func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
308+ mcptools.RefreshPrompts(ctx, name)
309+}
310+
311+func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
312+ mcptools.RefreshResources(ctx, name)
313+}
314+
315+func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
316+ mcptools.RefreshTools(ctx, w.store, name)
317+}
318+
319+func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
320+ contents, err := mcptools.ReadResource(ctx, w.store, name, uri)
321+ if err != nil {
322+ return nil, err
323+ }
324+ result := make([]MCPResourceContents, len(contents))
325+ for i, c := range contents {
326+ result[i] = MCPResourceContents{
327+ URI: c.URI,
328+ MIMEType: c.MIMEType,
329+ Text: c.Text,
330+ Blob: c.Blob,
331+ }
332+ }
333+ return result, nil
334+}
335+
336+func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
337+ return commands.GetMCPPrompt(w.store, clientID, promptID, args)
338+}
339+
340+func (w *AppWorkspace) EnableDockerMCP(ctx context.Context) error {
341+ mcpConfig, err := w.store.PrepareDockerMCPConfig()
342+ if err != nil {
343+ return err
344+ }
345+
346+ if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, w.store); err != nil {
347+ disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
348+ delete(w.store.Config().MCP, config.DockerMCPName)
349+ return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
350+ }
351+
352+ if err := w.store.PersistDockerMCPConfig(mcpConfig); err != nil {
353+ disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
354+ delete(w.store.Config().MCP, config.DockerMCPName)
355+ return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
356+ }
357+
358+ return nil
359+}
360+
361+func (w *AppWorkspace) DisableDockerMCP() error {
362+ if err := mcptools.DisableSingle(w.store, config.DockerMCPName); err != nil {
363+ return fmt.Errorf("failed to disable docker MCP: %w", err)
364+ }
365+ return w.store.DisableDockerMCP()
366+}
367+
368+// -- Lifecycle --
369+
370+func (w *AppWorkspace) Subscribe(program *tea.Program) {
371+ w.app.Subscribe(program)
372+}
373+
374+func (w *AppWorkspace) Shutdown() {
375+ w.app.Shutdown()
376+}
377+
378+// App returns the underlying app.App instance.
379+func (w *AppWorkspace) App() *app.App {
380+ return w.app
381+}
382+
383+// Store returns the underlying config store.
384+func (w *AppWorkspace) Store() *config.ConfigStore {
385+ return w.store
386+}
387+
388+// Compile-time check that AppWorkspace implements Workspace.
389+var _ Workspace = (*AppWorkspace)(nil)