-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Svelte 5 command dashboard with real-time WebSocket updates #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d91012d
f179fd6
3d1c66f
959b970
af59732
6826df5
8d73d53
3d8d88d
78f1afb
88298a7
c051e3e
d35d129
ee09bc2
76d8eda
d55914c
4366974
8e50d44
8db1431
b8d919b
0efa51e
e717988
25ddcca
fbe3587
22d69b2
ca75c15
f07b823
e1245a9
3ce18d3
25f4871
7bc5960
5d07e72
30e9673
62d5cc1
8bfd4d8
ef69faa
964c5c3
9fd0a73
4c59fcd
7019cba
942726a
cb7e33b
4b794b6
278095a
19b5798
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"sessionId":"0bcd5a26-d987-422f-baaa-4fab1b70762e","pid":58700,"acquiredAt":1773024431187} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "mcpServers": { | ||
| "shire": { | ||
| "args": [ | ||
| "serve" | ||
| ], | ||
| "command": "shire" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| package dashboard | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "net/http" | ||
| "time" | ||
| ) | ||
|
|
||
| type SpawnQuestRequest struct { | ||
| Task string `json:"task"` | ||
| Branch string `json:"branch,omitempty"` | ||
| Company string `json:"company,omitempty"` | ||
| } | ||
|
|
||
| type SpawnScoutRequest struct { | ||
| Question string `json:"question"` | ||
| } | ||
|
|
||
| type QuestIDRequest struct { | ||
| QuestID string `json:"quest_id"` | ||
| } | ||
|
|
||
| type QueuedResponse struct { | ||
| Queued bool `json:"queued"` | ||
| CommandID string `json:"command_id"` | ||
| } | ||
|
|
||
| func (s *Server) handleSpawnQuest(w http.ResponseWriter, r *http.Request) { | ||
| var req SpawnQuestRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| http.Error(w, "invalid request body", http.StatusBadRequest) | ||
| return | ||
| } | ||
| if req.Task == "" { | ||
| http.Error(w, "task is required", http.StatusBadRequest) | ||
| return | ||
| } | ||
| params, _ := json.Marshal(req) | ||
| cmd, err := EnqueueCommand(s.gitRoot, ActionSpawnQuest, params) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()}) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID}) | ||
| } | ||
|
|
||
| func (s *Server) handleSpawnScout(w http.ResponseWriter, r *http.Request) { | ||
| var req SpawnScoutRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| http.Error(w, "invalid request body", http.StatusBadRequest) | ||
| return | ||
| } | ||
| if req.Question == "" { | ||
| http.Error(w, "question is required", http.StatusBadRequest) | ||
| return | ||
| } | ||
| params, _ := json.Marshal(req) | ||
| cmd, err := EnqueueCommand(s.gitRoot, ActionSpawnScout, params) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()}) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID}) | ||
| } | ||
|
|
||
| func (s *Server) handleKillQuest(w http.ResponseWriter, r *http.Request) { | ||
| var req QuestIDRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| http.Error(w, "invalid request body", http.StatusBadRequest) | ||
| return | ||
| } | ||
| if req.QuestID == "" { | ||
| http.Error(w, "quest_id is required", http.StatusBadRequest) | ||
| return | ||
| } | ||
| params, _ := json.Marshal(req) | ||
| cmd, err := EnqueueCommand(s.gitRoot, ActionKillQuest, params) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()}) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID}) | ||
| } | ||
|
|
||
| func (s *Server) handleRestartQuest(w http.ResponseWriter, r *http.Request) { | ||
| var req QuestIDRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| http.Error(w, "invalid request body", http.StatusBadRequest) | ||
| return | ||
| } | ||
| if req.QuestID == "" { | ||
| http.Error(w, "quest_id is required", http.StatusBadRequest) | ||
| return | ||
| } | ||
| params, _ := json.Marshal(req) | ||
| cmd, err := EnqueueCommand(s.gitRoot, ActionRestartQuest, params) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()}) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID}) | ||
| } | ||
|
|
||
| func (s *Server) handleCommands(w http.ResponseWriter, r *http.Request) { | ||
| q, err := LoadCommandQueue(s.gitRoot) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(q.Commands) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| package dashboard | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "github.com/justinjdev/fellowship/cli/internal/autopsy" | ||
| "github.com/justinjdev/fellowship/cli/internal/db" | ||
| "github.com/justinjdev/fellowship/cli/internal/tome" | ||
| ) | ||
|
|
||
| // GET /api/autopsies — list all autopsy records | ||
| // GET /api/autopsies/<filename> — single autopsy detail | ||
| func (s *Server) handleAutopsies(w http.ResponseWriter, r *http.Request) { | ||
| suffix := strings.TrimPrefix(r.URL.Path, "/api/autopsies") | ||
| suffix = strings.TrimPrefix(suffix, "/") | ||
|
|
||
| // Individual autopsy lookup not supported via SQLite API; scan all and filter. | ||
| var records []autopsy.Autopsy | ||
| err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { | ||
| var loadErr error | ||
| records, loadErr = autopsy.Scan(conn, autopsy.ScanOptions{}, autopsy.DefaultExpiryDays) | ||
| return loadErr | ||
| }) | ||
|
|
||
| if err != nil { | ||
| s.logError("api", "handleAutopsies", err.Error()) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode([]interface{}{}) | ||
| return | ||
| } | ||
|
Comment on lines
+30
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't collapse database failures into empty success responses. These branches turn read failures into Also applies to: 69-77 |
||
| if suffix != "" { | ||
| // Filter to matching record by quest name | ||
| for _, r := range records { | ||
| if r.Quest == suffix { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(r) | ||
| return | ||
| } | ||
| } | ||
| http.Error(w, "autopsy not found", http.StatusNotFound) | ||
| return | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if records == nil { | ||
| records = []autopsy.Autopsy{} | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(records) | ||
| } | ||
|
|
||
| // GET /api/tome/<questName> — quest tome (CV) | ||
| func (s *Server) handleTome(w http.ResponseWriter, r *http.Request) { | ||
| questName := strings.TrimPrefix(r.URL.Path, "/api/tome/") | ||
| if questName == "" { | ||
| http.Error(w, "quest name required", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| var t *tome.QuestTome | ||
| err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { | ||
| var loadErr error | ||
| t, loadErr = tome.Load(conn, questName) | ||
| return loadErr | ||
| }) | ||
| if err != nil || t == nil { | ||
| if err != nil { | ||
| s.logError("api", "handleTome", err.Error()) | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(map[string]interface{}{ | ||
| "quest_name": questName, | ||
| "phases_completed": []interface{}{}, | ||
| "gate_history": []interface{}{}, | ||
| "files_touched": []string{}, | ||
| }) | ||
| return | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(t) | ||
| } | ||
|
|
||
| // GET /api/config — read fellowship config | ||
| func (s *Server) handleConfigRead(w http.ResponseWriter, r *http.Request) { | ||
| result := map[string]interface{}{ | ||
| "global": nil, | ||
| "project": nil, | ||
| } | ||
|
|
||
| if home, err := os.UserHomeDir(); err == nil { | ||
| globalPath := filepath.Join(home, ".claude", "fellowship.json") | ||
| if data, err := os.ReadFile(globalPath); err == nil { | ||
| var global interface{} | ||
| if json.Unmarshal(data, &global) == nil { | ||
| result["global"] = global | ||
| } | ||
| } | ||
| } // silently skip global config if home directory unavailable | ||
|
|
||
| projectPath := filepath.Join(s.gitRoot, ".fellowship", "config.json") | ||
| if data, err := os.ReadFile(projectPath); err == nil { | ||
| var project interface{} | ||
| if json.Unmarshal(data, &project) == nil { | ||
| result["project"] = project | ||
| } | ||
| } | ||
|
|
||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(result) | ||
| } | ||
|
|
||
| type ConfigWriteRequest struct { | ||
| Key string `json:"key"` | ||
| Value interface{} `json:"value"` | ||
| Scope string `json:"scope"` | ||
| } | ||
|
|
||
| func (s *Server) handleConfigWrite(w http.ResponseWriter, r *http.Request) { | ||
| var req ConfigWriteRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| http.Error(w, "invalid request body", http.StatusBadRequest) | ||
| return | ||
| } | ||
| if req.Key == "" { | ||
| http.Error(w, "key is required", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| s.configMu.Lock() | ||
| defer s.configMu.Unlock() | ||
|
|
||
| var configPath string | ||
| switch req.Scope { | ||
| case "global": | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| http.Error(w, "unable to determine home directory", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| configPath = filepath.Join(home, ".claude", "fellowship.json") | ||
|
Comment on lines
+137
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the global config private on first write. For Also applies to: 151-161 |
||
| case "project": | ||
| configPath = filepath.Join(s.gitRoot, ".fellowship", "config.json") | ||
| default: | ||
| http.Error(w, "scope must be 'global' or 'project'", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| existing := make(map[string]interface{}) | ||
| if data, err := os.ReadFile(configPath); err == nil { | ||
| if err := json.Unmarshal(data, &existing); err != nil { | ||
| http.Error(w, "existing config file contains invalid JSON", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| } else if !os.IsNotExist(err) { | ||
| http.Error(w, "failed to read existing config", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| existing[req.Key] = req.Value | ||
|
|
||
| if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { | ||
| http.Error(w, "failed to create config directory", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| data, err := json.MarshalIndent(existing, "", " ") | ||
| if err != nil { | ||
| http.Error(w, "failed to marshal config", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| tmp := fmt.Sprintf("%s.tmp.%d", configPath, os.Getpid()) | ||
| if err := os.WriteFile(tmp, data, 0644); err != nil { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| http.Error(w, "failed to write config", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| if err := os.Rename(tmp, configPath); err != nil { | ||
| os.Remove(tmp) // best-effort cleanup | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| http.Error(w, "failed to save config", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode(map[string]bool{"ok": true}) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,5 +2,5 @@ package dashboard | |
|
|
||
| import "embed" | ||
|
|
||
| //go:embed static | ||
| //go:embed all:static | ||
| var staticFiles embed.FS | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handleAutopsiescalls a filter-only API with no filters.cli/internal/autopsy/autopsy.go:134-136rejectsautopsy.ScanOptions{}when files/modules/tags are all empty, but this handler always passes an empty options struct. That means/api/autopsiesfalls straight into the error branch and the new Autopsies view cannot load real data.