diff --git a/CHANGELOG.md b/CHANGELOG.md index de4fe72..56a089a 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 +- 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. 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 new file mode 100644 index 0000000..93f6705 --- /dev/null +++ b/cmd/chatwoot/help_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "bytes" + "regexp" + "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", "Older Chatwoot versions"}, + "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) + } + } + } +} + +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 3c67b10..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() { @@ -93,14 +89,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. @@ -197,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 c4c58df..74584bf 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -20,12 +20,22 @@ 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. You can leave out 'list': 'chatwoot accounts' works too."` + 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 `Accounts show up here when you log in. 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 +200,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 +224,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..4ee8d08 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -16,21 +16,38 @@ 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 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."` } 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. + +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 + 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 +283,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..2004f3c 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. You can leave out 'view': 'chatwoot contact 456' works too."` + 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..a720e12 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. 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."` - 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..127ec7b 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. You can leave out 'view': 'chatwoot inbox 5' works too."` } 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 {