diff --git a/docs/superpowers/plans/2026-06-23-agent-account-list.md b/docs/superpowers/plans/2026-06-23-agent-account-list.md new file mode 100644 index 0000000..2b68b05 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-account-list.md @@ -0,0 +1,497 @@ +# Agent-readable `account list` 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 an agent holding only `EMCLI_KEY` run `emcli account list` and get a reduced JSON view (name, from, can_send), while admin keeps the full text table and `account add/edit/remove` stay admin-only. + +**Architecture:** Make `commandRole` subcommand-aware so `account list` routes to the agent role; branch the `list` renderer on whether the admin key is present (admin → existing text table; agent → standard `Success` JSON envelope). No schema change; `ListAccounts` already avoids decrypting secrets. + +**Tech Stack:** Go, standard library (`flag`, `encoding/json`), existing `internal/cli` envelope helpers and `internal/crypto` key loaders. + +## Global Constraints + +- Agent output is the existing JSON envelope shape: `{"error":bool,"error_detail":{...},"data":{...}}` via `Success(...)` / `Failure(...)` in `internal/cli/envelope.go`. +- Admin `account list` output stays byte-for-byte the current human-readable table (`NAME MODE IMAP USER`). +- The agent (reduced) view exposes only `name`, `from`, `can_send` — never the IMAP host/port or login username. +- `from = Account.SendFrom()` (explicit `FromAddress`, else `Username`). `can_send = (Mode == "RW")`. +- `account add/edit/remove` remain admin-only (hard-require `EMCLI_ADMIN_KEY`, no fallback). +- Privilege detection: a caller is "admin" iff `crypto.AdminKeyFromEnv()` returns no error. +- Spec: `docs/superpowers/specs/2026-06-23-agent-account-list-design.md`. + +--- + +### Task 1: Route `account list` to the agent role and render by privilege + +**Files:** +- Modify: `internal/cli/run.go` (`commandRole`, its call site in `Run`) +- Modify: `internal/cli/admin.go` (the `list` branch of `runAccount`; add `crypto` import) +- Modify: `internal/cli/role_test.go` (`TestCommandRole`) +- Modify: `internal/cli/security_invariant_test.go` (refused-commands set) +- Create/Test: `internal/cli/account_list_test.go` + +**Interfaces:** +- Consumes: `store.Account.SendFrom() string`, `store.Account.Mode string`, `store.Store.ListAccounts() ([]store.Account, error)`, `crypto.AdminKeyFromEnv() ([]byte, error)`, `Success(map[string]any) Envelope`, `Failure(code, msg string) Envelope`, `Envelope.Write(io.Writer) error`, test helpers `adminEnv(t)`, `run(t, args...)`. +- Produces: `commandRole(args []string) store.Role` (signature changes from `commandRole(cmd string)`). Agent `account list` emits `{"data":{"accounts":[{"name":string,"from":string,"can_send":bool}]}}`. + +- [ ] **Step 1: Rewrite `TestCommandRole` for the new signature and subcommand routing** + +Replace the body of `TestCommandRole` in `internal/cli/role_test.go` with: + +```go +func TestCommandRole(t *testing.T) { + adminCmds := [][]string{ + {"whitelist"}, {"config"}, {"audit"}, + {"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"}, + } + agentCmds := [][]string{ + {"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"}, + {"account", "list"}, + } + for _, c := range adminCmds { + if commandRole(c) != store.RoleAdmin { + t.Errorf("%v should be admin", c) + } + } + for _, c := range agentCmds { + if commandRole(c) != store.RoleAgent { + t.Errorf("%v should be agent", c) + } + } +} +``` + +Note: `init` is intentionally absent from this table. `commandRole({"init"})` falls through to the agent arm, but `Run` dispatches `init` via its own bootstrap path (which requires both keys), so its `commandRole` result is never used — asserting a role for it here would be both wrong and meaningless. + +- [ ] **Step 2: Run the routing test to verify it fails to compile** + +Run: `go test ./internal/cli/ -run TestCommandRole` +Expected: FAIL — compile error, `commandRole` takes `string`, called with `[]string`. + +- [ ] **Step 3: Make `commandRole` subcommand-aware, update its call site, and fix the security invariant** + +In `internal/cli/run.go`, replace: + +```go +func commandRole(cmd string) store.Role { + switch cmd { + case "account", "whitelist", "config", "audit": + return store.RoleAdmin + default: // list, get, search, ack, send, doctor + return store.RoleAgent + } +} +``` + +with: + +```go +func commandRole(args []string) store.Role { + switch args[0] { + case "account": + // account list is a read-only discovery view available to agents; + // add/edit/remove mutate config and require admin. + if len(args) >= 2 && args[1] == "list" { + return store.RoleAgent + } + return store.RoleAdmin + case "whitelist", "config", "audit": + return store.RoleAdmin + default: // list, get, search, ack, send, doctor + return store.RoleAgent + } +} +``` + +In `Run`, change the call site from `role := commandRole(cmd)` to `role := commandRole(args)`. + +In `internal/cli/security_invariant_test.go`, in `TestAgentKeyCannotRunAdminCommands`, replace the `adminAttempts` entry `{"account", "list"}` so the set covers a *mutating* account command instead (account list is now allowed for agents): + +```go + adminAttempts := [][]string{ + {"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"}, + {"config", "set", "audit_retention_days", "30"}, + {"audit"}, + } +``` + +- [ ] **Step 4: Run the cli package tests to verify routing + invariant pass** + +Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentKeyCannotRunAdminCommands'` +Expected: PASS (both). The agent can no longer be proven to refuse `account list` — that is intended; the invariant now proves `account add` is refused and the DB is unchanged. + +- [ ] **Step 5: Add the rendering tests (agent JSON view + admin text view)** + +Create `internal/cli/account_list_test.go`: + +```go +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope: +// name/from/can_send, and never the IMAP host or login username. +func TestAccountListAgentJSONView(t *testing.T) { + adminEnv(t) // both keys + initialized temp DB + run(t, "account", "add", "--name", "work", "--mode", "RW", + "--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com", + "--username", "login@example.com", "--from", "me@example.com") + run(t, "account", "add", "--name", "alerts", "--mode", "RO", + "--imap-host", "imap.example.com", "--username", "alerts@example.com") + + // Drop the admin key → caller is an agent. + t.Setenv("EMCLI_ADMIN_KEY", "") + code, out, errOut := run(t, "account", "list") + if code != 0 { + t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut) + } + + var env struct { + Error bool `json:"error"` + Data struct { + Accounts []struct { + Name string `json:"name"` + From string `json:"from"` + CanSend bool `json:"can_send"` + } `json:"accounts"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("output is not the agent envelope: %v\n%s", err, out) + } + if env.Error || len(env.Data.Accounts) != 2 { + t.Fatalf("want 2 accounts and no error, got %+v", env) + } + // The reduced view must not leak the IMAP host or the login username. + if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") { + t.Fatalf("agent view leaked host/username:\n%s", out) + } + + got := map[string]struct { + from string + canSend bool + }{} + for _, a := range env.Data.Accounts { + got[a.Name] = struct { + from string + canSend bool + }{a.From, a.CanSend} + } + if g := got["work"]; g.from != "me@example.com" || !g.canSend { + t.Errorf("work: want from=me@example.com can_send=true, got %+v", g) + } + // alerts has no --from → SendFrom() falls back to the username. + if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend { + t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g) + } +} + +// With the admin key present, `account list` stays the full human-readable table. +func TestAccountListAdminTextView(t *testing.T) { + adminEnv(t) + run(t, "account", "add", "--name", "work", "--mode", "RW", + "--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com", + "--username", "login@example.com", "--from", "me@example.com") + + code, out, _ := run(t, "account", "list") + if code != 0 { + t.Fatalf("admin account list failed: code=%d", code) + } + for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} { + if !strings.Contains(out, want) { + t.Fatalf("admin view missing %q:\n%s", want, out) + } + } + if strings.Contains(out, `"accounts"`) { + t.Fatalf("admin view should be text, not JSON:\n%s", out) + } +} +``` + +- [ ] **Step 6: Run the rendering tests to verify the agent view fails** + +Run: `go test ./internal/cli/ -run 'TestAccountListAgentJSONView|TestAccountListAdminTextView'` +Expected: `TestAccountListAdminTextView` PASS (already text); `TestAccountListAgentJSONView` FAIL — output is still the text table, so `json.Unmarshal` errors. + +- [ ] **Step 7: Split the `list` branch by privilege in `runAccount`** + +In `internal/cli/admin.go`, add the crypto import. Change the import block: + +```go +import ( + "flag" + "fmt" + "io" + "strconv" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" + "git.dcglab.co.uk/steve/emcli/internal/store" + "git.dcglab.co.uk/steve/emcli/internal/tui" +) +``` + +Replace the `case "list":` block (currently): + +```go + case "list": + accs, err := st.ListAccounts() + if err != nil { + fmt.Fprintf(errOut, "list: %v\n", err) + return 1 + } + fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER") + for _, a := range accs { + fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", + a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username) + } + return 0 +``` + +with: + +```go + case "list": + // Holding the admin key means the caller is the human admin (full + // detail). An agent holds only EMCLI_KEY and gets a reduced JSON view. + _, adminErr := crypto.AdminKeyFromEnv() + isAdmin := adminErr == nil + accs, err := st.ListAccounts() + if err != nil { + if isAdmin { + fmt.Fprintf(errOut, "list: %v\n", err) + } else { + _ = Failure(CodeDB, err.Error()).Write(out) + } + return 1 + } + if !isAdmin { + items := make([]map[string]any, 0, len(accs)) + for _, a := range accs { + items = append(items, map[string]any{ + "name": a.Name, + "from": a.SendFrom(), + "can_send": a.Mode == "RW", + }) + } + _ = Success(map[string]any{"accounts": items}).Write(out) + return 0 + } + fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER") + for _, a := range accs { + fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", + a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username) + } + return 0 +``` + +- [ ] **Step 8: Run the full cli package test suite** + +Run: `go test ./internal/cli/` +Expected: PASS (all tests, including the two new rendering tests, the routing test, and the security invariant). + +- [ ] **Step 9: Run the whole module to confirm nothing else regressed** + +Run: `go build ./... && go test ./...` +Expected: build clean; all packages PASS. + +- [ ] **Step 10: Commit** + +```bash +git add internal/cli/run.go internal/cli/admin.go internal/cli/role_test.go \ + internal/cli/security_invariant_test.go internal/cli/account_list_test.go +git commit -m "feat(cli): agent-readable account list (reduced JSON view) + +account list now routes to the agent role; an agent (EMCLI_KEY only) gets a +JSON envelope of name/from/can_send, while the admin keeps the full text +table. account add/edit/remove stay admin-only. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: Update user and agent documentation + +**Files:** +- Modify: `USER-MANUAL.md` (command-kinds note, role table, cheatsheet) +- Modify: `skills/emcli/SKILL.md` (allowed-commands note, command table, do/don't) +- Modify: `skills/emcli/AGENTIC-MANUAL.md` (§4 account discovery) + +**Interfaces:** +- Consumes: behavior shipped in Task 1 (agent `account list` → `{"data":{"accounts":[{name,from,can_send}]}}`). +- Produces: docs only; no code interface. + +- [ ] **Step 1: USER-MANUAL — note that `account list` is the one agent-readable admin view** + +In `USER-MANUAL.md`, in the "Two kinds of commands" block, change the Admin bullet (line ~36) from: + +``` +- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` + and are for *you*, the human. They print human-readable text or open an interactive form. +``` + +to: + +``` +- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require + `EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an + interactive form. (`account list` is the one exception — it is also an agent command; see below.) +``` + +- [ ] **Step 2: USER-MANUAL — update the role table** + +Replace the role table rows (lines ~127-128): + +``` +| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) | +| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) | +``` + +with: + +``` +| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) | +| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) | + +`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table; +with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and +`can_send` — no host or login username. +``` + +- [ ] **Step 3: USER-MANUAL — annotate the cheatsheet** + +In the cheatsheet (line ~597), change: + +``` +emcli account list # list accounts (no secrets) +``` + +to: + +``` +emcli account list # full table (admin) / name+from+can_send JSON (agent) +``` + +- [ ] **Step 4: SKILL.md — carve `account list` out of the forbidden-commands rule** + +In `skills/emcli/SKILL.md`, change the first bullet (lines ~20-24): + +``` +- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are + provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else. + Account setup, passwords, whitelists, and config are the **user's** job (admin commands that + require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`, + `audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands + with a privilege error. +``` + +to: + +``` +- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and + `account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which + authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords, + whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) — + do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or + `init`. `emcli` will refuse those with a privilege error. +``` + +- [ ] **Step 5: SKILL.md — add `account list` to the command table** + +In the command table (after the `send` row, line ~122), add: + +``` +| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account | +``` + +- [ ] **Step 6: SKILL.md — fix the "don't" bullet** + +Change (lines ~147-148): + +``` +- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only + `EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error. +``` + +to: + +``` +- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) — + you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error. + (`account list` is allowed — use it to discover accounts.) +``` + +Also change the ✅ bullet (line ~145) from `Ask the user for the account name; keep bodies plain text.` to: + +``` +- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text. +``` + +- [ ] **Step 7: AGENTIC-MANUAL — document discovery via `account list`** + +In `skills/emcli/AGENTIC-MANUAL.md`, replace the body of `## 4. Find the account(s)` (lines ~88-97): + +``` +You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use. +`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that +configured accounts connect and authenticate: + +```bash +emcli doctor # all accounts +emcli doctor --account gmail +``` + +Just take the account name from the user and start with the workflow in `SKILL.md`. +``` + +with: + +``` +You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself +with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope +with one entry per account: + +```bash +emcli account list +# {"error":false,"error_detail":{},"data":{"accounts":[ +# {"name":"gmail","from":"me@gmail.com","can_send":true}, +# {"name":"alerts","from":"alerts@x.com","can_send":false}]}} +``` + +`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for +read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor` +(also an agent command) checks that accounts connect and authenticate: + +```bash +emcli doctor # all accounts +emcli doctor --account gmail +``` + +Then start with the workflow in `SKILL.md`. +``` + +- [ ] **Step 8: Sanity-check the docs render and reference reality** + +Run: `grep -n "account list" USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md` +Expected: each file shows the updated `account list` references; no remaining text claims the agent cannot run `account list`. + +- [ ] **Step 9: Commit** + +```bash +git add USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md +git commit -m "docs: agent can discover accounts via account list + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Notes for the implementer + +- Run all Go commands from the repo root (`/home/steve/src/emcli`). +- The two intentional red states are Step 2 (compile error) and Step 6 (agent JSON test) in Task 1. Every other test run must be green. +- Do not change the admin text table or add columns to it — admin output must stay identical. +- `adminEnv(t)` and `run(t, ...)` live in `internal/cli/admin_test.go`; `b64Key`/`b64AgentKey` in `internal/cli/run_test.go`. No new helpers are needed.