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 diff --git a/cli/api.go b/cli/api.go index 22cd324..ace6bd3 100644 --- a/cli/api.go +++ b/cli/api.go @@ -103,8 +103,16 @@ 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)) - 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 { @@ -112,8 +120,29 @@ 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) + } +} + +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 @@ -121,6 +150,9 @@ func cacheAPI(name string, api *API) { // 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 @@ -161,12 +193,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/cli/cli.go b/cli/cli.go index 0f4ec24..1ae5842 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -268,7 +268,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/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..c4e168f --- /dev/null +++ b/cli/response_transform_test.go @@ -0,0 +1,298 @@ +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 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) + + 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 5845e54..d4a5947 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() @@ -50,7 +52,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:]...) @@ -59,6 +61,7 @@ func main() { // Initialize restish. cli.Init("surf", version) cli.Defaults() + applySurfAPIBaseURLOverride() cli.AddLoader(openapi.New()) cli.AddOperationCommandHook(addHiddenCompatOperationFlags) @@ -72,7 +75,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) { @@ -115,6 +118,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() @@ -202,7 +215,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. @@ -252,8 +265,10 @@ func main() { } func addHiddenCompatRootFlags(root *cobra.Command) { - root.PersistentFlags().String("agent-view", "", "Compatibility no-op for legacy instant skill examples") + root.PersistentFlags().String("agent-view", "", "Output an agent-friendly response view") _ = root.PersistentFlags().MarkHidden("agent-view") + root.PersistentFlags().Bool("shape", false, "Output response shape summary as JSON") + _ = root.PersistentFlags().MarkHidden("shape") } func addHiddenCompatOperationFlags(cmd *cobra.Command) { @@ -307,11 +322,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 @@ -335,14 +347,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 } - // No non-flag args → show help, no injection. - return false + 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 + } +} + +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) { @@ -451,9 +570,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 f0d4019..83faced 100644 --- a/cmd/surf/main_test.go +++ b/cmd/surf/main_test.go @@ -55,6 +55,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"}, @@ -95,6 +110,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 91a01b0..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 @@ -208,7 +381,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 +392,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) } }) @@ -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)) }