From c57f02af602f2def16c59c1ba1db4059ff2b0fd5 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 24 Jan 2026 00:28:47 +0000
Subject: [PATCH 01/32] feat(api): add cli
---
.github/workflows/release-doctor.yml | 2 +-
.stats.yml | 2 +-
README.md | 6 +++++-
cmd/beeper-desktop-api/main.go | 2 +-
go.mod | 2 +-
pkg/cmd/account.go | 2 +-
pkg/cmd/account_test.go | 2 +-
pkg/cmd/accountcontact.go | 4 ++--
pkg/cmd/accountcontact_test.go | 2 +-
pkg/cmd/asset.go | 4 ++--
pkg/cmd/asset_test.go | 2 +-
pkg/cmd/beeperdesktopapi.go | 4 ++--
pkg/cmd/beeperdesktopapi_test.go | 2 +-
pkg/cmd/chat.go | 4 ++--
pkg/cmd/chat_test.go | 2 +-
pkg/cmd/cmdutil.go | 2 +-
pkg/cmd/flagoptions.go | 8 ++++----
pkg/cmd/message.go | 4 ++--
pkg/cmd/message_test.go | 4 ++--
pkg/cmd/version.go | 2 +-
20 files changed, 33 insertions(+), 29 deletions(-)
diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml
index 3ae923b..8f8d88b 100644
--- a/.github/workflows/release-doctor.yml
+++ b/.github/workflows/release-doctor.yml
@@ -9,7 +9,7 @@ jobs:
release_doctor:
name: release doctor
runs-on: ubuntu-latest
- if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')
+ if: github.repository == 'beeper/desktop-api-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')
steps:
- uses: actions/checkout@v6
diff --git a/.stats.yml b/.stats.yml
index 02a0f28..d0840cd 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 18
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml
openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86
-config_hash: f10bf15270915c249c8c38316ffa83a7
+config_hash: 196c1c81b169ede101a71d1cf2796d99
diff --git a/README.md b/README.md
index fa3829a..806c60d 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,18 @@
The official CLI for the [Beeper Desktop REST API](https://developers.beeper.com/desktop-api/).
+
+
## Installation
### Installing with Go
```sh
-go install 'github.com/stainless-sdks/beeper-desktop-api-cli/cmd/beeper-desktop-api@latest'
+go install 'github.com/beeper/desktop-api-cli/cmd/beeper-desktop-api@latest'
```
+
+
### Running Locally
```sh
diff --git a/cmd/beeper-desktop-api/main.go b/cmd/beeper-desktop-api/main.go
index 0892549..e7dd4c8 100644
--- a/cmd/beeper-desktop-api/main.go
+++ b/cmd/beeper-desktop-api/main.go
@@ -9,8 +9,8 @@ import (
"net/http"
"os"
+ "github.com/beeper/desktop-api-cli/pkg/cmd"
"github.com/beeper/desktop-api-go"
- "github.com/stainless-sdks/beeper-desktop-api-cli/pkg/cmd"
"github.com/tidwall/gjson"
)
diff --git a/go.mod b/go.mod
index d55e4b9..f86e36e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module github.com/stainless-sdks/beeper-desktop-api-cli
+module github.com/beeper/desktop-api-cli
go 1.25
diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go
index 017d196..6ee1774 100644
--- a/pkg/cmd/account.go
+++ b/pkg/cmd/account.go
@@ -7,9 +7,9 @@ import (
"fmt"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
"github.com/beeper/desktop-api-go"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
diff --git a/pkg/cmd/account_test.go b/pkg/cmd/account_test.go
index 712e6fc..cd633e7 100644
--- a/pkg/cmd/account_test.go
+++ b/pkg/cmd/account_test.go
@@ -5,7 +5,7 @@ package cmd
import (
"testing"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
)
func TestAccountsList(t *testing.T) {
diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go
index b5f4363..1aea7eb 100644
--- a/pkg/cmd/accountcontact.go
+++ b/pkg/cmd/accountcontact.go
@@ -7,10 +7,10 @@ import (
"fmt"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
diff --git a/pkg/cmd/accountcontact_test.go b/pkg/cmd/accountcontact_test.go
index 1fc3d33..8ec6c99 100644
--- a/pkg/cmd/accountcontact_test.go
+++ b/pkg/cmd/accountcontact_test.go
@@ -5,7 +5,7 @@ package cmd
import (
"testing"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
)
func TestAccountsContactsSearch(t *testing.T) {
diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go
index 3db0679..7dd4e25 100644
--- a/pkg/cmd/asset.go
+++ b/pkg/cmd/asset.go
@@ -7,10 +7,10 @@ import (
"fmt"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go
index 1ce1f56..8802d8d 100644
--- a/pkg/cmd/asset_test.go
+++ b/pkg/cmd/asset_test.go
@@ -5,7 +5,7 @@ package cmd
import (
"testing"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
)
func TestAssetsDownload(t *testing.T) {
diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go
index 5905633..f0f2b3c 100644
--- a/pkg/cmd/beeperdesktopapi.go
+++ b/pkg/cmd/beeperdesktopapi.go
@@ -7,10 +7,10 @@ import (
"fmt"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
diff --git a/pkg/cmd/beeperdesktopapi_test.go b/pkg/cmd/beeperdesktopapi_test.go
index 8aafda6..1cfef7d 100644
--- a/pkg/cmd/beeperdesktopapi_test.go
+++ b/pkg/cmd/beeperdesktopapi_test.go
@@ -5,7 +5,7 @@ package cmd
import (
"testing"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
)
func TestFocus(t *testing.T) {
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index b09303a..dec3e32 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -7,10 +7,10 @@ import (
"fmt"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go
index 2ecac41..9ae6343 100644
--- a/pkg/cmd/chat_test.go
+++ b/pkg/cmd/chat_test.go
@@ -5,7 +5,7 @@ package cmd
import (
"testing"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
)
func TestChatsCreate(t *testing.T) {
diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go
index e481ec2..66142a4 100644
--- a/pkg/cmd/cmdutil.go
+++ b/pkg/cmd/cmdutil.go
@@ -14,8 +14,8 @@ import (
"strings"
"syscall"
+ "github.com/beeper/desktop-api-cli/internal/jsonview"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/jsonview"
"github.com/charmbracelet/x/term"
"github.com/itchyny/json2yaml"
diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go
index fa81c59..67108b5 100644
--- a/pkg/cmd/flagoptions.go
+++ b/pkg/cmd/flagoptions.go
@@ -8,11 +8,11 @@ import (
"mime/multipart"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiform"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/debugmiddleware"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiform"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/debugmiddleware"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
"github.com/goccy/go-yaml"
"github.com/urfave/cli/v3"
diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go
index 04bfe5f..7cbf8ec 100644
--- a/pkg/cmd/message.go
+++ b/pkg/cmd/message.go
@@ -7,10 +7,10 @@ import (
"fmt"
"os"
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
"github.com/beeper/desktop-api-go/option"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/apiquery"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go
index 49f2287..02c738d 100644
--- a/pkg/cmd/message_test.go
+++ b/pkg/cmd/message_test.go
@@ -5,8 +5,8 @@ package cmd
import (
"testing"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/mocktest"
- "github.com/stainless-sdks/beeper-desktop-api-cli/internal/requestflag"
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
)
func TestMessagesUpdate(t *testing.T) {
diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go
index ad586f4..1f71453 100644
--- a/pkg/cmd/version.go
+++ b/pkg/cmd/version.go
@@ -2,4 +2,4 @@
package cmd
-const Version = "0.0.1"
+const Version = "0.0.1" // x-release-please-version
From eded84a5cc05bb700f5d0c50add30ec257738aa0 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:12:48 +0000
Subject: [PATCH 02/32] feat(cli): improve shell completions for namespaced
commands and flags
---
cmd/beeper-desktop-api/main.go | 25 +-
internal/autocomplete/autocomplete.go | 361 ++++++++++++++++
internal/autocomplete/autocomplete_test.go | 393 ++++++++++++++++++
.../shellscripts/bash_autocomplete.bash | 21 +
.../shellscripts/fish_autocomplete.fish | 29 ++
.../shellscripts/pwsh_autocomplete.ps1 | 48 +++
.../shellscripts/zsh_autocomplete.zsh | 28 ++
pkg/cmd/cmd.go | 17 +-
8 files changed, 918 insertions(+), 4 deletions(-)
create mode 100644 internal/autocomplete/autocomplete.go
create mode 100644 internal/autocomplete/autocomplete_test.go
create mode 100755 internal/autocomplete/shellscripts/bash_autocomplete.bash
create mode 100644 internal/autocomplete/shellscripts/fish_autocomplete.fish
create mode 100644 internal/autocomplete/shellscripts/pwsh_autocomplete.ps1
create mode 100644 internal/autocomplete/shellscripts/zsh_autocomplete.zsh
diff --git a/cmd/beeper-desktop-api/main.go b/cmd/beeper-desktop-api/main.go
index e7dd4c8..1a40218 100644
--- a/cmd/beeper-desktop-api/main.go
+++ b/cmd/beeper-desktop-api/main.go
@@ -8,15 +8,29 @@ import (
"fmt"
"net/http"
"os"
+ "slices"
"github.com/beeper/desktop-api-cli/pkg/cmd"
"github.com/beeper/desktop-api-go"
"github.com/tidwall/gjson"
+ "github.com/urfave/cli/v3"
)
func main() {
app := cmd.Command
+
+ if slices.Contains(os.Args, "__complete") {
+ prepareForAutocomplete(app)
+ }
+
if err := app.Run(context.Background(), os.Args); err != nil {
+ exitCode := 1
+
+ // Check if error has a custom exit code
+ if exitErr, ok := err.(cli.ExitCoder); ok {
+ exitCode = exitErr.ExitCode()
+ }
+
var apierr *beeperdesktopapi.Error
if errors.As(err, &apierr) {
fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode))
@@ -30,6 +44,15 @@ func main() {
} else {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
}
- os.Exit(1)
+ os.Exit(exitCode)
+ }
+}
+
+func prepareForAutocomplete(cmd *cli.Command) {
+ // urfave/cli does not handle flag completions and will print an error if we inspect a command with invalid flags.
+ // This skips that sort of validation
+ cmd.SkipFlagParsing = true
+ for _, child := range cmd.Commands {
+ prepareForAutocomplete(child)
}
}
diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go
new file mode 100644
index 0000000..97fe1a8
--- /dev/null
+++ b/internal/autocomplete/autocomplete.go
@@ -0,0 +1,361 @@
+package autocomplete
+
+import (
+ "context"
+ "embed"
+ "fmt"
+ "os"
+ "slices"
+ "strings"
+
+ "github.com/urfave/cli/v3"
+)
+
+type CompletionStyle string
+
+const (
+ CompletionStyleZsh CompletionStyle = "zsh"
+ CompletionStyleBash CompletionStyle = "bash"
+ CompletionStylePowershell CompletionStyle = "pwsh"
+ CompletionStyleFish CompletionStyle = "fish"
+)
+
+type renderCompletion func(cmd *cli.Command, appName string) (string, error)
+
+var (
+ //go:embed shellscripts
+ autoCompleteFS embed.FS
+
+ shellCompletions = map[CompletionStyle]renderCompletion{
+ "bash": func(c *cli.Command, appName string) (string, error) {
+ b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash")
+ return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
+ },
+ "fish": func(c *cli.Command, appName string) (string, error) {
+ b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish")
+ return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
+ },
+ "pwsh": func(c *cli.Command, appName string) (string, error) {
+ b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1")
+ return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
+ },
+ "zsh": func(c *cli.Command, appName string) (string, error) {
+ b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh")
+ return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
+ },
+ }
+)
+
+func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error {
+ shells := make([]CompletionStyle, 0, len(shellCompletions))
+ for k := range shellCompletions {
+ shells = append(shells, k)
+ }
+
+ if cmd.Args().Len() == 0 {
+ return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1)
+ }
+ s := CompletionStyle(cmd.Args().First())
+
+ renderCompletion, ok := shellCompletions[s]
+ if !ok {
+ return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1)
+ }
+
+ completionScript, err := renderCompletion(cmd, cmd.Root().Name)
+ if err != nil {
+ return cli.Exit(err, 1)
+ }
+
+ _, err = cmd.Writer.Write([]byte(completionScript))
+ if err != nil {
+ return cli.Exit(err, 1)
+ }
+
+ return nil
+}
+
+type ShellCompletion struct {
+ Name string
+ Usage string
+}
+
+func NewShellCompletion(name string, usage string) ShellCompletion {
+ return ShellCompletion{Name: name, Usage: usage}
+}
+
+type ShellCompletionBehavior int
+
+const (
+ ShellCompletionBehaviorDefault ShellCompletionBehavior = iota
+ ShellCompletionBehaviorFile = 10
+ ShellCompletionBehaviorNoComplete
+)
+
+type CompletionResult struct {
+ Completions []ShellCompletion
+ Behavior ShellCompletionBehavior
+}
+
+func isFlag(arg string) bool {
+ return strings.HasPrefix(arg, "-")
+}
+
+func findFlag(cmd *cli.Command, arg string) *cli.Flag {
+ name := strings.TrimLeft(arg, "-")
+ for _, flag := range cmd.Flags {
+ if vf, ok := flag.(cli.VisibleFlag); ok && !vf.IsVisible() {
+ continue
+ }
+
+ if slices.Contains(flag.Names(), name) {
+ return &flag
+ }
+ }
+ return nil
+}
+
+func findChild(cmd *cli.Command, name string) *cli.Command {
+ for _, c := range cmd.Commands {
+ if !c.Hidden && c.Name == name {
+ return c
+ }
+ }
+ return nil
+}
+
+type shellCompletionBuilder struct {
+ completionStyle CompletionStyle
+}
+
+func (scb *shellCompletionBuilder) createFromCommand(input string, command *cli.Command, result []ShellCompletion) []ShellCompletion {
+ matchingNames := make([]string, 0, len(command.Names()))
+
+ for _, name := range command.Names() {
+ if strings.HasPrefix(name, input) {
+ matchingNames = append(matchingNames, name)
+ }
+ }
+
+ if scb.completionStyle == CompletionStyleBash {
+ index := strings.LastIndex(input, ":") + 1
+ if index > 0 {
+ for _, name := range matchingNames {
+ result = append(result, NewShellCompletion(name[index:], command.Usage))
+ }
+ return result
+ }
+ }
+
+ for _, name := range matchingNames {
+ result = append(result, NewShellCompletion(name, command.Usage))
+ }
+ return result
+}
+
+func (scb *shellCompletionBuilder) createFromFlag(input string, flag *cli.Flag, result []ShellCompletion) []ShellCompletion {
+ matchingNames := make([]string, 0, len((*flag).Names()))
+
+ for _, name := range (*flag).Names() {
+ withPrefix := ""
+ if len(name) == 1 {
+ withPrefix = "-" + name
+ } else {
+ withPrefix = "--" + name
+ }
+
+ if strings.HasPrefix(withPrefix, input) {
+ matchingNames = append(matchingNames, withPrefix)
+ }
+ }
+
+ usage := ""
+ if dgf, ok := (*flag).(cli.DocGenerationFlag); ok {
+ usage = dgf.GetUsage()
+ }
+
+ for _, name := range matchingNames {
+ result = append(result, NewShellCompletion(name, usage))
+ }
+
+ return result
+}
+
+func GetCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult {
+ result := getAllPossibleCompletions(completionStyle, root, args)
+
+ // If the user has not put in a colon, filter out colon commands
+ if len(args) > 0 && !strings.Contains(args[len(args)-1], ":") {
+ // Nothing with anything after a colon. Create a single entry for groups with the same colon subset
+ foundNames := make([]string, 0, len(result.Completions))
+ filteredCompletions := make([]ShellCompletion, 0, len(result.Completions))
+
+ for _, completion := range result.Completions {
+ name := completion.Name
+ firstColonIndex := strings.Index(name, ":")
+ if firstColonIndex > -1 {
+ name = name[0:firstColonIndex]
+ completion.Name = name
+ completion.Usage = ""
+ }
+
+ if !slices.Contains(foundNames, name) {
+ foundNames = append(foundNames, name)
+ filteredCompletions = append(filteredCompletions, completion)
+ }
+ }
+
+ result.Completions = filteredCompletions
+ }
+
+ return result
+}
+
+func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult {
+ builder := shellCompletionBuilder{completionStyle: completionStyle}
+ completions := make([]ShellCompletion, 0)
+ if len(args) == 0 {
+ for _, child := range root.Commands {
+ completions = builder.createFromCommand("", child, completions)
+ }
+ return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorDefault}
+ }
+
+ current := args[len(args)-1]
+ preceding := args[0 : len(args)-1]
+ cmd := root
+ i := 0
+ for i < len(preceding) {
+ arg := preceding[i]
+
+ if isFlag(arg) {
+ flag := findFlag(cmd, arg)
+ if flag == nil {
+ i++
+ } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() {
+ // All flags except for bool flags take values
+ i += 2
+ } else {
+ i++
+ }
+ } else {
+ child := findChild(cmd, arg)
+ if child != nil {
+ cmd = child
+ }
+ i++
+ }
+ }
+
+ // Check if the previous arg was a flag expecting a value
+ if len(preceding) > 0 {
+ prev := preceding[len(preceding)-1]
+ if isFlag(prev) {
+ flag := findFlag(cmd, prev)
+ if flag != nil {
+ if fb, ok := (*flag).(*cli.StringFlag); ok && fb.TakesFile {
+ return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorFile}
+ } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() {
+ return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorNoComplete}
+ }
+ }
+ }
+ }
+
+ // Completing a flag name
+ if isFlag(current) {
+ for _, flag := range cmd.Flags {
+ completions = builder.createFromFlag(current, &flag, completions)
+ }
+ }
+
+ for _, child := range cmd.Commands {
+ if !child.Hidden {
+ completions = builder.createFromCommand(current, child, completions)
+ }
+ }
+
+ return CompletionResult{
+ Completions: completions,
+ Behavior: ShellCompletionBehaviorDefault,
+ }
+}
+
+func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error {
+ root := cmd.Root()
+ args := rebuildColonSeparatedArgs(root.Args().Slice()[1:])
+
+ var completionStyle CompletionStyle
+ if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok {
+ switch style {
+ case "bash":
+ completionStyle = CompletionStyleBash
+ case "zsh":
+ completionStyle = CompletionStyleZsh
+ case "pwsh":
+ completionStyle = CompletionStylePowershell
+ case "fish":
+ completionStyle = CompletionStyleFish
+ default:
+ return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", 1)
+ }
+ } else {
+ return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'", 1)
+ }
+
+ result := GetCompletions(completionStyle, root, args)
+
+ for _, completion := range result.Completions {
+ name := completion.Name
+ if completionStyle == CompletionStyleZsh {
+ name = strings.ReplaceAll(name, ":", "\\:")
+ }
+ if completionStyle == CompletionStyleZsh && len(completion.Usage) > 0 {
+ _, _ = fmt.Fprintf(cmd.Writer, "%s:%s\n", name, completion.Usage)
+ } else if completionStyle == CompletionStyleFish && len(completion.Usage) > 0 {
+ _, _ = fmt.Fprintf(cmd.Writer, "%s\t%s\n", name, completion.Usage)
+ } else {
+ _, _ = fmt.Fprintf(cmd.Writer, "%s\n", name)
+ }
+ }
+ return cli.Exit("", int(result.Behavior))
+}
+
+// When CLI arguments are passed in, they are separated on word barriers.
+// Most commonly this is whitespace but in some cases that may also be colons.
+// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring
+// arguments.
+//
+// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]`
+func rebuildColonSeparatedArgs(args []string) []string {
+ if len(args) == 0 {
+ return args
+ }
+
+ result := []string{}
+ i := 0
+
+ for i < len(args) {
+ current := args[i]
+
+ // Keep joining while the next element is ":" or the current element ends with ":"
+ for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) {
+ if args[i+1] == ":" {
+ current += ":"
+ i++
+ // Check if there's a following element after the ":"
+ if i+1 < len(args) && args[i+1] != ":" {
+ current += args[i+1]
+ i++
+ }
+ } else {
+ break
+ }
+ }
+
+ result = append(result, current)
+ i++
+ }
+
+ return result
+}
diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go
new file mode 100644
index 0000000..3e8aa33
--- /dev/null
+++ b/internal/autocomplete/autocomplete_test.go
@@ -0,0 +1,393 @@
+package autocomplete
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/urfave/cli/v3"
+)
+
+func TestGetCompletions_EmptyArgs(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "generate", Usage: "Generate SDK"},
+ {Name: "test", Usage: "Run tests"},
+ {Name: "build", Usage: "Build project"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{})
+
+ assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
+ assert.Len(t, result.Completions, 3)
+ assert.Contains(t, result.Completions, ShellCompletion{Name: "generate", Usage: "Generate SDK"})
+ assert.Contains(t, result.Completions, ShellCompletion{Name: "test", Usage: "Run tests"})
+ assert.Contains(t, result.Completions, ShellCompletion{Name: "build", Usage: "Build project"})
+}
+
+func TestGetCompletions_SubcommandPrefix(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "generate", Usage: "Generate SDK"},
+ {Name: "test", Usage: "Run tests"},
+ {Name: "build", Usage: "Build project"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"ge"})
+
+ assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "generate", result.Completions[0].Name)
+ assert.Equal(t, "Generate SDK", result.Completions[0].Usage)
+}
+
+func TestGetCompletions_HiddenCommand(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "visible", Usage: "Visible command"},
+ {Name: "hidden", Usage: "Hidden command", Hidden: true},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{""})
+
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "visible", result.Completions[0].Name)
+}
+
+func TestGetCompletions_NestedSubcommand(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "config",
+ Usage: "Configuration commands",
+ Commands: []*cli.Command{
+ {Name: "get", Usage: "Get config value"},
+ {Name: "set", Usage: "Set config value"},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"config", "s"})
+
+ assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "set", result.Completions[0].Name)
+ assert.Equal(t, "Set config value", result.Completions[0].Usage)
+}
+
+func TestGetCompletions_FlagCompletion(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"},
+ &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"},
+ &cli.StringFlag{Name: "format", Usage: "Output format"},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--o"})
+
+ assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "--output", result.Completions[0].Name)
+ assert.Equal(t, "Output directory", result.Completions[0].Usage)
+}
+
+func TestGetCompletions_ShortFlagCompletion(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"},
+ &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v"})
+
+ assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "-v", result.Completions[0].Name)
+}
+
+func TestGetCompletions_FileFlagBehavior(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "Config file", TakesFile: true},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--config", ""})
+
+ assert.EqualValues(t, ShellCompletionBehaviorFile, result.Behavior)
+ assert.Empty(t, result.Completions)
+}
+
+func TestGetCompletions_NonBoolFlagValue(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "format", Usage: "Output format"},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--format", ""})
+
+ assert.EqualValues(t, ShellCompletionBehaviorNoComplete, result.Behavior)
+ assert.Empty(t, result.Completions)
+}
+
+func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"},
+ },
+ Commands: []*cli.Command{
+ {Name: "typescript", Usage: "Generate TypeScript SDK"},
+ {Name: "python", Usage: "Generate Python SDK"},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--verbose", "ty"})
+
+ assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "typescript", result.Completions[0].Name)
+}
+
+func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ {Name: "config:list", Usage: "List config values"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"co"})
+
+ // Should collapse to single "config" entry without usage
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "config", result.Completions[0].Name)
+ assert.Equal(t, "", result.Completions[0].Usage)
+}
+
+func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ {Name: "config:list", Usage: "List config values"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"config:"})
+
+ // For bash, should show suffixes only
+ assert.Len(t, result.Completions, 3)
+ names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name}
+ assert.Contains(t, names, "get")
+ assert.Contains(t, names, "set")
+ assert.Contains(t, names, "list")
+}
+
+func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ {Name: "config:list", Usage: "List config values"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleZsh, root, []string{"config:"})
+
+ // For zsh, should show full names
+ assert.Len(t, result.Completions, 3)
+ names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name}
+ assert.Contains(t, names, "config:get")
+ assert.Contains(t, names, "config:set")
+ assert.Contains(t, names, "config:list")
+}
+
+func TestGetCompletions_BashStyleColonCompletion(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"config:g"})
+
+ // For bash, should return suffix from after the colon in the input
+ // Input "config:g" has colon at index 6, so we take name[7:] from matched commands
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "get", result.Completions[0].Name)
+ assert.Equal(t, "Get config value", result.Completions[0].Usage)
+}
+
+func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"other:g"})
+
+ // No matches
+ assert.Len(t, result.Completions, 0)
+}
+
+func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleZsh, root, []string{"config:g"})
+
+ // For zsh, should return full name
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "config:get", result.Completions[0].Name)
+ assert.Equal(t, "Get config value", result.Completions[0].Usage)
+}
+
+func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "generate", Usage: "Generate SDK"},
+ {Name: "config:get", Usage: "Get config value"},
+ {Name: "config:set", Usage: "Set config value"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{""})
+
+ // Should show "generate" and "config" (collapsed)
+ assert.Len(t, result.Completions, 2)
+ names := []string{result.Completions[0].Name, result.Completions[1].Name}
+ assert.Contains(t, names, "generate")
+ assert.Contains(t, names, "config")
+}
+
+func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
+ &cli.StringFlag{Name: "output", Aliases: []string{"o"}},
+ },
+ Commands: []*cli.Command{
+ {Name: "typescript", Usage: "TypeScript SDK"},
+ },
+ },
+ },
+ }
+
+ // Bool flag should not consume the next arg as a value
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v", "ty"})
+
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "typescript", result.Completions[0].Name)
+}
+
+func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "config", Aliases: []string{"c"}},
+ &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
+ },
+ Commands: []*cli.Command{
+ {Name: "typescript", Usage: "TypeScript SDK"},
+ {Name: "python", Usage: "Python SDK"},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-c", "config.yml", "-v", "py"})
+
+ assert.Len(t, result.Completions, 1)
+ assert.Equal(t, "python", result.Completions[0].Name)
+}
+
+func TestGetCompletions_CommandAliases(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"},
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"g"})
+
+ // Should match all aliases that start with "g"
+ assert.GreaterOrEqual(t, len(result.Completions), 2) // "generate" and "gen", possibly "g" too
+ names := []string{}
+ for _, c := range result.Completions {
+ names = append(names, c.Name)
+ }
+ assert.Contains(t, names, "generate")
+ assert.Contains(t, names, "gen")
+}
+
+func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) {
+ root := &cli.Command{
+ Commands: []*cli.Command{
+ {
+ Name: "generate",
+ Usage: "Generate SDK",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "output", Aliases: []string{"o"}},
+ &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
+ &cli.StringFlag{Name: "format", Aliases: []string{"f"}},
+ },
+ },
+ },
+ }
+
+ result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-"})
+
+ // Should show all flag variations
+ assert.GreaterOrEqual(t, len(result.Completions), 6) // -o, --output, -v, --verbose, -f, --format
+}
diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash
new file mode 100755
index 0000000..64fa6ab
--- /dev/null
+++ b/internal/autocomplete/shellscripts/bash_autocomplete.bash
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+____APPNAME___bash_autocomplete() {
+ if [[ "${COMP_WORDS[0]}" != "source" ]]; then
+ local cur completions exit_code
+ local IFS=$'\n'
+ cur="${COMP_WORDS[COMP_CWORD]}"
+
+ completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null)
+ exit_code=$?
+
+ case $exit_code in
+ 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file
+ 11) COMPREPLY=() ;; # no completion
+ 0) mapfile -t COMPREPLY <<< "$completions" ;; # use returned completions
+ esac
+ return 0
+ fi
+}
+
+complete -F ____APPNAME___bash_autocomplete __APPNAME__
diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish
new file mode 100644
index 0000000..0164b04
--- /dev/null
+++ b/internal/autocomplete/shellscripts/fish_autocomplete.fish
@@ -0,0 +1,29 @@
+#!/usr/bin/env fish
+
+function ____APPNAME___fish_autocomplete
+ set -l tokens (commandline -xpc)
+ set -l current (commandline -ct)
+
+ set -l cmd $tokens[1]
+ set -l args $tokens[2..-1]
+
+ set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log)
+ set -l exit_code $status
+
+ switch $exit_code
+ case 10
+ # File completion
+ __fish_complete_path "$current"
+ case 11
+ # No completion
+ return 0
+ case 0
+ # Use returned completions
+ for completion in $completions
+ echo $completion
+ end
+ end
+end
+
+complete -c __APPNAME__ -f -a '(____APPNAME___fish_autocomplete)'
+
diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1
new file mode 100644
index 0000000..f712e13
--- /dev/null
+++ b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1
@@ -0,0 +1,48 @@
+Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock {
+ param($wordToComplete, $commandAst, $cursorPosition)
+
+ $elements = $commandAst.CommandElements
+ $completionArgs = @()
+
+ # Extract each of the arguments
+ for ($i = 0; $i -lt $elements.Count; $i++) {
+ $completionArgs += $elements[$i].Extent.Text
+ }
+
+ # Add empty string if there's a trailing space (wordToComplete is empty but cursor is after space)
+ # Necessary for differentiating between getting completions for namespaced commands vs. subcommands
+ if ($wordToComplete.Length -eq 0 -and $elements.Count -gt 0) {
+ $completionArgs += ""
+ }
+
+ $output = & {
+ $env:COMPLETION_STYLE = 'pwsh'
+ __APPNAME__ __complete @completionArgs 2>&1
+ }
+ $exitCode = $LASTEXITCODE
+
+ switch ($exitCode) {
+ 10 {
+ # File completion behavior
+ Get-ChildItem -Path "$wordToComplete*" | ForEach-Object {
+ $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name }
+ [System.Management.Automation.CompletionResult]::new(
+ $completionText,
+ $completionText,
+ 'ProviderItem',
+ $completionText
+ )
+ }
+ }
+ 11 {
+ # No reasonable suggestions
+ [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ')
+ }
+ default {
+ # Default behavior - show command completions
+ $output | ForEach-Object {
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
+ }
+ }
+ }
+}
diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh
new file mode 100644
index 0000000..5412987
--- /dev/null
+++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh
@@ -0,0 +1,28 @@
+#!/bin/zsh
+compdef ____APPNAME___zsh_autocomplete __APPNAME__
+
+____APPNAME___zsh_autocomplete() {
+
+ local -a opts
+ local temp
+ local exit_code
+
+ temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}")
+ exit_code=$?
+
+ case $exit_code in
+ 10)
+ # File completion behavior
+ _files
+ ;;
+ 11)
+ # No completion behavior - return nothing
+ return 1
+ ;;
+ 0)
+ # Default behavior - show command completions
+ opts=("${(@f)temp}")
+ _describe 'values' opts
+ ;;
+ esac
+}
diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go
index c96ee64..b1e46a0 100644
--- a/pkg/cmd/cmd.go
+++ b/pkg/cmd/cmd.go
@@ -11,6 +11,7 @@ import (
"slices"
"strings"
+ "github.com/beeper/desktop-api-cli/internal/autocomplete"
docs "github.com/urfave/cli-docs/v3"
"github.com/urfave/cli/v3"
)
@@ -145,10 +146,20 @@ func init() {
},
},
},
+ {
+ Name: "__complete",
+ Hidden: true,
+ HideHelpCommand: true,
+ Action: autocomplete.ExecuteShellCompletion,
+ },
+ {
+ Name: "@completion",
+ Hidden: true,
+ HideHelpCommand: true,
+ Action: autocomplete.OutputCompletionScript,
+ },
},
- EnableShellCompletion: true,
- ShellCompletionCommandName: "@completion",
- HideHelpCommand: true,
+ HideHelpCommand: true,
}
}
From 7c4554a35871394eeed6927ee401ce7cc6fe99b8 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:15:43 +0000
Subject: [PATCH 03/32] fix: fix mock tests with inner fields that have
underscores
---
pkg/cmd/message_test.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go
index 02c738d..0cdd254 100644
--- a/pkg/cmd/message_test.go
+++ b/pkg/cmd/message_test.go
@@ -69,10 +69,10 @@ func TestMessagesSend(t *testing.T) {
t,
"messages", "send",
"--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
- "--attachment.uploadID", "uploadID",
+ "--attachment.upload-id", "uploadID",
"--attachment.duration", "0",
- "--attachment.fileName", "fileName",
- "--attachment.mimeType", "mimeType",
+ "--attachment.file-name", "fileName",
+ "--attachment.mime-type", "mimeType",
"--attachment.size", "{height: 0, width: 0}",
"--attachment.type", "gif",
"--reply-to-message-id", "replyToMessageID",
From 49ca642691b546494d700c2f782aa8ae88d9767e Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:16:39 +0000
Subject: [PATCH 04/32] feat!: add support for passing files as parameters
---
pkg/cmd/flagoptions.go | 156 +++++++++++++++++++++++-
pkg/cmd/flagoptions_test.go | 236 ++++++++++++++++++++++++++++++++++++
2 files changed, 389 insertions(+), 3 deletions(-)
create mode 100644 pkg/cmd/flagoptions_test.go
diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go
index 67108b5..57a53da 100644
--- a/pkg/cmd/flagoptions.go
+++ b/pkg/cmd/flagoptions.go
@@ -2,11 +2,17 @@ package cmd
import (
"bytes"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
+ "maps"
"mime/multipart"
+ "net/http"
"os"
+ "reflect"
+ "strings"
+ "unicode/utf8"
"github.com/beeper/desktop-api-cli/internal/apiform"
"github.com/beeper/desktop-api-cli/internal/apiquery"
@@ -27,6 +33,136 @@ const (
ApplicationOctetStream
)
+func embedFiles(obj any) (any, error) {
+ v := reflect.ValueOf(obj)
+ result, err := embedFilesValue(v)
+ if err != nil {
+ return nil, err
+ }
+ return result.Interface(), nil
+}
+
+// Replace "@file.txt" with the file's contents inside a value
+func embedFilesValue(v reflect.Value) (reflect.Value, error) {
+ // Unwrap interface values to get the concrete type
+ if v.Kind() == reflect.Interface {
+ if v.IsNil() {
+ return v, nil
+ }
+ v = v.Elem()
+ }
+
+ switch v.Kind() {
+ case reflect.Map:
+ if v.Len() == 0 {
+ return v, nil
+ }
+ result := reflect.MakeMap(v.Type())
+ iter := v.MapRange()
+ for iter.Next() {
+ key := iter.Key()
+ val := iter.Value()
+ newVal, err := embedFilesValue(val)
+ if err != nil {
+ return reflect.Value{}, err
+ }
+ result.SetMapIndex(key, newVal)
+ }
+ return result, nil
+
+ case reflect.Slice, reflect.Array:
+ if v.Len() == 0 {
+ return v, nil
+ }
+ result := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
+ for i := 0; i < v.Len(); i++ {
+ newVal, err := embedFilesValue(v.Index(i))
+ if err != nil {
+ return reflect.Value{}, err
+ }
+ result.Index(i).Set(newVal)
+ }
+ return result, nil
+
+ case reflect.String:
+ s := v.String()
+
+ if literal, ok := strings.CutPrefix(s, "\\@"); ok {
+ // Allow for escaped @ signs if you don't want them to be treated as files
+ return reflect.ValueOf("@" + literal), nil
+ } else if filename, ok := strings.CutPrefix(s, "@data://"); ok {
+ // The "@data://" prefix is for files you explicitly want to upload
+ // as base64-encoded (even if the file itself is plain text)
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ return v, err
+ }
+ return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
+ } else if filename, ok := strings.CutPrefix(s, "@file://"); ok {
+ // The "@file://" prefix is for files that you explicitly want to
+ // upload as a string literal with backslash escapes (not base64
+ // encoded)
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ return v, err
+ }
+ return reflect.ValueOf(string(content)), nil
+ } else if filename, ok := strings.CutPrefix(s, "@"); ok {
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ // If the string is "@username", it's probably supposed to be a
+ // string literal and not a file reference. However, if the
+ // string looks like "@file.txt" or "@/tmp/file", then it's
+ // probably supposed to be a file.
+ probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/")
+ if probablyFile {
+ // Give a useful error message if the user tried to upload a
+ // file, but the file couldn't be read (e.g. mistyped
+ // filename or permission error)
+ return v, err
+ }
+ // Fall back to the raw value if the user provided something
+ // like "@username" that's not intended to be a file.
+ return v, nil
+ }
+ // If the file looks like a plain text UTF8 file format, then use the contents directly.
+ if isUTF8TextFile(content) {
+ return reflect.ValueOf(string(content)), nil
+ }
+ // Otherwise it's a binary file, so encode it with base64
+ return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
+ }
+ return v, nil
+
+ default:
+ return v, nil
+ }
+}
+
+// Guess whether a file's contents are binary (e.g. a .jpg or .mp3), as opposed
+// to plain text (e.g. .txt or .md).
+func isUTF8TextFile(content []byte) bool {
+ // Go's DetectContentType follows https://mimesniff.spec.whatwg.org/ and
+ // these are the sniffable content types that are plain text:
+ textTypes := []string{
+ "text/",
+ "application/json",
+ "application/xml",
+ "application/javascript",
+ "application/x-javascript",
+ "application/ecmascript",
+ "application/x-ecmascript",
+ }
+
+ contentType := http.DetectContentType(content)
+ for _, prefix := range textTypes {
+ if strings.HasPrefix(contentType, prefix) {
+ return utf8.Valid(content)
+ }
+ }
+ return false
+}
+
func flagOptions(
cmd *cli.Command,
nestedFormat apiquery.NestedQueryFormat,
@@ -55,9 +191,7 @@ func flagOptions(
if err := yaml.Unmarshal(pipeData, &bodyData); err == nil {
if bodyMap, ok := bodyData.(map[string]any); ok {
if flagMap, ok := flagContents.Body.(map[string]any); ok {
- for k, v := range flagMap {
- bodyMap[k] = v
- }
+ maps.Copy(bodyMap, flagMap)
} else {
bodyData = flagContents.Body
}
@@ -70,6 +204,22 @@ func flagOptions(
bodyData = flagContents.Body
}
+ // Embed files passed as "@file.jpg" in the request body, headers, and query:
+ bodyData, err := embedFiles(bodyData)
+ if err != nil {
+ return nil, err
+ }
+ if headersWithFiles, err := embedFiles(flagContents.Headers); err != nil {
+ return nil, err
+ } else {
+ flagContents.Headers = headersWithFiles.(map[string]any)
+ }
+ if queriesWithFiles, err := embedFiles(flagContents.Queries); err != nil {
+ return nil, err
+ } else {
+ flagContents.Queries = queriesWithFiles.(map[string]any)
+ }
+
querySettings := apiquery.QuerySettings{
NestedFormat: nestedFormat,
ArrayFormat: arrayFormat,
diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go
new file mode 100644
index 0000000..2db8aa3
--- /dev/null
+++ b/pkg/cmd/flagoptions_test.go
@@ -0,0 +1,236 @@
+package cmd
+
+import (
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsUTF8TextFile(t *testing.T) {
+ tests := []struct {
+ content []byte
+ expected bool
+ }{
+ {[]byte("Hello, world!"), true},
+ {[]byte(`{"key": "value"}`), true},
+ {[]byte(``), true},
+ {[]byte(`function test() {}`), true},
+ {[]byte{0xFF, 0xD8, 0xFF, 0xE0}, false}, // JPEG header
+ {[]byte{0x00, 0x01, 0xFF, 0xFE}, false}, // binary
+ {[]byte("Hello \xFF\xFE"), false}, // invalid UTF-8
+ {[]byte("Hello ☺️"), true}, // emoji
+ {[]byte{}, true}, // empty
+ }
+
+ for _, tt := range tests {
+ assert.Equal(t, tt.expected, isUTF8TextFile(tt.content))
+ }
+}
+
+func TestEmbedFiles(t *testing.T) {
+ // Create temporary directory for test files
+ tmpDir := t.TempDir()
+
+ // Create test files
+ configContent := "host=localhost\nport=8080"
+ templateContent := "
Hello"
+ dataContent := `{"key": "value"}`
+
+ writeTestFile(t, tmpDir, "config.txt", configContent)
+ writeTestFile(t, tmpDir, "template.html", templateContent)
+ writeTestFile(t, tmpDir, "data.json", dataContent)
+ jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46}
+ writeTestFile(t, tmpDir, "image.jpg", string(jpegHeader))
+
+ tests := []struct {
+ name string
+ input any
+ want any
+ wantErr bool
+ }{
+ {
+ name: "map[string]any with file references",
+ input: map[string]any{
+ "config": "@" + filepath.Join(tmpDir, "config.txt"),
+ "template": "@file://" + filepath.Join(tmpDir, "template.html"),
+ "count": 42,
+ },
+ want: map[string]any{
+ "config": configContent,
+ "template": templateContent,
+ "count": 42,
+ },
+ wantErr: false,
+ },
+ {
+ name: "map[string]string with file references",
+ input: map[string]string{
+ "config": "@" + filepath.Join(tmpDir, "config.txt"),
+ "name": "test",
+ },
+ want: map[string]string{
+ "config": configContent,
+ "name": "test",
+ },
+ wantErr: false,
+ },
+ {
+ name: "[]any with file references",
+ input: []any{
+ "@" + filepath.Join(tmpDir, "config.txt"),
+ 42,
+ true,
+ "@file://" + filepath.Join(tmpDir, "data.json"),
+ },
+ want: []any{
+ configContent,
+ 42,
+ true,
+ dataContent,
+ },
+ wantErr: false,
+ },
+ {
+ name: "[]string with file references",
+ input: []string{
+ "@" + filepath.Join(tmpDir, "config.txt"),
+ "normal string",
+ },
+ want: []string{
+ configContent,
+ "normal string",
+ },
+ wantErr: false,
+ },
+ {
+ name: "nested structures",
+ input: map[string]any{
+ "outer": map[string]any{
+ "inner": []any{
+ "@" + filepath.Join(tmpDir, "config.txt"),
+ map[string]string{
+ "data": "@" + filepath.Join(tmpDir, "data.json"),
+ },
+ },
+ },
+ },
+ want: map[string]any{
+ "outer": map[string]any{
+ "inner": []any{
+ configContent,
+ map[string]string{
+ "data": dataContent,
+ },
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "base64 encoding",
+ input: map[string]string{
+ "encoded": "@data://" + filepath.Join(tmpDir, "config.txt"),
+ "image": "@" + filepath.Join(tmpDir, "image.jpg"),
+ },
+ want: map[string]string{
+ "encoded": base64.StdEncoding.EncodeToString([]byte(configContent)),
+ "image": base64.StdEncoding.EncodeToString(jpegHeader),
+ },
+ wantErr: false,
+ },
+ {
+ name: "non-existent file with @ prefix",
+ input: map[string]string{
+ "missing": "@file.txt",
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "non-file-like thing with @ prefix",
+ input: map[string]string{
+ "username": "@user",
+ "favorite_symbol": "@",
+ },
+ want: map[string]string{
+ "username": "@user",
+ "favorite_symbol": "@",
+ },
+ wantErr: false,
+ },
+ {
+ name: "non-existent file with @file:// prefix (error)",
+ input: map[string]string{
+ "missing": "@file:///nonexistent/file.txt",
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "escaping",
+ input: map[string]string{
+ "simple": "\\@file.txt",
+ "file": "\\@file://file.txt",
+ "data": "\\@data://file.txt",
+ "keep_escape": "user\\@example.com",
+ },
+ want: map[string]string{
+ "simple": "@file.txt",
+ "file": "@file://file.txt",
+ "data": "@data://file.txt",
+ "keep_escape": "user\\@example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "primitive types",
+ input: map[string]any{
+ "int": 123,
+ "float": 45.67,
+ "bool": true,
+ "null": nil,
+ "string": "no prefix",
+ "email": "user@example.com",
+ },
+ want: map[string]any{
+ "int": 123,
+ "float": 45.67,
+ "bool": true,
+ "null": nil,
+ "string": "no prefix",
+ "email": "user@example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "[]int unchanged",
+ input: []int{1, 2, 3, 4, 5},
+ want: []int{1, 2, 3, 4, 5},
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := embedFiles(tt.input)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func writeTestFile(t *testing.T, dir, filename, content string) {
+ t.Helper()
+ path := filepath.Join(dir, filename)
+ err := os.WriteFile(path, []byte(content), 0644)
+ require.NoError(t, err, "failed to write test file %s", path)
+}
From de2984b4cec53693f0b5b684cdc498c410211a82 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:21:35 +0000
Subject: [PATCH 05/32] fix: restore support for void endpoints
---
pkg/cmd/chat.go | 53 ++++++++++++++++
pkg/cmd/chat_test.go | 9 +++
pkg/cmd/chatreminder.go | 119 +++++++++++++++++++++++++++++++++++
pkg/cmd/chatreminder_test.go | 39 ++++++++++++
pkg/cmd/cmd.go | 10 +++
5 files changed, 230 insertions(+)
create mode 100644 pkg/cmd/chatreminder.go
create mode 100644 pkg/cmd/chatreminder_test.go
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index dec3e32..142007f 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -99,6 +99,27 @@ var chatsList = cli.Command{
HideHelpCommand: true,
}
+var chatsArchive = cli.Command{
+ Name: "archive",
+ Usage: "Archive or unarchive a chat. Set archived=true to move to archive,\narchived=false to move back to inbox",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "chat-id",
+ Usage: "Unique identifier of the chat.",
+ Required: true,
+ },
+ &requestflag.Flag[bool]{
+ Name: "archived",
+ Usage: "True to archive, false to unarchive",
+ Default: true,
+ BodyPath: "archived",
+ },
+ },
+ Action: handleChatsArchive,
+ HideHelpCommand: true,
+}
+
var chatsSearch = cli.Command{
Name: "search",
Usage: "Search chats by title/network or participants using Beeper Desktop's renderer\nalgorithm.",
@@ -287,6 +308,38 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error {
}
}
+func handleChatsArchive(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+ if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 {
+ cmd.Set("chat-id", unusedArgs[0])
+ unusedArgs = unusedArgs[1:]
+ }
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ params := beeperdesktopapi.ChatArchiveParams{}
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ ApplicationJSON,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ return client.Chats.Archive(
+ ctx,
+ cmd.Value("chat-id").(string),
+ params,
+ options...,
+ )
+}
+
func handleChatsSearch(ctx context.Context, cmd *cli.Command) error {
client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
unusedArgs := cmd.Args().Slice()
diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go
index 9ae6343..baabef5 100644
--- a/pkg/cmd/chat_test.go
+++ b/pkg/cmd/chat_test.go
@@ -40,6 +40,15 @@ func TestChatsList(t *testing.T) {
)
}
+func TestChatsArchive(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats", "archive",
+ "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
+ "--archived=true",
+ )
+}
+
func TestChatsSearch(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go
new file mode 100644
index 0000000..5f288e1
--- /dev/null
+++ b/pkg/cmd/chatreminder.go
@@ -0,0 +1,119 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
+ "github.com/beeper/desktop-api-go"
+ "github.com/urfave/cli/v3"
+)
+
+var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{
+ Name: "create",
+ Usage: "Set a reminder for a chat at a specific time",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "chat-id",
+ Usage: "Unique identifier of the chat.",
+ Required: true,
+ },
+ &requestflag.Flag[map[string]any]{
+ Name: "reminder",
+ Usage: "Reminder configuration",
+ Required: true,
+ BodyPath: "reminder",
+ },
+ },
+ Action: handleChatsRemindersCreate,
+ HideHelpCommand: true,
+}, map[string][]requestflag.HasOuterFlag{
+ "reminder": {
+ &requestflag.InnerFlag[float64]{
+ Name: "reminder.remind-at-ms",
+ Usage: "Unix timestamp in milliseconds when reminder should trigger",
+ InnerField: "remindAtMs",
+ },
+ &requestflag.InnerFlag[bool]{
+ Name: "reminder.dismiss-on-incoming-message",
+ Usage: "Cancel reminder if someone messages in the chat",
+ InnerField: "dismissOnIncomingMessage",
+ },
+ },
+})
+
+var chatsRemindersDelete = cli.Command{
+ Name: "delete",
+ Usage: "Clear an existing reminder from a chat",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "chat-id",
+ Usage: "Unique identifier of the chat.",
+ Required: true,
+ },
+ },
+ Action: handleChatsRemindersDelete,
+ HideHelpCommand: true,
+}
+
+func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+ if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 {
+ cmd.Set("chat-id", unusedArgs[0])
+ unusedArgs = unusedArgs[1:]
+ }
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ params := beeperdesktopapi.ChatReminderNewParams{}
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ ApplicationJSON,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ return client.Chats.Reminders.New(
+ ctx,
+ cmd.Value("chat-id").(string),
+ params,
+ options...,
+ )
+}
+
+func handleChatsRemindersDelete(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+ if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 {
+ cmd.Set("chat-id", unusedArgs[0])
+ unusedArgs = unusedArgs[1:]
+ }
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ EmptyBody,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ return client.Chats.Reminders.Delete(ctx, cmd.Value("chat-id").(string), options...)
+}
diff --git a/pkg/cmd/chatreminder_test.go b/pkg/cmd/chatreminder_test.go
new file mode 100644
index 0000000..88b17c0
--- /dev/null
+++ b/pkg/cmd/chatreminder_test.go
@@ -0,0 +1,39 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package cmd
+
+import (
+ "testing"
+
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
+)
+
+func TestChatsRemindersCreate(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats:reminders", "create",
+ "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
+ "--reminder", "{remindAtMs: 0, dismissOnIncomingMessage: true}",
+ )
+
+ // Check that inner flags have been set up correctly
+ requestflag.CheckInnerFlags(chatsRemindersCreate)
+
+ // Alternative argument passing style using inner flags
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats:reminders", "create",
+ "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
+ "--reminder.remind-at-ms", "0",
+ "--reminder.dismiss-on-incoming-message=true",
+ )
+}
+
+func TestChatsRemindersDelete(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats:reminders", "delete",
+ "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
+ )
+}
diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go
index b1e46a0..36f2d2e 100644
--- a/pkg/cmd/cmd.go
+++ b/pkg/cmd/cmd.go
@@ -94,9 +94,19 @@ func init() {
&chatsCreate,
&chatsRetrieve,
&chatsList,
+ &chatsArchive,
&chatsSearch,
},
},
+ {
+ Name: "chats:reminders",
+ Category: "API RESOURCE",
+ Suggest: true,
+ Commands: []*cli.Command{
+ &chatsRemindersCreate,
+ &chatsRemindersDelete,
+ },
+ },
{
Name: "messages",
Category: "API RESOURCE",
From 06bc1c7a0ba890d76e2c210476ad1d7586cd069a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:22:20 +0000
Subject: [PATCH 06/32] fix: use RawJSON for iterated values instead of
re-marshalling
---
pkg/cmd/cmdutil.go | 30 ++++++++++++++++++++++--------
1 file changed, 22 insertions(+), 8 deletions(-)
diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go
index 66142a4..b3a5d8d 100644
--- a/pkg/cmd/cmdutil.go
+++ b/pkg/cmd/cmdutil.go
@@ -239,6 +239,10 @@ func countTerminalLines(data []byte, terminalWidth int) int {
return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n"))
}
+type HasRawJSON interface {
+ RawJSON() string
+}
+
// For an iterator over different value types, display its values to the user in
// different formats.
func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string) error {
@@ -257,11 +261,16 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat
numberOfNewlines := 0
for iter.Next() {
item := iter.Current()
- jsonData, err := json.Marshal(item)
- if err != nil {
- return err
+ var obj gjson.Result
+ if hasRaw, ok := any(item).(HasRawJSON); ok {
+ obj = gjson.Parse(hasRaw.RawJSON())
+ } else {
+ jsonData, err := json.Marshal(item)
+ if err != nil {
+ return err
+ }
+ obj = gjson.ParseBytes(jsonData)
}
- obj := gjson.ParseBytes(jsonData)
json, err := formatJSON(stdout, title, obj, format, transform)
if err != nil {
return err
@@ -295,11 +304,16 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat
for iter.Next() {
item := iter.Current()
- jsonData, err := json.Marshal(item)
- if err != nil {
- return err
+ var obj gjson.Result
+ if hasRaw, ok := any(item).(HasRawJSON); ok {
+ obj = gjson.Parse(hasRaw.RawJSON())
+ } else {
+ jsonData, err := json.Marshal(item)
+ if err != nil {
+ return err
+ }
+ obj = gjson.ParseBytes(jsonData)
}
- obj := gjson.ParseBytes(jsonData)
if err := ShowJSON(pager, title, obj, format, transform); err != nil {
return err
}
From 5f105117110982a972554fb9ab720b354829bae4 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 29 Jan 2026 04:24:07 +0000
Subject: [PATCH 07/32] fix: fix for nullable arguments
---
pkg/cmd/chat.go | 6 +++---
pkg/cmd/message.go | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index 142007f..992eb72 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -63,7 +63,7 @@ var chatsRetrieve = cli.Command{
Usage: "Unique identifier of the chat.",
Required: true,
},
- &requestflag.Flag[int64]{
+ &requestflag.Flag[any]{
Name: "max-participant-count",
Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to all (-1).",
Default: -1,
@@ -145,7 +145,7 @@ var chatsSearch = cli.Command{
Usage: `Filter by inbox type: "primary" (non-archived, non-low-priority), "low-priority", or "archive". If not specified, shows all chats.`,
QueryPath: "inbox",
},
- &requestflag.Flag[bool]{
+ &requestflag.Flag[any]{
Name: "include-muted",
Usage: "Include chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search.",
Default: true,
@@ -184,7 +184,7 @@ var chatsSearch = cli.Command{
Default: "any",
QueryPath: "type",
},
- &requestflag.Flag[bool]{
+ &requestflag.Flag[any]{
Name: "unread-only",
Usage: "Set to true to only retrieve chats that have unread messages",
QueryPath: "unreadOnly",
diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go
index 7cbf8ec..66a2465 100644
--- a/pkg/cmd/message.go
+++ b/pkg/cmd/message.go
@@ -105,13 +105,13 @@ var messagesSearch = cli.Command{
Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.",
QueryPath: "direction",
},
- &requestflag.Flag[bool]{
+ &requestflag.Flag[any]{
Name: "exclude-low-priority",
Usage: "Exclude messages marked Low Priority by the user. Default: true. Set to false to include all.",
Default: true,
QueryPath: "excludeLowPriority",
},
- &requestflag.Flag[bool]{
+ &requestflag.Flag[any]{
Name: "include-muted",
Usage: "Include messages in chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search.",
Default: true,
From f2bddcf00a9a3faacf1a1a8293f3f46b7befe187 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 30 Jan 2026 04:01:13 +0000
Subject: [PATCH 08/32] chore: add build step to ci
---
.github/workflows/ci.yml | 42 +++++++++++++++++++++++++
scripts/utils/upload-artifact.sh | 53 ++++++++++++++++++++++++++++++++
2 files changed, 95 insertions(+)
create mode 100755 scripts/utils/upload-artifact.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 05ef7f0..e8dc5d8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,6 +33,48 @@ jobs:
- name: Run lints
run: ./scripts/lint
+ build:
+ timeout-minutes: 10
+ name: build
+ permissions:
+ contents: read
+ id-token: write
+ runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: ./go.mod
+
+ - name: Bootstrap
+ run: ./scripts/bootstrap
+
+ - name: Run goreleaser
+ uses: goreleaser/goreleaser-action@v6.1.0
+ with:
+ version: latest
+ args: release --snapshot --clean --skip=publish
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Get GitHub OIDC Token
+ if: github.repository == 'stainless-sdks/beeper-desktop-api-cli'
+ id: github-oidc
+ uses: actions/github-script@v8
+ with:
+ script: core.setOutput('github_token', await core.getIDToken());
+
+ - name: Upload tarball
+ if: github.repository == 'stainless-sdks/beeper-desktop-api-cli'
+ env:
+ URL: https://pkg.stainless.com/s
+ AUTH: ${{ steps.github-oidc.outputs.github_token }}
+ SHA: ${{ github.sha }}
+ run: ./scripts/utils/upload-artifact.sh
+
test:
timeout-minutes: 10
name: test
diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh
new file mode 100755
index 0000000..6678188
--- /dev/null
+++ b/scripts/utils/upload-artifact.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -exuo pipefail
+
+BINARY_NAME="beeper-desktop-api"
+DIST_DIR="dist"
+FILENAME="dist.zip"
+
+mapfile -d '' files < <(
+ find "$DIST_DIR" -regextype posix-extended -type f \
+ -regex ".*/[^/]*(amd64|arm64)[^/]*/${BINARY_NAME}(\\.exe)?$" -print0
+)
+
+if [[ ${#files[@]} -eq 0 ]]; then
+ echo -e "\033[31mNo binaries found for packaging.\033[0m"
+ exit 1
+fi
+
+rm -f "${DIST_DIR}/${FILENAME}"
+
+while IFS= read -r -d '' dir; do
+ printf "Remove the quarantine attribute before running the executable:\n\nxattr -d com.apple.quarantine %s\n" \
+ "$BINARY_NAME" >"${dir}/README.txt"
+done < <(find "$DIST_DIR" -type d -name '*macos*' -print0)
+
+relative_files=()
+for file in "${files[@]}"; do
+ relative_files+=("${file#"${DIST_DIR}"/}")
+done
+
+(cd "$DIST_DIR" && zip -r "$FILENAME" "${relative_files[@]}")
+
+RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \
+ -H "Authorization: Bearer $AUTH" \
+ -H "Content-Type: application/json")
+
+SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url')
+
+if [[ "$SIGNED_URL" == "null" ]]; then
+ echo -e "\033[31mFailed to get signed URL.\033[0m"
+ exit 1
+fi
+
+UPLOAD_RESPONSE=$(curl -v -X PUT \
+ -H "Content-Type: application/zip" \
+ --data-binary "@${DIST_DIR}/${FILENAME}" "$SIGNED_URL" 2>&1)
+
+if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then
+ echo -e "\033[32mUploaded build to Stainless storage.\033[0m"
+ echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/beeper-desktop-api-cli/$SHA/$FILENAME'. On macOS, run `xattr -d com.apple.quarantine {executable name}.`\033[0m"
+else
+ echo -e "\033[31mFailed to upload artifact.\033[0m"
+ exit 1
+fi
From 5633fad79b0a5db1d66b83a58d1d0e8fe7cf3f1d Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 30 Jan 2026 04:05:52 +0000
Subject: [PATCH 09/32] chore: update documentation in readme
---
README.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/README.md b/README.md
index 806c60d..a1168bd 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,31 @@ The official CLI for the [Beeper Desktop REST API](https://developers.beeper.com
### Installing with Go
+To test or install the CLI locally, you need [Go](https://go.dev/doc/install) version 1.22 or later installed.
+
```sh
go install 'github.com/beeper/desktop-api-cli/cmd/beeper-desktop-api@latest'
```
+Once you have run `go install`, the binary is placed in your Go bin directory:
+
+- **Default location**: `$HOME/go/bin` (or `$GOPATH/bin` if GOPATH is set)
+- **Check your path**: Run `go env GOPATH` to see the base directory
+
+If commands aren't found after installation, add the Go bin directory to your PATH:
+
+```sh
+# Add to your shell profile (.zshrc, .bashrc, etc.)
+export PATH="$PATH:$(go env GOPATH)/bin"
+```
+
### Running Locally
+After cloning the git repository for this project, you can use the
+`scripts/run` script to run the tool locally:
+
```sh
./scripts/run args...
```
From f26b475dce7f9eb0cc9fa5a20c26667e1c32fc1a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 30 Jan 2026 04:07:29 +0000
Subject: [PATCH 10/32] fix: fix for file uploads to octet stream and form
encoding endpoints
---
pkg/cmd/flagoptions.go | 138 +++++++++++++++++++++++-------------
pkg/cmd/flagoptions_test.go | 46 +++++++-----
2 files changed, 117 insertions(+), 67 deletions(-)
diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go
index 57a53da..a09ffb6 100644
--- a/pkg/cmd/flagoptions.go
+++ b/pkg/cmd/flagoptions.go
@@ -1,6 +1,7 @@
package cmd
import (
+ "bufio"
"bytes"
"encoding/base64"
"encoding/json"
@@ -33,9 +34,16 @@ const (
ApplicationOctetStream
)
-func embedFiles(obj any) (any, error) {
+type FileEmbedStyle int
+
+const (
+ EmbedText FileEmbedStyle = iota
+ EmbedIOReader
+)
+
+func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) {
v := reflect.ValueOf(obj)
- result, err := embedFilesValue(v)
+ result, err := embedFilesValue(v, embedStyle)
if err != nil {
return nil, err
}
@@ -43,7 +51,7 @@ func embedFiles(obj any) (any, error) {
}
// Replace "@file.txt" with the file's contents inside a value
-func embedFilesValue(v reflect.Value) (reflect.Value, error) {
+func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) {
// Unwrap interface values to get the concrete type
if v.Kind() == reflect.Interface {
if v.IsNil() {
@@ -57,12 +65,14 @@ func embedFilesValue(v reflect.Value) (reflect.Value, error) {
if v.Len() == 0 {
return v, nil
}
- result := reflect.MakeMap(v.Type())
+ // Always create map[string]any to handle potential type changes when embedding files
+ result := reflect.MakeMap(reflect.TypeOf(map[string]any{}))
+
iter := v.MapRange()
for iter.Next() {
key := iter.Key()
val := iter.Value()
- newVal, err := embedFilesValue(val)
+ newVal, err := embedFilesValue(val, embedStyle)
if err != nil {
return reflect.Value{}, err
}
@@ -74,9 +84,10 @@ func embedFilesValue(v reflect.Value) (reflect.Value, error) {
if v.Len() == 0 {
return v, nil
}
- result := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
+ // Use `[]any` to allow for types to change when embedding files
+ result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
- newVal, err := embedFilesValue(v.Index(i))
+ newVal, err := embedFilesValue(v.Index(i), embedStyle)
if err != nil {
return reflect.Value{}, err
}
@@ -86,51 +97,78 @@ func embedFilesValue(v reflect.Value) (reflect.Value, error) {
case reflect.String:
s := v.String()
-
if literal, ok := strings.CutPrefix(s, "\\@"); ok {
// Allow for escaped @ signs if you don't want them to be treated as files
return reflect.ValueOf("@" + literal), nil
- } else if filename, ok := strings.CutPrefix(s, "@data://"); ok {
- // The "@data://" prefix is for files you explicitly want to upload
- // as base64-encoded (even if the file itself is plain text)
- content, err := os.ReadFile(filename)
- if err != nil {
- return v, err
- }
- return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
- } else if filename, ok := strings.CutPrefix(s, "@file://"); ok {
- // The "@file://" prefix is for files that you explicitly want to
- // upload as a string literal with backslash escapes (not base64
- // encoded)
- content, err := os.ReadFile(filename)
- if err != nil {
- return v, err
- }
- return reflect.ValueOf(string(content)), nil
- } else if filename, ok := strings.CutPrefix(s, "@"); ok {
- content, err := os.ReadFile(filename)
- if err != nil {
- // If the string is "@username", it's probably supposed to be a
- // string literal and not a file reference. However, if the
- // string looks like "@file.txt" or "@/tmp/file", then it's
- // probably supposed to be a file.
- probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/")
- if probablyFile {
- // Give a useful error message if the user tried to upload a
- // file, but the file couldn't be read (e.g. mistyped
- // filename or permission error)
+ }
+
+ if embedStyle == EmbedText {
+ if filename, ok := strings.CutPrefix(s, "@data://"); ok {
+ // The "@data://" prefix is for files you explicitly want to upload
+ // as base64-encoded (even if the file itself is plain text)
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ return v, err
+ }
+ return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
+ } else if filename, ok := strings.CutPrefix(s, "@file://"); ok {
+ // The "@file://" prefix is for files that you explicitly want to
+ // upload as a string literal with backslash escapes (not base64
+ // encoded)
+ content, err := os.ReadFile(filename)
+ if err != nil {
return v, err
}
- // Fall back to the raw value if the user provided something
- // like "@username" that's not intended to be a file.
- return v, nil
- }
- // If the file looks like a plain text UTF8 file format, then use the contents directly.
- if isUTF8TextFile(content) {
return reflect.ValueOf(string(content)), nil
+ } else if filename, ok := strings.CutPrefix(s, "@"); ok {
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ // If the string is "@username", it's probably supposed to be a
+ // string literal and not a file reference. However, if the
+ // string looks like "@file.txt" or "@/tmp/file", then it's
+ // probably supposed to be a file.
+ probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/")
+ if probablyFile {
+ // Give a useful error message if the user tried to upload a
+ // file, but the file couldn't be read (e.g. mistyped
+ // filename or permission error)
+ return v, err
+ }
+ // Fall back to the raw value if the user provided something
+ // like "@username" that's not intended to be a file.
+ return v, nil
+ }
+ // If the file looks like a plain text UTF8 file format, then use the contents directly.
+ if isUTF8TextFile(content) {
+ return reflect.ValueOf(string(content)), nil
+ }
+ // Otherwise it's a binary file, so encode it with base64
+ return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
+ }
+ } else {
+ if filename, ok := strings.CutPrefix(s, "@"); ok {
+ // Behavior is the same for @file, @data://file, and @file://file, except that
+ // @username will be treated as a literal string if no "username" file exists
+ expectsFile := true
+ if withoutPrefix, ok := strings.CutPrefix(filename, "data://"); ok {
+ filename = withoutPrefix
+ } else if withoutPrefix, ok := strings.CutPrefix(filename, "file://"); ok {
+ filename = withoutPrefix
+ } else {
+ expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/")
+ }
+
+ file, err := os.Open(filename)
+ if err != nil {
+ if !expectsFile {
+ // For strings that start with "@" and don't look like a filename, return the string
+ return v, nil
+ }
+ return v, err
+ }
+ reader := bufio.NewReader(file)
+ return reflect.ValueOf(reader), nil
}
- // Otherwise it's a binary file, so encode it with base64
- return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
}
return v, nil
@@ -205,16 +243,20 @@ func flagOptions(
}
// Embed files passed as "@file.jpg" in the request body, headers, and query:
- bodyData, err := embedFiles(bodyData)
+ embedStyle := EmbedText
+ if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded {
+ embedStyle = EmbedIOReader
+ }
+ bodyData, err := embedFiles(bodyData, embedStyle)
if err != nil {
return nil, err
}
- if headersWithFiles, err := embedFiles(flagContents.Headers); err != nil {
+ if headersWithFiles, err := embedFiles(flagContents.Headers, EmbedText); err != nil {
return nil, err
} else {
flagContents.Headers = headersWithFiles.(map[string]any)
}
- if queriesWithFiles, err := embedFiles(flagContents.Queries); err != nil {
+ if queriesWithFiles, err := embedFiles(flagContents.Queries, EmbedText); err != nil {
return nil, err
} else {
flagContents.Queries = queriesWithFiles.(map[string]any)
diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go
index 2db8aa3..e5dad4b 100644
--- a/pkg/cmd/flagoptions_test.go
+++ b/pkg/cmd/flagoptions_test.go
@@ -68,11 +68,11 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "map[string]string with file references",
- input: map[string]string{
+ input: map[string]any{
"config": "@" + filepath.Join(tmpDir, "config.txt"),
"name": "test",
},
- want: map[string]string{
+ want: map[string]any{
"config": configContent,
"name": "test",
},
@@ -96,11 +96,11 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "[]string with file references",
- input: []string{
+ input: []any{
"@" + filepath.Join(tmpDir, "config.txt"),
"normal string",
},
- want: []string{
+ want: []any{
configContent,
"normal string",
},
@@ -112,7 +112,7 @@ func TestEmbedFiles(t *testing.T) {
"outer": map[string]any{
"inner": []any{
"@" + filepath.Join(tmpDir, "config.txt"),
- map[string]string{
+ map[string]any{
"data": "@" + filepath.Join(tmpDir, "data.json"),
},
},
@@ -122,7 +122,7 @@ func TestEmbedFiles(t *testing.T) {
"outer": map[string]any{
"inner": []any{
configContent,
- map[string]string{
+ map[string]any{
"data": dataContent,
},
},
@@ -132,11 +132,11 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "base64 encoding",
- input: map[string]string{
+ input: map[string]any{
"encoded": "@data://" + filepath.Join(tmpDir, "config.txt"),
"image": "@" + filepath.Join(tmpDir, "image.jpg"),
},
- want: map[string]string{
+ want: map[string]any{
"encoded": base64.StdEncoding.EncodeToString([]byte(configContent)),
"image": base64.StdEncoding.EncodeToString(jpegHeader),
},
@@ -144,7 +144,7 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "non-existent file with @ prefix",
- input: map[string]string{
+ input: map[string]any{
"missing": "@file.txt",
},
want: nil,
@@ -152,11 +152,11 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "non-file-like thing with @ prefix",
- input: map[string]string{
+ input: map[string]any{
"username": "@user",
"favorite_symbol": "@",
},
- want: map[string]string{
+ want: map[string]any{
"username": "@user",
"favorite_symbol": "@",
},
@@ -164,7 +164,7 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "non-existent file with @file:// prefix (error)",
- input: map[string]string{
+ input: map[string]any{
"missing": "@file:///nonexistent/file.txt",
},
want: nil,
@@ -172,13 +172,13 @@ func TestEmbedFiles(t *testing.T) {
},
{
name: "escaping",
- input: map[string]string{
+ input: map[string]any{
"simple": "\\@file.txt",
"file": "\\@file://file.txt",
"data": "\\@data://file.txt",
"keep_escape": "user\\@example.com",
},
- want: map[string]string{
+ want: map[string]any{
"simple": "@file.txt",
"file": "@file://file.txt",
"data": "@data://file.txt",
@@ -207,17 +207,16 @@ func TestEmbedFiles(t *testing.T) {
wantErr: false,
},
{
- name: "[]int unchanged",
+ name: "[]int values unchanged",
input: []int{1, 2, 3, 4, 5},
- want: []int{1, 2, 3, 4, 5},
+ want: []any{1, 2, 3, 4, 5},
wantErr: false,
},
}
for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := embedFiles(tt.input)
-
+ t.Run(tt.name+" text", func(t *testing.T) {
+ got, err := embedFiles(tt.input, EmbedText)
if tt.wantErr {
assert.Error(t, err)
} else {
@@ -225,6 +224,15 @@ func TestEmbedFiles(t *testing.T) {
assert.Equal(t, tt.want, got)
}
})
+
+ t.Run(tt.name+" io.Reader", func(t *testing.T) {
+ _, err := embedFiles(tt.input, EmbedIOReader)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
}
}
From f7b1b4af1c7220c9cd21afc58aba32508504073b Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 30 Jan 2026 04:08:12 +0000
Subject: [PATCH 11/32] feat: add readme documentation for passing files as
arguments
---
README.md | 44 ++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 42 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index a1168bd..32defd7 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ After cloning the git repository for this project, you can use the
The CLI follows a resource-based command structure:
```sh
-beeper-desktop-api [resource] [command] [flags]
+beeper-desktop-api [resource] [flags...]
```
```sh
@@ -64,7 +64,7 @@ beeper-desktop-api chats search \
For details about specific commands, use the `--help` flag.
-## Global Flags
+### Global Flags
- `--help` - Show command line usage
- `--debug` - Enable debug logging (includes HTTP request/response details)
@@ -74,3 +74,43 @@ For details about specific commands, use the `--help` flag.
- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`)
- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)
- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)
+
+### Passing files as arguments
+
+To pass files to your API, you can use the `@myfile.ext` syntax:
+
+```bash
+beeper-desktop-api --arg @abe.jpg
+```
+
+Files can also be passed inside JSON or YAML blobs:
+
+```bash
+beeper-desktop-api --arg '{image: "@abe.jpg"}'
+# Equivalent:
+beeper-desktop-api < --username '\@abe'
+```
+
+#### Explicit encoding
+
+For JSON endpoints, the CLI tool does filetype sniffing to determine whether the
+file contents should be sent as a string literal (for plain text files) or as a
+base64-encoded string literal (for binary files). If you need to explicitly send
+the file as either plain text or base64-encoded data, you can use
+`@file://myfile.txt` (for string encoding) or `@data://myfile.dat` (for
+base64-encoding). Note that absolute paths will begin with `@file://` or
+`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`).
+
+```bash
+beeper-desktop-api --arg @data://file.txt
+```
From b66f2b5c68eb90c5644faf0c1f2fc67a94f100cb Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 31 Jan 2026 03:42:35 +0000
Subject: [PATCH 12/32] feat(api): manual updates
---
.stats.yml | 8 ++++----
pkg/cmd/asset.go | 40 ++++++++++++++++++++++++++++++++++++++++
pkg/cmd/asset_test.go | 8 ++++++++
pkg/cmd/chat.go | 12 +++++++++++-
pkg/cmd/chatreminder.go | 27 +++++++++++++++++++++++++--
pkg/cmd/cmd.go | 1 +
6 files changed, 89 insertions(+), 7 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index d0840cd..5cfb00e 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 18
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml
-openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86
-config_hash: 196c1c81b169ede101a71d1cf2796d99
+configured_endpoints: 19
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-099d55ac0e749a64dacc1706d7d8276d1acbe52103f0419393c39e8911966cfe.yml
+openapi_spec_hash: 70a1b1d513b62c6d6caabbbf360220b4
+config_hash: 48ff2d23c2ebc82bd3c15787f0041684
diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go
index 7dd4e25..d7fc779 100644
--- a/pkg/cmd/asset.go
+++ b/pkg/cmd/asset.go
@@ -31,6 +31,22 @@ var assetsDownload = cli.Command{
HideHelpCommand: true,
}
+var assetsServe = cli.Command{
+ Name: "serve",
+ Usage: "Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if\nnot cached. Supports Range requests for seeking in large files.",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "url",
+ Usage: "Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs.",
+ Required: true,
+ QueryPath: "url",
+ },
+ },
+ Action: handleAssetsServe,
+ HideHelpCommand: true,
+}
+
var assetsUpload = cli.Command{
Name: "upload",
Usage: "Upload a file to a temporary location using multipart/form-data. Returns an\nuploadID that can be referenced when sending messages with attachments.",
@@ -117,6 +133,30 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error {
return ShowJSON(os.Stdout, "assets download", obj, format, transform)
}
+func handleAssetsServe(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ params := beeperdesktopapi.AssetServeParams{}
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ EmptyBody,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ return client.Assets.Serve(ctx, params, options...)
+}
+
func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error {
client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
unusedArgs := cmd.Args().Slice()
diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go
index 8802d8d..036f0ec 100644
--- a/pkg/cmd/asset_test.go
+++ b/pkg/cmd/asset_test.go
@@ -16,6 +16,14 @@ func TestAssetsDownload(t *testing.T) {
)
}
+func TestAssetsServe(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "assets", "serve",
+ "--url", "x",
+ )
+}
+
func TestAssetsUpload(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index 992eb72..508f0f9 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -332,12 +332,22 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error {
return err
}
- return client.Chats.Archive(
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Chats.Archive(
ctx,
cmd.Value("chat-id").(string),
params,
options...,
)
+ if err != nil {
+ return err
+ }
+
+ obj := gjson.ParseBytes(res)
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ return ShowJSON(os.Stdout, "chats archive", obj, format, transform)
}
func handleChatsSearch(ctx context.Context, cmd *cli.Command) error {
diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go
index 5f288e1..2361908 100644
--- a/pkg/cmd/chatreminder.go
+++ b/pkg/cmd/chatreminder.go
@@ -5,10 +5,13 @@ package cmd
import (
"context"
"fmt"
+ "os"
"github.com/beeper/desktop-api-cli/internal/apiquery"
"github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
+ "github.com/beeper/desktop-api-go/option"
+ "github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
@@ -85,12 +88,22 @@ func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error {
return err
}
- return client.Chats.Reminders.New(
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Chats.Reminders.New(
ctx,
cmd.Value("chat-id").(string),
params,
options...,
)
+ if err != nil {
+ return err
+ }
+
+ obj := gjson.ParseBytes(res)
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ return ShowJSON(os.Stdout, "chats:reminders create", obj, format, transform)
}
func handleChatsRemindersDelete(ctx context.Context, cmd *cli.Command) error {
@@ -115,5 +128,15 @@ func handleChatsRemindersDelete(ctx context.Context, cmd *cli.Command) error {
return err
}
- return client.Chats.Reminders.Delete(ctx, cmd.Value("chat-id").(string), options...)
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Chats.Reminders.Delete(ctx, cmd.Value("chat-id").(string), options...)
+ if err != nil {
+ return err
+ }
+
+ obj := gjson.ParseBytes(res)
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ return ShowJSON(os.Stdout, "chats:reminders delete", obj, format, transform)
}
diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go
index 36f2d2e..f3f3233 100644
--- a/pkg/cmd/cmd.go
+++ b/pkg/cmd/cmd.go
@@ -124,6 +124,7 @@ func init() {
Suggest: true,
Commands: []*cli.Command{
&assetsDownload,
+ &assetsServe,
&assetsUpload,
&assetsUploadBase64,
},
From bdf34cecc8cdbd2e9d19dade4616970bfd43ae6a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Feb 2026 04:37:51 +0000
Subject: [PATCH 13/32] feat(client): provide file completions when using file
embed syntax
---
.../shellscripts/bash_autocomplete.bash | 48 ++++++++--
.../shellscripts/fish_autocomplete.fish | 46 +++++++---
.../shellscripts/pwsh_autocomplete.ps1 | 87 +++++++++++++++----
.../shellscripts/zsh_autocomplete.zsh | 18 ++++
4 files changed, 163 insertions(+), 36 deletions(-)
diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash
index 64fa6ab..8fb7b0b 100755
--- a/internal/autocomplete/shellscripts/bash_autocomplete.bash
+++ b/internal/autocomplete/shellscripts/bash_autocomplete.bash
@@ -9,11 +9,49 @@ ____APPNAME___bash_autocomplete() {
completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null)
exit_code=$?
- case $exit_code in
- 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file
- 11) COMPREPLY=() ;; # no completion
- 0) mapfile -t COMPREPLY <<< "$completions" ;; # use returned completions
- esac
+ local last_token="$cur"
+
+ # If the last token has been split apart by a ':', join it back together.
+ # Ex: 'a:b' will be represented in COMP_WORDS as 'a', ':', 'b'
+ if [[ $COMP_CWORD -ge 2 ]]; then
+ local prev2="${COMP_WORDS[COMP_CWORD - 2]}"
+ local prev1="${COMP_WORDS[COMP_CWORD - 1]}"
+ if [[ "$prev2" =~ ^@(file|data)$ && "$prev1" == ":" && "$cur" =~ ^// ]]; then
+ last_token="$prev2:$cur"
+ fi
+ fi
+
+ # Check for custom file completion patterns
+ local prefix=""
+ local file_part="$cur"
+ local force_file_completion=false
+ if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then
+ local before_at="${BASH_REMATCH[1]}"
+ local protocol="${BASH_REMATCH[2]}"
+ file_part="${BASH_REMATCH[3]}"
+
+ if [[ "$protocol" == "" ]]; then
+ prefix="$before_at@"
+ else
+ if [[ "$before_at" == "" ]]; then
+ prefix="//"
+ else
+ prefix="$before_at@$protocol"
+ fi
+ fi
+
+ force_file_completion=true
+ fi
+
+ if [[ "$force_file_completion" == true ]]; then
+ mapfile -t COMPREPLY < <(compgen -f -- "$file_part" | sed "s|^|$prefix|")
+ else
+ case $exit_code in
+ 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion
+ 11) COMPREPLY=() ;; # no completion
+ 0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions
+ esac
+ fi
return 0
fi
}
diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish
index 0164b04..b853057 100644
--- a/internal/autocomplete/shellscripts/fish_autocomplete.fish
+++ b/internal/autocomplete/shellscripts/fish_autocomplete.fish
@@ -10,18 +10,40 @@ function ____APPNAME___fish_autocomplete
set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log)
set -l exit_code $status
- switch $exit_code
- case 10
- # File completion
- __fish_complete_path "$current"
- case 11
- # No completion
- return 0
- case 0
- # Use returned completions
- for completion in $completions
- echo $completion
- end
+ # Check for custom file completion patterns
+ # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path')
+ set -l prefix ""
+ set -l file_part "$current"
+ set -l force_file_completion 0
+
+ if string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current
+ if string match -qr '^[\'"]' -- $before
+ # Ensures we don't insert an extra quote when the user is building an argument in quotes
+ set before (string sub -s 2 -- $before)
+ end
+
+ set prefix "$before@$protocol"
+ set force_file_completion 1
+ end
+
+ if test $force_file_completion -eq 1
+ for path in (__fish_complete_path "$file_part")
+ echo $prefix$path
+ end
+ else
+ switch $exit_code
+ case 10
+ # File completion
+ __fish_complete_path "$current"
+ case 11
+ # No completion
+ return 0
+ case 0
+ # Use returned completions
+ for completion in $completions
+ echo $completion
+ end
+ end
end
end
diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1
index f712e13..7cd6e62 100644
--- a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1
+++ b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1
@@ -21,27 +21,76 @@ Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock {
}
$exitCode = $LASTEXITCODE
- switch ($exitCode) {
- 10 {
- # File completion behavior
- Get-ChildItem -Path "$wordToComplete*" | ForEach-Object {
- $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name }
- [System.Management.Automation.CompletionResult]::new(
- $completionText,
- $completionText,
- 'ProviderItem',
- $completionText
- )
- }
+ # Check for custom file completion patterns
+ # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path')
+ $prefix = ""
+ $filePart = $wordToComplete
+ $forceFileCompletion = $false
+
+ # PowerShell includes quotes in $wordToComplete - strip them for pattern matching
+ # but preserve them in the prefix for the completion result
+ $wordContent = $wordToComplete
+ $leadingQuote = ""
+ if ($wordToComplete -match '^([''"])(.*)(\1)$') {
+ # Fully quoted: "content" or 'content'
+ $leadingQuote = $Matches[1]
+ $wordContent = $Matches[2]
+ } elseif ($wordToComplete -match '^([''"])(.*)$') {
+ # Opening quote only: "content or 'content
+ $leadingQuote = $Matches[1]
+ $wordContent = $Matches[2]
+ }
+
+ if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') {
+ $prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2]
+ $filePart = $Matches[3]
+ $forceFileCompletion = $true
+ }
+
+ if ($forceFileCompletion) {
+ # Handle empty filePart (e.g., "@" or "@file://") by listing current directory
+ $items = if ([string]::IsNullOrEmpty($filePart)) {
+ Get-ChildItem -ErrorAction SilentlyContinue
+ } else {
+ Get-ChildItem -Path "$filePart*" -ErrorAction SilentlyContinue
}
- 11 {
- # No reasonable suggestions
- [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ')
+ $items | ForEach-Object {
+ $completionText = if ($_.PSIsContainer) { $prefix + $_.Name + "/" } else { $prefix + $_.Name }
+ [System.Management.Automation.CompletionResult]::new(
+ $completionText,
+ $completionText,
+ 'ProviderItem',
+ $completionText
+ )
}
- default {
- # Default behavior - show command completions
- $output | ForEach-Object {
- [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
+ } else {
+ switch ($exitCode) {
+ 10 {
+ # File completion behavior
+ $items = if ([string]::IsNullOrEmpty($wordToComplete)) {
+ Get-ChildItem -ErrorAction SilentlyContinue
+ } else {
+ Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue
+ }
+ $items | ForEach-Object {
+ $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name }
+ [System.Management.Automation.CompletionResult]::new(
+ $completionText,
+ $completionText,
+ 'ProviderItem',
+ $completionText
+ )
+ }
+ }
+ 11 {
+ # No reasonable suggestions
+ [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ')
+ }
+ default {
+ # Default behavior - show command completions
+ $output | ForEach-Object {
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
+ }
}
}
}
diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh
index 5412987..4d4bdcd 100644
--- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh
+++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh
@@ -10,6 +10,24 @@ ____APPNAME___zsh_autocomplete() {
temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}")
exit_code=$?
+ # Check for custom file completion patterns
+ # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path')
+ local cur="${words[CURRENT]}"
+
+ if [[ "$cur" = *'@'* ]]; then
+ # Extract everything after the last @
+ local after_last_at="${cur##*@}"
+
+ if [[ $after_last_at =~ ^(file://|data://) ]]; then
+ compset -P "*$MATCH"
+ _files
+ else
+ compset -P '*@'
+ _files
+ fi
+ return
+ fi
+
case $exit_code in
10)
# File completion behavior
From 6987a4c5870caa9323a8be80124069fc5f28d45a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 5 Feb 2026 03:48:03 +0000
Subject: [PATCH 14/32] chore(internal): codegen related update
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index f86e36e..cf46cc1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/beeper/desktop-api-cli
go 1.25
require (
- github.com/beeper/desktop-api-go v0.1.0
+ github.com/beeper/desktop-api-go v0.2.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
diff --git a/go.sum b/go.sum
index 59794ef..fa83658 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
-github.com/beeper/desktop-api-go v0.1.0 h1:Cd8prDsvb2t3Bx50/aFtag3h7oiTefsvWmZwHK/2Esk=
-github.com/beeper/desktop-api-go v0.1.0/go.mod h1:r37xr4oqM7zQ3VMCRPfnfLSmNC3VgSU+AeN4banq/P0=
+github.com/beeper/desktop-api-go v0.2.0 h1:VrwB1FCEiuPycGo6TsYSVVSKQIWFg22xmlRWVJ88E0A=
+github.com/beeper/desktop-api-go v0.2.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
From eba0a3f905f7eb7bc3cd9a8f571713e5bdff1f87 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 6 Feb 2026 04:31:38 +0000
Subject: [PATCH 15/32] fix: fix for when terminal width is not available
---
pkg/cmd/cmdutil.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go
index b3a5d8d..86c83c6 100644
--- a/pkg/cmd/cmdutil.go
+++ b/pkg/cmd/cmdutil.go
@@ -252,7 +252,8 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat
terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd())
if err != nil {
- terminalHeight = 100
+ terminalWidth = 100
+ terminalHeight = 40
}
// Decide whether or not to use a pager based on whether it's a short output or a long output
From 8c8fa8743cbfd67e8ddbf165a06c151d319e2612 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 11 Feb 2026 04:18:56 +0000
Subject: [PATCH 16/32] feat: improved support for passing files for
`any`-typed arguments
---
internal/requestflag/requestflag.go | 35 ++++++++++++--
internal/requestflag/requestflag_test.go | 59 ++++++++++++++++++++++++
2 files changed, 89 insertions(+), 5 deletions(-)
diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go
index ca99ce1..519fdce 100644
--- a/internal/requestflag/requestflag.go
+++ b/internal/requestflag/requestflag.go
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"time"
+ "unicode"
"github.com/goccy/go-yaml"
"github.com/urfave/cli/v3"
@@ -359,12 +360,21 @@ func parseCLIArg[
}
default:
- var yamlValue T
- err = yaml.Unmarshal([]byte(value), &yamlValue)
- if err != nil {
- err = fmt.Errorf("failed to parse as YAML: %w", err)
+ if strings.HasPrefix(value, "@") {
+ // File literals like @file.txt should work here
+ parsedValue = value
+ } else {
+ var yamlValue T
+ err = yaml.Unmarshal([]byte(value), &yamlValue)
+ if err == nil {
+ parsedValue = yamlValue
+ } else if allowAsLiteralString(value) {
+ parsedValue = value
+ } else {
+ parsedValue = nil
+ err = fmt.Errorf("failed to parse as YAML: %w", err)
+ }
}
- parsedValue = yamlValue
}
// Nil needs to be handled specially because unmarshalling a YAML `null`
@@ -385,6 +395,21 @@ func parseCLIArg[
}
+// Assuming this string failed to parse as valid YAML, this function will
+// return true for strings that can reasonably be interpreted as a string literal,
+// like identifiers (`foo_bar`), UUIDs (`945b2f0c-8e89-487a-b02c-f851c69ea459`),
+// base64 (`aGVsbG8=`), and qualified identifiers (`color.Red`). This should
+// not include strings that look like mistyped YAML (e.g. `{key:`)
+func allowAsLiteralString(s string) bool {
+ for _, c := range s {
+ if !unicode.IsLetter(c) && !unicode.IsDigit(c) &&
+ c != '_' && c != '-' && c != '.' && c != '=' {
+ return false
+ }
+ }
+ return true
+}
+
// Parse the input string and set result as the cliValue's value
func (c *cliValue[T]) Set(value string) error {
valueType := reflect.TypeOf(c.value)
diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go
index 35ccac2..9751904 100644
--- a/internal/requestflag/requestflag_test.go
+++ b/internal/requestflag/requestflag_test.go
@@ -494,6 +494,65 @@ func TestYamlHandling(t *testing.T) {
}
})
+ t.Run("Parse @file.txt as YAML", func(t *testing.T) {
+ flag := &Flag[any]{
+ Name: "file-flag",
+ Default: nil,
+ }
+ assert.NoError(t, flag.PreParse())
+ assert.NoError(t, flag.Set("file-flag", "@file.txt"))
+
+ val := flag.Get()
+ assert.Equal(t, "@file.txt", val)
+ })
+
+ t.Run("Parse @file.txt list as YAML", func(t *testing.T) {
+ flag := &Flag[[]any]{
+ Name: "file-flag",
+ Default: nil,
+ }
+ assert.NoError(t, flag.PreParse())
+ assert.NoError(t, flag.Set("file-flag", "@file1.txt"))
+ assert.NoError(t, flag.Set("file-flag", "@file2.txt"))
+
+ val := flag.Get()
+ assert.Equal(t, []any{"@file1.txt", "@file2.txt"}, val)
+ })
+
+ t.Run("Parse identifiers as YAML", func(t *testing.T) {
+ tests := []string{
+ "hello",
+ "e4e355fa-b03b-4c57-a73d-25c9733eec79",
+ "foo_bar",
+ "Color.Red",
+ "aGVsbG8=",
+ }
+ for _, test := range tests {
+ flag := &Flag[any]{
+ Name: "flag",
+ Default: nil,
+ }
+ assert.NoError(t, flag.PreParse())
+ assert.NoError(t, flag.Set("flag", test))
+
+ val := flag.Get()
+ assert.Equal(t, test, val)
+ }
+
+ for _, test := range tests {
+ flag := &Flag[[]any]{
+ Name: "identifier",
+ Default: nil,
+ }
+ assert.NoError(t, flag.PreParse())
+ assert.NoError(t, flag.Set("identifier", test))
+ assert.NoError(t, flag.Set("identifier", test))
+
+ val := flag.Get()
+ assert.Equal(t, []any{test, test}, val)
+ }
+ })
+
// Test with invalid YAML
t.Run("Parse invalid YAML", func(t *testing.T) {
invalidYaml := `[not closed`
From c230cefdf540e6d49e102cbb9cb9010625be04c6 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 11 Feb 2026 04:28:40 +0000
Subject: [PATCH 17/32] fix: preserve filename in content-disposition for file
uploads
---
pkg/cmd/flagoptions.go | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go
index a09ffb6..b7c4aef 100644
--- a/pkg/cmd/flagoptions.go
+++ b/pkg/cmd/flagoptions.go
@@ -1,7 +1,6 @@
package cmd
import (
- "bufio"
"bytes"
"encoding/base64"
"encoding/json"
@@ -166,8 +165,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value,
}
return v, err
}
- reader := bufio.NewReader(file)
- return reflect.ValueOf(reader), nil
+ return reflect.ValueOf(file), nil
}
}
return v, nil
From 46e5aefac484dd40ad069ea5500dd2c885abf611 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 12 Feb 2026 04:31:38 +0000
Subject: [PATCH 18/32] chore(internal): codegen related update
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index cf46cc1..ead32f6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/beeper/desktop-api-cli
go 1.25
require (
- github.com/beeper/desktop-api-go v0.2.0
+ github.com/beeper/desktop-api-go v0.2.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
diff --git a/go.sum b/go.sum
index fa83658..75c54d9 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
-github.com/beeper/desktop-api-go v0.2.0 h1:VrwB1FCEiuPycGo6TsYSVVSKQIWFg22xmlRWVJ88E0A=
-github.com/beeper/desktop-api-go v0.2.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
+github.com/beeper/desktop-api-go v0.2.1 h1:clX6nuA3TCkHD6kMHM+gasvhkWLBceMDlAZqvUldhxM=
+github.com/beeper/desktop-api-go v0.2.1/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
From b16c08ae98ca116514ce0a8977142db49196c5d9 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 13 Feb 2026 00:19:20 +0000
Subject: [PATCH 19/32] feat(api): add reactions
---
.stats.yml | 4 +--
pkg/cmd/accountcontact.go | 2 +-
pkg/cmd/beeperdesktopapi.go | 2 +-
pkg/cmd/chat.go | 63 ++++++++++++++++++++++++++++---------
pkg/cmd/chat_test.go | 25 +++++++++++++++
pkg/cmd/chatreminder.go | 27 ++--------------
6 files changed, 80 insertions(+), 43 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 5cfb00e..26acc07 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 19
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-099d55ac0e749a64dacc1706d7d8276d1acbe52103f0419393c39e8911966cfe.yml
-openapi_spec_hash: 70a1b1d513b62c6d6caabbbf360220b4
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-3f6555bfea11258c6e8882455360ae08202067a270313716ee15571b83ada577.yml
+openapi_spec_hash: 020324a708981384284f8fad8ac8c66c
config_hash: 48ff2d23c2ebc82bd3c15787f0041684
diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go
index 1aea7eb..27f15e0 100644
--- a/pkg/cmd/accountcontact.go
+++ b/pkg/cmd/accountcontact.go
@@ -17,7 +17,7 @@ import (
var accountsContactsSearch = cli.Command{
Name: "search",
- Usage: "Search contacts on a specific account using the network's search API. Only use\nfor creating new chats.",
+ Usage: "Search contacts on a specific account using merged account contacts, network\nsearch, and exact identifier lookup.",
Suggest: true,
Flags: []cli.Flag{
&requestflag.Flag[string]{
diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go
index f0f2b3c..bbef6b7 100644
--- a/pkg/cmd/beeperdesktopapi.go
+++ b/pkg/cmd/beeperdesktopapi.go
@@ -47,7 +47,7 @@ var focus = cli.Command{
var search = cli.Command{
Name: "search",
- Usage: "Returns matching chats, participant name matches in groups, and the first page\nof messages in one call. Paginate messages via search-messages. Paginate chats\nvia search-chats. Uses the same sorting as the chat search in the app.",
+ Usage: "Returns matching chats, participant name matches in groups, and the first page\nof messages in one call. Paginate messages via search-messages. Paginate chats\nvia search-chats.",
Suggest: true,
Flags: []cli.Flag{
&requestflag.Flag[string]{
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index 508f0f9..364a553 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -15,9 +15,9 @@ import (
"github.com/urfave/cli/v3"
)
-var chatsCreate = cli.Command{
+var chatsCreate = requestflag.WithInnerFlags(cli.Command{
Name: "create",
- Usage: "Create a single or group chat on a specific account using participant IDs and\noptional title.",
+ Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').",
Suggest: true,
Flags: []cli.Flag{
&requestflag.Flag[string]{
@@ -43,15 +43,60 @@ var chatsCreate = cli.Command{
Usage: "Optional first message content if the platform requires it to create the chat.",
BodyPath: "messageText",
},
+ &requestflag.Flag[string]{
+ Name: "mode",
+ Usage: "Create mode. Defaults to 'create' when omitted.",
+ BodyPath: "mode",
+ },
&requestflag.Flag[string]{
Name: "title",
Usage: "Optional title for group chats; ignored for single chats on most platforms.",
BodyPath: "title",
},
+ &requestflag.Flag[map[string]any]{
+ Name: "user",
+ Usage: "Merged user-like contact payload used to resolve the best identifier.",
+ Required: true,
+ BodyPath: "user",
+ },
+ &requestflag.Flag[bool]{
+ Name: "allow-invite",
+ Usage: "Whether invite-based DM creation is allowed when required by the platform.",
+ Default: true,
+ BodyPath: "allowInvite",
+ },
},
Action: handleChatsCreate,
HideHelpCommand: true,
-}
+}, map[string][]requestflag.HasOuterFlag{
+ "user": {
+ &requestflag.InnerFlag[string]{
+ Name: "user.id",
+ Usage: "Known user ID when available.",
+ InnerField: "id",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "user.email",
+ Usage: "Email candidate.",
+ InnerField: "email",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "user.full-name",
+ Usage: "Display name hint used for ranking only.",
+ InnerField: "fullName",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "user.phone-number",
+ Usage: "Phone number candidate (E.164 preferred).",
+ InnerField: "phoneNumber",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "user.username",
+ Usage: "Username/handle candidate.",
+ InnerField: "username",
+ },
+ },
+})
var chatsRetrieve = cli.Command{
Name: "retrieve",
@@ -332,22 +377,12 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error {
return err
}
- var res []byte
- options = append(options, option.WithResponseBodyInto(&res))
- _, err = client.Chats.Archive(
+ return client.Chats.Archive(
ctx,
cmd.Value("chat-id").(string),
params,
options...,
)
- if err != nil {
- return err
- }
-
- obj := gjson.ParseBytes(res)
- format := cmd.Root().String("format")
- transform := cmd.Root().String("transform")
- return ShowJSON(os.Stdout, "chats archive", obj, format, transform)
}
func handleChatsSearch(ctx context.Context, cmd *cli.Command) error {
diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go
index baabef5..3082d78 100644
--- a/pkg/cmd/chat_test.go
+++ b/pkg/cmd/chat_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/beeper/desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
)
func TestChatsCreate(t *testing.T) {
@@ -16,7 +17,31 @@ func TestChatsCreate(t *testing.T) {
"--participant-id", "string",
"--type", "single",
"--message-text", "messageText",
+ "--mode", "create",
"--title", "title",
+ "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}",
+ "--allow-invite=true",
+ )
+
+ // Check that inner flags have been set up correctly
+ requestflag.CheckInnerFlags(chatsCreate)
+
+ // Alternative argument passing style using inner flags
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats", "create",
+ "--account-id", "accountID",
+ "--participant-id", "string",
+ "--type", "single",
+ "--message-text", "messageText",
+ "--mode", "create",
+ "--title", "title",
+ "--user.id", "id",
+ "--user.email", "email",
+ "--user.full-name", "fullName",
+ "--user.phone-number", "phoneNumber",
+ "--user.username", "username",
+ "--allow-invite=true",
)
}
diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go
index 2361908..5f288e1 100644
--- a/pkg/cmd/chatreminder.go
+++ b/pkg/cmd/chatreminder.go
@@ -5,13 +5,10 @@ package cmd
import (
"context"
"fmt"
- "os"
"github.com/beeper/desktop-api-cli/internal/apiquery"
"github.com/beeper/desktop-api-cli/internal/requestflag"
"github.com/beeper/desktop-api-go"
- "github.com/beeper/desktop-api-go/option"
- "github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
@@ -88,22 +85,12 @@ func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error {
return err
}
- var res []byte
- options = append(options, option.WithResponseBodyInto(&res))
- _, err = client.Chats.Reminders.New(
+ return client.Chats.Reminders.New(
ctx,
cmd.Value("chat-id").(string),
params,
options...,
)
- if err != nil {
- return err
- }
-
- obj := gjson.ParseBytes(res)
- format := cmd.Root().String("format")
- transform := cmd.Root().String("transform")
- return ShowJSON(os.Stdout, "chats:reminders create", obj, format, transform)
}
func handleChatsRemindersDelete(ctx context.Context, cmd *cli.Command) error {
@@ -128,15 +115,5 @@ func handleChatsRemindersDelete(ctx context.Context, cmd *cli.Command) error {
return err
}
- var res []byte
- options = append(options, option.WithResponseBodyInto(&res))
- _, err = client.Chats.Reminders.Delete(ctx, cmd.Value("chat-id").(string), options...)
- if err != nil {
- return err
- }
-
- obj := gjson.ParseBytes(res)
- format := cmd.Root().String("format")
- transform := cmd.Root().String("transform")
- return ShowJSON(os.Stdout, "chats:reminders delete", obj, format, transform)
+ return client.Chats.Reminders.Delete(ctx, cmd.Value("chat-id").(string), options...)
}
From fcf4608ef721212651ce3839bd4885d0b86744cf Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sun, 15 Feb 2026 03:08:35 +0000
Subject: [PATCH 20/32] fix: prevent tests from hanging on streaming/paginated
endpoints
---
internal/mocktest/mocktest.go | 47 +++++++++++++++++++++++++++++------
1 file changed, 39 insertions(+), 8 deletions(-)
diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go
index 84940e4..d95a68a 100644
--- a/internal/mocktest/mocktest.go
+++ b/internal/mocktest/mocktest.go
@@ -14,7 +14,6 @@ import (
"testing"
"time"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -79,13 +78,45 @@ func TestRunMockTestWithFlags(t *testing.T, flags ...string) {
t.Logf("Testing command: beeper-desktop-api %s", strings.Join(args[4:], " "))
- cmd := exec.Command("go", args...)
- output, err := cmd.CombinedOutput()
- if err != nil {
- assert.Fail(t, "Test failed", "Error: %v\nOutput: %s", err, output)
- }
-
- t.Logf("Test passed successfully with output:\n%s\n", output)
+ cliCmd := exec.Command("go", args...)
+
+ // Pipe the CLI tool's output into `head` so it doesn't hang when simulating
+ // paginated or streamed endpoints. 100 lines of output should be enough to
+ // test that the API endpoint worked, or report back a meaningful amount of
+ // data if something went wrong.
+ headCmd := exec.Command("head", "-n", "100")
+ pipe, err := cliCmd.StdoutPipe()
+ require.NoError(t, err, "Failed to create pipe for CLI command")
+ headCmd.Stdin = pipe
+
+ // Capture `head` output and CLI command stderr outputs:
+ var output strings.Builder
+ headCmd.Stdout = &output
+ headCmd.Stderr = &output
+ cliCmd.Stderr = &output
+
+ // First start `head`, so it's ready for data to come in:
+ err = headCmd.Start()
+ require.NoError(t, err, "Failed to start `head` command")
+
+ // Next start the CLI command so it can pipe data to `head` without
+ // buffering any data in advance:
+ err = cliCmd.Start()
+ require.NoError(t, err, "Failed to start CLI command")
+
+ // Ensure that the stdout pipe is closed as soon as `head` exits, to let the
+ // CLI tool know that no more output is needed and it can stop streaming
+ // test data for streaming/paginated endpoints. This needs to happen before
+ // calling `cliCmd.Wait()`, otherwise there will be a deadlock.
+ err = headCmd.Wait()
+ pipe.Close()
+ require.NoError(t, err, "`head` command finished with an error")
+
+ // Finally, wait for the CLI tool to finish up:
+ err = cliCmd.Wait()
+ require.NoError(t, err, "CLI command failed\n%s", output.String())
+
+ t.Logf("Test passed successfully\nOutput:\n%s", output.String())
}
func TestFile(t *testing.T, contents string) string {
From 8e91787eccfac7cc9455444fca72045a312f95a0 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 03:15:50 +0000
Subject: [PATCH 21/32] chore(internal): codegen related update
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index ead32f6..851e8cb 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/beeper/desktop-api-cli
go 1.25
require (
- github.com/beeper/desktop-api-go v0.2.1
+ github.com/beeper/desktop-api-go v0.3.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
diff --git a/go.sum b/go.sum
index 75c54d9..a7d83d5 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
-github.com/beeper/desktop-api-go v0.2.1 h1:clX6nuA3TCkHD6kMHM+gasvhkWLBceMDlAZqvUldhxM=
-github.com/beeper/desktop-api-go v0.2.1/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
+github.com/beeper/desktop-api-go v0.3.0 h1:lDPWI2dLj5/yKbfiMHdNYmjJUaMVeUZgV6nvQq8Uwjc=
+github.com/beeper/desktop-api-go v0.3.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
From b3fb2a0cf62fff27da2f2d1ca8062bf3b3ede582 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 01:10:17 +0000
Subject: [PATCH 22/32] feat(api): manual updates
---
.github/workflows/publish-release.yml | 8 +-
.gitignore | 2 +-
.goreleaser.yml | 46 ++++-
.stats.yml | 8 +-
README.md | 23 ++-
.../main.go | 0
internal/mocktest/mocktest.go | 2 +-
pkg/cmd/accountcontact.go | 87 ++++++++++
pkg/cmd/accountcontact_test.go | 12 ++
pkg/cmd/chat.go | 77 +--------
pkg/cmd/chat_test.go | 31 +---
pkg/cmd/chatmessagereaction.go | 159 ++++++++++++++++++
pkg/cmd/chatmessagereaction_test.go | 30 ++++
pkg/cmd/cmd.go | 30 +++-
pkg/cmd/info.go | 56 ++++++
pkg/cmd/info_test.go | 16 ++
scripts/build | 4 +-
scripts/run | 2 +-
scripts/utils/upload-artifact.sh | 2 +-
19 files changed, 459 insertions(+), 136 deletions(-)
rename cmd/{beeper-desktop-api => beeper-desktop-cli}/main.go (100%)
create mode 100644 pkg/cmd/chatmessagereaction.go
create mode 100644 pkg/cmd/chatmessagereaction_test.go
create mode 100644 pkg/cmd/info.go
create mode 100644 pkg/cmd/info_test.go
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 263859c..4e556fb 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -28,4 +28,10 @@ jobs:
version: latest
args: release --clean
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
+ MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
+ MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
+ MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
+ MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
+ MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5dd5636..4bcf8e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
.prism.log
dist/
-/beeper-desktop-api
+/beeper-desktop-cli
*.exe
diff --git a/.goreleaser.yml b/.goreleaser.yml
index b39ebc6..b418408 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,20 +1,20 @@
-project_name: beeper-desktop-api
+project_name: beeper-desktop-cli
version: 2
before:
hooks:
- mkdir -p completions
- - sh -c "go run ./cmd/beeper-desktop-api/main.go @completion bash > completions/beeper-desktop-api.bash"
- - sh -c "go run ./cmd/beeper-desktop-api/main.go @completion zsh > completions/beeper-desktop-api.zsh"
- - sh -c "go run ./cmd/beeper-desktop-api/main.go @completion fish > completions/beeper-desktop-api.fish"
- - sh -c "go run ./cmd/beeper-desktop-api/main.go @manpages -o man"
+ - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion bash > completions/beeper-desktop-cli.bash"
+ - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion zsh > completions/beeper-desktop-cli.zsh"
+ - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion fish > completions/beeper-desktop-cli.fish"
+ - sh -c "go run ./cmd/beeper-desktop-cli/main.go @manpages -o man"
builds:
- id: macos
goos: [darwin]
goarch: [amd64, arm64]
binary: '{{ .ProjectName }}'
- main: ./cmd/beeper-desktop-api/main.go
+ main: ./cmd/beeper-desktop-cli/main.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
@@ -25,7 +25,7 @@ builds:
env:
- CGO_ENABLED=0
binary: '{{ .ProjectName }}'
- main: ./cmd/beeper-desktop-api/main.go
+ main: ./cmd/beeper-desktop-cli/main.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
@@ -34,7 +34,7 @@ builds:
goos: [windows]
goarch: ['386', amd64, arm64]
binary: '{{ .ProjectName }}'
- main: ./cmd/beeper-desktop-api/main.go
+ main: ./cmd/beeper-desktop-cli/main.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
@@ -78,3 +78,33 @@ nfpms:
contents:
- src: man/man1/*.1.gz
dst: /usr/share/man/man1/
+homebrew_casks:
+ - name: beeper-desktop-cli
+ repository:
+ owner: beeper
+ name: homebrew-tap
+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+ homepage: https://developers.beeper.com/desktop-api/
+ description: CLI for Beeper Desktop API
+ license: MIT
+ binary: "beeper-desktop-cli"
+ completions:
+ bash: "completions/beeper-desktop-cli.bash"
+ zsh: "completions/beeper-desktop-cli.zsh"
+ fish: "completions/beeper-desktop-cli.fish"
+ manpages:
+ - man/man1/beeper-desktop-cli.1.gz
+
+notarize:
+ macos:
+ - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
+ ids: [macos]
+
+ sign:
+ certificate: "{{.Env.MACOS_SIGN_P12}}"
+ password: "{{.Env.MACOS_SIGN_PASSWORD}}"
+
+ notarize:
+ issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
+ key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
+ key: "{{.Env.MACOS_NOTARY_KEY}}"
diff --git a/.stats.yml b/.stats.yml
index 26acc07..50bd7a1 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 19
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-3f6555bfea11258c6e8882455360ae08202067a270313716ee15571b83ada577.yml
-openapi_spec_hash: 020324a708981384284f8fad8ac8c66c
-config_hash: 48ff2d23c2ebc82bd3c15787f0041684
+configured_endpoints: 23
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml
+openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637
+config_hash: abdcaeff62a619bdf25d727cdeacf3b0
diff --git a/README.md b/README.md
index 32defd7..f94091a 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,19 @@ The official CLI for the [Beeper Desktop REST API](https://developers.beeper.com
## Installation
+### Installing with Homebrew
+
+```sh
+brew tap beeper/tap
+brew install beeper-desktop-cli
+```
+
### Installing with Go
To test or install the CLI locally, you need [Go](https://go.dev/doc/install) version 1.22 or later installed.
```sh
-go install 'github.com/beeper/desktop-api-cli/cmd/beeper-desktop-api@latest'
+go install 'github.com/beeper/desktop-api-cli/cmd/beeper-desktop-cli@latest'
```
Once you have run `go install`, the binary is placed in your Go bin directory:
@@ -42,11 +49,11 @@ After cloning the git repository for this project, you can use the
The CLI follows a resource-based command structure:
```sh
-beeper-desktop-api [resource] [flags...]
+beeper-desktop-cli [resource] [flags...]
```
```sh
-beeper-desktop-api chats search \
+beeper-desktop-cli chats search \
--account-id local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc \
--account-id local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI \
--cursor '1725489123456|c29tZUltc2dQYWdl' \
@@ -80,15 +87,15 @@ For details about specific commands, use the `--help` flag.
To pass files to your API, you can use the `@myfile.ext` syntax:
```bash
-beeper-desktop-api --arg @abe.jpg
+beeper-desktop-cli --arg @abe.jpg
```
Files can also be passed inside JSON or YAML blobs:
```bash
-beeper-desktop-api --arg '{image: "@abe.jpg"}'
+beeper-desktop-cli --arg '{image: "@abe.jpg"}'
# Equivalent:
-beeper-desktop-api < < --username '\@abe'
+beeper-desktop-cli --username '\@abe'
```
#### Explicit encoding
@@ -112,5 +119,5 @@ base64-encoding). Note that absolute paths will begin with `@file://` or
`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`).
```bash
-beeper-desktop-api --arg @data://file.txt
+beeper-desktop-cli --arg @data://file.txt
```
diff --git a/cmd/beeper-desktop-api/main.go b/cmd/beeper-desktop-cli/main.go
similarity index 100%
rename from cmd/beeper-desktop-api/main.go
rename to cmd/beeper-desktop-cli/main.go
diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go
index d95a68a..5735b6b 100644
--- a/internal/mocktest/mocktest.go
+++ b/internal/mocktest/mocktest.go
@@ -76,7 +76,7 @@ func TestRunMockTestWithFlags(t *testing.T, flags ...string) {
args := []string{"run", project, "--base-url", mockServerURL.String()}
args = append(args, flags...)
- t.Logf("Testing command: beeper-desktop-api %s", strings.Join(args[4:], " "))
+ t.Logf("Testing command: beeper-desktop-cli %s", strings.Join(args[4:], " "))
cliCmd := exec.Command("go", args...)
diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go
index 27f15e0..493397a 100644
--- a/pkg/cmd/accountcontact.go
+++ b/pkg/cmd/accountcontact.go
@@ -15,6 +15,42 @@ import (
"github.com/urfave/cli/v3"
)
+var accountsContactsList = cli.Command{
+ Name: "list",
+ Usage: "List merged contacts for a specific account with cursor-based pagination.",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "account-id",
+ Usage: "Account ID this resource belongs to.",
+ Required: true,
+ },
+ &requestflag.Flag[string]{
+ Name: "cursor",
+ Usage: "Opaque pagination cursor; do not inspect. Use together with 'direction'.",
+ QueryPath: "cursor",
+ },
+ &requestflag.Flag[string]{
+ Name: "direction",
+ Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.",
+ QueryPath: "direction",
+ },
+ &requestflag.Flag[int64]{
+ Name: "limit",
+ Usage: "Maximum contacts to return per page.",
+ Default: 50,
+ QueryPath: "limit",
+ },
+ &requestflag.Flag[string]{
+ Name: "query",
+ Usage: "Optional search query for blended contact lookup.",
+ QueryPath: "query",
+ },
+ },
+ Action: handleAccountsContactsList,
+ HideHelpCommand: true,
+}
+
var accountsContactsSearch = cli.Command{
Name: "search",
Usage: "Search contacts on a specific account using merged account contacts, network\nsearch, and exact identifier lookup.",
@@ -36,6 +72,57 @@ var accountsContactsSearch = cli.Command{
HideHelpCommand: true,
}
+func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+ if !cmd.IsSet("account-id") && len(unusedArgs) > 0 {
+ cmd.Set("account-id", unusedArgs[0])
+ unusedArgs = unusedArgs[1:]
+ }
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ params := beeperdesktopapi.AccountContactListParams{}
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ EmptyBody,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ if format == "raw" {
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Accounts.Contacts.List(
+ ctx,
+ cmd.Value("account-id").(string),
+ params,
+ options...,
+ )
+ if err != nil {
+ return err
+ }
+ obj := gjson.ParseBytes(res)
+ return ShowJSON(os.Stdout, "accounts:contacts list", obj, format, transform)
+ } else {
+ iter := client.Accounts.Contacts.ListAutoPaging(
+ ctx,
+ cmd.Value("account-id").(string),
+ params,
+ options...,
+ )
+ return ShowJSONIterator(os.Stdout, "accounts:contacts list", iter, format, transform)
+ }
+}
+
func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error {
client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
unusedArgs := cmd.Args().Slice()
diff --git a/pkg/cmd/accountcontact_test.go b/pkg/cmd/accountcontact_test.go
index 8ec6c99..a9aca95 100644
--- a/pkg/cmd/accountcontact_test.go
+++ b/pkg/cmd/accountcontact_test.go
@@ -8,6 +8,18 @@ import (
"github.com/beeper/desktop-api-cli/internal/mocktest"
)
+func TestAccountsContactsList(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "accounts:contacts", "list",
+ "--account-id", "accountID",
+ "--cursor", "1725489123456|c29tZUltc2dQYWdl",
+ "--direction", "before",
+ "--limit", "1",
+ "--query", "x",
+ )
+}
+
func TestAccountsContactsSearch(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index 364a553..d4128ac 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -15,88 +15,19 @@ import (
"github.com/urfave/cli/v3"
)
-var chatsCreate = requestflag.WithInnerFlags(cli.Command{
+var chatsCreate = cli.Command{
Name: "create",
Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').",
Suggest: true,
Flags: []cli.Flag{
- &requestflag.Flag[string]{
- Name: "account-id",
- Usage: "Account to create the chat on.",
- Required: true,
- BodyPath: "accountID",
- },
- &requestflag.Flag[[]string]{
- Name: "participant-id",
- Usage: "User IDs to include in the new chat.",
- Required: true,
- BodyPath: "participantIDs",
- },
- &requestflag.Flag[string]{
- Name: "type",
- Usage: "Chat type to create: 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.",
- Required: true,
- BodyPath: "type",
- },
- &requestflag.Flag[string]{
- Name: "message-text",
- Usage: "Optional first message content if the platform requires it to create the chat.",
- BodyPath: "messageText",
- },
- &requestflag.Flag[string]{
- Name: "mode",
- Usage: "Create mode. Defaults to 'create' when omitted.",
- BodyPath: "mode",
- },
- &requestflag.Flag[string]{
- Name: "title",
- Usage: "Optional title for group chats; ignored for single chats on most platforms.",
- BodyPath: "title",
- },
&requestflag.Flag[map[string]any]{
- Name: "user",
- Usage: "Merged user-like contact payload used to resolve the best identifier.",
- Required: true,
- BodyPath: "user",
- },
- &requestflag.Flag[bool]{
- Name: "allow-invite",
- Usage: "Whether invite-based DM creation is allowed when required by the platform.",
- Default: true,
- BodyPath: "allowInvite",
+ Name: "chat",
+ BodyRoot: true,
},
},
Action: handleChatsCreate,
HideHelpCommand: true,
-}, map[string][]requestflag.HasOuterFlag{
- "user": {
- &requestflag.InnerFlag[string]{
- Name: "user.id",
- Usage: "Known user ID when available.",
- InnerField: "id",
- },
- &requestflag.InnerFlag[string]{
- Name: "user.email",
- Usage: "Email candidate.",
- InnerField: "email",
- },
- &requestflag.InnerFlag[string]{
- Name: "user.full-name",
- Usage: "Display name hint used for ranking only.",
- InnerField: "fullName",
- },
- &requestflag.InnerFlag[string]{
- Name: "user.phone-number",
- Usage: "Phone number candidate (E.164 preferred).",
- InnerField: "phoneNumber",
- },
- &requestflag.InnerFlag[string]{
- Name: "user.username",
- Usage: "Username/handle candidate.",
- InnerField: "username",
- },
- },
-})
+}
var chatsRetrieve = cli.Command{
Name: "retrieve",
diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go
index 3082d78..d767716 100644
--- a/pkg/cmd/chat_test.go
+++ b/pkg/cmd/chat_test.go
@@ -6,42 +6,13 @@ import (
"testing"
"github.com/beeper/desktop-api-cli/internal/mocktest"
- "github.com/beeper/desktop-api-cli/internal/requestflag"
)
func TestChatsCreate(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
"chats", "create",
- "--account-id", "accountID",
- "--participant-id", "string",
- "--type", "single",
- "--message-text", "messageText",
- "--mode", "create",
- "--title", "title",
- "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}",
- "--allow-invite=true",
- )
-
- // Check that inner flags have been set up correctly
- requestflag.CheckInnerFlags(chatsCreate)
-
- // Alternative argument passing style using inner flags
- mocktest.TestRunMockTestWithFlags(
- t,
- "chats", "create",
- "--account-id", "accountID",
- "--participant-id", "string",
- "--type", "single",
- "--message-text", "messageText",
- "--mode", "create",
- "--title", "title",
- "--user.id", "id",
- "--user.email", "email",
- "--user.full-name", "fullName",
- "--user.phone-number", "phoneNumber",
- "--user.username", "username",
- "--allow-invite=true",
+ "--chat", "{accountID: accountID, participantIDs: [string], type: single, messageText: messageText, mode: create, title: title}",
)
}
diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go
new file mode 100644
index 0000000..f72f189
--- /dev/null
+++ b/pkg/cmd/chatmessagereaction.go
@@ -0,0 +1,159 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
+ "github.com/beeper/desktop-api-go"
+ "github.com/beeper/desktop-api-go/option"
+ "github.com/tidwall/gjson"
+ "github.com/urfave/cli/v3"
+)
+
+var chatsMessagesReactionsDelete = cli.Command{
+ Name: "delete",
+ Usage: "Remove the authenticated user's reaction from an existing message.",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "chat-id",
+ Usage: "Unique identifier of the chat.",
+ Required: true,
+ },
+ &requestflag.Flag[string]{
+ Name: "message-id",
+ Required: true,
+ },
+ &requestflag.Flag[string]{
+ Name: "reaction-key",
+ Usage: "Reaction key to remove",
+ Required: true,
+ QueryPath: "reactionKey",
+ },
+ },
+ Action: handleChatsMessagesReactionsDelete,
+ HideHelpCommand: true,
+}
+
+var chatsMessagesReactionsAdd = cli.Command{
+ Name: "add",
+ Usage: "Add a reaction to an existing message.",
+ Suggest: true,
+ Flags: []cli.Flag{
+ &requestflag.Flag[string]{
+ Name: "chat-id",
+ Usage: "Unique identifier of the chat.",
+ Required: true,
+ },
+ &requestflag.Flag[string]{
+ Name: "message-id",
+ Required: true,
+ },
+ &requestflag.Flag[string]{
+ Name: "reaction-key",
+ Usage: "Reaction key to add (emoji, shortcode, or custom emoji key)",
+ Required: true,
+ BodyPath: "reactionKey",
+ },
+ &requestflag.Flag[string]{
+ Name: "transaction-id",
+ Usage: "Optional transaction ID for deduplication and local echo tracking",
+ BodyPath: "transactionID",
+ },
+ },
+ Action: handleChatsMessagesReactionsAdd,
+ HideHelpCommand: true,
+}
+
+func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+ if !cmd.IsSet("message-id") && len(unusedArgs) > 0 {
+ cmd.Set("message-id", unusedArgs[0])
+ unusedArgs = unusedArgs[1:]
+ }
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ params := beeperdesktopapi.ChatMessageReactionDeleteParams{
+ ChatID: cmd.Value("chat-id").(string),
+ }
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ EmptyBody,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Chats.Messages.Reactions.Delete(
+ ctx,
+ cmd.Value("message-id").(string),
+ params,
+ options...,
+ )
+ if err != nil {
+ return err
+ }
+
+ obj := gjson.ParseBytes(res)
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ return ShowJSON(os.Stdout, "chats:messages:reactions delete", obj, format, transform)
+}
+
+func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+ if !cmd.IsSet("message-id") && len(unusedArgs) > 0 {
+ cmd.Set("message-id", unusedArgs[0])
+ unusedArgs = unusedArgs[1:]
+ }
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ params := beeperdesktopapi.ChatMessageReactionAddParams{
+ ChatID: cmd.Value("chat-id").(string),
+ }
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ ApplicationJSON,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Chats.Messages.Reactions.Add(
+ ctx,
+ cmd.Value("message-id").(string),
+ params,
+ options...,
+ )
+ if err != nil {
+ return err
+ }
+
+ obj := gjson.ParseBytes(res)
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ return ShowJSON(os.Stdout, "chats:messages:reactions add", obj, format, transform)
+}
diff --git a/pkg/cmd/chatmessagereaction_test.go b/pkg/cmd/chatmessagereaction_test.go
new file mode 100644
index 0000000..3a42a97
--- /dev/null
+++ b/pkg/cmd/chatmessagereaction_test.go
@@ -0,0 +1,30 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package cmd
+
+import (
+ "testing"
+
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
+)
+
+func TestChatsMessagesReactionsDelete(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats:messages:reactions", "delete",
+ "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
+ "--message-id", "messageID",
+ "--reaction-key", "x",
+ )
+}
+
+func TestChatsMessagesReactionsAdd(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats:messages:reactions", "add",
+ "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com",
+ "--message-id", "messageID",
+ "--reaction-key", "x",
+ "--transaction-id", "transactionID",
+ )
+}
diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go
index f3f3233..d0c40cf 100644
--- a/pkg/cmd/cmd.go
+++ b/pkg/cmd/cmd.go
@@ -22,7 +22,7 @@ var (
func init() {
Command = &cli.Command{
- Name: "beeper-desktop-api",
+ Name: "beeper-desktop-cli",
Usage: "CLI for the beeperdesktop API",
Suggest: true,
Version: Version,
@@ -39,7 +39,7 @@ func init() {
&cli.StringFlag{
Name: "format",
Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")",
- Value: "auto",
+ Value: "pretty",
Validator: func(format string) error {
if !slices.Contains(OutputFormats, strings.ToLower(format)) {
return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", "))
@@ -50,7 +50,7 @@ func init() {
&cli.StringFlag{
Name: "format-error",
Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")",
- Value: "auto",
+ Value: "pretty",
Validator: func(format string) error {
if !slices.Contains(OutputFormats, strings.ToLower(format)) {
return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", "))
@@ -83,6 +83,7 @@ func init() {
Category: "API RESOURCE",
Suggest: true,
Commands: []*cli.Command{
+ &accountsContactsList,
&accountsContactsSearch,
},
},
@@ -107,6 +108,15 @@ func init() {
&chatsRemindersDelete,
},
},
+ {
+ Name: "chats:messages:reactions",
+ Category: "API RESOURCE",
+ Suggest: true,
+ Commands: []*cli.Command{
+ &chatsMessagesReactionsDelete,
+ &chatsMessagesReactionsAdd,
+ },
+ },
{
Name: "messages",
Category: "API RESOURCE",
@@ -129,10 +139,18 @@ func init() {
&assetsUploadBase64,
},
},
+ {
+ Name: "info",
+ Category: "API RESOURCE",
+ Suggest: true,
+ Commands: []*cli.Command{
+ &infoRetrieve,
+ },
+ },
{
Name: "@manpages",
Usage: "Generate documentation for 'man'",
- UsageText: "beeper-desktop-api @manpages [-o beeper-desktop-api.1] [--gzip]",
+ UsageText: "beeper-desktop-cli @manpages [-o beeper-desktop-cli.1] [--gzip]",
Hidden: true,
Action: generateManpages,
HideHelpCommand: true,
@@ -185,7 +203,7 @@ func generateManpages(ctx context.Context, c *cli.Command) error {
// handle error
}
if c.Bool("text") {
- file, err := os.Create(filepath.Join(dir, "man1", "beeper-desktop-api.1"))
+ file, err := os.Create(filepath.Join(dir, "man1", "beeper-desktop-cli.1"))
if err != nil {
return err
}
@@ -195,7 +213,7 @@ func generateManpages(ctx context.Context, c *cli.Command) error {
}
}
if c.Bool("gzip") {
- file, err := os.Create(filepath.Join(dir, "man1", "beeper-desktop-api.1.gz"))
+ file, err := os.Create(filepath.Join(dir, "man1", "beeper-desktop-cli.1.gz"))
if err != nil {
return err
}
diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go
new file mode 100644
index 0000000..2b46d49
--- /dev/null
+++ b/pkg/cmd/info.go
@@ -0,0 +1,56 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/beeper/desktop-api-cli/internal/apiquery"
+ "github.com/beeper/desktop-api-go"
+ "github.com/beeper/desktop-api-go/option"
+ "github.com/tidwall/gjson"
+ "github.com/urfave/cli/v3"
+)
+
+var infoRetrieve = cli.Command{
+ Name: "retrieve",
+ Usage: "Returns app, platform, server, and endpoint discovery metadata for this Beeper\nDesktop instance.",
+ Suggest: true,
+ Flags: []cli.Flag{},
+ Action: handleInfoRetrieve,
+ HideHelpCommand: true,
+}
+
+func handleInfoRetrieve(ctx context.Context, cmd *cli.Command) error {
+ client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...)
+ unusedArgs := cmd.Args().Slice()
+
+ if len(unusedArgs) > 0 {
+ return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs)
+ }
+
+ options, err := flagOptions(
+ cmd,
+ apiquery.NestedQueryFormatBrackets,
+ apiquery.ArrayQueryFormatRepeat,
+ EmptyBody,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+
+ var res []byte
+ options = append(options, option.WithResponseBodyInto(&res))
+ _, err = client.Info.Get(ctx, options...)
+ if err != nil {
+ return err
+ }
+
+ obj := gjson.ParseBytes(res)
+ format := cmd.Root().String("format")
+ transform := cmd.Root().String("transform")
+ return ShowJSON(os.Stdout, "info retrieve", obj, format, transform)
+}
diff --git a/pkg/cmd/info_test.go b/pkg/cmd/info_test.go
new file mode 100644
index 0000000..cc916cd
--- /dev/null
+++ b/pkg/cmd/info_test.go
@@ -0,0 +1,16 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package cmd
+
+import (
+ "testing"
+
+ "github.com/beeper/desktop-api-cli/internal/mocktest"
+)
+
+func TestInfoRetrieve(t *testing.T) {
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "info", "retrieve",
+ )
+}
diff --git a/scripts/build b/scripts/build
index da2bfa3..f81d546 100755
--- a/scripts/build
+++ b/scripts/build
@@ -4,5 +4,5 @@ set -e
cd "$(dirname "$0")/.."
-echo "==> Building beeper-desktop-api"
-go build ./cmd/beeper-desktop-api
+echo "==> Building beeper-desktop-cli"
+go build ./cmd/beeper-desktop-cli
diff --git a/scripts/run b/scripts/run
index 74d9570..b18ccb7 100755
--- a/scripts/run
+++ b/scripts/run
@@ -4,4 +4,4 @@ set -e
cd "$(dirname "$0")/.."
-go run ./cmd/beeper-desktop-api "$@"
+go run ./cmd/beeper-desktop-cli "$@"
diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh
index 6678188..d453066 100755
--- a/scripts/utils/upload-artifact.sh
+++ b/scripts/utils/upload-artifact.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -exuo pipefail
-BINARY_NAME="beeper-desktop-api"
+BINARY_NAME="beeper-desktop-cli"
DIST_DIR="dist"
FILENAME="dist.zip"
From 2bde108a1f7e2953799d7ea97cef4472fbae79ed Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 01:10:40 +0000
Subject: [PATCH 23/32] codegen metadata
---
.stats.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.stats.yml b/.stats.yml
index 50bd7a1..9b53ab5 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml
openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637
-config_hash: abdcaeff62a619bdf25d727cdeacf3b0
+config_hash: bd091e75baa300de3a05731fbd7f479e
From 1db68115824945115d61688362e9b3289c61e261 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 01:10:56 +0000
Subject: [PATCH 24/32] codegen metadata
---
.stats.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.stats.yml b/.stats.yml
index 9b53ab5..776042b 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml
openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637
-config_hash: bd091e75baa300de3a05731fbd7f479e
+config_hash: 07a9227b2e53d5bf022c964ac30d72fa
From 0c8a0ee510531e30ce5ed8748af4b56c19cf2433 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 01:21:28 +0000
Subject: [PATCH 25/32] feat(api): manual updates
---
.stats.yml | 6 +++---
pkg/cmd/chat.go | 48 ++++++++++++++++++++++++++++++++++++++++++--
pkg/cmd/chat_test.go | 20 +++++++++++++++++-
3 files changed, 68 insertions(+), 6 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 776042b..768e7e7 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml
-openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637
-config_hash: 07a9227b2e53d5bf022c964ac30d72fa
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-1b8324f05cd39e88cfc36b9b86a868b6f7e0c9e0827bb30d70a6d875c151ae52.yml
+openapi_spec_hash: 41410e315f6a3d0be787ece9e4fcb96a
+config_hash: abdcaeff62a619bdf25d727cdeacf3b0
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index d4128ac..24f9d42 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -15,19 +15,63 @@ import (
"github.com/urfave/cli/v3"
)
-var chatsCreate = cli.Command{
+var chatsCreate = requestflag.WithInnerFlags(cli.Command{
Name: "create",
Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').",
Suggest: true,
Flags: []cli.Flag{
&requestflag.Flag[map[string]any]{
Name: "chat",
+ Required: true,
BodyRoot: true,
},
},
Action: handleChatsCreate,
HideHelpCommand: true,
-}
+}, map[string][]requestflag.HasOuterFlag{
+ "chat": {
+ &requestflag.InnerFlag[string]{
+ Name: "chat.account-id",
+ Usage: "Account to create or start the chat on.",
+ InnerField: "accountID",
+ },
+ &requestflag.InnerFlag[bool]{
+ Name: "chat.allow-invite",
+ Usage: "Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'.",
+ InnerField: "allowInvite",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "chat.message-text",
+ Usage: "Optional first message content if the platform requires it to create the chat.",
+ InnerField: "messageText",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "chat.mode",
+ Usage: "Operation mode. Defaults to 'create' when omitted.",
+ InnerField: "mode",
+ },
+ &requestflag.InnerFlag[[]string]{
+ Name: "chat.participant-ids",
+ Usage: "Required when mode='create'. User IDs to include in the new chat.",
+ InnerField: "participantIDs",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "chat.title",
+ Usage: "Optional title for group chats when mode='create'; ignored for single chats on most platforms.",
+ InnerField: "title",
+ },
+ &requestflag.InnerFlag[string]{
+ Name: "chat.type",
+ Usage: "Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.",
+ InnerField: "type",
+ },
+ &requestflag.InnerFlag[map[string]any]{
+ Name: "chat.user",
+ Usage: "Required when mode='start'. Merged user-like contact payload used to resolve the best identifier.",
+ InnerField: "user",
+ },
+ },
+})
var chatsRetrieve = cli.Command{
Name: "retrieve",
diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go
index d767716..95480f1 100644
--- a/pkg/cmd/chat_test.go
+++ b/pkg/cmd/chat_test.go
@@ -6,13 +6,31 @@ import (
"testing"
"github.com/beeper/desktop-api-cli/internal/mocktest"
+ "github.com/beeper/desktop-api-cli/internal/requestflag"
)
func TestChatsCreate(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
"chats", "create",
- "--chat", "{accountID: accountID, participantIDs: [string], type: single, messageText: messageText, mode: create, title: title}",
+ "--chat", "{accountID: accountID, allowInvite: true, messageText: messageText, mode: create, participantIDs: [string], title: title, type: single, user: {id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}}",
+ )
+
+ // Check that inner flags have been set up correctly
+ requestflag.CheckInnerFlags(chatsCreate)
+
+ // Alternative argument passing style using inner flags
+ mocktest.TestRunMockTestWithFlags(
+ t,
+ "chats", "create",
+ "--chat.account-id", "accountID",
+ "--chat.allow-invite=true",
+ "--chat.message-text", "messageText",
+ "--chat.mode", "create",
+ "--chat.participant-ids", "[string]",
+ "--chat.title", "title",
+ "--chat.type", "single",
+ "--chat.user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}",
)
}
From d75edff3133bf062dabd021246ed75049811b49a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:19:04 +0000
Subject: [PATCH 26/32] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 768e7e7..3a78cf5 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-1b8324f05cd39e88cfc36b9b86a868b6f7e0c9e0827bb30d70a6d875c151ae52.yml
-openapi_spec_hash: 41410e315f6a3d0be787ece9e4fcb96a
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml
+openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842
config_hash: abdcaeff62a619bdf25d727cdeacf3b0
From 8432cdaa7ffacf8505b6864b0cb6c705a497250c Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:19:52 +0000
Subject: [PATCH 27/32] codegen metadata
---
.stats.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.stats.yml b/.stats.yml
index 3a78cf5..b40afd8 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml
openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842
-config_hash: abdcaeff62a619bdf25d727cdeacf3b0
+config_hash: cd9eef64c1202fa937a22172b0218447
From 12811d40bc57d8d48ea88ef6d5db3be7d4d8b91a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:42:22 +0000
Subject: [PATCH 28/32] codegen metadata
---
.stats.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.stats.yml b/.stats.yml
index b40afd8..8ec4701 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml
openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842
-config_hash: cd9eef64c1202fa937a22172b0218447
+config_hash: b5ac0c1579dfe6257bcdb84cfd1002fc
From 9f69525f394b74266a664c50899f760525844cd8 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:52:18 +0000
Subject: [PATCH 29/32] feat(api): api update
---
.stats.yml | 4 ++--
pkg/cmd/message_test.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 8ec4701..5a6113f 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml
-openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml
+openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9
config_hash: b5ac0c1579dfe6257bcdb84cfd1002fc
diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go
index 0cdd254..08bb115 100644
--- a/pkg/cmd/message_test.go
+++ b/pkg/cmd/message_test.go
@@ -47,7 +47,7 @@ func TestMessagesSearch(t *testing.T) {
"--limit", "20",
"--media-type", "any",
"--query", "dinner",
- "--sender", "me",
+ "--sender", "sender",
)
}
From 56afbbc6d75f019edac752e6100caefe333de434 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:58:12 +0000
Subject: [PATCH 30/32] feat(api): api update
---
.stats.yml | 2 +-
pkg/cmd/chat.go | 90 +++++++++++++++++++++++++++-----------------
pkg/cmd/chat_test.go | 29 +++++++++-----
3 files changed, 77 insertions(+), 44 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 5a6113f..56c368e 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml
openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9
-config_hash: b5ac0c1579dfe6257bcdb84cfd1002fc
+config_hash: 659111d4e28efa599b5f800619ed79c2
diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go
index 24f9d42..0b828d9 100644
--- a/pkg/cmd/chat.go
+++ b/pkg/cmd/chat.go
@@ -20,55 +20,77 @@ var chatsCreate = requestflag.WithInnerFlags(cli.Command{
Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').",
Suggest: true,
Flags: []cli.Flag{
- &requestflag.Flag[map[string]any]{
- Name: "chat",
+ &requestflag.Flag[string]{
+ Name: "account-id",
+ Usage: "Account to create or start the chat on.",
Required: true,
- BodyRoot: true,
+ BodyPath: "accountID",
+ },
+ &requestflag.Flag[bool]{
+ Name: "allow-invite",
+ Usage: "Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'.",
+ Default: true,
+ BodyPath: "allowInvite",
+ },
+ &requestflag.Flag[string]{
+ Name: "message-text",
+ Usage: "Optional first message content if the platform requires it to create the chat.",
+ BodyPath: "messageText",
+ },
+ &requestflag.Flag[string]{
+ Name: "mode",
+ Usage: "Operation mode. Defaults to 'create' when omitted.",
+ BodyPath: "mode",
+ },
+ &requestflag.Flag[[]string]{
+ Name: "participant-id",
+ Usage: "Required when mode='create'. User IDs to include in the new chat.",
+ BodyPath: "participantIDs",
+ },
+ &requestflag.Flag[string]{
+ Name: "title",
+ Usage: "Optional title for group chats when mode='create'; ignored for single chats on most platforms.",
+ BodyPath: "title",
+ },
+ &requestflag.Flag[string]{
+ Name: "type",
+ Usage: "Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.",
+ BodyPath: "type",
+ },
+ &requestflag.Flag[map[string]any]{
+ Name: "user",
+ Usage: "Required when mode='start'. Merged user-like contact payload used to resolve the best identifier.",
+ BodyPath: "user",
},
},
Action: handleChatsCreate,
HideHelpCommand: true,
}, map[string][]requestflag.HasOuterFlag{
- "chat": {
+ "user": {
&requestflag.InnerFlag[string]{
- Name: "chat.account-id",
- Usage: "Account to create or start the chat on.",
- InnerField: "accountID",
- },
- &requestflag.InnerFlag[bool]{
- Name: "chat.allow-invite",
- Usage: "Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'.",
- InnerField: "allowInvite",
+ Name: "user.id",
+ Usage: "Known user ID when available.",
+ InnerField: "id",
},
&requestflag.InnerFlag[string]{
- Name: "chat.message-text",
- Usage: "Optional first message content if the platform requires it to create the chat.",
- InnerField: "messageText",
+ Name: "user.email",
+ Usage: "Email candidate.",
+ InnerField: "email",
},
&requestflag.InnerFlag[string]{
- Name: "chat.mode",
- Usage: "Operation mode. Defaults to 'create' when omitted.",
- InnerField: "mode",
- },
- &requestflag.InnerFlag[[]string]{
- Name: "chat.participant-ids",
- Usage: "Required when mode='create'. User IDs to include in the new chat.",
- InnerField: "participantIDs",
+ Name: "user.full-name",
+ Usage: "Display name hint used for ranking only.",
+ InnerField: "fullName",
},
&requestflag.InnerFlag[string]{
- Name: "chat.title",
- Usage: "Optional title for group chats when mode='create'; ignored for single chats on most platforms.",
- InnerField: "title",
+ Name: "user.phone-number",
+ Usage: "Phone number candidate (E.164 preferred).",
+ InnerField: "phoneNumber",
},
&requestflag.InnerFlag[string]{
- Name: "chat.type",
- Usage: "Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.",
- InnerField: "type",
- },
- &requestflag.InnerFlag[map[string]any]{
- Name: "chat.user",
- Usage: "Required when mode='start'. Merged user-like contact payload used to resolve the best identifier.",
- InnerField: "user",
+ Name: "user.username",
+ Usage: "Username/handle candidate.",
+ InnerField: "username",
},
},
})
diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go
index 95480f1..8739818 100644
--- a/pkg/cmd/chat_test.go
+++ b/pkg/cmd/chat_test.go
@@ -13,7 +13,14 @@ func TestChatsCreate(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
"chats", "create",
- "--chat", "{accountID: accountID, allowInvite: true, messageText: messageText, mode: create, participantIDs: [string], title: title, type: single, user: {id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}}",
+ "--account-id", "accountID",
+ "--allow-invite=true",
+ "--message-text", "messageText",
+ "--mode", "create",
+ "--participant-id", "string",
+ "--title", "title",
+ "--type", "single",
+ "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}",
)
// Check that inner flags have been set up correctly
@@ -23,14 +30,18 @@ func TestChatsCreate(t *testing.T) {
mocktest.TestRunMockTestWithFlags(
t,
"chats", "create",
- "--chat.account-id", "accountID",
- "--chat.allow-invite=true",
- "--chat.message-text", "messageText",
- "--chat.mode", "create",
- "--chat.participant-ids", "[string]",
- "--chat.title", "title",
- "--chat.type", "single",
- "--chat.user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}",
+ "--account-id", "accountID",
+ "--allow-invite=true",
+ "--message-text", "messageText",
+ "--mode", "create",
+ "--participant-id", "string",
+ "--title", "title",
+ "--type", "single",
+ "--user.id", "id",
+ "--user.email", "email",
+ "--user.full-name", "fullName",
+ "--user.phone-number", "phoneNumber",
+ "--user.username", "username",
)
}
From 1cfe60b670c95b1e9840d9768b8b5936512919aa Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 21 Feb 2026 04:02:01 +0000
Subject: [PATCH 31/32] chore(internal): codegen related update
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 851e8cb..fc4ffb7 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/beeper/desktop-api-cli
go 1.25
require (
- github.com/beeper/desktop-api-go v0.3.0
+ github.com/beeper/desktop-api-go v0.4.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
diff --git a/go.sum b/go.sum
index a7d83d5..057802d 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
-github.com/beeper/desktop-api-go v0.3.0 h1:lDPWI2dLj5/yKbfiMHdNYmjJUaMVeUZgV6nvQq8Uwjc=
-github.com/beeper/desktop-api-go v0.3.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
+github.com/beeper/desktop-api-go v0.4.0 h1:cSZLj1pSVD7pAdBOwiHkSzpXDoWjzJCRfb7lDtW5POM=
+github.com/beeper/desktop-api-go v0.4.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
From 9154b961d4e8de50d4b28f96932d0a6d3753a107 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 21 Feb 2026 04:02:19 +0000
Subject: [PATCH 32/32] release: 0.1.0
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++
pkg/cmd/version.go | 2 +-
3 files changed, 51 insertions(+), 2 deletions(-)
create mode 100644 CHANGELOG.md
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 1332969..3d2ac0b 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.0.1"
+ ".": "0.1.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d8cabf0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,49 @@
+# Changelog
+
+## 0.1.0 (2026-02-21)
+
+Full Changelog: [v0.0.1...v0.1.0](https://github.com/beeper/desktop-api-cli/compare/v0.0.1...v0.1.0)
+
+### ⚠ BREAKING CHANGES
+
+* add support for passing files as parameters
+
+### Features
+
+* add readme documentation for passing files as arguments ([f7b1b4a](https://github.com/beeper/desktop-api-cli/commit/f7b1b4af1c7220c9cd21afc58aba32508504073b))
+* add support for passing files as parameters ([49ca642](https://github.com/beeper/desktop-api-cli/commit/49ca642691b546494d700c2f782aa8ae88d9767e))
+* **api:** add cli ([c57f02a](https://github.com/beeper/desktop-api-cli/commit/c57f02af602f2def16c59c1ba1db4059ff2b0fd5))
+* **api:** add reactions ([b16c08a](https://github.com/beeper/desktop-api-cli/commit/b16c08ae98ca116514ce0a8977142db49196c5d9))
+* **api:** add upload asset and edit message endpoints ([da2ca66](https://github.com/beeper/desktop-api-cli/commit/da2ca66a4910e80ffd919fd8105b026497b9a0ea))
+* **api:** api update ([56afbbc](https://github.com/beeper/desktop-api-cli/commit/56afbbc6d75f019edac752e6100caefe333de434))
+* **api:** api update ([9f69525](https://github.com/beeper/desktop-api-cli/commit/9f69525f394b74266a664c50899f760525844cd8))
+* **api:** manual updates ([0c8a0ee](https://github.com/beeper/desktop-api-cli/commit/0c8a0ee510531e30ce5ed8748af4b56c19cf2433))
+* **api:** manual updates ([b3fb2a0](https://github.com/beeper/desktop-api-cli/commit/b3fb2a0cf62fff27da2f2d1ca8062bf3b3ede582))
+* **api:** manual updates ([b66f2b5](https://github.com/beeper/desktop-api-cli/commit/b66f2b5c68eb90c5644faf0c1f2fc67a94f100cb))
+* **client:** provide file completions when using file embed syntax ([bdf34ce](https://github.com/beeper/desktop-api-cli/commit/bdf34cecc8cdbd2e9d19dade4616970bfd43ae6a))
+* **cli:** improve shell completions for namespaced commands and flags ([eded84a](https://github.com/beeper/desktop-api-cli/commit/eded84a5cc05bb700f5d0c50add30ec257738aa0))
+* improved support for passing files for `any`-typed arguments ([8c8fa87](https://github.com/beeper/desktop-api-cli/commit/8c8fa8743cbfd67e8ddbf165a06c151d319e2612))
+
+
+### Bug Fixes
+
+* fix for file uploads to octet stream and form encoding endpoints ([f26b475](https://github.com/beeper/desktop-api-cli/commit/f26b475dce7f9eb0cc9fa5a20c26667e1c32fc1a))
+* fix for nullable arguments ([5f10511](https://github.com/beeper/desktop-api-cli/commit/5f105117110982a972554fb9ab720b354829bae4))
+* fix for when terminal width is not available ([eba0a3f](https://github.com/beeper/desktop-api-cli/commit/eba0a3f905f7eb7bc3cd9a8f571713e5bdff1f87))
+* fix mock tests with inner fields that have underscores ([7c4554a](https://github.com/beeper/desktop-api-cli/commit/7c4554a35871394eeed6927ee401ce7cc6fe99b8))
+* preserve filename in content-disposition for file uploads ([c230cef](https://github.com/beeper/desktop-api-cli/commit/c230cefdf540e6d49e102cbb9cb9010625be04c6))
+* prevent tests from hanging on streaming/paginated endpoints ([fcf4608](https://github.com/beeper/desktop-api-cli/commit/fcf4608ef721212651ce3839bd4885d0b86744cf))
+* restore support for void endpoints ([de2984b](https://github.com/beeper/desktop-api-cli/commit/de2984b4cec53693f0b5b684cdc498c410211a82))
+* use RawJSON for iterated values instead of re-marshalling ([06bc1c7](https://github.com/beeper/desktop-api-cli/commit/06bc1c7a0ba890d76e2c210476ad1d7586cd069a))
+
+
+### Chores
+
+* add build step to ci ([f2bddcf](https://github.com/beeper/desktop-api-cli/commit/f2bddcf00a9a3faacf1a1a8293f3f46b7befe187))
+* configure new SDK language ([6db7b30](https://github.com/beeper/desktop-api-cli/commit/6db7b300c46fd6331b4bada5759f5e31ed5a0b56))
+* configure new SDK language ([388b391](https://github.com/beeper/desktop-api-cli/commit/388b3910792deb197365fc9e5fbf266260845d9e))
+* **internal:** codegen related update ([1cfe60b](https://github.com/beeper/desktop-api-cli/commit/1cfe60b670c95b1e9840d9768b8b5936512919aa))
+* **internal:** codegen related update ([8e91787](https://github.com/beeper/desktop-api-cli/commit/8e91787eccfac7cc9455444fca72045a312f95a0))
+* **internal:** codegen related update ([46e5aef](https://github.com/beeper/desktop-api-cli/commit/46e5aefac484dd40ad069ea5500dd2c885abf611))
+* **internal:** codegen related update ([6987a4c](https://github.com/beeper/desktop-api-cli/commit/6987a4c5870caa9323a8be80124069fc5f28d45a))
+* update documentation in readme ([5633fad](https://github.com/beeper/desktop-api-cli/commit/5633fad79b0a5db1d66b83a58d1d0e8fe7cf3f1d))
diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go
index 1f71453..9bb8168 100644
--- a/pkg/cmd/version.go
+++ b/pkg/cmd/version.go
@@ -2,4 +2,4 @@
package cmd
-const Version = "0.0.1" // x-release-please-version
+const Version = "0.1.0" // x-release-please-version