feat(cli): add help for all commands
emcli had only raw flag usage and no command listing; `--help` on agent commands even emitted a JSON error envelope and exited 2. Add real help: - Top-level `emcli` / `help` / `-h` / `--help` prints a grouped command catalogue (agent vs admin) with one-line summaries and the EMCLI_KEY/EMCLI_DB env vars. - `emcli help <command>` prints that command's synopsis + summary. - `emcli <command> --help` prints synopsis + summary + flags and exits 0. Agent commands keep stdout JSON-free (usage goes to stderr); admin commands print to stdout. Help works without EMCLI_KEY (no DB access). - help.go holds the command catalogue; flag.ErrHelp is handled as success, and admin handlers short-circuit help before opening the store. Unknown commands still error (exit 2). Full suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -524,6 +524,10 @@ running non-interactively.
|
|||||||
## 12. Command cheat sheet
|
## 12. Command cheat sheet
|
||||||
|
|
||||||
```
|
```
|
||||||
|
# Help
|
||||||
|
emcli # or: emcli help / emcli --help — list all commands
|
||||||
|
emcli <command> --help # usage and flags for one command
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
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
|
||||||
|
|||||||
+24
-2
@@ -12,8 +12,12 @@ import (
|
|||||||
|
|
||||||
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
||||||
func runAccount(args []string, out, errOut io.Writer) int {
|
func runAccount(args []string, out, errOut io.Writer) int {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
fmt.Fprintln(errOut, "usage: emcli account <add|list>")
|
printCmdUsage(out, "account")
|
||||||
|
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
||||||
|
if len(args) > 0 {
|
||||||
|
return 0 // explicit --help
|
||||||
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, rest := args[0], args[1:]
|
sub, rest := args[0], args[1:]
|
||||||
@@ -188,6 +192,13 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
|
|||||||
|
|
||||||
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
||||||
func runConfig(args []string, out, errOut io.Writer) int {
|
func runConfig(args []string, out, errOut io.Writer) int {
|
||||||
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
|
printCmdUsage(out, "config")
|
||||||
|
if len(args) > 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
|
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
|
||||||
return 2
|
return 2
|
||||||
@@ -236,6 +247,10 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
|||||||
|
|
||||||
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
||||||
func runAudit(args []string, out, errOut io.Writer) int {
|
func runAudit(args []string, out, errOut io.Writer) int {
|
||||||
|
if len(args) > 0 && helpRequested(args[0]) {
|
||||||
|
printCmdUsage(out, "audit")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
if len(args) == 0 || args[0] != "list" {
|
if len(args) == 0 || args[0] != "list" {
|
||||||
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
|
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
|
||||||
return 2
|
return 2
|
||||||
@@ -262,6 +277,13 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
|||||||
|
|
||||||
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
||||||
func runWhitelist(args []string, out, errOut io.Writer) int {
|
func runWhitelist(args []string, out, errOut io.Writer) int {
|
||||||
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
|
printCmdUsage(out, "whitelist")
|
||||||
|
if len(args) > 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cmdHelp struct {
|
||||||
|
name string
|
||||||
|
synopsis string
|
||||||
|
summary string
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentCmds emit machine-readable JSON; adminCmds are human-readable.
|
||||||
|
var agentCmds = []cmdHelp{
|
||||||
|
{"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."},
|
||||||
|
{"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."},
|
||||||
|
{"search", "search --account <name> [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
|
||||||
|
{"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."},
|
||||||
|
{"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."},
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminCmds = []cmdHelp{
|
||||||
|
{"init", "init", "Create the database and add the first account (interactive)."},
|
||||||
|
{"account", "account <add|edit|remove|list> [flags]", "Manage accounts (add/edit accept flags, or run with none for an interactive form)."},
|
||||||
|
{"whitelist", "whitelist <in|out> <add|remove|list> --account <name> [--address A]", "Manage inbound/outbound whitelists."},
|
||||||
|
{"config", "config <set|get> <key> [value]", "Get or set global settings (e.g. audit_retention_days)."},
|
||||||
|
{"audit", "audit list [--account <name>] [--limit N]", "Show recent audit-log entries."},
|
||||||
|
{"doctor", "doctor [--account <name>]", "Check each account's IMAP/SMTP connectivity and auth."},
|
||||||
|
{"version", "version", "Print the emcli version."},
|
||||||
|
{"help", "help [command]", "Show this help, or detailed usage for one command."},
|
||||||
|
}
|
||||||
|
|
||||||
|
func helpIndex() map[string]cmdHelp {
|
||||||
|
m := make(map[string]cmdHelp, len(agentCmds)+len(adminCmds))
|
||||||
|
for _, c := range append(append([]cmdHelp{}, agentCmds...), adminCmds...) {
|
||||||
|
m[c.name] = c
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpRequested reports whether an argument is a help flag/word.
|
||||||
|
func helpRequested(s string) bool {
|
||||||
|
return s == "help" || s == "-h" || s == "--help"
|
||||||
|
}
|
||||||
|
|
||||||
|
// printMainHelp writes the top-level command catalogue.
|
||||||
|
func printMainHelp(w io.Writer) {
|
||||||
|
fmt.Fprint(w, "emcli — guard-railed email gateway for agents\n\n")
|
||||||
|
fmt.Fprint(w, "Usage:\n emcli <command> [flags]\n\n")
|
||||||
|
fmt.Fprint(w, "Agent commands (machine-readable JSON on stdout):\n")
|
||||||
|
for _, c := range agentCmds {
|
||||||
|
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "\nAdmin commands (human-readable):\n")
|
||||||
|
for _, c := range adminCmds {
|
||||||
|
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "\nRun \"emcli <command> --help\" for a command's flags.\n")
|
||||||
|
fmt.Fprint(w, "\nEnvironment:\n")
|
||||||
|
fmt.Fprint(w, " EMCLI_KEY base64-encoded 32-byte AES key; required for any command that uses the database\n")
|
||||||
|
fmt.Fprint(w, " EMCLI_DB database path (default ~/.config/emcli/emcli.db; %AppData%\\emcli\\emcli.db on Windows)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCmdUsage writes "Usage: emcli <synopsis>" and the summary for one command.
|
||||||
|
func printCmdUsage(w io.Writer, name string) {
|
||||||
|
if h, ok := helpIndex()[name]; ok {
|
||||||
|
fmt.Fprintf(w, "Usage: emcli %s\n\n%s\n", h.synopsis, h.summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Usage: emcli %s\n", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageFlags makes a flag set print the command's synopsis/summary followed by
|
||||||
|
// its flags whenever flag prints usage (on -h/--help or a flag error).
|
||||||
|
func usageFlags(fs *flag.FlagSet, name string, w io.Writer) {
|
||||||
|
fs.Usage = func() {
|
||||||
|
printCmdUsage(w, name)
|
||||||
|
fmt.Fprintln(w, "\nFlags:")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainHelpListsAllCommands(t *testing.T) {
|
||||||
|
// help / --help / -h / no-args all print the command catalogue, exit 0,
|
||||||
|
// and require no EMCLI_KEY (help must work before any DB access).
|
||||||
|
for _, args := range [][]string{{"help"}, {"--help"}, {"-h"}, {}} {
|
||||||
|
code, out, errOut := run(t, args...)
|
||||||
|
text := out + errOut
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Usage", "list", "get", "search", "ack", "send",
|
||||||
|
"account", "whitelist", "config", "audit", "doctor", "version",
|
||||||
|
"EMCLI_KEY", "EMCLI_DB",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("%v: help missing %q\n%s", args, want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpForSpecificCommand(t *testing.T) {
|
||||||
|
code, out, errOut := run(t, "help", "send")
|
||||||
|
text := out + errOut
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("help send exit=%d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "Usage: emcli send") || !strings.Contains(text, "--to") {
|
||||||
|
t.Fatalf("help send missing synopsis:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentHelpDoesNotEmitJSON(t *testing.T) {
|
||||||
|
// `list --help` must NOT print a JSON envelope on stdout (an agent parses
|
||||||
|
// stdout) and must exit 0 — even with no EMCLI_KEY set.
|
||||||
|
code, out, errOut := run(t, "list", "--help")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("list --help exit=%d (out=%q err=%q)", code, out, errOut)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) != "" {
|
||||||
|
t.Fatalf("agent help must keep stdout clean, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "Usage: emcli list") || !strings.Contains(errOut, "--account") {
|
||||||
|
t.Fatalf("list --help should print usage+flags on stderr:\n%s", errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendHelpExitsZero(t *testing.T) {
|
||||||
|
code, _, errOut := run(t, "send", "--help")
|
||||||
|
if code != 0 || !strings.Contains(errOut, "--to") {
|
||||||
|
t.Fatalf("send --help: code=%d err=%q", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCommandHelpExitsZero(t *testing.T) {
|
||||||
|
for _, c := range []string{"account", "whitelist", "config", "audit", "doctor"} {
|
||||||
|
code, out, errOut := run(t, c, "--help")
|
||||||
|
text := out + errOut
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("%s --help exit=%d\n%s", c, code, text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "Usage: emcli "+c) {
|
||||||
|
t.Fatalf("%s --help missing usage line:\n%s", c, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,10 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
|
|||||||
// runInit creates/opens the DB and adds the first account via the TUI form,
|
// runInit creates/opens the DB and adds the first account via the TUI form,
|
||||||
// seeding a default audit retention if unset.
|
// seeding a default audit retention if unset.
|
||||||
func runInit(args []string, out, errOut io.Writer) int {
|
func runInit(args []string, out, errOut io.Writer) int {
|
||||||
|
if len(args) > 0 && helpRequested(args[0]) {
|
||||||
|
printCmdUsage(out, "init")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
st, err := openStore()
|
st, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
|||||||
+21
-3
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -70,8 +71,12 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
|
|||||||
func runDoctor(args []string, out, errOut io.Writer) int {
|
func runDoctor(args []string, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
|
usageFlags(fs, "doctor", errOut)
|
||||||
account := fs.String("account", "", "check only this account")
|
account := fs.String("account", "", "check only this account")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore()
|
||||||
@@ -89,9 +94,14 @@ func runDoctor(args []string, out, errOut io.Writer) int {
|
|||||||
|
|
||||||
// Run routes a command line and returns an exit code.
|
// Run routes a command line and returns an exit code.
|
||||||
func Run(args []string, out, errOut io.Writer) int {
|
func Run(args []string, out, errOut io.Writer) int {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
fmt.Fprintln(errOut, "emcli: no command given")
|
// `emcli`, `emcli help`, `emcli -h`, `emcli --help`, and `emcli help <cmd>`.
|
||||||
return 2
|
if len(args) >= 2 {
|
||||||
|
printCmdUsage(out, args[1])
|
||||||
|
} else {
|
||||||
|
printMainHelp(out)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
cmd, rest := args[0], args[1:]
|
cmd, rest := args[0], args[1:]
|
||||||
switch cmd {
|
switch cmd {
|
||||||
@@ -121,6 +131,7 @@ func Run(args []string, out, errOut io.Writer) int {
|
|||||||
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
|
usageFlags(fs, cmd, errOut)
|
||||||
account := fs.String("account", "", "account name")
|
account := fs.String("account", "", "account name")
|
||||||
folder := fs.String("folder", "INBOX", "folder/mailbox")
|
folder := fs.String("folder", "INBOX", "folder/mailbox")
|
||||||
onlyNew := fs.Bool("new", false, "only new (unacked) messages")
|
onlyNew := fs.Bool("new", false, "only new (unacked) messages")
|
||||||
@@ -135,6 +146,9 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
|||||||
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
|
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
|
||||||
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
|
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0 // usage already printed to stderr; help isn't an error
|
||||||
|
}
|
||||||
_ = Failure(CodeUsage, err.Error()).Write(out)
|
_ = Failure(CodeUsage, err.Error()).Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -213,6 +227,7 @@ func (s *stringSlice) Set(v string) error {
|
|||||||
func runSend(args []string, out, errOut io.Writer) int {
|
func runSend(args []string, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
|
usageFlags(fs, "send", errOut)
|
||||||
account := fs.String("account", "", "account name")
|
account := fs.String("account", "", "account name")
|
||||||
var to, cc, bcc, attach stringSlice
|
var to, cc, bcc, attach stringSlice
|
||||||
fs.Var(&to, "to", "recipient (repeatable / comma-separated)")
|
fs.Var(&to, "to", "recipient (repeatable / comma-separated)")
|
||||||
@@ -224,6 +239,9 @@ func runSend(args []string, out, errOut io.Writer) int {
|
|||||||
replyTo := fs.Uint("reply-to", 0, "source UID to reply to (threading)")
|
replyTo := fs.Uint("reply-to", 0, "source UID to reply to (threading)")
|
||||||
folder := fs.String("folder", "INBOX", "folder of the reply source")
|
folder := fs.String("folder", "INBOX", "folder of the reply source")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
_ = Failure(CodeUsage, err.Error()).Write(out)
|
_ = Failure(CodeUsage, err.Error()).Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user