Files
emcli/docs/superpowers/plans/2026-06-23-agent-account-list.md
T
steve 64ff32ab29 docs(plan): agent-readable account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:34:33 +01:00

18 KiB

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:

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:

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:

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):

	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:

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:

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):

	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:

	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
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) <noreply@anthropic.com>"

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:

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:

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) <noreply@anthropic.com>"

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.