From b898a10abfa3881b503a166a79b32f06502180df Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 25 Sep 2026 16:18:44 +0530 Subject: [PATCH 1/4] feat: rewrite help text for people Plain-language descriptions for every command, argument, and flag, with examples for the most-used commands. Top-level help explains how commands read (account, what, which one, verb), shows @account and pasted links, and groups commands by topic instead of listing every verb. Tests keep it that way: every command, argument, and flag has help written as a sentence, key commands show examples, and all help fits an 80-column terminal. --- CHANGELOG.md | 1 + cmd/chatwoot/help_test.go | 163 +++++++++++++++++++++++++++++++++++ cmd/chatwoot/main.go | 12 ++- internal/cmd/accounts.go | 37 ++++++-- internal/cmd/api.go | 21 +++-- internal/cmd/auth.go | 31 +++++-- internal/cmd/cli.go | 77 ++++++++++------- internal/cmd/config.go | 4 +- internal/cmd/contact.go | 21 +++-- internal/cmd/conversation.go | 143 +++++++++++++++++++++--------- internal/cmd/help_center.go | 38 +++++--- internal/cmd/inbox.go | 4 +- internal/cmd/version.go | 2 +- 13 files changed, 433 insertions(+), 121 deletions(-) create mode 100644 cmd/chatwoot/help_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index de4fe72..e6b087b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Help text is rewritten in plain language, with examples for the most-used commands. `chatwoot --help` explains how commands read and groups them by topic (conversations, contacts and team, help center, accounts and login), and lists subcommands under each command's own help. - `auth login` takes an optional URL (a bare host or any dashboard link) and no longer asks for an account ID, except on Chatwoot versions whose profile doesn't list accounts. - `-a` takes an account name; a number still means that account ID on the default account's instance. - The config file moves to a versioned format with one entry per account, and help center defaults are saved per account. Existing configs and keyring tokens are upgraded automatically on first run with no prompt; the old config is kept as `config.yaml.bak`, and the old keyring entry is left in place. diff --git a/cmd/chatwoot/help_test.go b/cmd/chatwoot/help_test.go new file mode 100644 index 0000000..251b6e8 --- /dev/null +++ b/cmd/chatwoot/help_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "strings" + "testing" + "unicode" + + "github.com/alecthomas/kong" + "github.com/chatwoot/cli/internal/cmd" +) + +type helpExit struct{} + +// helpFor renders `chatwoot --help` the way a user sees it. +func helpFor(t *testing.T, args ...string) string { + t.Helper() + var out bytes.Buffer + var cli cmd.CLI + parser, err := newParser(&cli, + kong.Writers(&out, &out), + kong.Exit(func(int) { panic(helpExit{}) }), + ) + if err != nil { + t.Fatalf("newParser: %v", err) + } + func() { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(helpExit); !ok { + panic(r) + } + } + }() + _, _ = parser.Parse(normalizeArgs(append(args, "--help"))) + }() + return out.String() +} + +// Help text is written for people: every command, argument, and flag says +// something, as a sentence. +func TestEveryCommandArgAndFlagHasHelp(t *testing.T) { + var cli cmd.CLI + parser, err := newParser(&cli) + if err != nil { + t.Fatalf("newParser: %v", err) + } + + checkSentence := func(what, help string) { + t.Helper() + help = strings.TrimSpace(help) + if help == "" { + t.Errorf("%s has no help text", what) + return + } + if first := []rune(help)[0]; !unicode.IsUpper(first) && !unicode.IsDigit(first) { + t.Errorf("%s help should start with a capital letter: %q", what, help) + } + if !strings.HasSuffix(help, ".") && !strings.HasSuffix(help, ")") { + t.Errorf("%s help should end with a period: %q", what, help) + } + } + + var walk func(node *kong.Node) + walk = func(node *kong.Node) { + // Shell completion is provided by a library with its own wording. + if node.Name == "completion" { + return + } + name := node.Path() + if node.Type != kong.ApplicationNode { + checkSentence("command "+name, node.Help) + } + for _, arg := range node.Positional { + checkSentence("argument "+name+" <"+arg.Name+">", arg.Help) + } + for _, flag := range node.Flags { + checkSentence("flag "+name+" --"+flag.Name, flag.Help) + } + for _, child := range node.Children { + walk(child) + } + } + walk(parser.Model.Node) +} + +func TestRootHelpTeachesTheGrammar(t *testing.T) { + out := helpFor(t) + for _, want := range []string{ + `chatwoot conv 123 reply "`, // id before verb + "chatwoot @acme", // pick an account + "/app/accounts/", // paste a link + "Conversations", // commands are grouped + "Accounts and login", + } { + if !strings.Contains(out, want) { + t.Errorf("root help missing %q:\n%s", want, out) + } + } +} + +func TestCommandHelpHasExamples(t *testing.T) { + cases := map[string][]string{ + "convs": {"Examples:", "chatwoot convs --assignee all"}, + "conv": {"Examples:", "chatwoot conv 123 reply"}, + "conv reply": {"Examples:", "--private", "customer"}, + "conv assign": {"Examples:", "--agent me", "--team"}, + "conv snooze": {"Examples:", "--until 7d"}, + "conv label": {"Examples:", "replaces", "billing,urgent"}, + "contacts": {"Examples:", "--search"}, + "accounts": {"Examples:", "chatwoot accounts --refresh"}, + "use": {"Examples:", "CHATWOOT_ACCOUNT"}, + "auth login": {"Examples:", "chatwoot auth login staging.chatwoot.com"}, + "auth logout": {"Examples:", "chatwoot auth logout staging.chatwoot.com"}, + "hc articles": {"Examples:", "--query"}, + "api": {"Examples:", "chatwoot api /conversations/123"}, + } + for command, wants := range cases { + out := helpFor(t, strings.Fields(command)...) + for _, want := range wants { + if !strings.Contains(out, want) { + t.Errorf("chatwoot %s --help missing %q:\n%s", command, want, out) + } + } + } +} + +// Usage lines read id-first, matching how commands are typed. +func TestSubcommandHelpUsesIDFirstOrder(t *testing.T) { + out := helpFor(t, "conv", "reply") + if !strings.Contains(out, " reply ") { + t.Fatalf("conv reply usage should read id-first:\n%s", out) + } +} + +// Help fits an 80-column terminal, for every command. +func TestHelpFitsEightyColumns(t *testing.T) { + var cli cmd.CLI + parser, err := newParser(&cli) + if err != nil { + t.Fatalf("newParser: %v", err) + } + var paths [][]string + var walk func(node *kong.Node, path []string) + walk = func(node *kong.Node, path []string) { + if node.Name == "completion" { + return + } + paths = append(paths, path) + for _, child := range node.Children { + walk(child, append(append([]string{}, path...), child.Name)) + } + } + walk(parser.Model.Node, nil) + + for _, path := range paths { + for _, line := range strings.Split(helpFor(t, path...), "\n") { + if n := len([]rune(line)); n > 80 { + t.Errorf("chatwoot %s --help has a %d-column line:\n%s", strings.Join(path, " "), n, line) + } + } + } +} diff --git a/cmd/chatwoot/main.go b/cmd/chatwoot/main.go index 3c67b10..d01ac2a 100644 --- a/cmd/chatwoot/main.go +++ b/cmd/chatwoot/main.go @@ -93,14 +93,18 @@ func main() { app.Finish(os.Stderr, interactive) } -func newParser(cli *cmd.CLI) (*kong.Kong, error) { - return kong.New(cli, +func newParser(cli *cmd.CLI, extra ...kong.Option) (*kong.Kong, error) { + options := []kong.Option{ kong.Name("chatwoot"), - kong.Description("CLI for Chatwoot."), + kong.Description("Work with your Chatwoot inbox from the terminal."), kong.Vars{"version": version}, kong.UsageOnError(), kong.Help(idFirstHelpPrinter), - ) + // Top-level help lists commands, not every verb; `chatwoot conv --help` + // shows the verbs. + kong.ConfigureHelp(kong.HelpOptions{NoExpandSubcommands: true}), + } + return kong.New(cli, append(options, extra...)...) } // normalizeArgs turns the user-facing grammar into what Kong parses. diff --git a/internal/cmd/accounts.go b/internal/cmd/accounts.go index c4c58df..1f0ca43 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -20,12 +20,23 @@ var fetchProfile = func(client *sdk.Client) (*sdk.ProfileResponse, error) { // AccountsCmd is `chatwoot accounts` — list, refresh, and rename accounts. type AccountsCmd struct { - List AccountsListCmd `cmd:"" default:"withargs" help:"List registered accounts (the default)."` - Rename AccountsRenameCmd `cmd:"" help:"Give an account your own name."` + List AccountsListCmd `cmd:"" default:"withargs" help:"List your accounts. This is what 'chatwoot accounts' does."` + Rename AccountsRenameCmd `cmd:"" help:"Give an account a name you prefer."` } type AccountsListCmd struct { - Refresh bool `help:"Re-sync accounts with every saved login."` + Refresh bool `help:"Check for accounts you've joined or left since you logged in."` +} + +func (c *AccountsCmd) Help() string { + return `Log in once per Chatwoot instance and all your accounts there show up here. +The one marked * is your default. + +Examples: + chatwoot accounts List your accounts + chatwoot accounts --refresh Find new or removed ones + chatwoot accounts rename chatwoot-staging stg Use a shorter name + chatwoot @stg convs Use one for one command` } // accountView is the JSON shape of one listed account. @@ -190,8 +201,8 @@ func resolveAccount(cfg *config.Config, selector string) (*config.Account, error } type AccountsRenameCmd struct { - Old string `arg:"" help:"Current account name."` - New string `arg:"" help:"New account name."` + Old string `arg:"" help:"The account's current name."` + New string `arg:"" help:"The name you want. Letters, numbers, and dashes."` } func (c *AccountsRenameCmd) Run(app *App) error { @@ -214,7 +225,21 @@ func (c *AccountsRenameCmd) Run(app *App) error { // UseCmd is `chatwoot use ` — change the saved default account. type UseCmd struct { - Name string `arg:"" optional:"" help:"Account name (or unique prefix) to make the default. Omit to show the current default."` + Name string `arg:"" optional:"" help:"The account name, or just its first few letters. Leave out to see your current default."` +} + +func (c *UseCmd) Help() string { + return `Your default account is used whenever a command doesn't name one. This +changes it everywhere: every terminal and script on this machine. + +To switch just for one command, put @name first. For one terminal session or +a script, set CHATWOOT_ACCOUNT instead. + +Examples: + chatwoot use acme Make acme the default + chatwoot use Show the current default + chatwoot @acme convs Use acme for one command only + export CHATWOOT_ACCOUNT=acme Use acme in this terminal only` } func (c *UseCmd) Run(app *App) error { diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 04a2313..feee156 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -15,23 +15,26 @@ import ( ) type ApiCmd struct { - Method string `short:"X" placeholder:"METHOD" help:"HTTP method. Defaults to POST with --data, otherwise GET."` - Data string `short:"d" placeholder:"JSON|@FILE" help:"JSON request body, or @file to read body from a file."` - Header []string `short:"H" placeholder:"HEADER" help:"Additional request header, as 'Name: value'. Repeatable."` - Exact bool `help:"Use the path exactly as provided under the configured base URL."` - Path string `arg:"" help:"Endpoint path. /conversations/123 expands under the configured account."` + Method string `short:"X" placeholder:"METHOD" help:"HTTP method, like PATCH or DELETE. Defaults to POST when you send --data, otherwise GET."` + Data string `short:"d" placeholder:"JSON|@FILE" help:"JSON to send. Use @file.json to send a file, or @- to read from stdin."` + Header []string `short:"H" placeholder:"HEADER" help:"Extra header, like 'X-Trace: 1'. Repeat for more."` + Exact bool `help:"Send the path as-is instead of under your account (for paths outside /api/v1/accounts)."` + Path string `arg:"" help:"Where to send it, like /conversations/123."` } func (c *ApiCmd) Help() string { - return `Account-relative paths such as /conversations/123 are expanded under -/api/v1/accounts/. Use --exact for non-account-scoped paths. + return `Paths go under your account, so /conversations/123 calls +/api/v1/accounts//conversations/123. The response is printed as JSON. -The default method is GET, or POST when --data is provided. Override with -X. +A request other than GET can change data, so check it before you run it. Examples: chatwoot api /conversations/123 chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}' - chatwoot api --exact /api/v1/profile` + chatwoot api --exact /api/v1/profile + chatwoot @staging api /inboxes + +API reference: https://developers.chatwoot.com/api-reference/introduction` } func (c *ApiCmd) Run(app *App) error { diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index f9f49d9..f70b8a1 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -16,21 +16,34 @@ import ( ) type AuthCmd struct { - Login AuthLoginCmd `cmd:"" help:"Login with your Chatwoot credentials."` - Logout AuthLogoutCmd `cmd:"" help:"Logout and remove saved credentials."` - Status AuthStatusCmd `cmd:"" help:"Show current authentication status."` + Login AuthLoginCmd `cmd:"" help:"Log in to a Chatwoot instance. All your accounts there are added."` + Logout AuthLogoutCmd `cmd:"" help:"Log out of every instance, or just one."` + Status AuthStatusCmd `cmd:"" help:"Show who you're logged in as, and which instance and account."` } const defaultBaseURL = "https://app.chatwoot.com" type AuthLoginCmd struct { - URL string `arg:"" optional:"" help:"Chatwoot URL, or any link copied from the dashboard."` + URL string `arg:"" optional:"" help:"Your Chatwoot address, like app.chatwoot.com. A link copied from the dashboard works too. Leave out to be asked."` // reader continues a prompt session started elsewhere (a link's offered // login), so buffered input isn't lost between two readers. reader *bufio.Reader } +func (c *AuthLoginCmd) Help() string { + return `You'll be asked for an access token. Find it in Chatwoot under Profile +Settings, then Access Token. It's saved in your system keychain. + +Each account gets a short name, like acme. See them with 'chatwoot accounts'. +Log in again to add another instance, like staging. + +Examples: + chatwoot auth login Asks for the address + chatwoot auth login staging.chatwoot.com Log in to another instance + chatwoot auth login https://chat.example.com/app/accounts/3/dashboard` +} + func (c *AuthLoginCmd) Run(app *App) error { reader := c.reader if reader == nil { @@ -266,7 +279,15 @@ func readAPIKey(reader *bufio.Reader) (string, error) { } type AuthLogoutCmd struct { - URL string `arg:"" optional:"" help:"Log out of one Chatwoot instance only."` + URL string `arg:"" optional:"" help:"Only log out of this instance, like staging.chatwoot.com."` +} + +func (c *AuthLogoutCmd) Help() string { + return `Removes saved tokens from your keychain, along with the accounts that use them. + +Examples: + chatwoot auth logout Log out of everything + chatwoot auth logout staging.chatwoot.com Just this instance` } func (c *AuthLogoutCmd) Run(app *App) error { diff --git a/internal/cmd/cli.go b/internal/cmd/cli.go index ee663be..3d5c5e1 100644 --- a/internal/cmd/cli.go +++ b/internal/cmd/cli.go @@ -12,40 +12,53 @@ import ( // - Singular + verb + id: `chatwoot conv view 123`, `chatwoot conv reply 123 "hi"` // - `conv 123` is shorthand for `conv view 123` (default subcommand). type CLI struct { - Output string `short:"o" default:"text" enum:"text,json,csv" help:"Output format."` - Account string `short:"a" env:"CHATWOOT_ACCOUNT" placeholder:"NAME" help:"Account to use: a name from 'chatwoot accounts' (or @name), or an account ID."` - Quiet bool `short:"q" help:"Print only IDs."` - NoColor bool `help:"Disable colored output."` - Verbose bool `short:"v" help:"Show request/response details."` - - // Plurals — list commands. - Convs ConvsCmd `cmd:"" aliases:"conversations" help:"List conversations."` - Contacts ContactsCmd `cmd:"" help:"List or search contacts."` - Inboxes InboxesCmd `cmd:"" help:"List inboxes."` - Agents AgentsCmd `cmd:"" help:"List agents."` - Labels LabelsCmd `cmd:"" help:"List labels."` - Teams TeamsCmd `cmd:"" help:"List teams."` - HelpCenters HCsCmd `cmd:"" name:"hcs" aliases:"help-centers" help:"List help centers."` - - // Singulars — context for verbs and subresources. - Conv ConvCmd `cmd:"" aliases:"conversation" help:"View or act on a conversation."` - Contact ContactCmd `cmd:"" help:"View a contact."` - Inbox InboxCmd `cmd:"" help:"View an inbox."` - HelpCenter HCCmd `cmd:"" name:"hc" aliases:"help-center" help:"List, search, or view help center articles."` - - // Workflow. `me` and `whoami` are aliases of `auth status`. - Me MeCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."` - Whoami WhoamiCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."` - Api ApiCmd `cmd:"" help:"Make an HTTP request to the Chatwoot API."` + Output string `short:"o" default:"text" enum:"text,json,csv" help:"How to print results: text (for people), json, or csv."` + Account string `short:"a" env:"CHATWOOT_ACCOUNT" placeholder:"NAME" help:"Which account to use: a name from 'chatwoot accounts', or an account ID. Same as putting @name first."` + Quiet bool `short:"q" help:"Print only IDs, one per line. Handy in scripts."` + NoColor bool `help:"Turn off colors."` + Verbose bool `short:"v" help:"Show each HTTP request and response (for debugging)."` + + // Plurals list; singulars view or act on one item. + Convs ConvsCmd `cmd:"" aliases:"conversations" group:"Conversations" help:"List conversations."` + Conv ConvCmd `cmd:"" aliases:"conversation" group:"Conversations" help:"View or act on one conversation: reply, resolve, assign, label, and more."` + Contacts ContactsCmd `cmd:"" group:"Contacts and team" help:"List or search contacts."` + Contact ContactCmd `cmd:"" group:"Contacts and team" help:"View a contact or their conversations."` + Inboxes InboxesCmd `cmd:"" group:"Contacts and team" help:"List inboxes."` + Inbox InboxCmd `cmd:"" group:"Contacts and team" help:"View an inbox."` + Agents AgentsCmd `cmd:"" group:"Contacts and team" help:"List agents."` + Teams TeamsCmd `cmd:"" group:"Contacts and team" help:"List teams."` + Labels LabelsCmd `cmd:"" group:"Contacts and team" help:"List the labels you can put on conversations."` + HelpCenters HCsCmd `cmd:"" name:"hcs" aliases:"help-centers" group:"Help center" help:"List help centers."` + HelpCenter HCCmd `cmd:"" name:"hc" aliases:"help-center" group:"Help center" help:"Search and read help center articles."` // Setup. - Accounts AccountsCmd `cmd:"" help:"List, refresh, or rename your accounts."` - Use UseCmd `cmd:"" help:"Set the default account."` - Auth AuthCmd `cmd:"" help:"Login, logout, and status."` - Config ConfigCmd `cmd:"" aliases:"cfg" help:"Manage CLI configuration."` + Auth AuthCmd `cmd:"" group:"Accounts and login" help:"Log in, log out, or see who you're logged in as."` + Accounts AccountsCmd `cmd:"" group:"Accounts and login" help:"See your accounts, pick up new ones, or rename them."` + Use UseCmd `cmd:"" group:"Accounts and login" help:"Choose your default account."` + Me MeCmd `cmd:"" group:"Accounts and login" help:"Show who you're logged in as, and where (same as 'auth status')."` + Whoami WhoamiCmd `cmd:"" group:"Accounts and login" help:"Show who you're logged in as, and where (same as 'auth status')."` + + Api ApiCmd `cmd:"" group:"Other" help:"Call any Chatwoot API endpoint, for things without a command yet."` + Config ConfigCmd `cmd:"" aliases:"cfg" group:"Other" help:"Show your saved settings and where they live."` + Completion kongcompletion.Completion `cmd:"" group:"Other" help:"Set up tab completion for your shell."` + Version VersionCmd `cmd:"" group:"Other" help:"Show the CLI version."` + + VersionFlag kong.VersionFlag `name:"version" help:"Show the CLI version."` +} + +// Help is shown under the one-line description in `chatwoot --help`. +func (c *CLI) Help() string { + return `Commands read the way you'd say them: which account, what, which one, then +what to do. Leave out the account to use your default. + +Examples: + chatwoot convs Your open conversations + chatwoot conv 123 Look at conversation 123 + chatwoot conv 123 reply "On it!" Reply to it + chatwoot @acme convs The same list, in your acme account - Completion kongcompletion.Completion `cmd:"" help:"Print shell completion setup."` - Version VersionCmd `cmd:"" help:"Print the CLI version."` +Copied a link from the dashboard? Use it in place of "conv 123": + chatwoot https://app.chatwoot.com/app/accounts/1/conversations/123 resolve - VersionFlag kong.VersionFlag `name:"version" help:"Print the CLI version."` +New here? Start with: chatwoot auth login` } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index a395fa1..a2eadca 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -10,8 +10,8 @@ import ( ) type ConfigCmd struct { - Path ConfigPathCmd `cmd:"" help:"Print the config file path."` - View ConfigViewCmd `cmd:"" help:"Print current configuration."` + Path ConfigPathCmd `cmd:"" help:"Print where your settings file is."` + View ConfigViewCmd `cmd:"" help:"Show your accounts and saved settings. Tokens are never shown."` } type ConfigPathCmd struct{} diff --git a/internal/cmd/contact.go b/internal/cmd/contact.go index c8ca0d1..8f4a595 100644 --- a/internal/cmd/contact.go +++ b/internal/cmd/contact.go @@ -13,9 +13,16 @@ import ( // ----------------------------------------------------------------------------- type ContactsCmd struct { - Search string `help:"Search query (name, email, phone)."` - Page int `short:"p" default:"1" help:"Page number."` - Sort string `help:"Sort by name, email, phone_number, last_activity_at; '-' prefix for descending."` + Search string `help:"Find contacts by name, email, or phone number."` + Page int `short:"p" default:"1" help:"Which page of results to show."` + Sort string `help:"Sort by name, email, phone_number, or last_activity_at. Put - in front to reverse, like -last_activity_at."` +} + +func (c *ContactsCmd) Help() string { + return `Examples: + chatwoot contacts List contacts + chatwoot contacts --search "jane@example.com" Find someone + chatwoot contacts --sort -last_activity_at Most recently active first` } func (c *ContactsCmd) Run(app *App) error { @@ -68,12 +75,12 @@ func (c *ContactsCmd) Run(app *App) error { // ----------------------------------------------------------------------------- type ContactCmd struct { - View ContactViewCmd `cmd:"" default:"withargs" help:"View a contact (default)."` - Conversations ContactConversationsCmd `cmd:"" help:"List conversations for the contact."` + View ContactViewCmd `cmd:"" default:"withargs" help:"Show the contact. This is what 'chatwoot contact 456' does."` + Conversations ContactConversationsCmd `cmd:"" help:"List this contact's conversations."` } type ContactViewCmd struct { - ID int `arg:"" help:"Contact ID."` + ID int `arg:"" help:"The contact ID."` } func (c *ContactViewCmd) Run(app *App) error { @@ -104,7 +111,7 @@ func renderContact(app *App, contact *sdk.ContactFull) error { } type ContactConversationsCmd struct { - ID int `arg:"" help:"Contact ID."` + ID int `arg:"" help:"The contact ID."` } func (c *ContactConversationsCmd) Run(app *App) error { diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index eef8276..c3ef830 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -19,13 +19,25 @@ import ( // ----------------------------------------------------------------------------- type ConvsCmd struct { - Status string `short:"s" default:"open" help:"Filter: open, resolved, pending, snoozed."` - Inbox int `short:"i" help:"Filter by inbox ID."` - Assignee string `default:"me" help:"Filter: me, unassigned, all."` - Team int `help:"Filter by team ID."` - Label []string `short:"l" help:"Filter by labels."` - Query string `help:"Search conversations by message content."` - Page int `short:"p" default:"1" help:"Page number."` + Status string `short:"s" default:"open" help:"Only conversations with this status: open, resolved, pending, or snoozed."` + Inbox int `short:"i" help:"Only this inbox (IDs are in 'chatwoot inboxes')."` + Assignee string `default:"me" help:"Whose conversations: me, unassigned, or all."` + Team int `help:"Only this team (IDs are in 'chatwoot teams')."` + Label []string `short:"l" help:"Only conversations with these labels, like billing,urgent."` + Query string `help:"Search for words in the messages."` + Page int `short:"p" default:"1" help:"Which page of results to show."` +} + +func (c *ConvsCmd) Help() string { + return `With no flags, this shows the open conversations assigned to you. + +Examples: + chatwoot convs Your open conversations + chatwoot convs --assignee all Everyone's open conversations + chatwoot convs --assignee unassigned Nobody has picked these up yet + chatwoot convs -s resolved -l billing Resolved, labeled billing + chatwoot convs --query "refund" Search the messages + chatwoot convs -q Just the IDs, for scripts` } func (c *ConvsCmd) Run(app *App) error { @@ -76,24 +88,38 @@ func (c *ConvsCmd) Run(app *App) error { // ----------------------------------------------------------------------------- type ConvCmd struct { - View ConvViewCmd `cmd:"" default:"withargs" help:"View a conversation (default)."` - Messages ConvMessagesCmd `cmd:"" help:"List messages in the conversation."` - Reply ConvReplyCmd `cmd:"" help:"Reply to the conversation."` + View ConvViewCmd `cmd:"" default:"withargs" help:"Show the conversation. This is what 'chatwoot conv 123' does."` + Messages ConvMessagesCmd `cmd:"" help:"Show the messages in the conversation."` + Reply ConvReplyCmd `cmd:"" help:"Send a reply the customer will see, or a private note with --private."` Resolve ConvResolveCmd `cmd:"" help:"Mark the conversation as resolved."` - Open ConvOpenCmd `cmd:"" help:"Set the conversation status to open."` - Pending ConvPendingCmd `cmd:"" help:"Set the conversation status to pending."` - Snooze ConvSnoozeCmd `cmd:"" help:"Snooze the conversation (default: until next reply)."` - Assign ConvAssignCmd `cmd:"" help:"Assign the conversation to an agent and/or team (--agent, --team)."` - Unassign ConvUnassignCmd `cmd:"" help:"Remove the assignee."` - Label ConvLabelCmd `cmd:"" help:"Set labels on the conversation (replaces existing)."` - Priority ConvPriorityCmd `cmd:"" help:"Set or clear conversation priority."` - Contact ConvContactCmd `cmd:"" help:"View the contact (sender) for the conversation."` + Open ConvOpenCmd `cmd:"" help:"Reopen the conversation."` + Pending ConvPendingCmd `cmd:"" help:"Mark the conversation as pending."` + Snooze ConvSnoozeCmd `cmd:"" help:"Snooze the conversation until the customer replies, or until a time you pick."` + Assign ConvAssignCmd `cmd:"" help:"Assign the conversation to an agent, a team, or both."` + Unassign ConvUnassignCmd `cmd:"" help:"Remove the assigned agent."` + Label ConvLabelCmd `cmd:"" help:"Set the conversation's labels. This replaces the labels it has now."` + Priority ConvPriorityCmd `cmd:"" help:"Set the priority, or clear it with none."` + Contact ConvContactCmd `cmd:"" help:"Show the contact who started the conversation."` +} + +func (c *ConvCmd) Help() string { + return `Put the conversation ID first, then what to do. + +Examples: + chatwoot conv 123 Show conversation 123 + chatwoot conv 123 messages Read its messages + chatwoot conv 123 reply "On it!" Reply to the customer + chatwoot conv 123 reply "Called" --private Leave a note for your team + chatwoot conv 123 resolve Mark it resolved + chatwoot conv 123 assign --agent me Take it yourself + +A link copied from the dashboard works in place of "conv 123".` } // -- view --------------------------------------------------------------------- type ConvViewCmd struct { - ID int `arg:"" help:"Conversation ID."` + ID int `arg:"" help:"The conversation ID."` } func (c *ConvViewCmd) Run(app *App) error { @@ -142,9 +168,9 @@ func (c *ConvViewCmd) Run(app *App) error { // -- messages ----------------------------------------------------------------- type ConvMessagesCmd struct { - ID int `arg:"" help:"Conversation ID."` - Before int `help:"Messages before this message ID."` - After int `help:"Messages after this message ID."` + ID int `arg:"" help:"The conversation ID."` + Before int `help:"Show messages older than this message ID."` + After int `help:"Show messages newer than this message ID."` } func (c *ConvMessagesCmd) Run(app *App) error { @@ -219,9 +245,18 @@ func convLockScope(app *App) string { // -- reply -------------------------------------------------------------------- type ConvReplyCmd struct { - ID int `arg:"" help:"Conversation ID."` - Text string `arg:"" help:"Reply content."` - Private bool `help:"Send as a private note instead of a public reply."` + ID int `arg:"" help:"The conversation ID."` + Text string `arg:"" help:"What to send. Put it in quotes."` + Private bool `help:"Post a private note only your team can see, instead of a reply."` +} + +func (c *ConvReplyCmd) Help() string { + return `Without --private, the customer sees your reply right away, and it can't be +taken back. + +Examples: + chatwoot conv 123 reply "Thanks, we're on it!" + chatwoot conv 123 reply "Refund approved by finance" --private` } func (c *ConvReplyCmd) Run(app *App) error { @@ -246,7 +281,7 @@ func (c *ConvReplyCmd) Run(app *App) error { // -- status verbs: resolve / open / pending / snooze -------------------------- type ConvResolveCmd struct { - ID int `arg:"" help:"Conversation ID."` + ID int `arg:"" help:"The conversation ID."` } func (c *ConvResolveCmd) Run(app *App) error { @@ -256,7 +291,7 @@ func (c *ConvResolveCmd) Run(app *App) error { } type ConvOpenCmd struct { - ID int `arg:"" help:"Conversation ID."` + ID int `arg:"" help:"The conversation ID."` } func (c *ConvOpenCmd) Run(app *App) error { @@ -266,7 +301,7 @@ func (c *ConvOpenCmd) Run(app *App) error { } type ConvPendingCmd struct { - ID int `arg:"" help:"Conversation ID."` + ID int `arg:"" help:"The conversation ID."` } func (c *ConvPendingCmd) Run(app *App) error { @@ -276,8 +311,18 @@ func (c *ConvPendingCmd) Run(app *App) error { } type ConvSnoozeCmd struct { - ID int `arg:"" help:"Conversation ID."` - Until string `help:"Snooze duration (e.g. 24h, 7d) or absolute date (2006-01-02). Omit to snooze until next reply."` + ID int `arg:"" help:"The conversation ID."` + Until string `help:"When it should come back: a length of time like 4h, 2d, or 1w, or a date like 2026-05-10. Leave out to wait for the customer's next reply."` +} + +func (c *ConvSnoozeCmd) Help() string { + return `A snoozed conversation leaves your open list and comes back on its own. + +Examples: + chatwoot conv 123 snooze Until the customer replies + chatwoot conv 123 snooze --until 4h For four hours + chatwoot conv 123 snooze --until 7d For a week + chatwoot conv 123 snooze --until 2026-05-10` } func (c *ConvSnoozeCmd) Run(app *App) error { @@ -346,9 +391,20 @@ func parseExtendedDuration(s string) (time.Duration, bool) { // -- assign / unassign -------------------------------------------------------- type ConvAssignCmd struct { - ID int `arg:"" help:"Conversation ID."` - Agent string `help:"Agent ID, 'me', or name (case-insensitive substring)."` - Team int `help:"Team ID."` + ID int `arg:"" help:"The conversation ID."` + Agent string `help:"Who to assign: me, an agent ID, part of their name, or the start of their email."` + Team int `help:"Team to assign (IDs are in 'chatwoot teams')."` +} + +func (c *ConvAssignCmd) Help() string { + return `If a name matches more than one agent, nothing changes and you'll see who +matched, so you can use their ID instead. + +Examples: + chatwoot conv 123 assign --agent me + chatwoot conv 123 assign --agent alice + chatwoot conv 123 assign --agent 42 --team 7 + chatwoot conv 123 assign --team 7` } func (c *ConvAssignCmd) Run(app *App) error { @@ -388,7 +444,7 @@ func (c *ConvAssignCmd) Run(app *App) error { } type ConvUnassignCmd struct { - ID int `arg:"" help:"Conversation ID."` + ID int `arg:"" help:"The conversation ID."` } func (c *ConvUnassignCmd) Run(app *App) error { @@ -408,8 +464,17 @@ func (c *ConvUnassignCmd) Run(app *App) error { // -- label -------------------------------------------------------------------- type ConvLabelCmd struct { - ID int `arg:"" help:"Conversation ID."` - Labels []string `arg:"" help:"Labels to set (comma-separated or repeated). Replaces existing."` + ID int `arg:"" help:"The conversation ID."` + Labels []string `arg:"" help:"The labels it should have, like billing,urgent."` +} + +func (c *ConvLabelCmd) Help() string { + return `Any label you leave out is removed. To add one, include the labels it +already has (see 'chatwoot conv 123'). + +Examples: + chatwoot conv 123 label billing,urgent + chatwoot conv 123 label billing urgent` } func (c *ConvLabelCmd) Run(app *App) error { @@ -438,8 +503,8 @@ func (c *ConvLabelCmd) Run(app *App) error { // -- priority ----------------------------------------------------------------- type ConvPriorityCmd struct { - ID int `arg:"" help:"Conversation ID."` - Level string `arg:"" enum:"urgent,high,medium,low,none" help:"Priority: urgent, high, medium, low, or none."` + ID int `arg:"" help:"The conversation ID."` + Level string `arg:"" enum:"urgent,high,medium,low,none" help:"One of urgent, high, medium, or low. Use none to clear it."` } func (c *ConvPriorityCmd) Run(app *App) error { @@ -463,7 +528,7 @@ func (c *ConvPriorityCmd) Run(app *App) error { // -- contact ------------------------------------------------------------------ type ConvContactCmd struct { - ID int `arg:"" help:"Conversation ID."` + ID int `arg:"" help:"The conversation ID."` } func (c *ConvContactCmd) Run(app *App) error { diff --git a/internal/cmd/help_center.go b/internal/cmd/help_center.go index f0d7908..72b6bfc 100644 --- a/internal/cmd/help_center.go +++ b/internal/cmd/help_center.go @@ -48,15 +48,15 @@ func (c *HCsCmd) Run(app *App) error { } type HCCmd struct { - List HCsCmd `cmd:"" help:"List help centers."` - Default HCDefaultCmd `cmd:"" help:"Show, set, or clear the default help center."` - Articles HCArticlesCmd `cmd:"" help:"List or search public help center articles."` - Article HCArticleCmd `cmd:"" help:"Get a public help center article."` + List HCsCmd `cmd:"" help:"List help centers (same as 'chatwoot hcs')."` + Default HCDefaultCmd `cmd:"" help:"Choose which help center to search by default, or show the current one."` + Articles HCArticlesCmd `cmd:"" help:"List or search published articles."` + Article HCArticleCmd `cmd:"" help:"Read one article."` } type HCDefaultCmd struct { - Slug string `arg:"" optional:"" help:"Help center portal slug to set as default."` - Clear bool `help:"Clear the configured default help center."` + Slug string `arg:"" optional:"" help:"The help center's slug (see 'chatwoot hcs'). Leave out to see the current default."` + Clear bool `help:"Forget the default help center."` } func (c *HCDefaultCmd) Run(app *App) error { @@ -101,12 +101,22 @@ func (c *HCDefaultCmd) Run(app *App) error { } type HCArticlesCmd struct { - PortalSlug string `name:"portal" help:"Help center portal slug. Defaults to 'chatwoot hc default'."` - Locale string `help:"Article locale, for example en. Defaults to the configured help center locale."` - CategorySlug string `name:"category" help:"Restrict results to a category slug."` - Query string `help:"Search query."` - Page int `short:"p" default:"1" help:"Page number."` - PerPage int `help:"Results per page, capped by Chatwoot at 100."` + PortalSlug string `name:"portal" help:"Which help center to search. Uses your default if you leave it out."` + Locale string `help:"Which language, like en or fr. Uses your default help center's language if you leave it out."` + CategorySlug string `name:"category" help:"Only articles in this category."` + Query string `help:"Words to search for."` + Page int `short:"p" default:"1" help:"Which page of results to show."` + PerPage int `help:"How many articles per page (up to 100)."` +} + +func (c *HCArticlesCmd) Help() string { + return `Set a default help center first with 'chatwoot hc default ', or pass +--portal and --locale each time. + +Examples: + chatwoot hc articles --query "api channel" + chatwoot hc articles --category getting-started + chatwoot hc articles --portal docs --locale fr` } func (c *HCArticlesCmd) Run(app *App) error { @@ -159,8 +169,8 @@ func (c *HCArticlesCmd) Run(app *App) error { } type HCArticleCmd struct { - PortalSlug string `name:"portal" help:"Help center portal slug. Defaults to 'chatwoot hc default'."` - ArticleSlug string `arg:"" help:"Article slug."` + PortalSlug string `name:"portal" help:"Which help center it's in. Uses your default if you leave it out."` + ArticleSlug string `arg:"" help:"The article's slug, from 'chatwoot hc articles'."` } func (c *HCArticleCmd) Run(app *App) error { diff --git a/internal/cmd/inbox.go b/internal/cmd/inbox.go index 06f4a9a..1ab7a55 100644 --- a/internal/cmd/inbox.go +++ b/internal/cmd/inbox.go @@ -48,11 +48,11 @@ func (c *InboxesCmd) Run(app *App) error { // ----------------------------------------------------------------------------- type InboxCmd struct { - View InboxViewCmd `cmd:"" default:"withargs" help:"View an inbox (default)."` + View InboxViewCmd `cmd:"" default:"withargs" help:"Show the inbox. This is what 'chatwoot inbox 5' does."` } type InboxViewCmd struct { - ID int `arg:"" help:"Inbox ID."` + ID int `arg:"" help:"The inbox ID (see 'chatwoot inboxes')."` } func (c *InboxViewCmd) Run(app *App) error { diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 79b4ff3..110453b 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -14,7 +14,7 @@ var latestReleaseURL = "https://api.github.com/repos/chatwoot/cli/releases/lates // VersionCmd is `chatwoot version` — prints the CLI version. Equivalent to // the `--version` flag, exposed as a subcommand for discoverability. type VersionCmd struct { - Check bool `help:"Check GitHub for a newer release."` + Check bool `help:"Also check whether a newer version is out."` } func (c *VersionCmd) Run(app *App) error { From d1e722c49185ee8680d3d46bf21bb458259d2757 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 25 Sep 2026 16:23:19 +0530 Subject: [PATCH 2/4] feat: color and tidy the help layout Help gets muted 256-color styling in a terminal: soft blue headers, bold command names, muted cyan flags, gray placeholders and example notes. Pipes, --no-color, NO_COLOR, and TERM=dumb stay plain, and stripping the colors always gives back the plain text. Every section header gets a blank line under it, and entries start flush left with descriptions two spaces in. Coloring goes by section rather than by indentation. The default-verb help now says you can leave out 'view'. --- CHANGELOG.md | 2 +- cmd/chatwoot/help.go | 156 +++++++++++++++++++++++++++++++++++ cmd/chatwoot/help_test.go | 98 ++++++++++++++++++++++ cmd/chatwoot/main.go | 19 ----- internal/cmd/accounts.go | 2 +- internal/cmd/contact.go | 2 +- internal/cmd/conversation.go | 2 +- internal/cmd/inbox.go | 2 +- 8 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 cmd/chatwoot/help.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b087b..7643b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Help text is rewritten in plain language, with examples for the most-used commands. `chatwoot --help` explains how commands read and groups them by topic (conversations, contacts and team, help center, accounts and login), and lists subcommands under each command's own help. +- Help text is rewritten in plain language, with examples for the most-used commands. `chatwoot --help` explains how commands read and groups them by topic (conversations, contacts and team, help center, accounts and login), and lists subcommands under each command's own help. Help has a cleaner layout (flush-left entries, a blank line under each header) and muted colors in a terminal; `--no-color`, `NO_COLOR`, and piped output stay plain. - `auth login` takes an optional URL (a bare host or any dashboard link) and no longer asks for an account ID, except on Chatwoot versions whose profile doesn't list accounts. - `-a` takes an account name; a number still means that account ID on the default account's instance. - The config file moves to a versioned format with one entry per account, and help center defaults are saved per account. Existing configs and keyring tokens are upgraded automatically on first run with no prompt; the old config is kept as `config.yaml.bak`, and the old keyring entry is left in place. diff --git a/cmd/chatwoot/help.go b/cmd/chatwoot/help.go new file mode 100644 index 0000000..93ce921 --- /dev/null +++ b/cmd/chatwoot/help.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "regexp" + "slices" + "strings" + + "github.com/alecthomas/kong" + "golang.org/x/term" +) + +var helpVerbSwap = regexp.MustCompile(`\b(view|messages|reply|resolve|open|pending|snooze|assign|unassign|label|priority|contact|conversations)\s+`) + +// idFirstHelpPrinter runs Kong's default printer into a buffer, swaps +// ` ` → ` ` so help reads in the user-facing order, +// tightens the layout, and adds color when a person is reading it. +func idFirstHelpPrinter(o kong.HelpOptions, ctx *kong.Context) error { + var buf bytes.Buffer + orig := ctx.Stdout + ctx.Stdout = &buf + err := kong.DefaultHelpPrinter(o, ctx) + ctx.Stdout = orig + if err != nil { + return err + } + + text := layoutHelp(helpVerbSwap.ReplaceAllString(buf.String(), ` $1`)) + if wantHelpColor(isTerminal(orig), ctx.Args) { + text = colorizeHelp(text) + } + _, werr := fmt.Fprint(orig, text) + return werr +} + +// helpHeader matches section headers: "Examples:", "Flags:", "Conversations". +var helpHeader = regexp.MustCompile(`^[A-Z][A-Za-z ]*:?$`) + +var exampleIndent = regexp.MustCompile(`(?m)^ ((?:chatwoot|export) )`) + +// layoutHelp gives every section header a blank line under it and moves the +// section's content two columns left, so entries (commands, flags, examples) +// start flush left and their descriptions sit two spaces in. +func layoutHelp(text string) string { + lines := strings.Split(exampleIndent.ReplaceAllString(text, " $1"), "\n") + out := make([]string, 0, len(lines)+8) + inSection := false + for i, line := range lines { + switch { + case helpHeader.MatchString(line): + inSection = true + out = append(out, line) + if i+1 < len(lines) && lines[i+1] != "" { + out = append(out, "") + } + case inSection && strings.HasPrefix(line, " "): + out = append(out, line[2:]) + default: + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +// wantHelpColor reports whether help should be colored: only for a person at +// a terminal, and never with --no-color, NO_COLOR, or TERM=dumb. +func wantHelpColor(terminal bool, args []string) bool { + return terminal && + os.Getenv("NO_COLOR") == "" && + os.Getenv("TERM") != "dumb" && + !slices.Contains(args, "--no-color") +} + +func isTerminal(w any) bool { + f, ok := w.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} + +const ansiReset = "\x1b[0m" + +// helpStyle is muted on purpose: 256-color codes that read on dark and light +// backgrounds. Plain text (example commands) keeps the terminal's own color. +var helpStyle = struct { + header, command, flag, placeholder, note string +}{ + header: "\x1b[1;38;5;75m", // bold soft blue + command: "\x1b[1m", // bold + flag: "\x1b[38;5;80m", // muted cyan + placeholder: "\x1b[3;38;5;245m", // italic gray + note: "\x1b[38;5;244m", // gray +} + +var ( + helpPlaceholder = regexp.MustCompile(`\[<[a-z-]+>\](?: \.\.\.)?|<[a-z-]+>(?: \.\.\.)?|=[A-Z][A-Z,.|@]*|="[^"]*"|=\d+`) + helpExample = regexp.MustCompile(`^((?:chatwoot|export) .*?)(?:(\s{2,})(\S.*))?$`) + helpCommand = regexp.MustCompile(`^([a-z][\w-]*(?: \([\w-]+\))?(?: [a-z][\w-]*)*?)((?: [<\[].*)?)$`) + helpFlag = regexp.MustCompile(`^(\s*)((?:-\w, )?--[\w-]+\S*)(.*)$`) + helpArgument = regexp.MustCompile(`^(\[?<[\w-]+>\]?(?: \.\.\.)?)(.*)$`) +) + +func paint(style, s string) string { + if s == "" { + return s + } + return style + s + ansiReset +} + +func paintPlaceholders(s, after string) string { + return helpPlaceholder.ReplaceAllStringFunc(s, func(m string) string { + return helpStyle.placeholder + m + ansiReset + after + }) +} + +// colorizeHelp styles laid-out help text. Each line is styled by the section +// it's in (Flags, Arguments, Examples, or a group of commands); lines that +// start with a space are descriptions and stay plain. It only adds escape +// codes: stripping them gives back the plain text. +func colorizeHelp(text string) string { + lines := strings.Split(text, "\n") + section := "" + for i, line := range lines { + switch { + case strings.HasPrefix(line, "Usage:"): + lines[i] = paint(helpStyle.header, "Usage:") + paintPlaceholders(line[len("Usage:"):], "") + continue + case helpHeader.MatchString(line): + section = line + lines[i] = paint(helpStyle.header, line) + continue + case section == "" || line == "": + continue + } + + switch section { + case "Flags:": + if m := helpFlag.FindStringSubmatch(line); m != nil { + lines[i] = m[1] + helpStyle.flag + paintPlaceholders(m[2], helpStyle.flag) + ansiReset + m[3] + } + case "Arguments:": + if m := helpArgument.FindStringSubmatch(line); m != nil { + lines[i] = paint(helpStyle.placeholder, m[1]) + m[2] + } + case "Examples:": + if m := helpExample.FindStringSubmatch(line); m != nil { + lines[i] = m[1] + m[2] + paint(helpStyle.note, m[3]) + } + default: // a group of commands + if m := helpCommand.FindStringSubmatch(line); m != nil { + lines[i] = paint(helpStyle.command, m[1]) + paintPlaceholders(m[2], "") + } + } + } + return strings.Join(lines, "\n") +} diff --git a/cmd/chatwoot/help_test.go b/cmd/chatwoot/help_test.go index 251b6e8..b9583a1 100644 --- a/cmd/chatwoot/help_test.go +++ b/cmd/chatwoot/help_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "regexp" "strings" "testing" "unicode" @@ -161,3 +162,100 @@ func TestHelpFitsEightyColumns(t *testing.T) { } } } + +func TestLayoutHelpSpacesHeadersAndDedentsSections(t *testing.T) { + in := "Usage: chatwoot convs\n\nList conversations.\n\nExamples:\n\n chatwoot convs Yours\n\n" + + "Flags:\n -h, --help Help.\n --no-color Plain.\n More.\n\n" + + "Conversations\n convs [flags]\n List conversations.\n" + want := "Usage: chatwoot convs\n\nList conversations.\n\nExamples:\n\nchatwoot convs Yours\n\n" + + "Flags:\n\n-h, --help Help.\n --no-color Plain.\n More.\n\n" + + "Conversations\n\nconvs [flags]\n List conversations.\n" + if got := layoutHelp(in); got != want { + t.Fatalf("layoutHelp() =\n%s\nwant\n%s", got, want) + } +} + +// Real help output: a blank line after every header, and entries flush left +// with their descriptions two spaces in. +func TestHelpSectionLayout(t *testing.T) { + for _, args := range [][]string{nil, {"conv"}, {"conv", "reply"}, {"accounts"}} { + lines := strings.Split(helpFor(t, args...), "\n") + for i, line := range lines[:len(lines)-1] { + if helpHeader.MatchString(line) && lines[i+1] != "" { + t.Errorf("chatwoot %v --help: no blank line after header %q", args, line) + } + } + out := strings.Join(lines, "\n") + for _, bad := range []string{"\n convs", "\n -h, --help", "\n chatwoot convs", "\n List conversations."} { + if strings.Contains(out, bad) { + t.Errorf("chatwoot %v --help still indents %q", args, strings.TrimSpace(bad)) + } + } + } + root := helpFor(t) + for _, want := range []string{"\nconvs (conversations) [flags]\n List conversations.", "\n-h, --help ", "\nchatwoot convs "} { + if !strings.Contains(root, want) { + t.Errorf("root help missing %q:\n%s", want, root) + } + } +} + +func TestColorizeHelpPaintsWithoutChangingText(t *testing.T) { + plain := helpFor(t, "conv", "reply") + colored := colorizeHelp(plain) + + if !strings.Contains(colored, "\x1b[") { + t.Fatal("colorizeHelp added no color") + } + if got := stripANSI(colored); got != plain { + t.Fatalf("colorizing changed the text:\n%s", got) + } + for _, want := range []string{ + helpStyle.header + "Examples:" + ansiReset, + helpStyle.header + "Arguments:" + ansiReset, + helpStyle.placeholder + "" + ansiReset, + helpStyle.flag + "--private" + ansiReset, + } { + if !strings.Contains(colored, want) { + t.Errorf("colored help missing %q", want) + } + } + + root := colorizeHelp(helpFor(t)) + for _, want := range []string{ + helpStyle.header + "Conversations" + ansiReset, + helpStyle.command + "convs", + helpStyle.note + "Your open conversations" + ansiReset, // example descriptions + } { + if !strings.Contains(root, want) { + t.Errorf("colored root help missing %q", want) + } + } +} + +func TestHelpColorOnlyForInteractiveTerminals(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("TERM", "xterm-256color") + if !wantHelpColor(true, nil) { + t.Fatal("a terminal should get color") + } + if wantHelpColor(false, nil) { + t.Fatal("a pipe or file must stay plain") + } + if wantHelpColor(true, []string{"convs", "--no-color", "--help"}) { + t.Fatal("--no-color must turn color off") + } + t.Setenv("NO_COLOR", "1") + if wantHelpColor(true, nil) { + t.Fatal("NO_COLOR must turn color off") + } + t.Setenv("NO_COLOR", "") + t.Setenv("TERM", "dumb") + if wantHelpColor(true, nil) { + t.Fatal("TERM=dumb must stay plain") + } +} + +func stripANSI(s string) string { + return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(s, "") +} diff --git a/cmd/chatwoot/main.go b/cmd/chatwoot/main.go index d01ac2a..f7d5ed4 100644 --- a/cmd/chatwoot/main.go +++ b/cmd/chatwoot/main.go @@ -1,10 +1,8 @@ package main import ( - "bytes" "fmt" "os" - "regexp" "slices" "strconv" "strings" @@ -36,8 +34,6 @@ var ( // valueFlags are global flags whose value is the next token, so the arg // rewriters must skip both when looking for the noun. valueFlags = []string{"-o", "--output", "-a", "--account"} - - helpVerbSwap = regexp.MustCompile(`\b(view|messages|reply|resolve|open|pending|snooze|assign|unassign|label|priority|contact|conversations)\s+`) ) func main() { @@ -201,18 +197,3 @@ func rewriteIDFirstGrammar(args []string) []string { out[i+1], out[i+2] = out[i+2], out[i+1] return out } - -// idFirstHelpPrinter runs Kong's default printer into a buffer, then swaps -// ` ` → ` ` so help reads in the user-facing order. -func idFirstHelpPrinter(o kong.HelpOptions, ctx *kong.Context) error { - var buf bytes.Buffer - orig := ctx.Stdout - ctx.Stdout = &buf - err := kong.DefaultHelpPrinter(o, ctx) - ctx.Stdout = orig - if err != nil { - return err - } - _, werr := fmt.Fprint(orig, helpVerbSwap.ReplaceAllString(buf.String(), ` $1`)) - return werr -} diff --git a/internal/cmd/accounts.go b/internal/cmd/accounts.go index 1f0ca43..8dafedf 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -20,7 +20,7 @@ var fetchProfile = func(client *sdk.Client) (*sdk.ProfileResponse, error) { // AccountsCmd is `chatwoot accounts` — list, refresh, and rename accounts. type AccountsCmd struct { - List AccountsListCmd `cmd:"" default:"withargs" help:"List your accounts. This is what 'chatwoot accounts' does."` + List AccountsListCmd `cmd:"" default:"withargs" help:"List your accounts. You can leave out 'list': 'chatwoot accounts' works too."` Rename AccountsRenameCmd `cmd:"" help:"Give an account a name you prefer."` } diff --git a/internal/cmd/contact.go b/internal/cmd/contact.go index 8f4a595..2004f3c 100644 --- a/internal/cmd/contact.go +++ b/internal/cmd/contact.go @@ -75,7 +75,7 @@ func (c *ContactsCmd) Run(app *App) error { // ----------------------------------------------------------------------------- type ContactCmd struct { - View ContactViewCmd `cmd:"" default:"withargs" help:"Show the contact. This is what 'chatwoot contact 456' does."` + View ContactViewCmd `cmd:"" default:"withargs" help:"Show the contact. You can leave out 'view': 'chatwoot contact 456' works too."` Conversations ContactConversationsCmd `cmd:"" help:"List this contact's conversations."` } diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index c3ef830..a720e12 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -88,7 +88,7 @@ func (c *ConvsCmd) Run(app *App) error { // ----------------------------------------------------------------------------- type ConvCmd struct { - View ConvViewCmd `cmd:"" default:"withargs" help:"Show the conversation. This is what 'chatwoot conv 123' does."` + View ConvViewCmd `cmd:"" default:"withargs" help:"Show the conversation. You can leave out 'view': 'chatwoot conv 123' works too."` Messages ConvMessagesCmd `cmd:"" help:"Show the messages in the conversation."` Reply ConvReplyCmd `cmd:"" help:"Send a reply the customer will see, or a private note with --private."` Resolve ConvResolveCmd `cmd:"" help:"Mark the conversation as resolved."` diff --git a/internal/cmd/inbox.go b/internal/cmd/inbox.go index 1ab7a55..127ec7b 100644 --- a/internal/cmd/inbox.go +++ b/internal/cmd/inbox.go @@ -48,7 +48,7 @@ func (c *InboxesCmd) Run(app *App) error { // ----------------------------------------------------------------------------- type InboxCmd struct { - View InboxViewCmd `cmd:"" default:"withargs" help:"Show the inbox. This is what 'chatwoot inbox 5' does."` + View InboxViewCmd `cmd:"" default:"withargs" help:"Show the inbox. You can leave out 'view': 'chatwoot inbox 5' works too."` } type InboxViewCmd struct { From 517d3b9565218a33292c468015a7b6f2ec81d808 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 25 Sep 2026 16:23:54 +0530 Subject: [PATCH 3/4] docs: shorten the help changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7643b01..56a089a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Help text is rewritten in plain language, with examples for the most-used commands. `chatwoot --help` explains how commands read and groups them by topic (conversations, contacts and team, help center, accounts and login), and lists subcommands under each command's own help. Help has a cleaner layout (flush-left entries, a blank line under each header) and muted colors in a terminal; `--no-color`, `NO_COLOR`, and piped output stay plain. +- Clearer help: plain language, examples, commands grouped by topic, and subtle colors in the terminal. - `auth login` takes an optional URL (a bare host or any dashboard link) and no longer asks for an account ID, except on Chatwoot versions whose profile doesn't list accounts. - `-a` takes an account name; a number still means that account ID on the default account's instance. - The config file moves to a versioned format with one entry per account, and help center defaults are saved per account. Existing configs and keyring tokens are upgraded automatically on first run with no prompt; the old config is kept as `config.yaml.bak`, and the old keyring entry is left in place. From 714c28db6ab06777011bbdf7aa566b5fd5b2173f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 25 Sep 2026 16:27:12 +0530 Subject: [PATCH 4/4] fix: explain the single-account login fallback in help Login help promised that every account is added, but on Chatwoot versions whose profile doesn't list accounts, login asks for one account ID and adds only that one. The help now says so, and the accounts help no longer repeats the promise. --- cmd/chatwoot/help_test.go | 2 +- internal/cmd/accounts.go | 3 +-- internal/cmd/auth.go | 10 +++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cmd/chatwoot/help_test.go b/cmd/chatwoot/help_test.go index b9583a1..93f6705 100644 --- a/cmd/chatwoot/help_test.go +++ b/cmd/chatwoot/help_test.go @@ -111,7 +111,7 @@ func TestCommandHelpHasExamples(t *testing.T) { "contacts": {"Examples:", "--search"}, "accounts": {"Examples:", "chatwoot accounts --refresh"}, "use": {"Examples:", "CHATWOOT_ACCOUNT"}, - "auth login": {"Examples:", "chatwoot auth login staging.chatwoot.com"}, + "auth login": {"Examples:", "chatwoot auth login staging.chatwoot.com", "Older Chatwoot versions"}, "auth logout": {"Examples:", "chatwoot auth logout staging.chatwoot.com"}, "hc articles": {"Examples:", "--query"}, "api": {"Examples:", "chatwoot api /conversations/123"}, diff --git a/internal/cmd/accounts.go b/internal/cmd/accounts.go index 8dafedf..74584bf 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -29,8 +29,7 @@ type AccountsListCmd struct { } func (c *AccountsCmd) Help() string { - return `Log in once per Chatwoot instance and all your accounts there show up here. -The one marked * is your default. + return `Accounts show up here when you log in. The one marked * is your default. Examples: chatwoot accounts List your accounts diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index f70b8a1..4ee8d08 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -16,7 +16,7 @@ import ( ) type AuthCmd struct { - Login AuthLoginCmd `cmd:"" help:"Log in to a Chatwoot instance. All your accounts there are added."` + Login AuthLoginCmd `cmd:"" help:"Log in to a Chatwoot instance and add your accounts there."` Logout AuthLogoutCmd `cmd:"" help:"Log out of every instance, or just one."` Status AuthStatusCmd `cmd:"" help:"Show who you're logged in as, and which instance and account."` } @@ -35,8 +35,12 @@ func (c *AuthLoginCmd) Help() string { return `You'll be asked for an access token. Find it in Chatwoot under Profile Settings, then Access Token. It's saved in your system keychain. -Each account gets a short name, like acme. See them with 'chatwoot accounts'. -Log in again to add another instance, like staging. +Every account you belong to is added, each with a short name like acme. See +them with 'chatwoot accounts'. To add another instance, like staging, log in +again. + +Older Chatwoot versions don't list your accounts. There you're asked for an +account ID; log in again with another ID to add more. Examples: chatwoot auth login Asks for the address