purple-ai 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2514 @@
1
+ # Purple CLIent - Technical PRD
2
+
3
+ Version: 1.4
4
+ Last Updated: 2026-01-12
5
+
6
+ ---
7
+
8
+ ## 1. Overview
9
+
10
+ Purple CLIent is a terminal-based workspace manager for the Purple AI agent workflow. It provides a polished TUI for managing workspaces, teams, and feature development sessions that integrate with Claude Code.
11
+
12
+ **Core Value Proposition:**
13
+ - Elegant workspace and team management
14
+ - Seamless Claude Code integration with status bar overlay
15
+ - Streamlined plugin-driven development workflow (spec → architecture → implementation)
16
+
17
+ ---
18
+
19
+ ## 2. Tech Stack
20
+
21
+ | Component | Technology | Rationale |
22
+ |-----------|------------|-----------|
23
+ | Language | **Go 1.22+** | Single binary, fast startup, excellent cross-platform support |
24
+ | TUI Framework | **Bubbletea** | Industry-standard Go TUI, composable, well-maintained |
25
+ | Forms/Prompts | **Huh** | Built on Bubbletea, first-class form support |
26
+ | Styling | **Lipgloss** | Declarative styling for terminal UIs |
27
+ | PTY | **creack/pty** | Go PTY library for spawning Claude Code |
28
+ | HTTP Client | **net/http** (stdlib) | Backend API calls |
29
+ | Auth | **Purple Backend** (proxy to Supabase) | Client never talks to Supabase directly |
30
+ | Distribution | **npm wrapper + Go binary** | `npx purple-ai` convenience |
31
+
32
+ ---
33
+
34
+ ## 3. Architecture
35
+
36
+ ### 3.1 Package Structure
37
+
38
+ ```
39
+ purple-client/
40
+ ├── cmd/
41
+ │ └── purple/
42
+ │ └── main.go # Entry point, CLI flag handling
43
+ ├── internal/
44
+ │ ├── auth/
45
+ │ │ ├── auth.go # ensureAuth, checkAuth, logout
46
+ │ │ ├── storage.go # Keychain/file token storage
47
+ │ │ └── types.go # AuthSession, credentials types
48
+ │ ├── workspace/
49
+ │ │ ├── workspace.go # Create, load, detect workspace
50
+ │ │ ├── detector.go # Find purple.json in dir tree
51
+ │ │ ├── config.go # purple.json read/write
52
+ │ │ └── types.go # Workspace types
53
+ │ ├── git/
54
+ │ │ ├── repos.go # List repos (gh CLI or manual)
55
+ │ │ ├── clone.go # Clone repos to workspace
56
+ │ │ └── detect.go # Check gh CLI availability
57
+ │ ├── session/
58
+ │ │ ├── session.go # Feature session management (local only)
59
+ │ │ ├── scanner.go # Scan workbench/documentation for sessions
60
+ │ │ └── types.go # Session types
61
+ │ ├── plugins/
62
+ │ │ ├── plugins.go # Plugin installation and updates
63
+ │ │ ├── catalog.go # Available plugins from backend
64
+ │ │ ├── installer.go # Extract plugins to .claude/
65
+ │ │ └── types.go # Plugin, PluginSummary types
66
+ │ ├── standards/
67
+ │ │ ├── standards.go # Standards sync logic
68
+ │ │ ├── sync.go # Download from API, write to local
69
+ │ │ ├── upload.go # Upload local changes to API
70
+ │ │ └── types.go # Standard types
71
+ │ ├── claude/
72
+ │ │ ├── pty.go # PTY spawning and management
73
+ │ │ ├── hooks.go # Hook registration and handling
74
+ │ │ └── commands.go # Launch with specific commands
75
+ │ ├── tui/
76
+ │ │ ├── app.go # Main Bubbletea app model
77
+ │ │ ├── views/
78
+ │ │ │ ├── welcome.go # Welcome/logo screen
79
+ │ │ │ ├── auth.go # Login/Register forms
80
+ │ │ │ ├── workspace_picker.go # Workspace selection
81
+ │ │ │ ├── workspace_create.go # New workspace flow
82
+ │ │ │ ├── repo_picker.go # Multi-select repo list
83
+ │ │ │ ├── plugin_picker.go # Plugin selection
84
+ │ │ │ ├── session_picker.go # Feature session list
85
+ │ │ │ └── loading.go # Progress/spinner views
86
+ │ │ ├── components/
87
+ │ │ │ ├── statusbar.go # Bottom status bar
88
+ │ │ │ └── header.go # Top header component
89
+ │ │ └── styles.go # Lipgloss style definitions
90
+ │ ├── server/
91
+ │ │ ├── status.go # HTTP server for MCP communication
92
+ │ │ └── types.go # Status update types
93
+ │ ├── api/
94
+ │ │ ├── client.go # Backend API client
95
+ │ │ ├── auth.go # /auth/* endpoints
96
+ │ │ ├── user.go # /user/* endpoints
97
+ │ │ ├── team.go # /team/* endpoints
98
+ │ │ ├── workspace.go # /workspace/* endpoints
99
+ │ │ ├── repo.go # /repo/* endpoints
100
+ │ │ ├── standard.go # /standard/* endpoints
101
+ │ │ ├── plugin.go # /plugins/* endpoints
102
+ │ │ └── types.go # API response types
103
+ │ └── config/
104
+ │ ├── settings.go # ~/.purple/settings.json
105
+ │ └── paths.go # Standard path helpers
106
+ ├── pkg/
107
+ │ └── version/
108
+ │ └── version.go # Version info for updates
109
+ ├── npm/
110
+ │ ├── package.json # npm wrapper package
111
+ │ ├── bin/
112
+ │ │ └── purple-ai.js # Node shim that spawns Go binary
113
+ │ └── platform-packages/ # Platform-specific binary packages
114
+ │ ├── darwin-arm64/
115
+ │ ├── darwin-x64/
116
+ │ └── linux-x64/
117
+ ├── go.mod
118
+ ├── go.sum
119
+ ├── Makefile # Build targets for all platforms
120
+ └── README.md
121
+ ```
122
+
123
+ ### 3.2 Data Models
124
+
125
+ #### Local Files
126
+
127
+ **~/.purple/settings.json** (User-level config - minimal)
128
+ ```json
129
+ {
130
+ "theme": "dark"
131
+ }
132
+ ```
133
+
134
+ **{workspace}/purple.json** (Workspace pointer - minimal)
135
+ ```json
136
+ {
137
+ "workspace_id": "uuid"
138
+ }
139
+ ```
140
+
141
+ All other data (user, team, repos, standards, plugins) is fetched from the API.
142
+
143
+ #### Token Storage
144
+
145
+ Tokens stored in OS Keychain (not in settings.json):
146
+ - `purple-ai/access_token`
147
+ - `purple-ai/refresh_token`
148
+
149
+ #### Session Detection (Local Only)
150
+
151
+ Sessions are detected by scanning the filesystem:
152
+ ```
153
+ {workspace}/workbench/documentation/
154
+
155
+ Each subdirectory (e.g., 260112-auth-feature/) is a session.
156
+ Session name = folder name
157
+ Status = inferred from contents (has implementation-spec = in-progress, etc.)
158
+ Archived sessions live in /archived/ subfolder
159
+ ```
160
+
161
+ Sessions never sync to backend - they are purely local.
162
+
163
+ ---
164
+
165
+ ## 4. API Integration
166
+
167
+ ### 4.1 Configuration Management
168
+
169
+ #### Build-time vs Runtime Configuration
170
+
171
+ For a distributed Go binary, configuration is handled via **build-time constants** with optional **environment variable overrides** for development.
172
+
173
+ ```go
174
+ // internal/config/config.go
175
+
176
+ package config
177
+
178
+ import "os"
179
+
180
+ // Build-time constants (set via ldflags during build)
181
+ var (
182
+ // Version is set at build time: -ldflags "-X config.Version=1.0.0"
183
+ Version = "dev"
184
+
185
+ // DefaultAPIBaseURL is compiled into the binary
186
+ DefaultAPIBaseURL = "https://api.purple.ai/api/v1"
187
+ )
188
+
189
+ // Runtime configuration
190
+ type Config struct {
191
+ APIBaseURL string
192
+ Debug bool
193
+ }
194
+
195
+ // Load returns the runtime configuration
196
+ // Environment variables override compiled defaults (for development only)
197
+ func Load() *Config {
198
+ apiURL := DefaultAPIBaseURL
199
+
200
+ // Allow override for development (not advertised to users)
201
+ if envURL := os.Getenv("PURPLE_API_URL"); envURL != "" {
202
+ apiURL = envURL
203
+ }
204
+
205
+ return &Config{
206
+ APIBaseURL: apiURL,
207
+ Debug: os.Getenv("PURPLE_DEBUG") == "1",
208
+ }
209
+ }
210
+ ```
211
+
212
+ #### Build Process
213
+
214
+ ```makefile
215
+ VERSION := 1.0.0
216
+ API_URL := https://api.purple.ai/api/v1
217
+
218
+ LDFLAGS := -ldflags "\
219
+ -X 'purple-client/internal/config.Version=$(VERSION)' \
220
+ -X 'purple-client/internal/config.DefaultAPIBaseURL=$(API_URL)'"
221
+
222
+ build-prod:
223
+ go build $(LDFLAGS) -o dist/purple ./cmd/purple
224
+
225
+ build-dev:
226
+ PURPLE_API_URL=http://localhost:3000/api/v1 go run ./cmd/purple
227
+ ```
228
+
229
+ #### Why Not Environment Variables in Production?
230
+
231
+ | Approach | Pros | Cons |
232
+ |----------|------|------|
233
+ | **Compiled constants** (chosen) | No setup required, works out of box, single binary | Requires rebuild to change URL |
234
+ | Environment variables | Flexible | Users must configure, easy to misconfigure |
235
+ | Config file | Flexible, documented | Another file to manage, can get lost |
236
+
237
+ For a CLI tool distributed via `npx`, **compiled constants are ideal**:
238
+ - Zero configuration for end users
239
+ - No `.env` files to manage
240
+ - No risk of pointing to wrong server
241
+ - Development override via env var is hidden/undocumented
242
+
243
+ #### Configuration Values
244
+
245
+ | Value | Source | Default |
246
+ |-------|--------|---------|
247
+ | `APIBaseURL` | Compiled | `https://api.purple.ai/api/v1` |
248
+ | `Version` | Compiled | Set at build time |
249
+ | `Debug` | Env: `PURPLE_DEBUG=1` | `false` |
250
+ | `APIOverride` | Env: `PURPLE_API_URL` | (none, dev only) |
251
+
252
+ ### 4.2 HTTP Client Setup
253
+
254
+ All requests include:
255
+ - `Authorization: Bearer {access_token}` header
256
+ - `Content-Type: application/json`
257
+ - `User-Agent: purple-cli/{version}`
258
+
259
+ ```go
260
+ // internal/api/client.go
261
+
262
+ type Client struct {
263
+ baseURL string
264
+ httpClient *http.Client
265
+ token string
266
+ }
267
+
268
+ func NewClient(cfg *config.Config) *Client {
269
+ return &Client{
270
+ baseURL: cfg.APIBaseURL,
271
+ httpClient: &http.Client{
272
+ Timeout: 30 * time.Second,
273
+ },
274
+ }
275
+ }
276
+
277
+ func (c *Client) SetToken(token string) {
278
+ c.token = token
279
+ }
280
+
281
+ func (c *Client) request(method, path string, body interface{}) (*http.Response, error) {
282
+ var bodyReader io.Reader
283
+ if body != nil {
284
+ jsonBody, _ := json.Marshal(body)
285
+ bodyReader = bytes.NewReader(jsonBody)
286
+ }
287
+
288
+ req, _ := http.NewRequest(method, c.baseURL+path, bodyReader)
289
+ req.Header.Set("Content-Type", "application/json")
290
+ req.Header.Set("User-Agent", "purple-cli/"+config.Version)
291
+
292
+ if c.token != "" {
293
+ req.Header.Set("Authorization", "Bearer "+c.token)
294
+ }
295
+
296
+ return c.httpClient.Do(req)
297
+ }
298
+ ```
299
+
300
+ ### 4.3 API Client Interface
301
+
302
+ ```go
303
+ type APIClient interface {
304
+ // Auth (proxy to Supabase)
305
+ Register(email, password, name string) (*AuthResponse, error)
306
+ Login(email, password string) (*AuthResponse, error)
307
+ RefreshToken(refreshToken string) (*TokenResponse, error)
308
+ Logout() error
309
+
310
+ // User
311
+ GetCurrentUser() (*User, error)
312
+ UpdateUser(name string) (*User, error)
313
+
314
+ // Team
315
+ ListTeams() ([]Team, error)
316
+ GetTeam(id string) (*Team, error)
317
+
318
+ // Workspace
319
+ CreateWorkspace(name, teamID string) (*Workspace, error)
320
+ GetWorkspace(id string) (*Workspace, error)
321
+ UpdateWorkspace(id, name string) (*Workspace, error)
322
+ DeleteWorkspace(id string) error
323
+
324
+ // Repo
325
+ CreateRepo(workspaceID, remoteURL, defaultBranch string) (*Repo, error)
326
+ ListRepos(workspaceID string) ([]Repo, error)
327
+ DeleteRepo(id string) error
328
+
329
+ // Standard
330
+ CreateStandard(workspaceID, path, name, content string) (*Standard, error)
331
+ ListStandards(workspaceID string) ([]Standard, error)
332
+ UpdateStandard(id, content string) (*Standard, error)
333
+ DeleteStandard(id string) error
334
+
335
+ // Plugin
336
+ ListAvailablePlugins() ([]PluginSummary, error)
337
+ GetPlugin(id string) (*Plugin, error)
338
+ ListWorkspacePlugins(workspaceID string) ([]PluginSummary, error)
339
+ InstallPlugin(workspaceID, pluginID string) error
340
+ UninstallPlugin(workspaceID, pluginID string) error
341
+ }
342
+ ```
343
+
344
+ ### 4.4 API Response Types
345
+
346
+ ```go
347
+ // Auth
348
+ type AuthResponse struct {
349
+ Token string `json:"token"`
350
+ RefreshToken string `json:"refreshToken"`
351
+ User User `json:"user"`
352
+ }
353
+
354
+ type TokenResponse struct {
355
+ Token string `json:"token"`
356
+ RefreshToken string `json:"refreshToken"`
357
+ }
358
+
359
+ // User
360
+ type User struct {
361
+ ID string `json:"id"`
362
+ Email string `json:"email"`
363
+ Name string `json:"name"`
364
+ AvatarURL *string `json:"avatarUrl"`
365
+ Teams []TeamRef `json:"teams"`
366
+ }
367
+
368
+ type TeamRef struct {
369
+ ID string `json:"id"`
370
+ Name string `json:"name"`
371
+ }
372
+
373
+ // Team
374
+ type Team struct {
375
+ ID string `json:"id"`
376
+ Name string `json:"name"`
377
+ EmailDomains []string `json:"emailDomains"`
378
+ Workspaces []WorkspaceRef `json:"workspaces"`
379
+ UserIDs []string `json:"userIds"`
380
+ }
381
+
382
+ type WorkspaceRef struct {
383
+ ID string `json:"id"`
384
+ Name string `json:"name"`
385
+ }
386
+
387
+ // Workspace
388
+ type Workspace struct {
389
+ ID string `json:"id"`
390
+ Name string `json:"name"`
391
+ TeamID string `json:"teamId"`
392
+ }
393
+
394
+ // Repo
395
+ type Repo struct {
396
+ ID string `json:"id"`
397
+ WorkspaceID string `json:"workspaceId"`
398
+ RemoteURL string `json:"remoteUrl"`
399
+ DefaultBranch string `json:"defaultBranch"`
400
+ }
401
+
402
+ // Standard
403
+ type Standard struct {
404
+ ID string `json:"id"`
405
+ WorkspaceID string `json:"workspaceId"`
406
+ Path string `json:"path"` // folder path, e.g. "code-style/frontend"
407
+ Name string `json:"name"` // filename without extension
408
+ Content string `json:"content"`
409
+ Version int `json:"version"` // auto-increments on update
410
+ }
411
+
412
+ // Plugin
413
+ type PluginSummary struct {
414
+ ID string `json:"id"`
415
+ Name string `json:"name"`
416
+ Description string `json:"description"`
417
+ Type string `json:"type"` // "tarball" | "repo"
418
+ }
419
+
420
+ type Plugin struct {
421
+ ID string `json:"id"`
422
+ Name string `json:"name"`
423
+ Description string `json:"description"`
424
+ Type string `json:"type"`
425
+ RepoURL *string `json:"repoUrl,omitempty"` // if type = "repo"
426
+ Tarball *string `json:"tarball,omitempty"` // base64 if type = "tarball"
427
+ }
428
+ ```
429
+
430
+ ### 4.5 API Endpoints Reference
431
+
432
+ ```
433
+ Auth:
434
+ POST /auth/register { email, password, name } → AuthResponse
435
+ POST /auth/login { email, password } → AuthResponse
436
+ POST /auth/refresh { refreshToken } → TokenResponse
437
+ POST /auth/logout → { success: bool }
438
+
439
+ User:
440
+ GET /user/me → { user: User }
441
+ PUT /user/me { name } → { user: User }
442
+
443
+ Team:
444
+ GET /team → { teams: Team[] }
445
+ GET /team/{id} → { team: Team }
446
+
447
+ Workspace:
448
+ POST /workspace { name, teamId } → { workspace: Workspace }
449
+ GET /workspace/{id} → { workspace: Workspace }
450
+ PUT /workspace/{id} { name } → { workspace: Workspace }
451
+ DELETE /workspace/{id} → { success: bool }
452
+
453
+ Repo:
454
+ POST /repo { workspaceId, remoteUrl, defaultBranch } → { repo: Repo }
455
+ GET /repo?workspaceId= → { repos: Repo[] }
456
+ DELETE /repo/{id} → { success: bool }
457
+
458
+ Standard:
459
+ POST /standard { workspaceId, path, name, content } → { standard: Standard }
460
+ GET /standard?workspaceId= → { standards: Standard[] }
461
+ PUT /standard/{id} { content } → { standard: Standard }
462
+ DELETE /standard/{id} → { success: bool }
463
+
464
+ Plugin:
465
+ GET /plugins → { plugins: PluginSummary[] }
466
+ GET /plugins/{id} → { plugin: Plugin }
467
+ GET /workspace/{id}/plugins → { plugins: PluginSummary[] }
468
+ POST /workspace/{id}/plugins { pluginId } → { success: bool }
469
+ DELETE /workspace/{id}/plugins/{pluginId} → { success: bool }
470
+ ```
471
+
472
+ ---
473
+
474
+ ## 5. Core Flows
475
+
476
+ ### 5.1 Bootstrap Flow (App Startup)
477
+
478
+ ```go
479
+ func bootstrap() error {
480
+ // 1. Load token from keychain
481
+ token, err := auth.LoadToken()
482
+ if err != nil {
483
+ return showLoginView()
484
+ }
485
+
486
+ // 2. Check if token expired, refresh if needed
487
+ if auth.IsExpired(token) {
488
+ newToken, err := api.RefreshToken(token.RefreshToken)
489
+ if err != nil {
490
+ return showLoginView()
491
+ }
492
+ auth.SaveToken(newToken)
493
+ token = newToken
494
+ }
495
+
496
+ // 3. Fetch current user
497
+ user, err := api.GetCurrentUser()
498
+ if err != nil {
499
+ return showLoginView()
500
+ }
501
+
502
+ // 4. Detect workspace
503
+ return detectWorkspace()
504
+ }
505
+ ```
506
+
507
+ ### 5.2 Registration Flow
508
+
509
+ ```
510
+ ┌─────────────────────────────────────────────────────────┐
511
+ │ Register Form (Huh form) │
512
+ │ ┌──────────────────────────────────────┐ │
513
+ │ │ Name: [_____________________] │ │
514
+ │ │ Email: [_____________________] │ │
515
+ │ │ Password: [__________________] │ │
516
+ │ │ │ │
517
+ │ │ [Register] [Back to Login] │ │
518
+ │ └──────────────────────────────────────┘ │
519
+ └─────────────────────────────────────────────────────────┘
520
+
521
+
522
+ ┌─────────────────────────────────────────────────────────┐
523
+ │ POST /api/v1/auth/register │
524
+ │ { email, password, name } │
525
+ │ │
526
+ │ Backend: │
527
+ │ - Creates Supabase user │
528
+ │ - Creates User in Purple DB │
529
+ │ - Creates/joins Team based on email domain │
530
+ │ - Returns tokens + user │
531
+ └─────────────────────────────────────────────────────────┘
532
+
533
+
534
+ ┌─────────────────────────────────────────────────────────┐
535
+ │ If email domain matched existing team: │
536
+ │ "You've been added to {TeamName} based on your │
537
+ │ email domain (@company.com)" │
538
+ └─────────────────────────────────────────────────────────┘
539
+
540
+
541
+ ┌─────────────────────────────────────────────────────────┐
542
+ │ Store tokens in OS Keychain │
543
+ │ Continue to workspace detection │
544
+ └─────────────────────────────────────────────────────────┘
545
+ ```
546
+
547
+ ### 5.3 Workspace Open Flow
548
+
549
+ ```go
550
+ func openWorkspace(workspaceID string) error {
551
+ // Parallel fetch all workspace data
552
+ var (
553
+ workspace *Workspace
554
+ repos []Repo
555
+ standards []Standard
556
+ plugins []PluginSummary
557
+ )
558
+
559
+ g, ctx := errgroup.WithContext(context.Background())
560
+
561
+ g.Go(func() error {
562
+ var err error
563
+ workspace, err = api.GetWorkspace(workspaceID)
564
+ return err
565
+ })
566
+
567
+ g.Go(func() error {
568
+ var err error
569
+ repos, err = api.ListRepos(workspaceID)
570
+ return err
571
+ })
572
+
573
+ g.Go(func() error {
574
+ var err error
575
+ standards, err = api.ListStandards(workspaceID)
576
+ return err
577
+ })
578
+
579
+ g.Go(func() error {
580
+ var err error
581
+ plugins, err = api.ListWorkspacePlugins(workspaceID)
582
+ return err
583
+ })
584
+
585
+ if err := g.Wait(); err != nil {
586
+ return err
587
+ }
588
+
589
+ // Sync standards to local files
590
+ if err := syncStandardsToLocal(standards); err != nil {
591
+ return err
592
+ }
593
+
594
+ // Check for plugin updates
595
+ if err := checkPluginUpdates(plugins); err != nil {
596
+ // Non-fatal, just log
597
+ log.Warn("Failed to check plugin updates:", err)
598
+ }
599
+
600
+ return showSessionList()
601
+ }
602
+ ```
603
+
604
+ ### 5.4 Standards Sync
605
+
606
+ **Download (API → Local):**
607
+ ```go
608
+ func syncStandardsToLocal(standards []Standard) error {
609
+ standardsDir := filepath.Join(workspacePath, "workbench", "standards")
610
+
611
+ for _, std := range standards {
612
+ // Build file path: workbench/standards/{path}/{name}.md
613
+ filePath := filepath.Join(standardsDir, std.Path, std.Name+".md")
614
+
615
+ // Ensure directory exists
616
+ os.MkdirAll(filepath.Dir(filePath), 0755)
617
+
618
+ // Write content
619
+ if err := os.WriteFile(filePath, []byte(std.Content), 0644); err != nil {
620
+ return err
621
+ }
622
+ }
623
+ return nil
624
+ }
625
+ ```
626
+
627
+ **Upload (Local → API):**
628
+ ```go
629
+ func uploadStandardsToAPI(workspaceID string) error {
630
+ standardsDir := filepath.Join(workspacePath, "workbench", "standards")
631
+
632
+ // Get existing standards from API for ID lookup
633
+ existing, _ := api.ListStandards(workspaceID)
634
+ existingMap := make(map[string]*Standard) // key: path/name
635
+ for _, s := range existing {
636
+ key := filepath.Join(s.Path, s.Name)
637
+ existingMap[key] = &s
638
+ }
639
+
640
+ // Walk local standards directory
641
+ return filepath.Walk(standardsDir, func(path string, info os.FileInfo, err error) error {
642
+ if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
643
+ return nil
644
+ }
645
+
646
+ relPath, _ := filepath.Rel(standardsDir, path)
647
+ dir := filepath.Dir(relPath)
648
+ name := strings.TrimSuffix(filepath.Base(relPath), ".md")
649
+ content, _ := os.ReadFile(path)
650
+
651
+ key := filepath.Join(dir, name)
652
+ if existing, ok := existingMap[key]; ok {
653
+ // Update existing
654
+ api.UpdateStandard(existing.ID, string(content))
655
+ } else {
656
+ // Create new
657
+ api.CreateStandard(workspaceID, dir, name, string(content))
658
+ }
659
+ return nil
660
+ })
661
+ }
662
+ ```
663
+
664
+ ### 5.5 Plugin Installation
665
+
666
+ ```go
667
+ func installPlugin(workspaceID string, plugin *Plugin) error {
668
+ claudeDir := filepath.Join(workspacePath, ".claude")
669
+
670
+ switch plugin.Type {
671
+ case "tarball":
672
+ // Decode base64 tarball
673
+ data, _ := base64.StdEncoding.DecodeString(*plugin.Tarball)
674
+
675
+ // Extract to .claude/
676
+ return extractTarball(bytes.NewReader(data), claudeDir)
677
+
678
+ case "repo":
679
+ // Clone repo to temp, copy agents/ and commands/ to .claude/
680
+ tmpDir, _ := os.MkdirTemp("", "purple-plugin-")
681
+ defer os.RemoveAll(tmpDir)
682
+
683
+ exec.Command("git", "clone", "--depth=1", *plugin.RepoURL, tmpDir).Run()
684
+
685
+ // Copy agents
686
+ copyDir(filepath.Join(tmpDir, "agents"), filepath.Join(claudeDir, "agents"))
687
+ // Copy commands
688
+ copyDir(filepath.Join(tmpDir, "commands"), filepath.Join(claudeDir, "commands"))
689
+
690
+ return nil
691
+ }
692
+
693
+ return fmt.Errorf("unknown plugin type: %s", plugin.Type)
694
+ }
695
+ ```
696
+
697
+ ---
698
+
699
+ ## 6. Terminal Management
700
+
701
+ ### 6.1 PTY Architecture
702
+
703
+ ```
704
+ ┌─────────────────────────────────────────────────────────────┐
705
+ │ TERMINAL │
706
+ │ ┌───────────────────────────────────────────────────────┐ │
707
+ │ │ │ │
708
+ │ │ SCROLL REGION │ │
709
+ │ │ (Claude Code output) │ │
710
+ │ │ │ │
711
+ │ │ PTY dimensions: cols x (rows - STATUS_BAR_HEIGHT) │ │
712
+ │ │ │ │
713
+ │ ├───────────────────────────────────────────────────────┤ │
714
+ │ │ ─────────────────────────────────────────────────────│ │ <- Border line
715
+ │ │ Phase: Implementation | Agent: senior-engineer | ... │ │ <- Status content
716
+ │ │ │ │ <- Padding
717
+ │ └───────────────────────────────────────────────────────┘ │
718
+ │ STATUS_BAR_HEIGHT = 3 │
719
+ └─────────────────────────────────────────────────────────────┘
720
+ ```
721
+
722
+ ### 6.2 Status Bar Communication
723
+
724
+ Two methods (for redundancy):
725
+
726
+ **Method 1: MCP Server (Primary)**
727
+ ```
728
+ Claude Agent → purple_update_status tool → MCP stdio
729
+ → MCP Server (Node process) → HTTP POST localhost:7685
730
+ → Purple HTTP handler → Update status bar
731
+ ```
732
+
733
+ **Method 2: File Watcher (Fallback)**
734
+ ```
735
+ Claude Agent → Write to workbench/.purple-state.json
736
+ → Purple file watcher detects change
737
+ → Read file → Update status bar
738
+ ```
739
+
740
+ ### 6.3 Input Handling
741
+
742
+ ```go
743
+ func handleInput(input []byte, pty *os.File) {
744
+ // Intercept Ctrl+K for command palette
745
+ if input[0] == 0x0b { // Ctrl+K
746
+ showCommandPalette()
747
+ return
748
+ }
749
+
750
+ // If command palette is visible, route to palette
751
+ if commandPaletteVisible {
752
+ result := handlePaletteInput(input)
753
+ if result.Command != "" {
754
+ pty.Write([]byte(result.Command + "\n"))
755
+ }
756
+ return
757
+ }
758
+
759
+ // Normal input - pass to PTY
760
+ pty.Write(input)
761
+ }
762
+ ```
763
+
764
+ ### 6.4 Hook-based Return Flow
765
+
766
+ ```bash
767
+ # ~/.claude/hooks/session-end.sh
768
+ #!/bin/bash
769
+ # Signal Purple that Claude session ended
770
+ curl -X POST http://127.0.0.1:7685/session-end 2>/dev/null || true
771
+ ```
772
+
773
+ When Purple receives `/session-end`:
774
+ 1. Wait for PTY process to fully exit
775
+ 2. Restore terminal (disable raw mode, reset scroll region)
776
+ 3. Clear screen
777
+ 4. Resume Purple TUI
778
+
779
+ ### 6.5 Claude Code Launch Commands
780
+
781
+ Purple constructs different `claude` commands based on context:
782
+
783
+ ```go
784
+ // internal/claude/commands.go
785
+
786
+ package claude
787
+
788
+ import (
789
+ "fmt"
790
+ "os/exec"
791
+ "path/filepath"
792
+ )
793
+
794
+ type LaunchConfig struct {
795
+ WorkspacePath string
796
+ SessionName string // For --resume or session naming
797
+ InitialPrompt string // First message to send
798
+ Command string // Slash command (e.g., "/build-from-spec")
799
+ SkipPerms bool // --dangerously-skip-permissions
800
+ }
801
+
802
+ func BuildCommand(cfg LaunchConfig) *exec.Cmd {
803
+ args := []string{}
804
+
805
+ // Always start in workspace directory
806
+ args = append(args, "--cwd", cfg.WorkspacePath)
807
+
808
+ // Resume existing session OR create new named session
809
+ if cfg.SessionName != "" {
810
+ // Check if session exists
811
+ if sessionExists(cfg.SessionName) {
812
+ args = append(args, "--resume", cfg.SessionName)
813
+ }
814
+ // Note: Session will be renamed via /rename after launch
815
+ }
816
+
817
+ // Skip permissions for automated flows (create-standards, build-from-spec)
818
+ if cfg.SkipPerms {
819
+ args = append(args, "--dangerously-skip-permissions")
820
+ }
821
+
822
+ // Initial prompt (command + arguments)
823
+ if cfg.Command != "" {
824
+ prompt := cfg.Command
825
+ if cfg.InitialPrompt != "" {
826
+ prompt = fmt.Sprintf("%s %s", cfg.Command, cfg.InitialPrompt)
827
+ }
828
+ args = append(args, "-p", prompt)
829
+ }
830
+
831
+ return exec.Command("claude", args...)
832
+ }
833
+
834
+ // Example launches:
835
+
836
+ // 1. New session with /build-from-spec
837
+ func LaunchBuildFromSpec(workspace, specPath string) *exec.Cmd {
838
+ return BuildCommand(LaunchConfig{
839
+ WorkspacePath: workspace,
840
+ Command: "/build-from-spec",
841
+ InitialPrompt: specPath,
842
+ SkipPerms: true,
843
+ })
844
+ }
845
+
846
+ // 2. Resume existing session
847
+ func LaunchResumeSession(workspace, sessionName string) *exec.Cmd {
848
+ return BuildCommand(LaunchConfig{
849
+ WorkspacePath: workspace,
850
+ SessionName: sessionName,
851
+ })
852
+ }
853
+
854
+ // 3. Create standards (first-time setup)
855
+ func LaunchCreateStandards(workspace string) *exec.Cmd {
856
+ return BuildCommand(LaunchConfig{
857
+ WorkspacePath: workspace,
858
+ Command: "/create-standards",
859
+ SkipPerms: true,
860
+ })
861
+ }
862
+
863
+ // 4. Interactive session (user just wants Claude)
864
+ func LaunchInteractive(workspace string) *exec.Cmd {
865
+ return BuildCommand(LaunchConfig{
866
+ WorkspacePath: workspace,
867
+ })
868
+ }
869
+ ```
870
+
871
+ **Session Naming Flow:**
872
+
873
+ After launching with `/build-from-spec`, the command itself renames the session:
874
+ ```markdown
875
+ # In build-from-spec.md command
876
+
877
+ After creating the feature folder (e.g., 260112-auth-feature):
878
+ 1. Run /rename 260112-auth-feature
879
+ 2. This ensures the Claude session matches the feature folder name
880
+ ```
881
+
882
+ ### 6.6 Prerequisites Check
883
+
884
+ Before launching Claude Code, Purple verifies prerequisites:
885
+
886
+ ```go
887
+ // internal/prereqs/check.go
888
+
889
+ package prereqs
890
+
891
+ import (
892
+ "errors"
893
+ "os/exec"
894
+ )
895
+
896
+ type Prerequisite struct {
897
+ Name string
898
+ Command string
899
+ Args []string
900
+ HelpURL string
901
+ }
902
+
903
+ var Prerequisites = []Prerequisite{
904
+ {
905
+ Name: "Claude Code",
906
+ Command: "claude",
907
+ Args: []string{"--version"},
908
+ HelpURL: "https://claude.ai/code",
909
+ },
910
+ {
911
+ Name: "Node.js",
912
+ Command: "node",
913
+ Args: []string{"--version"},
914
+ HelpURL: "https://nodejs.org",
915
+ },
916
+ {
917
+ Name: "Git",
918
+ Command: "git",
919
+ Args: []string{"--version"},
920
+ HelpURL: "https://git-scm.com",
921
+ },
922
+ }
923
+
924
+ func CheckAll() []error {
925
+ var errs []error
926
+
927
+ for _, prereq := range Prerequisites {
928
+ cmd := exec.Command(prereq.Command, prereq.Args...)
929
+ if err := cmd.Run(); err != nil {
930
+ errs = append(errs, errors.New(prereq.Name+" not found. Install: "+prereq.HelpURL))
931
+ }
932
+ }
933
+
934
+ return errs
935
+ }
936
+
937
+ func CheckOnStartup() error {
938
+ errs := CheckAll()
939
+ if len(errs) > 0 {
940
+ return errs[0] // Return first missing prerequisite
941
+ }
942
+ return nil
943
+ }
944
+ ```
945
+
946
+ **Node.js is required** because:
947
+ - MCP server runs on Node.js (official MCP SDK)
948
+ - Claude Code may spawn MCP servers at any time
949
+ - Without Node.js, status bar communication will silently fail
950
+
951
+ ---
952
+
953
+ ## 7. MCP Integration
954
+
955
+ The Model Context Protocol (MCP) is how Claude Code agents communicate status updates back to Purple CLIent. This enables the real-time status bar that shows current phase, agent, and progress.
956
+
957
+ ### 7.1 Architecture Overview
958
+
959
+ ```
960
+ ┌─────────────────────────────────────────────────────────────────────────┐
961
+ │ CLAUDE CODE SESSION │
962
+ │ │
963
+ │ ┌──────────────────┐ stdio ┌──────────────────┐ │
964
+ │ │ Claude Agent │ ←──────────→ │ MCP Server │ │
965
+ │ │ (senior-eng, │ (mcp │ (Node.js) │ │
966
+ │ │ spec-writer) │ protocol) │ │ │
967
+ │ └──────────────────┘ └────────┬─────────┘ │
968
+ │ │ │
969
+ │ Agent calls: │ HTTP POST │
970
+ │ purple_update_status() │ localhost:7685 │
971
+ │ purple_set_phase() │ │
972
+ │ purple_report_progress() ▼ │
973
+ └─────────────────────────────────────────────┼────────────────────────────┘
974
+
975
+
976
+ ┌─────────────────────────────────────────────┼────────────────────────────┐
977
+ │ PURPLE CLENT (Go) │ │
978
+ │ │ │
979
+ │ ┌──────────────────┐ ┌────────▼─────────┐ │
980
+ │ │ PTY Manager │ │ HTTP Server │ │
981
+ │ │ (claude code │ │ (status recv) │ │
982
+ │ │ subprocess) │ │ :7685 │ │
983
+ │ └────────┬─────────┘ └────────┬─────────┘ │
984
+ │ │ │ │
985
+ │ │ raw output │ status updates │
986
+ │ ▼ ▼ │
987
+ │ ┌─────────────────────────────────────────────────────────────────┐ │
988
+ │ │ TERMINAL RENDERER │ │
989
+ │ │ ┌───────────────────────────────────────────────────────────┐ │ │
990
+ │ │ │ [Claude Code output area - PTY scroll region] │ │ │
991
+ │ │ ├───────────────────────────────────────────────────────────┤ │ │
992
+ │ │ │ Phase: Implementation │ Agent: senior-engineer │ 3/10 │ │ │
993
+ │ │ └───────────────────────────────────────────────────────────┘ │ │
994
+ │ └─────────────────────────────────────────────────────────────────┘ │
995
+ └─────────────────────────────────────────────────────────────────────────┘
996
+ ```
997
+
998
+ ### 7.2 MCP Server Implementation
999
+
1000
+ **Decision: Keep MCP Server in TypeScript/Node.js**
1001
+
1002
+ Rationale:
1003
+ - MCP SDK is officially maintained in TypeScript
1004
+ - Claude Code expects Node-based MCP servers
1005
+ - MCP server runs as a separate process (spawned by Claude Code, not Purple)
1006
+ - Communication to Purple is via simple HTTP POST, language-agnostic
1007
+
1008
+ ```
1009
+ purple-client/
1010
+ ├── cmd/purple/ # Go CLI
1011
+ ├── internal/ # Go packages
1012
+ └── mcp-server/ # Node.js MCP server (bundled)
1013
+ ├── package.json
1014
+ ├── src/
1015
+ │ └── index.ts
1016
+ └── dist/
1017
+ └── index.js # Bundled for distribution
1018
+ ```
1019
+
1020
+ ### 7.3 MCP Server Code
1021
+
1022
+ ```typescript
1023
+ // mcp-server/src/index.ts
1024
+
1025
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1026
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1027
+
1028
+ const PURPLE_STATUS_URL = "http://127.0.0.1:7685/status";
1029
+
1030
+ const server = new Server(
1031
+ { name: "purple-status", version: "1.0.0" },
1032
+ { capabilities: { tools: {} } }
1033
+ );
1034
+
1035
+ // Define available tools
1036
+ server.setRequestHandler("tools/list", async () => ({
1037
+ tools: [
1038
+ {
1039
+ name: "purple_update_status",
1040
+ description: "Update the Purple CLI status bar with current progress",
1041
+ inputSchema: {
1042
+ type: "object",
1043
+ properties: {
1044
+ phase: {
1045
+ type: "string",
1046
+ description: 'Current phase (e.g., "0 - Validation", "3 - Implementation")'
1047
+ },
1048
+ agent: {
1049
+ type: "string",
1050
+ description: 'Active agent name (e.g., "orchestrator", "senior-engineer")'
1051
+ },
1052
+ currentTicket: {
1053
+ type: "number",
1054
+ description: "Current ticket number being worked on"
1055
+ },
1056
+ totalTickets: {
1057
+ type: "number",
1058
+ description: "Total number of tickets"
1059
+ },
1060
+ mode: {
1061
+ type: "string",
1062
+ enum: ["plan", "execute", "review"],
1063
+ description: "Current mode"
1064
+ },
1065
+ tool: {
1066
+ type: "string",
1067
+ description: 'Currently active tool (e.g., "Edit", "Grep")'
1068
+ },
1069
+ activeFile: {
1070
+ type: "string",
1071
+ description: "File currently being worked on"
1072
+ }
1073
+ }
1074
+ }
1075
+ },
1076
+ {
1077
+ name: "purple_set_phase",
1078
+ description: "Quick helper to set just the current phase",
1079
+ inputSchema: {
1080
+ type: "object",
1081
+ properties: {
1082
+ phase: {
1083
+ type: "string",
1084
+ description: 'Phase name (e.g., "1 - Product Spec", "3 - Implementation")'
1085
+ }
1086
+ },
1087
+ required: ["phase"]
1088
+ }
1089
+ },
1090
+ {
1091
+ name: "purple_report_progress",
1092
+ description: "Report ticket progress during implementation",
1093
+ inputSchema: {
1094
+ type: "object",
1095
+ properties: {
1096
+ current: { type: "number", description: "Current ticket number" },
1097
+ total: { type: "number", description: "Total tickets" }
1098
+ },
1099
+ required: ["current", "total"]
1100
+ }
1101
+ }
1102
+ ]
1103
+ }));
1104
+
1105
+ // Handle tool calls
1106
+ server.setRequestHandler("tools/call", async (request) => {
1107
+ const { name, arguments: args } = request.params;
1108
+
1109
+ try {
1110
+ // Forward status update to Purple CLI via HTTP
1111
+ await fetch(PURPLE_STATUS_URL, {
1112
+ method: "POST",
1113
+ headers: { "Content-Type": "application/json" },
1114
+ body: JSON.stringify({ tool: name, ...args })
1115
+ });
1116
+
1117
+ return { content: [{ type: "text", text: "Status updated" }] };
1118
+ } catch (error) {
1119
+ // Non-fatal - Purple might not be running
1120
+ return { content: [{ type: "text", text: "Status update sent (Purple may not be active)" }] };
1121
+ }
1122
+ });
1123
+
1124
+ // Start server
1125
+ const transport = new StdioServerTransport();
1126
+ server.connect(transport);
1127
+ ```
1128
+
1129
+ ### 7.4 Auto-Installation Flow
1130
+
1131
+ When Purple starts for the first time, it must register the MCP server with Claude Code.
1132
+
1133
+ ```go
1134
+ // internal/mcp/installer.go
1135
+
1136
+ package mcp
1137
+
1138
+ import (
1139
+ "encoding/json"
1140
+ "os"
1141
+ "path/filepath"
1142
+ )
1143
+
1144
+ type MCPConfig struct {
1145
+ MCPServers map[string]MCPServer `json:"mcpServers"`
1146
+ }
1147
+
1148
+ type MCPServer struct {
1149
+ Command string `json:"command"`
1150
+ Args []string `json:"args"`
1151
+ }
1152
+
1153
+ func EnsureMCPInstalled() error {
1154
+ claudeConfigPath := filepath.Join(os.Getenv("HOME"), ".claude.json")
1155
+ mcpServerPath := getMCPServerPath()
1156
+
1157
+ // Read existing config or create new
1158
+ var config MCPConfig
1159
+ if data, err := os.ReadFile(claudeConfigPath); err == nil {
1160
+ json.Unmarshal(data, &config)
1161
+ }
1162
+ if config.MCPServers == nil {
1163
+ config.MCPServers = make(map[string]MCPServer)
1164
+ }
1165
+
1166
+ // Check if already registered
1167
+ if _, exists := config.MCPServers["purple-status"]; exists {
1168
+ return nil // Already installed
1169
+ }
1170
+
1171
+ // Register purple-status MCP server
1172
+ config.MCPServers["purple-status"] = MCPServer{
1173
+ Command: "node",
1174
+ Args: []string{mcpServerPath},
1175
+ }
1176
+
1177
+ // Write updated config
1178
+ data, _ := json.MarshalIndent(config, "", " ")
1179
+ return os.WriteFile(claudeConfigPath, data, 0644)
1180
+ }
1181
+
1182
+ func getMCPServerPath() string {
1183
+ // MCP server is bundled with Purple installation
1184
+ // Path depends on how Purple was installed
1185
+
1186
+ // If installed via npm (npx purple-ai)
1187
+ if npmPath := getNpmMCPPath(); npmPath != "" {
1188
+ return npmPath
1189
+ }
1190
+
1191
+ // Fallback: look in ~/.purple/mcp-server/
1192
+ return filepath.Join(os.Getenv("HOME"), ".purple", "mcp-server", "dist", "index.js")
1193
+ }
1194
+
1195
+ func getNpmMCPPath() string {
1196
+ // When installed via npm, MCP server is in the package
1197
+ execPath, _ := os.Executable()
1198
+ packageDir := filepath.Dir(filepath.Dir(execPath))
1199
+ mcpPath := filepath.Join(packageDir, "mcp-server", "dist", "index.js")
1200
+
1201
+ if _, err := os.Stat(mcpPath); err == nil {
1202
+ return mcpPath
1203
+ }
1204
+ return ""
1205
+ }
1206
+ ```
1207
+
1208
+ **Installation Trigger Points:**
1209
+
1210
+ 1. **First run**: When Purple detects no workspace and user is setting up
1211
+ 2. **Workspace open**: Verify MCP is still registered (user may have reset Claude config)
1212
+ 3. **Before launching Claude Code**: Always verify MCP is registered
1213
+
1214
+ ### 7.5 Agent Tool Call Instructions
1215
+
1216
+ Agents must be instructed to use Purple's MCP tools. This is done via **agent prompt files** that are part of the Purple Core plugin.
1217
+
1218
+ **Example: senior-engineer.md agent prompt**
1219
+
1220
+ ```markdown
1221
+ # Senior Engineer Agent
1222
+
1223
+ You are a senior engineer implementing features based on engineering specifications.
1224
+
1225
+ ## Status Reporting (REQUIRED)
1226
+
1227
+ You MUST update Purple's status bar using MCP tools as you work:
1228
+
1229
+ 1. **At the start of each ticket:**
1230
+ ```
1231
+ Call: purple_report_progress({ current: 1, total: 5 })
1232
+ Call: purple_update_status({ agent: "senior-engineer", mode: "execute" })
1233
+ ```
1234
+
1235
+ 2. **When switching modes:**
1236
+ ```
1237
+ Call: purple_update_status({ mode: "plan" }) // When planning
1238
+ Call: purple_update_status({ mode: "execute" }) // When coding
1239
+ Call: purple_update_status({ mode: "review" }) // When reviewing
1240
+ ```
1241
+
1242
+ 3. **When working on a file:**
1243
+ ```
1244
+ Call: purple_update_status({ activeFile: "src/components/Button.tsx" })
1245
+ ```
1246
+
1247
+ This keeps the user informed of progress without them needing to read your output.
1248
+
1249
+ ## Implementation Guidelines
1250
+ ...
1251
+ ```
1252
+
1253
+ **Example: orchestrator command (build-from-spec.md)**
1254
+
1255
+ ```markdown
1256
+ # Build From Spec
1257
+
1258
+ This command orchestrates the full spec-to-implementation workflow.
1259
+
1260
+ ## Phase Updates (REQUIRED)
1261
+
1262
+ Update the phase as you progress:
1263
+
1264
+ ```
1265
+ purple_set_phase({ phase: "0 - Validation" }) // Validating inputs
1266
+ purple_set_phase({ phase: "1 - Product Spec" }) // Writing product spec
1267
+ purple_set_phase({ phase: "2 - Engineering" }) // Creating architecture
1268
+ purple_set_phase({ phase: "3 - Implementation" }) // Building features
1269
+ purple_set_phase({ phase: "4 - Review" }) // Final review
1270
+ ```
1271
+
1272
+ ## Spawning Sub-Agents
1273
+
1274
+ When spawning senior-engineer agents for parallel implementation:
1275
+ ```
1276
+ purple_update_status({
1277
+ phase: "3 - Implementation",
1278
+ agent: "orchestrator",
1279
+ currentTicket: 1,
1280
+ totalTickets: 10
1281
+ })
1282
+ ```
1283
+
1284
+ Then spawn the Task with senior-engineer...
1285
+ ```
1286
+
1287
+ ### 7.6 Purple HTTP Status Server (Go)
1288
+
1289
+ ```go
1290
+ // internal/server/status.go
1291
+
1292
+ package server
1293
+
1294
+ import (
1295
+ "encoding/json"
1296
+ "net/http"
1297
+ "sync"
1298
+ )
1299
+
1300
+ type StatusUpdate struct {
1301
+ Tool string `json:"tool"`
1302
+ Phase string `json:"phase,omitempty"`
1303
+ Agent string `json:"agent,omitempty"`
1304
+ CurrentTicket int `json:"currentTicket,omitempty"`
1305
+ TotalTickets int `json:"totalTickets,omitempty"`
1306
+ Mode string `json:"mode,omitempty"`
1307
+ ActiveTool string `json:"tool,omitempty"`
1308
+ ActiveFile string `json:"activeFile,omitempty"`
1309
+ }
1310
+
1311
+ type StatusServer struct {
1312
+ mu sync.RWMutex
1313
+ status StatusUpdate
1314
+ onChange func(StatusUpdate)
1315
+ server *http.Server
1316
+ }
1317
+
1318
+ func NewStatusServer(onChange func(StatusUpdate)) *StatusServer {
1319
+ s := &StatusServer{
1320
+ onChange: onChange,
1321
+ }
1322
+
1323
+ mux := http.NewServeMux()
1324
+ mux.HandleFunc("/status", s.handleStatus)
1325
+ mux.HandleFunc("/session-end", s.handleSessionEnd)
1326
+
1327
+ s.server = &http.Server{
1328
+ Addr: "127.0.0.1:7685",
1329
+ Handler: mux,
1330
+ }
1331
+
1332
+ return s
1333
+ }
1334
+
1335
+ func (s *StatusServer) Start() error {
1336
+ return s.server.ListenAndServe()
1337
+ }
1338
+
1339
+ func (s *StatusServer) Stop() error {
1340
+ return s.server.Close()
1341
+ }
1342
+
1343
+ func (s *StatusServer) handleStatus(w http.ResponseWriter, r *http.Request) {
1344
+ if r.Method != http.MethodPost {
1345
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1346
+ return
1347
+ }
1348
+
1349
+ var update StatusUpdate
1350
+ if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
1351
+ http.Error(w, err.Error(), http.StatusBadRequest)
1352
+ return
1353
+ }
1354
+
1355
+ s.mu.Lock()
1356
+ // Merge update (only set non-empty fields)
1357
+ if update.Phase != "" {
1358
+ s.status.Phase = update.Phase
1359
+ }
1360
+ if update.Agent != "" {
1361
+ s.status.Agent = update.Agent
1362
+ }
1363
+ if update.CurrentTicket > 0 {
1364
+ s.status.CurrentTicket = update.CurrentTicket
1365
+ }
1366
+ if update.TotalTickets > 0 {
1367
+ s.status.TotalTickets = update.TotalTickets
1368
+ }
1369
+ if update.Mode != "" {
1370
+ s.status.Mode = update.Mode
1371
+ }
1372
+ if update.ActiveTool != "" {
1373
+ s.status.ActiveTool = update.ActiveTool
1374
+ }
1375
+ if update.ActiveFile != "" {
1376
+ s.status.ActiveFile = update.ActiveFile
1377
+ }
1378
+ current := s.status
1379
+ s.mu.Unlock()
1380
+
1381
+ // Notify TUI to update
1382
+ if s.onChange != nil {
1383
+ s.onChange(current)
1384
+ }
1385
+
1386
+ w.WriteHeader(http.StatusOK)
1387
+ json.NewEncoder(w).Encode(map[string]bool{"ok": true})
1388
+ }
1389
+
1390
+ func (s *StatusServer) handleSessionEnd(w http.ResponseWriter, r *http.Request) {
1391
+ // Signal Purple to exit Claude mode and return to TUI
1392
+ // This is handled by the onChange callback with a special signal
1393
+ if s.onChange != nil {
1394
+ s.onChange(StatusUpdate{Tool: "__session_end__"})
1395
+ }
1396
+ w.WriteHeader(http.StatusOK)
1397
+ }
1398
+
1399
+ func (s *StatusServer) GetStatus() StatusUpdate {
1400
+ s.mu.RLock()
1401
+ defer s.mu.RUnlock()
1402
+ return s.status
1403
+ }
1404
+ ```
1405
+
1406
+ ### 7.7 Status Bar Rendering
1407
+
1408
+ ```go
1409
+ // internal/tui/components/statusbar.go
1410
+
1411
+ package components
1412
+
1413
+ import (
1414
+ "fmt"
1415
+ "strings"
1416
+
1417
+ "github.com/charmbracelet/lipgloss"
1418
+ )
1419
+
1420
+ var (
1421
+ // Status bar uses brand colors from bepurple.ai
1422
+ statusBarStyle = lipgloss.NewStyle().
1423
+ Background(lipgloss.Color("#1A1625")). // BgCard
1424
+ Foreground(lipgloss.Color("#E8E4EF")). // Light text
1425
+ Padding(0, 1)
1426
+
1427
+ phaseStyle = lipgloss.NewStyle().
1428
+ Foreground(lipgloss.Color("#B794F6")). // Purple (primary)
1429
+ Bold(true)
1430
+
1431
+ agentStyle = lipgloss.NewStyle().
1432
+ Foreground(lipgloss.Color("#4ADE80")) // Success green
1433
+
1434
+ progressStyle = lipgloss.NewStyle().
1435
+ Foreground(lipgloss.Color("#FBBF24")) // Warning amber
1436
+
1437
+ modeStyle = lipgloss.NewStyle().
1438
+ Foreground(lipgloss.Color("#8B7AA8")) // PurpleMuted
1439
+
1440
+ separatorStyle = lipgloss.NewStyle().
1441
+ Foreground(lipgloss.Color("#6B5B8C")). // PurpleDark
1442
+ SetString(" │ ")
1443
+ )
1444
+
1445
+ type StatusBar struct {
1446
+ Phase string
1447
+ Agent string
1448
+ CurrentTicket int
1449
+ TotalTickets int
1450
+ Mode string
1451
+ ActiveFile string
1452
+ Width int
1453
+ }
1454
+
1455
+ func (s StatusBar) View() string {
1456
+ var parts []string
1457
+
1458
+ // Phase
1459
+ if s.Phase != "" {
1460
+ parts = append(parts, phaseStyle.Render(s.Phase))
1461
+ }
1462
+
1463
+ // Agent
1464
+ if s.Agent != "" {
1465
+ parts = append(parts, agentStyle.Render(s.Agent))
1466
+ }
1467
+
1468
+ // Progress
1469
+ if s.TotalTickets > 0 {
1470
+ progress := fmt.Sprintf("%d/%d", s.CurrentTicket, s.TotalTickets)
1471
+ parts = append(parts, progressStyle.Render(progress))
1472
+ }
1473
+
1474
+ // Mode
1475
+ if s.Mode != "" {
1476
+ modeIcon := map[string]string{
1477
+ "plan": "📋",
1478
+ "execute": "⚡",
1479
+ "review": "🔍",
1480
+ }[s.Mode]
1481
+ parts = append(parts, modeStyle.Render(modeIcon+" "+s.Mode))
1482
+ }
1483
+
1484
+ content := strings.Join(parts, separatorStyle.String())
1485
+
1486
+ // Pad to full width
1487
+ return statusBarStyle.Width(s.Width).Render(content)
1488
+ }
1489
+ ```
1490
+
1491
+ ### 7.8 MCP Distribution Strategy
1492
+
1493
+ The MCP server must be bundled with Purple for reliable operation.
1494
+
1495
+ **npm Package Structure:**
1496
+ ```
1497
+ purple-ai/
1498
+ ├── package.json
1499
+ ├── bin/
1500
+ │ └── purple-ai.js # Entry point
1501
+ ├── binaries/
1502
+ │ ├── purple-darwin-arm64
1503
+ │ ├── purple-darwin-x64
1504
+ │ └── purple-linux-x64
1505
+ └── mcp-server/
1506
+ ├── package.json
1507
+ └── dist/
1508
+ └── index.js # Pre-bundled MCP server
1509
+ ```
1510
+
1511
+ **postinstall.js:**
1512
+ ```javascript
1513
+ const { execSync } = require('child_process');
1514
+ const path = require('path');
1515
+
1516
+ // Install MCP server dependencies
1517
+ const mcpDir = path.join(__dirname, 'mcp-server');
1518
+ execSync('npm install --production', { cwd: mcpDir, stdio: 'inherit' });
1519
+ ```
1520
+
1521
+ ### 7.9 Fallback: File-based Status (No MCP)
1522
+
1523
+ If MCP communication fails, agents can write status to a file:
1524
+
1525
+ ```
1526
+ {workspace}/workbench/.purple-state.json
1527
+ ```
1528
+
1529
+ Purple watches this file as a fallback:
1530
+
1531
+ ```go
1532
+ // internal/status/watcher.go
1533
+
1534
+ func watchStatusFile(path string, onChange func(StatusUpdate)) {
1535
+ watcher, _ := fsnotify.NewWatcher()
1536
+ watcher.Add(path)
1537
+
1538
+ for event := range watcher.Events {
1539
+ if event.Op&fsnotify.Write == fsnotify.Write {
1540
+ data, _ := os.ReadFile(path)
1541
+ var status StatusUpdate
1542
+ json.Unmarshal(data, &status)
1543
+ onChange(status)
1544
+ }
1545
+ }
1546
+ }
1547
+ ```
1548
+
1549
+ Agents are instructed to write to this file if MCP tools aren't available (for users running Claude Code without Purple).
1550
+
1551
+ ---
1552
+
1553
+ ## 8. UI/UX Design Principles
1554
+
1555
+ Purple CLIent should feel **premium and polished** - not like a typical CLI tool. The goal is a terminal experience that rivals native desktop apps.
1556
+
1557
+ ### Brand Identity
1558
+
1559
+ Purple CLIent uses the official bepurple.ai brand colors. **Dark theme only** - matches the website aesthetic.
1560
+
1561
+ | Color | Hex | Usage |
1562
+ |-------|-----|-------|
1563
+ | **Purple** | `#B794F6` | Primary - logo, headings, selected items |
1564
+ | **Purple Light** | `#D4BFFF` | Hover states, highlights |
1565
+ | **Purple Muted** | `#8B7AA8` | Buttons, secondary accents |
1566
+ | **Purple Dark** | `#6B5B8C` | Borders, separators |
1567
+ | **Background** | `#0D0B14` | Main background (deep purple-black) |
1568
+ | **Card Background** | `#1A1625` | Terminal cards, elevated surfaces |
1569
+ | **Success** | `#4ADE80` | Checkmarks, completion states |
1570
+ | **Warning** | `#FBBF24` | In-progress, active states |
1571
+ | **Error** | `#F87171` | Error messages, failures |
1572
+ | **Muted Text** | `#6B6378` | Secondary text, labels |
1573
+
1574
+ ### 8.1 Design Philosophy
1575
+
1576
+ | Principle | Implementation |
1577
+ |-----------|----------------|
1578
+ | **Instant feedback** | Every action shows immediate visual response |
1579
+ | **No dead ends** | Always show what user can do next |
1580
+ | **Graceful degradation** | Errors don't break the UI, they inform |
1581
+ | **Progressive disclosure** | Show simple first, details on demand |
1582
+ | **Consistent patterns** | Same keys do same things everywhere |
1583
+
1584
+ ### 8.2 Charm Libraries Usage
1585
+
1586
+ #### Bubbletea (Core TUI Framework)
1587
+
1588
+ ```go
1589
+ // Model-View-Update architecture
1590
+ type Model struct {
1591
+ state AppState
1592
+ viewport viewport.Model // Scrollable content
1593
+ spinner spinner.Model // Loading states
1594
+ list list.Model // Session/workspace lists
1595
+ form *huh.Form // Input forms
1596
+ statusBar StatusBar // Bottom status
1597
+ err error // Current error (if any)
1598
+ }
1599
+
1600
+ // Always return a command for smooth updates
1601
+ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1602
+ switch msg := msg.(type) {
1603
+ case tea.KeyMsg:
1604
+ switch msg.String() {
1605
+ case "q", "ctrl+c":
1606
+ return m, tea.Quit
1607
+ case "?":
1608
+ return m, showHelp // Help always accessible
1609
+ }
1610
+ case errMsg:
1611
+ m.err = msg.err
1612
+ return m, clearErrorAfter(5 * time.Second)
1613
+ }
1614
+ return m, nil
1615
+ }
1616
+ ```
1617
+
1618
+ #### Lipgloss (Styling)
1619
+
1620
+ Define a consistent style system:
1621
+
1622
+ ```go
1623
+ // internal/tui/styles.go
1624
+
1625
+ package tui
1626
+
1627
+ import "github.com/charmbracelet/lipgloss"
1628
+
1629
+ var (
1630
+ // Brand colors (from bepurple.ai)
1631
+ Purple = lipgloss.Color("#B794F6") // Primary purple - logo, headings
1632
+ PurpleLight = lipgloss.Color("#D4BFFF") // Light purple - hover states
1633
+ PurpleMuted = lipgloss.Color("#8B7AA8") // Muted purple - buttons, accents
1634
+ PurpleDark = lipgloss.Color("#6B5B8C") // Dark purple - borders
1635
+
1636
+ // Backgrounds (dark theme)
1637
+ BgDark = lipgloss.Color("#0D0B14") // Main background - deep purple-black
1638
+ BgCard = lipgloss.Color("#1A1625") // Card/terminal background
1639
+ BgElevated = lipgloss.Color("#252033") // Elevated surfaces
1640
+
1641
+ // Semantic colors
1642
+ Success = lipgloss.Color("#4ADE80") // Green - checkmarks, completion
1643
+ Warning = lipgloss.Color("#FBBF24") // Amber - in-progress, caution
1644
+ Error = lipgloss.Color("#F87171") // Red - errors, failures
1645
+ Muted = lipgloss.Color("#6B6378") // Gray-purple - secondary text
1646
+
1647
+ // Base styles
1648
+ Title = lipgloss.NewStyle().
1649
+ Foreground(Purple).
1650
+ Bold(true).
1651
+ MarginBottom(1)
1652
+
1653
+ Subtitle = lipgloss.NewStyle().
1654
+ Foreground(PurpleLight).
1655
+ Italic(true)
1656
+
1657
+ // Interactive elements
1658
+ Selected = lipgloss.NewStyle().
1659
+ Foreground(lipgloss.Color("#FFFFFF")).
1660
+ Background(Purple).
1661
+ Padding(0, 1)
1662
+
1663
+ Unselected = lipgloss.NewStyle().
1664
+ Foreground(Muted).
1665
+ Padding(0, 1)
1666
+
1667
+ // Status indicators
1668
+ StatusSuccess = lipgloss.NewStyle().
1669
+ Foreground(Success).
1670
+ SetString("✓")
1671
+
1672
+ StatusError = lipgloss.NewStyle().
1673
+ Foreground(Error).
1674
+ SetString("✗")
1675
+
1676
+ StatusPending = lipgloss.NewStyle().
1677
+ Foreground(Warning).
1678
+ SetString("○")
1679
+
1680
+ // Containers
1681
+ Box = lipgloss.NewStyle().
1682
+ Border(lipgloss.RoundedBorder()).
1683
+ BorderForeground(Purple).
1684
+ Padding(1, 2)
1685
+
1686
+ // Error display
1687
+ ErrorBox = lipgloss.NewStyle().
1688
+ Border(lipgloss.RoundedBorder()).
1689
+ BorderForeground(Error).
1690
+ Foreground(Error).
1691
+ Padding(0, 1).
1692
+ MarginTop(1)
1693
+ )
1694
+ ```
1695
+
1696
+ #### Huh (Forms & Prompts)
1697
+
1698
+ ```go
1699
+ // Styled, accessible forms
1700
+ func loginForm() *huh.Form {
1701
+ return huh.NewForm(
1702
+ huh.NewGroup(
1703
+ huh.NewInput().
1704
+ Key("email").
1705
+ Title("Email").
1706
+ Placeholder("you@company.com").
1707
+ Validate(validateEmail),
1708
+
1709
+ huh.NewInput().
1710
+ Key("password").
1711
+ Title("Password").
1712
+ EchoMode(huh.EchoModePassword),
1713
+ ),
1714
+ ).WithTheme(purpleTheme())
1715
+ }
1716
+
1717
+ // Custom theme matching our brand
1718
+ func purpleTheme() *huh.Theme {
1719
+ t := huh.ThemeBase()
1720
+ t.Focused.Title = t.Focused.Title.Foreground(Purple)
1721
+ t.Focused.Base = t.Focused.Base.BorderForeground(Purple)
1722
+ return t
1723
+ }
1724
+ ```
1725
+
1726
+ ### 8.3 UX Patterns
1727
+
1728
+ #### Loading States
1729
+
1730
+ Never leave the user wondering. Show spinners with context:
1731
+
1732
+ ```go
1733
+ func (m Model) viewLoading() string {
1734
+ return lipgloss.JoinVertical(
1735
+ lipgloss.Center,
1736
+ m.spinner.View(),
1737
+ lipgloss.NewStyle().Foreground(Muted).Render(m.loadingMessage),
1738
+ )
1739
+ }
1740
+
1741
+ // Good loading messages:
1742
+ // "Syncing standards..." (not "Loading...")
1743
+ // "Cloning react-app (2/3)..." (not "Please wait...")
1744
+ // "Authenticating..." (not "Working...")
1745
+ ```
1746
+
1747
+ #### Progress Indicators
1748
+
1749
+ For multi-step operations:
1750
+
1751
+ ```go
1752
+ func renderProgress(current, total int, items []string) string {
1753
+ var b strings.Builder
1754
+
1755
+ // Progress bar
1756
+ pct := float64(current) / float64(total)
1757
+ filled := int(pct * 20)
1758
+ bar := strings.Repeat("█", filled) + strings.Repeat("░", 20-filled)
1759
+ b.WriteString(fmt.Sprintf("[%s] %d/%d\n\n", bar, current, total))
1760
+
1761
+ // Item status
1762
+ for i, item := range items {
1763
+ if i < current {
1764
+ b.WriteString(StatusSuccess.Render() + " " + item + "\n")
1765
+ } else if i == current {
1766
+ b.WriteString(m.spinner.View() + " " + item + "\n")
1767
+ } else {
1768
+ b.WriteString(StatusPending.Render() + " " + Muted.Render(item) + "\n")
1769
+ }
1770
+ }
1771
+
1772
+ return Box.Render(b.String())
1773
+ }
1774
+ ```
1775
+
1776
+ #### Empty States
1777
+
1778
+ Never show a blank screen:
1779
+
1780
+ ```go
1781
+ func (m Model) viewEmptySessions() string {
1782
+ return lipgloss.JoinVertical(
1783
+ lipgloss.Center,
1784
+ "\n",
1785
+ lipgloss.NewStyle().Foreground(Muted).Render("No sessions yet"),
1786
+ "\n",
1787
+ lipgloss.NewStyle().Foreground(Purple).Render("Press 'n' to start a new session"),
1788
+ lipgloss.NewStyle().Foreground(Muted).Render("or 'q' to quit"),
1789
+ )
1790
+ }
1791
+ ```
1792
+
1793
+ #### Error Display
1794
+
1795
+ Errors should be helpful, not scary:
1796
+
1797
+ ```go
1798
+ func renderError(err error, suggestion string) string {
1799
+ return ErrorBox.Render(
1800
+ lipgloss.JoinVertical(
1801
+ lipgloss.Left,
1802
+ lipgloss.NewStyle().Bold(true).Render("Something went wrong"),
1803
+ "",
1804
+ err.Error(),
1805
+ "",
1806
+ lipgloss.NewStyle().Foreground(Muted).Render("💡 "+suggestion),
1807
+ ),
1808
+ )
1809
+ }
1810
+
1811
+ // Usage:
1812
+ renderError(
1813
+ errors.New("Failed to clone repository"),
1814
+ "Check that you have access to this repo and try again",
1815
+ )
1816
+ ```
1817
+
1818
+ #### Keyboard Navigation
1819
+
1820
+ Consistent across all views:
1821
+
1822
+ ```go
1823
+ // internal/tui/keys.go
1824
+
1825
+ var GlobalKeys = map[string]string{
1826
+ "q": "Quit",
1827
+ "?": "Help",
1828
+ "esc": "Back / Cancel",
1829
+ "enter": "Select / Confirm",
1830
+ "tab": "Next field",
1831
+ "shift+tab": "Previous field",
1832
+ }
1833
+
1834
+ var ListKeys = map[string]string{
1835
+ "j/↓": "Move down",
1836
+ "k/↑": "Move up",
1837
+ "g": "Go to top",
1838
+ "G": "Go to bottom",
1839
+ "/": "Search / Filter",
1840
+ }
1841
+
1842
+ // Show contextual help footer
1843
+ func renderHelpFooter(keys map[string]string) string {
1844
+ var parts []string
1845
+ for key, desc := range keys {
1846
+ parts = append(parts, Muted.Render(key)+" "+desc)
1847
+ }
1848
+ return lipgloss.NewStyle().
1849
+ Foreground(Muted).
1850
+ MarginTop(1).
1851
+ Render(strings.Join(parts, " • "))
1852
+ }
1853
+ ```
1854
+
1855
+ ### 8.4 Animation & Transitions
1856
+
1857
+ Use subtle animations to make the UI feel alive:
1858
+
1859
+ ```go
1860
+ // Spinner options (pick one style, use consistently)
1861
+ var SpinnerStyle = spinner.Dot // ⣾⣽⣻⢿⡿⣟⣯⣷
1862
+
1863
+ // Smooth list scrolling
1864
+ list.NewModel(items, delegate, width, height).
1865
+ SetShowHelp(false).
1866
+ SetFilteringEnabled(true).
1867
+ SetShowStatusBar(true)
1868
+
1869
+ // Viewport for scrollable content
1870
+ viewport.New(width, height).
1871
+ SetContent(longContent).
1872
+ GotoTop()
1873
+ ```
1874
+
1875
+ ### 8.5 Accessibility
1876
+
1877
+ - **High contrast**: All text readable on dark/light terminals
1878
+ - **No color-only indicators**: Icons + color (✓ green, ✗ red)
1879
+ - **Keyboard-first**: Everything usable without mouse
1880
+ - **Screen reader friendly**: Logical content order
1881
+
1882
+ ### 8.6 Reference Implementations
1883
+
1884
+ Study these Charm-built tools for inspiration:
1885
+
1886
+ | Tool | What to learn |
1887
+ |------|---------------|
1888
+ | [gum](https://github.com/charmbracelet/gum) | Simple, focused interactions |
1889
+ | [soft-serve](https://github.com/charmbracelet/soft-serve) | Complex multi-view app |
1890
+ | [glow](https://github.com/charmbracelet/glow) | Beautiful markdown rendering |
1891
+ | [lazygit](https://github.com/jesseduffield/lazygit) | Complex TUI with panels |
1892
+ | [gh-dash](https://github.com/dlvhdr/gh-dash) | Dashboard-style layout |
1893
+
1894
+ ---
1895
+
1896
+ ## 9. Distribution
1897
+
1898
+ **Single command install**: `npx purple-ai` includes everything:
1899
+ - Go binary (platform-specific)
1900
+ - MCP server (TypeScript/Node.js, bundled)
1901
+ - No separate installation steps required
1902
+
1903
+ ### 9.1 Build Targets
1904
+
1905
+ ```makefile
1906
+ PLATFORMS := darwin-arm64 darwin-amd64 linux-amd64
1907
+
1908
+ build-all:
1909
+ for platform in $(PLATFORMS); do \
1910
+ GOOS=$$(echo $$platform | cut -d- -f1) \
1911
+ GOARCH=$$(echo $$platform | cut -d- -f2) \
1912
+ go build -o dist/purple-$$platform ./cmd/purple; \
1913
+ done
1914
+ ```
1915
+
1916
+ ### 9.2 npm Wrapper Structure
1917
+
1918
+ ```
1919
+ npm/
1920
+ ├── package.json
1921
+ ├── bin/
1922
+ │ └── purple-ai.js # Entry point
1923
+ └── postinstall.js # Downloads correct binary
1924
+ ```
1925
+
1926
+ **package.json:**
1927
+ ```json
1928
+ {
1929
+ "name": "purple-ai",
1930
+ "version": "1.0.0",
1931
+ "bin": {
1932
+ "purple-ai": "bin/purple-ai.js"
1933
+ },
1934
+ "scripts": {
1935
+ "postinstall": "node postinstall.js"
1936
+ },
1937
+ "os": ["darwin", "linux"],
1938
+ "cpu": ["x64", "arm64"]
1939
+ }
1940
+ ```
1941
+
1942
+ **bin/purple-ai.js:**
1943
+ ```javascript
1944
+ #!/usr/bin/env node
1945
+ const { spawn } = require('child_process');
1946
+ const path = require('path');
1947
+
1948
+ const platform = `${process.platform}-${process.arch}`;
1949
+ const binaryPath = path.join(__dirname, '..', 'bin', `purple-${platform}`);
1950
+
1951
+ const child = spawn(binaryPath, process.argv.slice(2), {
1952
+ stdio: 'inherit'
1953
+ });
1954
+
1955
+ child.on('exit', (code) => process.exit(code));
1956
+ ```
1957
+
1958
+ ### 9.3 Version Check & Auto-Update
1959
+
1960
+ On workspace entry (not every launch):
1961
+ ```go
1962
+ func checkForUpdates() {
1963
+ // Future: GET /api/v1/cli/version endpoint
1964
+ // For now, skip update check
1965
+ }
1966
+ ```
1967
+
1968
+ ---
1969
+
1970
+ ## 10. Security
1971
+
1972
+ ### 10.1 Token Storage
1973
+
1974
+ **Primary: OS Keychain**
1975
+ ```go
1976
+ import "github.com/zalando/go-keyring"
1977
+
1978
+ func storeTokens(access, refresh string) error {
1979
+ if err := keyring.Set("purple-ai", "access_token", access); err != nil {
1980
+ return err
1981
+ }
1982
+ return keyring.Set("purple-ai", "refresh_token", refresh)
1983
+ }
1984
+
1985
+ func loadTokens() (access, refresh string, err error) {
1986
+ access, err = keyring.Get("purple-ai", "access_token")
1987
+ if err != nil {
1988
+ return "", "", err
1989
+ }
1990
+ refresh, err = keyring.Get("purple-ai", "refresh_token")
1991
+ return
1992
+ }
1993
+
1994
+ func clearTokens() error {
1995
+ keyring.Delete("purple-ai", "access_token")
1996
+ keyring.Delete("purple-ai", "refresh_token")
1997
+ return nil
1998
+ }
1999
+ ```
2000
+
2001
+ **Fallback: Encrypted file** (if keychain unavailable)
2002
+ ```
2003
+ ~/.purple/.credentials (chmod 600)
2004
+ Encrypted with machine-specific key derived from:
2005
+ - Machine UUID
2006
+ - User ID
2007
+ ```
2008
+
2009
+ ### 10.2 Git Credential Handling
2010
+
2011
+ - Never store git credentials
2012
+ - Use system git, which uses system credential helper
2013
+ - For `gh` CLI, user must have already run `gh auth login`
2014
+
2015
+ ---
2016
+
2017
+ ## 11. Error Handling
2018
+
2019
+ **Principle: Never crash the terminal process**
2020
+
2021
+ ```go
2022
+ func handleError(err error) {
2023
+ switch {
2024
+ case errors.Is(err, ErrNoConnection):
2025
+ showErrorView("No internet connection. Please check your network.")
2026
+
2027
+ case errors.Is(err, ErrAuthExpired):
2028
+ // Attempt refresh, if fails show login
2029
+ if refreshErr := refreshAuth(); refreshErr != nil {
2030
+ showLoginView()
2031
+ }
2032
+
2033
+ case errors.Is(err, ErrUnauthorized):
2034
+ showLoginView()
2035
+
2036
+ case errors.Is(err, ErrClaudeNotFound):
2037
+ showErrorView("Claude Code not found. Please install it first.")
2038
+ showInstallInstructions()
2039
+
2040
+ case errors.Is(err, ErrGitCloneFailed):
2041
+ showErrorView(fmt.Sprintf("Failed to clone repo: %v", err))
2042
+ offerRetryOrSkip()
2043
+
2044
+ case errors.Is(err, ErrWorkspaceNotFound):
2045
+ // Workspace deleted from backend
2046
+ showErrorView("This workspace no longer exists.")
2047
+ offerCreateNew()
2048
+
2049
+ default:
2050
+ showErrorView(fmt.Sprintf("An error occurred: %v", err))
2051
+ offerReturnToMenu()
2052
+ }
2053
+ }
2054
+ ```
2055
+
2056
+ **Offline Behavior:**
2057
+ - On any network call failure, show: "No internet connection"
2058
+ - Do not attempt offline mode or caching
2059
+ - User must have connection to use Purple
2060
+
2061
+ ---
2062
+
2063
+ ## 12. Platform Support
2064
+
2065
+ | Platform | Architecture | Status |
2066
+ |----------|--------------|--------|
2067
+ | macOS | arm64 (M1/M2/M3) | Supported |
2068
+ | macOS | x64 (Intel) | Supported |
2069
+ | Linux | x64 | Supported |
2070
+ | Linux | arm64 | Not in v1 |
2071
+ | Windows | any | Not in v1 (use WSL) |
2072
+
2073
+ **Claude Code Detection:**
2074
+ ```go
2075
+ func detectClaude() (string, error) {
2076
+ path, err := exec.LookPath("claude")
2077
+ if err != nil {
2078
+ return "", ErrClaudeNotFound
2079
+ }
2080
+ return path, nil
2081
+ }
2082
+ ```
2083
+
2084
+ No minimum Claude Code version enforced in v1.
2085
+
2086
+ ---
2087
+
2088
+ ## 13. Future Considerations (Out of Scope for v1)
2089
+
2090
+ - Windows native support
2091
+ - Offline mode with sync
2092
+ - Custom plugin marketplace
2093
+ - Workspace templates
2094
+ - Real-time collaboration
2095
+ - Session sync to backend
2096
+ - IDE extensions (VS Code, JetBrains)
2097
+
2098
+ ---
2099
+
2100
+ ## Appendix A: Current Implementation Reference
2101
+
2102
+ The existing Node/TS implementation in `purple-test/src/` demonstrates:
2103
+
2104
+ - **PTY spawning**: `src/claude/pty-spawn.ts` - Use `creack/pty` equivalent in Go
2105
+ - **Status bar**: `src/ui/status-bar.ts` - Lipgloss equivalent
2106
+ - **MCP server**: `mcp-server/src/index.ts` - Keep as-is or rewrite in Go
2107
+ - **Auth flow**: `src/auth/` - Reference for token storage patterns
2108
+ - **Live flow**: `src/live/index.ts` - Reference for startup sequence
2109
+
2110
+ ---
2111
+
2112
+ ## Appendix B: Key Dependencies (Go)
2113
+
2114
+ ```go
2115
+ require (
2116
+ github.com/charmbracelet/bubbletea v0.25.0
2117
+ github.com/charmbracelet/huh v0.3.0
2118
+ github.com/charmbracelet/lipgloss v0.9.0
2119
+ github.com/creack/pty v1.1.21
2120
+ github.com/zalando/go-keyring v0.2.3
2121
+ github.com/google/uuid v1.6.0
2122
+ golang.org/x/sync v0.6.0 // for errgroup
2123
+ )
2124
+ ```
2125
+
2126
+ ---
2127
+
2128
+ ## Appendix C: Data Flow Summary
2129
+
2130
+ ```
2131
+ ┌─────────────────────────────────────────────────────────────┐
2132
+ │ PURPLE BACKEND │
2133
+ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
2134
+ │ │ User │ │ Team │ │Workspace │ │ Plugin │ │
2135
+ │ └────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ │
2136
+ │ │ │ │ │ │
2137
+ │ │ │ ┌─────┴─────┐ │ │
2138
+ │ │ │ │ │ │ │
2139
+ │ │ │ ┌──┴──┐ ┌───┴───┐ │ │
2140
+ │ │ │ │Repo │ │Standard│ │ │
2141
+ │ │ │ └─────┘ └───────┘ │ │
2142
+ └───────┼───────────┼─────────────────────────────────────────┘
2143
+ │ │ │ │
2144
+ ▼ ▼ ▼ ▼
2145
+ ┌─────────────────────────────────────────────────────────────┐
2146
+ │ PURPLE CLI │
2147
+ │ │
2148
+ │ ~/.purple/ {workspace}/ │
2149
+ │ └─ (theme only) ├─ purple.json (workspace_id only) │
2150
+ │ ├─ repos/ (cloned) │
2151
+ │ Keychain: ├─ .claude/ (from plugins) │
2152
+ │ └─ tokens └─ workbench/ │
2153
+ │ ├─ standards/ (synced from API) │
2154
+ │ └─ documentation/ (LOCAL ONLY) │
2155
+ │ ├─ user-originals/ │
2156
+ │ ├─ {feature-folders}/ │
2157
+ │ └─ archived/ │
2158
+ └─────────────────────────────────────────────────────────────┘
2159
+ ```
2160
+
2161
+ ---
2162
+
2163
+ ## Appendix D: Behavioral Specifications
2164
+
2165
+ Detailed decisions for edge cases and UX behaviors.
2166
+
2167
+ ### D.1 Command Palette (Ctrl+K)
2168
+
2169
+ Available commands while in Claude Code session:
2170
+ - **Return to Purple** - Exit Claude, return to session list
2171
+ - **All /commands** - Dynamically loaded from `.claude/commands/` folder
2172
+ - Note: Session-specific commands like archive/delete not available mid-session
2173
+
2174
+ ### D.2 Repository Selection
2175
+
2176
+ **When gh CLI is available:**
2177
+ - List repos filtered by organization
2178
+ - Multi-select with checkboxes
2179
+ - Option to enter manual URL for unlisted repos
2180
+
2181
+ **When gh CLI is unavailable:**
2182
+ - Show message: "GitHub CLI not found. Enter repository URLs manually."
2183
+ - Text input for git URL (validates format)
2184
+ - Support both HTTPS and SSH URLs
2185
+
2186
+ ### D.3 Empty Workspaces
2187
+
2188
+ - Workspaces can be created with zero repositories
2189
+ - User can add repos later via workspace settings
2190
+ - Sessions cannot be started without at least one repo (show helpful message)
2191
+
2192
+ ### D.4 Session Archiving
2193
+
2194
+ ```
2195
+ Archive action:
2196
+ 1. Move folder from workbench/documentation/{session}/
2197
+ to workbench/documentation/archived/{session}/
2198
+ 2. Session no longer appears in main list
2199
+ 3. Archived sessions viewable via "Show archived" toggle
2200
+ ```
2201
+
2202
+ ### D.5 Hook Registration for Session End
2203
+
2204
+ Purple registers a hook in Claude Code's settings to signal session completion:
2205
+
2206
+ ```json
2207
+ // ~/.claude.json (managed by Purple)
2208
+ {
2209
+ "hooks": {
2210
+ "Stop": [
2211
+ {
2212
+ "type": "command",
2213
+ "command": "curl -s -X POST http://127.0.0.1:7685/session-end"
2214
+ }
2215
+ ]
2216
+ },
2217
+ "mcpServers": {
2218
+ "purple-status": {
2219
+ "command": "node",
2220
+ "args": ["/path/to/mcp-server/dist/index.js"]
2221
+ }
2222
+ }
2223
+ }
2224
+ ```
2225
+
2226
+ ### D.6 Standards Generation Failure
2227
+
2228
+ If `/create-standards` fails or produces poor results, offer:
2229
+ 1. **Re-run** - Run `/create-standards` again with fresh context
2230
+ 2. **Manual edit** - Open `workbench/standards/` in system editor
2231
+ 3. **Skip** - Continue without standards (not recommended, show warning)
2232
+
2233
+ ### D.7 Plugin Version Check
2234
+
2235
+ On workspace open:
2236
+ ```go
2237
+ func checkPluginUpdates(installed []PluginSummary) error {
2238
+ available, _ := api.ListAvailablePlugins()
2239
+
2240
+ for _, inst := range installed {
2241
+ for _, avail := range available {
2242
+ if inst.ID == avail.ID && inst.Version < avail.Version {
2243
+ // Prompt: "Plugin {name} has an update. Install now?"
2244
+ promptPluginUpdate(inst, avail)
2245
+ }
2246
+ }
2247
+ }
2248
+ return nil
2249
+ }
2250
+ ```
2251
+
2252
+ ### D.8 Team Selection Flow
2253
+
2254
+ 1. User logs in / registers
2255
+ 2. If no workspace found in current directory:
2256
+ - Fetch user's teams via `GET /team`
2257
+ - If multiple teams: show team picker
2258
+ - If single team: auto-select
2259
+ 3. Then proceed to workspace creation or clone
2260
+
2261
+ ### D.9 Session List Display
2262
+
2263
+ ```
2264
+ ┌─────────────────────────────────────────────────────────────┐
2265
+ │ Sessions [s]ettings│
2266
+ ├─────────────────────────────────────────────────────────────┤
2267
+ │ > 260112-auth-feature ● active Jan 12, 2026 │
2268
+ │ 260110-dashboard ✓ done Jan 10, 2026 │
2269
+ │ 260108-api-refactor ✓ done Jan 8, 2026 │
2270
+ │ │
2271
+ │ [n] New session [o] Open Claude [a] Show archived │
2272
+ └─────────────────────────────────────────────────────────────┘
2273
+
2274
+ Status indicators:
2275
+ ● active (in-progress) - Yellow/Orange
2276
+ ✓ done (completed) - Green
2277
+ ○ new (just created) - Gray
2278
+ ```
2279
+
2280
+ ### D.10 Interactive Claude Sessions
2281
+
2282
+ When user selects "Open Claude" (interactive mode):
2283
+ - Auto-generate session name: `interactive-{YYMMDD}-{n}` (e.g., `interactive-260112-1`)
2284
+ - Creates minimal folder in documentation (for consistency)
2285
+ - No `/build-from-spec` command sent
2286
+ - User has full control of Claude session
2287
+
2288
+ ### D.11 Session Resume Verification
2289
+
2290
+ ```go
2291
+ func resumeSession(sessionName string) error {
2292
+ // Check if Claude session exists
2293
+ sessions, _ := listClaudeSessions() // claude --list-sessions
2294
+
2295
+ exists := false
2296
+ for _, s := range sessions {
2297
+ if s.Name == sessionName {
2298
+ exists = true
2299
+ break
2300
+ }
2301
+ }
2302
+
2303
+ if !exists {
2304
+ // Offer options:
2305
+ // 1. Start fresh session in same folder
2306
+ // 2. Return to session list
2307
+ return promptSessionNotFound(sessionName)
2308
+ }
2309
+
2310
+ return launchClaude("--resume", sessionName)
2311
+ }
2312
+ ```
2313
+
2314
+ ### D.12 Missing Repo Detection
2315
+
2316
+ On workspace open:
2317
+ ```go
2318
+ func verifyRepos(workspace *Workspace) error {
2319
+ repos, _ := api.ListRepos(workspace.ID)
2320
+
2321
+ for _, repo := range repos {
2322
+ localPath := filepath.Join(workspace.Path, "repos", repoName(repo.RemoteURL))
2323
+ if _, err := os.Stat(localPath); os.IsNotExist(err) {
2324
+ // Prompt: "Repository {name} is missing. Clone now?"
2325
+ if promptCloneMissing(repo) {
2326
+ cloneRepo(repo, localPath)
2327
+ }
2328
+ }
2329
+ }
2330
+ return nil
2331
+ }
2332
+ ```
2333
+
2334
+ ### D.13 Branch Selection
2335
+
2336
+ During repo selection:
2337
+ - Default branch is pre-selected (usually `main` or `master`)
2338
+ - User can click/expand to choose different branch
2339
+ - Branch stored in `Repo.defaultBranch` field in backend
2340
+
2341
+ ### D.14 Nested Workspace Detection
2342
+
2343
+ ```go
2344
+ func detectWorkspace(startDir string) (*WorkspaceMatch, error) {
2345
+ // Check current directory
2346
+ if exists(filepath.Join(startDir, "purple.json")) {
2347
+ return &WorkspaceMatch{Path: startDir, Type: "current"}, nil
2348
+ }
2349
+
2350
+ // Check parent directories
2351
+ parent := startDir
2352
+ for {
2353
+ parent = filepath.Dir(parent)
2354
+ if parent == "/" || parent == "." {
2355
+ break
2356
+ }
2357
+ if exists(filepath.Join(parent, "purple.json")) {
2358
+ // Found parent workspace - offer choice
2359
+ return &WorkspaceMatch{
2360
+ Path: parent,
2361
+ Type: "parent",
2362
+ Message: "You're inside an existing workspace. Open it or create nested?",
2363
+ }, nil
2364
+ }
2365
+ }
2366
+
2367
+ // Check subdirectories
2368
+ // ... scan for purple.json in subdirs
2369
+
2370
+ return nil, ErrNoWorkspaceFound
2371
+ }
2372
+ ```
2373
+
2374
+ ### D.15 Workspace Settings Menu
2375
+
2376
+ Accessible via `[s]` key from session list:
2377
+
2378
+ ```
2379
+ ┌─────────────────────────────────────────────────────────────┐
2380
+ │ Workspace Settings: my-project │
2381
+ ├─────────────────────────────────────────────────────────────┤
2382
+ │ > Rename workspace │
2383
+ │ Add repository │
2384
+ │ Remove repository │
2385
+ │ ─────────────────── │
2386
+ │ Manage plugins │
2387
+ │ Re-run /create-standards │
2388
+ │ Sync standards from team │
2389
+ │ ─────────────────── │
2390
+ │ View workspace info │
2391
+ │ │
2392
+ │ [esc] Back │
2393
+ └─────────────────────────────────────────────────────────────┘
2394
+ ```
2395
+
2396
+ ### D.16 Status Bar Default State
2397
+
2398
+ Before any MCP updates received:
2399
+ ```
2400
+ ┌─────────────────────────────────────────────────────────────┐
2401
+ │ Phase: Starting │ Agent: - │ Mode: plan │
2402
+ └─────────────────────────────────────────────────────────────┘
2403
+ ```
2404
+
2405
+ ### D.17 Logout Behavior
2406
+
2407
+ ```go
2408
+ func logout() error {
2409
+ // Clear tokens from keychain
2410
+ clearTokens()
2411
+
2412
+ // Keep local files:
2413
+ // - ~/.purple/settings.json (theme preference)
2414
+ // - All workspace purple.json files
2415
+ // - All cloned repos and documentation
2416
+
2417
+ // Show login screen
2418
+ return showLoginView()
2419
+ }
2420
+ ```
2421
+
2422
+ ### D.18 Spec File Handling
2423
+
2424
+ **Detection:**
2425
+ - Any `.md` file in `workbench/documentation/user-originals/` is a potential spec
2426
+
2427
+ **New spec creation:**
2428
+ ```go
2429
+ func createNewSpec(name string) (string, error) {
2430
+ template := `# Feature: {name}
2431
+
2432
+ ## Overview
2433
+ [Describe what this feature does]
2434
+
2435
+ ## User Stories
2436
+ - As a [user type], I want [goal] so that [benefit]
2437
+
2438
+ ## Requirements
2439
+ - [ ] Requirement 1
2440
+ - [ ] Requirement 2
2441
+
2442
+ ## Out of Scope
2443
+ - Not included in this feature
2444
+
2445
+ ## Open Questions
2446
+ - Any uncertainties to resolve?
2447
+ `
2448
+
2449
+ filename := slugify(name) + ".md"
2450
+ path := filepath.Join(workspace, "workbench/documentation/user-originals", filename)
2451
+
2452
+ content := strings.Replace(template, "{name}", name, -1)
2453
+ return path, os.WriteFile(path, []byte(content), 0644)
2454
+ }
2455
+ ```
2456
+
2457
+ ### D.19 Clone Progress Display
2458
+
2459
+ ```
2460
+ ┌─────────────────────────────────────────────────────────────┐
2461
+ │ Cloning repositories... │
2462
+ ├─────────────────────────────────────────────────────────────┤
2463
+ │ ✓ org/react-app │
2464
+ │ ✓ org/api-server │
2465
+ │ ⟳ org/shared-lib [=========== ] 67% │
2466
+ │ ○ org/mobile-app │
2467
+ │ ○ org/docs │
2468
+ │ │
2469
+ │ 3/5 repositories cloned │
2470
+ └─────────────────────────────────────────────────────────────┘
2471
+ ```
2472
+
2473
+ ### D.20 Confirmation Prompts
2474
+
2475
+ Required confirmations:
2476
+ - **Delete session** - "Permanently delete '{name}'? This cannot be undone."
2477
+ - **Archive session** - "Archive '{name}'? You can restore it later."
2478
+ - **Remove repository** - "Remove '{repo}' from workspace? Local files will be kept."
2479
+ - **Logout** - "Log out of Purple? Your workspaces will remain on this machine."
2480
+
2481
+ ### D.21 No Claude Code Installed
2482
+
2483
+ ```
2484
+ ┌─────────────────────────────────────────────────────────────┐
2485
+ │ ✗ Claude Code not found │
2486
+ ├─────────────────────────────────────────────────────────────┤
2487
+ │ │
2488
+ │ Purple requires Claude Code to be installed. │
2489
+ │ │
2490
+ │ Install with: │
2491
+ │ npm install -g @anthropic/claude-code │
2492
+ │ │
2493
+ │ Or visit: https://claude.ai/code │
2494
+ │ │
2495
+ │ [Press any key to exit] │
2496
+ └─────────────────────────────────────────────────────────────┘
2497
+ ```
2498
+
2499
+ ### D.22 Workspace Rename
2500
+
2501
+ ```go
2502
+ func renameWorkspace(workspaceID, newName string) error {
2503
+ // Update backend
2504
+ _, err := api.UpdateWorkspace(workspaceID, newName)
2505
+ if err != nil {
2506
+ return err
2507
+ }
2508
+
2509
+ // Local purple.json doesn't store name, only ID
2510
+ // So no local file changes needed
2511
+
2512
+ return nil
2513
+ }
2514
+ ```