Add settings page implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,996 @@
|
|||||||
|
# Settings file + settings page Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let the user edit the AI backend, Marvin tasks command, and knowledge profile path at runtime from a settings page in the web UI, persisted to a settings file, with a server-side file picker for the profile path.
|
||||||
|
|
||||||
|
**Architecture:** A new leaf `internal/settings` package owns the `Settings` struct and its JSON file (`~/.antidrift/settings.json`). `main.go` builds an `applyFn func(settings.Settings) error` closure that constructs adapters and calls the controller's existing setters, and injects it into the web server. The server's `POST /settings` handler validates-and-applies via that closure (atomically — invalid backend mutates nothing), then persists. A `GET /fs/browse` endpoint backs a custom file-picker modal. `web` never imports `ai`/`tasks`/`knowledge`.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, Gin, vanilla-JS SSE frontend. Spec: `docs/superpowers/specs/2026-06-01-settings-page-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
- `internal/settings/settings.go` (new) — `Settings` struct, `ErrInvalidBackend`, `DefaultPath`, `Load`, `Save`, `SeedFromEnv`.
|
||||||
|
- `internal/settings/settings_test.go` (new) — round-trip, seed, first-run.
|
||||||
|
- `internal/web/settings_handlers.go` (new) — server settings fields, `SetSettings`, `handleGetSettings`, `handlePostSettings`, `handleBrowse`.
|
||||||
|
- `internal/web/settings_handlers_test.go` (new) — GET/POST/browse tests with a fake applier.
|
||||||
|
- `internal/web/web.go` (modify) — register routes; drop `POST /knowledge/path` + `handleKnowledgePath`.
|
||||||
|
- `cmd/antidriftd/main.go` (modify) — load-or-seed settings, build `applyFn`, inject into server.
|
||||||
|
- `internal/web/static/index.html` (modify) — gear button + overlay container.
|
||||||
|
- `internal/web/static/app.js` (modify) — settings overlay + browse modal; repoint knowledge "change" link.
|
||||||
|
- `internal/web/static/app.css` (modify) — gear, overlay, modal, browse styles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `internal/settings` package
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/settings/settings.go`
|
||||||
|
- Test: `internal/settings/settings_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `internal/settings/settings_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||||
|
if err := Save(path, want); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("round trip = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingFileIsError(t *testing.T) {
|
||||||
|
_, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error loading missing file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvReadsVars(t *testing.T) {
|
||||||
|
t.Setenv("ANTIDRIFT_AI_BACKEND", "codex")
|
||||||
|
t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am")
|
||||||
|
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md")
|
||||||
|
got := SeedFromEnv()
|
||||||
|
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
|
||||||
|
t.Setenv("ANTIDRIFT_AI_BACKEND", "")
|
||||||
|
t.Setenv("ANTIDRIFT_MARVIN_CMD", "")
|
||||||
|
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "")
|
||||||
|
got := SeedFromEnv()
|
||||||
|
if got.AIBackend != "claude" {
|
||||||
|
t.Errorf("default backend = %q, want claude", got.AIBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveCreatesDir(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "sub", "settings.json")
|
||||||
|
if err := Save(path, Settings{AIBackend: "claude"}); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("file not written: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `go test ./internal/settings/`
|
||||||
|
Expected: FAIL — `undefined: Settings`, `undefined: Save`, etc.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Create `internal/settings/settings.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package settings persists the daemon's user-editable configuration as a single
|
||||||
|
// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a
|
||||||
|
// leaf type package: it imports nothing else in the app so any layer may depend
|
||||||
|
// on the Settings value without pulling in adapters.
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||||
|
// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400
|
||||||
|
// without importing the ai package.
|
||||||
|
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
||||||
|
|
||||||
|
// Settings is the user-editable configuration. Field names mirror the env vars
|
||||||
|
// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE.
|
||||||
|
type Settings struct {
|
||||||
|
AIBackend string `json:"ai_backend"`
|
||||||
|
MarvinCmd string `json:"marvin_cmd"`
|
||||||
|
KnowledgePath string `json:"knowledge_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPath returns ~/.antidrift/settings.json.
|
||||||
|
func DefaultPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".antidrift", "settings.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads a settings file. A missing file is an error; callers treat that as
|
||||||
|
// "first run" and seed instead.
|
||||||
|
func Load(path string) (Settings, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
var s Settings
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes settings atomically (temp file + rename), creating the directory
|
||||||
|
// if needed. Mirrors store.Save.
|
||||||
|
func Save(path string, s Settings) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying
|
||||||
|
// the same defaults the daemon used before the settings file existed. An unset
|
||||||
|
// AI backend defaults to "claude" (matching ai.NewBackend("")).
|
||||||
|
func SeedFromEnv() Settings {
|
||||||
|
backend := os.Getenv("ANTIDRIFT_AI_BACKEND")
|
||||||
|
if backend == "" {
|
||||||
|
backend = "claude"
|
||||||
|
}
|
||||||
|
return Settings{
|
||||||
|
AIBackend: backend,
|
||||||
|
MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"),
|
||||||
|
KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `go test ./internal/settings/`
|
||||||
|
Expected: PASS (all 5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/settings/
|
||||||
|
git commit -m "Add settings package: Settings file load/save/seed
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Web settings handlers (GET + POST)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/web/settings_handlers.go`
|
||||||
|
- Test: `internal/web/settings_handlers_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `internal/web/settings_handlers_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"antidrift/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeApplier records the last settings it was asked to apply and can be told to
|
||||||
|
// reject with ErrInvalidBackend.
|
||||||
|
type fakeApplier struct {
|
||||||
|
called int
|
||||||
|
last settings.Settings
|
||||||
|
reject bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeApplier) apply(s settings.Settings) error {
|
||||||
|
if f.reject {
|
||||||
|
return settings.ErrInvalidBackend
|
||||||
|
}
|
||||||
|
f.called++
|
||||||
|
f.last = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
||||||
|
fa := &fakeApplier{}
|
||||||
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var got settings.Settings
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got != cur {
|
||||||
|
t.Errorf("got %+v, want %+v", got, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
fa := &fakeApplier{}
|
||||||
|
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}`
|
||||||
|
w := post(t, r, "/settings", body)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if fa.called != 1 {
|
||||||
|
t.Fatalf("applier called %d times, want 1", fa.called)
|
||||||
|
}
|
||||||
|
if fa.last.AIBackend != "codex" {
|
||||||
|
t.Errorf("applied backend = %q, want codex", fa.last.AIBackend)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Errorf("settings file not written: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
fa := &fakeApplier{reject: true}
|
||||||
|
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
w := post(t, r, "/settings", `{"ai_backend":"bogus"}`)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("settings file should not exist after rejected save, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `httptest`, `post`, and `newTestServer` already exist in `web_test.go` (same package), so they are reused here.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `go test ./internal/web/ -run TestSettings -v` (and `TestGetSettings`, `TestPostSettings`)
|
||||||
|
Run: `go test ./internal/web/ -run 'Settings' -v`
|
||||||
|
Expected: FAIL — `s.SetSettings undefined`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Create `internal/web/settings_handlers.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"antidrift/internal/settings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSettings injects the persisted settings, their file path, and the applier
|
||||||
|
// closure (built by main, which owns adapter construction). Mirrors the
|
||||||
|
// controller's Set* injection so web never imports ai/tasks/knowledge.
|
||||||
|
func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) {
|
||||||
|
s.settingsMu.Lock()
|
||||||
|
s.settingsPath = path
|
||||||
|
s.settings = current
|
||||||
|
s.applyFn = apply
|
||||||
|
s.settingsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetSettings(c *gin.Context) {
|
||||||
|
s.settingsMu.Lock()
|
||||||
|
cur := s.settings
|
||||||
|
s.settingsMu.Unlock()
|
||||||
|
c.JSON(http.StatusOK, cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePostSettings validates-and-applies atomically, then persists. The applier
|
||||||
|
// checks the backend before mutating any state, so an invalid backend yields 400
|
||||||
|
// with nothing saved and the prior wiring intact.
|
||||||
|
func (s *Server) handlePostSettings(c *gin.Context) {
|
||||||
|
var req settings.Settings
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.settingsMu.Lock()
|
||||||
|
apply := s.applyFn
|
||||||
|
path := s.settingsPath
|
||||||
|
s.settingsMu.Unlock()
|
||||||
|
if apply == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := apply(req); err != nil {
|
||||||
|
// Apply validates the backend before mutating anything, so any error
|
||||||
|
// (invalid backend or otherwise) means nothing changed; surface it as 400.
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := settings.Save(path, req); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.settingsMu.Lock()
|
||||||
|
s.settings = req
|
||||||
|
s.settingsMu.Unlock()
|
||||||
|
s.broadcast()
|
||||||
|
c.JSON(http.StatusOK, req)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the server fields. In `internal/web/web.go`, modify the `Server` struct (currently lines 26-32) to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Server wires the session controller to HTTP and SSE.
|
||||||
|
type Server struct {
|
||||||
|
ctrl *session.Controller
|
||||||
|
bcast *Broadcaster
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
timer *time.Timer
|
||||||
|
|
||||||
|
settingsMu sync.Mutex
|
||||||
|
settings settings.Settings
|
||||||
|
settingsPath string
|
||||||
|
applyFn func(settings.Settings) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import `"antidrift/internal/settings"` to `web.go`'s import block.
|
||||||
|
|
||||||
|
Register the routes in `internal/web/web.go` `Router()`, after the existing `r.POST(...)` lines (around line 71):
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.GET("/settings", s.handleGetSettings)
|
||||||
|
r.POST("/settings", s.handlePostSettings)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `go test ./internal/web/ -run 'Settings' -v`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go
|
||||||
|
git commit -m "Add GET/POST /settings handlers with injected applier
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `GET /fs/browse` file-list endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/web/settings_handlers.go`
|
||||||
|
- Test: `internal/web/settings_handlers_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `internal/web/settings_handlers_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Parent string `json:"parent"`
|
||||||
|
Entries []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Parent != filepath.Dir(dir) {
|
||||||
|
t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir))
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range resp.Entries {
|
||||||
|
names = append(names, e.Name)
|
||||||
|
}
|
||||||
|
hasSub, hasMd, hasTxt := false, false, false
|
||||||
|
for _, e := range resp.Entries {
|
||||||
|
switch e.Name {
|
||||||
|
case "sub":
|
||||||
|
hasSub = e.IsDir
|
||||||
|
case "profile.md":
|
||||||
|
hasMd = !e.IsDir
|
||||||
|
case "notes.txt":
|
||||||
|
hasTxt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSub || !hasMd {
|
||||||
|
t.Errorf("missing dir or .md entry; names = %v", names)
|
||||||
|
}
|
||||||
|
if hasTxt {
|
||||||
|
t.Errorf(".txt should be filtered out; names = %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseBadDirIs400(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||||
|
r := s.Router()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `go test ./internal/web/ -run TestBrowse -v`
|
||||||
|
Expected: FAIL — 404 (route not registered) / `handleBrowse undefined`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Append to `internal/web/settings_handlers.go` (add `"os"`, `"path/filepath"`, `"strings"` to its imports):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type browseEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type browseResponse struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Parent string `json:"parent"`
|
||||||
|
Entries []browseEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build
|
||||||
|
// a file picker that returns a real server-side path. Dotfiles are NOT hidden —
|
||||||
|
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail
|
||||||
|
// beyond OS permissions (same trust boundary as the rest of the UI).
|
||||||
|
func (s *Server) handleBrowse(c *gin.Context) {
|
||||||
|
dir := strings.TrimSpace(c.Query("dir"))
|
||||||
|
if dir == "" {
|
||||||
|
dir = s.browseStart()
|
||||||
|
}
|
||||||
|
dir = filepath.Clean(dir)
|
||||||
|
infos, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}}
|
||||||
|
for _, e := range infos {
|
||||||
|
name := e.Name()
|
||||||
|
full := filepath.Join(dir, name)
|
||||||
|
if e.IsDir() {
|
||||||
|
resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true})
|
||||||
|
} else if strings.HasSuffix(name, ".md") {
|
||||||
|
resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// browseStart picks the directory of the current knowledge path, else $HOME.
|
||||||
|
func (s *Server) browseStart() string {
|
||||||
|
s.settingsMu.Lock()
|
||||||
|
kp := s.settings.KnowledgePath
|
||||||
|
s.settingsMu.Unlock()
|
||||||
|
if kp != "" {
|
||||||
|
return filepath.Dir(kp)
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register the route in `internal/web/web.go` `Router()`, next to the other settings routes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.GET("/fs/browse", s.handleBrowse)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `go test ./internal/web/ -run TestBrowse -v`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go
|
||||||
|
git commit -m "Add GET /fs/browse directory-listing endpoint
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Remove the now-redundant `/knowledge/path` endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/web/web.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Delete the route and handler**
|
||||||
|
|
||||||
|
In `internal/web/web.go`, remove the route registration line:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.POST("/knowledge/path", s.handleKnowledgePath)
|
||||||
|
```
|
||||||
|
|
||||||
|
and delete the `knowledgePathRequest` type plus the `handleKnowledgePath` function (currently `web.go:143-159`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type knowledgePathRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKnowledgePath repoints the profile file at runtime ...
|
||||||
|
func (s *Server) handleKnowledgePath(c *gin.Context) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller's `SetKnowledgePath` method stays — the applier (Task 5) calls it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the test that exercises the removed route**
|
||||||
|
|
||||||
|
Search for any test referencing `/knowledge/path`:
|
||||||
|
|
||||||
|
Run: `grep -rn "knowledge/path" internal/web/`
|
||||||
|
|
||||||
|
If `TestPlanningStatePayloadCarriesKnowledge` (or any test) POSTs to `/knowledge/path`, repoint it to `/settings`. Concretely, replace a call like
|
||||||
|
`post(t, r, "/knowledge/path", `+"`"+`{"path":"/tmp/x.md"}`+"`"+`)`
|
||||||
|
with wiring settings first and posting the full object:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
|
||||||
|
settings.Settings{}, func(settings.Settings) error { return nil })
|
||||||
|
post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/tmp/x.md"}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
If no test references it, skip this step.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build and tests**
|
||||||
|
|
||||||
|
Run: `go build ./... && go test ./internal/web/`
|
||||||
|
Expected: PASS, no references to `handleKnowledgePath` remain.
|
||||||
|
|
||||||
|
Run: `grep -rn "knowledge/path\|handleKnowledgePath" internal/`
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/web/
|
||||||
|
git commit -m "Remove /knowledge/path; folded into /settings
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Wire settings into `main.go` (applier + first-run seed)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `cmd/antidriftd/main.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the env-var wiring with settings load-or-seed + applier**
|
||||||
|
|
||||||
|
In `cmd/antidriftd/main.go`, replace the three wiring blocks (currently lines 39-65: the AI backend block, the `SetTasks` block, and the `SetKnowledge` block) with the following. Keep everything before (`store.DefaultPath`, `session.New`, `web.NewServer`, `srv.Init()`) and after (statusfile, guard, evidence, browser, Run) unchanged.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on
|
||||||
|
// first run. After first run the file is the sole source of truth; env is
|
||||||
|
// ignored. A missing file is the first-run signal.
|
||||||
|
settingsPath, err := settings.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolve settings path: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := settings.Load(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
cfg = settings.SeedFromEnv()
|
||||||
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
||||||
|
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("settings: seeded %s from environment", settingsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The knowledge source is a single file adapter whose path is driven entirely
|
||||||
|
// by SetKnowledgePath, so startup and live settings edits share one code path.
|
||||||
|
ctrl.SetKnowledge(knowledge.NewFileSource(""))
|
||||||
|
|
||||||
|
// applyFn re-wires the running daemon from a Settings value: AI backend +
|
||||||
|
// service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It
|
||||||
|
// validates the backend FIRST and mutates nothing on failure, so POST /settings
|
||||||
|
// can reject an invalid backend atomically.
|
||||||
|
applyFn := func(s settings.Settings) error {
|
||||||
|
backend, err := ai.NewBackend(s.AIBackend)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
||||||
|
}
|
||||||
|
svc := ai.NewService(backend)
|
||||||
|
ctrl.SetCoach(svc)
|
||||||
|
ctrl.SetDriftJudge(svc)
|
||||||
|
ctrl.SetNudge(svc)
|
||||||
|
ctrl.SetReviewer(svc)
|
||||||
|
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
|
||||||
|
ctrl.SetKnowledgePath(s.KnowledgePath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply once at startup. A bad persisted backend should not be fatal: fall back
|
||||||
|
// to claude (always valid), persist the correction, and re-apply.
|
||||||
|
if err := applyFn(cfg); err != nil {
|
||||||
|
log.Printf("settings: %v; falling back to claude backend", err)
|
||||||
|
cfg.AIBackend = "claude"
|
||||||
|
_ = settings.Save(settingsPath, cfg)
|
||||||
|
if err := applyFn(cfg); err != nil {
|
||||||
|
log.Fatalf("settings: apply failed even with claude: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||||
|
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `"fmt"` and `"antidrift/internal/settings"` to the import block. The `"os"` import may become unused (it was only used for `os.Getenv`) — check: `openBrowser` does not use `os`. Remove `"os"` from the import block if `go build` reports it unused.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `go build ./...`
|
||||||
|
Expected: success. If it reports `"os" imported and not used`, remove the `"os"` import line and rebuild.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite + vet**
|
||||||
|
|
||||||
|
Run: `go test ./... && go vet ./...`
|
||||||
|
Expected: all packages PASS, vet clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke test**
|
||||||
|
|
||||||
|
Run (no env vars needed now):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/antidriftd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected log lines include `settings: seeded ... from environment` (first run) or `settings: ai=claude, ...`. Confirm `~/.antidrift/settings.json` exists:
|
||||||
|
|
||||||
|
Run: `cat ~/.antidrift/settings.json`
|
||||||
|
Expected: JSON with the three keys.
|
||||||
|
|
||||||
|
Stop the daemon (Ctrl-C).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cmd/antidriftd/main.go
|
||||||
|
git commit -m "Drive daemon config from settings file via injected applier
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Settings UI — gear, overlay, browse modal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/web/static/index.html`
|
||||||
|
- Modify: `internal/web/static/app.js`
|
||||||
|
- Modify: `internal/web/static/app.css`
|
||||||
|
|
||||||
|
This task has no Go test harness (the repo has no JS tests); verification is `go build` + manual browser check. Write the exact code below.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the gear button and overlay container to `index.html`**
|
||||||
|
|
||||||
|
Replace the `<main id="app">…</main>` block (lines 12-15) with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<main id="app">
|
||||||
|
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||||
|
<div class="card" id="view">connecting…</div>
|
||||||
|
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
The overlay lives OUTSIDE `#view`, so the SSE re-render (which replaces `view.innerHTML`) never wipes it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Repoint the knowledge "change" link in `app.js`**
|
||||||
|
|
||||||
|
In `internal/web/static/app.js`, in `updatePlanningKnowledge` (lines 184-187), replace the prompt-based handler:
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.getElementById('knowChange').onclick = () => {
|
||||||
|
const next = prompt('Profile file path (blank = default):', k.path || '');
|
||||||
|
if (next !== null) post('/knowledge/path', { path: next.trim() });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.getElementById('knowChange').onclick = openSettings;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Append the settings overlay + browse logic to `app.js`**
|
||||||
|
|
||||||
|
Add at the end of `internal/web/static/app.js`, AFTER the `EventSource` block:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ---- Settings overlay ----
|
||||||
|
function openSettings() {
|
||||||
|
fetch('/settings').then(r => r.json()).then(s => {
|
||||||
|
const ov = document.getElementById('settingsOverlay');
|
||||||
|
ov.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<label>AI backend</label>
|
||||||
|
<select id="setBackend">
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="codex">codex</option>
|
||||||
|
</select>
|
||||||
|
<label>Marvin tasks command</label>
|
||||||
|
<input id="setMarvin" placeholder="uv run am">
|
||||||
|
<label>Knowledge profile path</label>
|
||||||
|
<div class="path-row">
|
||||||
|
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
|
||||||
|
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||||
|
</div>
|
||||||
|
<div id="browsePane" class="browse" hidden></div>
|
||||||
|
<div id="setError" class="set-error"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="setSave">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
||||||
|
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
||||||
|
document.getElementById('setKnow').value = s.knowledge_path || '';
|
||||||
|
ov.hidden = false;
|
||||||
|
document.getElementById('setCancel').onclick = closeSettings;
|
||||||
|
document.getElementById('setSave').onclick = saveSettings;
|
||||||
|
document.getElementById('setBrowse').onclick = () =>
|
||||||
|
loadBrowse(parentDir(document.getElementById('setKnow').value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
const ov = document.getElementById('settingsOverlay');
|
||||||
|
ov.hidden = true;
|
||||||
|
ov.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
const body = {
|
||||||
|
ai_backend: document.getElementById('setBackend').value,
|
||||||
|
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
||||||
|
knowledge_path: document.getElementById('setKnow').value.trim(),
|
||||||
|
};
|
||||||
|
post('/settings', body).then(r => {
|
||||||
|
if (r.ok) { closeSettings(); return; }
|
||||||
|
return r.json().then(e => {
|
||||||
|
document.getElementById('setError').textContent = e.error || 'save failed';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentDir(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
return path.replace(/\/[^/]*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBrowse(dir) {
|
||||||
|
const pane = document.getElementById('browsePane');
|
||||||
|
pane.hidden = false;
|
||||||
|
fetch('/fs/browse?dir=' + encodeURIComponent(dir || '')).then(r => {
|
||||||
|
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'cannot read directory'); });
|
||||||
|
return r.json();
|
||||||
|
}).then(d => {
|
||||||
|
const items = [];
|
||||||
|
if (d.parent && d.parent !== d.dir) {
|
||||||
|
items.push(`<li><button type="button" data-dir="${d.parent}">../</button></li>`);
|
||||||
|
}
|
||||||
|
for (const e of d.entries) {
|
||||||
|
if (e.is_dir) {
|
||||||
|
items.push(`<li><button type="button" data-dir="${e.path}">${e.name}/</button></li>`);
|
||||||
|
} else {
|
||||||
|
items.push(`<li><button type="button" data-file="${e.path}">${e.name}</button></li>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pane.innerHTML = `<div class="browse-dir">${d.dir}</div><ul class="browse-list">${items.join('')}</ul>`;
|
||||||
|
pane.querySelectorAll('button[data-dir]').forEach(b =>
|
||||||
|
b.onclick = () => loadBrowse(b.getAttribute('data-dir')));
|
||||||
|
pane.querySelectorAll('button[data-file]').forEach(b =>
|
||||||
|
b.onclick = () => {
|
||||||
|
document.getElementById('setKnow').value = b.getAttribute('data-file');
|
||||||
|
pane.hidden = true;
|
||||||
|
pane.innerHTML = '';
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
pane.innerHTML = `<div class="set-error">${err.message}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('gear').onclick = openSettings;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add styles to `app.css`**
|
||||||
|
|
||||||
|
Append to `internal/web/static/app.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Settings: header gear + overlay modal */
|
||||||
|
.gear {
|
||||||
|
background: none; border: 0; color: var(--ink-dim); cursor: pointer;
|
||||||
|
font-size: 14px; padding: 0 4px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.gear:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
padding: 8vh 16px; z-index: 10;
|
||||||
|
}
|
||||||
|
.overlay[hidden] { display: none; }
|
||||||
|
.modal {
|
||||||
|
width: 100%; max-width: 520px; background: var(--panel);
|
||||||
|
border: 1px solid var(--line); border-radius: 14px; padding: 20px;
|
||||||
|
}
|
||||||
|
.modal h2 { margin: 0 0 12px; font-size: 16px; }
|
||||||
|
.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.modal-actions .btn { margin-top: 0; }
|
||||||
|
.path-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.path-row input { flex: 1; }
|
||||||
|
.path-row .btn { margin-top: 0; white-space: nowrap; }
|
||||||
|
.set-error { color: var(--danger); font-size: 13px; margin-top: 8px; min-height: 1em; }
|
||||||
|
|
||||||
|
.browse {
|
||||||
|
margin-top: 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
background: var(--bg); max-height: 260px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.browse[hidden] { display: none; }
|
||||||
|
.browse-dir {
|
||||||
|
padding: 8px 10px; font-size: 12px; color: var(--ink-dim);
|
||||||
|
font-family: ui-monospace, monospace; border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky; top: 0; background: var(--bg);
|
||||||
|
}
|
||||||
|
.browse-list { list-style: none; margin: 0; padding: 4px 0; }
|
||||||
|
.browse-list button {
|
||||||
|
width: 100%; text-align: left; background: none; border: 0; cursor: pointer;
|
||||||
|
color: var(--ink); font: inherit; font-size: 13px; padding: 5px 12px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.browse-list button:hover { background: var(--line); color: var(--accent); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build and manual-verify in browser**
|
||||||
|
|
||||||
|
Static assets are `//go:embed`-ed, so a rebuild is required for changes to load.
|
||||||
|
|
||||||
|
Run: `go build ./... && go vet ./...`
|
||||||
|
Expected: success, vet clean.
|
||||||
|
|
||||||
|
Then run the daemon and hard-reload the browser (Ctrl-Shift-R):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/antidriftd
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checklist (verify each):
|
||||||
|
1. A gear (⚙) shows next to the "AntiDrift" header.
|
||||||
|
2. Clicking it opens the settings overlay with backend/marvin/knowledge prefilled from `~/.antidrift/settings.json`.
|
||||||
|
3. Clicking **Browse…** lists the knowledge path's directory; clicking folders navigates, `../` goes up, clicking a `.md` file fills the path field and closes the browser pane.
|
||||||
|
4. Changing the backend to `codex` and Save closes the overlay; `cat ~/.antidrift/settings.json` shows `"ai_backend":"codex"`.
|
||||||
|
5. The planning screen's knowledge "change" link opens the same settings overlay.
|
||||||
|
|
||||||
|
Stop the daemon (Ctrl-C).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/web/static/
|
||||||
|
git commit -m "Add settings gear, overlay, and file-browser modal to the UI
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] Run `go build ./... && go test ./... && go vet ./...` — all green.
|
||||||
|
- [ ] Run `grep -rn "os.Getenv" cmd/ internal/` — expect no matches (all config now flows through the settings file).
|
||||||
|
- [ ] Confirm `~/.antidrift/settings.json` round-trips a UI edit (change marvin command in UI, reload page, value persists).
|
||||||
|
</content>
|
||||||
Reference in New Issue
Block a user