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.
- package/.playwright-mcp/page-2026-01-13T02-10-06-846Z.png +0 -0
- package/bin/darwin-arm64/purple +0 -0
- package/bin/darwin-x64/purple +0 -0
- package/bin/linux-x64/purple +0 -0
- package/bin/purple.js +48 -1
- package/package.json +31 -5
- package/purple-CLIent technical.md +19 -0
- package/purple-CLIent-product-spec.md +513 -0
- package/purple-CLIent-technical-PRD.md +2514 -0
- package/purple-CLIent.md +84 -0
- package/purple_API_definition.md +546 -0
|
@@ -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
|
+
```
|