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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Multi-profile support: save and switch between multiple Chatwoot instances/accounts. A global `--profile` flag, the `CHATWOOT_PROFILE` env var, and a `profiles` / `profile <name> show|use|remove` command tree manage named profiles, each with its own keyring-stored token. `auth login --profile <name>` saves into a named profile; resolution order is flag → env → configured default → `default`.

### Changed

- `~/.chatwoot/config.yaml` now stores a `profiles` map with a `default_profile`. Existing single-instance configs are migrated into the `default` profile automatically on first load, and that profile keeps its historical keyring entry, so upgrades need no re-login.
- `auth logout` now signs out of the active profile (removing only its token) instead of wiping all credentials; `config view` and `auth status` report the active profile name.

### Fixed

## [0.6.1] - 2026-06-03
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ chatwoot auth login

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.

### Profiles

Work across more than one Chatwoot — separate staging and production, or several accounts — by saving each as a named profile:

```bash
chatwoot auth login --profile staging # save a second instance
chatwoot profiles # list saved profiles (the default is marked *)
chatwoot profile staging use # make it the default
chatwoot --profile staging convs # one-off override for a single command
chatwoot profile staging remove # delete a profile and its stored token
```

Each profile keeps its own API key in the keyring. Resolution order is `--profile` flag → `CHATWOOT_PROFILE` env → the configured default → `default`. An existing single-instance config is migrated into the `default` profile automatically on first run.

## Agent Skill

If you use Claude Code, Cursor, or another AI coding assistant, install the agent skill so it knows the CLI's grammar and safety rules before sending customer-visible replies:
Expand Down
Binary file added bin/chatwoot-dev
Binary file not shown.
24 changes: 22 additions & 2 deletions cmd/chatwoot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,23 @@ var (
convVerbs = []string{"view", "messages", "reply", "resolve", "open", "pending", "snooze", "assign", "unassign", "label", "priority", "contact"}
contactVerbs = []string{"view", "conversations"}
inboxVerbs = []string{"view"}
profileVerbs = []string{"show", "use", "remove"}

contextNouns = map[string][]string{
"conv": convVerbs,
"conversation": convVerbs,
"contact": contactVerbs,
"inbox": inboxVerbs,
"profile": profileVerbs,
}

helpVerbSwap = regexp.MustCompile(`\b(view|messages|reply|resolve|open|pending|snooze|assign|unassign|label|priority|contact|conversations)\s+<id>`)
// stringIDNouns have a free-form identifier (e.g. a profile name) rather than
// a numeric id, so the id-first rewrite must not require a number for them.
stringIDNouns = map[string]bool{
"profile": true,
}

helpVerbSwap = regexp.MustCompile(`\b(view|messages|reply|resolve|open|pending|snooze|assign|unassign|label|priority|contact|conversations|show|use|remove)\s+<(id|name)>`)
)

func main() {
Expand Down Expand Up @@ -63,6 +71,7 @@ func main() {
skipAuth := strings.HasPrefix(cmdStr, "auth") ||
strings.HasPrefix(cmdStr, "config") ||
strings.HasPrefix(cmdStr, "completion") ||
strings.HasPrefix(cmdStr, "profile") || // profile + profiles manage local config only
cmdStr == "me" ||
cmdStr == "whoami" ||
cmdStr == "version"
Expand Down Expand Up @@ -94,7 +103,18 @@ func rewriteIDFirstGrammar(args []string) []string {
if !ok || i+2 >= len(args) {
return args
}
if _, err := strconv.Atoi(args[i+1]); err != nil {
id := args[i+1]
if strings.HasPrefix(id, "-") {
return args
}
if stringIDNouns[args[i]] {
// A verb in the id slot means the input is already verb-first (e.g.
// `profile show use`, showing a profile named "use"); don't rewrite it.
if slices.Contains(verbs, id) {
return args
}
} else if _, err := strconv.Atoi(id); err != nil {
// Numeric-id nouns (conversations, contacts, …) require a number there.
return args
}
if !slices.Contains(verbs, args[i+2]) {
Expand Down
36 changes: 36 additions & 0 deletions cmd/chatwoot/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,42 @@ func TestRewriteIDFirstGrammar(t *testing.T) {
in: []string{"conv", "abc", "reply"},
want: []string{"conv", "abc", "reply"},
},
{
// profile is a string-id noun: a non-numeric name is rewritten.
name: "profile name verb -> profile verb name",
in: []string{"profile", "work", "use"},
want: []string{"profile", "use", "work"},
},
{
name: "profile name remove -> profile remove name",
in: []string{"profile", "personal", "remove"},
want: []string{"profile", "remove", "personal"},
},
{
// Verb-first profile input is left alone (the verb check guards it).
name: "profile verb-first passes through",
in: []string{"profile", "use", "work"},
want: []string{"profile", "use", "work"},
},
{
name: "profile unknown verb passes through",
in: []string{"profile", "work", "explode"},
want: []string{"profile", "work", "explode"},
},
{
// A verb in the id slot is already verb-first, even when the profile
// name collides with a verb (showing a profile literally named "use").
name: "profile verb in id slot is not rewritten",
in: []string{"profile", "show", "use"},
want: []string{"profile", "show", "use"},
},
{
// `profile work` (no verb) is too short to rewrite; Kong's default
// subcommand routes it to `profile show work`.
name: "profile name only passes through",
in: []string{"profile", "work"},
want: []string{"profile", "work"},
},
{
name: "too few args passes through",
in: []string{"conv", "123"},
Expand Down
25 changes: 18 additions & 7 deletions internal/cmd/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,26 +70,37 @@ Inbox configuration.
- Output: table or JSON

### profile.go
Authenticated user profile.
- `profile show` — current user details
- Output: formatted text or JSON
Named-profile management (multiple saved instances/accounts). Pure-local — no
API client needed, so these are `skipAuth` commands.
- `profiles` — list saved profiles, marking the default.
- `profile <name>` — show a profile (default subcommand).
- `profile <name> use` — set the default profile.
- `profile <name> remove` — delete a profile and its stored token.
- The id-first form (`profile <name> use`) is rewritten to Kong's verb-first
form in `cmd/chatwoot/main.go`; profile is a string-id noun there.

(The *authenticated user's* profile is shown by `me` / `whoami`, i.e.
`auth status`.)

## App Struct

Passed to every command:
```go
type App struct {
Client *sdk.Client
Printer *output.Printer
Config *config.Config
Version string
Client *sdk.Client
Printer *output.Printer
Config *config.Config
ProfileName string // resolved active profile (--profile → env → default → "default")
Version string
}
```

Commands use:
- `app.Client` for API calls
- `app.Printer.Print()` to render output (respects --format flag)
- `app.Config` for cached values (base URL, account ID, etc.)
- `app.ProfileName` for the active profile when reading/writing config
(`config.LoadProfile`/`SaveProfile`/`ResolveAPIKeyFor`)

## Output Formatting

Expand Down
39 changes: 29 additions & 10 deletions internal/cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,42 @@ type App struct {
Client *sdk.Client
Printer *output.Printer
Config *config.Config
Version string
// ProfileName is the resolved active profile (--profile → CHATWOOT_PROFILE →
// default), set for every command including those that skip auth.
ProfileName string
Version string
}

// NewApp creates an App from the parsed CLI flags.
// Commands that don't need auth (auth login/logout, config) pass skipAuth=true.
// Commands that don't need auth (auth login/logout, config, profile) pass
// skipAuth=true.
func NewApp(cli *CLI, skipAuth bool, version string) (*App, error) {
printer := output.NewPrinter(cli.Output, cli.NoColor, cli.Quiet)

// Skip-auth commands (auth, config, profile, version) must keep working when
// the config file is missing or corrupt, so they don't load it here; they
// resolve the active profile themselves, non-fatally.
if skipAuth {
return &App{Printer: printer, Version: version}, nil
return &App{Printer: printer, ProfileName: cli.Profile, Version: version}, nil
}

cfg, err := config.Load()
store, err := config.LoadStore()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
Comment on lines +35 to 37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid loading config before skip-auth commands

Because this runs before the skipAuth check, commands that are intentionally local/no-auth, such as chatwoot auth login, chatwoot config path, and chatwoot version, now fail with failed to load config whenever ~/.chatwoot/config.yaml is malformed or unreadable. That removes the normal recovery path for a user with a corrupt config; defer this load for skip-auth commands or make profile resolution non-fatal there.

Useful? React with 👍 / 👎.

}
profileName := store.ActiveName(cli.Profile)

cfg := store.Get(profileName)
if cfg == nil || !cfg.IsValid() {
return nil, fmt.Errorf("not authenticated. Run 'chatwoot auth login' to set up credentials")
return nil, notAuthenticatedError(profileName, cli.Profile)
}

effectiveCfg := *cfg
if cli.Account > 0 {
effectiveCfg.AccountID = cli.Account
}

apiKey, _, err := config.ResolveAPIKey(&effectiveCfg)
apiKey, _, err := config.ResolveAPIKeyFor(profileName, &effectiveCfg)
if err != nil {
return nil, fmt.Errorf("not authenticated: %w", err)
}
Expand All @@ -52,9 +61,19 @@ func NewApp(cli *CLI, skipAuth bool, version string) (*App, error) {
)

return &App{
Client: client,
Printer: printer,
Config: cfg,
Version: version,
Client: client,
Printer: printer,
Config: cfg,
ProfileName: profileName,
Version: version,
}, nil
}

// notAuthenticatedError points the user at the right login command for the
// requested profile.
func notAuthenticatedError(profileName, override string) error {
if override != "" || profileName != config.DefaultProfileName {
return fmt.Errorf("not authenticated for profile %q. Run 'chatwoot auth login --profile %s' to set up credentials", profileName, profileName)
}
return fmt.Errorf("not authenticated. Run 'chatwoot auth login' to set up credentials")
}
52 changes: 52 additions & 0 deletions internal/cmd/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import (
"os"
"path/filepath"
"testing"

"github.com/chatwoot/cli/internal/config"
"github.com/zalando/go-keyring"
)

// TestNewAppSkipAuthToleratesUnreadableConfig guards the recovery path: a
// corrupt config.yaml must not stop skip-auth commands (auth login, config,
// profile, version) from running, since those are how a user fixes it.
func TestNewAppSkipAuthToleratesUnreadableConfig(t *testing.T) {
keyring.MockInit()
home := t.TempDir()
t.Setenv("HOME", home)

dir := filepath.Join(home, ".chatwoot")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path, err := config.ConfigPath()
if err != nil {
t.Fatalf("ConfigPath: %v", err)
}
if err := os.WriteFile(path, []byte("{ this: is not: valid yaml ["), 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}

app, err := NewApp(&CLI{Output: "text"}, true, "test")
if err != nil {
t.Fatalf("skip-auth NewApp must not fail on a corrupt config, got: %v", err)
}
if app == nil || app.Printer == nil {
t.Fatal("skip-auth NewApp returned an unusable App")
}
}

func TestNewAppSkipAuthCarriesProfileFlag(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())

app, err := NewApp(&CLI{Output: "text", Profile: "staging"}, true, "test")
if err != nil {
t.Fatalf("NewApp: %v", err)
}
if app.ProfileName != "staging" {
t.Fatalf("ProfileName = %q, want staging", app.ProfileName)
}
}
Loading
Loading