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
|
||||
|
||||
```
|
||||
# Help
|
||||
emcli # or: emcli help / emcli --help — list all commands
|
||||
emcli <command> --help # usage and flags for one command
|
||||
|
||||
# Admin
|
||||
emcli init # create DB + add first account (form)
|
||||
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).
|
||||
func runAccount(args []string, out, errOut io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(errOut, "usage: emcli account <add|list>")
|
||||
if len(args) == 0 || helpRequested(args[0]) {
|
||||
printCmdUsage(out, "account")
|
||||
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
||||
if len(args) > 0 {
|
||||
return 0 // explicit --help
|
||||
}
|
||||
return 2
|
||||
}
|
||||
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>`.
|
||||
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 {
|
||||
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
|
||||
return 2
|
||||
@@ -236,6 +247,10 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
||||
|
||||
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
||||
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" {
|
||||
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
|
||||
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`.
|
||||
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 {
|
||||
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
||||
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,
|
||||
// seeding a default audit retention if unset.
|
||||
func runInit(args []string, out, errOut io.Writer) int {
|
||||
if len(args) > 0 && helpRequested(args[0]) {
|
||||
printCmdUsage(out, "init")
|
||||
return 0
|
||||
}
|
||||
st, err := openStore()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
|
||||
+21
-3
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -70,8 +71,12 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
|
||||
func runDoctor(args []string, out, errOut io.Writer) int {
|
||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
usageFlags(fs, "doctor", errOut)
|
||||
account := fs.String("account", "", "check only this account")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
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.
|
||||
func Run(args []string, out, errOut io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(errOut, "emcli: no command given")
|
||||
return 2
|
||||
if len(args) == 0 || helpRequested(args[0]) {
|
||||
// `emcli`, `emcli help`, `emcli -h`, `emcli --help`, and `emcli help <cmd>`.
|
||||
if len(args) >= 2 {
|
||||
printCmdUsage(out, args[1])
|
||||
} else {
|
||||
printMainHelp(out)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
cmd, rest := args[0], args[1:]
|
||||
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 {
|
||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
usageFlags(fs, cmd, errOut)
|
||||
account := fs.String("account", "", "account name")
|
||||
folder := fs.String("folder", "INBOX", "folder/mailbox")
|
||||
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")
|
||||
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
|
||||
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)
|
||||
return 2
|
||||
}
|
||||
@@ -213,6 +227,7 @@ func (s *stringSlice) Set(v string) error {
|
||||
func runSend(args []string, out, errOut io.Writer) int {
|
||||
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
usageFlags(fs, "send", errOut)
|
||||
account := fs.String("account", "", "account name")
|
||||
var to, cc, bcc, attach stringSlice
|
||||
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)")
|
||||
folder := fs.String("folder", "INBOX", "folder of the reply source")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
_ = Failure(CodeUsage, err.Error()).Write(out)
|
||||
return 2
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user