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
34 changes: 32 additions & 2 deletions cli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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))
Cache.WriteConfig()

Expand All @@ -116,11 +119,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
Expand Down Expand Up @@ -161,12 +188,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
132 changes: 121 additions & 11 deletions cmd/surf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -59,6 +61,7 @@ func main() {
// Initialize restish.
cli.Init("surf", version)
cli.Defaults()
applySurfAPIBaseURLOverride()
cli.AddLoader(openapi.New())
cli.AddOperationCommandHook(addHiddenCompatOperationFlags)

Expand Down Expand Up @@ -202,7 +205,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.
Expand Down Expand Up @@ -307,11 +310,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
Expand All @@ -335,14 +335,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) {
Expand Down Expand Up @@ -451,9 +558,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
},
Expand Down
19 changes: 19 additions & 0 deletions cmd/surf/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading