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