From 3596cbba986e3019e9fc1f9b555728a5c737fe27 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Wed, 28 Jan 2026 19:49:08 -0500 Subject: [PATCH 01/17] feat: add github version check --- cmd/stl/main.go | 12 +++++++++ go.mod | 1 + go.sum | 2 ++ pkg/cmd/versioncheck.go | 54 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 pkg/cmd/versioncheck.go diff --git a/cmd/stl/main.go b/cmd/stl/main.go index 3b3e1f92..223532c7 100644 --- a/cmd/stl/main.go +++ b/cmd/stl/main.go @@ -10,11 +10,14 @@ import ( "os" "github.com/stainless-api/stainless-api-cli/pkg/cmd" + "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-go" "github.com/tidwall/gjson" ) func main() { + updateCheck := cmd.CheckForUpdate() + app := cmd.Command if err := app.Run(context.Background(), os.Args); err != nil { var apierr *stainless.Error @@ -30,6 +33,15 @@ func main() { } else { fmt.Fprintf(os.Stderr, "%s\n", err.Error()) } + checkVersionUpdate(updateCheck) os.Exit(1) } + + checkVersionUpdate(updateCheck) +} + +func checkVersionUpdate(updateCheck <-chan string) { + if msg := <-updateCheck; msg != "" { + console.Warn("%s", msg) + } } diff --git a/go.mod b/go.mod index d56ecbc5..a83fc308 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7c64bffb..46af09da 100644 --- a/go.sum +++ b/go.sum @@ -134,6 +134,8 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/cmd/versioncheck.go b/pkg/cmd/versioncheck.go new file mode 100644 index 00000000..bb33a820 --- /dev/null +++ b/pkg/cmd/versioncheck.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +type GitHubRelease struct { + TagName string `json:"tag_name"` +} + +// CheckForUpdate starts a background check for new versions and returns a channel +// that will contain an update message if one is available +func CheckForUpdate() <-chan string { + updateMsg := make(chan string, 1) + + go func() { + defer close(updateMsg) + + client := &http.Client{ + Timeout: 3 * time.Second, + } + + resp, err := client.Get("https://api.github.com/repos/stainless-api/stainless-api-cli/releases/latest") + if err != nil { + return + } + defer resp.Body.Close() + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return + } + + latest := release.TagName + if !strings.HasPrefix(latest, "v") { + latest = "v" + latest + } + current := Version + if !strings.HasPrefix(current, "v") { + current = "v" + current + } + + if semver.Compare(latest, current) > 0 { + updateMsg <- "New version available: " + latest + " (current: " + current + ")" + } + }() + + return updateMsg +} From 91a6b9977b85c115ca97eee02e655401809c19e3 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Wed, 28 Jan 2026 20:21:33 -0500 Subject: [PATCH 02/17] fix: cleanup and make passing project flag make more sense --- pkg/cmd/init.go | 62 +++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index eb04b471..fa6ddc8b 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -102,44 +102,44 @@ func handleInit(ctx context.Context, cmd *cli.Command) error { projects := fetchUserProjects(ctx, client, org) - var targets []stainless.Target - project := "" + var projectName string + var project *stainless.Project if cmd.IsSet("project") { - project = cmd.String("project") - projectExists := false + projectName = cmd.String("project") for _, p := range projects { // User can specify display name or slug, but we should normalize to slug here: - if project == p.Slug || project == p.DisplayName { - project = p.Slug - projectExists = true + if projectName == p.Slug || projectName == p.DisplayName { + projectName = p.Slug + project = &p break } } - - if !projectExists { - return fmt.Errorf("project '%s' does not exist", project) - } } else { - project, targets, err = askSelectProject(projects) + projectName, project, err = askSelectProject(projects) if err != nil { return err } + } - // If project is empty, that means the user selected - if project == "" { - var err error - if project, targets, err = askCreateProject(ctx, cmd, client, org, ""); err != nil { - return err - } - } else { - console.Property("project", project) + if project == nil && projectName != "" { + project, _ = client.Projects.Get(ctx, stainless.ProjectGetParams{ + Project: stainless.String(projectName), + }) + } + + if project == nil { + var err error + if projectName, project, err = askCreateProject(ctx, cmd, client, org, projectName); err != nil { + return err } + } else { + console.Property("project", projectName) } console.Spacer() - return initializeWorkspace(ctx, cmd, client, project, targets) + return initializeWorkspace(ctx, cmd, client, projectName, project.Targets) } func ensureExistingWorkspaceIsDeleted(cmd *cli.Command) error { @@ -217,7 +217,7 @@ func fetchUserProjects(ctx context.Context, client stainless.Client, org string) } // askSelectProject prompts the user to select from existing projects or create a new one -func askSelectProject(projects []stainless.Project) (string, []stainless.Target, error) { +func askSelectProject(projects []stainless.Project) (string, *stainless.Project, error) { options := make([]huh.Option[*stainless.Project], 0, len(projects)+1) options = append(options, huh.NewOption("", &stainless.Project{})) projects = slices.SortedFunc(slices.Values(projects), func(p1, p2 stainless.Project) int { @@ -239,10 +239,10 @@ func askSelectProject(projects []stainless.Project) (string, []stainless.Target, if err != nil { return "", nil, err } - return picked.Slug, picked.Targets, nil + return picked.Slug, picked, nil } -func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Client, org, projectName string) (string, []stainless.Target, error) { +func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Client, org, projectName string) (string, *stainless.Project, error) { group := console.Property("project", "(new)") if projectName == "" { @@ -334,12 +334,13 @@ func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Cl }, } - options := []option.RequestOption{} - if cmd.Bool("debug") { - options = append(options, debugMiddlewareOption) - } + var project *stainless.Project err = group.Spinner("Creating project...", func() error { - _, err = client.Projects.New( + options := []option.RequestOption{} + if cmd.Bool("debug") { + options = append(options, debugMiddlewareOption) + } + project, err = client.Projects.New( ctx, params, options..., @@ -351,7 +352,7 @@ func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Cl } group.Success("Project created successfully") - return slug, selectedTargets, nil + return slug, project, nil } // initializeWorkspace sets up the local workspace configuration and downloads files @@ -652,6 +653,7 @@ func downloadConfigFiles(ctx context.Context, client stainless.Client, wc Worksp // If contents are identical, this is a no-op existingContent, readErr := os.ReadFile(path) if readErr == nil && string(existingContent) == string(content) { + group.Property(description, fmt.Sprintf("File %s is already up to date", path)) return nil } From d11eba51ee4b07e1360cefde3e329c24d47067d0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:40:02 +0000 Subject: [PATCH 03/17] feat(cli): improve shell completions for namespaced commands and flags --- cmd/stl/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/stl/main.go b/cmd/stl/main.go index 223532c7..3f0fb5f7 100644 --- a/cmd/stl/main.go +++ b/cmd/stl/main.go @@ -8,18 +8,32 @@ import ( "fmt" "net/http" "os" + "slices" "github.com/stainless-api/stainless-api-cli/pkg/cmd" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-go" "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" ) func main() { updateCheck := cmd.CheckForUpdate() 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 *stainless.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)) @@ -34,7 +48,16 @@ func main() { fmt.Fprintf(os.Stderr, "%s\n", err.Error()) } checkVersionUpdate(updateCheck) - 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) } checkVersionUpdate(updateCheck) diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go new file mode 100644 index 00000000..97fe1a81 --- /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 00000000..3e8aa335 --- /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 00000000..64fa6abe --- /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 00000000..0164b045 --- /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 00000000..f712e130 --- /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 00000000..5412987d --- /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 209ac75d..b16b177b 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/stainless-api/stainless-api-cli/pkg/console" + "github.com/stainless-api/stainless-api-cli/internal/autocomplete" docs "github.com/urfave/cli-docs/v3" "github.com/urfave/cli/v3" ) @@ -202,10 +203,20 @@ stl builds create --branch `, }, }, }, + { + 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 0fb140aa40f3e52e01a9a8b8142876e9fcee8647 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:43:16 +0000 Subject: [PATCH 04/17] fix: fix mock tests with inner fields that have underscores --- pkg/cmd/build_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/build_test.go b/pkg/cmd/build_test.go index eb9a0009..c15d734c 100644 --- a/pkg/cmd/build_test.go +++ b/pkg/cmd/build_test.go @@ -96,10 +96,10 @@ func TestBuildsCompare(t *testing.T) { "builds", "compare", "--base.branch", "branch", "--base.revision", "string", - "--base.commit_message", "commit_message", + "--base.commit-message", "commit_message", "--head.branch", "branch", "--head.revision", "string", - "--head.commit_message", "commit_message", + "--head.commit-message", "commit_message", "--project", "project", "--target", "node", ) From 5a44115f285231ae36c872a9ad510b48b8d4f55c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:44:10 +0000 Subject: [PATCH 05/17] 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 1821232f..cc2de3f3 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/stainless-api/stainless-api-cli/internal/apiform" "github.com/stainless-api/stainless-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 00000000..2db8aa36 --- /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 fcc55a618a14bf956aa0af852d66e463e0073fbf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:51:16 +0000 Subject: [PATCH 06/17] 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 93a5d95f..5f852481 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -293,6 +293,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 { @@ -311,11 +315,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 @@ -349,11 +358,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 7bbd05715c474f582ce2d076831d01a6fb32e2e4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:53:18 +0000 Subject: [PATCH 07/17] fix: fix for nullable arguments --- pkg/cmd/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/project.go b/pkg/cmd/project.go index 8ffea8b7..bf1d5699 100644 --- a/pkg/cmd/project.go +++ b/pkg/cmd/project.go @@ -78,7 +78,7 @@ var projectsUpdate = cli.Command{ &requestflag.Flag[string]{ Name: "project", }, - &requestflag.Flag[string]{ + &requestflag.Flag[any]{ Name: "display-name", BodyPath: "display_name", }, From 58c3d76c1a686698ae3b95e488c710b1ae1945a8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:31:30 +0000 Subject: [PATCH 08/17] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5f6ca364..f3f8d4a9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-8eddc28ac84a2e35494e0698cb6d1b3a682d14f4f9692b89870e3cb9cd30e952.yml -openapi_spec_hash: 1d3a92a6fcfc9efb7b1fe04461952a03 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-2726a5c9d1b5921ca1eae4d3074e01d53b6a368df847a21722f30b34de324794.yml +openapi_spec_hash: bdbc8f5388f88acaeff72e8f79e57e46 config_hash: 3a9b615b799ae43753c266e776f34e74 From c957b31381e935c9f8c80738bf003deb5b05727e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 05:54:54 +0000 Subject: [PATCH 09/17] 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 a8ce6c63..4e83c944 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/stainless-v0-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/stainless-v0-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/stainless-v0-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 00000000..4af18a58 --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -exuo pipefail + +BINARY_NAME="stl" +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/stainless-v0-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 dc0fd28e141c8f50c90a47144d7257d596612ada Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:00:16 +0000 Subject: [PATCH 10/17] chore: update documentation in readme --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 4dbc356d..d1252bce 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,31 @@ brew install stl ### 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/stainless-api/stainless-api-cli/cmd/stl@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 55530da089f0017c9815c456b5a2f850a241f122 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:02:48 +0000 Subject: [PATCH 11/17] 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 cc2de3f3..2a70ab57 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 2db8aa36..e5dad4be 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 3d5c3eb26a45dd52a3113fff16e6beda69434199 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:03:50 +0000 Subject: [PATCH 12/17] 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 d1252bce..ad8f0791 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ After cloning the git repository for this project, you can use the The CLI follows a resource-based command structure: ```sh -stl [resource] [command] [flags] +stl [resource] [flags...] ``` ```sh @@ -72,7 +72,7 @@ stl builds create \ 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) @@ -83,6 +83,46 @@ 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 +stl --arg @abe.jpg +``` + +Files can also be passed inside JSON or YAML blobs: + +```bash +stl --arg '{image: "@abe.jpg"}' +# Equivalent: +stl < --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 +stl --arg @data://file.txt +``` ## Workspace Configuration The CLI supports workspace configuration to avoid repeatedly specifying the project name. When you run a command, the CLI will: From d480ff2c074f611580f0a36cf96f316d5bc516ca Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:44:14 +0000 Subject: [PATCH 13/17] feat(client): provide file completions when using file embed syntax --- cmd/stl/main.go | 4 +- .../shellscripts/bash_autocomplete.bash | 48 ++++++++-- .../shellscripts/fish_autocomplete.fish | 46 +++++++--- .../shellscripts/pwsh_autocomplete.ps1 | 87 +++++++++++++++---- .../shellscripts/zsh_autocomplete.zsh | 18 ++++ 5 files changed, 165 insertions(+), 38 deletions(-) diff --git a/cmd/stl/main.go b/cmd/stl/main.go index 3f0fb5f7..f4b5b843 100644 --- a/cmd/stl/main.go +++ b/cmd/stl/main.go @@ -26,6 +26,8 @@ func main() { prepareForAutocomplete(app) } + checkVersionUpdate(updateCheck) + if err := app.Run(context.Background(), os.Args); err != nil { exitCode := 1 @@ -59,8 +61,6 @@ func prepareForAutocomplete(cmd *cli.Command) { for _, child := range cmd.Commands { prepareForAutocomplete(child) } - - checkVersionUpdate(updateCheck) } func checkVersionUpdate(updateCheck <-chan string) { diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash index 64fa6abe..8fb7b0b7 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 0164b045..b8530576 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 f712e130..7cd6e622 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 5412987d..4d4bdcd4 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 6cc53e5e0fa23babc2513843bec3ac53b0569cc5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:32:55 +0000 Subject: [PATCH 14/17] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index f3f8d4a9..f363a1d5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-2726a5c9d1b5921ca1eae4d3074e01d53b6a368df847a21722f30b34de324794.yml openapi_spec_hash: bdbc8f5388f88acaeff72e8f79e57e46 -config_hash: 3a9b615b799ae43753c266e776f34e74 +config_hash: 6c90179b9ee5bdbbbef5c039a44ac82b From a9113c7c26bb474e3118a1d4f096763881cafa15 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:56:34 +0000 Subject: [PATCH 15/17] feat(api): update support email address --- .goreleaser.yml | 2 +- .stats.yml | 2 +- SECURITY.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index c8398597..f939e8d5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -67,7 +67,7 @@ snapshot: nfpms: - license: Apache-2.0 - maintainer: local-dev@stainless.com + maintainer: support@stainless.com bindir: /usr formats: - apk diff --git a/.stats.yml b/.stats.yml index f363a1d5..eefa041d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-2726a5c9d1b5921ca1eae4d3074e01d53b6a368df847a21722f30b34de324794.yml openapi_spec_hash: bdbc8f5388f88acaeff72e8f79e57e46 -config_hash: 6c90179b9ee5bdbbbef5c039a44ac82b +config_hash: 3e46d270da9f524c0dee35db0bcf76df diff --git a/SECURITY.md b/SECURITY.md index 2472b826..624b0375 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,7 @@ or products provided by Stainless, please follow the respective company's securi ### Stainless Terms and Policies -Please contact local-dev@stainless.com for any questions or concerns regarding the security of our services. +Please contact support@stainless.com for any questions or concerns regarding the security of our services. --- From 78d791948a88bb9e509da315f8b51b89d452e945 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:34:01 +0000 Subject: [PATCH 16/17] 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 5f852481..5047da22 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -306,7 +306,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 82cfc265b7940a5ceefa4edafe22135306770665 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:34:21 -0500 Subject: [PATCH 17/17] release: 0.1.0-alpha.71 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ pkg/cmd/version.go | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3ef265bb..7391b28a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.70" + ".": "0.1.0-alpha.71" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f060a51e..2839bac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 0.1.0-alpha.71 (2026-02-06) + +Full Changelog: [v0.1.0-alpha.70...v0.1.0-alpha.71](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.70...v0.1.0-alpha.71) + +### ⚠ BREAKING CHANGES + +* add support for passing files as parameters + +### Features + +* add github version check ([3596cbb](https://github.com/stainless-api/stainless-api-cli/commit/3596cbba986e3019e9fc1f9b555728a5c737fe27)) +* add readme documentation for passing files as arguments ([3d5c3eb](https://github.com/stainless-api/stainless-api-cli/commit/3d5c3eb26a45dd52a3113fff16e6beda69434199)) +* add support for passing files as parameters ([5a44115](https://github.com/stainless-api/stainless-api-cli/commit/5a44115f285231ae36c872a9ad510b48b8d4f55c)) +* **api:** update support email address ([a9113c7](https://github.com/stainless-api/stainless-api-cli/commit/a9113c7c26bb474e3118a1d4f096763881cafa15)) +* **client:** provide file completions when using file embed syntax ([d480ff2](https://github.com/stainless-api/stainless-api-cli/commit/d480ff2c074f611580f0a36cf96f316d5bc516ca)) +* **cli:** improve shell completions for namespaced commands and flags ([d11eba5](https://github.com/stainless-api/stainless-api-cli/commit/d11eba51ee4b07e1360cefde3e329c24d47067d0)) + + +### Bug Fixes + +* cleanup and make passing project flag make more sense ([91a6b99](https://github.com/stainless-api/stainless-api-cli/commit/91a6b9977b85c115ca97eee02e655401809c19e3)) +* fix for file uploads to octet stream and form encoding endpoints ([55530da](https://github.com/stainless-api/stainless-api-cli/commit/55530da089f0017c9815c456b5a2f850a241f122)) +* fix for nullable arguments ([7bbd057](https://github.com/stainless-api/stainless-api-cli/commit/7bbd05715c474f582ce2d076831d01a6fb32e2e4)) +* fix for when terminal width is not available ([78d7919](https://github.com/stainless-api/stainless-api-cli/commit/78d791948a88bb9e509da315f8b51b89d452e945)) +* fix mock tests with inner fields that have underscores ([0fb140a](https://github.com/stainless-api/stainless-api-cli/commit/0fb140aa40f3e52e01a9a8b8142876e9fcee8647)) +* use RawJSON for iterated values instead of re-marshalling ([fcc55a6](https://github.com/stainless-api/stainless-api-cli/commit/fcc55a618a14bf956aa0af852d66e463e0073fbf)) + + +### Chores + +* add build step to ci ([c957b31](https://github.com/stainless-api/stainless-api-cli/commit/c957b31381e935c9f8c80738bf003deb5b05727e)) +* update documentation in readme ([dc0fd28](https://github.com/stainless-api/stainless-api-cli/commit/dc0fd28e141c8f50c90a47144d7257d596612ada)) + ## 0.1.0-alpha.70 (2026-01-28) Full Changelog: [v0.1.0-alpha.69...v0.1.0-alpha.70](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.69...v0.1.0-alpha.70) diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 9fc95c17..f607ade0 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0-alpha.70" // x-release-please-version +const Version = "0.1.0-alpha.71" // x-release-please-version