From 853514bd65884d71961b9c21b84eb8eeb1ac126a Mon Sep 17 00:00:00 2001 From: Wei Su Date: Tue, 21 Apr 2026 14:36:14 -0700 Subject: [PATCH 1/9] feat(cli): auto-sync API spec on cache miss (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a cached OpenAPI spec, every API command fails — either with "unknown command: polymarket-markets" (commands not registered) or "no cached API spec — run surf sync first" (list-operations). Agents hit this ~17 times in recent eval runs when they skipped the initial surf sync step the SKILL.md asks for. Do the sync ourselves on cache miss, deterministically: - Before registering API commands, detect cache-miss + API-style argv - Silently fetch the spec (one stderr line: "No cached API spec, syncing...") - Proceed with normal command registration and dispatch - On fetch failure, fall through — existing error paths still produce a specific message with context for the user's actual command Meta commands (auth, sync, version, install, help, etc.) skip the auto-sync path — they don't need the spec. list-operations DOES need it and triggers auto-sync. Co-authored-by: Claude Opus 4.7 (1M context) --- cmd/surf/main.go | 35 +++++++++++++++++++++++++++++++++++ cmd/surf/main_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/cmd/surf/main.go b/cmd/surf/main.go index 37b0506..b1f71dd 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -190,6 +190,20 @@ func main() { } } + // Lazy-sync: fresh install (or expired cache) + user is invoking an API + // command → fetch the spec once so commands register and the call can + // proceed, instead of failing with "unknown command" or "no cached API + // spec — run surf sync first". + if cli.LoadCachedAPI("surf") == nil && needsCachedAPI() { + fmt.Fprintln(os.Stderr, "No cached API spec, syncing...") + viper.Set("rsh-no-cache", true) + if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + fmt.Fprintf(os.Stderr, "Auto-sync failed: %v\n", err) + // Fall through — the downstream "no cached API spec" / + // "unknown command" path will produce a more specific error. + } + } + // Populate "Available API Commands" from cached API spec as full // operation commands (with flags, Long description, etc.). These are // used when --help/-h skips injection, and also for Root help display. @@ -230,6 +244,27 @@ func main() { os.Exit(exitCode) } +// needsCachedAPI reports whether the current argv invokes a command that +// requires the cached OpenAPI spec. Meta commands (auth, sync, help, version, +// install, completion, telemetry, feedback, catalog) work without it. +// list-operations DOES need it — it enumerates the spec — so it's not in +// the meta set and will trigger auto-sync on cache miss. +func needsCachedAPI() bool { + meta := map[string]bool{ + "auth": true, "sync": true, "catalog": true, + "help": true, "completion": true, "version": true, "install": true, + "telemetry": true, "feedback": true, + } + for _, arg := range os.Args[1:] { + if strings.HasPrefix(arg, "-") { + continue + } + return !meta[arg] + } + // No command given → will show root help, no sync needed. + return false +} + // shouldInjectAPIName returns true if os.Args represents an API operation // (not a local command like auth/help/completion). // When --help / -h is present, skip injection so Cobra routes to the diff --git a/cmd/surf/main_test.go b/cmd/surf/main_test.go index aaeadc5..8af2862 100644 --- a/cmd/surf/main_test.go +++ b/cmd/surf/main_test.go @@ -62,3 +62,36 @@ func TestShouldInjectAPIName(t *testing.T) { }) } } + +func TestNeedsCachedAPI(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {"no command", []string{"surf"}, false}, + {"auth is meta", []string{"surf", "auth"}, false}, + {"sync is meta", []string{"surf", "sync"}, false}, + {"version is meta", []string{"surf", "version"}, false}, + {"install is meta", []string{"surf", "install"}, false}, + {"help is meta", []string{"surf", "help"}, false}, + {"catalog is meta", []string{"surf", "catalog", "show", "ethereum_dex_trades"}, false}, + {"telemetry is meta", []string{"surf", "telemetry"}, false}, + {"feedback is meta", []string{"surf", "feedback", "test"}, false}, + {"API command needs cache", []string{"surf", "polymarket-markets"}, true}, + {"list-operations needs cache", []string{"surf", "list-operations"}, true}, + {"API command with --help needs cache", []string{"surf", "polymarket-markets", "--help"}, true}, + {"leading flags skipped when finding command", []string{"surf", "--debug", "polymarket-markets"}, true}, + {"leading flags skipped before meta command", []string{"surf", "--debug", "auth"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = tt.args + if got := needsCachedAPI(); got != tt.want { + t.Errorf("needsCachedAPI() with args=%v = %v, want %v", tt.args, got, tt.want) + } + }) + } +} From 7970afd304c71ad92224f1d340103618e5bc5a65 Mon Sep 17 00:00:00 2001 From: Wei Su Date: Tue, 21 Apr 2026 14:57:03 -0700 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20fix=20install=20path=20(~/.surf/bin?= =?UTF-8?q?=20=E2=86=92=20~/.local/bin)=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh targets ~/.local/bin (see installDir() in cmd/surf/install.go and install_test.go:11-19), not ~/.surf/bin. The old path was wrong in both the Install section (L13) and the Local development symlink example added in #14 (L83, L88) — following the latter silently shadowed nothing because ~/.surf/bin didn't exist. Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee21c47..35da584 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Every API endpoint is available as a CLI command, dynamically generated from the curl -fsSL https://downloads.asksurf.ai/cli/releases/install.sh | sh ``` -Installs to `~/.surf/bin`. No sudo required. +Installs to `~/.local/bin`. No sudo required. To install a specific version: @@ -80,12 +80,12 @@ Configuration is stored in `~/.surf/`. ### Local development -Build the binary and symlink it into `~/.surf/bin/surf` so the `surf` in your +Build the binary and symlink it into `~/.local/bin/surf` so the `surf` in your PATH resolves to your local build: ```sh go build -o bin/surf ./cmd/surf -ln -sf "$(pwd)/bin/surf" ~/.surf/bin/surf +ln -sf "$(pwd)/bin/surf" ~/.local/bin/surf surf version # confirm you're on the local build surf help From 2996cb3ad22e1632b16ee7d0f727a6f9753d5a05 Mon Sep 17 00:00:00 2001 From: Wei Su Date: Tue, 21 Apr 2026 16:55:59 -0700 Subject: [PATCH 3/9] feat(cli): render enum values as type placeholder in --help (#23) For string params with an OpenAPI enum, the --help column used to show the first back-quoted word from the description (e.g. `--metric tier` when the spec had `tier` in Markdown backticks), which agents read as "default = tier" and skipped the flag. Root cause: spec descriptions legitimately use Markdown backticks to emphasize enum values, but pflag's UnquoteUsage interprets the first back-quoted word as the flag's type placeholder. Two conventions collide on the same character. This change: - Strips backticks from descriptions in openapi.go so pflag can't hoist an enum value into the placeholder column. - Adds Param.Enum and a custom pflag Value whose Type() returns "{a|b|c}" so the placeholder column shows the full choice list. - Falls back to plain "string" when the joined placeholder would exceed 40 chars and break column alignment. Before: --metric tier Ranking metric. Can be tier (...) or `portfolio_count` (...). (required) After: --metric {tier|portfolio_count} Ranking metric. Can be tier (...) or portfolio_count (...). (required) Other commands now show clearer enum columns, e.g. --interval {5m|1h|1d|7d} --order {asc|desc} --sort-by {market_cap|change_24h|volume_24h} Refs docs/issues.md #3. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/param.go | 43 ++++++++++++++++++++++++++++++++++--------- cli/param_test.go | 30 ++++++++++++++++++++++++++++++ openapi/openapi.go | 16 ++++++++++++++-- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/cli/param.go b/cli/param.go index a8d8746..68b0a61 100644 --- a/cli/param.go +++ b/cli/param.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "reflect" + "strings" "github.com/iancoleman/strcase" "github.com/spf13/pflag" @@ -26,17 +27,31 @@ func typeConvert(from, to any) any { // Param represents an API operation input parameter. type Param struct { - Type string `json:"type" yaml:"type"` - Name string `json:"name" yaml:"name"` - DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Style Style `json:"style,omitempty" yaml:"style,omitempty"` - Explode bool `json:"explode,omitempty" yaml:"explide,omitempty"` - Required bool `json:"required,omitempty" yaml:"required,omitempty"` - Default any `json:"default,omitempty" yaml:"default,omitempty"` - Example any `json:"example,omitempty" yaml:"example,omitempty"` + Type string `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` + DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Style Style `json:"style,omitempty" yaml:"style,omitempty"` + Explode bool `json:"explode,omitempty" yaml:"explide,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Default any `json:"default,omitempty" yaml:"default,omitempty"` + Example any `json:"example,omitempty" yaml:"example,omitempty"` + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` } +// enumStringValue is a pflag.Value whose Type() reports a custom placeholder +// like "{a|b|c}". pflag's UnquoteUsage falls back to Value.Type() when the +// usage string has no back-quoted word, so this lets us render enum choices +// directly in the flag's type column. +type enumStringValue struct { + val *string + typeStr string +} + +func (e *enumStringValue) String() string { return *e.val } +func (e *enumStringValue) Set(s string) error { *e.val = s; return nil } +func (e *enumStringValue) Type() string { return e.typeStr } + // Parse the parameter from a string input (e.g. command line argument) func (p Param) Parse(value string) (any, error) { // TODO: parse based on the type, used mostly for path parameter parsing @@ -138,6 +153,16 @@ func (p Param) AddFlag(flags *pflag.FlagSet) any { if def == nil { def = "" } + if len(p.Enum) > 0 { + placeholder := "{" + strings.Join(p.Enum, "|") + "}" + // Skip the custom placeholder if it would be too wide and break + // alignment in --help output. pflag falls back to "string". + if len(placeholder) <= 40 { + v := def.(string) + flags.Var(&enumStringValue{val: &v, typeStr: placeholder}, name, desc) + return &v + } + } return flags.String(name, def.(string), desc) case "array[boolean]": if def == nil { diff --git a/cli/param_test.go b/cli/param_test.go index 9e79f52..837948b 100644 --- a/cli/param_test.go +++ b/cli/param_test.go @@ -68,6 +68,36 @@ func TestParamFlag(t *testing.T) { } } +func TestParamFlagEnumPlaceholder(t *testing.T) { + tests := []struct { + name string + enum []string + wantType string + }{ + {"no enum", nil, "string"}, + {"two values", []string{"tier", "portfolio_count"}, "{tier|portfolio_count}"}, + {"three values", []string{"asc", "desc", "none"}, "{asc|desc|none}"}, + {"too wide falls back to string", []string{ + "one_really_long_value", "another_really_long_value", + }, "string"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := Param{ + Name: "test", + Type: "string", + Enum: tt.enum, + } + flags := pflag.NewFlagSet("", pflag.PanicOnError) + p.AddFlag(flags) + + flag := flags.Lookup("test") + assert.NotNil(t, flag) + assert.Equal(t, tt.wantType, flag.Value.Type()) + }) + } +} + func TestParamFlagRequiredSuffix(t *testing.T) { tests := []struct { name string diff --git a/openapi/openapi.go b/openapi/openapi.go index ee0b8cf..91bb46b 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -339,10 +339,21 @@ func openapiOperation(cmd *cobra.Command, method string, uriTemplate *url.URL, p displayName := getExtOr(p.Extensions, ExtName, "") description := getExtOr(p.Extensions, ExtDescription, p.Description) + // Strip Markdown backticks so pflag's UnquoteUsage doesn't pull the + // first back-quoted word out of the description as the flag's type + // placeholder (e.g. --metric `tier` → "--metric tier" column). + description = strings.ReplaceAll(description, "`", "") - // Append enum values and min/max constraints to the flag - // description so they appear in --help output. + var enums []string if schema != nil { + if len(schema.Enum) > 0 { + enums = make([]string, 0, len(schema.Enum)) + for _, e := range schema.Enum { + enums = append(enums, e.Value) + } + } + // Append enum values and min/max constraints to the flag + // description so they appear in --help output. description = appendSchemaHints(description, schema) } @@ -355,6 +366,7 @@ func openapiOperation(cmd *cobra.Command, method string, uriTemplate *url.URL, p Required: p.Required != nil && *p.Required, Default: def, Example: example, + Enum: enums, } if p.Explode != nil { From 2b2b1158f84015891cbb424f097e4170ae619578 Mon Sep 17 00:00:00 2001 From: Wei Su Date: Mon, 27 Apr 2026 18:55:34 -0700 Subject: [PATCH 4/9] fix(cli): replace broken root help example (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `surf market-futures --symbol BTC` no longer works — `market-futures` became a ranking-style snapshot endpoint that explicitly does not accept `--symbol`. Swap to `surf market-price --symbol BTC`, which is the canonical symbol-keyed time-series query and returns real data. Co-authored-by: Claude Opus 4.7 (1M context) --- cmd/surf/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/surf/main.go b/cmd/surf/main.go index b1f71dd..d7afdc9 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -50,7 +50,7 @@ func main() { // Inject "surf" as the API name into os.Args so restish's Run() loads // the API config. Skip injection for commands that don't need API loading. - // surf market-futures --symbol BTC → [surf, surf, market-futures, --symbol, BTC] + // surf market-price --symbol BTC → [surf, surf, market-price, --symbol, BTC] // surf auth → [surf, auth] (no injection) if shouldInjectAPIName() { os.Args = append([]string{os.Args[0], "surf"}, os.Args[1:]...) @@ -71,7 +71,7 @@ func main() { cli.Root.SuggestionsMinimumDistance = 2 cli.Root.Short = "Surf data platform CLI" cli.Root.Long = "Query the Surf data platform — crypto market data, on-chain analytics, and more." - cli.Root.Example = " surf market-futures --symbol BTC\n surf search-project --q bitcoin" + cli.Root.Example = " surf market-price --symbol BTC\n surf search-project --q bitcoin" // Override restish's default root behavior (acts as GET handler with MinimumNArgs(1)). cli.Root.Args = nil cli.Root.Run = func(cmd *cobra.Command, args []string) { From 7df041dab51973d63de9a4f551cd10cd09223936 Mon Sep 17 00:00:00 2001 From: Hughie Date: Fri, 8 May 2026 01:28:36 -0700 Subject: [PATCH 5/9] docs(cli): point --help footer to agents.asksurf.ai instead of docs.asksurf.ai (#25) The Surf docs are served from agents.asksurf.ai (Next.js, surf-landing repo); docs.asksurf.ai is a stale Mintlify mirror that is no longer the canonical source. Update the root --help footer URL and the test assertion that pins it. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/cli.go | 2 +- cmd/surf/usage_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index 880116b..5b2b4d6 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -267,7 +267,7 @@ func Init(name string, version string) { Root.SetHelpTemplate(`{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces | highlight}} {{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}{{if not .HasParent}} -Documentation: https://docs.asksurf.ai/llms.txt +Documentation: https://agents.asksurf.ai/llms.txt Report issues: https://github.com/asksurf-ai/surf-cli/issues {{end}}`) diff --git a/cmd/surf/usage_test.go b/cmd/surf/usage_test.go index 91a01b0..7ecd9fa 100644 --- a/cmd/surf/usage_test.go +++ b/cmd/surf/usage_test.go @@ -208,7 +208,7 @@ func TestHelpFooterDocLink(t *testing.T) { t.Run("root help has footer", func(t *testing.T) { out, _ := exec.Command(bin, "--help").CombinedOutput() s := string(out) - if !strings.Contains(s, "https://docs.asksurf.ai/llms.txt") { + if !strings.Contains(s, "https://agents.asksurf.ai/llms.txt") { t.Errorf("root --help missing docs link:\n%s", s) } if !strings.Contains(s, "https://github.com/asksurf-ai/surf-cli/issues") { @@ -219,7 +219,7 @@ func TestHelpFooterDocLink(t *testing.T) { t.Run("subcommand help has no footer", func(t *testing.T) { out, _ := exec.Command(bin, "market-price", "--help").CombinedOutput() s := string(out) - if strings.Contains(s, "https://docs.asksurf.ai/llms.txt") { + if strings.Contains(s, "https://agents.asksurf.ai/llms.txt") { t.Errorf("market-price --help should NOT carry root footer:\n%s", s) } }) From d298f7c1385c5bda8436241c9b6e8d600a184861 Mon Sep 17 00:00:00 2001 From: HappySean Date: Wed, 10 Jun 2026 15:13:13 +0800 Subject: [PATCH 6/9] fix(cli): surface API cache write failures instead of silently re-syncing (#28) Cache.WriteConfig() errors were swallowed, so a failed expiry write made every subsequent invocation treat the spec cache as missing and re-sync ('No cached API spec, syncing...' on each call). Log the failure reason to stderr, and include the file path in the cbor cache write error. Co-authored-by: Claude Fable 5 --- cli/api.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/api.go b/cli/api.go index 5cc48e7..6b03f4b 100644 --- a/cli/api.go +++ b/cli/api.go @@ -93,7 +93,12 @@ func cacheAPI(name string, api *API) { } Cache.Set(name+".expires", time.Now().Add(24*time.Hour)) - Cache.WriteConfig() + if err := Cache.WriteConfig(); err != nil { + // Without the persisted expiry, LoadCachedAPI treats the cache as + // missing and every subsequent invocation re-syncs the spec. + // Surface the reason on stderr instead of silently degrading. + LogError("Could not persist API cache expiry (next runs will re-sync): %s", err) + } b, err := cbor.Marshal(api) if err != nil { @@ -101,7 +106,7 @@ func cacheAPI(name string, api *API) { } filename := filepath.Join(getCacheDir(), name+".cbor") if err := os.WriteFile(filename, b, 0o600); err != nil { - LogError("Could not write API cache %s", err) + LogError("Could not write API cache %s (next runs will re-sync): %s", filename, err) } } From 77c69e61e93fef97e5118990661ab5eb12a1d83f Mon Sep 17 00:00:00 2001 From: HappySean Date: Fri, 12 Jun 2026 17:01:11 +0800 Subject: [PATCH 7/9] feat(cli): add agent response views (#29) --- cli/request.go | 6 +- cli/request_test.go | 59 +++++ cli/response_transform.go | 441 +++++++++++++++++++++++++++++++++ cli/response_transform_test.go | 276 +++++++++++++++++++++ cmd/surf/main.go | 18 ++ 5 files changed, 799 insertions(+), 1 deletion(-) create mode 100644 cli/response_transform.go create mode 100644 cli/response_transform_test.go diff --git a/cli/request.go b/cli/request.go index 024d874..2ffe3ee 100644 --- a/cli/request.go +++ b/cli/request.go @@ -275,7 +275,6 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e return resp, nil } - // isRetryable returns true if a request should be retried. func isRetryable(code int) bool { if code == /* 408 */ http.StatusRequestTimeout || @@ -581,6 +580,11 @@ func MakeRequestAndFormat(req *http.Request) { } panic(err) } + transformed, err := transformResponseForCommand(currentCommand, parsed.Body) + if err != nil { + panic(err) + } + parsed.Body = transformed if err := Formatter.Format(parsed); err != nil { if e, ok := err.(shorthand.Error); ok { diff --git a/cli/request_test.go b/cli/request_test.go index a75e4d3..74cd476 100644 --- a/cli/request_test.go +++ b/cli/request_test.go @@ -9,9 +9,19 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gopkg.in/h2non/gock.v1" ) +type captureFormatter struct { + resp Response +} + +func (f *captureFormatter) Format(resp Response) error { + f.resp = resp + return nil +} + func TestFixAddress(t *testing.T) { reset(false) assert.Equal(t, "https://example.com", fixAddress("example.com")) @@ -61,6 +71,55 @@ func TestRequestPagination(t *testing.T) { assert.Equal(t, []any{1.0, 2.0, 3.0, 4.0, 5.0, 6.0}, resp.Body) } +func TestMakeRequestAndFormatTransformsOnchainTxResponse(t *testing.T) { + defer gock.Off() + + reset(false) + + oldFormatter := Formatter + oldCommand := currentCommand + defer func() { + Formatter = oldFormatter + currentCommand = oldCommand + }() + + capture := &captureFormatter{} + Formatter = capture + currentCommand = "onchain-tx" + viper.Set("rsh-agent-view", "") + viper.Set("rsh-shape", false) + + gock.New("http://example.com"). + Get("/tx"). + Reply(http.StatusOK). + JSON(map[string]any{ + "data": []any{ + map[string]any{ + "hash": "0xabc", + "blockNumber": "0x157f411", + "value": "0xde0b6b3a7640000", + }, + }, + }) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/tx", nil) + MakeRequestAndFormat(req) + + require.True(t, gock.IsDone()) + body, ok := capture.resp.Body.(map[string]any) + require.True(t, ok) + data, ok := body["data"].([]any) + require.True(t, ok) + require.Len(t, data, 1) + tx, ok := data[0].(map[string]any) + require.True(t, ok) + + assert.Equal(t, "0x157f411", tx["blockNumber"]) + assert.Equal(t, "22541329", tx["blockNumberDecimal"]) + assert.Equal(t, "1000000000000000000", tx["valueDecimal"]) + assert.Equal(t, "1", tx["valueNativeDecimal"]) +} + func TestGetStatus(t *testing.T) { defer gock.Off() diff --git a/cli/response_transform.go b/cli/response_transform.go new file mode 100644 index 0000000..c134bfc --- /dev/null +++ b/cli/response_transform.go @@ -0,0 +1,441 @@ +package cli + +import ( + "fmt" + "math/big" + "sort" + "strings" + + "github.com/spf13/viper" +) + +var onchainTxHexQuantityFields = []string{ + "blockNumber", + "chainId", + "gas", + "gasPrice", + "maxFeePerGas", + "maxPriorityFeePerGas", + "nonce", + "transactionIndex", + "type", + "value", + "v", + "yParity", +} + +type agentViewFunc func(any) any + +var agentViewRegistry = map[string]map[string]agentViewFunc{ + "market-tge": { + "summary": marketTGESummaryView, + }, + "project-detail": { + "contracts": projectContractsView, + }, + "search-web": { + "results": searchWebResultsView, + }, +} + +func transformResponseForCommand(command string, body any) (any, error) { + if viper.GetBool("rsh-shape") { + return responseShapeForCommand(command, body), nil + } + + view := strings.TrimSpace(viper.GetString("rsh-agent-view")) + if view != "" { + views, ok := agentViewRegistry[command] + if !ok { + return nil, fmt.Errorf("unsupported --agent-view %q for command %q; supported views: %s", view, command, supportedAgentViews(command)) + } + transform, ok := views[view] + if !ok { + return nil, fmt.Errorf("unsupported --agent-view %q for command %q; supported views: %s", view, command, supportedAgentViews(command)) + } + if isErrorEnvelope(body) { + return body, nil + } + return transform(body), nil + } + + if command != "onchain-tx" { + return body, nil + } + addOnchainTxDecimalFields(body) + return body, nil +} + +func addOnchainTxDecimalFields(body any) { + switch v := body.(type) { + case map[string]any: + if data, ok := v["data"]; ok { + addOnchainTxDecimalFields(data) + return + } + addOnchainTxDecimalFieldsToRow(v) + case []any: + for _, item := range v { + addOnchainTxDecimalFields(item) + } + } +} + +func addOnchainTxDecimalFieldsToRow(tx map[string]any) { + for _, field := range onchainTxHexQuantityFields { + raw, ok := tx[field].(string) + if !ok { + continue + } + decimal, ok := hexQuantityToDecimalString(raw) + if !ok { + continue + } + + decimalField := field + "Decimal" + if _, exists := tx[decimalField]; !exists { + tx[decimalField] = decimal + } + + if field == "value" { + if _, exists := tx["valueNativeDecimal"]; !exists { + tx["valueNativeDecimal"] = weiDecimalToNativeDecimal(decimal) + } + } + } +} + +func hexQuantityToDecimalString(raw string) (string, bool) { + s := strings.TrimSpace(raw) + if len(s) <= 2 || !strings.HasPrefix(strings.ToLower(s), "0x") { + return "", false + } + + n, ok := new(big.Int).SetString(s[2:], 16) + if !ok { + return "", false + } + return n.String(), true +} + +func weiDecimalToNativeDecimal(decimalWei string) string { + wei, ok := new(big.Int).SetString(decimalWei, 10) + if !ok { + return decimalWei + } + + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + whole := new(big.Int) + frac := new(big.Int) + whole.QuoRem(wei, scale, frac) + if frac.Sign() == 0 { + return whole.String() + } + + fracText := frac.String() + if len(fracText) < 18 { + fracText = strings.Repeat("0", 18-len(fracText)) + fracText + } + fracText = strings.TrimRight(fracText, "0") + return whole.String() + "." + fracText +} + +func searchWebResultsView(body any) any { + results := firstListAtPaths(body, + []string{"data"}, + []string{"data", "results"}, + []string{"data", "items"}, + []string{"results"}, + []string{"items"}, + ) + if results == nil { + return []any{} + } + + out := make([]any, 0, len(results)) + for _, item := range results { + m, ok := item.(map[string]any) + if !ok { + continue + } + out = append(out, map[string]any{ + "title": firstStringField(m, "title", "name"), + "description": firstStringField(m, "description", "snippet", "summary", "content"), + "url": firstStringField(m, "url", "link", "href"), + }) + } + return out +} + +func projectContractsView(body any) any { + root := valueAtPath(body, []string{"data", "contracts"}) + if root == nil { + root = valueAtPath(body, []string{"contracts"}) + } + if root == nil { + root = valueAtPath(body, []string{"data"}) + } + + rows := flattenContracts(root) + out := make([]any, 0, len(rows)) + for _, row := range rows { + out = append(out, compactContract(row)) + } + return out +} + +func marketTGESummaryView(body any) any { + data := valueAtPath(body, []string{"data"}) + if data == nil { + data = body + } + + switch v := data.(type) { + case []any: + out := make([]any, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + out = append(out, compactTGESummary(m)) + } + } + return out + case map[string]any: + return compactTGESummary(v) + default: + return data + } +} + +func responseShapeForCommand(command string, body any) any { + data := valueAtPath(body, []string{"data"}) + return map[string]any{ + "command": command, + "body_type": valueKind(body), + "top_keys": objectKeys(body), + "data_type": valueKind(data), + "data_keys": objectKeys(data), + "sample": sampleValue(data), + "suggested_views": suggestedAgentViews(command), + } +} + +func supportedAgentViews(command string) string { + views := suggestedAgentViews(command) + if len(views) == 0 { + return "none" + } + return strings.Join(views, ", ") +} + +func suggestedAgentViews(command string) []string { + viewsByName, ok := agentViewRegistry[command] + if !ok { + return []string{} + } + + views := make([]string, 0, len(viewsByName)) + for name := range viewsByName { + views = append(views, name) + } + sort.Strings(views) + return views +} + +func firstListAtPaths(body any, paths ...[]string) []any { + for _, path := range paths { + if list, ok := valueAtPath(body, path).([]any); ok { + return list + } + } + if list, ok := body.([]any); ok { + return list + } + return nil +} + +func valueAtPath(value any, path []string) any { + current := value + for _, key := range path { + m, ok := current.(map[string]any) + if !ok { + return nil + } + current = m[key] + } + return current +} + +func firstStringField(m map[string]any, keys ...string) any { + for _, key := range keys { + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return nil +} + +func flattenContracts(value any) []map[string]any { + switch v := value.(type) { + case []any: + var rows []map[string]any + for _, item := range v { + rows = append(rows, flattenContracts(item)...) + } + return rows + case map[string]any: + if nested, ok := v["contracts"]; ok { + return flattenContracts(nested) + } + if nested, ok := v["items"]; ok { + return flattenContracts(nested) + } + if looksLikeContract(v) { + return []map[string]any{v} + } + + var rows []map[string]any + keys := objectKeys(v) + for _, key := range keys { + rows = append(rows, flattenContracts(v[key])...) + } + return rows + default: + return nil + } +} + +func looksLikeContract(m map[string]any) bool { + return firstStringField(m, "address", "contract_address", "contractAddress", "ca", "contract") != nil +} + +func compactContract(m map[string]any) map[string]any { + out := map[string]any{ + "chain": firstStringField(m, "chain", "network", "blockchain", "chain_name", "chainName"), + "address": firstStringField(m, "address", "contract_address", "contractAddress", "ca", "contract"), + "symbol": firstStringField(m, "symbol", "ticker"), + "name": firstStringField(m, "name", "token_name", "tokenName"), + } + if decimals, ok := m["decimals"]; ok { + out["decimals"] = decimals + } + return out +} + +func compactTGESummary(m map[string]any) map[string]any { + keys := []string{ + "project", "project_name", "name", "symbol", + "status", "tge_status", "stage", + "date", "tge_date", "launch_date", "listing_date", + "last_event_time", "next_event_time", "event_time", + "exchanges", "listing_exchanges", "listings", + "price", "price_usd", "token_price", + "amount", "raise_amount", "valuation", "fdv", + "public_sale", "unlock_percentage", "circulating_supply", "initial_circulating_supply", + } + + out := make(map[string]any) + for _, key := range keys { + if value, ok := m[key]; ok { + out[key] = value + } + } + if len(out) > 0 { + return out + } + + for _, key := range objectKeys(m) { + if nested, ok := m[key].(map[string]any); ok { + for nestedKey, nestedValue := range compactTGESummary(nested) { + out[nestedKey] = nestedValue + } + } + } + if len(out) > 0 { + return out + } + return sampleObject(m) +} + +func isErrorEnvelope(value any) bool { + m, ok := value.(map[string]any) + if !ok { + return false + } + if _, ok := m["error"]; ok { + return true + } + if _, ok := m["code"]; ok && m["data"] == nil { + return true + } + return false +} + +func valueKind(value any) string { + switch value.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case bool: + return "boolean" + case float64, float32, int, int64, int32, uint, uint64, uint32: + return "number" + default: + return fmt.Sprintf("%T", value) + } +} + +func objectKeys(value any) []string { + m, ok := value.(map[string]any) + if !ok { + return []string{} + } + + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sampleValue(value any) any { + switch v := value.(type) { + case []any: + limit := len(v) + if limit > 2 { + limit = 2 + } + sample := make([]any, 0, limit) + for i := 0; i < limit; i++ { + sample = append(sample, sampleValue(v[i])) + } + return sample + case map[string]any: + return sampleObject(v) + case string: + runes := []rune(v) + if len(runes) > 180 { + return string(runes[:180]) + "..." + } + return v + default: + return v + } +} + +func sampleObject(m map[string]any) map[string]any { + out := make(map[string]any) + keys := objectKeys(m) + if len(keys) > 8 { + keys = keys[:8] + } + for _, key := range keys { + out[key] = sampleValue(m[key]) + } + return out +} diff --git a/cli/response_transform_test.go b/cli/response_transform_test.go new file mode 100644 index 0000000..099627e --- /dev/null +++ b/cli/response_transform_test.go @@ -0,0 +1,276 @@ +package cli + +import ( + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransformResponseForCommandAddsOnchainTxDecimalFields(t *testing.T) { + reset(false) + + body := map[string]any{ + "$schema": "https://api.asksurf.ai/schemas/TransactionResponse.json", + "data": []any{ + map[string]any{ + "hash": "0xabc", + "blockNumber": "0x157f411", + "gas": "0x5208", + "gasPrice": "0x3b9aca00", + "value": "0xde0b6b3a7640000", + }, + }, + } + + transformed, err := transformResponseForCommand("onchain-tx", body) + require.NoError(t, err) + got := transformed.(map[string]any) + tx := got["data"].([]any)[0].(map[string]any) + + assert.Equal(t, "0x157f411", tx["blockNumber"]) + assert.Equal(t, "22541329", tx["blockNumberDecimal"]) + assert.Equal(t, "21000", tx["gasDecimal"]) + assert.Equal(t, "1000000000", tx["gasPriceDecimal"]) + assert.Equal(t, "1000000000000000000", tx["valueDecimal"]) + assert.Equal(t, "1", tx["valueNativeDecimal"]) +} + +func TestTransformResponseForCommandHandlesFractionalNativeValue(t *testing.T) { + reset(false) + + body := map[string]any{ + "data": []any{ + map[string]any{ + "value": "0x2386f26fc10000", // 0.01 native units at 18 decimals. + }, + }, + } + + transformed, err := transformResponseForCommand("onchain-tx", body) + require.NoError(t, err) + got := transformed.(map[string]any) + tx := got["data"].([]any)[0].(map[string]any) + + assert.Equal(t, "10000000000000000", tx["valueDecimal"]) + assert.Equal(t, "0.01", tx["valueNativeDecimal"]) +} + +func TestTransformResponseForCommandDoesNotOverrideExistingFields(t *testing.T) { + reset(false) + + body := map[string]any{ + "data": []any{ + map[string]any{ + "blockNumber": "0x10", + "blockNumberDecimal": "existing", + "value": "0x0", + "valueNativeDecimal": "existing-native", + }, + }, + } + + transformed, err := transformResponseForCommand("onchain-tx", body) + require.NoError(t, err) + got := transformed.(map[string]any) + tx := got["data"].([]any)[0].(map[string]any) + + assert.Equal(t, "existing", tx["blockNumberDecimal"]) + assert.Equal(t, "0", tx["valueDecimal"]) + assert.Equal(t, "existing-native", tx["valueNativeDecimal"]) +} + +func TestTransformResponseForCommandIgnoresOtherCommands(t *testing.T) { + reset(false) + + body := map[string]any{ + "data": []any{ + map[string]any{ + "blockNumber": "0x10", + }, + }, + } + + transformed, err := transformResponseForCommand("market-price", body) + require.NoError(t, err) + got := transformed.(map[string]any) + tx := got["data"].([]any)[0].(map[string]any) + + assert.NotContains(t, tx, "blockNumberDecimal") +} + +func TestTransformResponseForCommandSearchWebAgentView(t *testing.T) { + reset(false) + viper.Set("rsh-agent-view", "results") + + body := map[string]any{ + "data": []any{ + map[string]any{ + "title": "Aave governance update", + "description": "Aave published an update.", + "url": "https://example.com/aave", + "content": "large markdown that should not survive", + }, + map[string]any{ + "name": "Fallback title", + "snippet": "Fallback snippet", + "link": "https://example.com/fallback", + }, + }, + } + + transformed, err := transformResponseForCommand("search-web", body) + require.NoError(t, err) + + assert.Equal(t, []any{ + map[string]any{ + "title": "Aave governance update", + "description": "Aave published an update.", + "url": "https://example.com/aave", + }, + map[string]any{ + "title": "Fallback title", + "description": "Fallback snippet", + "url": "https://example.com/fallback", + }, + }, transformed) +} + +func TestTransformResponseForCommandSearchWebAgentViewHandlesLegacyResultsShape(t *testing.T) { + reset(false) + viper.Set("rsh-agent-view", "results") + + body := map[string]any{ + "data": map[string]any{ + "results": []any{ + map[string]any{ + "title": "Legacy result", + "description": "Legacy shape", + "url": "https://example.com/legacy", + }, + }, + }, + } + + transformed, err := transformResponseForCommand("search-web", body) + require.NoError(t, err) + + assert.Equal(t, []any{ + map[string]any{ + "title": "Legacy result", + "description": "Legacy shape", + "url": "https://example.com/legacy", + }, + }, transformed) +} + +func TestTransformResponseForCommandProjectContractsAgentView(t *testing.T) { + reset(false) + viper.Set("rsh-agent-view", "contracts") + + body := map[string]any{ + "data": map[string]any{ + "contracts": map[string]any{ + "contracts": []any{ + map[string]any{ + "chain": "ethereum", + "contract_address": "0xabc", + "symbol": "ABC", + "name": "Example Token", + "decimals": float64(18), + "verbose": "dropped", + }, + }, + }, + }, + } + + transformed, err := transformResponseForCommand("project-detail", body) + require.NoError(t, err) + + assert.Equal(t, []any{ + map[string]any{ + "chain": "ethereum", + "address": "0xabc", + "symbol": "ABC", + "name": "Example Token", + "decimals": float64(18), + }, + }, transformed) +} + +func TestTransformResponseForCommandMarketTGESummaryAgentView(t *testing.T) { + reset(false) + viper.Set("rsh-agent-view", "summary") + + body := map[string]any{ + "data": map[string]any{ + "project_name": "Nubit", + "tge_status": "pre", + "last_event_time": "2026-06-01T00:00:00Z", + "listing_exchanges": []any{"Binance"}, + "large_raw_blob": "dropped", + "nested_unused_data": map[string]any{"foo": "bar"}, + }, + } + + transformed, err := transformResponseForCommand("market-tge", body) + require.NoError(t, err) + + assert.Equal(t, map[string]any{ + "project_name": "Nubit", + "tge_status": "pre", + "last_event_time": "2026-06-01T00:00:00Z", + "listing_exchanges": []any{"Binance"}, + }, transformed) +} + +func TestTransformResponseForCommandShapeSummary(t *testing.T) { + reset(false) + viper.Set("rsh-shape", true) + + body := map[string]any{ + "data": map[string]any{ + "contracts": map[string]any{"contracts": []any{}}, + "name": "Bitcoin", + }, + "meta": map[string]any{"request_id": "abc"}, + } + + transformed, err := transformResponseForCommand("project-detail", body) + require.NoError(t, err) + got := transformed.(map[string]any) + + assert.Equal(t, "project-detail", got["command"]) + assert.Equal(t, "object", got["body_type"]) + assert.Equal(t, []string{"data", "meta"}, got["top_keys"]) + assert.Equal(t, "object", got["data_type"]) + assert.Equal(t, []string{"contracts", "name"}, got["data_keys"]) + assert.Equal(t, []string{"contracts"}, got["suggested_views"]) +} + +func TestTransformResponseForCommandUnsupportedAgentViewReturnsError(t *testing.T) { + reset(false) + viper.Set("rsh-agent-view", "results") + + _, err := transformResponseForCommand("market-price", map[string]any{"data": []any{}}) + + require.Error(t, err) + assert.Contains(t, err.Error(), `unsupported --agent-view "results" for command "market-price"`) +} + +func TestTransformResponseForCommandAgentViewPreservesErrorEnvelope(t *testing.T) { + reset(false) + viper.Set("rsh-agent-view", "results") + + body := map[string]any{ + "error": "rate limited", + "code": "rate_limit", + } + + transformed, err := transformResponseForCommand("search-web", body) + require.NoError(t, err) + + assert.Equal(t, body, transformed) +} diff --git a/cmd/surf/main.go b/cmd/surf/main.go index d7afdc9..efc3fbb 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -94,6 +94,14 @@ func main() { cli.Root.PersistentFlags().Bool("json", false, "Output result as JSON (alias for -o json)") cli.Root.PersistentFlags().Bool("debug", false, "Enable debug log output") cli.Root.PersistentFlags().Bool("quiet", false, "Suppress non-error diagnostic output") + cli.Root.PersistentFlags().String("agent-view", "", "Output an agent-friendly response view") + cli.Root.PersistentFlags().Bool("shape", false, "Output response shape summary as JSON") + if f := cli.Root.PersistentFlags().Lookup("agent-view"); f != nil { + f.Hidden = true + } + if f := cli.Root.PersistentFlags().Lookup("shape"); f != nil { + f.Hidden = true + } // Add -v as shorthand for --version. Cobra auto-registers --version // (from Root.Version) but without a short flag. @@ -113,6 +121,16 @@ func main() { if j, err := cmd.Flags().GetBool("json"); err == nil && j { viper.Set("rsh-output-format", "json") } + if view, err := cmd.Flags().GetString("agent-view"); err == nil && view != "" { + viper.Set("rsh-agent-view", view) + if viper.GetString("rsh-output-format") == "auto" { + viper.Set("rsh-output-format", "json") + } + } + if shape, err := cmd.Flags().GetBool("shape"); err == nil && shape { + viper.Set("rsh-shape", true) + viper.Set("rsh-output-format", "json") + } if d, err := cmd.Flags().GetBool("debug"); err == nil && d { viper.Set("rsh-verbose", true) cli.EnableVerbose() From f47d10b2ed225822412fedb3c28fdcd34c82dfaf Mon Sep 17 00:00:00 2001 From: Zhimao Liu Date: Fri, 12 Jun 2026 04:14:44 -0700 Subject: [PATCH 8/9] [FIX] sync surf API spec from configured base (#27) * [FIX] sync surf API spec from configured base * fix(tests): build surf binary with windows extension * fix(cli): keep surf cache tests hermetic --------- Co-authored-by: HappySean --- cli/api.go | 34 +++++- cli/apiconfig.go | 43 ++++++- cmd/surf/main.go | 132 ++++++++++++++++++-- cmd/surf/main_test.go | 19 +++ cmd/surf/network_error_test.go | 101 ++++++++++++---- cmd/surf/usage_test.go | 213 ++++++++++++++++++++++++++++++--- 6 files changed, 489 insertions(+), 53 deletions(-) diff --git a/cli/api.go b/cli/api.go index 6b03f4b..1b72a20 100644 --- a/cli/api.go +++ b/cli/api.go @@ -92,6 +92,9 @@ func cacheAPI(name string, api *API) { return } + if base := apiCacheBase(name); base != "" { + Cache.Set(name+".base", base) + } Cache.Set(name+".expires", time.Now().Add(24*time.Hour)) if err := Cache.WriteConfig(); err != nil { // Without the persisted expiry, LoadCachedAPI treats the cache as @@ -110,11 +113,35 @@ func cacheAPI(name string, api *API) { } } +func apiCacheBase(name string) string { + config := configs[name] + if config == nil { + return "" + } + base := config.Base + profile := viper.GetString("rsh-profile") + if profile != "" && profile != "default" && config.Profiles[profile] != nil && config.Profiles[profile].Base != "" { + base = config.Profiles[profile].Base + } + return strings.TrimRight(base, "/") +} + +func apiCacheMatchesBase(name string) bool { + expected := apiCacheBase(name) + if expected == "" { + return true + } + return Cache.GetString(name+".base") == expected +} + // LoadCachedAPI loads an API from the local cache without making network // requests. Returns nil if no valid cache exists or is expired. // Unlike Load, this skips the version check since it is used only to // populate command names and descriptions for help output. func LoadCachedAPI(name string) *API { + if !apiCacheMatchesBase(name) { + return nil + } expires := Cache.GetTime(name + ".expires") if expires.IsZero() || !time.Now().Before(expires) { return nil @@ -155,12 +182,15 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { // See if there is a cache we can quickly load. expires := Cache.GetTime(name + ".expires") - if !viper.GetBool("rsh-no-cache") && !expires.IsZero() && expires.After(time.Now()) { + if !viper.GetBool("rsh-no-cache") && apiCacheMatchesBase(name) && !expires.IsZero() && expires.After(time.Now()) { var cached API filename := filepath.Join(getCacheDir(), name+".cbor") if data, err := os.ReadFile(filename); err == nil { if err := cbor.Unmarshal(data, &cached); err == nil { - if cached.RestishVersion == root.Version { + // API subcommands do not carry the root CLI version, but they still + // need to reuse the already-validated cache instead of refreshing the + // spec on every operation invocation. + if root.Version == "" || cached.RestishVersion == root.Version { setupRootFromAPI(root, &cached) return cached, nil } diff --git a/cli/apiconfig.go b/cli/apiconfig.go index 97d783e..0dbdd84 100644 --- a/cli/apiconfig.go +++ b/cli/apiconfig.go @@ -27,10 +27,10 @@ type APIAuth struct { // TLSConfig contains the TLS setup for the HTTP client type TLSConfig struct { - InsecureSkipVerify bool `json:"insecure,omitempty" yaml:"insecure,omitempty" mapstructure:"insecure"` - Cert string `json:"cert,omitempty" yaml:"cert,omitempty"` - Key string `json:"key,omitempty" yaml:"key,omitempty"` - CACert string `json:"ca_cert,omitempty" yaml:"ca_cert,omitempty" mapstructure:"ca_cert"` + InsecureSkipVerify bool `json:"insecure,omitempty" yaml:"insecure,omitempty" mapstructure:"insecure"` + Cert string `json:"cert,omitempty" yaml:"cert,omitempty"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + CACert string `json:"ca_cert,omitempty" yaml:"ca_cert,omitempty" mapstructure:"ca_cert"` } // APIProfile contains account-specific API information @@ -87,6 +87,41 @@ type apiConfigs map[string]*APIConfig var configs apiConfigs var apiCommand *cobra.Command +// OverrideAPIConfig updates an API config in memory. This is used for Surf's +// environment override before commands are registered from the OpenAPI cache. +func OverrideAPIConfig(name, base string, specFiles []string) { + config := configs[name] + if config == nil { + config = &APIConfig{name: name} + } + config.name = name + config.Base = base + config.SpecFiles = specFiles + if config.Profiles == nil { + config.Profiles = map[string]*APIProfile{} + } + if config.Profiles["default"] == nil { + config.Profiles["default"] = &APIProfile{} + } + configs[name] = config + + for _, cmd := range Root.Commands() { + if cmd.Use == name { + cmd.Short = base + break + } + } +} + +// APIBase returns the configured base URL for an API, if present. +func APIBase(name string) string { + config := configs[name] + if config == nil { + return "" + } + return config.Base +} + func initAPIConfig() { apis = viper.New() diff --git a/cmd/surf/main.go b/cmd/surf/main.go index efc3fbb..437311d 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -22,6 +22,8 @@ var embeddedAPIsJSON []byte var version = "dev" var configDir string +const defaultSurfGatewayBase = "https://api.asksurf.ai/gateway" + func main() { // Force config and cache to ~/.surf/ on all platforms. home, err := os.UserHomeDir() @@ -59,6 +61,7 @@ func main() { // Initialize restish. cli.Init("surf", version) cli.Defaults() + applySurfAPIBaseURLOverride() cli.AddLoader(openapi.New()) // Send Cobra diagnostics (deprecation warnings, usage errors) to stderr @@ -215,7 +218,7 @@ func main() { if cli.LoadCachedAPI("surf") == nil && needsCachedAPI() { fmt.Fprintln(os.Stderr, "No cached API spec, syncing...") viper.Set("rsh-no-cache", true) - if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + if _, err := cli.Load(currentSurfGatewayBase(), cli.Root); err != nil { fmt.Fprintf(os.Stderr, "Auto-sync failed: %v\n", err) // Fall through — the downstream "no cached API spec" / // "unknown command" path will produce a more specific error. @@ -273,11 +276,8 @@ func needsCachedAPI() bool { "help": true, "completion": true, "version": true, "install": true, "telemetry": true, "feedback": true, } - for _, arg := range os.Args[1:] { - if strings.HasPrefix(arg, "-") { - continue - } - return !meta[arg] + if cmd := firstCommandArg(os.Args); cmd != "" { + return !meta[cmd] } // No command given → will show root help, no sync needed. return false @@ -300,14 +300,121 @@ func shouldInjectAPIName() bool { return false } } - for _, arg := range os.Args[1:] { + if cmd := firstCommandArg(os.Args); cmd != "" { + return !local[cmd] + } + // No non-flag args → show help, no injection. + return false +} + +func firstCommandArg(argv []string) string { + for i := 1; i < len(argv); i++ { + arg := argv[i] + if arg == "--" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + if strings.HasPrefix(arg, "--") { + if flagConsumesValue(arg) && !strings.Contains(arg, "=") { + i++ + } + continue + } if strings.HasPrefix(arg, "-") { + if shortFlagConsumesValue(arg) && len(arg) == 2 { + i++ + } continue } - return !local[arg] + return arg + } + return "" +} + +func flagConsumesValue(arg string) bool { + name := strings.TrimPrefix(arg, "--") + if idx := strings.Index(name, "="); idx >= 0 { + name = name[:idx] + } + switch name { + case "surf-api-base-url", "rsh-output-format", "rsh-filter", "rsh-header", + "rsh-query", "rsh-profile", "rsh-client-cert", "rsh-client-key", + "rsh-ca-cert", "rsh-retry", "rsh-timeout", "category": + return true + default: + return false } - // No non-flag args → show help, no injection. - return false +} + +func shortFlagConsumesValue(arg string) bool { + name := strings.TrimPrefix(arg, "-") + if idx := strings.Index(name, "="); idx >= 0 { + name = name[:idx] + } + switch name { + case "s", "o", "f", "H", "q", "p", "t", "c": + return true + default: + return false + } +} + +func explicitSurfAPIBaseURLOverride(args []string) string { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case strings.HasPrefix(arg, "--surf-api-base-url="): + return strings.TrimPrefix(arg, "--surf-api-base-url=") + case arg == "--surf-api-base-url" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(arg, "-s="): + return strings.TrimPrefix(arg, "-s=") + case arg == "-s" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(arg, "-s") && len(arg) > 2: + return strings.TrimPrefix(arg, "-s") + } + } + return os.Getenv("SURF_API_BASE_URL") +} + +func normalizeSurfGatewayBase(raw string) string { + base := strings.TrimRight(strings.TrimSpace(raw), "/") + if strings.HasSuffix(base, "/v1") { + base = strings.TrimSuffix(base, "/v1") + } + return base +} + +func surfSpecFile(base string) string { + return strings.TrimRight(base, "/") + "/openapi.json" +} + +func applySurfAPIBaseURLOverride() { + raw := explicitSurfAPIBaseURLOverride(os.Args[1:]) + if raw == "" { + return + } + base := normalizeSurfGatewayBase(raw) + if base == "" { + return + } + viper.Set("surf-api-base-url", raw) + cli.OverrideAPIConfig("surf", base, []string{surfSpecFile(base)}) +} + +func currentSurfGatewayBase() string { + if raw := explicitSurfAPIBaseURLOverride(os.Args[1:]); raw != "" { + if base := normalizeSurfGatewayBase(raw); base != "" { + return base + } + } + if base := cli.APIBase("surf"); base != "" { + return strings.TrimRight(base, "/") + } + return defaultSurfGatewayBase } func removeCommands(root *cobra.Command, names ...string) { @@ -416,9 +523,12 @@ func newSyncCmd() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { viper.Set("rsh-no-cache", true) - if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + if _, err := cli.Load(currentSurfGatewayBase(), cli.Root); err != nil { return fmt.Errorf("sync failed: %w", err) } + if cli.LoadCachedAPI("surf") == nil { + return fmt.Errorf("sync failed: cached API spec was not written") + } fmt.Fprintln(os.Stderr, "API spec synced.") return nil }, diff --git a/cmd/surf/main_test.go b/cmd/surf/main_test.go index 8af2862..bd31d4b 100644 --- a/cmd/surf/main_test.go +++ b/cmd/surf/main_test.go @@ -45,6 +45,21 @@ func TestShouldInjectAPIName(t *testing.T) { args: []string{"surf", "-o", "json", "wallet-labels-batch"}, want: true, }, + { + name: "leading surf API base URL before sync does not inject", + args: []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "sync"}, + want: false, + }, + { + name: "leading surf API base URL before operation still injects", + args: []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "wallet-labels-batch"}, + want: true, + }, + { + name: "leading surf API base URL shorthand before operation still injects", + args: []string{"surf", "-s", "https://api.stg.ask.surf/gateway/v1", "wallet-labels-batch"}, + want: true, + }, { name: "leading flags before operation with --help do NOT inject", args: []string{"surf", "-o", "json", "wallet-labels-batch", "--help"}, @@ -83,6 +98,10 @@ func TestNeedsCachedAPI(t *testing.T) { {"API command with --help needs cache", []string{"surf", "polymarket-markets", "--help"}, true}, {"leading flags skipped when finding command", []string{"surf", "--debug", "polymarket-markets"}, true}, {"leading flags skipped before meta command", []string{"surf", "--debug", "auth"}, false}, + {"leading surf API base URL skipped before sync", []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "sync"}, false}, + {"leading surf API base URL skipped before API command", []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "polymarket-markets"}, true}, + {"leading surf API base URL equals form skipped before list", []string{"surf", "--surf-api-base-url=https://api.stg.ask.surf/gateway/v1", "list-operations"}, true}, + {"leading surf API base URL shorthand skipped before sync", []string{"surf", "-s", "https://api.stg.ask.surf/gateway/v1", "sync"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/surf/network_error_test.go b/cmd/surf/network_error_test.go index b9da76e..7f6893e 100644 --- a/cmd/surf/network_error_test.go +++ b/cmd/surf/network_error_test.go @@ -2,9 +2,14 @@ package main import ( "encoding/json" + "fmt" + "net/http" + "os" "os/exec" + "path/filepath" "strings" "testing" + "time" ) // TestNetworkErrorEnvelope verifies that transport-layer failures @@ -14,7 +19,7 @@ import ( // See docs/CLI_DESIGN_PRINCIPLES.md §4.1 and §4.2.1, and // docs/CLI_V1_ALPHA_26_REVIEW.md items #3 and #4 for the rationale. func TestNetworkErrorEnvelope(t *testing.T) { - bin := buildSurfBin(t) + bin := buildBareSurfBin(t) type envelope struct { Error struct { @@ -24,18 +29,22 @@ func TestNetworkErrorEnvelope(t *testing.T) { } t.Run("connection refused", func(t *testing.T) { - cmd := exec.Command(bin, + home := t.TempDir() + srv, _ := newSpecAndOperationServerWithOpenAPIServer(t, "marketPrice", "http://127.0.0.1:1", nil) + defer srv.Close() + seedSurfSpecCache(t, bin, home, "http://127.0.0.1:1/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", - "--symbol", "BTC", - "--time-range", "1d", - "--surf-api-base-url", "http://127.0.0.1:1", + "--address", "BTC", + "--surf-api-base-url", "http://127.0.0.1:1/gateway/v1", "--rsh-retry", "0", ) // Capture stdout and stderr separately. stdout, stderrPipe := splitOutput(cmd) err := cmd.Run() - wantExitCode(t, err, 4) + wantExitCodeWithStderr(t, err, 4, stderrPipe.String()) // stdout must contain a JSON envelope with error.code = NETWORK_ERROR. var env envelope @@ -56,32 +65,44 @@ func TestNetworkErrorEnvelope(t *testing.T) { }) t.Run("dns failure", func(t *testing.T) { - cmd := exec.Command(bin, + home := t.TempDir() + srv, _ := newSpecAndOperationServerWithOpenAPIServer(t, "marketPrice", "http://nonexistent-host-for-test-12345.invalid", nil) + defer srv.Close() + seedSurfSpecCache(t, bin, home, "http://nonexistent-host-for-test-12345.invalid/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", - "--symbol", "BTC", - "--time-range", "1d", - "--surf-api-base-url", "http://nonexistent-host-for-test-12345.invalid", + "--address", "BTC", + "--surf-api-base-url", "http://nonexistent-host-for-test-12345.invalid/gateway/v1", "--rsh-retry", "0", ) - stdout, _ := splitOutput(cmd) + stdout, stderrPipe := splitOutput(cmd) err := cmd.Run() - wantExitCode(t, err, 4) + wantExitCodeWithStderr(t, err, 4, stderrPipe.String()) var env envelope if jerr := json.Unmarshal(stdout.Bytes(), &env); jerr != nil { t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", jerr, stdout.String()) } if env.Error.Code != "NETWORK_ERROR" { - t.Errorf("want error.code = NETWORK_ERROR, got %q", env.Error.Code) + t.Errorf("want error.code = NETWORK_ERROR, got %q (stdout: %s)", env.Error.Code, stdout.String()) } }) t.Run("local timeout", func(t *testing.T) { - cmd := exec.Command(bin, + home := t.TempDir() + srv, _ := newSpecAndOperationServerWithOpenAPIServer(t, "marketPrice", "", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + seedSurfSpecCache(t, bin, home, srv.URL+"/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", - "--symbol", "BTC", - "--time-range", "1d", + "--address", "BTC", + "--surf-api-base-url", srv.URL+"/gateway/v1", "--rsh-timeout", "1ms", ) stdout, _ := splitOutput(cmd) @@ -104,12 +125,47 @@ func TestNetworkErrorEnvelope(t *testing.T) { // Sanity check: unknown flag errors still exit 1 with stderr text // (they are CLI-layer errors, not transport errors). t.Run("unknown flag still exit 1", func(t *testing.T) { - cmd := exec.Command(bin, "market-price", "--nonexistent-flag") + home := t.TempDir() + srv, _ := newSpecServer(t, "marketPrice") + defer srv.Close() + seedSurfSpecCache(t, bin, home, srv.URL+"/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", "--nonexistent-flag") err := cmd.Run() wantExitCode(t, err, 1) }) } +func seedSurfSpecCache(t *testing.T, bin string, home string, base string, specFile string) { + t.Helper() + surfDir := filepath.Join(home, ".surf") + if err := os.MkdirAll(surfDir, 0o700); err != nil { + t.Fatal(err) + } + apisJSON := fmt.Sprintf(`{ + "$schema": "https://rest.sh/schemas/apis.json", + "surf": { + "base": %q, + "spec_files": [%q], + "profiles": {"default": {}} + } +}`, base, specFile) + if err := os.WriteFile(filepath.Join(surfDir, "apis.json"), []byte(apisJSON), 0o600); err != nil { + t.Fatal(err) + } + runSurf(t, bin, home, nil, "sync") + if _, err := os.Stat(filepath.Join(surfDir, "surf.cbor")); err != nil { + t.Fatalf("expected seeded surf.cbor cache: %v", err) + } + configBytes, err := os.ReadFile(filepath.Join(surfDir, "config.json")) + if err != nil { + t.Fatalf("expected seeded config.json: %v", err) + } + if !strings.Contains(string(configBytes), base) { + t.Fatalf("seeded cache base mismatch: want %q in config.json:\n%s", base, string(configBytes)) + } +} + // splitOutput wires the cmd's Stdout and Stderr to separate buffers. func splitOutput(cmd *exec.Cmd) (stdout, stderr *strBuf) { stdout = &strBuf{} @@ -123,12 +179,17 @@ func splitOutput(cmd *exec.Cmd) (stdout, stderr *strBuf) { type strBuf struct{ b []byte } func (s *strBuf) Write(p []byte) (int, error) { s.b = append(s.b, p...); return len(p), nil } -func (s *strBuf) Bytes() []byte { return s.b } -func (s *strBuf) String() string { return string(s.b) } +func (s *strBuf) Bytes() []byte { return s.b } +func (s *strBuf) String() string { return string(s.b) } // wantExitCode asserts that the error from cmd.Run() corresponds to the // expected exit code. nil err means exit 0. func wantExitCode(t *testing.T, err error, want int) { + t.Helper() + wantExitCodeWithStderr(t, err, want, "") +} + +func wantExitCodeWithStderr(t *testing.T, err error, want int, stderr string) { t.Helper() got := 0 if err != nil { @@ -139,6 +200,6 @@ func wantExitCode(t *testing.T, err error, want int) { } } if got != want { - t.Errorf("want exit code %d, got %d", want, got) + t.Errorf("want exit code %d, got %d\nstderr: %s", want, got, stderr) } } diff --git a/cmd/surf/usage_test.go b/cmd/surf/usage_test.go index 7ecd9fa..895c4a4 100644 --- a/cmd/surf/usage_test.go +++ b/cmd/surf/usage_test.go @@ -1,11 +1,14 @@ package main import ( + "fmt" "net/http" "net/http/httptest" "os" "os/exec" + "path/filepath" "regexp" + "runtime" "strings" "testing" ) @@ -15,18 +18,188 @@ import ( // commands are registered from the cache. func buildSurfBin(t *testing.T) string { t.Helper() - bin := t.TempDir() + "/surf" + bin := buildBareSurfBin(t) + + home, _ := os.UserHomeDir() + if _, err := os.Stat(home + "/.surf/surf.cbor"); os.IsNotExist(err) { + t.Skip("no cached API spec — run `surf sync` first") + } + return bin +} + +func buildBareSurfBin(t *testing.T) string { + t.Helper() + binName := "surf" + if runtime.GOOS == "windows" { + binName += ".exe" + } + bin := filepath.Join(t.TempDir(), binName) build := exec.Command("go", "build", "-o", bin, ".") build.Dir = "." if out, err := build.CombinedOutput(); err != nil { t.Fatalf("build failed: %v\n%s", err, out) } + return bin +} - home, _ := os.UserHomeDir() - if _, err := os.Stat(home + "/.surf/surf.cbor"); os.IsNotExist(err) { - t.Skip("no cached API spec — run `surf sync` first") +func testOpenAPISpec(operationID, summary string) string { + return testOpenAPISpecWithServer(operationID, summary, "") +} + +func testOpenAPISpecWithServer(operationID, summary string, serverURL string) string { + serverBlock := "" + if serverURL != "" { + serverBlock = fmt.Sprintf(`, + "servers": [{"url": %q}]`, serverURL) + } + return fmt.Sprintf(`{ + "openapi": "3.0.0", + "info": {"title": "Surf Test API", "version": "1.0.0"}%s, + "paths": { + "/gateway/v1/%s": { + "get": { + "operationId": "%s", + "summary": "%s", + "tags": ["test"], + "parameters": [ + {"name": "address", "in": "query", "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "ok"}} + } + } + } +}`, serverBlock, operationID, operationID, summary) +} + +func newSpecServer(t *testing.T, operationID string) (*httptest.Server, *[]string) { + t.Helper() + return newSpecAndOperationServer(t, operationID, nil) +} + +func newSpecAndOperationServer(t *testing.T, operationID string, operationHandler http.HandlerFunc) (*httptest.Server, *[]string) { + return newSpecAndOperationServerWithOpenAPIServer(t, operationID, "", operationHandler) +} + +func newSpecAndOperationServerWithOpenAPIServer(t *testing.T, operationID string, serverURL string, operationHandler http.HandlerFunc) (*httptest.Server, *[]string) { + t.Helper() + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.URL.Path == "/gateway/openapi.json" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testOpenAPISpecWithServer(operationID, operationID+" summary", serverURL)) + return + } + if operationHandler != nil { + operationHandler(w, r) + return + } + http.NotFound(w, r) + })) + return srv, &paths +} + +func surfCmd(t *testing.T, bin string, home string, env []string, args ...string) *exec.Cmd { + t.Helper() + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "SURF_API_BASE_URL=", + "SURF_TELEMETRY_DISABLED=1", + "HTTP_PROXY=", + "HTTPS_PROXY=", + "ALL_PROXY=", + "NO_PROXY=*", + "http_proxy=", + "https_proxy=", + "all_proxy=", + "no_proxy=*", + ) + cmd.Env = append(cmd.Env, env...) + return cmd +} + +func runSurf(t *testing.T, bin string, home string, env []string, args ...string) string { + t.Helper() + cmd := surfCmd(t, bin, home, env, args...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("surf %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func TestSyncUsesSurfAPIBaseURLEnv(t *testing.T) { + bin := buildBareSurfBin(t) + home := t.TempDir() + srv, paths := newSpecServer(t, "hyperliquidAccount") + defer srv.Close() + + env := []string{"SURF_API_BASE_URL=" + srv.URL + "/gateway/v1"} + out := runSurf(t, bin, home, env, "sync") + if !strings.Contains(out, "API spec synced.") { + t.Fatalf("sync output missing success message:\n%s", out) + } + if _, err := os.Stat(home + "/.surf/surf.cbor"); err != nil { + t.Fatalf("expected surf.cbor cache to be written: %v", err) + } + if got := strings.Join(*paths, ","); got != "/gateway/openapi.json" { + t.Fatalf("sync should fetch env-specific OpenAPI spec, got paths %q", got) + } + + out = runSurf(t, bin, home, env, "list-operations") + if !strings.Contains(out, "hyperliquid-account") { + t.Fatalf("list-operations missing env-specific operation:\n%s", out) + } +} + +func TestSyncUsesConfiguredSurfAPIBase(t *testing.T) { + bin := buildBareSurfBin(t) + home := t.TempDir() + surfDir := home + "/.surf" + if err := os.MkdirAll(surfDir, 0o700); err != nil { + t.Fatal(err) + } + srv, _ := newSpecServer(t, "configuredOnly") + defer srv.Close() + + apisJSON := fmt.Sprintf(`{ + "$schema": "https://rest.sh/schemas/apis.json", + "surf": { + "base": %q, + "spec_files": [%q], + "profiles": {"default": {}} + } +}`, srv.URL+"/gateway", srv.URL+"/gateway/openapi.json") + if err := os.WriteFile(surfDir+"/apis.json", []byte(apisJSON), 0o600); err != nil { + t.Fatal(err) + } + + runSurf(t, bin, home, nil, "sync") + out := runSurf(t, bin, home, nil, "list-operations") + if !strings.Contains(out, "configured-only") { + t.Fatalf("list-operations missing configured operation:\n%s", out) + } +} + +func TestCachedSpecIsScopedToSurfAPIBaseURL(t *testing.T) { + bin := buildBareSurfBin(t) + home := t.TempDir() + prodSrv, _ := newSpecServer(t, "prodOnly") + defer prodSrv.Close() + stgSrv, _ := newSpecServer(t, "stgOnly") + defer stgSrv.Close() + + runSurf(t, bin, home, []string{"SURF_API_BASE_URL=" + prodSrv.URL + "/gateway/v1"}, "sync") + + out := runSurf(t, bin, home, []string{"SURF_API_BASE_URL=" + stgSrv.URL + "/gateway/v1"}, "list-operations") + if !strings.Contains(out, "stg-only") { + t.Fatalf("expected cache miss and STG resync, got:\n%s", out) + } + if strings.Contains(out, "prod-only") { + t.Fatalf("reused cached operations from the previous base:\n%s", out) } - return bin } // TestNoDoubleSurfInUsage builds the surf binary and verifies that @@ -244,10 +417,15 @@ func TestVersionFlag(t *testing.T) { // TestDebugFlag verifies that --debug enables debug logging to stderr. func TestDebugFlag(t *testing.T) { - bin := buildSurfBin(t) + bin := buildBareSurfBin(t) + home := t.TempDir() + srv, _ := newSpecServer(t, "marketPrice") + defer srv.Close() - cmd := exec.Command(bin, "--debug", "market-price", "--symbol", "BTC", - "--surf-api-base-url", "http://127.0.0.1:1", "--rsh-retry", "0") + env := []string{"SURF_API_BASE_URL=" + srv.URL + "/gateway/v1"} + runSurf(t, bin, home, env, "sync") + + cmd := surfCmd(t, bin, home, env, "--debug", "market-price", "--address", "BTC", "--rsh-retry", "0") out, _ := cmd.CombinedOutput() if !strings.Contains(string(out), "DEBUG:") { t.Errorf("--debug should produce DEBUG: lines, got:\n%s", string(out)) @@ -256,10 +434,11 @@ func TestDebugFlag(t *testing.T) { // TestQuietFlag verifies that --quiet suppresses WARN/INFO but not errors. func TestQuietFlag(t *testing.T) { - bin := buildSurfBin(t) + bin := buildBareSurfBin(t) + home := t.TempDir() t.Run("errors still show", func(t *testing.T) { - cmd := exec.Command(bin, "--quiet", "market-price", "--bogus") + cmd := surfCmd(t, bin, home, nil, "--quiet", "auth", "--bogus") out, _ := cmd.CombinedOutput() if !strings.Contains(string(out), "unknown flag") { t.Errorf("--quiet should still show errors, got:\n%s", string(out)) @@ -267,24 +446,26 @@ func TestQuietFlag(t *testing.T) { }) // Spin up a server that always returns 429 to trigger retry WARN lines. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv, _ := newSpecAndOperationServer(t, "marketPrice", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) - })) + }) defer srv.Close() - args := []string{"market-price", "--symbol", "BTC", "--time-range", "1d", - "--surf-api-base-url", srv.URL, "--rsh-retry", "1"} + env := []string{"SURF_API_BASE_URL=" + srv.URL + "/gateway/v1"} + runSurf(t, bin, home, env, "sync") + + args := []string{"market-price", "--address", "BTC", "--rsh-retry", "1"} t.Run("without --quiet shows WARN", func(t *testing.T) { - out, _ := exec.Command(bin, args...).CombinedOutput() + out, _ := surfCmd(t, bin, home, env, args...).CombinedOutput() if !strings.Contains(string(out), "WARN:") { t.Errorf("expected WARN: line on 429 retry, got:\n%s", string(out)) } }) t.Run("--quiet suppresses WARN", func(t *testing.T) { - out, _ := exec.Command(bin, append([]string{"--quiet"}, args...)...).CombinedOutput() + out, _ := surfCmd(t, bin, home, env, append([]string{"--quiet"}, args...)...).CombinedOutput() if strings.Contains(string(out), "WARN:") { t.Errorf("--quiet should suppress WARN:, got:\n%s", string(out)) } From de1409eaa79edd3b21a5cca05e667fbbeb165931 Mon Sep 17 00:00:00 2001 From: HappySean Date: Mon, 15 Jun 2026 13:44:15 +0800 Subject: [PATCH 9/9] test onchain tx decimal fields (#30) --- cli/response_transform_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cli/response_transform_test.go b/cli/response_transform_test.go index 099627e..c4e168f 100644 --- a/cli/response_transform_test.go +++ b/cli/response_transform_test.go @@ -37,6 +37,28 @@ func TestTransformResponseForCommandAddsOnchainTxDecimalFields(t *testing.T) { assert.Equal(t, "1", tx["valueNativeDecimal"]) } +func TestTransformResponseForCommandAddsQ188BlockDecimal(t *testing.T) { + reset(false) + + body := map[string]any{ + "data": []any{ + map[string]any{ + "blockNumber": "0x1630d13", + "value": "0x0", + }, + }, + } + + transformed, err := transformResponseForCommand("onchain-tx", body) + require.NoError(t, err) + got := transformed.(map[string]any) + tx := got["data"].([]any)[0].(map[string]any) + + assert.Equal(t, "23268627", tx["blockNumberDecimal"]) + assert.Equal(t, "0", tx["valueDecimal"]) + assert.Equal(t, "0", tx["valueNativeDecimal"]) +} + func TestTransformResponseForCommandHandlesFractionalNativeValue(t *testing.T) { reset(false)