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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 879a93427b38a2f5cc9c375ec98497a93adcf82f 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:38:05 +0000
Subject: [PATCH 14/14] release: 0.1.0
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 36 +++++++++++++++++++++++++++++++++++
pkg/cmd/version.go | 2 +-
3 files changed, 38 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..3af240a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+## 0.1.0 (2026-02-03)
+
+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 upload asset and edit message endpoints ([da2ca66](https://github.com/beeper/desktop-api-cli/commit/da2ca66a4910e80ffd919fd8105b026497b9a0ea))
+* **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))
+
+
+### 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 mock tests with inner fields that have underscores ([7c4554a](https://github.com/beeper/desktop-api-cli/commit/7c4554a35871394eeed6927ee401ce7cc6fe99b8))
+* 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))
+* 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