From 208420e3110535dfe11ebb6789ac427cf5b367eb Mon Sep 17 00:00:00 2001 From: HappySean Date: Fri, 12 Jun 2026 16:26:45 +0800 Subject: [PATCH] feat(cli): add agent response views --- 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()