ScuttleBot

chore: Gemini review cleanup — apiclient tests, remove stale memory, drop empty dir - cmd/scuttlectl/internal/apiclient: full test suite against httptest.Server covering all endpoints: status, agents CRUD, channels, LLM backends, admins, bearer auth assertion, API error parsing - memory/: remove stale project_current_state.md and Kohakku references - cmd/relay-demo/: remove empty directory

lmata 2026-04-02 23:17 trunk
Commit 3ec70229ba3a249f614d3012fb5a18d60777421c8e26d08143ebf349d712fc9a
--- a/cmd/scuttlectl/internal/apiclient/apiclient_test.go
+++ b/cmd/scuttlectl/internal/apiclient/apiclient_test.go
@@ -0,0 +1,306 @@
1
+package apiclient_test
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+
9
+ "github.com/conflicthq/scuttlebot/cmd/scuttlectl/internal/apiclient"
10
+)
11
+
12
+func newServer(t *testing.T, handler http.Handler) (*httptest.Server, *apiclient.Client) {
13
+ t.Helper()
14
+ srv := httptest.NewServer(handler)
15
+ t.Cleanup(srv.Close)
16
+ return srv, apiclient.New(srv.URL, "test-token")
17
+}
18
+
19
+func TestStatus(t *testing.T) {
20
+ srv, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21
+ if r.URL.Path != "/v1/status" || r.Method != http.MethodGet {
22
+ http.NotFound(w, r)
23
+ return
24
+ }
25
+ assertBearer(t, r)
26
+ w.Header().Set("Content-Type", "application/json")
27
+ _, _ = w.Write([]byte(`{"status":"ok"}`))
28
+ }))
29
+ _ = srv
30
+
31
+ raw, err := client.Status()
32
+ if err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ var got map[string]string
36
+ if err := json.Unmarshal(raw, &got); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ if got["status"] != "ok" {
40
+ t.Errorf("status: got %q", got["status"])
41
+ }
42
+}
43
+
44
+func TestListAgents(t *testing.T) {
45
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46
+ assertBearer(t, r)
47
+ w.Header().Set("Content-Type", "application/json")
48
+ _, _ = w.Write([]byte(`{"agents":[{"nick":"claude-1"}]}`))
49
+ }))
50
+
51
+ raw, err := client.ListAgents()
52
+ if err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if len(raw) == 0 {
56
+ t.Error("expected non-empty response")
57
+ }
58
+}
59
+
60
+func TestGetAgent(t *testing.T) {
61
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62
+ assertBearer(t, r)
63
+ if r.URL.Path != "/v1/agents/claude-1" {
64
+ http.NotFound(w, r)
65
+ return
66
+ }
67
+ w.Header().Set("Content-Type", "application/json")
68
+ _, _ = w.Write([]byte(`{"nick":"claude-1","type":"worker"}`))
69
+ }))
70
+
71
+ raw, err := client.GetAgent("claude-1")
72
+ if err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ var got map[string]string
76
+ if err := json.Unmarshal(raw, &got); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if got["nick"] != "claude-1" {
80
+ t.Errorf("nick: got %q", got["nick"])
81
+ }
82
+}
83
+
84
+func TestRegisterAgent(t *testing.T) {
85
+ var gotBody map[string]any
86
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87
+ assertBearer(t, r)
88
+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
89
+ http.Error(w, err.Error(), http.StatusBadRequest)
90
+ return
91
+ }
92
+ w.Header().Set("Content-Type", "application/json")
93
+ w.WriteHeader(http.StatusCreated)
94
+ _, _ = w.Write([]byte(`{"nick":"claude-1","credentials":{"passphrase":"secret"}}`))
95
+ }))
96
+
97
+ raw, err := client.RegisterAgent("claude-1", "worker", []string{"#general"})
98
+ if err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ if raw == nil {
102
+ t.Error("expected response body")
103
+ }
104
+ if gotBody["nick"] != "claude-1" {
105
+ t.Errorf("body nick: got %v", gotBody["nick"])
106
+ }
107
+ if gotBody["type"] != "worker" {
108
+ t.Errorf("body type: got %v", gotBody["type"])
109
+ }
110
+}
111
+
112
+func TestRevokeAgent(t *testing.T) {
113
+ called := false
114
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115
+ assertBearer(t, r)
116
+ if r.URL.Path == "/v1/agents/claude-1/revoke" && r.Method == http.MethodPost {
117
+ called = true
118
+ w.Header().Set("Content-Type", "application/json")
119
+ _, _ = w.Write([]byte(`{}`))
120
+ } else {
121
+ http.NotFound(w, r)
122
+ }
123
+ }))
124
+
125
+ if err := client.RevokeAgent("claude-1"); err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ if !called {
129
+ t.Error("revoke endpoint not called")
130
+ }
131
+}
132
+
133
+func TestRotateAgent(t *testing.T) {
134
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135
+ assertBearer(t, r)
136
+ w.Header().Set("Content-Type", "application/json")
137
+ _, _ = w.Write([]byte(`{"passphrase":"newpass"}`))
138
+ }))
139
+
140
+ raw, err := client.RotateAgent("claude-1")
141
+ if err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ var got map[string]string
145
+ if err := json.Unmarshal(raw, &got); err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if got["passphrase"] != "newpass" {
149
+ t.Errorf("passphrase: got %q", got["passphrase"])
150
+ }
151
+}
152
+
153
+func TestDeleteAgent(t *testing.T) {
154
+ called := false
155
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
156
+ assertBearer(t, r)
157
+ if r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/claude-1" {
158
+ called = true
159
+ w.WriteHeader(http.StatusNoContent)
160
+ } else {
161
+ http.NotFound(w, r)
162
+ }
163
+ }))
164
+
165
+ if err := client.DeleteAgent("claude-1"); err != nil {
166
+ t.Fatal(err)
167
+ }
168
+ if !called {
169
+ t.Error("delete endpoint not called")
170
+ }
171
+}
172
+
173
+func TestListChannels(t *testing.T) {
174
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175
+ assertBearer(t, r)
176
+ w.Header().Set("Content-Type", "application/json")
177
+ _, _ = w.Write([]byte(`{"channels":["#general","#ops"]}`))
178
+ }))
179
+
180
+ raw, err := client.ListChannels()
181
+ if err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if len(raw) == 0 {
185
+ t.Error("expected non-empty response")
186
+ }
187
+}
188
+
189
+func TestDeleteChannel(t *testing.T) {
190
+ called := false
191
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192
+ assertBearer(t, r)
193
+ if r.Method == http.MethodDelete && r.URL.Path == "/v1/channels/general" {
194
+ called = true
195
+ w.WriteHeader(http.StatusNoContent)
196
+ } else {
197
+ http.NotFound(w, r)
198
+ }
199
+ }))
200
+
201
+ // should strip the leading #
202
+ if err := client.DeleteChannel("#general"); err != nil {
203
+ t.Fatal(err)
204
+ }
205
+ if !called {
206
+ t.Error("delete channel endpoint not called")
207
+ }
208
+}
209
+
210
+func TestGetLLMBackend(t *testing.T) {
211
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212
+ assertBearer(t, r)
213
+ w.Header().Set("Content-Type", "application/json")
214
+ _, _ = w.Write([]byte(`{"backends":[{"name":"anthropic","backend":"anthropic"},{"name":"ollama","backend":"ollama"}]}`))
215
+ }))
216
+
217
+ raw, err := client.GetLLMBackend("ollama")
218
+ if err != nil {
219
+ t.Fatal(err)
220
+ }
221
+ var got map[string]string
222
+ if err := json.Unmarshal(raw, &got); err != nil {
223
+ t.Fatal(err)
224
+ }
225
+ if got["name"] != "ollama" {
226
+ t.Errorf("name: got %q", got["name"])
227
+ }
228
+}
229
+
230
+func TestGetLLMBackendNotFound(t *testing.T) {
231
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232
+ assertBearer(t, r)
233
+ w.Header().Set("Content-Type", "application/json")
234
+ _, _ = w.Write([]byte(`{"backends":[]}`))
235
+ }))
236
+
237
+ _, err := client.GetLLMBackend("nonexistent")
238
+ if err == nil {
239
+ t.Error("expected error for missing backend, got nil")
240
+ }
241
+}
242
+
243
+func TestAPIError(t *testing.T) {
244
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245
+ w.Header().Set("Content-Type", "application/json")
246
+ w.WriteHeader(http.StatusUnauthorized)
247
+ _, _ = w.Write([]byte(`{"error":"invalid token"}`))
248
+ }))
249
+
250
+ _, err := client.Status()
251
+ if err == nil {
252
+ t.Fatal("expected error, got nil")
253
+ }
254
+ if err.Error() != "API error 401: invalid token" {
255
+ t.Errorf("error message: got %q", err.Error())
256
+ }
257
+}
258
+
259
+func TestAddAdmin(t *testing.T) {
260
+ var gotBody map[string]string
261
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262
+ assertBearer(t, r)
263
+ _ = json.NewDecoder(r.Body).Decode(&gotBody)
264
+ w.Header().Set("Content-Type", "application/json")
265
+ w.WriteHeader(http.StatusCreated)
266
+ _, _ = w.Write([]byte(`{"username":"alice"}`))
267
+ }))
268
+
269
+ raw, err := client.AddAdmin("alice", "hunter2")
270
+ if err != nil {
271
+ t.Fatal(err)
272
+ }
273
+ if raw == nil {
274
+ t.Error("expected response")
275
+ }
276
+ if gotBody["username"] != "alice" || gotBody["password"] != "hunter2" {
277
+ t.Errorf("body: got %v", gotBody)
278
+ }
279
+}
280
+
281
+func TestRemoveAdmin(t *testing.T) {
282
+ called := false
283
+ _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
284
+ assertBearer(t, r)
285
+ if r.Method == http.MethodDelete && r.URL.Path == "/v1/admins/alice" {
286
+ called = true
287
+ w.WriteHeader(http.StatusNoContent)
288
+ } else {
289
+ http.NotFound(w, r)
290
+ }
291
+ }))
292
+
293
+ if err := client.RemoveAdmin("alice"); err != nil {
294
+ t.Fatal(err)
295
+ }
296
+ if !called {
297
+ t.Error("remove admin endpoint not called")
298
+ }
299
+}
300
+
301
+func assertBearer(t *testing.T, r *http.Request) {
302
+ t.Helper()
303
+ if r.Header.Get("Authorization") != "Bearer test-token" {
304
+ t.Errorf("Authorization header: got %q", r.Header.Get("Authorization"))
305
+ }
306
+}
--- a/cmd/scuttlectl/internal/apiclient/apiclient_test.go
+++ b/cmd/scuttlectl/internal/apiclient/apiclient_test.go
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/cmd/scuttlectl/internal/apiclient/apiclient_test.go
+++ b/cmd/scuttlectl/internal/apiclient/apiclient_test.go
@@ -0,0 +1,306 @@
1 package apiclient_test
2
3 import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8
9 "github.com/conflicthq/scuttlebot/cmd/scuttlectl/internal/apiclient"
10 )
11
12 func newServer(t *testing.T, handler http.Handler) (*httptest.Server, *apiclient.Client) {
13 t.Helper()
14 srv := httptest.NewServer(handler)
15 t.Cleanup(srv.Close)
16 return srv, apiclient.New(srv.URL, "test-token")
17 }
18
19 func TestStatus(t *testing.T) {
20 srv, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 if r.URL.Path != "/v1/status" || r.Method != http.MethodGet {
22 http.NotFound(w, r)
23 return
24 }
25 assertBearer(t, r)
26 w.Header().Set("Content-Type", "application/json")
27 _, _ = w.Write([]byte(`{"status":"ok"}`))
28 }))
29 _ = srv
30
31 raw, err := client.Status()
32 if err != nil {
33 t.Fatal(err)
34 }
35 var got map[string]string
36 if err := json.Unmarshal(raw, &got); err != nil {
37 t.Fatal(err)
38 }
39 if got["status"] != "ok" {
40 t.Errorf("status: got %q", got["status"])
41 }
42 }
43
44 func TestListAgents(t *testing.T) {
45 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46 assertBearer(t, r)
47 w.Header().Set("Content-Type", "application/json")
48 _, _ = w.Write([]byte(`{"agents":[{"nick":"claude-1"}]}`))
49 }))
50
51 raw, err := client.ListAgents()
52 if err != nil {
53 t.Fatal(err)
54 }
55 if len(raw) == 0 {
56 t.Error("expected non-empty response")
57 }
58 }
59
60 func TestGetAgent(t *testing.T) {
61 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62 assertBearer(t, r)
63 if r.URL.Path != "/v1/agents/claude-1" {
64 http.NotFound(w, r)
65 return
66 }
67 w.Header().Set("Content-Type", "application/json")
68 _, _ = w.Write([]byte(`{"nick":"claude-1","type":"worker"}`))
69 }))
70
71 raw, err := client.GetAgent("claude-1")
72 if err != nil {
73 t.Fatal(err)
74 }
75 var got map[string]string
76 if err := json.Unmarshal(raw, &got); err != nil {
77 t.Fatal(err)
78 }
79 if got["nick"] != "claude-1" {
80 t.Errorf("nick: got %q", got["nick"])
81 }
82 }
83
84 func TestRegisterAgent(t *testing.T) {
85 var gotBody map[string]any
86 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 assertBearer(t, r)
88 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
89 http.Error(w, err.Error(), http.StatusBadRequest)
90 return
91 }
92 w.Header().Set("Content-Type", "application/json")
93 w.WriteHeader(http.StatusCreated)
94 _, _ = w.Write([]byte(`{"nick":"claude-1","credentials":{"passphrase":"secret"}}`))
95 }))
96
97 raw, err := client.RegisterAgent("claude-1", "worker", []string{"#general"})
98 if err != nil {
99 t.Fatal(err)
100 }
101 if raw == nil {
102 t.Error("expected response body")
103 }
104 if gotBody["nick"] != "claude-1" {
105 t.Errorf("body nick: got %v", gotBody["nick"])
106 }
107 if gotBody["type"] != "worker" {
108 t.Errorf("body type: got %v", gotBody["type"])
109 }
110 }
111
112 func TestRevokeAgent(t *testing.T) {
113 called := false
114 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115 assertBearer(t, r)
116 if r.URL.Path == "/v1/agents/claude-1/revoke" && r.Method == http.MethodPost {
117 called = true
118 w.Header().Set("Content-Type", "application/json")
119 _, _ = w.Write([]byte(`{}`))
120 } else {
121 http.NotFound(w, r)
122 }
123 }))
124
125 if err := client.RevokeAgent("claude-1"); err != nil {
126 t.Fatal(err)
127 }
128 if !called {
129 t.Error("revoke endpoint not called")
130 }
131 }
132
133 func TestRotateAgent(t *testing.T) {
134 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135 assertBearer(t, r)
136 w.Header().Set("Content-Type", "application/json")
137 _, _ = w.Write([]byte(`{"passphrase":"newpass"}`))
138 }))
139
140 raw, err := client.RotateAgent("claude-1")
141 if err != nil {
142 t.Fatal(err)
143 }
144 var got map[string]string
145 if err := json.Unmarshal(raw, &got); err != nil {
146 t.Fatal(err)
147 }
148 if got["passphrase"] != "newpass" {
149 t.Errorf("passphrase: got %q", got["passphrase"])
150 }
151 }
152
153 func TestDeleteAgent(t *testing.T) {
154 called := false
155 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
156 assertBearer(t, r)
157 if r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/claude-1" {
158 called = true
159 w.WriteHeader(http.StatusNoContent)
160 } else {
161 http.NotFound(w, r)
162 }
163 }))
164
165 if err := client.DeleteAgent("claude-1"); err != nil {
166 t.Fatal(err)
167 }
168 if !called {
169 t.Error("delete endpoint not called")
170 }
171 }
172
173 func TestListChannels(t *testing.T) {
174 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175 assertBearer(t, r)
176 w.Header().Set("Content-Type", "application/json")
177 _, _ = w.Write([]byte(`{"channels":["#general","#ops"]}`))
178 }))
179
180 raw, err := client.ListChannels()
181 if err != nil {
182 t.Fatal(err)
183 }
184 if len(raw) == 0 {
185 t.Error("expected non-empty response")
186 }
187 }
188
189 func TestDeleteChannel(t *testing.T) {
190 called := false
191 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192 assertBearer(t, r)
193 if r.Method == http.MethodDelete && r.URL.Path == "/v1/channels/general" {
194 called = true
195 w.WriteHeader(http.StatusNoContent)
196 } else {
197 http.NotFound(w, r)
198 }
199 }))
200
201 // should strip the leading #
202 if err := client.DeleteChannel("#general"); err != nil {
203 t.Fatal(err)
204 }
205 if !called {
206 t.Error("delete channel endpoint not called")
207 }
208 }
209
210 func TestGetLLMBackend(t *testing.T) {
211 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212 assertBearer(t, r)
213 w.Header().Set("Content-Type", "application/json")
214 _, _ = w.Write([]byte(`{"backends":[{"name":"anthropic","backend":"anthropic"},{"name":"ollama","backend":"ollama"}]}`))
215 }))
216
217 raw, err := client.GetLLMBackend("ollama")
218 if err != nil {
219 t.Fatal(err)
220 }
221 var got map[string]string
222 if err := json.Unmarshal(raw, &got); err != nil {
223 t.Fatal(err)
224 }
225 if got["name"] != "ollama" {
226 t.Errorf("name: got %q", got["name"])
227 }
228 }
229
230 func TestGetLLMBackendNotFound(t *testing.T) {
231 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232 assertBearer(t, r)
233 w.Header().Set("Content-Type", "application/json")
234 _, _ = w.Write([]byte(`{"backends":[]}`))
235 }))
236
237 _, err := client.GetLLMBackend("nonexistent")
238 if err == nil {
239 t.Error("expected error for missing backend, got nil")
240 }
241 }
242
243 func TestAPIError(t *testing.T) {
244 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245 w.Header().Set("Content-Type", "application/json")
246 w.WriteHeader(http.StatusUnauthorized)
247 _, _ = w.Write([]byte(`{"error":"invalid token"}`))
248 }))
249
250 _, err := client.Status()
251 if err == nil {
252 t.Fatal("expected error, got nil")
253 }
254 if err.Error() != "API error 401: invalid token" {
255 t.Errorf("error message: got %q", err.Error())
256 }
257 }
258
259 func TestAddAdmin(t *testing.T) {
260 var gotBody map[string]string
261 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262 assertBearer(t, r)
263 _ = json.NewDecoder(r.Body).Decode(&gotBody)
264 w.Header().Set("Content-Type", "application/json")
265 w.WriteHeader(http.StatusCreated)
266 _, _ = w.Write([]byte(`{"username":"alice"}`))
267 }))
268
269 raw, err := client.AddAdmin("alice", "hunter2")
270 if err != nil {
271 t.Fatal(err)
272 }
273 if raw == nil {
274 t.Error("expected response")
275 }
276 if gotBody["username"] != "alice" || gotBody["password"] != "hunter2" {
277 t.Errorf("body: got %v", gotBody)
278 }
279 }
280
281 func TestRemoveAdmin(t *testing.T) {
282 called := false
283 _, client := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
284 assertBearer(t, r)
285 if r.Method == http.MethodDelete && r.URL.Path == "/v1/admins/alice" {
286 called = true
287 w.WriteHeader(http.StatusNoContent)
288 } else {
289 http.NotFound(w, r)
290 }
291 }))
292
293 if err := client.RemoveAdmin("alice"); err != nil {
294 t.Fatal(err)
295 }
296 if !called {
297 t.Error("remove admin endpoint not called")
298 }
299 }
300
301 func assertBearer(t *testing.T, r *http.Request) {
302 t.Helper()
303 if r.Header.Get("Authorization") != "Bearer test-token" {
304 t.Errorf("Authorization header: got %q", r.Header.Get("Authorization"))
305 }
306 }
--- memory/MEMORY.md
+++ memory/MEMORY.md
@@ -10,8 +10,6 @@
1010
1111
## Reference
1212
- [calliope-cli agent](reference_calliope_cli.md) — calliope.md is the shim for https://github.com/calliopeai/calliope-cli
1313
- [Calliope disambiguation](reference_calliope_disambiguation.md) — calliope-cli (coding agent) vs Calliope AI platform (JupyterHub) are different things
1414
15
-## Project
16
-- [Kohakku](project_kohakku.md) — agent dispatch system that pairs with scuttlebot, not yet built
17
-- [Current state](project_current_state.md) — All 8 bots wired, login auth, admin mgmt, test coverage, e2e fixed — v1 feature complete
15
+
1816
1917
DELETED memory/project_current_state.md
--- memory/MEMORY.md
+++ memory/MEMORY.md
@@ -10,8 +10,6 @@
10
11 ## Reference
12 - [calliope-cli agent](reference_calliope_cli.md) — calliope.md is the shim for https://github.com/calliopeai/calliope-cli
13 - [Calliope disambiguation](reference_calliope_disambiguation.md) — calliope-cli (coding agent) vs Calliope AI platform (JupyterHub) are different things
14
15 ## Project
16 - [Kohakku](project_kohakku.md) — agent dispatch system that pairs with scuttlebot, not yet built
17 - [Current state](project_current_state.md) — All 8 bots wired, login auth, admin mgmt, test coverage, e2e fixed — v1 feature complete
18
19 ELETED memory/project_current_state.md
--- memory/MEMORY.md
+++ memory/MEMORY.md
@@ -10,8 +10,6 @@
10
11 ## Reference
12 - [calliope-cli agent](reference_calliope_cli.md) — calliope.md is the shim for https://github.com/calliopeai/calliope-cli
13 - [Calliope disambiguation](reference_calliope_disambiguation.md) — calliope-cli (coding agent) vs Calliope AI platform (JupyterHub) are different things
14
15
 
 
16
17 ELETED memory/project_current_state.md
D memory/project_current_state.md
-43
--- a/memory/project_current_state.md
+++ b/memory/project_current_state.md
@@ -1,45 +0,0 @@
----
1
-name: Scuttlebot current state
2
-description: What's built, what's wired, what's tested as of end of March 2026 sprint
3
-type: project
----
4
-
5
-All 8 bots fully implemented and wired through the manager. Oracle reads history from scribe's log files via scribeHistoryAdapter. Login screen with username/password auth. Admin account management via CLI and web UI.
6
-
7
-**Why:** Major feature push to complete implementations and improve the web chat experience.
8
-
9
-**How to apply:** The core backplane is feature-complete for v1. Next focus areas would be polish, production hardening, and the Kohakku agent dispatch system.
10
-
11
-## What's complete
12
-- All 8 system bots: auditbot, herald, oracle, scribe, scroll, snitch, systembot, warden
13
-- Bot manager: starts/stops bots dynamically on policy change
14
-- Login screen: username/password auth via POST /login, rate limited 10/min per IP
15
-- Admin accounts: bcrypt, persisted to JSON, managed via scuttlectl admin + web UI
16
-- Web UI: login screen, message grouping, nick colors, unread badge, auto-scroll banner, admin card
17
-- Logging: jsonl/csv/text formats, daily/weekly/monthly/yearly/size rotation, per-channel files, age pruning
18
-- TLS: Let's Encrypt via tlsDomain config
19
-- MCP server for AI agent connectivity
20
-- run.sh dev helper
21
-
22
-## Test coverage
23
-- internal/auth — AdminStore (persistence, auth, CRUD)
24
-- internal/api — login handler, rate limiting, admin endpoints, auth required
25
-- internal/bots/manager — Sync, password persistence, start/stop/idempotency
26
-- internal/bots/snitch — nickWindow trim, flood counting, join/part threshold (internal tests)
27
-- internal/bots/scribe — FileStore (jsonl/csv/text, rotation, per-channel, prune)
28
-- All other bots have construction-level tests
29
-
30
-## What's complete (added)
31
-- `internal/llm/` omnibus LLM gateway — Provider interface, ModelDiscoverer interface, ModelFilter (regex allow/block), factory `llm.New(BackendConfig)`
32
-- Native providers: Anthropic (Messages API + static model list), Google Gemini (generateContent + /v1beta/models discovery), AWS Bedrock (Converse API + SigV4 signing + foundation-models discovery), Ollama (/api/generate + /api/tags discovery)
33
-- OpenAI-compatible: openai, openrouter, together, groq, fireworks, mistral, ai21, huggingface, deepseek, cerebras, xai, litellm, lmstudio, jan, localai, vllm, anythingllm
34
-- `internal/config/config.go` — `LLMConfig` with `[]LLMBackendConfig` (name, backend, api_key, model, region, aws credentials, allow/block regex lists)
35
-- API endpoints: `GET /v1/llm/known`, `GET /v1/llm/backends`, `GET /v1/llm/backends/{name}/models`
36
-- UI: AI tab with configured backend list, per-backend model discovery, supported backends reference, YAML example card
37
-- Oracle manager buildBot now uses `llm.New()` with configurable `backend` field
38
-
39
-## Known remaining
40
-- Kohakku (agent dispatch system) — not yet started
41
-- Per-session tokens (login returns shared server token — fine for v1)
42
-- scuttlectl / apiclient have no tests
43
-- `internal/llm` has no tests yet
--- a/memory/project_current_state.md
+++ b/memory/project_current_state.md
@@ -1,45 +0,0 @@
----
1 name: Scuttlebot current state
2 description: What's built, what's wired, what's tested as of end of March 2026 sprint
3 type: project
----
4
5 All 8 bots fully implemented and wired through the manager. Oracle reads history from scribe's log files via scribeHistoryAdapter. Login screen with username/password auth. Admin account management via CLI and web UI.
6
7 **Why:** Major feature push to complete implementations and improve the web chat experience.
8
9 **How to apply:** The core backplane is feature-complete for v1. Next focus areas would be polish, production hardening, and the Kohakku agent dispatch system.
10
11 ## What's complete
12 - All 8 system bots: auditbot, herald, oracle, scribe, scroll, snitch, systembot, warden
13 - Bot manager: starts/stops bots dynamically on policy change
14 - Login screen: username/password auth via POST /login, rate limited 10/min per IP
15 - Admin accounts: bcrypt, persisted to JSON, managed via scuttlectl admin + web UI
16 - Web UI: login screen, message grouping, nick colors, unread badge, auto-scroll banner, admin card
17 - Logging: jsonl/csv/text formats, daily/weekly/monthly/yearly/size rotation, per-channel files, age pruning
18 - TLS: Let's Encrypt via tlsDomain config
19 - MCP server for AI agent connectivity
20 - run.sh dev helper
21
22 ## Test coverage
23 - internal/auth — AdminStore (persistence, auth, CRUD)
24 - internal/api — login handler, rate limiting, admin endpoints, auth required
25 - internal/bots/manager — Sync, password persistence, start/stop/idempotency
26 - internal/bots/snitch — nickWindow trim, flood counting, join/part threshold (internal tests)
27 - internal/bots/scribe — FileStore (jsonl/csv/text, rotation, per-channel, prune)
28 - All other bots have construction-level tests
29
30 ## What's complete (added)
31 - `internal/llm/` omnibus LLM gateway — Provider interface, ModelDiscoverer interface, ModelFilter (regex allow/block), factory `llm.New(BackendConfig)`
32 - Native providers: Anthropic (Messages API + static model list), Google Gemini (generateContent + /v1beta/models discovery), AWS Bedrock (Converse API + SigV4 signing + foundation-models discovery), Ollama (/api/generate + /api/tags discovery)
33 - OpenAI-compatible: openai, openrouter, together, groq, fireworks, mistral, ai21, huggingface, deepseek, cerebras, xai, litellm, lmstudio, jan, localai, vllm, anythingllm
34 - `internal/config/config.go` — `LLMConfig` with `[]LLMBackendConfig` (name, backend, api_key, model, region, aws credentials, allow/block regex lists)
35 - API endpoints: `GET /v1/llm/known`, `GET /v1/llm/backends`, `GET /v1/llm/backends/{name}/models`
36 - UI: AI tab with configured backend list, per-backend model discovery, supported backends reference, YAML example card
37 - Oracle manager buildBot now uses `llm.New()` with configurable `backend` field
38
39 ## Known remaining
40 - Kohakku (agent dispatch system) — not yet started
41 - Per-session tokens (login returns shared server token — fine for v1)
42 - scuttlectl / apiclient have no tests
43 - `internal/llm` has no tests yet
--- a/memory/project_current_state.md
+++ b/memory/project_current_state.md
@@ -1,45 +0,0 @@
----
 
 
 
----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button