diff --git a/USER-MANUAL.md b/USER-MANUAL.md index 75a6c87..e39d078 100644 --- a/USER-MANUAL.md +++ b/USER-MANUAL.md @@ -524,6 +524,10 @@ running non-interactively. ## 12. Command cheat sheet ``` +# Help +emcli # or: emcli help / emcli --help — list all commands +emcli --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 diff --git a/internal/cli/admin.go b/internal/cli/admin.go index d251bd5..e387c18 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -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 ") + 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 ` and `config get `. 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 [value]") return 2 @@ -236,6 +247,10 @@ func runConfig(args []string, out, errOut io.Writer) int { // runAudit handles `audit list [--account ] [--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 ] [--limit N]") return 2 @@ -262,6 +277,13 @@ func runAudit(args []string, out, errOut io.Writer) int { // runWhitelist handles `whitelist 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 [flags]") return 2 diff --git a/internal/cli/help.go b/internal/cli/help.go new file mode 100644 index 0000000..36f32e7 --- /dev/null +++ b/internal/cli/help.go @@ -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 [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."}, + {"get", "get --account [--folder F] --uid ", "Fetch one full message (body + attachments)."}, + {"search", "search --account [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."}, + {"ack", "ack --account [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."}, + {"send", "send --account --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 [flags]", "Manage accounts (add/edit accept flags, or run with none for an interactive form)."}, + {"whitelist", "whitelist --account [--address A]", "Manage inbound/outbound whitelists."}, + {"config", "config [value]", "Get or set global settings (e.g. audit_retention_days)."}, + {"audit", "audit list [--account ] [--limit N]", "Show recent audit-log entries."}, + {"doctor", "doctor [--account ]", "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 [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 --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 " 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() + } +} diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go new file mode 100644 index 0000000..7d2a964 --- /dev/null +++ b/internal/cli/help_test.go @@ -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) + } + } +} diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index 58fc20c..acd6012 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -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) diff --git a/internal/cli/run.go b/internal/cli/run.go index e446603..0d24352 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -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 `. + 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 }