diff --git a/README.md b/README.md index 83b43aa9..8e5e188e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,25 @@ That command is for cosign v3. With cosign v2.6–v2.x add `--new-bundle-format= +## Help and reference + +The root help groups commands by the work they do and keeps the common output flags concise: + +```bash +hey --help # browse the command summary +hey compose --help # see a command's usage, flags, and examples +hey commands # list the complete executable command catalog +``` + +Cross-cutting references are available as help topics: + +```bash +hey help output # output formats, selectors, and jq filtering +hey help exit-codes # stable process exit statuses +hey help environment # supported HEY_* environment variables +hey help linked-accounts # account selection and precedence +``` + ## Upgrading ```bash diff --git a/internal/cmd/help.go b/internal/cmd/help.go index c5d45da1..5cac62d2 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -16,19 +16,31 @@ var curatedCategories = []struct { names []string }{ { - heading: "INTERACTIVE", - names: []string{"tui"}, + heading: "CORE COMMANDS", + names: []string{"tui", "box", "threads", "reply", "compose", "search", "contacts", "boxes", "calendars", "todo", "journal"}, }, { - heading: "EMAIL", - names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "clips", "clip", "snippets", "snippet", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, + heading: "MAIL", + names: []string{"screener", "attachments", "drafts", "watch"}, + }, + { + heading: "WRITE & SHARE", + names: []string{"bulk-reply", "forward", "share", "unshare"}, + }, + { + heading: "SAVED CONTENT", + names: []string{"clips", "clip", "snippets", "snippet"}, + }, + { + heading: "ORGANIZE", + names: []string{"labels", "label", "collections", "collection", "workflows", "workflow", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, }, { heading: "CALENDAR & TASKS", - names: []string{"calendars", "recordings", "todo", "habit", "timetrack", "journal"}, + names: []string{"recordings", "habit", "timetrack"}, }, { - heading: "AUTH & CONFIG", + heading: "ACCOUNT & SYSTEM", names: []string{"auth", "accounts", "config", "setup", "doctor", "upgrade", "version"}, }, } @@ -38,6 +50,42 @@ type helpEntry struct { desc string } +func configureHelpCommand(root *cobra.Command) { + root.InitDefaultHelpCmd() + help, _, err := root.Find([]string{"help"}) + if err != nil { + panic(err) + } + help.Hidden = true + help.ValidArgsFunction = completeHelpReference +} + +func completeHelpReference(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + target := cmd.Root() + if len(args) > 0 { + found, _, err := target.Find(args) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + target = found + } + + var completions []cobra.Completion + for _, sub := range target.Commands() { + if sub.Hidden || (!sub.IsAvailableCommand() && !sub.IsAdditionalHelpTopicCommand()) { + continue + } + if strings.HasPrefix(sub.Name(), toComplete) { + completions = append(completions, cobra.CompletionWithDesc(sub.Name(), sub.Short)) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp +} + +func isHelpReference(cmd *cobra.Command) bool { + return cmd.Name() == "help" || cmd.IsAdditionalHelpTopicCommand() +} + // customHelpFunc returns a help function that renders styled help for all // commands: agent JSON when --agent is set, curated categories for root, // and a consistent styled layout for every subcommand. @@ -51,6 +99,10 @@ func customHelpFunc(defaultHelp func(*cobra.Command, []string)) func(*cobra.Comm renderRootHelp(cmd.OutOrStdout(), cmd) return } + if cmd.IsAdditionalHelpTopicCommand() { + renderHelpTopic(cmd) + return + } renderCommandHelp(cmd) } } @@ -97,24 +149,40 @@ func renderRootHelp(w io.Writer, cmd *cobra.Command) { } } + // HELP TOPICS + b.WriteString("\n") + b.WriteString(bold.format("HELP TOPICS") + "\n") + for _, name := range curatedHelpTopics { + topic := registered[name] + if topic == nil { + continue + } + fmt.Fprintf(&b, " %-15s %s\n", topic.Name(), topic.Short) + } + // FLAGS — the global flags, as registered on the root command b.WriteString("\n") b.WriteString(bold.format("FLAGS") + "\n") for _, f := range globalFlags(cmd) { - writeFlagLine(&b, f.Shorthand, "--"+f.Name, f.Usage) + description := f.Usage + if concise, ok := rootFlagDescriptions[f.Name]; ok { + description = concise + } + writeFlagLine(&b, f.Shorthand, "--"+f.Name, description) } // Cobra owns --help and --version, so neither is a persistent flag to read. - writeFlagLine(&b, "", "--help", "Show help") + writeFlagLine(&b, "h", "--help", "Show help") writeFlagLine(&b, "", "--version", "Show version") // EXAMPLES b.WriteString("\n") b.WriteString(bold.format("EXAMPLES") + "\n") examples := []string{ - "$ hey boxes", + "$ hey tui", "$ hey box imbox", - "$ hey threads 123", `$ hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?"`, + "$ hey todo list", + "$ hey threads 123 --json", } for _, ex := range examples { b.WriteString(italic.format(" "+ex) + "\n") @@ -123,12 +191,17 @@ func renderRootHelp(w io.Writer, cmd *cobra.Command) { // LEARN MORE b.WriteString("\n") b.WriteString(bold.format("LEARN MORE") + "\n") - b.WriteString(" hey commands List all available commands\n") - b.WriteString(" hey -h Help for any command\n") + b.WriteString(" hey commands List all available commands\n") + b.WriteString(" hey help Read a help topic\n") + b.WriteString(" hey --help Help for any command\n") fmt.Fprint(w, b.String()) } +func renderHelpTopic(cmd *cobra.Command) { + fmt.Fprintln(cmd.OutOrStdout(), cmd.Long) +} + // renderCommandHelp renders styled help for any non-root command, reading // structure from cobra's command tree rather than hardcoding per-command. func renderCommandHelp(cmd *cobra.Command) { @@ -148,11 +221,12 @@ func renderCommandHelp(cmd *cobra.Command) { // USAGE b.WriteString("\n") b.WriteString(bold.format("USAGE") + "\n") - if cmd.HasAvailableSubCommands() && !cmd.Runnable() { - b.WriteString(" " + cmd.CommandPath() + " [flags]\n") - } else { + if cmd.Runnable() { b.WriteString(" " + cmd.UseLine() + "\n") } + if cmd.HasAvailableSubCommands() { + b.WriteString(" " + cmd.CommandPath() + " [flags]\n") + } // ALIASES if len(cmd.Aliases) > 0 { @@ -237,6 +311,21 @@ func renderCommandHelp(cmd *cobra.Command) { // here still gets listed, at the end. var globalFlagReadingOrder = []string{"account", "json", "jq", "markdown", "quiet", "ids-only", "count", "styled", "html", "stats", "base-url", "verbose"} +var rootFlagDescriptions = map[string]string{ + "account": "Select a linked mail account", + "json": "Output a JSON response envelope", + "jq": "Filter JSON with a jq expression", + "markdown": "Output Markdown", + "quiet": "Output result data without the response envelope", + "ids-only": "Output only IDs, one per line", + "count": "Output only the result count", + "styled": "Force human-readable terminal output", + "html": "Write original HTML", + "stats": "Include request statistics", + "base-url": "Override the server URL", + "verbose": "Show request details", +} + // globalFlags returns the root command's persistent flags — every flag help // calls global, described by the registration in root.go — in reading order, // leaving out the hidden ones. diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index f00fb408..f0eb03c9 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -3,6 +3,8 @@ package cmd import ( "bytes" "fmt" + "os" + "path/filepath" "strings" "testing" @@ -93,34 +95,44 @@ USAGE hey [flags] hey tui Open the interactive app -INTERACTIVE - tui Launch the interactive terminal UI +CORE COMMANDS + tui Launch the interactive terminal UI + box List email threads in a box + threads Read a thread + reply Reply to a thread + compose Write and send a new email + search Search email threads and messages + contacts Manage contacts + boxes List your HEY boxes + calendars List calendars + todo Create and manage to-dos + journal Read and write journal entries -EMAIL - boxes List your HEY boxes - box List email threads in a box +MAIL + screener Decide who gets to email you + attachments List and save files from a thread + drafts List draft emails + watch Follow email threads as they change + +WRITE & SHARE + bulk-reply Reply to multiple email threads + forward Forward the latest message in a thread + share Get a sharing link for an email thread + unshare Turn off an email thread's sharing link + +SAVED CONTENT + clips List the newest page of passages clipped from email + clip Save and manage passages from email + snippets List reusable email snippets + snippet Create and manage reusable email snippets + +ORGANIZE labels List your email labels label View and manage an email label collections List your email collections collection View and manage an email collection workflows List your email workflows workflow View and manage an email workflow - clips List the newest page of passages clipped from email - clip Save and manage passages from email - snippets List reusable email snippets - snippet Create and manage reusable email snippets - search Search email threads and messages - contacts Manage contacts - screener Decide who gets to email you - threads Read a thread - share Get a sharing link for an email thread - unshare Turn off an email thread's sharing link - attachments List and save files from a thread - compose Write and send a new email - reply Reply to a thread - bulk-reply Reply to multiple email threads - forward Forward the latest message in a thread - drafts List draft emails seen Mark email threads as seen unseen Mark email threads as unseen move Move email threads to another box @@ -128,17 +140,13 @@ EMAIL spam Mark email threads as spam ignore Ignore email threads stop-ignoring Stop ignoring email threads - watch Follow email threads as they change CALENDAR & TASKS - calendars List calendars recordings List events, to-dos, and other calendar entries - todo Create and manage to-dos habit Create and manage habits timetrack Track time - journal Read and write journal entries -AUTH & CONFIG +ACCOUNT & SYSTEM auth Sign in, sign out, and check login status accounts List and select linked mail accounts config View and change settings @@ -147,31 +155,39 @@ AUTH & CONFIG upgrade Upgrade hey to the latest release version Show the installed hey version +HELP TOPICS + output Output formats and filtering + exit-codes Exit status reference + environment Environment variable reference + linked-accounts Linked account selection + FLAGS - --account Select a linked mail account ID or all - --json Output JSON with metadata - --jq Filter JSON with a built-in jq expression - --markdown Output Markdown: a table for a listing, a document for a thread - --quiet Output result data only + --account Select a linked mail account + --json Output a JSON response envelope + --jq Filter JSON with a jq expression + --markdown Output Markdown + --quiet Output result data without the response envelope --ids-only Output only IDs, one per line - --count Output only the count of results - --styled Human rendering, bodies as rendered Markdown — the default on a terminal; forces it when piped - --html Write the original HTML to a pipe or file (threads, journal read, contacts show, contacts note show) - --stats Include request stats in response meta - --base-url Override server URL + --count Output only the result count + --styled Force human-readable terminal output + --html Write original HTML + --stats Include request statistics + --base-url Override the server URL -v, --verbose Show request details - --help Show help + -h, --help Show help --version Show version EXAMPLES - $ hey boxes + $ hey tui $ hey box imbox - $ hey threads 123 $ hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?" + $ hey todo list + $ hey threads 123 --json LEARN MORE - hey commands List all available commands - hey -h Help for any command + hey commands List all available commands + hey help Read a help topic + hey --help Help for any command ` if output.String() != expected { @@ -179,6 +195,138 @@ LEARN MORE } } +func TestRootHelpStaysScannable(t *testing.T) { + originalColorDisabled := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = originalColorDisabled }) + + var output strings.Builder + renderRootHelp(&output, newRootCmd()) + for _, line := range strings.Split(output.String(), "\n") { + if len(line) > 100 { + t.Errorf("root help line is %d columns, want no more than 100:\n%s", len(line), line) + } + } + + seen := make(map[string]string) + for _, category := range curatedCategories { + if len(category.names) > 13 { + t.Errorf("%s has %d commands, want no more than 13", category.heading, len(category.names)) + } + for _, name := range category.names { + if previous := seen[name]; previous != "" { + t.Errorf("%s appears in both %s and %s", name, previous, category.heading) + } + seen[name] = category.heading + } + } +} + +func TestHelpTopicsAreDiscoverableReferences(t *testing.T) { + root := newRootCmd() + catalog := walkCommands(root, "") + + for _, name := range curatedHelpTopics { + t.Run(name, func(t *testing.T) { + topic, _, err := root.Find([]string{name}) + if err != nil { + t.Fatal(err) + } + if !topic.IsAdditionalHelpTopicCommand() { + t.Fatalf("%s is not registered as a help topic", name) + } + + var output strings.Builder + topic.SetOut(&output) + renderHelpTopic(topic) + if !strings.Contains(output.String(), topic.Long) { + t.Errorf("%s help does not contain its reference text", name) + } + if strings.Contains(output.String(), "INHERITED FLAGS") || strings.Contains(output.String(), "USAGE") { + t.Errorf("%s help contains command scaffolding:\n%s", name, output.String()) + } + + for _, entry := range catalog { + if entry["name"] == name { + t.Errorf("help topic %s appears in the executable command catalog", name) + } + } + }) + } +} + +func TestHelpReferencesSkipRuntimeSetup(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, "hey-cli") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte("not json"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("HEY_TOKEN", "would-authenticate-a-runtime-command") + t.Setenv("HEY_ACCOUNT_ID", "999") + + repository := t.TempDir() + if err := os.Mkdir(filepath.Join(repository, ".hey"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".hey", "config.json"), []byte(`{"base_url":"http://127.0.0.1:1"}`), 0600); err != nil { + t.Fatal(err) + } + t.Chdir(repository) + + root := newRootCmd() + var output strings.Builder + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"help", "output"}) + if err := root.Execute(); err != nil { + t.Fatalf("help output: %v\n%s", err, output.String()) + } + if !strings.Contains(output.String(), "DEFAULT OUTPUT") { + t.Errorf("help topic was not rendered:\n%s", output.String()) + } +} + +func TestHelpCompletionIncludesCommandsAndTopics(t *testing.T) { + root := newRootCmd() + help, _, err := root.Find([]string{"help"}) + if err != nil { + t.Fatal(err) + } + completions, directive := help.ValidArgsFunction(help, nil, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("completion directive = %v, want no file completion", directive) + } + + joined := strings.Join(completions, "\n") + for _, want := range []string{"box\t", "output\t", "exit-codes\t", "environment\t", "linked-accounts\t"} { + if !strings.Contains(joined, want) { + t.Errorf("help completion does not include %q:\n%s", want, joined) + } + } +} + +func TestRunnableParentHelpShowsBothUsageForms(t *testing.T) { + root := newRootCmd() + search, _, err := root.Find([]string{"search"}) + if err != nil { + t.Fatal(err) + } + + var output strings.Builder + search.SetOut(&output) + renderCommandHelp(search) + for _, want := range []string{"hey search [query] [flags]", "hey search [flags]"} { + if !strings.Contains(output.String(), want) { + t.Errorf("search help does not contain %q:\n%s", want, output.String()) + } + } +} + func TestHelpListsEveryGlobalFlag(t *testing.T) { originalColorDisabled := colorDisabled colorDisabled = true diff --git a/internal/cmd/help_topics.go b/internal/cmd/help_topics.go new file mode 100644 index 00000000..f4a664be --- /dev/null +++ b/internal/cmd/help_topics.go @@ -0,0 +1,97 @@ +package cmd + +import "github.com/spf13/cobra" + +var curatedHelpTopics = []string{"output", "exit-codes", "environment", "linked-accounts"} + +func newHelpTopicCommands() []*cobra.Command { + return []*cobra.Command{ + { + Use: "output", + Short: "Output formats and filtering", + Long: `Choose how hey writes results for people and programs. + +DEFAULT OUTPUT + At a terminal, hey presents human-readable styled output. + When stdout is piped or redirected, hey writes a JSON response envelope. + +FORMATS + --styled Force human-readable terminal output. + --json Write the JSON response envelope. + --quiet Write result data without the response envelope. + --markdown Write Markdown suitable for another document. + --html Write original HTML for commands that support it. + +FILTERING + --jq EXPR Filter the JSON response with a built-in jq expression. + --ids-only Write result IDs, one per line. + --count Write only the result count. + --stats Include request statistics in JSON metadata. + +Formats and selectors apply when a command returns the corresponding data shape. Unsupported combinations return a usage error.`, + }, + { + Use: "exit-codes", + Short: "Exit status reference", + Long: `Use hey's exit status to handle results in scripts and agents. + +EXIT CODES + 0 The command completed successfully. + 1 Usage, validation, conflict, or other command error. + 2 The requested resource was not found. + 3 Authentication is required or failed. + 4 The signed-in identity cannot perform the operation. + 5 HEY rate-limited the request. + 6 A network connection failed. + 7 An API, server, or local operational failure occurred. + 8 The request matched more than one resource. + +JSON failures also carry a machine-readable error code and an actionable hint when one is available.`, + }, + { + Use: "environment", + Short: "Environment variable reference", + Long: `Configure hey for the current process with environment variables. + +CONNECTION & AUTHENTICATION + HEY_TOKEN Use a bearer token instead of stored credentials. + HEY_BASE_URL Override the HEY server URL. + HEY_ACCOUNT_ID Select a linked mail account ID or all. + HEY_NO_KEYRING Store credentials in the config directory instead of a keyring. + +INTERACTION & DIAGNOSTICS + HEY_NONINTERACTIVE Disable prompts when set to 1 or true. + HEY_DEBUG Show request details, equivalent to -v. + +TUI & SETUP + HEY_THEME Load a TUI theme overlay from a TOML file. + HEY_CABLE_URL Override the Action Cable websocket URL. + HEY_SETUP_AGENT Select claude, codex, all, or none during agent setup. + +Command-line flags take precedence over environment values.`, + }, + { + Use: "linked-accounts", + Short: "Linked account selection", + Long: `Choose a linked mail account for mail commands within one HEY identity. + +DISCOVERY & DEFAULTS + hey accounts list List All Accounts and every linked account. + hey accounts use ID Save a linked account as the default mail filter. + hey accounts use all Return to All Accounts. + +ONE INVOCATION + hey --account ID boxes + HEY_ACCOUNT_ID=ID hey search "quarterly planning" + +SELECTION ORDER + 1. --account + 2. HEY_ACCOUNT_ID + 3. A trusted repository .hey/config.json + 4. The global default for the active server + 5. All Accounts + +Compose and contact creation use an individually selected account. Replies and forwards use the thread's account. Calendar, task, time tracking, and journal commands remain identity-wide.`, + }, + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c00e23fa..717d26ba 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -62,6 +62,9 @@ func newRootCmd() *cobra.Command { Stderr: cmd.ErrOrStderr(), JQFilter: jqFlag, }) + if isHelpReference(cmd) { + return nil + } if err := validateHTMLFlag(cmd); err != nil { return err } @@ -136,7 +139,9 @@ func newRootCmd() *cobra.Command { return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - maybeRefreshSkills(cmd) + if !isHelpReference(cmd) { + maybeRefreshSkills(cmd) + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -221,12 +226,14 @@ func newRootCmd() *cobra.Command { root.AddCommand(newTuiCommand().cmd) root.AddCommand(newHeyCommand().cmd) root.AddCommand(newSkillCommand().cmd) + root.AddCommand(newHelpTopicCommands()...) root.AddCommand(newCommandsCommand()) root.AddCommand(newCompletionCommand()) root.AddCommand(newDoctorCommand()) root.AddCommand(newConfigCommand().cmd) root.AddCommand(newUpgradeCommand().cmd) root.AddCommand(newVersionCommand().cmd) + configureHelpCommand(root) return root }