From 665e431d3c39a2ba149168250d080bdf8b7546b5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:39:52 +0000 Subject: [PATCH 01/51] feat: better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` --- cmd/hyperspell/main.go | 7 +++++++ pkg/cmd/cmd.go | 3 +++ pkg/cmd/cmdutil.go | 9 +++++++++ pkg/cmd/cmdutil_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/cmd/hyperspell/main.go b/cmd/hyperspell/main.go index 53d2187..33d88ad 100644 --- a/cmd/hyperspell/main.go +++ b/cmd/hyperspell/main.go @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("HYPERSPELL_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "HYPERSPELL_BASE_URL"); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + os.Exit(1) + } + } + if err := app.Run(context.Background(), os.Args); err != nil { exitCode := 1 diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 974c1e2..4e2f336 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -39,6 +39,9 @@ func init() { Name: "base-url", DefaultText: "url", Usage: "Override the base URL for API requests", + Validator: func(baseURL string) error { + return ValidateBaseURL(baseURL, "--base-url") + }, }, &cli.StringFlag{ Name: "format", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index ef593fb..93220a4 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -29,6 +29,15 @@ import ( var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} +// ValidateBaseURL checks that a base URL is correctly prefixed with a protocol scheme and produces a better +// error message than the person would see otherwise if it doesn't. +func ValidateBaseURL(value, source string) error { + if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + return fmt.Errorf("%s %q is missing a scheme (expected http:// or https://)", source, value) + } + return nil +} + func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { opts := []option.RequestOption{ option.WithHeader("User-Agent", fmt.Sprintf("Hyperspell/CLI %s", Version)), diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 0a46fd1..8487408 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -125,3 +125,42 @@ func TestCreateDownloadFile(t *testing.T) { assert.Equal(t, "passwd", filepath.Base(file.Name())) }) } + +func TestValidateBaseURL(t *testing.T) { + t.Parallel() + + t.Run("ValidHTTPS", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("https://api.example.com", "--base-url")) + }) + + t.Run("ValidHTTP", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("http://localhost:8080", "--base-url")) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("", "MY_BASE_URL")) + }) + + t.Run("MissingScheme", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("localhost:8080", "MY_BASE_URL") + require.Error(t, err) + assert.Contains(t, err.Error(), "MY_BASE_URL") + assert.Contains(t, err.Error(), "missing a scheme") + }) + + t.Run("HostOnly", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("api.example.com", "--base-url") + require.Error(t, err) + assert.Contains(t, err.Error(), "--base-url") + }) +} From 40329ac0504bec769cdcbd0eda4821b15cf164d1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:40:25 +0000 Subject: [PATCH 02/51] feat: allow `-` as value representing stdin to binary-only file parameters in CLIs --- pkg/cmd/flagoptions.go | 103 +++++++++++++++++++++++++++++++++--- pkg/cmd/flagoptions_test.go | 91 ++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 2050e06..2cf85f8 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -40,12 +40,48 @@ const ( EmbedIOReader ) -func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { +// onceStdinReader wraps an io.Reader that can only be consumed once, used to ensure stdin is read by at most +// one parameter (or only for a body root parameter or only for YAML parameter input). If reason is set, stdin +// is unavailable and read() returns an error explaining why. +type onceStdinReader struct { + stdinReader io.Reader + failureReason string +} + +func (o *onceStdinReader) read() (io.Reader, error) { + if o.failureReason != "" { + return nil, fmt.Errorf("cannot read from stdin: %s", o.failureReason) + } + if o.stdinReader == nil { + return nil, fmt.Errorf("stdin has already been read by another parameter; it can only be read once") + } + r := o.stdinReader + o.stdinReader = nil + return r, nil +} + +func (o *onceStdinReader) readAll() ([]byte, error) { + r, err := o.read() + if err != nil { + return nil, err + } + return io.ReadAll(r) +} + +func isStdinPath(s string) bool { + switch s { + case "-", "/dev/fd/0", "/dev/stdin": + return true + } + return false +} + +func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) { if obj == nil { return obj, nil } v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle) + result, err := embedFilesValue(v, embedStyle, stdin) if err != nil { return nil, err } @@ -53,7 +89,7 @@ func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { } // Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) { +func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdinReader) (reflect.Value, error) { // Unwrap interface values to get the concrete type if v.Kind() == reflect.Interface { if v.IsNil() { @@ -74,7 +110,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, for iter.Next() { key := iter.Key() val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle) + newVal, err := embedFilesValue(val, embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -89,7 +125,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // 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), embedStyle) + newVal, err := embedFilesValue(v.Index(i), embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -106,6 +142,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, if s == "" { return v, nil } + if isStdinPath(s) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } content, err := os.ReadFile(s) if err != nil { return v, err @@ -123,6 +166,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, 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) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { return v, err @@ -132,12 +182,29 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // The "@file://" prefix is for files that you explicitly want to // upload as a string literal with backslash escapes (not base64 // encoded) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } 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 { + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + if isUTF8TextFile(content) { + return reflect.ValueOf(string(content)), nil + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { // If the string is "@username", it's probably supposed to be a @@ -175,6 +242,14 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") } + if isStdinPath(filename) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + file, err := os.Open(filename) if err != nil { if !expectsFile { @@ -234,6 +309,7 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) + stdinConsumedByPipe := false if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { @@ -241,6 +317,7 @@ func flagOptions( } if len(pipeData) > 0 { + stdinConsumedByPipe = true var bodyData any if err := yaml.Unmarshal(pipeData, &bodyData); err != nil { return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) @@ -279,24 +356,34 @@ func flagOptions( // via explicit CLI flags and values that arrived via piped YAML/JSON data. wrapFileInputValues(cmd, &requestContents) + // Determine stdin availability for FileInput params that use "-". + var stdinReader onceStdinReader + if ignoreStdin { + stdinReader = onceStdinReader{failureReason: "stdin is already being used for the request body"} + } else if stdinConsumedByPipe { + stdinReader = onceStdinReader{failureReason: "stdin was already consumed by piped YAML/JSON input"} + } else { + stdinReader = onceStdinReader{stdinReader: os.Stdin} + } + // Embed files passed as "@file.jpg" in the request body, headers, and query: embedStyle := EmbedText if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { embedStyle = EmbedIOReader } - if embedded, err := embedFiles(requestContents.Body, embedStyle); err != nil { + if embedded, err := embedFiles(requestContents.Body, embedStyle, &stdinReader); err != nil { return nil, err } else { requestContents.Body = embedded } - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText); err != nil { + if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Headers = headersWithFiles.(map[string]any) } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText); err != nil { + if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Queries = queriesWithFiles.(map[string]any) diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index e5dad4b..9a7fe3b 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -2,8 +2,10 @@ package cmd import ( "encoding/base64" + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +13,7 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + tests := []struct { content []byte expected bool @@ -32,6 +35,7 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + // Create temporary directory for test files tmpDir := t.TempDir() @@ -216,7 +220,8 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { - got, err := embedFiles(tt.input, EmbedText) + + got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { assert.Error(t, err) } else { @@ -226,7 +231,8 @@ func TestEmbedFiles(t *testing.T) { }) t.Run(tt.name+" io.Reader", func(t *testing.T) { - _, err := embedFiles(tt.input, EmbedIOReader) + + _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { assert.Error(t, err) } else { @@ -236,9 +242,90 @@ func TestEmbedFiles(t *testing.T) { } } +func TestEmbedFilesStdin(t *testing.T) { + + t.Run("FilePathValueDash", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("FilePathValueDevStdin", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("/dev/stdin")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + _, err := embedFiles(map[string]any{ + "file1": FilePathValue("-"), + "file2": FilePathValue("-"), + }, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "already been read") + }) + + t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + + stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} + + _, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot read from stdin") + require.Contains(t, err.Error(), "request body") + }) + + t.Run("AtDashEmbedText", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"data": "piped content"}, withEmbedded) + }) + + t.Run("AtDashEmbedIOReader", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedIOReader, stdin) + require.NoError(t, err) + + withEmbeddedMap := withEmbedded.(map[string]any) + r := withEmbeddedMap["data"].(io.ReadCloser) + + content, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, "piped content", string(content)) + }) + + t.Run("FilePathValueRealFile", func(t *testing.T) { + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "test.txt", "file content") + + stdin := &onceStdinReader{stdinReader: strings.NewReader("unused stdin")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(filepath.Join(tmpDir, "test.txt"))}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "file content"}, withEmbedded) + }) +} + 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 2f49e45e69e821c97605e3929f4d6864f2b306e7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:40:41 +0000 Subject: [PATCH 03/51] chore: switch some CLI Go tests from `os.Chdir` to `t.Chdir` --- pkg/cmd/cmdutil_test.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 8487408..550c995 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -67,10 +67,7 @@ func TestWriteBinaryResponse(t *testing.T) { func TestCreateDownloadFile(t *testing.T) { t.Run("creates file with filename from header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ @@ -96,10 +93,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("creates temp file when no header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{Header: http.Header{}} file, err := createDownloadFile(resp, []byte("test content")) @@ -109,10 +103,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("prevents directory traversal", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ From 5fc240280f247a2a3817b37e359c7c674048a448 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:40:57 +0000 Subject: [PATCH 04/51] chore: mark all CLI-related tests in Go with `t.Parallel()` --- internal/apiform/form_test.go | 4 ++ internal/apiquery/query_test.go | 4 ++ internal/autocomplete/autocomplete_test.go | 40 +++++++++++++++ internal/jsonview/explorer_test.go | 6 +++ internal/requestflag/innerflag_test.go | 28 +++++++++++ internal/requestflag/requestflag_test.go | 58 ++++++++++++++++++++++ pkg/cmd/flagoptions_test.go | 12 +++++ 7 files changed, 152 insertions(+) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index 2cf5bdd..f68cfd1 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -85,8 +85,12 @@ var tests = map[string]struct { } func TestEncode(t *testing.T) { + t.Parallel() + for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + buf := bytes.NewBuffer(nil) writer := multipart.NewWriter(buf) writer.SetBoundary("xxx") diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go index 8bee784..3791ec9 100644 --- a/internal/apiquery/query_test.go +++ b/internal/apiquery/query_test.go @@ -6,6 +6,8 @@ import ( ) func TestEncode(t *testing.T) { + t.Parallel() + tests := map[string]struct { val any settings QuerySettings @@ -114,6 +116,8 @@ func TestEncode(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + query := map[string]any{"query": test.val} values, err := MarshalWithSettings(query, test.settings) if err != nil { diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go index 3e8aa33..2338924 100644 --- a/internal/autocomplete/autocomplete_test.go +++ b/internal/autocomplete/autocomplete_test.go @@ -8,6 +8,8 @@ import ( ) func TestGetCompletions_EmptyArgs(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -26,6 +28,8 @@ func TestGetCompletions_EmptyArgs(t *testing.T) { } func TestGetCompletions_SubcommandPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -43,6 +47,8 @@ func TestGetCompletions_SubcommandPrefix(t *testing.T) { } func TestGetCompletions_HiddenCommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "visible", Usage: "Visible command"}, @@ -57,6 +63,8 @@ func TestGetCompletions_HiddenCommand(t *testing.T) { } func TestGetCompletions_NestedSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -79,6 +87,8 @@ func TestGetCompletions_NestedSubcommand(t *testing.T) { } func TestGetCompletions_FlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -102,6 +112,8 @@ func TestGetCompletions_FlagCompletion(t *testing.T) { } func TestGetCompletions_ShortFlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -123,6 +135,8 @@ func TestGetCompletions_ShortFlagCompletion(t *testing.T) { } func TestGetCompletions_FileFlagBehavior(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -142,6 +156,8 @@ func TestGetCompletions_FileFlagBehavior(t *testing.T) { } func TestGetCompletions_NonBoolFlagValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -161,6 +177,8 @@ func TestGetCompletions_NonBoolFlagValue(t *testing.T) { } func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -185,6 +203,8 @@ func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { } func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -202,6 +222,8 @@ func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -221,6 +243,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -240,6 +264,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -257,6 +283,8 @@ func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -271,6 +299,8 @@ func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { } func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -287,6 +317,8 @@ func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { } func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -305,6 +337,8 @@ func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { } func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -329,6 +363,8 @@ func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { } func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -353,6 +389,8 @@ func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { } func TestGetCompletions_CommandAliases(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, @@ -372,6 +410,8 @@ func TestGetCompletions_CommandAliases(t *testing.T) { } func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go index c559254..67ee730 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -10,6 +10,8 @@ import ( ) func TestNavigateForward_EmptyRowData(t *testing.T) { + t.Parallel() + // An empty JSON array produces a TableView with no rows. emptyArray := gjson.Parse("[]") view, err := newTableView("", emptyArray, false) @@ -38,6 +40,8 @@ type rawJSONItem struct { func (r rawJSONItem) RawJSON() string { return r.raw } func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ rawJSONItem{raw: `{"id":1,"name":"alice"}`}, rawJSONItem{raw: `{"id":2,"name":"bob"}`}, @@ -49,6 +53,8 @@ func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { } func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ map[string]any{"id": 1, "name": "alice"}, map[string]any{"id": 2, "name": "bob"}, diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go index 3f204c9..133e8b4 100644 --- a/internal/requestflag/innerflag_test.go +++ b/internal/requestflag/innerflag_test.go @@ -8,6 +8,8 @@ import ( ) func TestInnerFlagSet(t *testing.T) { + t.Parallel() + tests := []struct { name string flagType string @@ -27,6 +29,8 @@ func TestInnerFlagSet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{ Name: "test-flag", } @@ -81,6 +85,8 @@ func TestInnerFlagSet(t *testing.T) { } func TestInnerFlagValidator(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "test-flag"} innerFlag := &InnerFlag[int64]{ @@ -105,6 +111,8 @@ func TestInnerFlagValidator(t *testing.T) { } func TestWithInnerFlags(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[string]{ Name: "outer.baz", @@ -126,6 +134,8 @@ func TestWithInnerFlags(t *testing.T) { } func TestInnerFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -143,6 +153,8 @@ func TestInnerFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) @@ -150,8 +162,12 @@ func TestInnerFlagTypeNames(t *testing.T) { } func TestInnerYamlHandling(t *testing.T) { + t.Parallel() + // Test with map value t.Run("Parse YAML to map", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -176,6 +192,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -190,6 +208,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting inner flags on a map multiple times t.Run("Set inner flags on map multiple times", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // Set first inner flag @@ -219,6 +239,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting YAML and then an inner flag t.Run("Set YAML and then inner flag", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // First set the outer flag with YAML @@ -246,7 +268,11 @@ func TestInnerYamlHandling(t *testing.T) { } func TestInnerFlagWithSliceType(t *testing.T) { + t.Parallel() + t.Run("Setting inner flags on slice of maps", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[[]map[string]any]{Name: "outer"} // Set first inner flag (should create first item) @@ -284,6 +310,8 @@ func TestInnerFlagWithSliceType(t *testing.T) { }) t.Run("Appending to existing slice", func(t *testing.T) { + t.Parallel() + // Initialize with existing items outerFlag := &Flag[[]map[string]any]{Name: "outer"} err := outerFlag.Set(outerFlag.Name, `{name: initial}`) diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 9751904..0e86e07 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -11,6 +11,8 @@ import ( ) func TestDateValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -56,6 +58,8 @@ func TestDateValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateValue err := d.Parse(tt.input) @@ -70,6 +74,8 @@ func TestDateValueParse(t *testing.T) { } func TestDateTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -119,6 +125,8 @@ func TestDateTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateTimeValue err := d.Parse(tt.input) @@ -136,6 +144,8 @@ func TestDateTimeValueParse(t *testing.T) { } func TestTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -181,6 +191,8 @@ func TestTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var tv TimeValue err := tv.Parse(tt.input) @@ -195,7 +207,11 @@ func TestTimeValueParse(t *testing.T) { } func TestRequestParams(t *testing.T) { + t.Parallel() + t.Run("map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -283,6 +299,8 @@ func TestRequestParams(t *testing.T) { }) t.Run("non-map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -304,6 +322,8 @@ func TestRequestParams(t *testing.T) { } func TestFlagSet(t *testing.T) { + t.Parallel() + strFlag := &Flag[string]{ Name: "string-flag", Default: "default-string", @@ -327,38 +347,52 @@ func TestFlagSet(t *testing.T) { // Test initialization and setting t.Run("PreParse initialization", func(t *testing.T) { + t.Parallel() + assert.NoError(t, strFlag.PreParse()) assert.True(t, strFlag.applied) assert.Equal(t, "default-string", strFlag.Get()) }) t.Run("Set string flag", func(t *testing.T) { + t.Parallel() + assert.NoError(t, strFlag.Set("string-flag", "new-value")) assert.Equal(t, "new-value", strFlag.Get()) assert.True(t, strFlag.IsSet()) }) t.Run("Set int flag with valid value", func(t *testing.T) { + t.Parallel() + assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) assert.Equal(t, int64(100), superstitiousIntFlag.Get()) assert.True(t, superstitiousIntFlag.IsSet()) }) t.Run("Set int flag with invalid value", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) }) t.Run("Set int flag with validator failing", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) }) t.Run("Set bool flag", func(t *testing.T) { + t.Parallel() + assert.NoError(t, boolFlag.Set("bool-flag", "true")) assert.Equal(t, true, boolFlag.Get()) assert.True(t, boolFlag.IsSet()) }) t.Run("Set slice flag with multiple values", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{}, @@ -381,6 +415,8 @@ func TestFlagSet(t *testing.T) { }) t.Run("Set slice flag with a nonempty default", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{99, 100}, @@ -400,6 +436,8 @@ func TestFlagSet(t *testing.T) { } func TestParseTimeWithFormats(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -439,6 +477,8 @@ func TestParseTimeWithFormats(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseTimeWithFormats(tt.input, tt.formats) if tt.wantErr { @@ -452,8 +492,12 @@ func TestParseTimeWithFormats(t *testing.T) { } func TestYamlHandling(t *testing.T) { + t.Parallel() + // Test with any value t.Run("Parse YAML to any", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("name: test\nvalue: 42\n") assert.NoError(t, err) @@ -478,6 +522,8 @@ func TestYamlHandling(t *testing.T) { // Test with array t.Run("Parse YAML array", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("- item1\n- item2\n- item3\n") assert.NoError(t, err) @@ -495,6 +541,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[any]{ Name: "file-flag", Default: nil, @@ -507,6 +555,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt list as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[[]any]{ Name: "file-flag", Default: nil, @@ -520,6 +570,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse identifiers as YAML", func(t *testing.T) { + t.Parallel() + tests := []string{ "hello", "e4e355fa-b03b-4c57-a73d-25c9733eec79", @@ -555,6 +607,8 @@ func TestYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + invalidYaml := `[not closed` cv := &cliValue[any]{} err := cv.Set(invalidYaml) @@ -563,6 +617,8 @@ func TestYamlHandling(t *testing.T) { } func TestFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -583,6 +639,8 @@ func TestFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index 9a7fe3b..039b9ff 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -13,6 +13,7 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + t.Parallel() tests := []struct { content []byte @@ -35,6 +36,7 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + t.Parallel() // Create temporary directory for test files tmpDir := t.TempDir() @@ -220,6 +222,7 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { + t.Parallel() got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { @@ -231,6 +234,7 @@ func TestEmbedFiles(t *testing.T) { }) t.Run(tt.name+" io.Reader", func(t *testing.T) { + t.Parallel() _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { @@ -243,8 +247,10 @@ func TestEmbedFiles(t *testing.T) { } func TestEmbedFilesStdin(t *testing.T) { + t.Parallel() t.Run("FilePathValueDash", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -254,6 +260,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueDevStdin", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -263,6 +270,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -275,6 +283,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} @@ -285,6 +294,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("AtDashEmbedText", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} @@ -294,6 +304,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("AtDashEmbedIOReader", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} @@ -309,6 +320,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueRealFile", func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() writeTestFile(t, tmpDir, "test.txt", "file content") From 07389a55c46d676894d6392d7c6ed4cf33ac4147 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:30:16 +0000 Subject: [PATCH 05/51] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/memory.go | 9 ++++----- pkg/cmd/memory_test.go | 12 ++++-------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.stats.yml b/.stats.yml index 7c3ad8f..f8a5ae1 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-7d29d0843a52840291678a3c6d136f496ae1f956853abaa5003c1284ca2c94aa.yml -openapi_spec_hash: 010597ad0ec6376fbf2f01ec062787ad +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a73d280435ed6084d8ac9d9d7feb235de6141d866d40725ed26a27b87b0cf364.yml +openapi_spec_hash: 91eabc37804d07ce801b1d4ea1778d1c config_hash: bd8505e17db740d82e578d0edaa9bfe0 diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index d59229d..6220d3b 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -371,11 +371,10 @@ var memoriesUpload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file", - Usage: "The file to ingest.", - Required: true, - BodyPath: "file", - FileInput: true, + Name: "file", + Usage: "The file to ingest.", + Required: true, + BodyPath: "file", }, &requestflag.Flag[any]{ Name: "collection", diff --git a/pkg/cmd/memory_test.go b/pkg/cmd/memory_test.go index 7638074..05afdae 100644 --- a/pkg/cmd/memory_test.go +++ b/pkg/cmd/memory_test.go @@ -3,7 +3,6 @@ package cmd import ( - "strings" "testing" "github.com/hyperspell/hyperspell-cli/internal/mocktest" @@ -307,21 +306,18 @@ func TestMemoriesUpload(t *testing.T) { "--api-key", "string", "--user-id", "string", "memories", "upload", - "--file", mocktest.TestFile(t, "Example data"), + "--file", "file", "--collection", "collection", "--metadata", "metadata", ) }) t.Run("piping data", func(t *testing.T) { - testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeDataStr := "" + - "file: Example data\n" + + pipeData := []byte("" + + "file: file\n" + "collection: collection\n" + - "metadata: metadata\n" - pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) - pipeData := []byte(pipeDataStr) + "metadata: metadata\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", From b7fd5533571992857a741f834af4d272b8ad2b6d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 05:07:51 +0000 Subject: [PATCH 06/51] chore: modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary --- pkg/cmd/cmdutil.go | 9 ++++++--- pkg/cmd/cmdutil_test.go | 15 ++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 93220a4..9662b6d 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -196,7 +196,10 @@ func streamToStdout(generateOutput func(w *os.File) error) error { return err } -func writeBinaryResponse(response *http.Response, outfile string) (string, error) { +// writeBinaryResponse writes a binary response to stdout or a file. +// +// Takes in a stdout reference so we can test this function without overriding os.Stdout in tests. +func writeBinaryResponse(response *http.Response, stdout io.Writer, outfile string) (string, error) { defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { @@ -204,13 +207,13 @@ func writeBinaryResponse(response *http.Response, outfile string) (string, error } switch outfile { case "-", "/dev/stdout": - _, err := os.Stdout.Write(body) + _, err := stdout.Write(body) return "", err case "": // If output file is unspecified, then print to stdout for plain text or // if stdout is not a terminal: if !isTerminal(os.Stdout) || isUTF8TextFile(body) { - _, err := os.Stdout.Write(body) + _, err := stdout.Write(body) return "", err } diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 550c995..8eca397 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -32,7 +32,7 @@ func TestWriteBinaryResponse(t *testing.T) { Body: io.NopCloser(bytes.NewReader(body)), } - msg, err := writeBinaryResponse(resp, outfile) + msg, err := writeBinaryResponse(resp, os.Stdout, outfile) require.NoError(t, err) assert.Contains(t, msg, outfile) @@ -43,24 +43,17 @@ func TestWriteBinaryResponse(t *testing.T) { }) t.Run("write to stdout", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + t.Parallel() + var buf bytes.Buffer body := []byte("stdout content") resp := &http.Response{ Body: io.NopCloser(bytes.NewReader(body)), } - msg, err := writeBinaryResponse(resp, "-") - - w.Close() - os.Stdout = oldStdout + msg, err := writeBinaryResponse(resp, &buf, "-") require.NoError(t, err) assert.Empty(t, msg) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) assert.Equal(t, body, buf.Bytes()) }) } From 28b24981adf8c8c39a68a44495912a66e63945ea Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:10:22 +0000 Subject: [PATCH 07/51] fix: fall back to main branch if linking fails in CI --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7455843..d5e0c55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go - name: Bootstrap run: ./scripts/bootstrap From d4f35370e45ed2cc4deceeb6b8fae13f0a07fb63 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:11:10 +0000 Subject: [PATCH 08/51] fix: fix quoting typo --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e0c55..0ef4666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go' - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go' - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go' - name: Bootstrap run: ./scripts/bootstrap From 2f691007ba68d3b18b6a643db158ed16b4848871 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:30:36 +0000 Subject: [PATCH 09/51] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/memory.go | 9 +++++---- pkg/cmd/memory_test.go | 12 ++++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.stats.yml b/.stats.yml index f8a5ae1..7c3ad8f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a73d280435ed6084d8ac9d9d7feb235de6141d866d40725ed26a27b87b0cf364.yml -openapi_spec_hash: 91eabc37804d07ce801b1d4ea1778d1c +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-7d29d0843a52840291678a3c6d136f496ae1f956853abaa5003c1284ca2c94aa.yml +openapi_spec_hash: 010597ad0ec6376fbf2f01ec062787ad config_hash: bd8505e17db740d82e578d0edaa9bfe0 diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 6220d3b..d59229d 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -371,10 +371,11 @@ var memoriesUpload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file", - Usage: "The file to ingest.", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "The file to ingest.", + Required: true, + BodyPath: "file", + FileInput: true, }, &requestflag.Flag[any]{ Name: "collection", diff --git a/pkg/cmd/memory_test.go b/pkg/cmd/memory_test.go index 05afdae..7638074 100644 --- a/pkg/cmd/memory_test.go +++ b/pkg/cmd/memory_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/hyperspell/hyperspell-cli/internal/mocktest" @@ -306,18 +307,21 @@ func TestMemoriesUpload(t *testing.T) { "--api-key", "string", "--user-id", "string", "memories", "upload", - "--file", "file", + "--file", mocktest.TestFile(t, "Example data"), "--collection", "collection", "--metadata", "metadata", ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + - "file: file\n" + + pipeDataStr := "" + + "file: Example data\n" + "collection: collection\n" + - "metadata: metadata\n") + "metadata: metadata\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", From 61bdeb0835f89e6606ee1130b9738378a3dd73d7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:30:26 +0000 Subject: [PATCH 10/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 7c3ad8f..47b83a9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-7d29d0843a52840291678a3c6d136f496ae1f956853abaa5003c1284ca2c94aa.yml -openapi_spec_hash: 010597ad0ec6376fbf2f01ec062787ad +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-88c2041bdb5822ea3cbe99c4964f6445dc7c9df525250890d47b761b4a7a2510.yml +openapi_spec_hash: c4ccecb557509b14f5fff33330523964 config_hash: bd8505e17db740d82e578d0edaa9bfe0 From 78f8725007cde598cc4f5d7e3acbb9c34e061b18 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:30:17 +0000 Subject: [PATCH 11/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 47b83a9..61b9e21 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-88c2041bdb5822ea3cbe99c4964f6445dc7c9df525250890d47b761b4a7a2510.yml -openapi_spec_hash: c4ccecb557509b14f5fff33330523964 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-460d7c66cd5e8cf979cd761066c51d8f813a119a20e2149fcfcf847eb650d545.yml +openapi_spec_hash: 8ee512464a88de45c86faf4f46f4905c config_hash: bd8505e17db740d82e578d0edaa9bfe0 From d9c0c77f238264b97af2c5fa8aacbd71de7f6f4b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:56:00 +0000 Subject: [PATCH 12/51] chore(cli): let `--format raw` be used in conjunction with `--transform` --- pkg/cmd/cmdutil.go | 4 ++-- pkg/cmd/cmdutil_test.go | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 9662b6d..ad64a4b 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -315,7 +315,7 @@ func shouldUseColors(w io.Writer) bool { } func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { - if format != "raw" && transform != "" { + if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed @@ -359,7 +359,7 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format // Display JSON to the user in various different formats func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error { - if format != "raw" && transform != "" { + if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 8eca397..5178057 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestStreamOutput(t *testing.T) { @@ -148,3 +149,44 @@ func TestValidateBaseURL(t *testing.T) { assert.Contains(t, err.Error(), "--base-url") }) } + +func TestFormatJSON(t *testing.T) { + t.Parallel() + + t.Run("RawWithTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id") + require.NoError(t, err) + require.Equal(t, `"abc123"`+"\n", string(formatted)) + }) + + t.Run("RawWithoutTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "") + require.NoError(t, err) + require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) + }) + + t.Run("RawWithNestedTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items") + require.NoError(t, err) + require.Equal(t, "[1,2,3]\n", string(formatted)) + }) + + t.Run("RawWithNonexistentTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing") + require.NoError(t, err) + // Transform path doesn't exist, so original result is returned + require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) + }) +} From 5784e710f1021b0939a8985eedaa0c80b0b9df0b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:05:00 +0000 Subject: [PATCH 13/51] chore(cli): additional test cases for `ShowJSONIterator` --- pkg/cmd/cmdutil_test.go | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 5178057..eee2365 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + + "github.com/hyperspell/hyperspell-cli/internal/jsonview" ) func TestStreamOutput(t *testing.T) { @@ -190,3 +192,79 @@ func TestFormatJSON(t *testing.T) { require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) }) } + +func TestShowJSONIterator(t *testing.T) { + t.Parallel() + + t.Run("RawMultipleItems", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc", "name": "first"}, + {"id": "def", "name": "second"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "", -1) + assert.Equal(t, `{"id":"abc","name":"first"}`+"\n"+`{"id":"def","name":"second"}`+"\n", captured) + }) + + t.Run("RawWithTransform", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc", "name": "first"}, + {"id": "def", "name": "second"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "id", -1) + assert.Equal(t, `"abc"`+"\n"+`"def"`+"\n", captured) + }) + + t.Run("LimitItems", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc"}, + {"id": "def"}, + {"id": "ghi"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "", 2) + assert.Equal(t, `{"id":"abc"}`+"\n"+`{"id":"def"}`+"\n", captured) + }) +} + +// sliceIterator is a simple iterator over a slice for testing. +type sliceIterator[T any] struct { + index int + items []T +} + +func (it *sliceIterator[T]) Next() bool { + it.index++ + return it.index <= len(it.items) +} + +func (it *sliceIterator[T]) Current() T { + return it.items[it.index-1] +} + +func (it *sliceIterator[T]) Err() error { + return nil +} + +var _ jsonview.Iterator[any] = (*sliceIterator[any])(nil) + +// captureShowJSONIterator runs ShowJSONIterator and captures the output written to a file. +func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], format, transform string, itemsToDisplay int64) string { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + err = ShowJSONIterator(w, "test", iter, format, transform, itemsToDisplay) + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} From ad3cbf15ab7a70f3318ae32d95b7f6bebeaebceb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:07:19 +0000 Subject: [PATCH 14/51] fix: fix for failing to drop invalid module replace in link script --- .github/workflows/ci.yml | 6 +++--- scripts/link | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ef4666..7455843 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go' + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go' + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/hyperspell-cli' run: | - ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/hyperspell-go' + ./scripts/link 'github.com/stainless-sdks/hyperspell-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap diff --git a/scripts/link b/scripts/link index 0366ecf..837f0b7 100755 --- a/scripts/link +++ b/scripts/link @@ -9,5 +9,9 @@ export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/hyperspell/hyperspell-go,g REPLACEMENT="${1:-"../hyperspell-go"}" echo "==> Replacing Go SDK with $REPLACEMENT" -go mod edit -replace github.com/hyperspell/hyperspell-go="$REPLACEMENT" -go mod tidy -e +if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then + go mod edit -replace github.com/hyperspell/hyperspell-go="$REPLACEMENT" + go mod tidy -e +else + echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" +fi From a95a486ccc528af84af0a8aa0500680982ee6033 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:08:02 +0000 Subject: [PATCH 15/51] fix(cli): fix incompatible Go types for flag generated as array of maps --- pkg/cmd/memory.go | 6 +++--- pkg/cmd/session.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index d59229d..3544f10 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -141,7 +141,7 @@ var memoriesAdd = cli.Command{ Usage: "Date of the document. Depending on the document, this could be the creation date or date the document was last updated (eg. for a chat transcript, this would be the date of the last message). This helps the ranking algorithm and allows you to filter by date range.", BodyPath: "date", }, - &requestflag.Flag[any]{ + &requestflag.Flag[map[string]any]{ Name: "metadata", Usage: "Custom metadata for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, boolean, or null.", BodyPath: "metadata", @@ -192,7 +192,7 @@ var memoriesAddBulk = requestflag.WithInnerFlags(cli.Command{ Usage: "Date of the document. Depending on the document, this could be the creation date or date the document was last updated (eg. for a chat transcript, this would be the date of the last message). This helps the ranking algorithm and allows you to filter by date range.", InnerField: "date", }, - &requestflag.InnerFlag[any]{ + &requestflag.InnerFlag[map[string]any]{ Name: "item.metadata", Usage: "Custom metadata for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, boolean, or null.", InnerField: "metadata", @@ -293,7 +293,7 @@ var memoriesSearch = requestflag.WithInnerFlags(cli.Command{ Usage: "Search options for Box", InnerField: "box", }, - &requestflag.InnerFlag[any]{ + &requestflag.InnerFlag[map[string]any]{ Name: "options.filter", Usage: "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", InnerField: "filter", diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index bcb54af..0ab3449 100644 --- a/pkg/cmd/session.go +++ b/pkg/cmd/session.go @@ -42,7 +42,7 @@ var sessionsAdd = cli.Command{ Usage: "Trace format: 'vercel', 'hyperdoc', or 'openclaw'. Auto-detected if not set.", BodyPath: "format", }, - &requestflag.Flag[any]{ + &requestflag.Flag[map[string]any]{ Name: "metadata", Usage: "Custom metadata for filtering. Keys must be alphanumeric with underscores, max 64 chars.", BodyPath: "metadata", From 62e382b449383807ab1568d97c5f461fed2c702c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:05:59 +0000 Subject: [PATCH 16/51] docs: update examples --- README.md | 2 +- pkg/cmd/memory_test.go | 37 +++++++++++++++++++------------------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8c362a4..061e662 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ hyperspell [resource] [flags...] ```sh hyperspell memories add \ --api-key 'My API Key' \ - --text text + --text ... ``` For details about specific commands, use the `--help` flag. diff --git a/pkg/cmd/memory_test.go b/pkg/cmd/memory_test.go index 7638074..60dd91e 100644 --- a/pkg/cmd/memory_test.go +++ b/pkg/cmd/memory_test.go @@ -83,25 +83,27 @@ func TestMemoriesAdd(t *testing.T) { "--api-key", "string", "--user-id", "string", "memories", "add", - "--text", "text", - "--collection", "collection", + "--text", "...", + "--collection", "my-collection", "--date", "'2019-12-27T18:11:19.117Z'", - "--metadata", "{foo: string}", + "--metadata", "{author: John Doe, date: '2025-05-20T02:31:00Z', rating: 3}", "--resource-id", "resource_id", - "--title", "title", + "--title", "My Document", ) }) t.Run("piping data", func(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + - "text: text\n" + - "collection: collection\n" + + "text: ...\n" + + "collection: my-collection\n" + "date: '2019-12-27T18:11:19.117Z'\n" + "metadata:\n" + - " foo: string\n" + + " author: John Doe\n" + + " date: '2025-05-20T02:31:00Z'\n" + + " rating: 3\n" + "resource_id: resource_id\n" + - "title: title\n") + "title: My Document\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", @@ -183,12 +185,12 @@ func TestMemoriesSearch(t *testing.T) { "--api-key", "string", "--user-id", "string", "memories", "search", - "--query", "query", + "--query", "What does Hyperspell do?", "--answer=true", "--effort", "0", "--max-results", "0", - "--options", "{after: '2019-12-27T18:11:19.117Z', answer_model: llama-3.1, before: '2019-12-27T18:11:19.117Z', box: {weight: 0}, filter: {foo: bar}, google_calendar: {calendar_id: calendar_id, weight: 0}, google_drive: {weight: 0}, google_mail: {label_ids: [string], weight: 0}, max_results: 200, memory_types: [procedure], notion: {notion_page_ids: [string], weight: 0}, reddit: {period: hour, sort: relevance, subreddit: subreddit, weight: 0}, resource_ids: [string], slack: {channels: [string], exclude_archived: true, include_dms: true, include_group_dms: true, include_private: true, weight: 0}, vault: {weight: 0}, web_crawler: {max_depth: 0, url: url, weight: 0}}", - "--source", "reddit", + "--options", "{after: '2019-12-27T18:11:19.117Z', answer_model: llama-3.1, before: '2019-12-27T18:11:19.117Z', box: {weight: 0}, filter: {}, google_calendar: {calendar_id: calendar_id, weight: 0}, google_drive: {weight: 0}, google_mail: {label_ids: [string], weight: 0}, max_results: 200, memory_types: [procedure], notion: {notion_page_ids: [string], weight: 0}, reddit: {period: hour, sort: relevance, subreddit: subreddit, weight: 0}, resource_ids: [string], slack: {channels: [string], exclude_archived: true, include_dms: true, include_group_dms: true, include_private: true, weight: 0}, vault: {weight: 0}, web_crawler: {max_depth: 0, url: url, weight: 0}}", + "--source", "vault", ) }) @@ -202,7 +204,7 @@ func TestMemoriesSearch(t *testing.T) { "--api-key", "string", "--user-id", "string", "memories", "search", - "--query", "query", + "--query", "What does Hyperspell do?", "--answer=true", "--effort", "0", "--max-results", "0", @@ -210,7 +212,7 @@ func TestMemoriesSearch(t *testing.T) { "--options.answer-model", "llama-3.1", "--options.before", "2019-12-27T18:11:19.117Z", "--options.box", "{weight: 0}", - "--options.filter", "{foo: bar}", + "--options.filter", "{}", "--options.google-calendar", "{calendar_id: calendar_id, weight: 0}", "--options.google-drive", "{weight: 0}", "--options.google-mail", "{label_ids: [string], weight: 0}", @@ -222,14 +224,14 @@ func TestMemoriesSearch(t *testing.T) { "--options.slack", "{channels: [string], exclude_archived: true, include_dms: true, include_group_dms: true, include_private: true, weight: 0}", "--options.vault", "{weight: 0}", "--options.web-crawler", "{max_depth: 0, url: url, weight: 0}", - "--source", "reddit", + "--source", "vault", ) }) t.Run("piping data", func(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + - "query: query\n" + + "query: What does Hyperspell do?\n" + "answer: true\n" + "effort: 0\n" + "max_results: 0\n" + @@ -239,8 +241,7 @@ func TestMemoriesSearch(t *testing.T) { " before: '2019-12-27T18:11:19.117Z'\n" + " box:\n" + " weight: 0\n" + - " filter:\n" + - " foo: bar\n" + + " filter: {}\n" + " google_calendar:\n" + " calendar_id: calendar_id\n" + " weight: 0\n" + @@ -279,7 +280,7 @@ func TestMemoriesSearch(t *testing.T) { " url: url\n" + " weight: 0\n" + "sources:\n" + - " - reddit\n") + " - vault\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", From 2586047b1af969fcd3b5c21e52c072a7b620118d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:23:16 +0000 Subject: [PATCH 17/51] chore: add documentation for ./scripts/link --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 061e662..03e2679 100644 --- a/README.md +++ b/README.md @@ -119,3 +119,23 @@ base64-encoding). Note that absolute paths will begin with `@file://` or ```bash hyperspell --arg @data://file.txt ``` + +## Linking different Go SDK versions + +You can link the CLI against a different version of the Hyperspell Go SDK +for development purposes using the `./scripts/link` script. + +To link to a specific version from a repository (version can be a branch, +git tag, or commit hash): + +```bash +./scripts/link github.com/org/repo@version +``` + +To link to a local copy of the SDK: + +```bash +./scripts/link ../path/to/hyperspell-go +``` + +If you run the link script without any arguments, it will default to `../hyperspell-go`. From 1b5c7e9de0a6ceaf663c5a06365a0ce1ec11cca4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 05:05:20 +0000 Subject: [PATCH 18/51] chore(cli): fall back to JSON when using default "explore" with non-TTY --- cmd/hyperspell/main.go | 2 +- pkg/cmd/action.go | 6 ++- pkg/cmd/auth.go | 9 ++-- pkg/cmd/cmdutil.go | 29 +++++++++--- pkg/cmd/cmdutil_test.go | 70 +++++++++++++++++++++++++++- pkg/cmd/connection.go | 6 ++- pkg/cmd/evaluate.go | 9 ++-- pkg/cmd/folder.go | 12 +++-- pkg/cmd/integration.go | 6 ++- pkg/cmd/integrationgooglecalendar.go | 3 +- pkg/cmd/integrationslack.go | 3 +- pkg/cmd/integrationwebcrawler.go | 3 +- pkg/cmd/memory.go | 29 ++++++++---- pkg/cmd/session.go | 3 +- pkg/cmd/vault.go | 5 +- 15 files changed, 155 insertions(+), 40 deletions(-) diff --git a/cmd/hyperspell/main.go b/cmd/hyperspell/main.go index 33d88ad..5357867 100644 --- a/cmd/hyperspell/main.go +++ b/cmd/hyperspell/main.go @@ -43,7 +43,7 @@ func main() { fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) format := app.String("format-error") json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error")) + show_err := cmd.ShowJSON(os.Stdout, os.Stderr, "Error", json, format, app.IsSet("format-error"), app.String("transform-error")) if show_err != nil { // Just print the original error: fmt.Fprintf(os.Stderr, "%s\n", err.Error()) diff --git a/pkg/cmd/action.go b/pkg/cmd/action.go index 02cc198..55363b2 100644 --- a/pkg/cmd/action.go +++ b/pkg/cmd/action.go @@ -121,8 +121,9 @@ func handleActionsAddReaction(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "actions add-reaction", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "actions add-reaction", obj, format, explicitFormat, transform) } func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { @@ -155,6 +156,7 @@ func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "actions send-message", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "actions send-message", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index ffeb97c..f62a3db 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -86,8 +86,9 @@ func handleAuthDeleteUser(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "auth delete-user", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "auth delete-user", obj, format, explicitFormat, transform) } func handleAuthMe(ctx context.Context, cmd *cli.Command) error { @@ -118,8 +119,9 @@ func handleAuthMe(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "auth me", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "auth me", obj, format, explicitFormat, transform) } func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { @@ -152,6 +154,7 @@ func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "auth user-token", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "auth user-token", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index ad64a4b..d6f644b 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -357,8 +357,13 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format } } -// Display JSON to the user in various different formats -func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error { +const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" + +// Display JSON to the user in various different formats. The explicitFormat parameter indicates +// whether the format was explicitly set by the user (via --format), which controls whether we +// silently fall back to json when explore is requested on non-terminal output. Warnings are +// written to stderr. +func ShowJSON(out *os.File, stderr io.Writer, title string, res gjson.Result, format string, explicitFormat bool, transform string) error { if transform != "" { transformed := res.Get(transform) if transformed.Exists() { @@ -368,8 +373,14 @@ func ShowJSON(out *os.File, title string, res gjson.Result, format string, trans switch strings.ToLower(format) { case "auto": - return ShowJSON(out, title, res, "json", "") + return ShowJSON(out, stderr, title, res, "json", explicitFormat, "") case "explore": + if !isTerminal(out) { + if explicitFormat { + fmt.Fprint(stderr, warningExploreNotSupported) + } + return ShowJSON(out, stderr, title, res, "json", explicitFormat, transform) + } return jsonview.ExploreJSON(title, res) default: bytes, err := formatJSON(out, title, res, format, transform) @@ -394,9 +405,15 @@ type hasRawJSON interface { // For an iterator over different value types, display its values to the user in // different formats. // -1 is used to signal no limit of items to display -func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string, itemsToDisplay int64) error { +func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, iter jsonview.Iterator[T], format string, explicitFormat bool, transform string, itemsToDisplay int64) error { if format == "explore" { - return jsonview.ExploreJSONStream(title, iter) + if isTerminal(stdout) { + return jsonview.ExploreJSONStream(title, iter) + } + if explicitFormat { + fmt.Fprint(stderr, warningExploreNotSupported) + } + format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -469,7 +486,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } obj = gjson.ParseBytes(jsonData) } - if err := ShowJSON(pager, title, obj, format, transform); err != nil { + if err := ShowJSON(pager, stderr, title, obj, format, explicitFormat, transform); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index eee2365..c88856c 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -231,6 +231,73 @@ func TestShowJSONIterator(t *testing.T) { }) } +func TestExploreFallback(t *testing.T) { + t.Parallel() + + t.Run("ShowJSONFallsBackToJsonOnNonTTY", func(t *testing.T) { + t.Parallel() + + // os.Pipe() produces a *os.File that isn't a terminal, so explore should fall back. + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + assert.Contains(t, buf.String(), `"id"`) + assert.Contains(t, buf.String(), `"abc"`) + }) + + t.Run("ShowJSONIteratorFallsBackToJsonOnNonTTY", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc"}, + }} + captured := captureShowJSONIterator(t, iter, "explore", "", -1) + assert.Contains(t, captured, `"id"`) + assert.Contains(t, captured, `"abc"`) + }) + + t.Run("ShowJSONWarnsWhenExplicitFormatOnNonTTY", func(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(w, &stderr, "test", res, "explore", true, "") + w.Close() + require.NoError(t, err) + + assert.Equal(t, warningExploreNotSupported, stderr.String()) + }) + + t.Run("ShowJSONSilentWhenDefaultFormatOnNonTTY", func(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + w.Close() + require.NoError(t, err) + + assert.Empty(t, stderr.String(), "no warning expected when format was not explicit") + }) +} + // sliceIterator is a simple iterator over a slice for testing. type sliceIterator[T any] struct { index int @@ -260,7 +327,8 @@ func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], for require.NoError(t, err) defer r.Close() - err = ShowJSONIterator(w, "test", iter, format, transform, itemsToDisplay) + var stderr bytes.Buffer + err = ShowJSONIterator(w, &stderr, "test", iter, format, false, transform, itemsToDisplay) w.Close() require.NoError(t, err) diff --git a/pkg/cmd/connection.go b/pkg/cmd/connection.go index da554ab..9d5cf59 100644 --- a/pkg/cmd/connection.go +++ b/pkg/cmd/connection.go @@ -66,8 +66,9 @@ func handleConnectionsList(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "connections list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "connections list", obj, format, explicitFormat, transform) } func handleConnectionsRevoke(ctx context.Context, cmd *cli.Command) error { @@ -101,6 +102,7 @@ func handleConnectionsRevoke(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "connections revoke", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "connections revoke", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/evaluate.go b/pkg/cmd/evaluate.go index 67dfcd8..9a59de6 100644 --- a/pkg/cmd/evaluate.go +++ b/pkg/cmd/evaluate.go @@ -105,8 +105,9 @@ func handleEvaluateGetQuery(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "evaluate get-query", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "evaluate get-query", obj, format, explicitFormat, transform) } func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { @@ -147,8 +148,9 @@ func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "evaluate score-highlight", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "evaluate score-highlight", obj, format, explicitFormat, transform) } func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { @@ -189,6 +191,7 @@ func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "evaluate score-query", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "evaluate score-query", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/folder.go b/pkg/cmd/folder.go index 59ce939..bb93a45 100644 --- a/pkg/cmd/folder.go +++ b/pkg/cmd/folder.go @@ -145,8 +145,9 @@ func handleFoldersList(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "folders list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "folders list", obj, format, explicitFormat, transform) } func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { @@ -189,8 +190,9 @@ func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "folders delete-policy", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "folders delete-policy", obj, format, explicitFormat, transform) } func handleFoldersListPolicies(ctx context.Context, cmd *cli.Command) error { @@ -224,8 +226,9 @@ func handleFoldersListPolicies(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "folders list-policies", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "folders list-policies", obj, format, explicitFormat, transform) } func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { @@ -266,6 +269,7 @@ func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "folders set-policies", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "folders set-policies", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/integration.go b/pkg/cmd/integration.go index 97f4aaf..5b0b29b 100644 --- a/pkg/cmd/integration.go +++ b/pkg/cmd/integration.go @@ -70,8 +70,9 @@ func handleIntegrationsList(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "integrations list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "integrations list", obj, format, explicitFormat, transform) } func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { @@ -112,6 +113,7 @@ func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "integrations connect", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "integrations connect", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/integrationgooglecalendar.go b/pkg/cmd/integrationgooglecalendar.go index ba17c42..155c2b0 100644 --- a/pkg/cmd/integrationgooglecalendar.go +++ b/pkg/cmd/integrationgooglecalendar.go @@ -51,6 +51,7 @@ func handleIntegrationsGoogleCalendarList(ctx context.Context, cmd *cli.Command) obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "integrations:google-calendar list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "integrations:google-calendar list", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/integrationslack.go b/pkg/cmd/integrationslack.go index 2ff618e..be73cfa 100644 --- a/pkg/cmd/integrationslack.go +++ b/pkg/cmd/integrationslack.go @@ -84,6 +84,7 @@ func handleIntegrationsSlackList(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "integrations:slack list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "integrations:slack list", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/integrationwebcrawler.go b/pkg/cmd/integrationwebcrawler.go index 72e4dcb..8dfc33c 100644 --- a/pkg/cmd/integrationwebcrawler.go +++ b/pkg/cmd/integrationwebcrawler.go @@ -73,6 +73,7 @@ func handleIntegrationsWebCrawlerIndex(ctx context.Context, cmd *cli.Command) er obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "integrations:web-crawler index", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "integrations:web-crawler index", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 3544f10..3ebde7a 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -432,8 +432,9 @@ func handleMemoriesUpdate(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories update", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories update", obj, format, explicitFormat, transform) } func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { @@ -458,6 +459,7 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -467,14 +469,14 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "memories list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories list", obj, format, explicitFormat, transform) } else { iter := client.Memories.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "memories list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "memories list", iter, format, explicitFormat, transform, maxItems) } } @@ -518,8 +520,9 @@ func handleMemoriesDelete(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories delete", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories delete", obj, format, explicitFormat, transform) } func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { @@ -552,8 +555,9 @@ func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories add", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories add", obj, format, explicitFormat, transform) } func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { @@ -586,8 +590,9 @@ func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories add-bulk", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories add-bulk", obj, format, explicitFormat, transform) } func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { @@ -630,8 +635,9 @@ func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories get", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories get", obj, format, explicitFormat, transform) } func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { @@ -664,8 +670,9 @@ func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories search", obj, format, explicitFormat, transform) } func handleMemoriesStatus(ctx context.Context, cmd *cli.Command) error { @@ -696,8 +703,9 @@ func handleMemoriesStatus(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories status", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories status", obj, format, explicitFormat, transform) } func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { @@ -730,6 +738,7 @@ func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "memories upload", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "memories upload", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index 0ab3449..bfa61fa 100644 --- a/pkg/cmd/session.go +++ b/pkg/cmd/session.go @@ -92,6 +92,7 @@ func handleSessionsAdd(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "sessions add", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "sessions add", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/vault.go b/pkg/cmd/vault.go index 107fe81..86052ab 100644 --- a/pkg/cmd/vault.go +++ b/pkg/cmd/vault.go @@ -60,6 +60,7 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -69,13 +70,13 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "vaults list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "vaults list", obj, format, explicitFormat, transform) } else { iter := client.Vaults.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "vaults list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "vaults list", iter, format, explicitFormat, transform, maxItems) } } From fc1ebc74cf40a58ddb979128852ed202354922cc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 05:06:26 +0000 Subject: [PATCH 19/51] feat(cli): alias parameters in data with `x-stainless-cli-data-alias` --- internal/requestflag/innerflag.go | 19 ++++++++++-- internal/requestflag/requestflag.go | 9 ++++++ pkg/cmd/flagoptions.go | 47 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go index 102624f..eeeb8bc 100644 --- a/internal/requestflag/innerflag.go +++ b/internal/requestflag/innerflag.go @@ -22,14 +22,29 @@ type InnerFlag[ Aliases []string // aliases that are allowed for this flag Validator func(T) error // custom function to validate this flag value - OuterFlag cli.Flag // The flag on which this inner flag will set values - InnerField string // The inner field which this flag will set + OuterFlag cli.Flag // The flag on which this inner flag will set values + InnerField string // The inner field which this flag will set + DataAliases []string // alternate names recognized in YAML values passed as the outer flag +} + +// GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. +func (f *InnerFlag[T]) GetDataAliases() []string { + return f.DataAliases +} + +// GetInnerField returns the API field name that this inner flag sets on its outer flag's value. +// For example, the flag --parent.foo targeting a parameter whose OpenAPI property name is "foo" +// would return "foo". This is distinct from the flag's CLI name and from any DataAliases entries. +func (f *InnerFlag[T]) GetInnerField() string { + return f.InnerField } type HasOuterFlag interface { cli.Flag SetOuterFlag(cli.Flag) GetOuterFlag() cli.Flag + GetInnerField() string + GetDataAliases() []string } func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index bdef64f..bfaf064 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -48,6 +48,10 @@ type Flag[ // binary` in the OpenAPI spec. FileInput bool + // DataAliases is a list of alternate names for this parameter recognized when parsing piped YAML/JSON + // input. Values keyed by any alias are translated to the canonical API name before being sent. + DataAliases []string + // unexported fields for internal use count int // number of times the flag has been set hasBeenSet bool // whether the flag has been set from env or file @@ -65,6 +69,7 @@ type InRequest interface { GetBodyPath() string IsBodyRoot() bool IsFileInput() bool + GetDataAliases() []string } func (f Flag[T]) GetQueryPath() string { @@ -87,6 +92,10 @@ func (f Flag[T]) IsFileInput() bool { return f.FileInput } +func (f Flag[T]) GetDataAliases() []string { + return f.DataAliases +} + // The values that will be sent in different parts of a request. type RequestContents struct { Queries map[string]any diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 2cf85f8..5fa17c3 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -309,6 +309,12 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) + // Translate inner-field aliases in YAML values that came from flags (e.g. + // `--parent '{"alias": val}'` resolving to the canonical inner field). + if bodyMap, ok := requestContents.Body.(map[string]any); ok { + applyDataAliases(cmd, bodyMap) + } + stdinConsumedByPipe := false if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) @@ -323,6 +329,7 @@ func flagOptions( return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) } if bodyMap, ok := bodyData.(map[string]any); ok { + applyDataAliases(cmd, bodyMap) if flagMap, ok := requestContents.Body.(map[string]any); ok { maps.Copy(bodyMap, flagMap) requestContents.Body = bodyMap @@ -485,6 +492,46 @@ func flagOptions( // as a file path without needing the "@" prefix. type FilePathValue string +// applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, +// `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag +// via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's +// body path, so values like `--parent '{"alias": val}'` resolve to the canonical inner field name. +func applyDataAliases(cmd *cli.Command, bodyMap map[string]any) { + for _, flag := range cmd.Flags { + // Inner flags: rewrite aliases inside the nested map under the outer flag's body path. + if inner, ok := flag.(requestflag.HasOuterFlag); ok { + outer, outerOk := inner.GetOuterFlag().(requestflag.InRequest) + if !outerOk { + continue + } + if nested, ok := bodyMap[outer.GetBodyPath()].(map[string]any); ok && inner.GetInnerField() != "" { + rewriteAliases(nested, inner.GetInnerField(), inner.GetDataAliases()) + } + continue + } + // Top-level flags: rewrite aliases in the body map. + if inReq, ok := flag.(requestflag.InRequest); ok && inReq.GetBodyPath() != "" { + rewriteAliases(bodyMap, inReq.GetBodyPath(), inReq.GetDataAliases()) + } + } +} + +// rewriteAliases replaces each alias key in m with the canonical key, preserving the value. The +// "canonical" key is the name the API itself expects (the OpenAPI property/field name) — e.g. for +// a top-level flag, the parameter's BodyPath; for an inner flag, the inner field name. Aliases are +// the user-facing alternate names declared via x-stainless-cli-data-alias. +func rewriteAliases(m map[string]any, canonical string, aliases []string) { + for _, alias := range aliases { + if alias == "" || alias == canonical { + continue + } + if val, exists := m[alias]; exists { + m[canonical] = val + delete(m, alias) + } + } +} + // wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with // FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents // directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit From 4a9e2508c2605e814c40980baf343dbda59f28b8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:16:13 +0000 Subject: [PATCH 20/51] chore(cli): switch long lists of positional args over to param structs --- cmd/hyperspell/main.go | 7 ++- pkg/cmd/action.go | 15 ++++- pkg/cmd/auth.go | 22 ++++++-- pkg/cmd/cmdutil.go | 84 ++++++++++++++++++---------- pkg/cmd/cmdutil_test.go | 31 ++++++++-- pkg/cmd/connection.go | 15 ++++- pkg/cmd/evaluate.go | 22 ++++++-- pkg/cmd/folder.go | 29 ++++++++-- pkg/cmd/integration.go | 15 ++++- pkg/cmd/integrationgooglecalendar.go | 8 ++- pkg/cmd/integrationslack.go | 8 ++- pkg/cmd/integrationwebcrawler.go | 8 ++- pkg/cmd/memory.go | 71 +++++++++++++++++++---- pkg/cmd/session.go | 8 ++- pkg/cmd/vault.go | 15 ++++- 15 files changed, 279 insertions(+), 79 deletions(-) diff --git a/cmd/hyperspell/main.go b/cmd/hyperspell/main.go index 5357867..6179110 100644 --- a/cmd/hyperspell/main.go +++ b/cmd/hyperspell/main.go @@ -43,7 +43,12 @@ func main() { fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) format := app.String("format-error") json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(os.Stdout, os.Stderr, "Error", json, format, app.IsSet("format-error"), app.String("transform-error")) + show_err := cmd.ShowJSON(json, cmd.ShowJSONOpts{ + ExplicitFormat: app.IsSet("format-error"), + Format: format, + Title: "Error", + Transform: app.String("transform-error"), + }) if show_err != nil { // Just print the original error: fmt.Fprintf(os.Stderr, "%s\n", err.Error()) diff --git a/pkg/cmd/action.go b/pkg/cmd/action.go index 55363b2..30edbb0 100644 --- a/pkg/cmd/action.go +++ b/pkg/cmd/action.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -123,7 +122,12 @@ func handleActionsAddReaction(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "actions add-reaction", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "actions add-reaction", + Transform: transform, + }) } func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { @@ -158,5 +162,10 @@ func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "actions send-message", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "actions send-message", + Transform: transform, + }) } diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index f62a3db..0b93263 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -88,7 +87,12 @@ func handleAuthDeleteUser(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "auth delete-user", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "auth delete-user", + Transform: transform, + }) } func handleAuthMe(ctx context.Context, cmd *cli.Command) error { @@ -121,7 +125,12 @@ func handleAuthMe(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "auth me", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "auth me", + Transform: transform, + }) } func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { @@ -156,5 +165,10 @@ func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "auth user-token", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "auth user-token", + Transform: transform, + }) } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index d6f644b..6c0672d 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -359,36 +359,58 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" -// Display JSON to the user in various different formats. The explicitFormat parameter indicates -// whether the format was explicitly set by the user (via --format), which controls whether we -// silently fall back to json when explore is requested on non-terminal output. Warnings are -// written to stderr. -func ShowJSON(out *os.File, stderr io.Writer, title string, res gjson.Result, format string, explicitFormat bool, transform string) error { - if transform != "" { - transformed := res.Get(transform) +// ShowJSONOpts configures how JSON output is displayed. +type ShowJSONOpts struct { + ExplicitFormat bool // true if the user explicitly passed --format + Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml) + Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr + Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout + Title string // display title + Transform string // GJSON path to extract before displaying +} + +func (o *ShowJSONOpts) setDefaults() { + if o.Stderr == nil { + o.Stderr = os.Stderr + } + if o.Stdout == nil { + o.Stdout = os.Stdout + } +} + +// ShowJSON displays a single JSON result to the user. +func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { + opts.setDefaults() + + if opts.Transform != "" { + transformed := res.Get(opts.Transform) if transformed.Exists() { res = transformed } } - switch strings.ToLower(format) { + switch strings.ToLower(opts.Format) { case "auto": - return ShowJSON(out, stderr, title, res, "json", explicitFormat, "") + jsonOpts := opts + jsonOpts.Format = "json" + return ShowJSON(res, jsonOpts) case "explore": - if !isTerminal(out) { - if explicitFormat { - fmt.Fprint(stderr, warningExploreNotSupported) + if !isTerminal(opts.Stdout) { + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) } - return ShowJSON(out, stderr, title, res, "json", explicitFormat, transform) + jsonOpts := opts + jsonOpts.Format = "json" + return ShowJSON(res, jsonOpts) } - return jsonview.ExploreJSON(title, res) + return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(out, title, res, format, transform) + bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform) if err != nil { return err } - _, err = out.Write(bytes) + _, err = opts.Stdout.Write(bytes) return err } } @@ -402,16 +424,17 @@ type hasRawJSON interface { RawJSON() string } -// For an iterator over different value types, display its values to the user in -// different formats. -// -1 is used to signal no limit of items to display -func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, iter jsonview.Iterator[T], format string, explicitFormat bool, transform string, itemsToDisplay int64) error { +// ShowJSONIterator displays an iterator of values to the user. Use itemsToDisplay = -1 for no limit. +func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { + opts.setDefaults() + + format := opts.Format if format == "explore" { - if isTerminal(stdout) { - return jsonview.ExploreJSONStream(title, iter) + if isTerminal(opts.Stdout) { + return jsonview.ExploreJSONStream(opts.Title, iter) } - if explicitFormat { - fmt.Fprint(stderr, warningExploreNotSupported) + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) } format = "json" } @@ -439,7 +462,7 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(stdout, title, obj, format, transform) + json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform) if err != nil { return err } @@ -456,7 +479,7 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it } if !usePager { - _, err := stdout.Write(output) + _, err := opts.Stdout.Write(output) if err != nil { return err } @@ -464,13 +487,16 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it return iter.Err() } - return streamOutput(title, func(pager *os.File) error { - // Write the output we used during the initial terminal size computation + return streamOutput(opts.Title, func(pager *os.File) error { _, err := pager.Write(output) if err != nil { return err } + pagerOpts := opts + pagerOpts.Format = format + pagerOpts.Stdout = pager + for iter.Next() { if itemsToDisplay == 0 { break @@ -486,7 +512,7 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it } obj = gjson.ParseBytes(jsonData) } - if err := ShowJSON(pager, stderr, title, obj, format, explicitFormat, transform); err != nil { + if err := ShowJSON(obj, pagerOpts); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index c88856c..9f9bb0e 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -244,7 +244,12 @@ func TestExploreFallback(t *testing.T) { var stderr bytes.Buffer res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + err = ShowJSON(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) w.Close() require.NoError(t, err) @@ -274,7 +279,13 @@ func TestExploreFallback(t *testing.T) { var stderr bytes.Buffer res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(w, &stderr, "test", res, "explore", true, "") + err = ShowJSON(res, ShowJSONOpts{ + ExplicitFormat: true, + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) w.Close() require.NoError(t, err) @@ -290,7 +301,12 @@ func TestExploreFallback(t *testing.T) { var stderr bytes.Buffer res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + err = ShowJSON(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) w.Close() require.NoError(t, err) @@ -327,8 +343,13 @@ func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], for require.NoError(t, err) defer r.Close() - var stderr bytes.Buffer - err = ShowJSONIterator(w, &stderr, "test", iter, format, false, transform, itemsToDisplay) + err = ShowJSONIterator(iter, itemsToDisplay, ShowJSONOpts{ + Format: format, + Stderr: io.Discard, + Stdout: w, + Title: "test", + Transform: transform, + }) w.Close() require.NoError(t, err) diff --git a/pkg/cmd/connection.go b/pkg/cmd/connection.go index 9d5cf59..749ee82 100644 --- a/pkg/cmd/connection.go +++ b/pkg/cmd/connection.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -68,7 +67,12 @@ func handleConnectionsList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "connections list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "connections list", + Transform: transform, + }) } func handleConnectionsRevoke(ctx context.Context, cmd *cli.Command) error { @@ -104,5 +108,10 @@ func handleConnectionsRevoke(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "connections revoke", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "connections revoke", + Transform: transform, + }) } diff --git a/pkg/cmd/evaluate.go b/pkg/cmd/evaluate.go index 9a59de6..d0aa020 100644 --- a/pkg/cmd/evaluate.go +++ b/pkg/cmd/evaluate.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -107,7 +106,12 @@ func handleEvaluateGetQuery(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "evaluate get-query", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "evaluate get-query", + Transform: transform, + }) } func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { @@ -150,7 +154,12 @@ func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "evaluate score-highlight", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "evaluate score-highlight", + Transform: transform, + }) } func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { @@ -193,5 +202,10 @@ func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "evaluate score-query", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "evaluate score-query", + Transform: transform, + }) } diff --git a/pkg/cmd/folder.go b/pkg/cmd/folder.go index bb93a45..07e53d3 100644 --- a/pkg/cmd/folder.go +++ b/pkg/cmd/folder.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -147,7 +146,12 @@ func handleFoldersList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "folders list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "folders list", + Transform: transform, + }) } func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { @@ -192,7 +196,12 @@ func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "folders delete-policy", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "folders delete-policy", + Transform: transform, + }) } func handleFoldersListPolicies(ctx context.Context, cmd *cli.Command) error { @@ -228,7 +237,12 @@ func handleFoldersListPolicies(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "folders list-policies", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "folders list-policies", + Transform: transform, + }) } func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { @@ -271,5 +285,10 @@ func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "folders set-policies", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "folders set-policies", + Transform: transform, + }) } diff --git a/pkg/cmd/integration.go b/pkg/cmd/integration.go index 5b0b29b..dcdfa6d 100644 --- a/pkg/cmd/integration.go +++ b/pkg/cmd/integration.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -72,7 +71,12 @@ func handleIntegrationsList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "integrations list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "integrations list", + Transform: transform, + }) } func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { @@ -115,5 +119,10 @@ func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "integrations connect", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "integrations connect", + Transform: transform, + }) } diff --git a/pkg/cmd/integrationgooglecalendar.go b/pkg/cmd/integrationgooglecalendar.go index 155c2b0..33ddc52 100644 --- a/pkg/cmd/integrationgooglecalendar.go +++ b/pkg/cmd/integrationgooglecalendar.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-go" @@ -53,5 +52,10 @@ func handleIntegrationsGoogleCalendarList(ctx context.Context, cmd *cli.Command) format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "integrations:google-calendar list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "integrations:google-calendar list", + Transform: transform, + }) } diff --git a/pkg/cmd/integrationslack.go b/pkg/cmd/integrationslack.go index be73cfa..c9f8b5f 100644 --- a/pkg/cmd/integrationslack.go +++ b/pkg/cmd/integrationslack.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -86,5 +85,10 @@ func handleIntegrationsSlackList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "integrations:slack list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "integrations:slack list", + Transform: transform, + }) } diff --git a/pkg/cmd/integrationwebcrawler.go b/pkg/cmd/integrationwebcrawler.go index 8dfc33c..68504ce 100644 --- a/pkg/cmd/integrationwebcrawler.go +++ b/pkg/cmd/integrationwebcrawler.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -75,5 +74,10 @@ func handleIntegrationsWebCrawlerIndex(ctx context.Context, cmd *cli.Command) er format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "integrations:web-crawler index", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "integrations:web-crawler index", + Transform: transform, + }) } diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 3ebde7a..ebbb88a 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -434,7 +433,12 @@ func handleMemoriesUpdate(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories update", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories update", + Transform: transform, + }) } func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { @@ -469,14 +473,24 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "memories list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories list", + Transform: transform, + }) } else { iter := client.Memories.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "memories list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories list", + Transform: transform, + }) } } @@ -522,7 +536,12 @@ func handleMemoriesDelete(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories delete", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories delete", + Transform: transform, + }) } func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { @@ -557,7 +576,12 @@ func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories add", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories add", + Transform: transform, + }) } func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { @@ -592,7 +616,12 @@ func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories add-bulk", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories add-bulk", + Transform: transform, + }) } func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { @@ -637,7 +666,12 @@ func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories get", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories get", + Transform: transform, + }) } func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { @@ -672,7 +706,12 @@ func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories search", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories search", + Transform: transform, + }) } func handleMemoriesStatus(ctx context.Context, cmd *cli.Command) error { @@ -705,7 +744,12 @@ func handleMemoriesStatus(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories status", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories status", + Transform: transform, + }) } func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { @@ -740,5 +784,10 @@ func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "memories upload", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "memories upload", + Transform: transform, + }) } diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index bfa61fa..0eef40c 100644 --- a/pkg/cmd/session.go +++ b/pkg/cmd/session.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -94,5 +93,10 @@ func handleSessionsAdd(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "sessions add", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "sessions add", + Transform: transform, + }) } diff --git a/pkg/cmd/vault.go b/pkg/cmd/vault.go index 86052ab..fbfd51b 100644 --- a/pkg/cmd/vault.go +++ b/pkg/cmd/vault.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/hyperspell/hyperspell-cli/internal/apiquery" "github.com/hyperspell/hyperspell-cli/internal/requestflag" @@ -70,13 +69,23 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "vaults list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "vaults list", + Transform: transform, + }) } else { iter := client.Vaults.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "vaults list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "vaults list", + Transform: transform, + }) } } From 05fc238a5087e6cd3024fd92eccb7fa23cbb8440 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:30:30 +0000 Subject: [PATCH 21/51] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/memory.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index 61b9e21..60ac02f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-460d7c66cd5e8cf979cd761066c51d8f813a119a20e2149fcfcf847eb650d545.yml -openapi_spec_hash: 8ee512464a88de45c86faf4f46f4905c +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-6d6dbb68dd9021348431b28e08378d086b3eaf5e65b3dfa03125b1fdec417fa6.yml +openapi_spec_hash: 6ad2b84ac07c482fe838929694e49015 config_hash: bd8505e17db740d82e578d0edaa9bfe0 diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index ebbb88a..97d15dc 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -21,7 +21,7 @@ var memoriesUpdate = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "google_drive", "github", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, Required: true, }, &requestflag.Flag[string]{ @@ -107,7 +107,7 @@ var memoriesDelete = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "google_drive", "github", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, Required: true, }, &requestflag.Flag[string]{ @@ -216,7 +216,7 @@ var memoriesGet = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "google_drive", "github", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, Required: true, }, &requestflag.Flag[string]{ From b5836aa567c2ba79cf6ad7a8706de0ef196ed317 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:47:27 +0000 Subject: [PATCH 22/51] chore(ci): support manually triggering release workflow --- .github/workflows/publish-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 49983f5..3e8ffd2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,6 +10,7 @@ on: push: tags: - "v*" + workflow_dispatch: {} jobs: goreleaser: runs-on: ubuntu-latest From 4b76c79a99e8bb0f8a3ef112ccaf3092decb69a3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:49:02 +0000 Subject: [PATCH 23/51] feat(cli): send filename and content type when reading input from files --- pkg/cmd/flagoptions.go | 65 +++++++++++++++++++++++++++++++++++-- pkg/cmd/flagoptions_test.go | 59 ++++++++++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 5fa17c3..bdf1aef 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -7,9 +7,11 @@ import ( "fmt" "io" "maps" + "mime" "mime/multipart" "net/http" "os" + "path/filepath" "reflect" "strings" "unicode/utf8" @@ -36,7 +38,14 @@ const ( type FileEmbedStyle int const ( + // EmbedText reads referenced files fully into memory and substitutes the file's contents back into the + // value as a string. Binary files are base64-encoded. Used for JSON request bodies and for headers and + // query parameters, where the file contents need to be serialized inline. EmbedText FileEmbedStyle = iota + + // EmbedIOReader replaces file references with an io.Reader that streams the file's contents. Used for + // `multipart/form-data` and `application/octet-stream` request bodies, where files are uploaded as binary + // parts rather than embedded into a text value. EmbedIOReader ) @@ -142,6 +151,20 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi if s == "" { return v, nil } + if embedStyle == EmbedIOReader { + if isStdinPath(s) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + upload, err := openFileUpload(s) + if err != nil { + return v, err + } + return reflect.ValueOf(upload), nil + } if isStdinPath(s) { content, err := stdin.readAll() if err != nil { @@ -250,7 +273,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi return reflect.ValueOf(io.NopCloser(r)), nil } - file, err := os.Open(filename) + upload, err := openFileUpload(filename) if err != nil { if !expectsFile { // For strings that start with "@" and don't look like a filename, return the string @@ -258,7 +281,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi } return v, err } - return reflect.ValueOf(file), nil + return reflect.ValueOf(upload), nil } } return v, nil @@ -492,6 +515,44 @@ func flagOptions( // as a file path without needing the "@" prefix. type FilePathValue string +// fileUpload wraps an io.Reader with filename and content-type metadata for +// use as a multipart form part. The apiform encoder detects the Filename and +// ContentType methods and uses them to populate the Content-Disposition +// filename and the Content-Type header on the part. +type fileUpload struct { + io.Reader // apiform checks for reader and reads its contents during encode + filename string + contentType string +} + +func (f fileUpload) Filename() string { return f.filename } +func (f fileUpload) ContentType() string { return f.contentType } +func (f fileUpload) Close() error { + if c, ok := f.Reader.(io.Closer); ok { + return c.Close() + } + return nil +} + +// openFileUpload opens the file at path and returns a fileUpload whose filename +// is the path's basename and whose content type is derived from the file +// extension (falling back to application/octet-stream when unknown). +func openFileUpload(path string) (fileUpload, error) { + file, err := os.Open(path) + if err != nil { + return fileUpload{}, err + } + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + return fileUpload{ + Reader: file, + filename: filepath.Base(path), + contentType: contentType, + }, nil +} + // applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, // `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag // via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index 039b9ff..00734ca 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ func TestIsUTF8TextFile(t *testing.T) { } for _, tt := range tests { - assert.Equal(t, tt.expected, isUTF8TextFile(tt.content)) + require.Equal(t, tt.expected, isUTF8TextFile(tt.content)) } } @@ -226,10 +225,10 @@ func TestEmbedFiles(t *testing.T) { got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) - assert.Equal(t, tt.want, got) + require.Equal(t, tt.want, got) } }) @@ -238,7 +237,7 @@ func TestEmbedFiles(t *testing.T) { _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) } @@ -333,6 +332,56 @@ func TestEmbedFilesStdin(t *testing.T) { }) } +// TestEmbedFilesUploadMetadata verifies that EmbedIOReader mode wraps file readers with filename and +// content-type metadata so the multipart encoder populates `Content-Disposition` and `Content-Type` headers. +func TestEmbedFilesUploadMetadata(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "hello.txt", "hi") + writeTestFile(t, tmpDir, "page.html", "") + writeTestFile(t, tmpDir, "blob.bin", "\x00\x01") + + cases := []struct { + basename string + wantContentType string + }{ + {"hello.txt", "text/plain; charset=utf-8"}, + {"page.html", "text/html; charset=utf-8"}, + {"blob.bin", "application/octet-stream"}, + } + + for _, tc := range cases { + t.Run("AtPrefix_"+tc.basename, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(tmpDir, tc.basename) + withEmbedded, err := embedFiles(map[string]any{"file": "@" + path}, EmbedIOReader, nil) + require.NoError(t, err) + + upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) + require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) + require.Equal(t, tc.basename, upload.Filename()) + require.Equal(t, upload.ContentType(), tc.wantContentType) + require.NoError(t, upload.Close()) + }) + + t.Run("FilePathValue_"+tc.basename, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(tmpDir, tc.basename) + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(path)}, EmbedIOReader, nil) + require.NoError(t, err) + + upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) + require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) + require.Equal(t, tc.basename, upload.Filename()) + require.Equal(t, upload.ContentType(), tc.wantContentType) + require.NoError(t, upload.Close()) + }) + } +} + func writeTestFile(t *testing.T, dir, filename, content string) { t.Helper() From 422254937af57c228a18d67191aa8966cceebb45 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:50:41 +0000 Subject: [PATCH 24/51] feat(cli): add `--raw-output`/`-r` option to print raw (non-JSON) strings --- pkg/cmd/action.go | 2 ++ pkg/cmd/auth.go | 3 +++ pkg/cmd/cmd.go | 5 ++++ pkg/cmd/cmdutil.go | 14 ++++++++--- pkg/cmd/cmdutil_test.go | 37 +++++++++++++++++++++++++--- pkg/cmd/connection.go | 2 ++ pkg/cmd/evaluate.go | 3 +++ pkg/cmd/folder.go | 4 +++ pkg/cmd/integration.go | 2 ++ pkg/cmd/integrationgooglecalendar.go | 1 + pkg/cmd/integrationslack.go | 1 + pkg/cmd/integrationwebcrawler.go | 1 + pkg/cmd/memory.go | 10 ++++++++ pkg/cmd/session.go | 1 + pkg/cmd/vault.go | 2 ++ 15 files changed, 80 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/action.go b/pkg/cmd/action.go index 30edbb0..e839959 100644 --- a/pkg/cmd/action.go +++ b/pkg/cmd/action.go @@ -125,6 +125,7 @@ func handleActionsAddReaction(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "actions add-reaction", Transform: transform, }) @@ -165,6 +166,7 @@ func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "actions send-message", Transform: transform, }) diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index 0b93263..754bb5c 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -90,6 +90,7 @@ func handleAuthDeleteUser(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "auth delete-user", Transform: transform, }) @@ -128,6 +129,7 @@ func handleAuthMe(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "auth me", Transform: transform, }) @@ -168,6 +170,7 @@ func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "auth user-token", Transform: transform, }) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 4e2f336..de36ff4 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -73,6 +73,11 @@ func init() { Name: "transform-error", Usage: "The GJSON transformation for errors.", }, + &cli.BoolFlag{ + Name: "raw-output", + Aliases: []string{"r"}, + Usage: "If the result is a string, print it without JSON quotes. This can be useful for making output transforms talk to non-JSON-based systems.", + }, &requestflag.Flag[string]{ Name: "api-key", Usage: "Either an API Key or User Token to authenticate a specific user of your app.", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 6c0672d..d6cd002 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -314,16 +314,21 @@ func shouldUseColors(w io.Writer) bool { return isTerminal(w) } -func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { +func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string, rawOutput bool) ([]byte, error) { if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed } } + // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that + // it's easier to pipe into other programs. + if rawOutput && res.Type == gjson.String { + return []byte(res.Str + "\n"), nil + } switch strings.ToLower(format) { case "auto": - return formatJSON(expectedOutput, title, res, "json", "") + return formatJSON(expectedOutput, title, res, "json", "", rawOutput) case "pretty": return []byte(jsonview.RenderJSON(title, res) + "\n"), nil case "json": @@ -363,6 +368,7 @@ const warningExploreNotSupported = "Warning: Output format 'explore' not support type ShowJSONOpts struct { ExplicitFormat bool // true if the user explicitly passed --format Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml) + RawOutput bool // like jq -r: print strings without JSON quotes Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout Title string // display title @@ -405,7 +411,7 @@ func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { } return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform) + bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform, opts.RawOutput) if err != nil { return err } @@ -462,7 +468,7 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform) + json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform, opts.RawOutput) if err != nil { return err } diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 9f9bb0e..d358b3d 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -159,7 +159,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id", false) require.NoError(t, err) require.Equal(t, `"abc123"`+"\n", string(formatted)) }) @@ -168,7 +168,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "", false) require.NoError(t, err) require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) }) @@ -177,7 +177,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items", false) require.NoError(t, err) require.Equal(t, "[1,2,3]\n", string(formatted)) }) @@ -186,11 +186,40 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing", false) require.NoError(t, err) // Transform path doesn't exist, so original result is returned require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) }) + + t.Run("RawOutputString", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "json", "id", true) + require.NoError(t, err) + require.Equal(t, "abc123\n", string(formatted)) + }) + + t.Run("RawOutputNonString", func(t *testing.T) { + t.Parallel() + + // --raw-output has no effect on non-string values + res := gjson.Parse(`{"count":42}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "count", true) + require.NoError(t, err) + require.Equal(t, "42\n", string(formatted)) + }) + + t.Run("RawOutputObject", func(t *testing.T) { + t.Parallel() + + // --raw-output has no effect on objects + res := gjson.Parse(`{"nested":{"a":1}}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "nested", true) + require.NoError(t, err) + require.Equal(t, `{"a":1}`+"\n", string(formatted)) + }) } func TestShowJSONIterator(t *testing.T) { diff --git a/pkg/cmd/connection.go b/pkg/cmd/connection.go index 749ee82..d329036 100644 --- a/pkg/cmd/connection.go +++ b/pkg/cmd/connection.go @@ -70,6 +70,7 @@ func handleConnectionsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "connections list", Transform: transform, }) @@ -111,6 +112,7 @@ func handleConnectionsRevoke(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "connections revoke", Transform: transform, }) diff --git a/pkg/cmd/evaluate.go b/pkg/cmd/evaluate.go index d0aa020..6022942 100644 --- a/pkg/cmd/evaluate.go +++ b/pkg/cmd/evaluate.go @@ -109,6 +109,7 @@ func handleEvaluateGetQuery(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "evaluate get-query", Transform: transform, }) @@ -157,6 +158,7 @@ func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "evaluate score-highlight", Transform: transform, }) @@ -205,6 +207,7 @@ func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "evaluate score-query", Transform: transform, }) diff --git a/pkg/cmd/folder.go b/pkg/cmd/folder.go index 07e53d3..2a585e3 100644 --- a/pkg/cmd/folder.go +++ b/pkg/cmd/folder.go @@ -149,6 +149,7 @@ func handleFoldersList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "folders list", Transform: transform, }) @@ -199,6 +200,7 @@ func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "folders delete-policy", Transform: transform, }) @@ -240,6 +242,7 @@ func handleFoldersListPolicies(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "folders list-policies", Transform: transform, }) @@ -288,6 +291,7 @@ func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "folders set-policies", Transform: transform, }) diff --git a/pkg/cmd/integration.go b/pkg/cmd/integration.go index dcdfa6d..e86dd2a 100644 --- a/pkg/cmd/integration.go +++ b/pkg/cmd/integration.go @@ -74,6 +74,7 @@ func handleIntegrationsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "integrations list", Transform: transform, }) @@ -122,6 +123,7 @@ func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "integrations connect", Transform: transform, }) diff --git a/pkg/cmd/integrationgooglecalendar.go b/pkg/cmd/integrationgooglecalendar.go index 33ddc52..f33a051 100644 --- a/pkg/cmd/integrationgooglecalendar.go +++ b/pkg/cmd/integrationgooglecalendar.go @@ -55,6 +55,7 @@ func handleIntegrationsGoogleCalendarList(ctx context.Context, cmd *cli.Command) return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "integrations:google-calendar list", Transform: transform, }) diff --git a/pkg/cmd/integrationslack.go b/pkg/cmd/integrationslack.go index c9f8b5f..19a4584 100644 --- a/pkg/cmd/integrationslack.go +++ b/pkg/cmd/integrationslack.go @@ -88,6 +88,7 @@ func handleIntegrationsSlackList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "integrations:slack list", Transform: transform, }) diff --git a/pkg/cmd/integrationwebcrawler.go b/pkg/cmd/integrationwebcrawler.go index 68504ce..1e59c9e 100644 --- a/pkg/cmd/integrationwebcrawler.go +++ b/pkg/cmd/integrationwebcrawler.go @@ -77,6 +77,7 @@ func handleIntegrationsWebCrawlerIndex(ctx context.Context, cmd *cli.Command) er return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "integrations:web-crawler index", Transform: transform, }) diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 97d15dc..c07070d 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -436,6 +436,7 @@ func handleMemoriesUpdate(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories update", Transform: transform, }) @@ -476,6 +477,7 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories list", Transform: transform, }) @@ -488,6 +490,7 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories list", Transform: transform, }) @@ -539,6 +542,7 @@ func handleMemoriesDelete(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories delete", Transform: transform, }) @@ -579,6 +583,7 @@ func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories add", Transform: transform, }) @@ -619,6 +624,7 @@ func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories add-bulk", Transform: transform, }) @@ -669,6 +675,7 @@ func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories get", Transform: transform, }) @@ -709,6 +716,7 @@ func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories search", Transform: transform, }) @@ -747,6 +755,7 @@ func handleMemoriesStatus(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories status", Transform: transform, }) @@ -787,6 +796,7 @@ func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "memories upload", Transform: transform, }) diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index 0eef40c..c29645d 100644 --- a/pkg/cmd/session.go +++ b/pkg/cmd/session.go @@ -96,6 +96,7 @@ func handleSessionsAdd(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "sessions add", Transform: transform, }) diff --git a/pkg/cmd/vault.go b/pkg/cmd/vault.go index fbfd51b..deac0ba 100644 --- a/pkg/cmd/vault.go +++ b/pkg/cmd/vault.go @@ -72,6 +72,7 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "vaults list", Transform: transform, }) @@ -84,6 +85,7 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "vaults list", Transform: transform, }) From 6ea47ba9a0248351256a8ddd0bde7b5575a53eb3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:30:29 +0000 Subject: [PATCH 25/51] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/memory.go | 6 ++++++ pkg/cmd/memory_test.go | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 60ac02f..73fab0a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-6d6dbb68dd9021348431b28e08378d086b3eaf5e65b3dfa03125b1fdec417fa6.yml -openapi_spec_hash: 6ad2b84ac07c482fe838929694e49015 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-864ec17bc07aef2b5985bc5e876c8a99a26c8f53cd0cee693deafbe752b6b7e1.yml +openapi_spec_hash: f1d9dbac709b8de23a5243f7ccaa984f config_hash: bd8505e17db740d82e578d0edaa9bfe0 diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index c07070d..5266788 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -34,6 +34,12 @@ var memoriesUpdate = cli.Command{ Default: map[string]any{}, BodyPath: "collection", }, + &requestflag.Flag[any]{ + Name: "date", + Usage: "Date of the document for ranking and filtering.", + Default: map[string]any{}, + BodyPath: "date", + }, &requestflag.Flag[any]{ Name: "metadata", Usage: "Custom metadata for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, boolean, or null. Will be merged with existing metadata.", diff --git a/pkg/cmd/memory_test.go b/pkg/cmd/memory_test.go index 60dd91e..e845357 100644 --- a/pkg/cmd/memory_test.go +++ b/pkg/cmd/memory_test.go @@ -20,6 +20,7 @@ func TestMemoriesUpdate(t *testing.T) { "--source", "reddit", "--resource-id", "resource_id", "--collection", "string", + "--date", "'2019-12-27T18:11:19.117Z'", "--metadata", "{foo: string}", "--text", "string", "--title", "string", @@ -30,6 +31,7 @@ func TestMemoriesUpdate(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + "collection: string\n" + + "date: '2019-12-27T18:11:19.117Z'\n" + "metadata:\n" + " foo: string\n" + "text: string\n" + From 9e0c10938023ffbd46ca50c61baf2c3353df23d5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:13:40 +0000 Subject: [PATCH 26/51] chore(cli): use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals --- pkg/cmd/cmdutil.go | 54 ++++++++++++++++++++--------------------- pkg/cmd/cmdutil_test.go | 14 +++++------ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index d6cd002..1ce9fe5 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -314,26 +314,29 @@ func shouldUseColors(w io.Writer) bool { return isTerminal(w) } -func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string, rawOutput bool) ([]byte, error) { - if transform != "" { - transformed := res.Get(transform) +func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) { + if opts.Transform != "" { + transformed := res.Get(opts.Transform) if transformed.Exists() { res = transformed } } // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that // it's easier to pipe into other programs. - if rawOutput && res.Type == gjson.String { + if opts.RawOutput && res.Type == gjson.String { return []byte(res.Str + "\n"), nil } - switch strings.ToLower(format) { + switch strings.ToLower(opts.Format) { case "auto": - return formatJSON(expectedOutput, title, res, "json", "", rawOutput) + autoOpts := opts + autoOpts.Format = "json" + autoOpts.Transform = "" + return formatJSON(res, autoOpts) case "pretty": - return []byte(jsonview.RenderJSON(title, res) + "\n"), nil + return []byte(jsonview.RenderJSON(opts.Title, res) + "\n"), nil case "json": prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(expectedOutput) { + if shouldUseColors(opts.Stdout) { return pretty.Color(prettyJSON, pretty.TerminalStyle), nil } else { return prettyJSON, nil @@ -341,7 +344,7 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format case "jsonl": // @ugly is gjson syntax for "no whitespace", so it fits on one line oneLineJSON := res.Get("@ugly").Raw - if shouldUseColors(expectedOutput) { + if shouldUseColors(opts.Stdout) { bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') return bytes, nil } else { @@ -355,10 +358,10 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format if err := json2yaml.Convert(&yaml, input); err != nil { return nil, err } - _, err := expectedOutput.Write([]byte(yaml.String())) + _, err := opts.Stdout.Write([]byte(yaml.String())) return nil, err default: - return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) + return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", opts.Format, strings.Join(OutputFormats, ", ")) } } @@ -388,18 +391,11 @@ func (o *ShowJSONOpts) setDefaults() { func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { opts.setDefaults() - if opts.Transform != "" { - transformed := res.Get(opts.Transform) - if transformed.Exists() { - res = transformed - } - } - switch strings.ToLower(opts.Format) { case "auto": - jsonOpts := opts - jsonOpts.Format = "json" - return ShowJSON(res, jsonOpts) + autoOpts := opts + autoOpts.Format = "json" + return ShowJSON(res, autoOpts) case "explore": if !isTerminal(opts.Stdout) { if opts.ExplicitFormat { @@ -409,9 +405,15 @@ func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { jsonOpts.Format = "json" return ShowJSON(res, jsonOpts) } + if opts.Transform != "" { + transformed := res.Get(opts.Transform) + if transformed.Exists() { + res = transformed + } + } return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform, opts.RawOutput) + bytes, err := formatJSON(res, opts) if err != nil { return err } @@ -434,15 +436,14 @@ type hasRawJSON interface { func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { opts.setDefaults() - format := opts.Format - if format == "explore" { + if opts.Format == "explore" { if isTerminal(opts.Stdout) { return jsonview.ExploreJSONStream(opts.Title, iter) } if opts.ExplicitFormat { fmt.Fprint(opts.Stderr, warningExploreNotSupported) } - format = "json" + opts.Format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -468,7 +469,7 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform, opts.RawOutput) + json, err := formatJSON(obj, opts) if err != nil { return err } @@ -500,7 +501,6 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op } pagerOpts := opts - pagerOpts.Format = format pagerOpts.Stdout = pager for iter.Next() { diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index d358b3d..22b6019 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -159,7 +159,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "id"}) require.NoError(t, err) require.Equal(t, `"abc123"`+"\n", string(formatted)) }) @@ -168,7 +168,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout}) require.NoError(t, err) require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) }) @@ -177,7 +177,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "data.items"}) require.NoError(t, err) require.Equal(t, "[1,2,3]\n", string(formatted)) }) @@ -186,7 +186,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "missing"}) require.NoError(t, err) // Transform path doesn't exist, so original result is returned require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) @@ -196,7 +196,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "json", "id", true) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "json", Stdout: os.Stdout, Transform: "id", RawOutput: true}) require.NoError(t, err) require.Equal(t, "abc123\n", string(formatted)) }) @@ -206,7 +206,7 @@ func TestFormatJSON(t *testing.T) { // --raw-output has no effect on non-string values res := gjson.Parse(`{"count":42}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "count", true) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "count", RawOutput: true}) require.NoError(t, err) require.Equal(t, "42\n", string(formatted)) }) @@ -216,7 +216,7 @@ func TestFormatJSON(t *testing.T) { // --raw-output has no effect on objects res := gjson.Parse(`{"nested":{"a":1}}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "nested", true) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "nested", RawOutput: true}) require.NoError(t, err) require.Equal(t, `{"a":1}`+"\n", string(formatted)) }) From 522092e2767802d6e258353c4bb374f16f2751d5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:16:01 +0000 Subject: [PATCH 27/51] chore(tests): bump steady to v0.22.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 5cd7c15..feebe5e 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index b92c044..f2bde93 100755 --- a/scripts/test +++ b/scripts/test @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From e08b27e7dea11806c39edd3e7add13b97e0daeea Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:30:40 +0000 Subject: [PATCH 28/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 73fab0a..19377f4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-864ec17bc07aef2b5985bc5e876c8a99a26c8f53cd0cee693deafbe752b6b7e1.yml -openapi_spec_hash: f1d9dbac709b8de23a5243f7ccaa984f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml +openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 config_hash: bd8505e17db740d82e578d0edaa9bfe0 From 7ee8632b178ff7edaa44b8f134ceb6b7968fe3af Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:20:46 +0000 Subject: [PATCH 29/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 19377f4..b925885 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 -config_hash: bd8505e17db740d82e578d0edaa9bfe0 +config_hash: 2ac853a71ed8b421da86d06bd90cf23f From 878ee7b9800a0ce2959c6bfb2f53376c6d297797 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:26:51 +0000 Subject: [PATCH 30/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index b925885..390cd28 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 -config_hash: 2ac853a71ed8b421da86d06bd90cf23f +config_hash: f779e7db9263cd21efe5e9469bc1d012 From 7bd511fbd75dca2348fcae40300047b9d5b1c50e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:38:55 +0000 Subject: [PATCH 31/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 390cd28..08bee8b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 -config_hash: f779e7db9263cd21efe5e9469bc1d012 +config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 1fb3c191b04392f8289d3db984c021d48883c458 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:52:12 +0000 Subject: [PATCH 32/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 08bee8b..dba5310 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 -config_hash: 597eba5e5eaec83a5f0db3d946af8db5 +config_hash: 09bb5ca4418f316f95d2b75ef7399cf0 From 86e522e50766264bd8fa3af3943d6d1b0369b9aa Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:12:32 +0000 Subject: [PATCH 33/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index dba5310..08bee8b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 -config_hash: 09bb5ca4418f316f95d2b75ef7399cf0 +config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 014bf271f51526dab8ae459bd514bab798c8062d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:06:07 +0000 Subject: [PATCH 34/51] chore(internal): more robust bootstrap script --- scripts/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index 9ebb7d3..bbc786d 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response From f7523847ff23a7cc084c25fa9c36a9e902cabbce Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:30:35 +0000 Subject: [PATCH 35/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 08bee8b..37d5f10 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a4bf4f3aaf1509541db646bc9ff7ec58e78cb4d42cf6bf83881b02739ad77b87.yml -openapi_spec_hash: 89cd02b2290061877e6badcddb7c8eb8 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a3b85873a872b138491163f359f262878d19b98e5ded51fb2ba5c2cc6dae8f5c.yml +openapi_spec_hash: 2914ac795845af1b80926da07bc5727f config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 317503ca0a96825099c20ba04e83f96f220bc125 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:30:42 +0000 Subject: [PATCH 36/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 37d5f10..5adbadb 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a3b85873a872b138491163f359f262878d19b98e5ded51fb2ba5c2cc6dae8f5c.yml -openapi_spec_hash: 2914ac795845af1b80926da07bc5727f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-69d534d74b0030f4226e05ad99a1c636cf271b664d60c4dac2daedbe59e7be0d.yml +openapi_spec_hash: e3e75812a20b93e40f599c9b865927c4 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From f8df59b313ab085da8e29fd0b68e4b3af949046c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:30:52 +0000 Subject: [PATCH 37/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5adbadb..51c308d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-69d534d74b0030f4226e05ad99a1c636cf271b664d60c4dac2daedbe59e7be0d.yml -openapi_spec_hash: e3e75812a20b93e40f599c9b865927c4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-0b9bf804b7e67e6743eae14b04310e7efddb54d67f7d9c02e11acccd83169255.yml +openapi_spec_hash: 0feb465dcd08ad061bf0a0ce10c9df9f config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 9a73f604a3cf44325288f435599a722648bf20fb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:30:49 +0000 Subject: [PATCH 38/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 51c308d..61d253d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-0b9bf804b7e67e6743eae14b04310e7efddb54d67f7d9c02e11acccd83169255.yml -openapi_spec_hash: 0feb465dcd08ad061bf0a0ce10c9df9f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-8689d273264324bb97b5d765b0b6811c655e3d25ac4f9a65f36bf8966dcf48e0.yml +openapi_spec_hash: edb49a26f338bafcc4b339c5f6618450 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From ca5609b11395020165d19c43964ba27b7900da47 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:30:51 +0000 Subject: [PATCH 39/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 61d253d..38acd59 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-8689d273264324bb97b5d765b0b6811c655e3d25ac4f9a65f36bf8966dcf48e0.yml -openapi_spec_hash: edb49a26f338bafcc4b339c5f6618450 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-b79d6d7c41fc55e5392e94c9c3a5251b638615f5324fb9b939e9dfd829dd1da1.yml +openapi_spec_hash: 6666715c859f216551868dcf8e602dee config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 15efdbcd4ea5962464fe5eedc212d0fd601ceb1f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:31:08 +0000 Subject: [PATCH 40/51] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/memory.go | 5 +++++ pkg/cmd/memory_test.go | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 38acd59..2d998a9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-b79d6d7c41fc55e5392e94c9c3a5251b638615f5324fb9b939e9dfd829dd1da1.yml -openapi_spec_hash: 6666715c859f216551868dcf8e602dee +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-7fa6cf3d29bd35dc57fbcd164d5d059790535463f31a354a32f2628e480443d7.yml +openapi_spec_hash: a541dd122cc17cd49c5ddf64699d24c5 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 5266788..e3f1ce6 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -333,6 +333,11 @@ var memoriesSearch = requestflag.WithInnerFlags(cli.Command{ Usage: "Search options for Notion", InnerField: "notion", }, + &requestflag.InnerFlag[any]{ + Name: "options.recency-half-life-days", + Usage: "When set, multiplies each result's score by an exponential-decay factor based on the document's most recent activity timestamp (source-reported last_modified, falling back to document_date). A document one half-life old gets its score halved. Resources with no recency timestamp are passed through unchanged. Leave unset to disable.", + InnerField: "recency_half_life_days", + }, &requestflag.InnerFlag[map[string]any]{ Name: "options.reddit", Usage: "Search options for Reddit", diff --git a/pkg/cmd/memory_test.go b/pkg/cmd/memory_test.go index e845357..3ccb5dc 100644 --- a/pkg/cmd/memory_test.go +++ b/pkg/cmd/memory_test.go @@ -191,7 +191,7 @@ func TestMemoriesSearch(t *testing.T) { "--answer=true", "--effort", "0", "--max-results", "0", - "--options", "{after: '2019-12-27T18:11:19.117Z', answer_model: llama-3.1, before: '2019-12-27T18:11:19.117Z', box: {weight: 0}, filter: {}, google_calendar: {calendar_id: calendar_id, weight: 0}, google_drive: {weight: 0}, google_mail: {label_ids: [string], weight: 0}, max_results: 200, memory_types: [procedure], notion: {notion_page_ids: [string], weight: 0}, reddit: {period: hour, sort: relevance, subreddit: subreddit, weight: 0}, resource_ids: [string], slack: {channels: [string], exclude_archived: true, include_dms: true, include_group_dms: true, include_private: true, weight: 0}, vault: {weight: 0}, web_crawler: {max_depth: 0, url: url, weight: 0}}", + "--options", "{after: '2019-12-27T18:11:19.117Z', answer_model: llama-3.1, before: '2019-12-27T18:11:19.117Z', box: {weight: 0}, filter: {}, google_calendar: {calendar_id: calendar_id, weight: 0}, google_drive: {weight: 0}, google_mail: {label_ids: [string], weight: 0}, max_results: 200, memory_types: [procedure], notion: {notion_page_ids: [string], weight: 0}, recency_half_life_days: 1, reddit: {period: hour, sort: relevance, subreddit: subreddit, weight: 0}, resource_ids: [string], slack: {channels: [string], exclude_archived: true, include_dms: true, include_group_dms: true, include_private: true, weight: 0}, vault: {weight: 0}, web_crawler: {max_depth: 0, url: url, weight: 0}}", "--source", "vault", ) }) @@ -221,6 +221,7 @@ func TestMemoriesSearch(t *testing.T) { "--options.max-results", "200", "--options.memory-types", "[procedure]", "--options.notion", "{notion_page_ids: [string], weight: 0}", + "--options.recency-half-life-days", "1", "--options.reddit", "{period: hour, sort: relevance, subreddit: subreddit, weight: 0}", "--options.resource-ids", "[string]", "--options.slack", "{channels: [string], exclude_archived: true, include_dms: true, include_group_dms: true, include_private: true, weight: 0}", @@ -260,6 +261,7 @@ func TestMemoriesSearch(t *testing.T) { " notion_page_ids:\n" + " - string\n" + " weight: 0\n" + + " recency_half_life_days: 1\n" + " reddit:\n" + " period: hour\n" + " sort: relevance\n" + From 75c6ac11e45129fc650df9282ee281c910573ce2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:30:49 +0000 Subject: [PATCH 41/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2d998a9..3433ed7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-7fa6cf3d29bd35dc57fbcd164d5d059790535463f31a354a32f2628e480443d7.yml -openapi_spec_hash: a541dd122cc17cd49c5ddf64699d24c5 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a9b9447c6a65c54385fe3ae40350172c7539ece91f5cf982cd474a4afd86e050.yml +openapi_spec_hash: 1a2ca5653244b4e205d3df51991b4238 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 4b771ee81d9ad293f213c2c85d0a3bd107c4a49c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:31:06 +0000 Subject: [PATCH 42/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 3433ed7..1c1639a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a9b9447c6a65c54385fe3ae40350172c7539ece91f5cf982cd474a4afd86e050.yml -openapi_spec_hash: 1a2ca5653244b4e205d3df51991b4238 +openapi_spec_hash: 0d1d6e45ba54d24c8262744d34192950 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From 357c3823146f5658727e12fe552a3f6a4891d89e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:46:51 +0000 Subject: [PATCH 43/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 1c1639a..ff65df7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-a9b9447c6a65c54385fe3ae40350172c7539ece91f5cf982cd474a4afd86e050.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-e124cba28023639d4e72808379668f874ed53285fdd75ff2a7f580c12e3815f0.yml openapi_spec_hash: 0d1d6e45ba54d24c8262744d34192950 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From c419250da2b4345806d79c322c0384742e8b234a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:54:25 +0000 Subject: [PATCH 44/51] fix(cli): correctly load zsh autocompletion --- .../autocomplete/shellscripts/zsh_autocomplete.zsh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh index 4d4bdcd..d937171 100644 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh @@ -1,5 +1,4 @@ -#!/bin/zsh -compdef ____APPNAME___zsh_autocomplete __APPNAME__ +#compdef __APPNAME__ ____APPNAME___zsh_autocomplete() { @@ -44,3 +43,14 @@ ____APPNAME___zsh_autocomplete() { ;; esac } + +# When installed in fpath (e.g., via Homebrew's zsh_completion stanza), this file +# is autoloaded as the function ___APPNAME__ and its body becomes that function's +# body. Detect that case via funcstack and dispatch to the completion function. +# When sourced (e.g., `source <(__APPNAME__ @completion zsh)`), register the +# function with compdef instead. +if [[ "${funcstack[1]}" = "___APPNAME__" ]]; then + ____APPNAME___zsh_autocomplete "$@" +else + compdef ____APPNAME___zsh_autocomplete __APPNAME__ +fi From fb3478d0710310be3cf91b4e9770178bc56ea01e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:02:11 +0000 Subject: [PATCH 45/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index ff65df7..9e30a0e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-e124cba28023639d4e72808379668f874ed53285fdd75ff2a7f580c12e3815f0.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-e124cba28023639d4e72808379668f874ed53285fdd75ff2a7f580c12e3815f0.yml openapi_spec_hash: 0d1d6e45ba54d24c8262744d34192950 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From c9834390753ff2facace1035b6b692e73a73e81a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:03:24 +0000 Subject: [PATCH 46/51] fix: flags for nullable body scalar fields are strictly typed --- internal/requestflag/innerflag.go | 16 +- internal/requestflag/requestflag.go | 128 ++++++++++++- internal/requestflag/requestflag_test.go | 234 +++++++++++++++++++++++ pkg/cmd/action.go | 8 +- pkg/cmd/auth.go | 4 +- pkg/cmd/evaluate.go | 2 +- pkg/cmd/folder.go | 8 +- pkg/cmd/integration.go | 2 +- pkg/cmd/integrationslack.go | 2 +- pkg/cmd/memory.go | 24 +-- pkg/cmd/session.go | 4 +- pkg/cmd/vault.go | 2 +- 12 files changed, 402 insertions(+), 32 deletions(-) diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go index eeeb8bc..528915f 100644 --- a/internal/requestflag/innerflag.go +++ b/internal/requestflag/innerflag.go @@ -14,7 +14,8 @@ import ( type InnerFlag[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool, + string | float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { Name string // name of the flag DefaultText string // default text of the flag for usage purposes @@ -25,6 +26,12 @@ type InnerFlag[ OuterFlag cli.Flag // The flag on which this inner flag will set values InnerField string // The inner field which this flag will set DataAliases []string // alternate names recognized in YAML values passed as the outer flag + + // OuterIsArrayOfObjects tells an untyped outer flag (Flag[any], used for nullable + // complex schemas) to seed its underlying value as []map[string]any rather than + // map[string]any before SetInnerField runs. The hint is ignored for typed outer + // flags whose zero value already carries a dispatchable reflect.Kind. + OuterIsArrayOfObjects bool } // GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. @@ -76,6 +83,10 @@ func (f *InnerFlag[T]) Set(name string, rawVal string) error { } } + if seeder, ok := f.OuterFlag.(InnerFieldSeeder); ok { + seeder.SeedInnerCollection(f.OuterIsArrayOfObjects) + } + if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { settableInnerField.SetInnerField(f.InnerField, parsedValue) } else { @@ -136,6 +147,9 @@ func (f *InnerFlag[T]) TypeName() string { if ty == nil { return "" } + if ty.Kind() == reflect.Pointer { + ty = ty.Elem() + } // Get base type name with special handling for built-in types getTypeName := func(t reflect.Type) string { diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index bfaf064..54c2509 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -15,10 +15,15 @@ import ( // Flag [T] is a generic flag base which can be used to implement the most // common interfaces used by urfave/cli. Additionally, it allows specifying // where in an HTTP request the flag values should be placed (e.g. query, body, etc.). +// +// Pointer-to-primitive type parameters (e.g. *string) are used for flags whose underlying +// schema is nullable. They give flags a tri-state: unset (excluded from the request), +// set to the literal "null" (nil pointer → JSON null), or set to a value (*v → JSON value). type Flag[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool, + string | float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { Name string // name of the flag Category string // category of the flag, if any @@ -341,6 +346,11 @@ func (f *Flag[T]) TypeName() string { if ty == nil { return "" } + // Deref pointer-typed flags so --help surfaces the pointee kind (e.g. "string"), not + // Go's pointer syntax. + if ty.Kind() == reflect.Pointer { + ty = ty.Elem() + } // Get base type name with special handling for built-in types getTypeName := func(t reflect.Type) string { @@ -396,6 +406,8 @@ func (f *Flag[T]) IsMultiValueFlag() bool { } func (f *Flag[T]) IsBoolFlag() bool { + // Flag[*bool] is deliberately not treated as a bool flag — the pointer form needs an + // explicit value (`--foo true`, `--foo null`) to disambiguate the tri-state. _, isBool := any(f.Default).(bool) return isBool } @@ -419,7 +431,8 @@ func (f Flag[T]) IsLocal() bool { type cliValue[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool, + float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { value T } @@ -429,12 +442,27 @@ type cliValue[ func parseCLIArg[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool, + float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ](value string) (T, error) { var parsedValue any var err error var empty T + + if value == "null" { + switch any(empty).(type) { + // Pointer-to-primitive: explicit nil gives the tri-state its "null" state + // (unset / null / value). Without this, numeric flags would fail to parse + // "null" and string flags would accept the literal word as a raw value. + case *string, *int64, *float64, *bool, *DateValue, *DateTimeValue, *TimeValue: + return empty, nil + // Maps marshal nil as JSON null natively; short-circuit avoids a YAML round-trip. + case map[string]any: + return empty, nil + } + } + switch any(empty).(type) { case string: parsedValue = value @@ -465,6 +493,48 @@ func parseCLIArg[ parsedValue = t } + // Pointer-to-primitive flags reach here only when `value != "null"`; we parse the + // pointee type and return its address so JSON marshaling emits the underlying value. + case *string: + v := value + parsedValue = &v + case *int64: + var v int64 + v, err = strconv.ParseInt(value, 0, 64) + if err == nil { + parsedValue = &v + } + case *float64: + var v float64 + v, err = strconv.ParseFloat(value, 64) + if err == nil { + parsedValue = &v + } + case *bool: + var v bool + v, err = strconv.ParseBool(value) + if err == nil { + parsedValue = &v + } + case *DateTimeValue: + var dt DateTimeValue + err = (&dt).Parse(value) + if err == nil { + parsedValue = &dt + } + case *DateValue: + var d DateValue + err = (&d).Parse(value) + if err == nil { + parsedValue = &d + } + case *TimeValue: + var t TimeValue + err = (&t).Parse(value) + if err == nil { + parsedValue = &t + } + default: if strings.HasPrefix(value, "@") { // File literals like @file.txt should work here @@ -501,6 +571,13 @@ func parseCLIArg[ } +// Ptr returns a pointer to its argument. It is used to initialize `Default` on pointer-typed +// Flag values, since Go does not allow taking the address of a composite literal's element +// or of an untyped constant. +func Ptr[T any](v T) *T { + return &v +} + // 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`), @@ -594,6 +671,15 @@ func (c *cliValue[T]) String() string { // For basic types, use standard string representation return fmt.Sprintf("%v", v) + case *string, *int64, *float64, *bool, *DateTimeValue, *DateValue, *TimeValue: + // Pointer-to-primitive: nil renders as "null" (the CLI literal that produces it); + // non-nil derefs to the pointee's standard representation. + rv := reflect.ValueOf(v) + if rv.IsNil() { + return "null" + } + return fmt.Sprintf("%v", rv.Elem().Interface()) + default: // For complex types, convert to YAML yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) @@ -705,6 +791,15 @@ type SettableInnerField interface { SetInnerField(string, any) } +// InnerFieldSeeder lets an InnerFlag prepare its outer flag's underlying value +// before dispatching SetInnerField. This is only meaningful for Flag[any] — +// the codegen output for nullable complex schemas — whose untyped-nil zero +// value would otherwise have no reflect.Kind for the inner-field switch to +// dispatch on. +type InnerFieldSeeder interface { + SeedInnerCollection(isArrayOfObjects bool) +} + func (f *Flag[T]) SetInnerField(field string, val any) { if f.value == nil { f.value = &cliValue[T]{} @@ -718,6 +813,33 @@ func (f *Flag[T]) SetInnerField(field string, val any) { } } +// SeedInnerCollection initializes a Flag[any]'s underlying value as an empty +// map[string]any or []map[string]any so subsequent SetInnerField calls have a +// dispatchable reflect.Kind. For typed Flag[T] this is a no-op: the type +// assertion fails and the existing reflect.Kind on the typed-nil zero value +// already routes correctly. +func (f *Flag[T]) SeedInnerCollection(isArrayOfObjects bool) { + if f.value == nil { + f.value = &cliValue[T]{} + } + cv, ok := f.value.(*cliValue[T]) + if !ok { + return + } + if reflect.ValueOf(cv.value).Kind() != reflect.Invalid { + return + } + if isArrayOfObjects { + if seed, ok := any([]map[string]any{}).(T); ok { + cv.value = seed + } + return + } + if seed, ok := any(map[string]any{}).(T); ok { + cv.value = seed + } +} + func (c *cliValue[T]) SetInnerField(field string, val any) { flagVal := c.value flagValReflect := reflect.ValueOf(flagVal) diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 0e86e07..06ffb72 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -1,6 +1,7 @@ package requestflag import ( + "encoding/json" "fmt" "testing" "time" @@ -616,6 +617,178 @@ func TestYamlHandling(t *testing.T) { }) } +// TestNullLiteralHandling pins how each Flag[T] type handles the literal value "null" +// when passed via the CLI. Pointer-typed flags serialize nil as JSON null, which is how +// nullable body fields (`anyOf: [T, null]` / `{nullable: true}`) let users clear a field +// via `--foo null`. Non-pointer primitive flags treat "null" as a raw value — these are +// non-nullable schemas where explicit null has no API semantics anyway. +func TestNullLiteralHandling(t *testing.T) { + t.Parallel() + + assertJSONBody := func(t *testing.T, value any, expected string) { + t.Helper() + body, err := json.Marshal(map[string]any{"foo": value}) + assert.NoError(t, err) + assert.JSONEq(t, expected, string(body)) + } + + t.Run("Flag[any] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[string] null is the raw string \"null\"", func(t *testing.T) { + t.Parallel() + cv := &cliValue[string]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":"null"}`) + }) + + t.Run("Flag[int64] null errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[int64]{} + assert.Error(t, cv.Set("null")) + }) + + t.Run("Flag[*string] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*string]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*string] value sends the string", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*string]{} + assert.NoError(t, cv.Set("1.1")) + assertJSONBody(t, cv.Get(), `{"foo":"1.1"}`) + }) + + t.Run("Flag[*int64] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*int64] value sends the integer", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.NoError(t, cv.Set("42")) + assertJSONBody(t, cv.Get(), `{"foo":42}`) + }) + + t.Run("Flag[*int64] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.Error(t, cv.Set("not-an-int")) + }) + + t.Run("Flag[*bool] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*bool]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*bool] value sends the boolean", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*bool]{} + assert.NoError(t, cv.Set("true")) + assertJSONBody(t, cv.Get(), `{"foo":true}`) + }) + + t.Run("Flag[*float64] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*float64] value sends the float", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set("1.5")) + assertJSONBody(t, cv.Get(), `{"foo":1.5}`) + }) + + t.Run("Flag[*float64] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.Error(t, cv.Set("not-a-float")) + }) + + t.Run("Flag[*DateValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*DateValue] value sends the date", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.NoError(t, cv.Set("2023-05-15")) + assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15"}`) + }) + + t.Run("Flag[*DateValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.Error(t, cv.Set("not-a-date")) + }) + + t.Run("Flag[*DateTimeValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*DateTimeValue] value sends the datetime", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.NoError(t, cv.Set("2023-05-15T14:30:45Z")) + assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15T14:30:45Z"}`) + }) + + t.Run("Flag[*DateTimeValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.Error(t, cv.Set("not-a-datetime")) + }) + + t.Run("Flag[*TimeValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*TimeValue] value sends the time", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.NoError(t, cv.Set("14:30:45")) + assertJSONBody(t, cv.Get(), `{"foo":"14:30:45"}`) + }) + + t.Run("Flag[*TimeValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.Error(t, cv.Set("not-a-time")) + }) + + // Nullable maps don't need pointer wrapping — a nil map already marshals as JSON null. + t.Run("Flag[map[string]any] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[map[string]any]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) +} + func TestFlagTypeNames(t *testing.T) { t.Parallel() @@ -646,3 +819,64 @@ func TestFlagTypeNames(t *testing.T) { }) } } + +// TestInnerFlagDispatchOnUntypedFlag pins inner-flag behavior for `Flag[any]`, +// which is the codegen output for nullable complex schemas (`anyOf: [T, null]` +// or `{nullable: true}`). The untyped-nil zero value carries no reflect.Kind, +// so SetInnerField has nowhere to dispatch the assignment — without explicit +// help the inner-field value silently drops. +func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { + t.Parallel() + + t.Run("nullable array of objects appends element from inner flag", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "mcp-server"} + assert.NoError(t, outer.PreParse()) + + nameFlag := &InnerFlag[string]{ + Name: "mcp-server.name", InnerField: "name", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":[{"name":"first"}]}`, string(body)) + }) + + t.Run("nullable object sets field from inner flag", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "metadata"} + assert.NoError(t, outer.PreParse()) + + keyFlag := &InnerFlag[string]{ + Name: "metadata.key", InnerField: "key", OuterFlag: outer, + } + assert.NoError(t, keyFlag.Set("metadata.key", "value")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":{"key":"value"}}`, string(body)) + }) + + t.Run("multiple inner flags merge into the trailing element", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "mcp-server"} + assert.NoError(t, outer.PreParse()) + + nameFlag := &InnerFlag[string]{ + Name: "mcp-server.name", InnerField: "name", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + urlFlag := &InnerFlag[string]{ + Name: "mcp-server.url", InnerField: "url", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) + assert.NoError(t, urlFlag.Set("mcp-server.url", "https://example.com")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) + }) +} diff --git a/pkg/cmd/action.go b/pkg/cmd/action.go index e839959..8f3b4b5 100644 --- a/pkg/cmd/action.go +++ b/pkg/cmd/action.go @@ -43,7 +43,7 @@ var actionsAddReaction = cli.Command{ Required: true, BodyPath: "timestamp", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "connection", Usage: "Connection ID. If omitted, auto-resolved from provider + user.", BodyPath: "connection", @@ -70,17 +70,17 @@ var actionsSendMessage = cli.Command{ Required: true, BodyPath: "text", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "channel", Usage: "Channel ID (required for Slack)", BodyPath: "channel", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "connection", Usage: "Connection ID. If omitted, auto-resolved from provider + user.", BodyPath: "connection", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "parent", Usage: "Parent message ID for threading (thread_ts for Slack)", BodyPath: "parent", diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index 754bb5c..e9eae31 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -42,12 +42,12 @@ var authUserToken = cli.Command{ Required: true, BodyPath: "user_id", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "expires-in", Usage: "Token lifetime, e.g., '30m', '2h', '1d'. Defaults to 24 hours if not provided.", BodyPath: "expires_in", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "origin", Usage: "Origin of the request, used for CSRF protection. If set, the token will only be valid for requests originating from this origin.", BodyPath: "origin", diff --git a/pkg/cmd/evaluate.go b/pkg/cmd/evaluate.go index 6022942..173713c 100644 --- a/pkg/cmd/evaluate.go +++ b/pkg/cmd/evaluate.go @@ -37,7 +37,7 @@ var evaluateScoreHighlight = cli.Command{ Name: "highlight-id", Required: true, }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "comment", Usage: "Comment on the chunk", BodyPath: "comment", diff --git a/pkg/cmd/folder.go b/pkg/cmd/folder.go index 2a585e3..f0c10a3 100644 --- a/pkg/cmd/folder.go +++ b/pkg/cmd/folder.go @@ -23,7 +23,7 @@ var foldersList = cli.Command{ Name: "connection-id", Required: true, }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "parent-id", Usage: "Parent folder ID. Omit for root-level folders.", QueryPath: "parent_id", @@ -86,17 +86,17 @@ var foldersSetPolicies = cli.Command{ Required: true, BodyPath: "sync_mode", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "folder-name", Usage: "Display name of the folder", BodyPath: "folder_name", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "folder-path", Usage: "Display path of the folder", BodyPath: "folder_path", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "parent-folder-id", Usage: "Parent folder's provider ID for inheritance resolution", BodyPath: "parent_folder_id", diff --git a/pkg/cmd/integration.go b/pkg/cmd/integration.go index e86dd2a..a151256 100644 --- a/pkg/cmd/integration.go +++ b/pkg/cmd/integration.go @@ -32,7 +32,7 @@ var integrationsConnect = cli.Command{ Name: "integration-id", Required: true, }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "redirect-url", QueryPath: "redirect_url", }, diff --git a/pkg/cmd/integrationslack.go b/pkg/cmd/integrationslack.go index 19a4584..c6ed28e 100644 --- a/pkg/cmd/integrationslack.go +++ b/pkg/cmd/integrationslack.go @@ -25,7 +25,7 @@ var integrationsSlackList = cli.Command{ Default: []string{}, QueryPath: "channels", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ Name: "exclude-archived", Usage: "If set, pass 'exclude_archived' to Slack. If None, omit the param.", QueryPath: "exclude_archived", diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index e3f1ce6..6485098 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -68,16 +68,16 @@ var memoriesList = cli.Command{ Usage: "This endpoint allows you to paginate through all documents in the index. You can\nfilter the documents by title, date, metadata, etc.", Suggest: true, Flags: []cli.Flag{ - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "collection", Usage: "Filter documents by collection.", QueryPath: "collection", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "cursor", QueryPath: "cursor", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "filter", Usage: `Filter documents by metadata using MongoDB-style operators. Example: {"department": "engineering", "priority": {"$gt": 3}}`, QueryPath: "filter", @@ -87,12 +87,12 @@ var memoriesList = cli.Command{ Default: 50, QueryPath: "size", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "source", Usage: "Filter documents by source.", QueryPath: "source", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "status", Usage: "Filter documents by status.", QueryPath: "status", @@ -136,7 +136,7 @@ var memoriesAdd = cli.Command{ Required: true, BodyPath: "text", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "collection", Usage: "The collection to add the document to — deprecated, set the collection using metadata instead.", BodyPath: "collection", @@ -156,7 +156,7 @@ var memoriesAdd = cli.Command{ Usage: "The resource ID to add the document to. If not provided, a new resource ID will be generated. If provided, the document will be updated if it already exists.", BodyPath: "resource_id", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "title", Usage: "Title of the document.", BodyPath: "title", @@ -187,7 +187,7 @@ var memoriesAddBulk = requestflag.WithInnerFlags(cli.Command{ Usage: "Full text of the document.", InnerField: "text", }, - &requestflag.InnerFlag[any]{ + &requestflag.InnerFlag[*string]{ Name: "item.collection", Usage: "The collection to add the document to — deprecated, set the collection using metadata instead.", InnerField: "collection", @@ -207,7 +207,7 @@ var memoriesAddBulk = requestflag.WithInnerFlags(cli.Command{ Usage: "The resource ID to add the document to. If not provided, a new resource ID will be generated. If provided, the document will be updated if it already exists.", InnerField: "resource_id", }, - &requestflag.InnerFlag[any]{ + &requestflag.InnerFlag[*string]{ Name: "item.title", Usage: "Title of the document.", InnerField: "title", @@ -333,7 +333,7 @@ var memoriesSearch = requestflag.WithInnerFlags(cli.Command{ Usage: "Search options for Notion", InnerField: "notion", }, - &requestflag.InnerFlag[any]{ + &requestflag.InnerFlag[*float64]{ Name: "options.recency-half-life-days", Usage: "When set, multiplies each result's score by an exponential-decay factor based on the document's most recent activity timestamp (source-reported last_modified, falling back to document_date). A document one half-life old gets its score halved. Resources with no recency timestamp are passed through unchanged. Leave unset to disable.", InnerField: "recency_half_life_days", @@ -387,12 +387,12 @@ var memoriesUpload = cli.Command{ BodyPath: "file", FileInput: true, }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "collection", Usage: "The collection to add the document to — deprecated, set the collection using metadata instead.", BodyPath: "collection", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "metadata", Usage: "Custom metadata as JSON string for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, or boolean.", BodyPath: "metadata", diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index c29645d..1d988a5 100644 --- a/pkg/cmd/session.go +++ b/pkg/cmd/session.go @@ -36,7 +36,7 @@ var sessionsAdd = cli.Command{ Default: []string{"procedure"}, BodyPath: "extract", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "format", Usage: "Trace format: 'vercel', 'hyperdoc', or 'openclaw'. Auto-detected if not set.", BodyPath: "format", @@ -51,7 +51,7 @@ var sessionsAdd = cli.Command{ Usage: "Resource identifier for the trace.", BodyPath: "session_id", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "title", Usage: "Title of the trace", BodyPath: "title", diff --git a/pkg/cmd/vault.go b/pkg/cmd/vault.go index deac0ba..9a6b07b 100644 --- a/pkg/cmd/vault.go +++ b/pkg/cmd/vault.go @@ -19,7 +19,7 @@ var vaultsList = cli.Command{ Usage: "This endpoint lists all collections, and how many documents are in each\ncollection. All documents that do not have a collection assigned are in the\n`null` collection.", Suggest: true, Flags: []cli.Flag{ - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "cursor", QueryPath: "cursor", }, From 14f99ef63dff6a55aca07c84419c42ce25d99142 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:05:26 +0000 Subject: [PATCH 47/51] feat: support passing path and query params over stdin --- internal/requestflag/requestflag.go | 115 +++++++- internal/requestflag/requestflag_test.go | 345 +++++++++++++++++++++++ pkg/cmd/action.go | 8 +- pkg/cmd/auth.go | 4 +- pkg/cmd/connection.go | 5 +- pkg/cmd/evaluate.go | 23 +- pkg/cmd/flagoptions.go | 48 +++- pkg/cmd/folder.go | 41 +-- pkg/cmd/integration.go | 9 +- pkg/cmd/integrationslack.go | 4 +- pkg/cmd/integrationwebcrawler.go | 4 +- pkg/cmd/memory.go | 80 +++--- pkg/cmd/session.go | 4 +- pkg/cmd/vault.go | 4 +- 14 files changed, 597 insertions(+), 97 deletions(-) diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 54c2509..77c4f1f 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -1,6 +1,7 @@ package requestflag import ( + "encoding/json" "fmt" "reflect" "strconv" @@ -12,6 +13,26 @@ import ( "github.com/urfave/cli/v3" ) +// formatForFlagSet converts a Go value parsed from YAML/JSON stdin data into a string +// that flag.Set (and thus parseCLIArg) can parse correctly for each flag type. +// Strings are returned as-is (parseCLIArg[string] assigns the raw value directly, so +// JSON-quoting must be avoided). Scalars use %v. Complex types (maps, slices) are +// JSON-encoded, which the yaml.Unmarshal default branch in parseCLIArg can parse. +func formatForFlagSet(val any) (string, error) { + switch v := val.(type) { + case string: + return v, nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprintf("%v", val), nil + default: + b, err := json.Marshal(val) + if err != nil { + return "", fmt.Errorf("cannot format value %T for flag.Set: %w", val, err) + } + return string(b), nil + } +} + // Flag [T] is a generic flag base which can be used to implement the most // common interfaces used by urfave/cli. Additionally, it allows specifying // where in an HTTP request the flag values should be placed (e.g. query, body, etc.). @@ -41,6 +62,7 @@ type Flag[ HeaderPath string // location in the request header to put this flag's value BodyPath string // location in the request body to put this flag's value BodyRoot bool // if true, then use this value as the entire request body + PathParam string // name of the URL path parameter this flag's value maps to // Const, when true, marks this flag as a constant. The flag's Default value is used as the fixed value // and always included in the request (IsSet returns true). The user can still see and override the flag, @@ -72,6 +94,7 @@ type InRequest interface { GetQueryPath() string GetHeaderPath() string GetBodyPath() string + GetPathParam() string IsBodyRoot() bool IsFileInput() bool GetDataAliases() []string @@ -89,6 +112,10 @@ func (f Flag[T]) GetBodyPath() string { return f.BodyPath } +func (f Flag[T]) GetPathParam() string { + return f.PathParam +} + func (f Flag[T]) IsBodyRoot() bool { return f.BodyRoot } @@ -108,7 +135,91 @@ type RequestContents struct { Body any } -// Extract query parameters, headers, and body values from command flags. +// ApplyStdinDataToFlags sets flag values from a parsed stdin data map for flags that have not already been +// set via the command line. This allows piped YAML/JSON data to satisfy path, query, and header parameters. +// Body parameters are excluded: they are already handled by the maps.Copy merge in flagOptions. +// For each unset flag, if the parsed data map contains a key matching the flag's QueryPath, HeaderPath, or +// PathParam (or any of its DataAliases), the flag is set to that value via flag.Set. +// +// Inner flags (those with an outer flag) are also handled: if the outer flag's body path key exists in the +// data map and contains a nested map with a key matching the inner flag's field (or aliases), the inner +// flag is set from that nested value. +func ApplyStdinDataToFlags(cmd *cli.Command, data map[string]any) error { + for _, flag := range cmd.Flags { + if flag.IsSet() { + continue + } + + // Handle inner flags: look for their value nested under the outer flag's body path. + if inner, ok := flag.(HasOuterFlag); ok { + outer, outerOk := inner.GetOuterFlag().(InRequest) + if !outerOk || outer.GetBodyPath() == "" { + continue + } + nested, ok := data[outer.GetBodyPath()].(map[string]any) + if !ok { + continue + } + innerField := inner.GetInnerField() + val, found := nested[innerField] + if !found { + for _, alias := range inner.GetDataAliases() { + if alias != "" && alias != innerField { + if v, ok := nested[alias]; ok { + val, found = v, true + break + } + } + } + } + if !found { + continue + } + setVal, err := formatForFlagSet(val) + if err != nil { + return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) + } + if err := flag.Set(flag.Names()[0], setVal); err != nil { + return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) + } + continue + } + + inReq, ok := flag.(InRequest) + if !ok { + continue + } + + // Try each request location in turn, checking the canonical path key and all aliases. + // Body params are excluded: they are already handled by the maps.Copy merge in flagOptions. + for _, path := range []string{inReq.GetQueryPath(), inReq.GetHeaderPath(), inReq.GetPathParam()} { + if path == "" { + continue + } + var val any + var found bool + for _, key := range append([]string{path}, inReq.GetDataAliases()...) { + if v, ok := data[key]; ok { + val, found = v, true + break + } + } + if !found { + continue + } + setVal, err := formatForFlagSet(val) + if err != nil { + return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) + } + if err := flag.Set(flag.Names()[0], setVal); err != nil { + return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) + } + break + } + } + return nil +} + func ExtractRequestContents(cmd *cli.Command) RequestContents { bodyMap := make(map[string]any) res := RequestContents{ @@ -291,7 +402,7 @@ func (f *Flag[T]) IsRequired() bool { } // Intentionally don't use `f.Required`, because request flags may be passed // over stdin as well as by flag. - if f.BodyPath != "" || f.BodyRoot { + if f.BodyPath != "" || f.BodyRoot || f.PathParam != "" || f.QueryPath != "" || f.HeaderPath != "" { return false } return f.Required diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 06ffb72..779bd57 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -880,3 +880,348 @@ func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) }) } + +func TestApplyStdinDataToFlags(t *testing.T) { + t.Parallel() + + t.Run("sets query path flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"account_id": "acct_123"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "acct_123", flag.Get()) + }) + + t.Run("sets header path flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "idempotency-key", + HeaderPath: "Idempotency-Key", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"Idempotency-Key": "key-xyz"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "key-xyz", flag.Get()) + }) + + t.Run("does not set body path flag from piped data", func(t *testing.T) { + t.Parallel() + + // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. + flag := &Flag[string]{ + Name: "message", + BodyPath: "message", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"message": "hello world"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("does not override flag already set via CLI", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("account-id", "explicit_value")) + + data := map[string]any{"account_id": "piped_value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // The explicitly-set value should win. + assert.Equal(t, "explicit_value", flag.Get()) + }) + + t.Run("sets integer query flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[int64]{ + Name: "page-size", + QueryPath: "page_size", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"page_size": int64(50)} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, int64(50), flag.Get()) + }) + + t.Run("sets boolean query flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[bool]{ + Name: "include-deleted", + QueryPath: "include_deleted", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"include_deleted": true} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, true, flag.Get()) + }) + + t.Run("resolves query path flag via data alias", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + DataAliases: []string{"accountId", "account"}, + } + assert.NoError(t, flag.PreParse()) + + // Use one of the aliases as the key in piped data. + data := map[string]any{"accountId": "acct_alias"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "acct_alias", flag.Get()) + }) + + t.Run("does not set body path flag via data alias", func(t *testing.T) { + t.Parallel() + + // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. + flag := &Flag[string]{ + Name: "user-name", + BodyPath: "user_name", + DataAliases: []string{"userName", "username"}, + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"userName": "alice"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("ignores flags with no matching key in piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"other_key": "value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("ignores flags with no path set", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "some-flag", + // No QueryPath, HeaderPath, or BodyPath + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"some-flag": "value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("handles multiple flags from piped data", func(t *testing.T) { + t.Parallel() + + accountFlag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + limitFlag := &Flag[int64]{ + Name: "limit", + QueryPath: "limit", + } + assert.NoError(t, accountFlag.PreParse()) + assert.NoError(t, limitFlag.PreParse()) + + data := map[string]any{ + "account_id": "acct_abc", + "limit": int64(25), + } + cmd := &cli.Command{Flags: []cli.Flag{accountFlag, limitFlag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, accountFlag.IsSet()) + assert.Equal(t, "acct_abc", accountFlag.Get()) + assert.True(t, limitFlag.IsSet()) + assert.Equal(t, int64(25), limitFlag.Get()) + }) + + t.Run("sets inner flag from nested piped data under outer body path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + cityInner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + OuterFlag: outer, + } + + data := map[string]any{ + "address": map[string]any{"city": "San Francisco"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // InnerFlag.IsSet() is always false by design; verify the value was written + // into the outer flag's underlying map instead. + outerVal, ok := outer.Get().(map[string]any) + assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) + assert.Equal(t, "San Francisco", outerVal["city"]) + }) + + t.Run("sets inner flag via data alias in nested piped data", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + cityInner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + DataAliases: []string{"cityName"}, + OuterFlag: outer, + } + + // Use the alias in piped data. + data := map[string]any{ + "address": map[string]any{"cityName": "Portland"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // InnerFlag.IsSet() is always false by design; verify the value was written + // into the outer flag's underlying map instead. + outerVal, ok := outer.Get().(map[string]any) + assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) + assert.Equal(t, "Portland", outerVal["city"]) + }) + + t.Run("does not set inner flag when outer flag has no body path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "options", + // No BodyPath set + } + assert.NoError(t, outer.PreParse()) + + inner := &InnerFlag[string]{ + Name: "options.key", + InnerField: "key", + OuterFlag: outer, + } + + data := map[string]any{ + "options": map[string]any{"key": "value"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, inner.IsSet()) + }) + + t.Run("does not set inner flag when piped data has no nested map for outer path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + inner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + OuterFlag: outer, + } + + // The outer body path key is missing from the piped data. + data := map[string]any{"other": "value"} + cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, inner.IsSet()) + }) + + t.Run("canonical path key takes precedence over alias when both are present", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + DataAliases: []string{"accountId"}, + } + assert.NoError(t, flag.PreParse()) + + // Both canonical and alias present — canonical should win because it's checked first. + data := map[string]any{ + "account_id": "canonical_value", + "accountId": "alias_value", + } + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "canonical_value", flag.Get()) + }) + + t.Run("empty data map does not set any flags", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, map[string]any{})) + + assert.False(t, flag.IsSet()) + }) +} diff --git a/pkg/cmd/action.go b/pkg/cmd/action.go index 8f3b4b5..5fbec88 100644 --- a/pkg/cmd/action.go +++ b/pkg/cmd/action.go @@ -98,8 +98,6 @@ func handleActionsAddReaction(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.ActionAddReactionParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -111,6 +109,8 @@ func handleActionsAddReaction(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.ActionAddReactionParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Actions.AddReaction(ctx, params, options...) @@ -139,8 +139,6 @@ func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.ActionSendMessageParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -152,6 +150,8 @@ func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.ActionSendMessageParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Actions.SendMessage(ctx, params, options...) diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index e9eae31..73d7a94 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -143,8 +143,6 @@ func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.AuthUserTokenParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -156,6 +154,8 @@ func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.AuthUserTokenParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Auth.UserToken(ctx, params, options...) diff --git a/pkg/cmd/connection.go b/pkg/cmd/connection.go index d329036..d65db94 100644 --- a/pkg/cmd/connection.go +++ b/pkg/cmd/connection.go @@ -29,8 +29,9 @@ var connectionsRevoke = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "connection-id", - Required: true, + Name: "connection-id", + Required: true, + PathParam: "connection_id", }, }, Action: handleConnectionsRevoke, diff --git a/pkg/cmd/evaluate.go b/pkg/cmd/evaluate.go index 173713c..669e11a 100644 --- a/pkg/cmd/evaluate.go +++ b/pkg/cmd/evaluate.go @@ -20,8 +20,9 @@ var evaluateGetQuery = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "query-id", - Required: true, + Name: "query-id", + Required: true, + PathParam: "query_id", }, }, Action: handleEvaluateGetQuery, @@ -34,8 +35,9 @@ var evaluateScoreHighlight = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "highlight-id", - Required: true, + Name: "highlight-id", + Required: true, + PathParam: "highlight_id", }, &requestflag.Flag[*string]{ Name: "comment", @@ -59,8 +61,9 @@ var evaluateScoreQuery = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "query-id", - Required: true, + Name: "query-id", + Required: true, + PathParam: "query_id", }, &requestflag.Flag[float64]{ Name: "score", @@ -126,8 +129,6 @@ func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.EvaluateScoreHighlightParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -139,6 +140,8 @@ func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.EvaluateScoreHighlightParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Evaluate.ScoreHighlight( @@ -175,8 +178,6 @@ func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.EvaluateScoreQueryParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -188,6 +189,8 @@ func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.EvaluateScoreQueryParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Evaluate.ScoreQuery( diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index bdf1aef..91eb14a 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -339,7 +339,7 @@ func flagOptions( } stdinConsumedByPipe := false - if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { + if bodyType != ApplicationOctetStream && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { return nil, err @@ -353,16 +353,45 @@ func flagOptions( } if bodyMap, ok := bodyData.(map[string]any); ok { applyDataAliases(cmd, bodyMap) - if flagMap, ok := requestContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) - requestContents.Body = bodyMap + // Apply any matching keys from the piped data to path, query, and header flags + // that have not already been set via the command line. + if err := requestflag.ApplyStdinDataToFlags(cmd, bodyMap); err != nil { + return nil, err + } + // Re-extract request contents now that flags may have been updated. + requestContents = requestflag.ExtractRequestContents(cmd) + // Remove keys that were consumed as query, header, or path params so they + // don't also leak into the request body via the maps.Copy merge below. + // We delete both the canonical key and any aliases since the user may have + // piped data using an alias name rather than the canonical API name. + for _, flag := range cmd.Flags { + inReq, ok := flag.(requestflag.InRequest) + if !ok || !flag.IsSet() { + continue + } + if inReq.GetQueryPath() != "" || inReq.GetHeaderPath() != "" || inReq.GetPathParam() != "" { + delete(bodyMap, inReq.GetQueryPath()) + delete(bodyMap, inReq.GetHeaderPath()) + delete(bodyMap, inReq.GetPathParam()) + for _, alias := range inReq.GetDataAliases() { + delete(bodyMap, alias) + } + } + } + if bodyType != EmptyBody { + if flagMap, ok := requestContents.Body.(map[string]any); ok { + maps.Copy(bodyMap, flagMap) + requestContents.Body = bodyMap + } else { + bodyData = requestContents.Body + } + } + } else if bodyType != EmptyBody { + if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { + return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) } else { - bodyData = requestContents.Body + requestContents.Body = bodyData } - } else if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { - return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) - } else { - requestContents.Body = bodyData } } } @@ -370,7 +399,6 @@ func flagOptions( if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { if len(missingFlags) == 1 { return nil, fmt.Errorf("Required flag %q not set\nRun '%s --help' for usage information", missingFlags[0].Names()[0], cmd.FullName()) - } else { names := []string{} for _, flag := range missingFlags { diff --git a/pkg/cmd/folder.go b/pkg/cmd/folder.go index f0c10a3..94b7bc9 100644 --- a/pkg/cmd/folder.go +++ b/pkg/cmd/folder.go @@ -20,8 +20,9 @@ var foldersList = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "connection-id", - Required: true, + Name: "connection-id", + Required: true, + PathParam: "connection_id", }, &requestflag.Flag[*string]{ Name: "parent-id", @@ -39,12 +40,14 @@ var foldersDeletePolicy = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "connection-id", - Required: true, + Name: "connection-id", + Required: true, + PathParam: "connection_id", }, &requestflag.Flag[string]{ - Name: "policy-id", - Required: true, + Name: "policy-id", + Required: true, + PathParam: "policy_id", }, }, Action: handleFoldersDeletePolicy, @@ -57,8 +60,9 @@ var foldersListPolicies = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "connection-id", - Required: true, + Name: "connection-id", + Required: true, + PathParam: "connection_id", }, }, Action: handleFoldersListPolicies, @@ -71,8 +75,9 @@ var foldersSetPolicies = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "connection-id", - Required: true, + Name: "connection-id", + Required: true, + PathParam: "connection_id", }, &requestflag.Flag[string]{ Name: "provider-folder-id", @@ -117,8 +122,6 @@ func handleFoldersList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.FolderListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -130,6 +133,8 @@ func handleFoldersList(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.FolderListParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Folders.List( @@ -166,10 +171,6 @@ func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.FolderDeletePolicyParams{ - ConnectionID: cmd.Value("connection-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -181,6 +182,10 @@ func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.FolderDeletePolicyParams{ + ConnectionID: cmd.Value("connection-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Folders.DeletePolicy( @@ -259,8 +264,6 @@ func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.FolderSetPoliciesParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -272,6 +275,8 @@ func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.FolderSetPoliciesParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Folders.SetPolicies( diff --git a/pkg/cmd/integration.go b/pkg/cmd/integration.go index a151256..83b6cf9 100644 --- a/pkg/cmd/integration.go +++ b/pkg/cmd/integration.go @@ -29,8 +29,9 @@ var integrationsConnect = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "integration-id", - Required: true, + Name: "integration-id", + Required: true, + PathParam: "integration_id", }, &requestflag.Flag[*string]{ Name: "redirect-url", @@ -91,8 +92,6 @@ func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.IntegrationConnectParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -104,6 +103,8 @@ func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.IntegrationConnectParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Integrations.Connect( diff --git a/pkg/cmd/integrationslack.go b/pkg/cmd/integrationslack.go index c6ed28e..af125ef 100644 --- a/pkg/cmd/integrationslack.go +++ b/pkg/cmd/integrationslack.go @@ -61,8 +61,6 @@ func handleIntegrationsSlackList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.IntegrationSlackListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -74,6 +72,8 @@ func handleIntegrationsSlackList(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.IntegrationSlackListParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Integrations.Slack.List(ctx, params, options...) diff --git a/pkg/cmd/integrationwebcrawler.go b/pkg/cmd/integrationwebcrawler.go index 1e59c9e..a91fb36 100644 --- a/pkg/cmd/integrationwebcrawler.go +++ b/pkg/cmd/integrationwebcrawler.go @@ -50,8 +50,6 @@ func handleIntegrationsWebCrawlerIndex(ctx context.Context, cmd *cli.Command) er return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.IntegrationWebCrawlerIndexParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -63,6 +61,8 @@ func handleIntegrationsWebCrawlerIndex(ctx context.Context, cmd *cli.Command) er return err } + params := hyperspell.IntegrationWebCrawlerIndexParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Integrations.WebCrawler.Index(ctx, params, options...) diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 6485098..21b09fd 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -20,13 +20,15 @@ var memoriesUpdate = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, - Required: true, + Name: "source", + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Required: true, + PathParam: "source", }, &requestflag.Flag[string]{ - Name: "resource-id", - Required: true, + Name: "resource-id", + Required: true, + PathParam: "resource_id", }, &requestflag.Flag[any]{ Name: "collection", @@ -112,13 +114,15 @@ var memoriesDelete = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, - Required: true, + Name: "source", + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Required: true, + PathParam: "source", }, &requestflag.Flag[string]{ - Name: "resource-id", - Required: true, + Name: "resource-id", + Required: true, + PathParam: "resource_id", }, }, Action: handleMemoriesDelete, @@ -221,13 +225,15 @@ var memoriesGet = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, - Required: true, + Name: "source", + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Required: true, + PathParam: "source", }, &requestflag.Flag[string]{ - Name: "resource-id", - Required: true, + Name: "resource-id", + Required: true, + PathParam: "resource_id", }, }, Action: handleMemoriesGet, @@ -413,10 +419,6 @@ func handleMemoriesUpdate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryUpdateParams{ - Source: hyperspell.MemoryUpdateParamsSource(cmd.Value("source").(string)), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -428,6 +430,10 @@ func handleMemoriesUpdate(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryUpdateParams{ + Source: hyperspell.MemoryUpdateParamsSource(cmd.Value("source").(string)), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.Update( @@ -461,8 +467,6 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -474,6 +478,8 @@ func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryListParams{} + format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") @@ -519,10 +525,6 @@ func handleMemoriesDelete(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryDeleteParams{ - Source: hyperspell.MemoryDeleteParamsSource(cmd.Value("source").(string)), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -534,6 +536,10 @@ func handleMemoriesDelete(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryDeleteParams{ + Source: hyperspell.MemoryDeleteParamsSource(cmd.Value("source").(string)), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.Delete( @@ -567,8 +573,6 @@ func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryAddParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -580,6 +584,8 @@ func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryAddParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.Add(ctx, params, options...) @@ -608,8 +614,6 @@ func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryAddBulkParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -621,6 +625,8 @@ func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryAddBulkParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.AddBulk(ctx, params, options...) @@ -652,10 +658,6 @@ func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryGetParams{ - Source: hyperspell.MemoryGetParamsSource(cmd.Value("source").(string)), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -667,6 +669,10 @@ func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryGetParams{ + Source: hyperspell.MemoryGetParamsSource(cmd.Value("source").(string)), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.Get( @@ -700,8 +706,6 @@ func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemorySearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -713,6 +717,8 @@ func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemorySearchParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.Search(ctx, params, options...) @@ -780,8 +786,6 @@ func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.MemoryUploadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -793,6 +797,8 @@ func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.MemoryUploadParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Memories.Upload(ctx, params, options...) diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index 1d988a5..0f6f29c 100644 --- a/pkg/cmd/session.go +++ b/pkg/cmd/session.go @@ -69,8 +69,6 @@ func handleSessionsAdd(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.SessionAddParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -82,6 +80,8 @@ func handleSessionsAdd(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.SessionAddParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Sessions.Add(ctx, params, options...) diff --git a/pkg/cmd/vault.go b/pkg/cmd/vault.go index 9a6b07b..efe3daa 100644 --- a/pkg/cmd/vault.go +++ b/pkg/cmd/vault.go @@ -45,8 +45,6 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hyperspell.VaultListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -58,6 +56,8 @@ func handleVaultsList(ctx context.Context, cmd *cli.Command) error { return err } + params := hyperspell.VaultListParams{} + format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") From 95294cea29feb91f7e088afb8ad615d8578f9728 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 00:31:00 +0000 Subject: [PATCH 48/51] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9e30a0e..1c75d4f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-e124cba28023639d4e72808379668f874ed53285fdd75ff2a7f580c12e3815f0.yml -openapi_spec_hash: 0d1d6e45ba54d24c8262744d34192950 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-baf348c0f71316403deeb2d500f83b41b2a0affc38b130e0b3646f67a1166b7b.yml +openapi_spec_hash: 459a33fc87569764c4d041e37435b77a config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From c472cd12bbad33f248d04ce4d1857a643e910fdc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 05:00:23 +0000 Subject: [PATCH 49/51] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 1c75d4f..82f42ad 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-baf348c0f71316403deeb2d500f83b41b2a0affc38b130e0b3646f67a1166b7b.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-65aaec91ce1035c25007430cbfd84b22a5f270da6488fcc3592fc86e320bf17a.yml openapi_spec_hash: 459a33fc87569764c4d041e37435b77a config_hash: 597eba5e5eaec83a5f0db3d946af8db5 From cde57e6cc0907e80bc2fa80d05b6701c3b777863 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 19:31:22 +0000 Subject: [PATCH 50/51] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/memory.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index 82f42ad..4e70c53 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-65aaec91ce1035c25007430cbfd84b22a5f270da6488fcc3592fc86e320bf17a.yml -openapi_spec_hash: 459a33fc87569764c4d041e37435b77a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell/hyperspell-3ea7819d73f46347d7870c6238e12458921045b6386d6091bffe67906d7a017f.yml +openapi_spec_hash: 004b15cbe7b318ef25b91f6d45b4cba3 config_hash: 597eba5e5eaec83a5f0db3d946af8db5 diff --git a/pkg/cmd/memory.go b/pkg/cmd/memory.go index 21b09fd..c3e0750 100644 --- a/pkg/cmd/memory.go +++ b/pkg/cmd/memory.go @@ -21,7 +21,7 @@ var memoriesUpdate = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions", "granola", "fathom", "linear".`, Required: true, PathParam: "source", }, @@ -115,7 +115,7 @@ var memoriesDelete = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions", "granola", "fathom", "linear".`, Required: true, PathParam: "source", }, @@ -226,7 +226,7 @@ var memoriesGet = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "source", - Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions".`, + Usage: `Allowed values: "reddit", "notion", "slack", "google_calendar", "google_mail", "box", "dropbox", "github", "google_drive", "vault", "web_crawler", "trace", "microsoft_teams", "gmail_actions", "granola", "fathom", "linear".`, Required: true, PathParam: "source", }, From 9eee0f231332b1a42600cffedac64f9dd118a391 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 19:32:02 +0000 Subject: [PATCH 51/51] release: 0.2.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++ pkg/cmd/version.go | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3d2ac0b..10f3091 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0" + ".": "0.2.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d65d2c..92574aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## 0.2.0 (2026-05-05) + +Full Changelog: [v0.1.0...v0.2.0](https://github.com/hyperspell/hyperspell-cli/compare/v0.1.0...v0.2.0) + +### Features + +* allow `-` as value representing stdin to binary-only file parameters in CLIs ([40329ac](https://github.com/hyperspell/hyperspell-cli/commit/40329ac0504bec769cdcbd0eda4821b15cf164d1)) +* **api:** api update ([cde57e6](https://github.com/hyperspell/hyperspell-cli/commit/cde57e6cc0907e80bc2fa80d05b6701c3b777863)) +* **api:** api update ([15efdbc](https://github.com/hyperspell/hyperspell-cli/commit/15efdbcd4ea5962464fe5eedc212d0fd601ceb1f)) +* **api:** api update ([6ea47ba](https://github.com/hyperspell/hyperspell-cli/commit/6ea47ba9a0248351256a8ddd0bde7b5575a53eb3)) +* **api:** api update ([05fc238](https://github.com/hyperspell/hyperspell-cli/commit/05fc238a5087e6cd3024fd92eccb7fa23cbb8440)) +* **api:** api update ([2f69100](https://github.com/hyperspell/hyperspell-cli/commit/2f691007ba68d3b18b6a643db158ed16b4848871)) +* **api:** api update ([07389a5](https://github.com/hyperspell/hyperspell-cli/commit/07389a55c46d676894d6392d7c6ed4cf33ac4147)) +* better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` ([665e431](https://github.com/hyperspell/hyperspell-cli/commit/665e431d3c39a2ba149168250d080bdf8b7546b5)) +* **cli:** add `--raw-output`/`-r` option to print raw (non-JSON) strings ([4222549](https://github.com/hyperspell/hyperspell-cli/commit/422254937af57c228a18d67191aa8966cceebb45)) +* **cli:** alias parameters in data with `x-stainless-cli-data-alias` ([fc1ebc7](https://github.com/hyperspell/hyperspell-cli/commit/fc1ebc74cf40a58ddb979128852ed202354922cc)) +* **cli:** send filename and content type when reading input from files ([4b76c79](https://github.com/hyperspell/hyperspell-cli/commit/4b76c79a99e8bb0f8a3ef112ccaf3092decb69a3)) +* support passing path and query params over stdin ([14f99ef](https://github.com/hyperspell/hyperspell-cli/commit/14f99ef63dff6a55aca07c84419c42ce25d99142)) + + +### Bug Fixes + +* **cli:** correctly load zsh autocompletion ([c419250](https://github.com/hyperspell/hyperspell-cli/commit/c419250da2b4345806d79c322c0384742e8b234a)) +* **cli:** fix incompatible Go types for flag generated as array of maps ([a95a486](https://github.com/hyperspell/hyperspell-cli/commit/a95a486ccc528af84af0a8aa0500680982ee6033)) +* fall back to main branch if linking fails in CI ([28b2498](https://github.com/hyperspell/hyperspell-cli/commit/28b24981adf8c8c39a68a44495912a66e63945ea)) +* fix for failing to drop invalid module replace in link script ([ad3cbf1](https://github.com/hyperspell/hyperspell-cli/commit/ad3cbf15ab7a70f3318ae32d95b7f6bebeaebceb)) +* fix quoting typo ([d4f3537](https://github.com/hyperspell/hyperspell-cli/commit/d4f35370e45ed2cc4deceeb6b8fae13f0a07fb63)) +* flags for nullable body scalar fields are strictly typed ([c983439](https://github.com/hyperspell/hyperspell-cli/commit/c9834390753ff2facace1035b6b692e73a73e81a)) + + +### Chores + +* add documentation for ./scripts/link ([2586047](https://github.com/hyperspell/hyperspell-cli/commit/2586047b1af969fcd3b5c21e52c072a7b620118d)) +* **ci:** support manually triggering release workflow ([b5836aa](https://github.com/hyperspell/hyperspell-cli/commit/b5836aa567c2ba79cf6ad7a8706de0ef196ed317)) +* **cli:** additional test cases for `ShowJSONIterator` ([5784e71](https://github.com/hyperspell/hyperspell-cli/commit/5784e710f1021b0939a8985eedaa0c80b0b9df0b)) +* **cli:** fall back to JSON when using default "explore" with non-TTY ([1b5c7e9](https://github.com/hyperspell/hyperspell-cli/commit/1b5c7e9de0a6ceaf663c5a06365a0ce1ec11cca4)) +* **cli:** let `--format raw` be used in conjunction with `--transform` ([d9c0c77](https://github.com/hyperspell/hyperspell-cli/commit/d9c0c77f238264b97af2c5fa8aacbd71de7f6f4b)) +* **cli:** switch long lists of positional args over to param structs ([4a9e250](https://github.com/hyperspell/hyperspell-cli/commit/4a9e2508c2605e814c40980baf343dbda59f28b8)) +* **cli:** use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals ([9e0c109](https://github.com/hyperspell/hyperspell-cli/commit/9e0c10938023ffbd46ca50c61baf2c3353df23d5)) +* **internal:** more robust bootstrap script ([014bf27](https://github.com/hyperspell/hyperspell-cli/commit/014bf271f51526dab8ae459bd514bab798c8062d)) +* mark all CLI-related tests in Go with `t.Parallel()` ([5fc2402](https://github.com/hyperspell/hyperspell-cli/commit/5fc240280f247a2a3817b37e359c7c674048a448)) +* modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary ([b7fd553](https://github.com/hyperspell/hyperspell-cli/commit/b7fd5533571992857a741f834af4d272b8ad2b6d)) +* switch some CLI Go tests from `os.Chdir` to `t.Chdir` ([2f49e45](https://github.com/hyperspell/hyperspell-cli/commit/2f49e45e69e821c97605e3929f4d6864f2b306e7)) +* **tests:** bump steady to v0.22.1 ([522092e](https://github.com/hyperspell/hyperspell-cli/commit/522092e2767802d6e258353c4bb374f16f2751d5)) + + +### Documentation + +* update examples ([62e382b](https://github.com/hyperspell/hyperspell-cli/commit/62e382b449383807ab1568d97c5f461fed2c702c)) + ## 0.1.0 (2026-04-02) Full Changelog: [v0.0.1...v0.1.0](https://github.com/hyperspell/hyperspell-cli/compare/v0.0.1...v0.1.0) diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 9bb8168..10d2893 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0" // x-release-please-version +const Version = "0.2.0" // x-release-please-version