Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions cli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,24 +103,56 @@ 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 {
LogError("Could not marshal API cache %s", err)
}
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
// 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
Expand Down Expand Up @@ -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
}
Expand Down
43 changes: 39 additions & 4 deletions cli/apiconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}`)

Expand Down
6 changes: 5 additions & 1 deletion cli/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions cli/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading