diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e63bfd..2bf1575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 show|use|remove` command tree manage named profiles, each with its own keyring-stored token. `auth login --profile ` 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 diff --git a/README.md b/README.md index 0e2030b..5328012 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/bin/chatwoot-dev b/bin/chatwoot-dev new file mode 100755 index 0000000..feadc8e Binary files /dev/null and b/bin/chatwoot-dev differ diff --git a/cmd/chatwoot/main.go b/cmd/chatwoot/main.go index af865ce..4a08e79 100644 --- a/cmd/chatwoot/main.go +++ b/cmd/chatwoot/main.go @@ -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+`) + // 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() { @@ -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" @@ -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]) { diff --git a/cmd/chatwoot/main_test.go b/cmd/chatwoot/main_test.go index bc1a64a..f54bb0a 100644 --- a/cmd/chatwoot/main_test.go +++ b/cmd/chatwoot/main_test.go @@ -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"}, diff --git a/internal/cmd/CLAUDE.md b/internal/cmd/CLAUDE.md index 3c18de5..3b024f5 100644 --- a/internal/cmd/CLAUDE.md +++ b/internal/cmd/CLAUDE.md @@ -70,19 +70,28 @@ 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 ` — show a profile (default subcommand). +- `profile use` — set the default profile. +- `profile remove` — delete a profile and its stored token. +- The id-first form (`profile 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 } ``` @@ -90,6 +99,8 @@ 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 diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 10adee7..e811450 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -13,25 +13,34 @@ 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) } + 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 @@ -39,7 +48,7 @@ func NewApp(cli *CLI, skipAuth bool, version string) (*App, error) { 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) } @@ -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") +} diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go new file mode 100644 index 0000000..fcd2a02 --- /dev/null +++ b/internal/cmd/app_test.go @@ -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) + } +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 4026b95..526c008 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -72,16 +72,21 @@ func (c *AuthLoginCmd) Run(app *App) error { } cfg.UserID = profile.ID - if err := config.SaveAPIKey(cfg, apiKey); err != nil { + profileName := config.ResolveActiveName(app.ProfileName) + + if err := config.SaveAPIKeyFor(profileName, cfg, apiKey); err != nil { return err } - if err := config.Save(cfg); err != nil { - _ = config.DeleteAPIKey(cfg) + if err := config.SaveProfile(profileName, cfg); err != nil { + _ = config.DeleteAPIKeyFor(profileName) return fmt.Errorf("failed to save config: %w", err) } fmt.Print(loginSuccessMessage(profile.Name, profile.Email)) + if profileName != config.DefaultProfileName { + fmt.Printf("Saved as profile %q.\n", profileName) + } return nil } @@ -143,32 +148,37 @@ func readAPIKey(reader *bufio.Reader) (string, error) { type AuthLogoutCmd struct{} func (c *AuthLogoutCmd) Run(app *App) error { - cfg, err := config.Load() - if err != nil { + profileName := config.ResolveActiveName(app.ProfileName) + + // Always clear the keyring entry first, even if no config file exists, so a + // stale token can't linger after logout. + if err := config.DeleteAPIKeyFor(profileName); err != nil { return err } - path, err := config.ConfigPath() + store, err := config.LoadStore() if err != nil { return err } + removed := store.Remove(profileName) - if err := config.DeleteAPIKey(cfg); err != nil { + if store.IsEmpty() { + path, perr := config.ConfigPath() + if perr != nil { + return perr + } + if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) { + return fmt.Errorf("failed to remove config: %w", rerr) + } + } else if err := store.Save(); err != nil { return err } - if err := os.Remove(path); err != nil { - if os.IsNotExist(err) { - fmt.Println("Not logged in.") - if strings.TrimSpace(os.Getenv(config.APIKeyEnv)) != "" { - fmt.Printf("%s is set in your environment; logout cannot remove environment-provided credentials.\n", config.APIKeyEnv) - } - return nil - } - return fmt.Errorf("failed to remove config: %w", err) + if removed { + fmt.Printf("Logged out of profile %q.\n", profileName) + } else { + fmt.Println("Not logged in.") } - - fmt.Println("Logged out successfully.") if strings.TrimSpace(os.Getenv(config.APIKeyEnv)) != "" { fmt.Printf("%s is set in your environment; logout cannot remove environment-provided credentials.\n", config.APIKeyEnv) } @@ -183,7 +193,8 @@ func (c *AuthStatusCmd) Run(app *App) error { return runAuthStatus(app) } // `whoami`. They all answer "who am I and where am I logged in?" so they // share output. It also opportunistically refreshes the cached UserID. func runAuthStatus(app *App) error { - cfg, err := config.Load() + name := config.ResolveActiveName(app.ProfileName) + cfg, err := config.LoadProfile(name) if err != nil { return err } @@ -193,7 +204,7 @@ func runAuthStatus(app *App) error { return err } - apiKey, source, err := config.ResolveAPIKey(cfg) + apiKey, source, err := config.ResolveAPIKeyFor(name, cfg) if err != nil { return fmt.Errorf("not authenticated: %w", err) } @@ -208,10 +219,11 @@ func runAuthStatus(app *App) error { // temporary overrides and must not rewrite the persisted login identity. if source == config.CredentialSourceKeyring && cfg.UserID != profile.ID { cfg.UserID = profile.ID - _ = config.Save(cfg) + _ = config.SaveProfile(name, cfg) } app.Printer.PrintDetail([]output.KeyValue{ + {Key: "Profile", Value: name}, {Key: "Instance", Value: cfg.BaseURL}, {Key: "Account", Value: strconv.Itoa(cfg.AccountID)}, {Key: "User ID", Value: strconv.Itoa(profile.ID)}, diff --git a/internal/cmd/cli.go b/internal/cmd/cli.go index c955478..1dbce09 100644 --- a/internal/cmd/cli.go +++ b/internal/cmd/cli.go @@ -13,6 +13,7 @@ import ( // - `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."` + Profile string `help:"Use a named profile (overrides the configured default)."` Account int `short:"a" help:"Override account ID."` Quiet bool `short:"q" help:"Print only IDs."` NoColor bool `help:"Disable colored output."` @@ -39,8 +40,10 @@ type CLI struct { Api ApiCmd `cmd:"" help:"Make an HTTP request to the Chatwoot API."` // Setup. - Auth AuthCmd `cmd:"" help:"Login, logout, and status."` - Config ConfigCmd `cmd:"" aliases:"cfg" help:"Manage CLI configuration."` + Auth AuthCmd `cmd:"" help:"Login, logout, and status."` + Config ConfigCmd `cmd:"" aliases:"cfg" help:"Manage CLI configuration."` + Profiles ProfilesCmd `cmd:"" help:"List saved profiles."` + ProfileCmd ProfileCmd `cmd:"" name:"profile" help:"Show, switch, or remove a profile."` Completion kongcompletion.Completion `cmd:"" help:"Print shell completion setup."` Version VersionCmd `cmd:"" help:"Print the CLI version."` diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 2151398..e3aaae1 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -27,7 +27,8 @@ func (c *ConfigPathCmd) Run(app *App) error { type ConfigViewCmd struct{} func (c *ConfigViewCmd) Run(app *App) error { - cfg, err := config.Load() + name := config.ResolveActiveName(app.ProfileName) + cfg, err := config.LoadProfile(name) if err != nil { return err } @@ -37,23 +38,22 @@ func (c *ConfigViewCmd) Run(app *App) error { return nil } - credential := credentialStatus(cfg) - detail := []output.KeyValue{ + {Key: "Profile", Value: name}, {Key: "Base URL", Value: cfg.BaseURL}, {Key: "Account ID", Value: fmt.Sprintf("%d", cfg.AccountID)}, - {Key: "Credential", Value: credential}, + {Key: "Credential", Value: credentialStatus(name, cfg)}, } if config.IsDev { - detail = append(detail, output.KeyValue{Key: "Profile", Value: "dev"}) + detail = append(detail, output.KeyValue{Key: "Build", Value: "dev"}) } app.Printer.PrintDetail(detail) return nil } -func credentialStatus(cfg *config.Config) string { - _, source, err := config.ResolveAPIKey(cfg) +func credentialStatus(profile string, cfg *config.Config) string { + _, source, err := config.ResolveAPIKeyFor(profile, cfg) if err == nil { return string(source) } diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index a0eacb9..0e56dcb 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -445,7 +445,7 @@ func resolveAgent(app *App, ref string) (int, error) { } if app.Config != nil { app.Config.UserID = profile.ID - _ = config.Save(app.Config) + _ = config.SaveProfile(app.ProfileName, app.Config) } return profile.ID, nil } diff --git a/internal/cmd/help_center.go b/internal/cmd/help_center.go index 01359c1..7eec97b 100644 --- a/internal/cmd/help_center.go +++ b/internal/cmd/help_center.go @@ -65,7 +65,7 @@ func (c *HCDefaultCmd) Run(app *App) error { return fmt.Errorf("config is not loaded") } app.Config.HelpCenter = config.HelpCenterConfig{} - if err := config.Save(app.Config); err != nil { + if err := config.SaveProfile(app.ProfileName, app.Config); err != nil { return err } _, _ = fmt.Fprintln(app.Printer.Writer, "Cleared default help center.") @@ -88,7 +88,7 @@ func (c *HCDefaultCmd) Run(app *App) error { DefaultPortalSlug: portal.Slug, DefaultLocale: portalDefaultLocale(portal), } - if err := config.Save(app.Config); err != nil { + if err := config.SaveProfile(app.ProfileName, app.Config); err != nil { return err } diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go new file mode 100644 index 0000000..e6e9da6 --- /dev/null +++ b/internal/cmd/profile.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + + "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/output" +) + +// ----------------------------------------------------------------------------- +// Plural: `chatwoot profiles` — list saved profiles. +// ----------------------------------------------------------------------------- + +type ProfilesCmd struct{} + +func (c *ProfilesCmd) Run(app *App) error { + store, err := config.LoadStore() + if err != nil { + return err + } + + names := store.Names() + if len(names) == 0 { + _, err := fmt.Fprintln(app.Printer.Writer, "No profiles configured. Run 'chatwoot auth login' to add one.") + return err + } + + defaultName := store.DefaultProfile + if defaultName == "" { + defaultName = config.DefaultProfileName + } + + if app.Printer.Format == "json" && !app.Printer.Quiet { + type profileView struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + AccountID int `json:"account_id"` + Default bool `json:"default"` + } + views := make([]profileView, 0, len(names)) + for _, n := range names { + p := store.Get(n) + views = append(views, profileView{Name: n, BaseURL: p.BaseURL, AccountID: p.AccountID, Default: n == defaultName}) + } + app.Printer.PrintJSON(views) + return nil + } + + headers := []string{"Name", "Default", "Base URL", "Account"} + rows := make([][]string, 0, len(names)) + for _, n := range names { + p := store.Get(n) + mark := "" + if n == defaultName { + mark = "*" + } + rows = append(rows, []string{n, mark, p.BaseURL, strconv.Itoa(p.AccountID)}) + } + app.Printer.PrintTable(headers, rows) + return nil +} + +// ----------------------------------------------------------------------------- +// Singular: `chatwoot profile ` — act on one profile. +// ----------------------------------------------------------------------------- + +type ProfileCmd struct { + Show ProfileShowCmd `cmd:"" default:"withargs" help:"Show a profile (default)."` + Use ProfileUseCmd `cmd:"" help:"Set a profile as the default."` + Remove ProfileRemoveCmd `cmd:"" aliases:"rm,delete" help:"Remove a saved profile and its stored token."` +} + +// -- show --------------------------------------------------------------------- + +type ProfileShowCmd struct { + Name string `arg:"" optional:"" help:"Profile name (default: the active profile)."` +} + +func (c *ProfileShowCmd) Run(app *App) error { + store, err := config.LoadStore() + if err != nil { + return err + } + + name := c.Name + if name == "" { + name = store.ActiveName(app.ProfileName) + } + + cfg := store.Get(name) + if cfg == nil { + return fmt.Errorf("profile %q not found; run 'chatwoot profiles' to list saved profiles", name) + } + + defaultName := store.DefaultProfile + if defaultName == "" { + defaultName = config.DefaultProfileName + } + + app.Printer.PrintDetail([]output.KeyValue{ + {Key: "Profile", Value: name}, + {Key: "Default", Value: yesNo(name == defaultName)}, + {Key: "Base URL", Value: cfg.BaseURL}, + {Key: "Account ID", Value: strconv.Itoa(cfg.AccountID)}, + {Key: "Credential", Value: credentialStatus(name, cfg)}, + }) + return nil +} + +// -- use ---------------------------------------------------------------------- + +type ProfileUseCmd struct { + Name string `arg:"" help:"Profile name to set as the default."` +} + +func (c *ProfileUseCmd) Run(app *App) error { + store, err := config.LoadStore() + if err != nil { + return err + } + if store.Get(c.Name) == nil { + return fmt.Errorf("profile %q not found; run 'chatwoot profiles' to list saved profiles", c.Name) + } + + store.DefaultProfile = c.Name + if err := store.Save(); err != nil { + return err + } + + if app.Printer != nil && app.Printer.Quiet { + fmt.Println(c.Name) + return nil + } + fmt.Printf("Default profile set to %q.\n", c.Name) + return nil +} + +// -- remove ------------------------------------------------------------------- + +type ProfileRemoveCmd struct { + Name string `arg:"" help:"Profile name to remove."` +} + +func (c *ProfileRemoveCmd) Run(app *App) error { + store, err := config.LoadStore() + if err != nil { + return err + } + if store.Get(c.Name) == nil { + return fmt.Errorf("profile %q not found", c.Name) + } + + if err := config.DeleteAPIKeyFor(c.Name); err != nil { + return err + } + store.Remove(c.Name) + + if store.IsEmpty() { + path, perr := config.ConfigPath() + if perr != nil { + return perr + } + if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) { + return fmt.Errorf("failed to remove config: %w", rerr) + } + } else if err := store.Save(); err != nil { + return err + } + + fmt.Printf("Removed profile %q.\n", c.Name) + return nil +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/internal/cmd/profile_test.go b/internal/cmd/profile_test.go new file mode 100644 index 0000000..d79a15e --- /dev/null +++ b/internal/cmd/profile_test.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/output" + "github.com/zalando/go-keyring" +) + +// seedProfiles isolates HOME + the keyring and writes two profiles, with "work" +// as the default. Returns nothing; the active build's config file is used. +func seedProfiles(t *testing.T) { + t.Helper() + keyring.MockInit() + if err := keyring.DeleteAll("chatwoot-cli"); err != nil { + t.Fatalf("keyring.DeleteAll: %v", err) + } + t.Setenv("HOME", t.TempDir()) + t.Setenv(config.APIKeyEnv, "") + t.Setenv(config.ProfileEnv, "") + + if err := config.SaveProfile("work", &config.Config{BaseURL: "https://work.example", AccountID: 1}); err != nil { + t.Fatalf("seed work: %v", err) + } + if err := config.SaveProfile("personal", &config.Config{BaseURL: "https://personal.example", AccountID: 2}); err != nil { + t.Fatalf("seed personal: %v", err) + } +} + +func runProfileCmd(t *testing.T, format string, run func(*App) error) string { + t.Helper() + var out bytes.Buffer + printer := output.NewPrinter(format, false, false) + printer.Writer = &out + if err := run(&App{Printer: printer}); err != nil { + t.Fatalf("Run: %v", err) + } + return out.String() +} + +func TestProfilesListMarksDefault(t *testing.T) { + seedProfiles(t) + + got := runProfileCmd(t, "text", (&ProfilesCmd{}).Run) + + for _, want := range []string{"work", "personal", "https://work.example", "https://personal.example"} { + if !strings.Contains(got, want) { + t.Fatalf("profiles output missing %q:\n%s", want, got) + } + } + // The default profile (work, first saved) is marked. + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(line, "work") && !strings.Contains(line, "*") { + t.Fatalf("default profile row not marked:\n%s", got) + } + } +} + +func TestProfilesListEmpty(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv(config.ProfileEnv, "") + + got := runProfileCmd(t, "text", (&ProfilesCmd{}).Run) + if !strings.Contains(got, "No profiles configured") { + t.Fatalf("expected empty-state message, got: %s", got) + } +} + +func TestProfileUseSwitchesDefault(t *testing.T) { + seedProfiles(t) + + _ = runProfileCmd(t, "text", (&ProfileUseCmd{Name: "personal"}).Run) + + store, err := config.LoadStore() + if err != nil { + t.Fatalf("LoadStore: %v", err) + } + if store.DefaultProfile != "personal" { + t.Fatalf("DefaultProfile = %q, want personal", store.DefaultProfile) + } +} + +func TestProfileUseUnknownErrors(t *testing.T) { + seedProfiles(t) + + printer := output.NewPrinter("text", false, false) + err := (&ProfileUseCmd{Name: "ghost"}).Run(&App{Printer: printer}) + if err == nil || !strings.Contains(err.Error(), "ghost") { + t.Fatalf("expected error naming the unknown profile, got %v", err) + } +} + +func TestProfileRemoveDeletesProfileAndToken(t *testing.T) { + seedProfiles(t) + + // Give personal a stored token so we can prove removal clears it. + if err := config.SaveAPIKeyFor("personal", &config.Config{BaseURL: "https://personal.example", AccountID: 2}, "tok"); err != nil { + t.Fatalf("SaveAPIKeyFor: %v", err) + } + + _ = runProfileCmd(t, "text", (&ProfileRemoveCmd{Name: "personal"}).Run) + + store, err := config.LoadStore() + if err != nil { + t.Fatalf("LoadStore: %v", err) + } + if store.Get("personal") != nil { + t.Fatal("personal profile should be removed") + } + if _, err := keyring.Get("chatwoot-cli", "profile:personal"); err == nil { + t.Fatal("personal profile token should be deleted from keyring") + } + // work survives. + if store.Get("work") == nil { + t.Fatal("work profile should survive removing personal") + } +} + +func TestProfileShowReportsDefaultFlag(t *testing.T) { + seedProfiles(t) + + got := runProfileCmd(t, "text", (&ProfileShowCmd{Name: "work"}).Run) + for _, want := range []string{"Profile:", "work", "Default:", "yes", "https://work.example"} { + if !strings.Contains(got, want) { + t.Fatalf("profile show output missing %q:\n%s", want, got) + } + } +} diff --git a/internal/config/CLAUDE.md b/internal/config/CLAUDE.md index f72105f..25974d0 100644 --- a/internal/config/CLAUDE.md +++ b/internal/config/CLAUDE.md @@ -1,24 +1,38 @@ # internal/config - Configuration Management -YAML-based configuration persistence for non-secret account settings. Configuration is stored in `~/.chatwoot/config.yaml` and auto-loaded on startup. API keys are resolved from `CHATWOOT_API_KEY` first, then the OS keyring. +YAML-based configuration persistence for non-secret account settings. The +config document holds one or more **named profiles** (each a Chatwoot +instance + account) plus a default selection, stored in `~/.chatwoot/config.yaml` +and auto-loaded on startup. API keys are resolved from `CHATWOOT_API_KEY` first, +then the active profile's OS keyring entry. ## Files ### config.go -Configuration struct and file I/O. Provides: -- `Config` struct with BaseURL and AccountID -- `Load()` — read from `~/.chatwoot/config.yaml`, create if missing -- `Save()` — write non-secret YAML -- Validation: ensures BaseURL and AccountID are set before API calls -- Error handling: distinguishes between missing file and parse errors +Profile-aware config struct and file I/O. Provides: +- `Config` — one profile's non-secret settings (BaseURL, AccountID, UserID, HelpCenter). +- `Store` — the on-disk document: `default_profile` + a `profiles` map. +- `LoadStore()` / `(*Store).Save()` — read/write the document; a legacy flat + (pre-profiles) config is migrated into the `default` profile on load. +- `(*Store).ActiveName(override)` — resolve the active profile: override (flag) + → `CHATWOOT_PROFILE` → `default_profile` → `"default"`. +- `(*Store).Get/Set/Remove/Names/IsEmpty` — profile management. `Remove` promotes + another profile to default if the removed one was the default. +- `ResolveActiveName/LoadProfile/SaveProfile` — package-level helpers for callers + that hold only a profile-override string (the command layer). +- `Load()` / `Save()` — compatibility shims that operate on the active profile. ### credentials.go -Credential resolution and OS keyring storage. Provides: -- `ResolveAPIKey()` — `CHATWOOT_API_KEY` first, then OS keyring -- `SaveAPIKey()` — write validated login token to keyring -- `DeleteAPIKey()` — remove saved keyring token on logout +Per-profile credential resolution and OS keyring storage. Provides: +- `ResolveAPIKeyFor(profile, cfg)` — `CHATWOOT_API_KEY` first, then the profile's + keyring entry. The `default` profile keeps the historical `api-key` entry (and + migrates older URL/account-scoped entries forward), so pre-profiles logins + resolve unchanged; named profiles use a `profile:` entry. +- `SaveAPIKeyFor(profile, cfg, key)` / `DeleteAPIKeyFor(profile)` — per-profile. +- `ResolveAPIKey/SaveAPIKey` — shims for the active profile. +- `DeleteAPIKey` — wipes every entry under this build's keyring service (full reset). -## Build Profiles (dev vs prod) +## Build Profiles (dev vs prod) — distinct from named profiles `configFileName` and `keyringService` are selected at build time via the `dev` build tag (`profile_prod.go` for `//go:build !dev`, `profile_dev.go` for @@ -30,34 +44,32 @@ build tag (`profile_prod.go` for `//go:build !dev`, `profile_dev.go` for | dev (`go build -tags dev`, `mise run dev`) | `~/.chatwoot/config.dev.yaml` | `chatwoot-cli-dev` | `true` | A dev build keeps its own credentials, so iterating on the CLI never reads or -clobbers the production login. The keyring **service** (not just the entry name) -is namespaced per profile, so `auth logout` — which does -`keyring.DeleteAll(keyringService)` to clear stale entries — only wipes the -active build's tokens and never the other profile's. Release builds (goreleaser -passes no tags) exclude `profile_dev.go` entirely — the dev path is compiled -out. `config view` shows a `Profile: dev` line on dev builds. +clobbers the production login. The two concepts compose cleanly: the **build +profile** selects the config *file* and keyring *service*; **named profiles** +select an entry *within* them. `config view` shows a `Build: dev` line on dev +builds (the `Profile:` line now reports the active named profile). ## Config Schema ```yaml -base_url: https://staging.chatwoot.com -account_id: 47 +default_profile: work +profiles: + work: + base_url: https://app.chatwoot.com + account_id: 47 + staging: + base_url: https://staging.chatwoot.com + account_id: 3 ``` ## Usage -In `main.go`: +Command layer (the `--profile` flag is resolved into `App.ProfileName`): ```go -cfg, err := config.Load() -if err != nil { - // Handle missing/invalid config -} - -apiKey, _, err := config.ResolveAPIKey(cfg) -if err != nil { - // Handle missing credentials -} - +store, _ := config.LoadStore() +name := store.ActiveName(cli.Profile) +cfg := store.Get(name) // nil if not configured +apiKey, _, err := config.ResolveAPIKeyFor(name, cfg) client := sdk.NewClient(cfg.BaseURL, apiKey, cfg.AccountID) ``` @@ -68,9 +80,5 @@ client := sdk.NewClient(cfg.BaseURL, apiKey, cfg.AccountID) ## File Permissions -Config directory is created with `0700`; config file is created with `0600`. API keys are not written to YAML. - -## TODO - -- Implement config migration for schema changes -- Add profile support (multiple saved credentials) +Config directory is created with `0700`; the config file is written atomically +(temp file + rename) with `0600`. API keys are never written to YAML. diff --git a/internal/config/config.go b/internal/config/config.go index 6c7533d..8df4f26 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,11 +4,22 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "gopkg.in/yaml.v3" ) +const ( + // ProfileEnv overrides the active profile for a single invocation. + ProfileEnv = "CHATWOOT_PROFILE" + // DefaultProfileName is used when no profile is named. Its credentials keep + // the historical keyring entry so pre-profiles logins resolve unchanged. + DefaultProfileName = "default" +) + +// Config holds one profile's non-secret settings (a single Chatwoot instance +// and account). The API token lives in the OS keyring, never here. type Config struct { BaseURL string `yaml:"base_url"` AccountID int `yaml:"account_id"` @@ -21,6 +32,12 @@ type HelpCenterConfig struct { DefaultLocale string `yaml:"default_locale,omitempty"` } +// Store is the on-disk config document: named profiles plus the default. +type Store struct { + DefaultProfile string `yaml:"default_profile,omitempty"` + Profiles map[string]*Config `yaml:"profiles,omitempty"` +} + func ConfigDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -37,7 +54,7 @@ func ConfigPath() (string, error) { return filepath.Join(dir, configFileName), nil } -func Load() (*Config, error) { +func LoadStore() (*Store, error) { path, err := ConfigPath() if err != nil { return nil, err @@ -46,25 +63,48 @@ func Load() (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return nil, nil // No config file exists + return &Store{Profiles: map[string]*Config{}}, nil } return nil, fmt.Errorf("failed to read config: %w", err) } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + // Decode both layouts so a pre-profiles flat config.yaml migrates on load. + var doc struct { + DefaultProfile string `yaml:"default_profile"` + Profiles map[string]*Config `yaml:"profiles"` + BaseURL string `yaml:"base_url"` + AccountID int `yaml:"account_id"` + UserID int `yaml:"user_id"` + HelpCenter HelpCenterConfig `yaml:"help_center"` + } + if err := yaml.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } - return &cfg, nil + store := &Store{DefaultProfile: doc.DefaultProfile, Profiles: doc.Profiles} + if store.Profiles == nil { + store.Profiles = map[string]*Config{} + } + if len(store.Profiles) == 0 && (strings.TrimSpace(doc.BaseURL) != "" || doc.AccountID != 0) { + store.Profiles[DefaultProfileName] = &Config{ + BaseURL: doc.BaseURL, + AccountID: doc.AccountID, + UserID: doc.UserID, + HelpCenter: doc.HelpCenter, + } + if store.DefaultProfile == "" { + store.DefaultProfile = DefaultProfileName + } + } + + return store, nil } -func Save(cfg *Config) error { +func (s *Store) Save() error { dir, err := ConfigDir() if err != nil { return err } - if err := os.MkdirAll(dir, 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -77,7 +117,7 @@ func Save(cfg *Config) error { return err } - data, err := yaml.Marshal(cfg) + data, err := yaml.Marshal(s) if err != nil { return fmt.Errorf("failed to serialize config: %w", err) } @@ -88,10 +128,113 @@ func Save(cfg *Config) error { if err := os.Chmod(path, 0600); err != nil { return fmt.Errorf("failed to secure config file: %w", err) } - return nil } +// ActiveName resolves the profile to use: override (flag) → CHATWOOT_PROFILE → +// configured default → "default". +func (s *Store) ActiveName(override string) string { + switch { + case strings.TrimSpace(override) != "": + return strings.TrimSpace(override) + case strings.TrimSpace(os.Getenv(ProfileEnv)) != "": + return strings.TrimSpace(os.Getenv(ProfileEnv)) + case s != nil && s.DefaultProfile != "": + return s.DefaultProfile + default: + return DefaultProfileName + } +} + +func (s *Store) Get(name string) *Config { + if s == nil || s.Profiles == nil { + return nil + } + return s.Profiles[name] +} + +func (s *Store) Set(name string, cfg *Config) { + if s.Profiles == nil { + s.Profiles = map[string]*Config{} + } + s.Profiles[name] = cfg +} + +// Remove deletes a profile, promoting another to default if the removed one was +// the default. Reports whether the profile existed. +func (s *Store) Remove(name string) bool { + if s == nil || s.Profiles == nil { + return false + } + if _, ok := s.Profiles[name]; !ok { + return false + } + delete(s.Profiles, name) + if s.DefaultProfile == name { + s.DefaultProfile = "" + if names := s.Names(); len(names) > 0 { + s.DefaultProfile = names[0] + } + } + return true +} + +func (s *Store) Names() []string { + names := make([]string, 0, len(s.Profiles)) + for n := range s.Profiles { + names = append(names, n) + } + sort.Strings(names) + return names +} + +func (s *Store) IsEmpty() bool { return len(s.Profiles) == 0 } + +// ResolveActiveName loads the store and resolves the active profile for an +// override, for callers that hold only the --profile flag value. +func ResolveActiveName(override string) string { + store, err := LoadStore() + if err != nil { + return (&Store{}).ActiveName(override) + } + return store.ActiveName(override) +} + +// LoadProfile returns the named profile (empty name → active), or nil if absent. +func LoadProfile(name string) (*Config, error) { + store, err := LoadStore() + if err != nil { + return nil, err + } + if name == "" { + name = store.ActiveName("") + } + return store.Get(name), nil +} + +// SaveProfile persists cfg as the named profile (empty name → active), selecting +// it as default when it is the first profile. +func SaveProfile(name string, cfg *Config) error { + store, err := LoadStore() + if err != nil { + return err + } + if name == "" { + name = store.ActiveName("") + } + first := store.IsEmpty() + store.Set(name, cfg) + if first || store.DefaultProfile == "" { + store.DefaultProfile = name + } + return store.Save() +} + +// Load and Save operate on the active profile, for callers without an explicit +// profile. +func Load() (*Config, error) { return LoadProfile("") } +func Save(cfg *Config) error { return SaveProfile("", cfg) } + func (c *Config) IsValid() bool { return strings.TrimSpace(c.BaseURL) != "" && c.AccountID > 0 } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 0c4b176..c6133e1 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -16,10 +16,8 @@ const ( ) // keyringService is build-profile specific ("chatwoot-cli" for prod, -// "chatwoot-cli-dev" for dev). Namespacing the whole service per profile keeps -// logout's DeleteAll(keyringService) scoped to the active build, so a dev logout -// can't erase a prod login's token and vice versa. See profile_prod.go / -// profile_dev.go. +// "chatwoot-cli-dev" for dev); see profile_prod.go / profile_dev.go. Named +// profiles are distinct entries within that service. type CredentialSource string @@ -37,11 +35,20 @@ type savedCredential struct { APIKey string `json:"api_key"` } -// ResolveAPIKey implements the auth flow for the CLI. YAML config intentionally -// stores only non-secrets, and plaintext api_key values from older configs are -// ignored. CHATWOOT_API_KEY wins for CI, coding agents, and temporary overrides; -// otherwise saved interactive logins read the token from the OS keyring. -func ResolveAPIKey(cfg *Config) (string, CredentialSource, error) { +// keyringEntry maps a profile to its keyring entry. The default profile keeps +// the historical "api-key" entry so pre-profiles logins resolve unchanged; +// named profiles get their own namespaced entry. +func keyringEntry(profile string) string { + if profile == "" || profile == DefaultProfileName { + return apiKeyKeyringEntry + } + return "profile:" + profile +} + +// ResolveAPIKeyFor returns a profile's token. CHATWOOT_API_KEY wins for CI, +// coding agents, and temporary overrides; otherwise the token is read from the +// profile's keyring entry. Plaintext api_key values in YAML are ignored. +func ResolveAPIKeyFor(profile string, cfg *Config) (string, CredentialSource, error) { if apiKey := strings.TrimSpace(os.Getenv(APIKeyEnv)); apiKey != "" { return apiKey, CredentialSourceEnvironment, nil } @@ -50,11 +57,12 @@ func ResolveAPIKey(cfg *Config) (string, CredentialSource, error) { return "", CredentialSourceMissing, missingAPIKeyError() } - stored, err := keyring.Get(keyringService, apiKeyKeyringEntry) + entry := keyringEntry(profile) + stored, err := keyring.Get(keyringService, entry) if err == nil { - apiKey, err := parseSavedCredential(stored, cfg) - if err != nil { - return "", CredentialSourceMissing, err + apiKey, perr := parseSavedCredential(stored, cfg) + if perr != nil { + return "", CredentialSourceMissing, perr } return apiKey, CredentialSourceKeyring, nil } @@ -62,24 +70,26 @@ func ResolveAPIKey(cfg *Config) (string, CredentialSource, error) { return "", CredentialSourceMissing, fmt.Errorf("failed to read API key from keyring: %w", err) } - // TODO(v1): remove this legacy key migration after users have had a release - // cycle to move from URL/account-scoped keyring entries to api-key. - apiKey, err := keyring.Get(keyringService, legacyCredentialKey(cfg)) - if err == nil { - if err := saveAPIKeyToKeyring(cfg, apiKey); err != nil { - return "", CredentialSourceMissing, fmt.Errorf("failed to migrate API key in keyring: %w", err) + // TODO(v1): drop this once users have migrated. Only the default profile had + // the older URL/account-scoped entry; migrate it forward to its stable entry. + if entry == apiKeyKeyringEntry { + legacyToken, lerr := keyring.Get(keyringService, legacyCredentialKey(cfg)) + if lerr == nil { + if serr := saveAPIKeyToKeyring(entry, cfg, legacyToken); serr != nil { + return "", CredentialSourceMissing, fmt.Errorf("failed to migrate API key in keyring: %w", serr) + } + _ = keyring.Delete(keyringService, legacyCredentialKey(cfg)) + return legacyToken, CredentialSourceKeyring, nil + } + if !errors.Is(lerr, keyring.ErrNotFound) { + return "", CredentialSourceMissing, fmt.Errorf("failed to read legacy API key from keyring: %w", lerr) } - _ = keyring.Delete(keyringService, legacyCredentialKey(cfg)) - return apiKey, CredentialSourceKeyring, nil - } - if !errors.Is(err, keyring.ErrNotFound) { - return "", CredentialSourceMissing, fmt.Errorf("failed to read legacy API key from keyring: %w", err) } return "", CredentialSourceMissing, missingAPIKeyError() } -func SaveAPIKey(cfg *Config, apiKey string) error { +func SaveAPIKeyFor(profile string, cfg *Config, apiKey string) error { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return fmt.Errorf("api key is required") @@ -87,13 +97,42 @@ func SaveAPIKey(cfg *Config, apiKey string) error { if cfg == nil || !cfg.IsValid() { return fmt.Errorf("valid config is required to save API key") } - if err := saveAPIKeyToKeyring(cfg, apiKey); err != nil { + if err := saveAPIKeyToKeyring(keyringEntry(profile), cfg, apiKey); err != nil { return fmt.Errorf("failed to save API key to keyring: %w", err) } return nil } -func saveAPIKeyToKeyring(cfg *Config, apiKey string) error { +// DeleteAPIKeyFor removes a single profile's token, leaving other profiles intact. +func DeleteAPIKeyFor(profile string) error { + err := keyring.Delete(keyringService, keyringEntry(profile)) + if err == nil || errors.Is(err, keyring.ErrNotFound) { + return nil + } + return fmt.Errorf("failed to delete API key from keyring: %w", err) +} + +// ResolveAPIKey and SaveAPIKey operate on the active profile, for callers +// without an explicit profile. +func ResolveAPIKey(cfg *Config) (string, CredentialSource, error) { + return ResolveAPIKeyFor(ResolveActiveName(""), cfg) +} + +func SaveAPIKey(cfg *Config, apiKey string) error { + return SaveAPIKeyFor(ResolveActiveName(""), cfg, apiKey) +} + +// DeleteAPIKey removes every credential under this build's keyring service (a +// full reset); per-profile logout uses DeleteAPIKeyFor. +func DeleteAPIKey(_ *Config) error { + err := keyring.DeleteAll(keyringService) + if err == nil || errors.Is(err, keyring.ErrNotFound) { + return nil + } + return fmt.Errorf("failed to delete API keys from keyring: %w", err) +} + +func saveAPIKeyToKeyring(entry string, cfg *Config, apiKey string) error { credential := savedCredential{ BaseURL: normalizeBaseURL(cfg.BaseURL), AccountID: cfg.AccountID, @@ -104,7 +143,7 @@ func saveAPIKeyToKeyring(cfg *Config, apiKey string) error { if err != nil { return err } - return keyring.Set(keyringService, apiKeyKeyringEntry, string(data)) + return keyring.Set(keyringService, entry, string(data)) } func parseSavedCredential(stored string, cfg *Config) (string, error) { @@ -120,16 +159,6 @@ func parseSavedCredential(stored string, cfg *Config) (string, error) { return credential.APIKey, nil } -// DeleteAPIKey removes every credential saved by this CLI service. This avoids -// leaving stale keyring entries behind when config.yaml was edited or removed. -func DeleteAPIKey(_ *Config) error { - err := keyring.DeleteAll(keyringService) - if err == nil || errors.Is(err, keyring.ErrNotFound) { - return nil - } - return fmt.Errorf("failed to delete API keys from keyring: %w", err) -} - func missingAPIKeyError() error { return fmt.Errorf("%w; run 'chatwoot auth login' or set %s", ErrAPIKeyNotFound, APIKeyEnv) } diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 82b76fd..7b36edd 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -139,6 +139,67 @@ func TestResolveAPIKeyMigratesLegacyKeyringToken(t *testing.T) { } } +func TestNamedProfilesUseDistinctKeyringEntries(t *testing.T) { + initMockKeyring(t) + + work := &Config{BaseURL: "https://work.example", AccountID: 1} + personal := &Config{BaseURL: "https://personal.example", AccountID: 2} + if err := SaveAPIKeyFor("work", work, "work-token"); err != nil { + t.Fatalf("SaveAPIKeyFor(work) error = %v", err) + } + if err := SaveAPIKeyFor("personal", personal, "personal-token"); err != nil { + t.Fatalf("SaveAPIKeyFor(personal) error = %v", err) + } + + // The default profile keeps the historical "api-key" entry; named profiles + // are namespaced, so neither collides. + if entry := keyringEntry(DefaultProfileName); entry != apiKeyKeyringEntry { + t.Fatalf("default entry = %q, want %q", entry, apiKeyKeyringEntry) + } + if entry := keyringEntry("work"); entry == apiKeyKeyringEntry { + t.Fatalf("named profile entry must differ from the default entry, got %q", entry) + } + + wk, src, err := ResolveAPIKeyFor("work", work) + if err != nil || wk != "work-token" || src != CredentialSourceKeyring { + t.Fatalf("ResolveAPIKeyFor(work) = (%q, %v, %v), want work-token/keyring", wk, src, err) + } + pk, _, err := ResolveAPIKeyFor("personal", personal) + if err != nil || pk != "personal-token" { + t.Fatalf("ResolveAPIKeyFor(personal) = (%q, %v), want personal-token", pk, err) + } + + // Removing one profile's token must leave the other intact. + if err := DeleteAPIKeyFor("work"); err != nil { + t.Fatalf("DeleteAPIKeyFor(work) error = %v", err) + } + if _, _, err := ResolveAPIKeyFor("work", work); !errors.Is(err, ErrAPIKeyNotFound) { + t.Fatalf("work token should be gone, err = %v", err) + } + if pk, _, err := ResolveAPIKeyFor("personal", personal); err != nil || pk != "personal-token" { + t.Fatalf("personal token should survive work logout, got (%q, %v)", pk, err) + } +} + +func TestDefaultProfileMapsToLegacyEntry(t *testing.T) { + initMockKeyring(t) + cfg := &Config{BaseURL: "https://app.chatwoot.com", AccountID: 9} + + // Saving the default profile must write the legacy "api-key" entry so that + // pre-profiles single-instance logins keep resolving. + if err := SaveAPIKeyFor(DefaultProfileName, cfg, "default-token"); err != nil { + t.Fatalf("SaveAPIKeyFor(default) error = %v", err) + } + if _, err := keyring.Get(keyringService, apiKeyKeyringEntry); err != nil { + t.Fatalf("default profile token not stored at legacy entry: %v", err) + } + // An empty profile name resolves to the default too. + key, src, err := ResolveAPIKeyFor("", cfg) + if err != nil || key != "default-token" || src != CredentialSourceKeyring { + t.Fatalf("ResolveAPIKeyFor(\"\") = (%q, %v, %v), want default-token/keyring", key, src, err) + } +} + func TestResolveAPIKeyMissing(t *testing.T) { initMockKeyring(t) cfg := &Config{BaseURL: "https://app.chatwoot.com", AccountID: 125} diff --git a/internal/config/store_test.go b/internal/config/store_test.go new file mode 100644 index 0000000..9489aa8 --- /dev/null +++ b/internal/config/store_test.go @@ -0,0 +1,144 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestActiveNamePrecedence(t *testing.T) { + // No env set for this test; flag override beats default beats "default". + t.Setenv(ProfileEnv, "") + + s := &Store{DefaultProfile: "cloud"} + if got := s.ActiveName("flagwins"); got != "flagwins" { + t.Fatalf("override should win, got %q", got) + } + if got := s.ActiveName(""); got != "cloud" { + t.Fatalf("default should win when no override/env, got %q", got) + } + + t.Setenv(ProfileEnv, "envwins") + if got := s.ActiveName(""); got != "envwins" { + t.Fatalf("env should beat default, got %q", got) + } + if got := s.ActiveName("flagwins"); got != "flagwins" { + t.Fatalf("override should beat env, got %q", got) + } + + empty := &Store{} + t.Setenv(ProfileEnv, "") + if got := empty.ActiveName(""); got != DefaultProfileName { + t.Fatalf("fallback should be %q, got %q", DefaultProfileName, got) + } +} + +func TestLoadStoreMigratesLegacyFlatConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir, err := ConfigDir() + if err != nil { + t.Fatalf("ConfigDir() error = %v", err) + } + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + path, err := ConfigPath() + if err != nil { + t.Fatalf("ConfigPath() error = %v", err) + } + legacy := "base_url: https://app.chatwoot.com\naccount_id: 123\nuser_id: 7\n" + + "help_center:\n default_portal_slug: kb\n default_locale: en\n" + if err := os.WriteFile(path, []byte(legacy), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error = %v", err) + } + if store.DefaultProfile != DefaultProfileName { + t.Fatalf("DefaultProfile = %q, want %q", store.DefaultProfile, DefaultProfileName) + } + cfg := store.Get(DefaultProfileName) + if cfg == nil { + t.Fatal("legacy flat config was not migrated into the default profile") + } + if cfg.BaseURL != "https://app.chatwoot.com" || cfg.AccountID != 123 || cfg.UserID != 7 { + t.Fatalf("migrated cfg = %#v, want base_url/account/user preserved", cfg) + } + if cfg.HelpCenter.DefaultPortalSlug != "kb" || cfg.HelpCenter.DefaultLocale != "en" { + t.Fatalf("migrated help center = %#v, want preserved", cfg.HelpCenter) + } + + // Re-saving must produce the nested profile layout, not the flat one. + if err := store.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !strings.Contains(string(data), "profiles:") || !strings.Contains(string(data), "default:") { + t.Fatalf("saved config not in profile layout: %s", string(data)) + } +} + +func TestSaveProfileRoundTripAndIsolation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv(ProfileEnv, "") + + work := &Config{BaseURL: "https://work.example", AccountID: 1} + personal := &Config{BaseURL: "https://personal.example", AccountID: 2} + if err := SaveProfile("work", work); err != nil { + t.Fatalf("SaveProfile(work) error = %v", err) + } + if err := SaveProfile("personal", personal); err != nil { + t.Fatalf("SaveProfile(personal) error = %v", err) + } + + // First profile saved becomes the default. + store, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error = %v", err) + } + if store.DefaultProfile != "work" { + t.Fatalf("DefaultProfile = %q, want work (first saved)", store.DefaultProfile) + } + if got := store.Get("personal"); got == nil || got.AccountID != 2 { + t.Fatalf("personal profile = %#v, want account 2", got) + } + if got := store.Get("work"); got == nil || got.AccountID != 1 { + t.Fatalf("work profile = %#v, want account 1", got) + } +} + +func TestStoreRemovePromotesDefault(t *testing.T) { + s := &Store{ + DefaultProfile: "work", + Profiles: map[string]*Config{ + "work": {BaseURL: "https://work.example", AccountID: 1}, + "personal": {BaseURL: "https://personal.example", AccountID: 2}, + }, + } + + if !s.Remove("work") { + t.Fatal("Remove(work) should report the profile existed") + } + if s.Get("work") != nil { + t.Fatal("work should be gone after Remove") + } + if s.DefaultProfile != "personal" { + t.Fatalf("DefaultProfile = %q, want personal promoted after removing the default", s.DefaultProfile) + } + if s.Remove("nope") { + t.Fatal("Remove of a missing profile should report false") + } + + if !s.Remove("personal") { + t.Fatal("Remove(personal) should report it existed") + } + if !s.IsEmpty() { + t.Fatal("store should be empty after removing all profiles") + } +} diff --git a/skills/chatwoot-cli/SKILL.md b/skills/chatwoot-cli/SKILL.md index dc6426e..f60cbf2 100644 --- a/skills/chatwoot-cli/SKILL.md +++ b/skills/chatwoot-cli/SKILL.md @@ -132,8 +132,12 @@ chatwoot convs --help # filters for the list command | `api ` | Call an arbitrary Chatwoot API endpoint with saved auth headers | | `auth login` / `logout` | Interactive login / remove credentials | | `config path` / `config view` | Inspect config file location and contents | +| `profiles` / `profile ` | List saved profiles / show one | +| `profile use` / `remove` | Set the default profile / delete one | | `completion ` | Print shell-completion script | +Use `--profile ` (or `CHATWOOT_PROFILE`) to target a saved instance/account for any command, e.g. `chatwoot --profile staging convs`. Resolution order: flag → env → default profile → `default`. + ## Common Mistakes | # | Mistake | Fix | @@ -174,7 +178,12 @@ Customer- or team-visible (effectively irreversible): Read-only and safe to run freely: `convs`, `conv ` (view), `conv messages`, `conv contact`, `contacts`, `contact `, `inboxes`, `inbox `, `agents`, `labels`, `teams`, `me`, `whoami`, -`auth status`, `config path`, `config view`, `api ` when it is a GET. +`auth status`, `config path`, `config view`, `profiles`, `profile ` (view), +`api ` when it is a GET. + +`profile use` and `profile remove` change only local CLI config +(the default selection and stored tokens) — not customer-visible, but `remove` +deletes a saved login, so confirm before running it. ## Common Patterns