diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b2713c..53704f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
## 0.7.1 - Unreleased
+### Added
+
+- Accounts: add first-class named WhatsApp accounts with isolated stores, `--account NAME`, and `wacli accounts list/add/use/show/remove`.
+
+### Fixed
+
+- Store: fix migration of legacy databases whose `groups` table existed before group hierarchy columns were introduced.
+
+### Docs
+
+- Docs: add a dedicated accounts page covering YAML config, store selection precedence, and multi-account usage.
+
## 0.7.0 - 2026-05-06
### Added
diff --git a/README.md b/README.md
index d6f0889..4ce3c73 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ More recipes — replies, mentions, stickers, voice, reactions, channels, histor
| Area | Pages |
| --- | --- |
-| **Setup** | [overview](docs/overview.md) · [auth](docs/auth.md) · [sync](docs/sync.md) · [doctor](docs/doctor.md) |
+| **Setup** | [overview](docs/overview.md) · [auth](docs/auth.md) · [accounts](docs/accounts.md) · [sync](docs/sync.md) · [doctor](docs/doctor.md) |
| **Messaging** | [messages](docs/messages.md) · [send](docs/send.md) · [media](docs/media.md) · [presence](docs/presence.md) |
| **Address book** | [contacts](docs/contacts.md) · [chats](docs/chats.md) · [groups](docs/groups.md) · [channels](docs/channels.md) |
| **History** | [history coverage / fill / backfill](docs/history.md) |
@@ -75,9 +75,9 @@ More recipes — replies, mentions, stickers, voice, reactions, channels, histor
## Configuration
-Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere. Existing `~/.wacli` directories on Linux keep working.
+Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere. Existing `~/.wacli` directories on Linux keep working. Use `wacli accounts add NAME` and `--account NAME` for first-class multi-account stores.
-**Global flags:** `--store DIR`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
+**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
**Environment overrides:**
diff --git a/cmd/wacli/accounts.go b/cmd/wacli/accounts.go
new file mode 100644
index 0000000..7521308
--- /dev/null
+++ b/cmd/wacli/accounts.go
@@ -0,0 +1,289 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "time"
+
+ "github.com/spf13/cobra"
+ "github.com/steipete/wacli/internal/config"
+ "github.com/steipete/wacli/internal/fsutil"
+ "github.com/steipete/wacli/internal/out"
+)
+
+type accountPayload struct {
+ Name string `json:"name"`
+ Label string `json:"label,omitempty"`
+ ConfiguredStore string `json:"configured_store"`
+ StoreDir string `json:"store_dir"`
+ Default bool `json:"default"`
+}
+
+func newAccountsCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "accounts",
+ Short: "Manage named WhatsApp accounts",
+ }
+ cmd.AddCommand(newAccountsListCmd(flags))
+ cmd.AddCommand(newAccountsAddCmd(flags))
+ cmd.AddCommand(newAccountsUseCmd(flags))
+ cmd.AddCommand(newAccountsShowCmd(flags))
+ cmd.AddCommand(newAccountsRemoveCmd(flags))
+ return cmd
+}
+
+func newAccountsListCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List configured accounts",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := config.DefaultConfigPath()
+ cfg, _, err := config.LoadAccountsConfigIfExists(path)
+ if err != nil {
+ return err
+ }
+ accounts := sortedAccounts(path, cfg)
+ payloads := accountPayloads(accounts)
+ if flags.asJSON {
+ return out.WriteJSON(os.Stdout, map[string]any{
+ "config_path": path,
+ "default_account": cfg.DefaultAccount,
+ "accounts": payloads,
+ })
+ }
+ if len(accounts) == 0 {
+ fmt.Fprintln(os.Stdout, "No accounts configured. Run `wacli accounts add personal`.")
+ return nil
+ }
+ w := newTableWriter(os.Stdout)
+ fmt.Fprintln(w, "DEFAULT\tNAME\tSTORE")
+ for _, account := range accounts {
+ mark := ""
+ if account.Default {
+ mark = "*"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", mark, account.Name, account.StoreDir)
+ }
+ _ = w.Flush()
+ return nil
+ },
+ }
+}
+
+func newAccountsAddCmd(flags *rootFlags) *cobra.Command {
+ opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
+ var noAuth bool
+ cmd := &cobra.Command{
+ Use: "add NAME",
+ Short: "Add an account and authenticate it",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := flags.requireWritable(); err != nil {
+ return err
+ }
+ name := args[0]
+ if err := config.ValidateAccountName(name); err != nil {
+ return err
+ }
+ if !noAuth {
+ if _, err := validateAuthOptions(flags, opts); err != nil {
+ return err
+ }
+ }
+ path := config.DefaultConfigPath()
+ cfg, _, err := config.LoadAccountsConfigIfExists(path)
+ if err != nil {
+ return err
+ }
+ if _, ok := cfg.Accounts[name]; ok {
+ return fmt.Errorf("account %q already exists", name)
+ }
+ cfg.Accounts[name] = config.AccountEntry{Store: config.DefaultAccountStore(name)}
+ if cfg.DefaultAccount == "" {
+ cfg.DefaultAccount = name
+ }
+ storeDir := config.ListAccounts(path, cfg)
+ var added config.Account
+ for _, account := range storeDir {
+ if account.Name == name {
+ added = account
+ break
+ }
+ }
+ if err := fsutil.EnsurePrivateDir(added.StoreDir); err != nil {
+ return fmt.Errorf("create account store: %w", err)
+ }
+ if err := config.SaveAccountsConfig(path, cfg); err != nil {
+ return err
+ }
+ if noAuth {
+ if flags.asJSON {
+ return out.WriteJSON(os.Stdout, map[string]any{
+ "config_path": path,
+ "account": accountPayloadFromAccount(added),
+ })
+ }
+ fmt.Fprintf(os.Stdout, "Account %s added at %s. Run `wacli --account %s auth` to authenticate.\n", name, added.StoreDir, name)
+ return nil
+ }
+
+ oldAccount := flags.account
+ oldStore := flags.storeDir
+ flags.account = name
+ flags.storeDir = ""
+ defer func() {
+ flags.account = oldAccount
+ flags.storeDir = oldStore
+ }()
+
+ if !flags.asJSON {
+ fmt.Fprintf(os.Stdout, "Account %s added at %s\n", name, added.StoreDir)
+ }
+ res, err := runAuth(flags, opts)
+ if err != nil {
+ return err
+ }
+ if flags.asJSON {
+ return out.WriteJSON(os.Stdout, map[string]any{
+ "account": accountPayloadFromAccount(added),
+ "authenticated": true,
+ "messages_stored": res.MessagesStored,
+ })
+ }
+ fmt.Fprintf(os.Stdout, "Account %s authenticated. Messages stored: %d\n", name, res.MessagesStored)
+ return nil
+ },
+ }
+ addAuthFlags(cmd, &opts)
+ cmd.Flags().BoolVar(&noAuth, "no-auth", false, "create the account without running auth")
+ return cmd
+}
+
+func newAccountsUseCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "use NAME",
+ Short: "Set the default account",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := flags.requireWritable(); err != nil {
+ return err
+ }
+ name := args[0]
+ if err := config.ValidateAccountName(name); err != nil {
+ return err
+ }
+ path := config.DefaultConfigPath()
+ cfg, err := config.LoadAccountsConfig(path)
+ if err != nil {
+ return err
+ }
+ if _, ok := cfg.Accounts[name]; !ok {
+ return fmt.Errorf("account %q is not configured", name)
+ }
+ cfg.DefaultAccount = name
+ if err := config.SaveAccountsConfig(path, cfg); err != nil {
+ return err
+ }
+ if flags.asJSON {
+ return out.WriteJSON(os.Stdout, map[string]any{"default_account": name})
+ }
+ fmt.Fprintf(os.Stdout, "Default account: %s\n", name)
+ return nil
+ },
+ }
+}
+
+func newAccountsShowCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "show NAME",
+ Short: "Show one configured account",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ _, account, err := config.ResolveAccountStore(config.DefaultConfigPath(), args[0])
+ if err != nil {
+ return err
+ }
+ payload := accountPayloadFromAccount(account)
+ if flags.asJSON {
+ return out.WriteJSON(os.Stdout, payload)
+ }
+ fmt.Fprintf(os.Stdout, "Name: %s\nStore: %s\nDefault: %t\n", payload.Name, payload.StoreDir, payload.Default)
+ if payload.Label != "" {
+ fmt.Fprintf(os.Stdout, "Label: %s\n", payload.Label)
+ }
+ return nil
+ },
+ }
+}
+
+func newAccountsRemoveCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "remove NAME",
+ Short: "Remove an account from config without deleting its store",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := flags.requireWritable(); err != nil {
+ return err
+ }
+ name := args[0]
+ if err := config.ValidateAccountName(name); err != nil {
+ return err
+ }
+ path := config.DefaultConfigPath()
+ cfg, err := config.LoadAccountsConfig(path)
+ if err != nil {
+ return err
+ }
+ entry, ok := cfg.Accounts[name]
+ if !ok {
+ return fmt.Errorf("account %q is not configured", name)
+ }
+ storeDir := config.ListAccounts(path, &config.AccountsConfig{
+ DefaultAccount: cfg.DefaultAccount,
+ Accounts: map[string]config.AccountEntry{name: entry},
+ })[0].StoreDir
+ delete(cfg.Accounts, name)
+ if cfg.DefaultAccount == name {
+ cfg.DefaultAccount = ""
+ }
+ if err := config.SaveAccountsConfig(path, cfg); err != nil {
+ return err
+ }
+ if flags.asJSON {
+ return out.WriteJSON(os.Stdout, map[string]any{
+ "removed": name,
+ "store_dir_kept": storeDir,
+ })
+ }
+ fmt.Fprintf(os.Stdout, "Removed account %s. Store kept at %s\n", name, storeDir)
+ return nil
+ },
+ }
+}
+
+func sortedAccounts(path string, cfg *config.AccountsConfig) []config.Account {
+ accounts := config.ListAccounts(path, cfg)
+ sort.Slice(accounts, func(i, j int) bool {
+ return accounts[i].Name < accounts[j].Name
+ })
+ return accounts
+}
+
+func accountPayloads(accounts []config.Account) []accountPayload {
+ payloads := make([]accountPayload, 0, len(accounts))
+ for _, account := range accounts {
+ payloads = append(payloads, accountPayloadFromAccount(account))
+ }
+ return payloads
+}
+
+func accountPayloadFromAccount(account config.Account) accountPayload {
+ return accountPayload{
+ Name: account.Name,
+ Label: account.Label,
+ ConfiguredStore: account.ConfiguredStore,
+ StoreDir: account.StoreDir,
+ Default: account.Default,
+ }
+}
diff --git a/cmd/wacli/accounts_test.go b/cmd/wacli/accounts_test.go
new file mode 100644
index 0000000..6d77c3c
--- /dev/null
+++ b/cmd/wacli/accounts_test.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/steipete/wacli/internal/config"
+)
+
+func TestAccountsAddNoAuthCreatesConfig(t *testing.T) {
+ isolateAccountConfigHome(t)
+
+ var stdout string
+ stderr := captureRootStderr(t, func() {
+ stdout = captureRootStdout(t, func() {
+ if err := execute([]string{"accounts", "add", "personal", "--no-auth"}); err != nil {
+ t.Fatalf("execute accounts add: %v", err)
+ }
+ })
+ })
+ if stderr != "" {
+ t.Fatalf("stderr = %q, want empty", stderr)
+ }
+ if !strings.Contains(stdout, "Account personal added") {
+ t.Fatalf("stdout = %q, want account added", stdout)
+ }
+
+ cfg, err := config.LoadAccountsConfig(config.DefaultConfigPath())
+ if err != nil {
+ t.Fatalf("LoadAccountsConfig: %v", err)
+ }
+ if cfg.DefaultAccount != "personal" {
+ t.Fatalf("DefaultAccount = %q, want personal", cfg.DefaultAccount)
+ }
+ account, ok := cfg.Accounts["personal"]
+ if !ok {
+ t.Fatal("personal account missing")
+ }
+ if account.Store != "accounts/personal" {
+ t.Fatalf("Store = %q, want accounts/personal", account.Store)
+ }
+ if _, err := os.Stat(filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")); err != nil {
+ t.Fatalf("account store not created: %v", err)
+ }
+}
+
+func TestAccountsAddValidatesAuthFlagsBeforeSaving(t *testing.T) {
+ isolateAccountConfigHome(t)
+
+ err := execute([]string{"--json", "accounts", "add", "personal", "--qr-format", "text"})
+ if err == nil || !strings.Contains(err.Error(), "--qr-format=text cannot be combined with --json") {
+ t.Fatalf("execute error = %v, want QR/json validation error", err)
+ }
+ if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) {
+ t.Fatalf("config stat error = %v, want not exist", statErr)
+ }
+ storeDir := filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")
+ if _, statErr := os.Stat(storeDir); !os.IsNotExist(statErr) {
+ t.Fatalf("store stat error = %v, want not exist", statErr)
+ }
+}
+
+func TestAccountsAddRejectsWhitespaceName(t *testing.T) {
+ isolateAccountConfigHome(t)
+
+ err := execute([]string{"accounts", "add", " work ", "--no-auth"})
+ if err == nil || !strings.Contains(err.Error(), "whitespace") {
+ t.Fatalf("execute error = %v, want whitespace validation error", err)
+ }
+ if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) {
+ t.Fatalf("config stat error = %v, want not exist", statErr)
+ }
+}
+
+func TestAccountsListJSON(t *testing.T) {
+ isolateAccountConfigHome(t)
+ cfgPath := config.DefaultConfigPath()
+ cfg := &config.AccountsConfig{
+ DefaultAccount: "work",
+ Accounts: map[string]config.AccountEntry{
+ "personal": {Store: "accounts/personal"},
+ "work": {Store: "accounts/work"},
+ },
+ }
+ if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ var stdout string
+ stdout = captureRootStdout(t, func() {
+ if err := execute([]string{"--json", "accounts", "list"}); err != nil {
+ t.Fatalf("execute accounts list: %v", err)
+ }
+ })
+
+ var payload struct {
+ Data struct {
+ DefaultAccount string `json:"default_account"`
+ Accounts []struct {
+ Name string `json:"name"`
+ Default bool `json:"default"`
+ } `json:"accounts"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
+ t.Fatalf("json.Unmarshal(%q): %v", stdout, err)
+ }
+ if payload.Data.DefaultAccount != "work" || len(payload.Data.Accounts) != 2 {
+ t.Fatalf("payload = %+v, want work and 2 accounts", payload)
+ }
+ if payload.Data.Accounts[0].Name != "personal" || payload.Data.Accounts[1].Name != "work" || !payload.Data.Accounts[1].Default {
+ t.Fatalf("accounts = %+v, want sorted personal/work with work default", payload.Data.Accounts)
+ }
+}
diff --git a/cmd/wacli/auth.go b/cmd/wacli/auth.go
index b6c4b70..7e2cca6 100644
--- a/cmd/wacli/auth.go
+++ b/cmd/wacli/auth.go
@@ -16,69 +16,27 @@ import (
"go.mau.fi/whatsmeow/types"
)
+type authOptions struct {
+ follow bool
+ idleExit time.Duration
+ downloadMedia bool
+ qrFormat string
+ phone string
+}
+
+type validatedAuthOptions struct {
+ qrFormat string
+ pairPhone string
+}
+
func newAuthCmd(flags *rootFlags) *cobra.Command {
- var follow bool
- var idleExit time.Duration
- var downloadMedia bool
- var qrFormat string
- var phone string
+ opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with WhatsApp (QR) and bootstrap sync",
RunE: func(cmd *cobra.Command, args []string) error {
- if err := flags.requireWritable(); err != nil {
- return err
- }
- qrFormat, err := normalizeAuthQRFormat(qrFormat)
- if err != nil {
- return err
- }
- if flags.asJSON && qrFormat == "text" {
- return fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
- }
- pairPhone, err := normalizePairPhone(phone)
- if err != nil {
- return err
- }
- maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
- if err != nil {
- return err
- }
- ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
- defer stop()
-
- a, lk, err := newApp(ctx, flags, true, true)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- mode := appPkg.SyncModeBootstrap
- if follow {
- mode = appPkg.SyncModeFollow
- }
-
- if a.Events().Enabled() {
- _ = a.Events().Emit("auth_starting", nil)
- } else {
- fmt.Fprintln(os.Stderr, "Starting authentication…")
- }
- res, err := a.Sync(ctx, appPkg.SyncOptions{
- Mode: mode,
- AllowQR: true,
- DownloadMedia: downloadMedia,
- RefreshContacts: true,
- RefreshGroups: true,
- RefreshChannels: true,
- IdleExit: idleExit,
- OnQRCode: authQRWriter(qrFormat, os.Stdout, os.Stderr, a.Events()),
- PairPhoneNumber: pairPhone,
- OnPairCode: authPairCodeWriter(pairPhone, os.Stderr, a.Events()),
- MaxMessages: maxMessages,
- MaxDBSizeBytes: maxDBSize,
- WarnNoLimits: true,
- })
+ res, err := runAuth(flags, opts)
if err != nil {
return err
}
@@ -95,11 +53,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
},
}
- cmd.Flags().BoolVar(&follow, "follow", false, "keep syncing after auth")
- cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
- cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
- cmd.Flags().StringVar(&qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
- cmd.Flags().StringVar(&phone, "phone", "", "pair by phone number instead of QR code")
+ addAuthFlags(cmd, &opts)
cmd.AddCommand(newAuthStatusCmd(flags))
cmd.AddCommand(newAuthLogoutCmd(flags))
@@ -107,6 +61,77 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
return cmd
}
+func addAuthFlags(cmd *cobra.Command, opts *authOptions) {
+ cmd.Flags().BoolVar(&opts.follow, "follow", false, "keep syncing after auth")
+ cmd.Flags().DurationVar(&opts.idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
+ cmd.Flags().BoolVar(&opts.downloadMedia, "download-media", false, "download media in the background during sync")
+ cmd.Flags().StringVar(&opts.qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
+ cmd.Flags().StringVar(&opts.phone, "phone", "", "pair by phone number instead of QR code")
+}
+
+func runAuth(flags *rootFlags, opts authOptions) (appPkg.SyncResult, error) {
+ if err := flags.requireWritable(); err != nil {
+ return appPkg.SyncResult{}, err
+ }
+ validated, err := validateAuthOptions(flags, opts)
+ if err != nil {
+ return appPkg.SyncResult{}, err
+ }
+ maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
+ if err != nil {
+ return appPkg.SyncResult{}, err
+ }
+ ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
+ defer stop()
+
+ a, lk, err := newApp(ctx, flags, true, true)
+ if err != nil {
+ return appPkg.SyncResult{}, err
+ }
+ defer closeApp(a, lk)
+
+ mode := appPkg.SyncModeBootstrap
+ if opts.follow {
+ mode = appPkg.SyncModeFollow
+ }
+
+ if a.Events().Enabled() {
+ _ = a.Events().Emit("auth_starting", nil)
+ } else {
+ fmt.Fprintln(os.Stderr, "Starting authentication…")
+ }
+ return a.Sync(ctx, appPkg.SyncOptions{
+ Mode: mode,
+ AllowQR: true,
+ DownloadMedia: opts.downloadMedia,
+ RefreshContacts: true,
+ RefreshGroups: true,
+ RefreshChannels: true,
+ IdleExit: opts.idleExit,
+ OnQRCode: authQRWriter(validated.qrFormat, os.Stdout, os.Stderr, a.Events()),
+ PairPhoneNumber: validated.pairPhone,
+ OnPairCode: authPairCodeWriter(validated.pairPhone, os.Stderr, a.Events()),
+ MaxMessages: maxMessages,
+ MaxDBSizeBytes: maxDBSize,
+ WarnNoLimits: true,
+ })
+}
+
+func validateAuthOptions(flags *rootFlags, opts authOptions) (validatedAuthOptions, error) {
+ qrFormat, err := normalizeAuthQRFormat(opts.qrFormat)
+ if err != nil {
+ return validatedAuthOptions{}, err
+ }
+ if flags.asJSON && qrFormat == "text" {
+ return validatedAuthOptions{}, fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
+ }
+ pairPhone, err := normalizePairPhone(opts.phone)
+ if err != nil {
+ return validatedAuthOptions{}, err
+ }
+ return validatedAuthOptions{qrFormat: qrFormat, pairPhone: pairPhone}, nil
+}
+
func normalizePairPhone(phone string) (string, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
diff --git a/cmd/wacli/doctor.go b/cmd/wacli/doctor.go
index 103ff75..7266987 100644
--- a/cmd/wacli/doctor.go
+++ b/cmd/wacli/doctor.go
@@ -11,7 +11,6 @@ import (
"time"
"github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/config"
"github.com/steipete/wacli/internal/lock"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
@@ -116,11 +115,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
- storeDir := flags.storeDir
- if storeDir == "" {
- storeDir = config.DefaultStoreDir()
+ storeDir, err := resolveStoreDir(flags)
+ if err != nil {
+ return err
}
- storeDir, _ = filepath.Abs(storeDir)
var lockHeld bool
var lockInfo string
diff --git a/cmd/wacli/root.go b/cmd/wacli/root.go
index b6d0276..a067dd6 100644
--- a/cmd/wacli/root.go
+++ b/cmd/wacli/root.go
@@ -22,6 +22,7 @@ const docsURL = "https://wacli.sh"
type rootFlags struct {
storeDir string
+ account string
asJSON bool
fullOutput bool
events bool
@@ -44,6 +45,7 @@ func execute(args []string) error {
rootCmd.SetVersionTemplate("wacli {{.Version}}\n")
rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: $WACLI_STORE_DIR, XDG state dir on Linux, or ~/.wacli)")
+ rootCmd.PersistentFlags().StringVar(&flags.account, "account", "", "named account from config.yaml")
rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "output JSON instead of human-readable text")
rootCmd.PersistentFlags().BoolVar(&flags.fullOutput, "full", false, "disable truncation in table output")
rootCmd.PersistentFlags().BoolVar(&flags.events, "events", false, "emit machine-readable NDJSON lifecycle events on stderr")
@@ -52,6 +54,7 @@ func execute(args []string) error {
rootCmd.PersistentFlags().BoolVar(&flags.readOnly, "read-only", false, "reject commands that intentionally write WhatsApp or the local store (or set WACLI_READONLY=1)")
rootCmd.AddCommand(newVersionCmd())
+ rootCmd.AddCommand(newAccountsCmd(&flags))
rootCmd.AddCommand(newDoctorCmd(&flags))
rootCmd.AddCommand(newAuthCmd(&flags))
rootCmd.AddCommand(newSyncCmd(&flags))
@@ -88,11 +91,13 @@ func writeRootError(flags rootFlags, err error) {
}
func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
- storeDir := resolveStoreDir(flags)
+ storeDir, err := resolveStoreDir(flags)
+ if err != nil {
+ return nil, nil, err
+ }
var lk *lock.Lock
if needLock {
- var err error
lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait)
if err != nil {
return nil, nil, err
@@ -116,16 +121,43 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
return a, lk, nil
}
-func resolveStoreDir(flags *rootFlags) string {
+func resolveStoreDir(flags *rootFlags) (string, error) {
storeDir := ""
+ account := ""
if flags != nil {
storeDir = flags.storeDir
+ account = strings.TrimSpace(flags.account)
}
- if storeDir == "" {
+ if storeDir != "" && account != "" {
+ return "", fmt.Errorf("--store and --account cannot be combined")
+ }
+ switch {
+ case storeDir != "":
+ case account != "":
+ resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), account)
+ if err != nil {
+ return "", err
+ }
+ storeDir = resolved
+ case os.Getenv(config.EnvStoreDir) != "":
storeDir = config.DefaultStoreDir()
+ default:
+ cfg, found, err := config.LoadAccountsConfigIfExists(config.DefaultConfigPath())
+ if err != nil {
+ return "", err
+ }
+ if found && strings.TrimSpace(cfg.DefaultAccount) != "" {
+ resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), cfg.DefaultAccount)
+ if err != nil {
+ return "", err
+ }
+ storeDir = resolved
+ } else {
+ storeDir = config.DefaultStoreDir()
+ }
}
storeDir, _ = filepath.Abs(storeDir)
- return storeDir
+ return storeDir, nil
}
func (f *rootFlags) isReadOnly() bool {
diff --git a/cmd/wacli/root_test.go b/cmd/wacli/root_test.go
index 509ffde..abc10ec 100644
--- a/cmd/wacli/root_test.go
+++ b/cmd/wacli/root_test.go
@@ -6,8 +6,11 @@ import (
"errors"
"io"
"os"
+ "path/filepath"
"strings"
"testing"
+
+ "github.com/steipete/wacli/internal/config"
)
func captureRootStderr(t *testing.T, fn func()) string {
@@ -93,3 +96,64 @@ func TestRootFlagsReadOnlyEnv(t *testing.T) {
t.Fatal("isReadOnly = false, want true")
}
}
+
+func TestResolveStoreDirAccount(t *testing.T) {
+ isolateAccountConfigHome(t)
+ cfgPath := config.DefaultConfigPath()
+ cfg := &config.AccountsConfig{
+ Accounts: map[string]config.AccountEntry{
+ "work": {Store: "accounts/work"},
+ },
+ }
+ if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := resolveStoreDir(&rootFlags{account: "work"})
+ if err != nil {
+ t.Fatalf("resolveStoreDir: %v", err)
+ }
+ want := filepath.Join(filepath.Dir(cfgPath), "accounts", "work")
+ if got != want {
+ t.Fatalf("storeDir = %q, want %q", got, want)
+ }
+}
+
+func TestResolveStoreDirStoreAndAccountConflict(t *testing.T) {
+ _, err := resolveStoreDir(&rootFlags{storeDir: "/tmp/wacli", account: "work"})
+ if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
+ t.Fatalf("resolveStoreDir error = %v, want conflict", err)
+ }
+}
+
+func TestResolveStoreDirEnvBeatsDefaultAccount(t *testing.T) {
+ isolateAccountConfigHome(t)
+ cfgPath := config.DefaultConfigPath()
+ cfg := &config.AccountsConfig{
+ DefaultAccount: "work",
+ Accounts: map[string]config.AccountEntry{
+ "work": {Store: "accounts/work"},
+ },
+ }
+ if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
+ t.Fatal(err)
+ }
+ envStore := filepath.Join(t.TempDir(), "env-store")
+ t.Setenv(config.EnvStoreDir, envStore)
+
+ got, err := resolveStoreDir(&rootFlags{})
+ if err != nil {
+ t.Fatalf("resolveStoreDir: %v", err)
+ }
+ if got != envStore {
+ t.Fatalf("storeDir = %q, want %q", got, envStore)
+ }
+}
+
+func isolateAccountConfigHome(t *testing.T) {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
+ t.Setenv(config.EnvStoreDir, "")
+}
diff --git a/cmd/wacli/send_ipc.go b/cmd/wacli/send_ipc.go
index a96d9cb..ad65330 100644
--- a/cmd/wacli/send_ipc.go
+++ b/cmd/wacli/send_ipc.go
@@ -65,7 +65,11 @@ func sendDelegateSocketPath(storeDir string) string {
func delegateSend(ctx context.Context, flags *rootFlags, req sendDelegateRequest) (sendDelegateResponse, error) {
req.Version = sendDelegateVersion
req.TimeoutMS = durationMillis(flags.timeout)
- path := sendDelegateSocketPath(resolveStoreDir(flags))
+ storeDir, err := resolveStoreDir(flags)
+ if err != nil {
+ return sendDelegateResponse{}, err
+ }
+ path := sendDelegateSocketPath(storeDir)
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", path)
diff --git a/docs/accounts.md b/docs/accounts.md
new file mode 100644
index 0000000..affc235
--- /dev/null
+++ b/docs/accounts.md
@@ -0,0 +1,57 @@
+# accounts
+
+Read when: using more than one WhatsApp account, choosing the active account, or migrating from manual `--store` directories.
+
+`wacli accounts` manages named accounts. Each account is an isolated store directory with its own WhatsApp linked-device session, local mirror database, media files, and lock.
+
+## Commands
+
+```bash
+wacli accounts list
+wacli accounts add NAME [--no-auth]
+wacli accounts use NAME
+wacli accounts show NAME
+wacli accounts remove NAME
+```
+
+Use a named account with any command:
+
+```bash
+wacli --account work chats list
+wacli --account personal send text --to 1234567890 --message "hi"
+```
+
+## Config
+
+The default config path is `/config.yaml`, where `` is the default store root (`~/.wacli` on macOS and existing legacy Linux installs, otherwise `~/.local/state/wacli` on Linux).
+
+```yaml
+default_account: personal
+
+accounts:
+ personal:
+ store: accounts/personal
+ work:
+ store: accounts/work
+```
+
+Relative `store` paths resolve from the config directory. Absolute paths are allowed for custom layouts.
+
+## Selection Rules
+
+Store selection is intentionally explicit:
+
+1. `--store DIR` uses that exact store and cannot be combined with `--account`.
+2. `--account NAME` resolves `NAME` from `config.yaml`.
+3. `WACLI_STORE_DIR` keeps its existing override behavior for scripts and one-off stores.
+4. If `default_account` is set, commands use that account.
+5. Otherwise existing single-store behavior remains: XDG state dir on Linux, or `~/.wacli` elsewhere.
+
+Account names may contain letters, digits, `.`, `_`, and `-`, and must start with a letter or digit.
+
+## Notes
+
+- `accounts add NAME` creates the isolated store and then runs the normal auth/bootstrap flow for that account. Use `--no-auth` to only write config and create the store.
+- Locks are per account store, so `wacli --account personal sync --follow` and `wacli --account work chats list` do not block each other unless they share the same store path.
+- Cross-account search or status should be explicit aggregate commands, not accidental shared database queries.
+- Use `--store DIR` for one-off migration/debugging against an old manual store.
diff --git a/docs/auth.md b/docs/auth.md
index 24358e0..e653459 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -2,7 +2,7 @@
Read when: pairing a store, checking auth state, logging out, or choosing QR vs phone pairing.
-`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store.
+`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store or named account.
## Commands
@@ -10,6 +10,7 @@ Read when: pairing a store, checking auth state, logging out, or choosing QR vs
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events]
wacli auth status
wacli auth logout
+wacli --account work auth status
```
## Notes
@@ -23,6 +24,7 @@ wacli auth logout
- `--events` emits NDJSON lifecycle events on stderr, including raw QR and phone-pairing codes for external renderers.
- `auth status` reports whether the local store is authenticated.
- `auth logout` invalidates the linked-device session and requires writable mode.
+- For multiple accounts, prefer `wacli accounts add NAME`; it creates an isolated account store and runs the same auth/bootstrap flow.
## Examples
diff --git a/docs/index.md b/docs/index.md
index c53154e..948f867 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -21,6 +21,7 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
## Pick your path
- **Trying it.** Read [Install](install.md), then [Quickstart](quickstart.md). Pair, sync, and send your first message in under five minutes.
+- **Using multiple WhatsApp accounts.** Read [Accounts](accounts.md) for named account stores and `--account`.
- **Searching old chats.** Read [Sync](sync.md) for the sync model and [History](history.md) for coverage planning and on-demand backfill.
- **Managing chat state.** Read [Chats](chats.md) for archive, pin, mute, and read/unread commands.
- **Managing local storage.** Read [Store](store.md) for stats, dry-run cleanup, and local-only pruning.
diff --git a/docs/integrations.md b/docs/integrations.md
index f3013fe..5bc3cc5 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -20,7 +20,7 @@ The default store is:
- Linux: `~/.local/state/wacli`, with legacy `~/.wacli` reused when present.
- macOS and other platforms: `~/.wacli`.
-Override with `--store DIR` or `WACLI_STORE_DIR`.
+Override with `--store DIR` or `WACLI_STORE_DIR`. Named accounts live in `config.yaml` and resolve with `--account NAME`; each account points at a normal isolated store directory.
The store contains two SQLite databases:
@@ -29,6 +29,8 @@ The store contains two SQLite databases:
Companion tools should not read or write `session.db` unless they are explicitly working on WhatsApp session internals. Never write to `wacli.db` from a companion tool.
+For multi-account tools, iterate configured accounts explicitly and annotate derived rows with the account name in the companion tool's own database. Do not merge account data into `wacli.db`.
+
## Read-only SQLite
Open the database in SQLite read-only mode:
diff --git a/docs/overview.md b/docs/overview.md
index 5297a2c..6401289 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -2,7 +2,7 @@
Read when: you need the user-facing command map, global flags, store model, or links to command-specific docs.
-`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans.
+`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans. Named accounts let multiple WhatsApp identities use isolated stores via `--account`.
## Store and output
@@ -22,6 +22,7 @@ Read when: you need the user-facing command map, global flags, store model, or l
## Command pages
- [auth](auth.md) - pair, inspect auth status, logout.
+- [accounts](accounts.md) - create and select named account stores.
- [sync](sync.md) - sync messages, contacts, groups, channels, and optional media.
- [messages](messages.md) - list, search, show, and contextualize stored messages.
- [send](send.md) - send text, files, stickers, replies, and reactions.
diff --git a/docs/spec.md b/docs/spec.md
index 50f3255..b589bd4 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -137,6 +137,7 @@ Fallback:
Global flags:
- `--store DIR` (default: XDG state dir on Linux, `~/.wacli` elsewhere)
+- `--account NAME` (named account from `config.yaml`; mutually exclusive with `--store`)
- `--json` (default: human text)
- `--full` (disable table truncation; non-TTY output keeps full IDs)
- `--timeout DURATION` (non-sync commands; e.g. `5m`)
@@ -154,6 +155,19 @@ Global flags:
- `wacli auth status`
- `wacli auth logout`
+### Accounts
+
+- `wacli accounts list`
+- `wacli accounts add NAME [--no-auth]`
+- `wacli accounts use NAME`
+- `wacli accounts show NAME`
+- `wacli accounts remove NAME`
+
+Named accounts resolve to isolated store directories. Account config lives in
+`/config.yaml`; relative account store paths resolve from that config
+directory. `--store` remains the direct manual-store escape hatch and cannot be
+combined with `--account`.
+
### Sync
- `wacli sync [--once] [--follow] [--download-media] [--webhook URL] [--webhook-secret SECRET]`
diff --git a/docs/store.md b/docs/store.md
index de5ec4c..d271a8f 100644
--- a/docs/store.md
+++ b/docs/store.md
@@ -2,7 +2,7 @@
Read when: inspecting local SQLite size/counts or pruning old local chat/group rows.
-`wacli store` manages the local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers.
+`wacli store` manages the selected account's local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers.
## Commands
@@ -29,6 +29,7 @@ wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [
- Destructive cleanup commands require confirmation unless `--confirm` is passed.
- Use `--dry-run` first; it lists what would be deleted without changing the local store.
- Use `--read-only` or `WACLI_READONLY=1` to make cleanup commands fail before opening the store for writes.
+- Use `--account NAME` to target a named account store. Use `--store DIR` for manual stores or migration debugging; it cannot be combined with `--account`.
## Examples
diff --git a/go.mod b/go.mod
index 0b7c67b..08aeb1f 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0
google.golang.org/protobuf v1.36.11
+ gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -20,6 +21,7 @@ require (
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
@@ -33,5 +35,6 @@ require (
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
rsc.io/qr v0.2.0 // indirect
)
diff --git a/go.sum b/go.sum
index 17d5b93..70ee694 100644
--- a/go.sum
+++ b/go.sum
@@ -22,8 +22,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -77,6 +79,7 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/internal/config/config.go b/internal/config/config.go
index 0240a5e..aea76ac 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,9 +1,17 @@
package config
import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
"os"
"path/filepath"
+ "regexp"
"runtime"
+ "strings"
+
+ "gopkg.in/yaml.v3"
)
// EnvStoreDir is the environment variable that overrides the default store
@@ -12,6 +20,28 @@ import (
// every invocation.
const EnvStoreDir = "WACLI_STORE_DIR"
+const ConfigFileName = "config.yaml"
+
+var accountNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+type AccountsConfig struct {
+ DefaultAccount string `yaml:"default_account,omitempty"`
+ Accounts map[string]AccountEntry `yaml:"accounts,omitempty"`
+}
+
+type AccountEntry struct {
+ Store string `yaml:"store"`
+ Label string `yaml:"label,omitempty"`
+}
+
+type Account struct {
+ Name string
+ Label string
+ ConfiguredStore string
+ StoreDir string
+ Default bool
+}
+
// DefaultStoreDir returns the store directory to use when --store is not
// supplied. It checks WACLI_STORE_DIR first, then falls back to the XDG state
// directory on Linux or ~/.wacli on other platforms.
@@ -19,6 +49,13 @@ func DefaultStoreDir() string {
if dir := os.Getenv(EnvStoreDir); dir != "" {
return dir
}
+ return DefaultBaseDir()
+}
+
+// DefaultBaseDir returns wacli's platform default state root without honoring
+// WACLI_STORE_DIR. Account config lives here so a temporary store override
+// does not hide the account registry.
+func DefaultBaseDir() string {
xdgStateHome := os.Getenv("XDG_STATE_HOME")
home, err := os.UserHomeDir()
if err != nil || home == "" {
@@ -30,6 +67,153 @@ func DefaultStoreDir() string {
return defaultStoreDirFor(runtime.GOOS, home, xdgStateHome, pathExists)
}
+func DefaultConfigPath() string {
+ return filepath.Join(DefaultBaseDir(), ConfigFileName)
+}
+
+func ValidateAccountName(name string) error {
+ if name == "" {
+ return fmt.Errorf("account name is required")
+ }
+ if strings.TrimSpace(name) != name {
+ return fmt.Errorf("invalid account name %q: leading or trailing whitespace is not allowed", name)
+ }
+ if !accountNameRE.MatchString(name) {
+ return fmt.Errorf("invalid account name %q: use letters, digits, '.', '_', or '-', starting with a letter or digit", name)
+ }
+ return nil
+}
+
+func LoadAccountsConfig(path string) (*AccountsConfig, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var cfg AccountsConfig
+ dec := yaml.NewDecoder(bytes.NewReader(data))
+ dec.KnownFields(true)
+ if err := dec.Decode(&cfg); err != nil {
+ if errors.Is(err, io.EOF) {
+ cfg = AccountsConfig{}
+ } else {
+ return nil, fmt.Errorf("parse account config %s: %w", path, err)
+ }
+ }
+ if cfg.Accounts == nil {
+ cfg.Accounts = map[string]AccountEntry{}
+ }
+ for name, entry := range cfg.Accounts {
+ if err := ValidateAccountName(name); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(entry.Store) == "" {
+ return nil, fmt.Errorf("account %q store is required", name)
+ }
+ if strings.ContainsAny(entry.Store, "?#") {
+ return nil, fmt.Errorf("account %q store must not contain '?' or '#'", name)
+ }
+ }
+ if cfg.DefaultAccount != "" {
+ if err := ValidateAccountName(cfg.DefaultAccount); err != nil {
+ return nil, fmt.Errorf("default_account: %w", err)
+ }
+ if _, ok := cfg.Accounts[cfg.DefaultAccount]; !ok {
+ return nil, fmt.Errorf("default_account %q is not defined", cfg.DefaultAccount)
+ }
+ }
+ return &cfg, nil
+}
+
+func LoadAccountsConfigIfExists(path string) (*AccountsConfig, bool, error) {
+ cfg, err := LoadAccountsConfig(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return &AccountsConfig{Accounts: map[string]AccountEntry{}}, false, nil
+ }
+ return nil, false, err
+ }
+ return cfg, true, nil
+}
+
+func SaveAccountsConfig(path string, cfg *AccountsConfig) error {
+ if cfg == nil {
+ cfg = &AccountsConfig{}
+ }
+ if cfg.Accounts == nil {
+ cfg.Accounts = map[string]AccountEntry{}
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return fmt.Errorf("create config dir: %w", err)
+ }
+ data, err := yaml.Marshal(cfg)
+ if err != nil {
+ return fmt.Errorf("encode account config: %w", err)
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return fmt.Errorf("write account config: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ _ = os.Remove(tmp)
+ return fmt.Errorf("replace account config: %w", err)
+ }
+ _ = os.Chmod(path, 0o600)
+ return nil
+}
+
+func ResolveAccountStore(path, name string) (string, Account, error) {
+ if err := ValidateAccountName(name); err != nil {
+ return "", Account{}, err
+ }
+ cfg, err := LoadAccountsConfig(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", Account{}, fmt.Errorf("account config not found at %s; run `wacli accounts add %s`", path, name)
+ }
+ return "", Account{}, err
+ }
+ entry, ok := cfg.Accounts[name]
+ if !ok {
+ return "", Account{}, fmt.Errorf("account %q is not configured; run `wacli accounts list`", name)
+ }
+ storeDir := resolveConfiguredStore(filepath.Dir(path), entry.Store)
+ return storeDir, Account{
+ Name: name,
+ Label: entry.Label,
+ ConfiguredStore: entry.Store,
+ StoreDir: storeDir,
+ Default: cfg.DefaultAccount == name,
+ }, nil
+}
+
+func ListAccounts(path string, cfg *AccountsConfig) []Account {
+ if cfg == nil || len(cfg.Accounts) == 0 {
+ return nil
+ }
+ out := make([]Account, 0, len(cfg.Accounts))
+ for name, entry := range cfg.Accounts {
+ out = append(out, Account{
+ Name: name,
+ Label: entry.Label,
+ ConfiguredStore: entry.Store,
+ StoreDir: resolveConfiguredStore(filepath.Dir(path), entry.Store),
+ Default: cfg.DefaultAccount == name,
+ })
+ }
+ return out
+}
+
+func DefaultAccountStore(name string) string {
+ return filepath.ToSlash(filepath.Join("accounts", name))
+}
+
+func resolveConfiguredStore(baseDir, store string) string {
+ if filepath.IsAbs(store) {
+ return filepath.Clean(store)
+ }
+ return filepath.Clean(filepath.Join(baseDir, store))
+}
+
func defaultStoreDirFor(goos, home, xdgStateHome string, exists func(string) bool) string {
legacy := filepath.Join(home, ".wacli")
if goos != "linux" {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5abf81c..b2b30a8 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "strings"
"testing"
)
@@ -77,3 +78,64 @@ func TestDefaultStoreDirFor(t *testing.T) {
}
})
}
+
+func TestAccountsConfigRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ cfg := &AccountsConfig{
+ DefaultAccount: "personal",
+ Accounts: map[string]AccountEntry{
+ "personal": {Store: "accounts/personal"},
+ "work": {Store: "/tmp/wacli-work", Label: "Work"},
+ },
+ }
+
+ if err := SaveAccountsConfig(path, cfg); err != nil {
+ t.Fatalf("SaveAccountsConfig: %v", err)
+ }
+ loaded, err := LoadAccountsConfig(path)
+ if err != nil {
+ t.Fatalf("LoadAccountsConfig: %v", err)
+ }
+ if loaded.DefaultAccount != "personal" {
+ t.Fatalf("DefaultAccount = %q, want personal", loaded.DefaultAccount)
+ }
+ store, account, err := ResolveAccountStore(path, "personal")
+ if err != nil {
+ t.Fatalf("ResolveAccountStore: %v", err)
+ }
+ wantStore := filepath.Join(filepath.Dir(path), "accounts", "personal")
+ if store != wantStore || account.StoreDir != wantStore {
+ t.Fatalf("store = %q/%q, want %q", store, account.StoreDir, wantStore)
+ }
+ if !account.Default {
+ t.Fatal("account.Default = false, want true")
+ }
+}
+
+func TestAccountsConfigKnownFields(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := os.WriteFile(path, []byte("unknown: true\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := LoadAccountsConfig(path)
+ if err == nil || !strings.Contains(err.Error(), "field unknown not found") {
+ t.Fatalf("LoadAccountsConfig error = %v, want unknown field error", err)
+ }
+}
+
+func TestValidateAccountName(t *testing.T) {
+ valid := []string{"personal", "work-2", "client.foo", "a_b"}
+ for _, name := range valid {
+ if err := ValidateAccountName(name); err != nil {
+ t.Fatalf("ValidateAccountName(%q): %v", name, err)
+ }
+ }
+
+ invalid := []string{"", " work", "work ", ".hidden", "-work", "bad/name", "bad?name", "bad name"}
+ for _, name := range invalid {
+ if err := ValidateAccountName(name); err == nil {
+ t.Fatalf("ValidateAccountName(%q) succeeded, want error", name)
+ }
+ }
+}
diff --git a/internal/store/schema.go b/internal/store/schema.go
index 3889232..3aad132 100644
--- a/internal/store/schema.go
+++ b/internal/store/schema.go
@@ -35,9 +35,6 @@ const coreSchemaSQL = `
left_at INTEGER,
updated_at INTEGER NOT NULL
);
-
- CREATE INDEX IF NOT EXISTS idx_groups_linked_parent_jid ON groups(linked_parent_jid);
-
CREATE TABLE IF NOT EXISTS group_participants (
group_jid TEXT NOT NULL,
user_jid TEXT NOT NULL,
diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go
index c7984e5..2f6f12f 100644
--- a/internal/store/schema_test.go
+++ b/internal/store/schema_test.go
@@ -133,6 +133,52 @@ func TestOpenMigratesGroupHierarchyColumns(t *testing.T) {
}
}
+func TestOpenMigratesLegacyGroupsWithoutMigrationTable(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "wacli.db")
+
+ raw, err := sql.Open("sqlite3", path)
+ if err != nil {
+ t.Fatalf("sql.Open: %v", err)
+ }
+ if _, err := raw.Exec(`
+ CREATE TABLE groups (
+ jid TEXT PRIMARY KEY,
+ name TEXT,
+ owner_jid TEXT,
+ created_ts INTEGER,
+ left_at INTEGER,
+ updated_at INTEGER NOT NULL
+ );
+ INSERT INTO groups(jid, name, updated_at) VALUES('g@g.us', 'Old', 1);
+ `); err != nil {
+ _ = raw.Close()
+ t.Fatalf("create legacy schema: %v", err)
+ }
+ if err := raw.Close(); err != nil {
+ t.Fatalf("raw close: %v", err)
+ }
+
+ db, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open legacy DB: %v", err)
+ }
+ defer db.Close()
+
+ groupCols, err := tableColumns(db.sql, "groups")
+ if err != nil {
+ t.Fatalf("groups tableColumns: %v", err)
+ }
+ for _, want := range []string{"is_parent", "linked_parent_jid"} {
+ if !groupCols[want] {
+ t.Fatalf("expected migrated groups column %q to exist", want)
+ }
+ }
+ if !indexExists(t, db.sql, "idx_groups_linked_parent_jid") {
+ t.Fatalf("expected migrated linked-parent group index to exist")
+ }
+}
+
func TestOpenMigratesContactsSystemNameColumn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wacli.db")