Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Multiple accounts. `auth login` registers every account you belong to on an instance, named after the account (`acme`, `globex-inc`); log in once per instance. Names that clash across instances get the host added (`chatwoot-staging`), and a second user on the same instance gets their name added.
- `@name` as the first word (or `-a name`, or `CHATWOOT_ACCOUNT`) picks the account for a command; unique prefixes work. `chatwoot use <name>` changes the saved default.
- Paste a Chatwoot dashboard link in place of the noun and id: `chatwoot <conversation link> reply "hi"`. Conversation, contact, and inbox links are supported, and the link's account is used. A link to an instance you haven't logged in to offers to log in first.
- `chatwoot accounts` (with `--refresh` and `rename`) and `chatwoot auth logout <url>` for a single instance. An unknown `@name` refreshes accounts once before failing.
- Writes to a non-default account print `→ <name>` to stderr before running, and a rejected token (401) explains how to log in again.

### Changed

- `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.
- Conversation locks are scoped per account, so the same conversation ID on two accounts no longer blocks.

### Fixed

- Global flags with values (`-a acme`, `-o json`) before the noun no longer break the id-first grammar (`chatwoot -a acme conv 123 reply "hi"`).
- `-a <id>` works with keyring logins, not only with `CHATWOOT_API_KEY`.
- Detail views align values when a key is as long as the longest one.

## [0.7.0] - 2026-09-25

### Added
Expand Down
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mise run dev # auto-rebuild on file changes
./chatwoot convs # plural noun = list
./chatwoot conv 123 # `conv 123` = view conv 123
./chatwoot conv 123 reply "hi" # id-first verb dispatch
./chatwoot @acme convs # pick an account for one command
./chatwoot accounts # registered accounts (* = default)
```

## Project Structure
Expand All @@ -34,12 +36,15 @@ internal/
## Key Conventions

- Kong commands: define flags/args as struct fields with tags, implement `Run(app *App) error`
- Grammar: plural noun = list (`ConvsCmd`), singular noun = parent struct with verb subcommands; each verb's struct holds its own `arg:""` ID. Kong forbids mixing `arg:""` and `cmd:""` siblings, so internally the verb comes before the ID (`conv reply 123 "hi"`).
- Grammar reads where → what → which → do: `chatwoot [@account] <noun> [id] [verb]`; a pasted dashboard link fills account + noun + id. Plural noun = list (`ConvsCmd`), singular noun = parent struct with verb subcommands; each verb's struct holds its own `arg:""` ID. Kong forbids mixing `arg:""` and `cmd:""` siblings, so internally the verb comes before the ID (`conv reply 123 "hi"`).
- `cmd/chatwoot/main.go` runs `rewriteIDFirstGrammar` to swap `<noun> <id> <verb>` → `<noun> <verb> <id>` before Kong parses, so users get to type the natural id-first form. A custom `kong.Help` printer flips the help output to match.
- `default:"withargs"` on the View subcommand routes `chatwoot conv 123` to `conv view 123`.
- `skipAuth` in main.go: auth/config commands bypass API client creation
- `GetRaw()` on Client: for non-account-scoped endpoints (e.g. `/api/v1/profile`)
- `Config.UserID` is cached on `auth login` and lazy-fetched on first `assign --agent me` so subsequent calls don't need a profile request
- Multi-account: `config.Config` holds `Accounts` (name, base URL, account ID, user ID, per-account help center) plus `Default`. `App.Account` is the account the command runs against, picked by `cfg.Resolve` (link > `@name`/`-a` > `CHATWOOT_ACCOUNT` > default). `rewriteLink` and `rewriteAccountShorthand` in main.go turn a pasted link or leading `@name` into `--account=…` before the id-first rewrite.
- Tokens are keyed per login (base URL + user ID) in the keyring; Chatwoot tokens are user-scoped, so one login serves every account that user sees. The pre-multi-account `api-key` entry is still read as a fallback.
- v1 (flat) configs migrate in memory on `config.Load`; `loadConfig` in cmd saves them best-effort. The migrated account has a provisional name until `App.Finish` syncs it after the first successful command.
- `Account.UserID` is cached on `auth login` and lazy-fetched on first `assign --agent me` so subsequent calls don't need a profile request

## Chatwoot API Quirks

Expand Down
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,32 @@ For a specific version or Windows, see the [install docs](https://developers.cha
## Setup

```bash
chatwoot auth login
chatwoot auth login # asks for the URL (default: Chatwoot Cloud)
chatwoot auth login staging.chatwoot.com # or pass it; any dashboard link works too
```

You'll be prompted for your **Base URL**, **API Key**, and **Account ID**. Credentials are validated before saving. Non-secret config lives at `~/.chatwoot/config.yaml`; the API key is stored in your OS keyring. For CI or headless environments, set `CHATWOOT_API_KEY` to override the keyring.
You'll be prompted for your **access token**. The CLI then registers every account you belong to on that instance, named after the account (`acme`, `globex-inc`), and asks which one is the default. Non-secret config lives at `~/.chatwoot/config.yaml`; tokens are stored in your OS keyring. For CI or headless environments, set `CHATWOOT_API_KEY` to override the keyring.

## Multiple accounts

Log in once per Chatwoot instance; every account you can see there is registered.

```bash
chatwoot accounts # List accounts (* = default)
chatwoot @acme convs # Use an account for one command (unique prefixes work: @ac)
chatwoot -a acme convs # Same, as a flag
chatwoot use acme # Change the saved default
CHATWOOT_ACCOUNT=acme chatwoot convs # Per shell session or CI

chatwoot https://app.chatwoot.com/app/accounts/1/conversations/4521 # Paste a link
chatwoot https://app.chatwoot.com/app/accounts/1/conversations/4521 reply "hi" # ...and act on it

chatwoot accounts --refresh # Pick up accounts added or removed since login
chatwoot accounts rename chatwoot-staging stg # Your own names
chatwoot auth logout staging.chatwoot.com # Log out of one instance
```

The account is chosen in this order: a pasted link, then `@name` / `-a`, then `CHATWOOT_ACCOUNT`, then the default. When the same account name exists on two instances, the second gets the host added (`chatwoot-staging`). Writes to a non-default account print `→ <name>` to stderr first. Upgrading from a single-account version needs no action.

## Agent Skill

Expand All @@ -33,11 +55,15 @@ See the [agent skill docs](https://developers.chatwoot.com/cli/agent-skill) for

## Usage

The CLI uses a simple noun grammar:
Every command reads left to right, the way you'd say it: **where**, **what**, **which one**, then **what to do**.

- **Plural noun = list:** `chatwoot convs`, `chatwoot contacts`, `chatwoot agents`
- **`<noun> <id>`** views: `chatwoot conv 123`
- **`<noun> <id> <verb>`** acts: `chatwoot conv 123 reply "thanks"` — id before verb, the way you'd say it.
| Shape | Means | Example |
|---|---|---|
| Plural noun | list | `chatwoot convs`, `chatwoot contacts`, `chatwoot agents` |
| Singular noun + id | view | `chatwoot conv 123` |
| Singular noun + id + verb | act (id before verb, the way you'd say it) | `chatwoot conv 123 reply "thanks"` |
| `@account` + any of the above | the same, in another account (leave it out for your default) | `chatwoot @acme conv 123 reply "thanks"` |
| Dashboard link + verb | the link is account + noun + id in one | `chatwoot https://app.chatwoot.com/app/accounts/1/conversations/123 reply "thanks"` |

```bash
chatwoot convs # Open conversations assigned to you
Expand Down
119 changes: 106 additions & 13 deletions cmd/chatwoot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import (

"github.com/alecthomas/kong"
"github.com/chatwoot/cli/internal/cmd"
"github.com/chatwoot/cli/internal/config"
kongcompletion "github.com/jotaen/kong-completion"
"golang.org/x/term"
)

var version = "dev"
Expand All @@ -31,6 +33,10 @@ var (
"inbox": inboxVerbs,
}

// 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+<id>`)
)

Expand All @@ -39,16 +45,13 @@ func main() {
if len(args) == 0 {
args = []string{"--help"}
}
args = rewriteIDFirstGrammar(args)
args = normalizeArgs(args)

var cli cmd.CLI
parser := kong.Must(&cli,
kong.Name("chatwoot"),
kong.Description("CLI for Chatwoot."),
kong.Vars{"version": version},
kong.UsageOnError(),
kong.Help(idFirstHelpPrinter),
)
parser, err := newParser(&cli)
if err != nil {
panic(err)
}

// Enable shell completions (must be called before Parse)
kongcompletion.Register(parser)
Expand All @@ -61,6 +64,8 @@ func main() {
// "not logged in" gracefully.
cmdStr := ctx.Command()
skipAuth := strings.HasPrefix(cmdStr, "auth") ||
strings.HasPrefix(cmdStr, "accounts") ||
strings.HasPrefix(cmdStr, "use") ||
strings.HasPrefix(cmdStr, "config") ||
strings.HasPrefix(cmdStr, "completion") ||
cmdStr == "me" ||
Expand All @@ -73,20 +78,108 @@ func main() {
os.Exit(1)
}

if notice := cmd.TargetNotice(app, cmdStr, &cli); notice != "" {
fmt.Fprintln(os.Stderr, notice)
}

if err := ctx.Run(app); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", cmd.ExplainError(app, err))
os.Exit(1)
}

// Notices go to stderr and only to a person at a terminal, never into
// --json/--quiet output or a pipe.
interactive := cli.Output == "text" && !cli.Quiet && term.IsTerminal(int(os.Stderr.Fd()))
app.Finish(os.Stderr, interactive)
}

// rewriteIDFirstGrammar swaps `<noun> <id> <verb>` to `<noun> <verb> <id>`
// when the args match a known context-noun grammar. Other shapes pass through
// unchanged, so verb-first input still works.
func rewriteIDFirstGrammar(args []string) []string {
func newParser(cli *cmd.CLI) (*kong.Kong, error) {
return kong.New(cli,
kong.Name("chatwoot"),
kong.Description("CLI for Chatwoot."),
kong.Vars{"version": version},
kong.UsageOnError(),
kong.Help(idFirstHelpPrinter),
)
}

// normalizeArgs turns the user-facing grammar into what Kong parses.
func normalizeArgs(args []string) []string {
return rewriteIDFirstGrammar(rewriteAccountShorthand(rewriteLink(args)))
}

// rewriteLink lets a pasted dashboard link stand in for the noun and id:
// `chatwoot <conversation link> reply "hi"` becomes
// `chatwoot --account=<its account> conv <id> reply "hi"`. The link names its
// account, so it replaces any other account selector. A link to another page
// only selects the account (listing conversations when nothing follows).
func rewriteLink(args []string) []string {
i := nounIndex(args)
j := i
if j < len(args) && len(args[j]) > 1 && strings.HasPrefix(args[j], "@") {
j++
}
if j >= len(args) {
return args
}
link, ok := config.ParseLink(args[j])
if !ok {
return args
}

out := make([]string, 0, len(args)+2)
for k := 0; k < i; k++ {
switch {
case args[k] == "-a" || args[k] == "--account":
k++ // drop the flag and its value
case strings.HasPrefix(args[k], "--account="):
default:
out = append(out, args[k])
}
}
out = append(out, "--account="+link.AccountSelector())

rest := args[j+1:]
switch {
case link.Noun != "":
out = append(out, link.Noun, strconv.Itoa(link.ID))
case len(rest) == 0:
out = append(out, "convs")
}
return append(out, rest...)
}

// nounIndex returns the index of the first token that is not a global flag or
// a global flag's value.
func nounIndex(args []string) int {
i := 0
for i < len(args) && strings.HasPrefix(args[i], "-") {
if slices.Contains(valueFlags, args[i]) {
i++
}
i++
}
return i
}

// rewriteAccountShorthand turns a leading `@name` into `--account=name`. Only
// the token where the noun would start counts, so `@` inside message text
// (`conv 1 reply "@john hi"`) is never treated as an account.
func rewriteAccountShorthand(args []string) []string {
i := nounIndex(args)
if i >= len(args) || len(args[i]) < 2 || !strings.HasPrefix(args[i], "@") {
return args
}
out := slices.Clone(args)
out[i] = "--account=" + strings.TrimPrefix(args[i], "@")
return out
}

// rewriteIDFirstGrammar swaps `<noun> <id> <verb>` to `<noun> <verb> <id>`
// when the args match a known context-noun grammar. Other shapes pass through
// unchanged, so verb-first input still works.
func rewriteIDFirstGrammar(args []string) []string {
i := nounIndex(args)
if i >= len(args) {
return args
}
Expand Down
Loading
Loading