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 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/.stats.yml b/.stats.yml index 7c3ad8f..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%2Fhyperspell-7d29d0843a52840291678a3c6d136f496ae1f956853abaa5003c1284ca2c94aa.yml -openapi_spec_hash: 010597ad0ec6376fbf2f01ec062787ad -config_hash: bd8505e17db740d82e578d0edaa9bfe0 +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/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/README.md b/README.md index 8c362a4..03e2679 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. @@ -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`. diff --git a/cmd/hyperspell/main.go b/cmd/hyperspell/main.go index 53d2187..6179110 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 @@ -36,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, "Error", json, format, 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/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/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 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.go b/internal/requestflag/innerflag.go index 102624f..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 @@ -22,14 +23,35 @@ 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 + + // 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. +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) { @@ -61,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 { @@ -121,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/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.go b/internal/requestflag/requestflag.go index bdef64f..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,13 +13,38 @@ 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.). +// +// 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 @@ -36,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, @@ -48,6 +75,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 @@ -63,8 +94,10 @@ type InRequest interface { GetQueryPath() string GetHeaderPath() string GetBodyPath() string + GetPathParam() string IsBodyRoot() bool IsFileInput() bool + GetDataAliases() []string } func (f Flag[T]) GetQueryPath() string { @@ -79,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 } @@ -87,6 +124,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 @@ -94,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{ @@ -277,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 @@ -332,6 +457,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 { @@ -387,6 +517,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 } @@ -410,7 +542,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 } @@ -420,12 +553,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 @@ -456,6 +604,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 @@ -492,6 +682,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`), @@ -585,6 +782,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)) @@ -696,6 +902,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]{} @@ -709,6 +924,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 9751904..779bd57 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" @@ -11,6 +12,8 @@ import ( ) func TestDateValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -56,6 +59,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 +75,8 @@ func TestDateValueParse(t *testing.T) { } func TestDateTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -119,6 +126,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 +145,8 @@ func TestDateTimeValueParse(t *testing.T) { } func TestTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -181,6 +192,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 +208,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 +300,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 +323,8 @@ func TestRequestParams(t *testing.T) { } func TestFlagSet(t *testing.T) { + t.Parallel() + strFlag := &Flag[string]{ Name: "string-flag", Default: "default-string", @@ -327,38 +348,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 +416,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 +437,8 @@ func TestFlagSet(t *testing.T) { } func TestParseTimeWithFormats(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -439,6 +478,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 +493,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 +523,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 +542,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 +556,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 +571,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 +608,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) @@ -562,7 +617,181 @@ 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() + tests := []struct { name string flag cli.DocGenerationFlag @@ -583,8 +812,416 @@ 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) }) } } + +// 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)) + }) +} + +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 02cc198..5fbec88 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" @@ -44,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", @@ -71,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", @@ -99,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, @@ -112,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...) @@ -121,8 +120,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "actions add-reaction", + Transform: transform, + }) } func handleActionsSendMessage(ctx context.Context, cmd *cli.Command) error { @@ -133,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, @@ -146,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...) @@ -155,6 +161,13 @@ 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(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 ffeb97c..73d7a94 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" @@ -43,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", @@ -86,8 +85,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "auth delete-user", + Transform: transform, + }) } func handleAuthMe(ctx context.Context, cmd *cli.Command) error { @@ -118,8 +124,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "auth me", + Transform: transform, + }) } func handleAuthUserToken(ctx context.Context, cmd *cli.Command) error { @@ -130,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, @@ -143,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...) @@ -152,6 +165,13 @@ 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(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 974c1e2..de36ff4 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", @@ -70,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 ef593fb..1ce9fe5 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)), @@ -187,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 { @@ -195,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 } @@ -302,21 +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) ([]byte, error) { - if format != "raw" && 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 } } - switch strings.ToLower(format) { + // 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 opts.RawOutput && res.Type == gjson.String { + return []byte(res.Str + "\n"), nil + } + switch strings.ToLower(opts.Format) { case "auto": - return formatJSON(expectedOutput, title, res, "json", "") + 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 @@ -324,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 { @@ -338,34 +358,67 @@ 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, ", ")) } } -// 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 != "" { - transformed := res.Get(transform) - if transformed.Exists() { - res = transformed - } +const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" + +// 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) + 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 + 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() - switch strings.ToLower(format) { + switch strings.ToLower(opts.Format) { case "auto": - return ShowJSON(out, title, res, "json", "") + autoOpts := opts + autoOpts.Format = "json" + return ShowJSON(res, autoOpts) case "explore": - return jsonview.ExploreJSON(title, res) + if !isTerminal(opts.Stdout) { + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) + } + jsonOpts := opts + 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(out, title, res, format, transform) + bytes, err := formatJSON(res, opts) if err != nil { return err } - _, err = out.Write(bytes) + _, err = opts.Stdout.Write(bytes) return err } } @@ -379,12 +432,18 @@ 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, title string, iter jsonview.Iterator[T], format string, transform string, itemsToDisplay int64) error { - if format == "explore" { - return jsonview.ExploreJSONStream(title, iter) +// 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() + + if opts.Format == "explore" { + if isTerminal(opts.Stdout) { + return jsonview.ExploreJSONStream(opts.Title, iter) + } + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) + } + opts.Format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -410,7 +469,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(stdout, title, obj, format, transform) + json, err := formatJSON(obj, opts) if err != nil { return err } @@ -427,7 +486,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } if !usePager { - _, err := stdout.Write(output) + _, err := opts.Stdout.Write(output) if err != nil { return err } @@ -435,13 +494,15 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat 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.Stdout = pager + for iter.Next() { if itemsToDisplay == 0 { break @@ -457,7 +518,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(obj, pagerOpts); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 0a46fd1..22b6019 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -10,6 +10,9 @@ 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) { @@ -32,7 +35,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,34 +46,24 @@ 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()) }) } 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 +89,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 +99,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{ @@ -125,3 +112,277 @@ 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") + }) +} + +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(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "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(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout}) + 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(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "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(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)) + }) + + t.Run("RawOutputString", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + 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)) + }) + + 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(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "count", RawOutput: 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(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "nested", RawOutput: true}) + require.NoError(t, err) + require.Equal(t, `{"a":1}`+"\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) + }) +} + +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(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) + 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(res, ShowJSONOpts{ + ExplicitFormat: true, + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) + 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(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) + 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 + 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(iter, itemsToDisplay, ShowJSONOpts{ + Format: format, + Stderr: io.Discard, + Stdout: w, + Title: "test", + Transform: transform, + }) + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} diff --git a/pkg/cmd/connection.go b/pkg/cmd/connection.go index da554ab..d65db94 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" @@ -30,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, @@ -66,8 +66,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "connections list", + Transform: transform, + }) } func handleConnectionsRevoke(ctx context.Context, cmd *cli.Command) error { @@ -101,6 +108,13 @@ 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(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 67dfcd8..669e11a 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" @@ -21,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, @@ -35,10 +35,11 @@ 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[any]{ + &requestflag.Flag[*string]{ Name: "comment", Usage: "Comment on the chunk", BodyPath: "comment", @@ -60,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", @@ -105,8 +107,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "evaluate get-query", + Transform: transform, + }) } func handleEvaluateScoreHighlight(ctx context.Context, cmd *cli.Command) error { @@ -120,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, @@ -133,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( @@ -147,8 +156,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "evaluate score-highlight", + Transform: transform, + }) } func handleEvaluateScoreQuery(ctx context.Context, cmd *cli.Command) error { @@ -162,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, @@ -175,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( @@ -189,6 +205,13 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "evaluate score-query", + Transform: transform, + }) } diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 2050e06..91eb14a 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,16 +38,59 @@ 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 ) -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 +98,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 +119,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 +134,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 +151,27 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, 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 { + return v, err + } + return reflect.ValueOf(string(content)), nil + } content, err := os.ReadFile(s) if err != nil { return v, err @@ -123,6 +189,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 +205,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,7 +265,15 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") } - file, err := os.Open(filename) + if isStdinPath(filename) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + + upload, err := openFileUpload(filename) if err != nil { if !expectsFile { // For strings that start with "@" and don't look like a filename, return the string @@ -183,7 +281,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, } return v, err } - return reflect.ValueOf(file), nil + return reflect.ValueOf(upload), nil } } return v, nil @@ -234,28 +332,66 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) - if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { + // 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 != ApplicationOctetStream && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { return nil, err } 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) } if bodyMap, ok := bodyData.(map[string]any); ok { - if flagMap, ok := requestContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) - requestContents.Body = bodyMap + applyDataAliases(cmd, 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 } } } @@ -263,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 { @@ -279,24 +414,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) @@ -398,6 +543,84 @@ 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 +// 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 diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index e5dad4b..00734ca 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -2,15 +2,18 @@ package cmd import ( "encoding/base64" + "io" "os" "path/filepath" + "strings" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsUTF8TextFile(t *testing.T) { + t.Parallel() + tests := []struct { content []byte expected bool @@ -27,11 +30,13 @@ 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)) } } func TestEmbedFiles(t *testing.T) { + t.Parallel() + // Create temporary directory for test files tmpDir := t.TempDir() @@ -216,19 +221,23 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { - got, err := embedFiles(tt.input, EmbedText) + t.Parallel() + + 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) } }) t.Run(tt.name+" io.Reader", func(t *testing.T) { - _, err := embedFiles(tt.input, EmbedIOReader) + t.Parallel() + + _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) } @@ -236,9 +245,148 @@ 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")} + + 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) { + t.Parallel() + + 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) { + t.Parallel() + + 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) { + t.Parallel() + + 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) { + t.Parallel() + + 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) { + t.Parallel() + + 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) { + t.Parallel() + + 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) + }) +} + +// 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() + path := filepath.Join(dir, filename) + err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err, "failed to write test file %s", path) } diff --git a/pkg/cmd/folder.go b/pkg/cmd/folder.go index 59ce939..94b7bc9 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" @@ -21,10 +20,11 @@ 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[any]{ + &requestflag.Flag[*string]{ Name: "parent-id", Usage: "Parent folder ID. Omit for root-level folders.", QueryPath: "parent_id", @@ -40,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, @@ -58,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, @@ -72,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", @@ -87,17 +91,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", @@ -118,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, @@ -131,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( @@ -145,8 +149,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "folders list", + Transform: transform, + }) } func handleFoldersDeletePolicy(ctx context.Context, cmd *cli.Command) error { @@ -160,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, @@ -175,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( @@ -189,8 +200,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "folders delete-policy", + Transform: transform, + }) } func handleFoldersListPolicies(ctx context.Context, cmd *cli.Command) error { @@ -224,8 +242,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "folders list-policies", + Transform: transform, + }) } func handleFoldersSetPolicies(ctx context.Context, cmd *cli.Command) error { @@ -239,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, @@ -252,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( @@ -266,6 +291,13 @@ 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(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 97f4aaf..83b6cf9 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" @@ -30,10 +29,11 @@ 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[any]{ + &requestflag.Flag[*string]{ Name: "redirect-url", QueryPath: "redirect_url", }, @@ -70,8 +70,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "integrations list", + Transform: transform, + }) } func handleIntegrationsConnect(ctx context.Context, cmd *cli.Command) error { @@ -85,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, @@ -98,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( @@ -112,6 +119,13 @@ 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(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 ba17c42..f33a051 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" @@ -51,6 +50,13 @@ 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(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 2ff618e..af125ef 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" @@ -26,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", @@ -62,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, @@ -75,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...) @@ -84,6 +83,13 @@ 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(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 72e4dcb..a91fb36 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" @@ -51,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, @@ -64,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...) @@ -73,6 +72,13 @@ 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(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 d59229d..c3e0750 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" @@ -21,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", "google_drive", "github", "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", "granola", "fathom", "linear".`, + 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", @@ -35,6 +36,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.", @@ -63,16 +70,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", @@ -82,12 +89,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", @@ -107,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", "google_drive", "github", "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", "granola", "fathom", "linear".`, + Required: true, + PathParam: "source", }, &requestflag.Flag[string]{ - Name: "resource-id", - Required: true, + Name: "resource-id", + Required: true, + PathParam: "resource_id", }, }, Action: handleMemoriesDelete, @@ -131,7 +140,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", @@ -141,7 +150,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", @@ -151,7 +160,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", @@ -182,7 +191,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", @@ -192,7 +201,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", @@ -202,7 +211,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", @@ -216,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", "google_drive", "github", "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", "granola", "fathom", "linear".`, + Required: true, + PathParam: "source", }, &requestflag.Flag[string]{ - Name: "resource-id", - Required: true, + Name: "resource-id", + Required: true, + PathParam: "resource_id", }, }, Action: handleMemoriesGet, @@ -293,7 +304,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", @@ -328,6 +339,11 @@ var memoriesSearch = requestflag.WithInnerFlags(cli.Command{ Usage: "Search options for Notion", InnerField: "notion", }, + &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", + }, &requestflag.InnerFlag[map[string]any]{ Name: "options.reddit", Usage: "Search options for Reddit", @@ -377,12 +393,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", @@ -403,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, @@ -418,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( @@ -432,8 +448,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories update", + Transform: transform, + }) } func handleMemoriesList(ctx context.Context, cmd *cli.Command) error { @@ -444,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, @@ -457,7 +478,10 @@ 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") if format == "raw" { var res []byte @@ -467,14 +491,26 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + 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, "memories list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories list", + Transform: transform, + }) } } @@ -489,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, @@ -504,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( @@ -518,8 +554,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories delete", + Transform: transform, + }) } func handleMemoriesAdd(ctx context.Context, cmd *cli.Command) error { @@ -530,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, @@ -543,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...) @@ -552,8 +595,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories add", + Transform: transform, + }) } func handleMemoriesAddBulk(ctx context.Context, cmd *cli.Command) error { @@ -564,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, @@ -577,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...) @@ -586,8 +636,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories add-bulk", + Transform: transform, + }) } func handleMemoriesGet(ctx context.Context, cmd *cli.Command) error { @@ -601,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, @@ -616,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( @@ -630,8 +687,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories get", + Transform: transform, + }) } func handleMemoriesSearch(ctx context.Context, cmd *cli.Command) error { @@ -642,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, @@ -655,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...) @@ -664,8 +728,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories search", + Transform: transform, + }) } func handleMemoriesStatus(ctx context.Context, cmd *cli.Command) error { @@ -696,8 +767,15 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories status", + Transform: transform, + }) } func handleMemoriesUpload(ctx context.Context, cmd *cli.Command) error { @@ -708,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, @@ -721,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...) @@ -730,6 +808,13 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "memories upload", + Transform: transform, + }) } diff --git a/pkg/cmd/memory_test.go b/pkg/cmd/memory_test.go index 7638074..3ccb5dc 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" + @@ -83,25 +85,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 +187,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}, 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", ) }) @@ -202,7 +206,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,26 +214,27 @@ 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}", "--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}", "--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 +244,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" + @@ -257,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" + @@ -279,7 +284,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", diff --git a/pkg/cmd/session.go b/pkg/cmd/session.go index bcb54af..0f6f29c 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" @@ -37,12 +36,12 @@ 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", }, - &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", @@ -52,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", @@ -70,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, @@ -83,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...) @@ -92,6 +91,13 @@ 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(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 107fe81..efe3daa 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" @@ -20,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", }, @@ -46,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, @@ -59,7 +56,10 @@ 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") if format == "raw" { var res []byte @@ -69,13 +69,25 @@ 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(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + 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, "vaults list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "vaults list", + Transform: transform, + }) } } 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 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 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 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