9 Commits

Author SHA1 Message Date
steve 3c5e0a26f3 chore(release): default installer to v0.5.2
release / release (push) Successful in 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:05:31 +01:00
steve 456d25d4f3 fix(cli): clearer whitelist usage errors
`whitelist <in|out> <add|remove|list>` has two positional slots; omitting
either let a --flag slide into the slot and produced a misleading
"--account is required". Validate the direction and the subcommand up
front, before flag parsing, so the real mistake is reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:04:40 +01:00
steve 3bea73f857 fix(store): expand a leading ~ in EMCLI_DB
A literal "~/..." in EMCLI_DB has no shell to expand it, so SQLite opened
it relative to the cwd and silently created a stray "~" directory tree.
Expand a leading "~" or "~/" to the user's home dir; "~user", mid-path
tildes, and absolute/relative paths are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:04:40 +01:00
steve c651b00d08 chore(release): default installer to v0.5.1
release / release (push) Failing after 3m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:11:48 +01:00
steve 8ed10dd503 docs: agent can discover accounts via account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:39:12 +01:00
steve 2140d9e173 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.

Also emit the agent path's missing-key/open failure as a JSON Failure
envelope (per spec), and update the stale run_test case that asserted the
old admin-only behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:37:37 +01:00
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
steve 7039371f70 docs(spec): agent-readable account list (reduced JSON view)
Let an agent holding only EMCLI_KEY discover accounts via `account list`,
exposing name/from/can_send (not host/username); 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>
2026-06-23 21:29:07 +01:00
steve e1b4ec38e5 docs(manual): document --from on account edit and the send-as address
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:49:46 +01:00
15 changed files with 961 additions and 49 deletions
+26 -10
View File
@@ -33,8 +33,9 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## 1. Key concepts ## 1. Key concepts
**Two kinds of commands.** **Two kinds of commands.**
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` - **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
and are for *you*, the human. They print human-readable text or open an interactive form. `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.)
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or - **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and `EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
@@ -124,8 +125,12 @@ DEK for an admin command, even if it somehow knows the agent key.
| Command | Role required | | Command | Role required |
|---|---| |---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) | | `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) | | `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.
| `init` | Both keys required (writes both wrap slots) | | `init` | Both keys required (writes both wrap slots) |
### Agent launcher guidance ### Agent launcher guidance
@@ -261,9 +266,15 @@ emcli account edit --name work --mode RW --smtp-host smtp.example.com --smtp-por
emcli account edit --name gmail --password 'new-app-password' # rotate the app password emcli account edit --name gmail --password 'new-app-password' # rotate the app password
``` ```
> Note: the flag form of `account edit` covers connection/auth fields and `--subject-regex`. To ```bash
> toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit --name X` emcli account edit --name work --from 'Work Team <you@yourdomain.com>' # set the send-as address
> with no other flags), or the `whitelist` commands in section 6. emcli account edit --name work --from '' # clear it (revert to username)
```
> Note: the flag form of `account edit` covers connection/auth fields, `--from`, and
> `--subject-regex`. Passing `--from ''` clears the send-as address so mail falls back to the login
> username. To toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit
> --name X` with no other flags), or the `whitelist` commands in section 6.
**Remove an account** (requires `--yes`): **Remove an account** (requires `--yes`):
@@ -421,6 +432,11 @@ emcli send --account gmail --to a@x.com --subject "Re: Hi" --body "thanks" \
client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed
to see. to see.
The message's `From:` is the account's send-as address (`--from`, set on `account add`/`edit`); if
none is configured it falls back to the login username. A display-name address like
`Steve Cliff <me@example.com>` shows the name in the recipient's client while the bare address is
used for the SMTP envelope.
--- ---
## 8. The JSON envelope ## 8. The JSON envelope
@@ -541,8 +557,8 @@ base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`,
`doctor`) need this key. `doctor`) need this key.
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2). **"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
Admin commands (`account`, `whitelist`, `config`, `audit`, `init`) need this key; `EMCLI_KEY` Admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) need this key;
alone is not enough for them. `EMCLI_KEY` alone is not enough for them. (`account list` is the exception — an agent can run it.)
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database **A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
@@ -583,7 +599,7 @@ emcli <command> --help # usage and flags for one command
# Admin (requires EMCLI_ADMIN_KEY) # Admin (requires EMCLI_ADMIN_KEY)
emcli init # create DB + add first account (form) emcli init # create DB + add first account (form)
emcli account add [flags | none for form] # add an account emcli account add [flags | none for form] # add an account
emcli account list # list accounts (no secrets) emcli account list # full table (admin) / name+from+can_send JSON (agent)
emcli account edit --name N [flags | none for form] # change an account emcli account edit --name N [flags | none for form] # change an account
emcli account remove --name N --yes # delete an account emcli account remove --name N --yes # delete an account
emcli whitelist in|out add|remove|list --account N [--address A] emcli whitelist in|out add|remove|list --account N [--address A]
@@ -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) <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:
```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) <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.
@@ -0,0 +1,141 @@
# Agent-readable `account list` — design
**Date:** 2026-06-23
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
An agent process is launched with only `EMCLI_KEY` (the two-key model — see
`2026-06-22-two-key-privilege-design.md`). Every agent command takes
`--account NAME`, but the agent has no way to *discover* which accounts exist:
`account list` is classified admin-only and is refused under the agent key. So
account names must be supplied to the agent out of band, which is brittle and
defeats the point of a self-directed agent.
The two-key spec deliberately gated the whole `account` command to admin because
`account add/edit/remove` mutate configuration. But `account list` is read-only
and exposes no secrets — `store.ListAccounts` never decrypts the password
(`enc_password` is scanned and discarded). Gating *discovery* behind admin is
stricter than the threat model requires.
## Goal
Let an agent holding only `EMCLI_KEY` run `account list` to discover the
accounts it may operate on, while:
- keeping `account add/edit/remove` admin-only (mutation stays gated);
- exposing to the agent only what it needs — **account name, the send-as From
address, and whether the account can send** — and *not* the IMAP host/port or
login username;
- preserving the admin's existing full-detail view unchanged.
## Constraints / decisions
Settled during brainstorming:
1. **Scope is exactly `account list`.** `whitelist list`, `config get`, and
`audit list` stay admin-only. `audit` in particular is oversight data and
must remain invisible to the agent.
2. **Privilege-dependent rendering.** The admin keeps the current full table
(`NAME MODE IMAP USER`, human-readable). The agent gets a *reduced* view
containing only name, From, and send-capability.
3. **Agent output is JSON.** The agent is a machine consumer, so its
`account list` emits the standard agent envelope (like `list`/`get`/`search`),
not a text table. The admin path stays human-readable text.
4. **No secret exposure, no schema change.** `ListAccounts` already avoids
decrypting passwords; nothing about the data model changes.
## Approach
Reclassify `account list` to the agent role, and branch rendering on the
caller's actual privilege (presence of the admin key).
### Routing (`internal/cli/run.go`)
`commandRole` becomes subcommand-aware for `account`:
- `account list``RoleAgent`
- `account add | edit | remove` (and bare `account`) → `RoleAdmin`
- all other commands unchanged.
`commandRole` currently takes `cmd string`; it changes to take the full
`args []string` so it can peek at the `account` subcommand. `Run` passes
`args` through. This keeps `commandRole` the single source of truth for the
classification table.
Authorization mechanics are unchanged: `openStore(RoleAgent)` requires
`EMCLI_KEY` (falling back to the admin key for a human who holds only that and
runs `account list`). `account add/edit/remove` still hard-require
`EMCLI_ADMIN_KEY` with no fallback.
### Rendering (`internal/cli/admin.go`, the `list` branch)
Determine privilege from the environment: `_, err := crypto.AdminKeyFromEnv()`;
`isAdmin := err == nil`. (Holding the admin key *is* being the admin in this
trust model. A human with only the admin key still gets the admin view; an agent
with only `EMCLI_KEY` gets the reduced view.)
- **Admin** → existing full table, unchanged:
```
NAME MODE IMAP USER
work RW imap.example.com:993 me@example.com
```
- **Agent** → JSON envelope to stdout:
```json
{"error":false,"error_detail":{},"data":{"accounts":[
{"name":"work","from":"me@example.com","can_send":true},
{"name":"alerts","from":"alerts@example.com","can_send":false}
]}}
```
where `from = Account.SendFrom()` (the configured From address, falling back
to the username) and `can_send = (Mode == "RW")` (RW accounts have SMTP
configured; RO cannot send). The IMAP host/port and the raw login username are
**not** emitted (when From falls back to the username it may coincide with it,
which is acceptable — the user asked for From specifically).
The reduced view reuses the existing `Success(...)` envelope and `Envelope.Write`
helper; no new output machinery.
## Error handling
- Agent key on `account add/edit/remove` → unchanged:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`, non-zero.
- Agent `account list` with neither key set → the agent-command config error
(`EMCLI_KEY is not set`), surfaced the same way other agent commands surface a
missing key. Because the agent path now emits JSON, a missing-key failure on
`account list` is a JSON `Failure(CodeConfig, …)` envelope, consistent with the
other agent commands.
- DB/list errors on the agent path → `Failure(CodeDB, …)` envelope; on the admin
path → existing `list: <err>` text to stderr.
## Testing
- **Routing:** extend the `commandRole` table test — `account list` → agent;
`account add` / `account edit` / `account remove` → admin.
- **Agent view:** with only `EMCLI_KEY`, `account list` exits 0, emits a valid
envelope, and the `data.accounts` entries carry `name`/`from`/`can_send` — and
the output does **not** contain the IMAP host or the login username.
- **Admin view:** with `EMCLI_ADMIN_KEY`, `account list` still prints the full
`NAME MODE IMAP USER` table (regression guard).
- **`can_send`:** an RW account yields `can_send:true`, an RO account
`can_send:false`; `from` reflects `SendFrom()` (explicit From, else username).
- **Security invariant (`security_invariant_test.go`):** remove
`{"account","list"}` from the refused-commands set (it is now allowed) and
replace it with a *mutating* `account add …` attempt, so the "forced agent
cannot run admin commands and the DB is byte-for-byte unchanged" invariant
still covers the `account` family.
## Documentation updates
- **USER-MANUAL:** role/command table — `account list` is agent-readable
(reduced JSON view); `account add/edit/remove` remain admin.
- **`skills/emcli` (SKILL.md / AGENTIC-MANUAL.md):** document that the agent
discovers accounts via `account list`, including the JSON shape
(`name`, `from`, `can_send`).
## Out of scope
- Agent access to `whitelist list`, `config get`, or `audit list`.
- Any change to the admin `account list` columns or to the data model.
- JSON output for the admin `account list` path (stays human-readable text).
+85
View File
@@ -0,0 +1,85 @@
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)
}
}
+40 -3
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"strconv" "strconv"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui" "git.dcglab.co.uk/steve/emcli/internal/tui"
) )
@@ -23,7 +24,14 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
sub, rest := args[0], args[1:] sub, rest := args[0], args[1:]
st, err := openStore(role) st, err := openStore(role)
if err != nil { if err != nil {
// account list is an agent command (a JSON consumer), so its
// open/key failures are emitted as an envelope, like the other agent
// commands; the admin subcommands stay human-readable.
if sub == "list" {
_ = Failure(CodeConfig, err.Error()).Write(out)
} else {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
}
return 1 return 1
} }
defer st.Close() defer st.Close()
@@ -171,11 +179,31 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
fmt.Fprintf(out, "account %q removed\n", *name) fmt.Fprintf(out, "account %q removed\n", *name)
return 0 return 0
case "list": 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() accs, err := st.ListAccounts()
if err != nil { if err != nil {
if isAdmin {
fmt.Fprintf(errOut, "list: %v\n", err) fmt.Fprintf(errOut, "list: %v\n", err)
} else {
_ = Failure(CodeDB, err.Error()).Write(out)
}
return 1 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") fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs { for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
@@ -302,7 +330,19 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
return 2 return 2
} }
dir := store.Direction(args[0]) dir := store.Direction(args[0])
if dir != store.DirIn && dir != store.DirOut {
fmt.Fprintf(errOut, "whitelist direction must be \"in\" or \"out\", got %q\n", args[0])
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
}
sub, rest := args[1], args[2:] sub, rest := args[1], args[2:]
switch sub {
case "add", "remove", "list": // valid
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list)\n", sub)
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
}
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError) fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
account := fs.String("account", "", "account name") account := fs.String("account", "", "account name")
@@ -343,9 +383,6 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
for _, a := range addrs { for _, a := range addrs {
fmt.Fprintln(out, a) fmt.Fprintln(out, a)
} }
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
return 2
} }
return 0 return 0
} }
+45
View File
@@ -110,6 +110,51 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
} }
} }
// A missing direction (e.g. `whitelist list`) must report the real problem —
// the in|out direction — not the misleading "--account is required".
func TestWhitelistMissingDirectionReported(t *testing.T) {
adminEnv(t)
code, _, errOut := run(t, "whitelist", "list", "--account", "bobby")
if code == 0 {
t.Fatal("missing direction must be a usage error")
}
if strings.Contains(errOut, "--account is required") {
t.Fatalf("misleading error; want a direction complaint, got: %q", errOut)
}
if !strings.Contains(errOut, "in") || !strings.Contains(errOut, "out") {
t.Fatalf("error should name the in|out direction, got: %q", errOut)
}
}
// A missing subcommand (e.g. `whitelist out --account x`) must report the real
// problem — the add|remove|list subcommand — not "--account is required".
func TestWhitelistMissingSubcommandReported(t *testing.T) {
adminEnv(t)
code, _, errOut := run(t, "whitelist", "out", "--account", "bobby")
if code == 0 {
t.Fatal("missing subcommand must be a usage error")
}
if strings.Contains(errOut, "--account is required") {
t.Fatalf("misleading error; want a subcommand complaint, got: %q", errOut)
}
if !strings.Contains(errOut, "add") || !strings.Contains(errOut, "list") {
t.Fatalf("error should name the add|remove|list subcommand, got: %q", errOut)
}
}
// The happy path still works after the direction/subcommand validation.
func TestWhitelistListWorks(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "bobby", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "whitelist", "out", "add", "--account", "bobby", "--address", "@x.com"); code != 0 {
t.Fatalf("add failed: %s", e)
}
code, out, _ := run(t, "whitelist", "out", "list", "--account", "bobby")
if code != 0 || !strings.Contains(out, "@x.com") {
t.Fatalf("list: code=%d out=%q", code, out)
}
}
func TestAuditListCoreRenders(t *testing.T) { func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil { if err != nil {
+12 -6
View File
@@ -9,16 +9,22 @@ import (
) )
func TestCommandRole(t *testing.T) { func TestCommandRole(t *testing.T) {
admin := []string{"account", "whitelist", "config", "audit"} adminCmds := [][]string{
agent := []string{"list", "get", "search", "ack", "send", "doctor"} {"whitelist"}, {"config"}, {"audit"},
for _, c := range admin { {"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 { if commandRole(c) != store.RoleAdmin {
t.Errorf("%s should be admin", c) t.Errorf("%v should be admin", c)
} }
} }
for _, c := range agent { for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent { if commandRole(c) != store.RoleAgent {
t.Errorf("%s should be agent", c) t.Errorf("%v should be agent", c)
} }
} }
} }
+11 -4
View File
@@ -27,9 +27,16 @@ func realMailer(acc store.Account) (Mailer, error) {
// commandRole maps a command to the privilege it requires. Admin commands // commandRole maps a command to the privilege it requires. Admin commands
// mutate configuration or expose oversight data; everything else is agent. // mutate configuration or expose oversight data; everything else is agent.
func commandRole(cmd string) store.Role { func commandRole(args []string) store.Role {
switch cmd { switch args[0] {
case "account", "whitelist", "config", "audit": 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 return store.RoleAdmin
default: // list, get, search, ack, send, doctor default: // list, get, search, ack, send, doctor
return store.RoleAgent return store.RoleAgent
@@ -135,7 +142,7 @@ func Run(args []string, out, errOut io.Writer) int {
return 0 return 0
} }
cmd, rest := args[0], args[1:] cmd, rest := args[0], args[1:]
role := commandRole(cmd) role := commandRole(args)
switch cmd { switch cmd {
case "list", "get", "search", "ack": case "list", "get", "search", "ack":
return runAgent(cmd, rest, role, out, errOut) return runAgent(cmd, rest, role, out, errOut)
+12 -5
View File
@@ -18,9 +18,9 @@ func TestRunUnknownCommand(t *testing.T) {
} }
} }
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) { func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) {
// `account list` with no DB key should fail closed with a usage/config error, // `account list` is an agent command: with no DB key it fails closed before
// proving the key check happens before any DB work. // any DB work, emitting a JSON config-error envelope that names EMCLI_KEY.
var out, errOut bytes.Buffer var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", "") t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "") t.Setenv("EMCLI_ADMIN_KEY", "")
@@ -28,8 +28,15 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
if code == 0 { if code == 0 {
t.Fatal("missing EMCLI_KEY must fail") t.Fatal("missing EMCLI_KEY must fail")
} }
if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") { var env map[string]any
t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String()) if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("agent account list error must be JSON, got out=%q err=%q", out.String(), errOut.String())
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
if !strings.Contains(out.String(), "EMCLI_KEY") {
t.Fatalf("should name the missing EMCLI_KEY, got %q", out.String())
} }
} }
+1 -1
View File
@@ -49,7 +49,7 @@ func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
before := dbBytes(t, db) before := dbBytes(t, db)
adminAttempts := [][]string{ adminAttempts := [][]string{
{"account", "list"}, {"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"}, {"config", "set", "audit_retention_days", "30"},
{"audit"}, {"audit"},
} }
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -77,10 +78,24 @@ func (s *Store) migrate() error {
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
// expandUserHome replaces a leading "~" or "~/" in p with the user's home
// directory. Only a leading tilde is expanded (the usual shell convention) —
// "~user" and a tilde elsewhere in the path are left untouched. This guards
// against an EMCLI_DB set to a literal "~/..." (no shell to expand it), which
// would otherwise be opened relative to the cwd and create a stray "~" dir.
func expandUserHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
}
}
return p
}
// DefaultDBPath resolves EMCLI_DB or the per-OS default location. // DefaultDBPath resolves EMCLI_DB or the per-OS default location.
func DefaultDBPath() (string, error) { func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" { if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil return expandUserHome(p), nil
} }
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" { if dir := os.Getenv("AppData"); dir != "" {
+43
View File
@@ -2,10 +2,53 @@ package store
import ( import (
"database/sql" "database/sql"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
// A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
// tilde (no shell to expand it) can't be opened relative to the cwd and
// silently create a stray "~" directory.
func TestDefaultDBPathExpandsLeadingTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cases := map[string]string{
"~/.config/emcli/emcli.db": filepath.Join(home, ".config", "emcli", "emcli.db"),
"~": home,
}
for in, want := range cases {
t.Setenv("EMCLI_DB", in)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", in, err)
}
if got != want {
t.Fatalf("EMCLI_DB=%q -> %q, want %q", in, got, want)
}
if strings.Contains(got, "~") {
t.Fatalf("EMCLI_DB=%q left a literal tilde: %q", in, got)
}
}
}
// A non-leading tilde or "~user" is NOT a path we should rewrite — leave it be.
func TestDefaultDBPathLeavesOtherPathsUntouched(t *testing.T) {
for _, p := range []string{"/var/lib/emcli.db", "./rel/emcli.db", "~user/db"} {
t.Setenv("EMCLI_DB", p)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", p, err)
}
if got != p {
t.Fatalf("EMCLI_DB=%q was rewritten to %q", p, got)
}
}
}
// openTemp opens a fresh store in a temp dir and initialises keys so that // openTemp opens a fresh store in a temp dir and initialises keys so that
// account tests (which do crypto) work without needing their own setup. // account tests (which do crypto) work without needing their own setup.
func openTemp(t *testing.T) *Store { func openTemp(t *testing.T) *Store {
+16 -5
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `EMCLI_VERSION` | `v0.5.0` | Release tag to fetch | | `EMCLI_VERSION` | `v0.5.2` | Release tag to fetch |
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL | | `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location | | `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
@@ -85,16 +85,27 @@ with both keys exported. Account creation and other admin is the human's job —
## 4. Find the account(s) ## 4. Find the account(s)
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use. You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
configured accounts connect and authenticate: 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 ```bash
emcli doctor # all accounts emcli doctor # all accounts
emcli doctor --account gmail emcli doctor --account gmail
``` ```
Just take the account name from the user and start with the workflow in `SKILL.md`. Then start with the workflow in `SKILL.md`.
## You're set up ## You're set up
+11 -9
View File
@@ -17,12 +17,12 @@ sets its exit code to match.
## Security model — read this first ## Security model — read this first
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are - **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else. `account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`, whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
with a privilege error. `init`. `emcli` will refuse those with a privilege error.
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched - **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
missing, stop and tell the user (see "Files & first run"). missing, stop and tell the user (see "Files & first run").
@@ -120,6 +120,7 @@ read-only — tell the user; do not attempt another account without their say-so
| `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search | | `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed | | `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply | | `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g. Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output. `2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
@@ -142,8 +143,9 @@ The user configures these; you cannot change them and shouldn't try.
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user. - ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
- ✅ `get` to read, then `ack` only after you've truly processed a message. - ✅ `get` to read, then `ack` only after you've truly processed a message.
- ✅ Ask the user for the account name; keep bodies plain text. - ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password. - ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only - ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error. you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy. - ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh # bash install.sh
# #
# Environment overrides: # Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.5.0) # EMCLI_VERSION release tag to fetch (default: v0.5.2)
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli) # EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin) # EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
# #
# Release assets follow this naming scheme: # Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64 # emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.2_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset) # checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.5.0}" VERSION="${EMCLI_VERSION:-v0.5.2}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}" BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}" INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"