diff --git a/cmd/surf/instant_operations.go b/cmd/surf/instant_operations.go new file mode 100644 index 0000000..f31290a --- /dev/null +++ b/cmd/surf/instant_operations.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/asksurf-ai/surf-cli/cli" + "github.com/spf13/cobra" +) + +var instantOperationBlacklist = map[string]bool{ + "onchain-sql": true, + "onchain-structured-query": true, +} + +func newListInstantOperationsCmd() *cobra.Command { + var groupByTag bool + var detail bool + var category string + cmd := &cobra.Command{ + Use: "list-instant-operations", + Aliases: []string{"list-instant-operation"}, + Short: "List API operations available to instant mode", + Long: "Show OpenAPI operations available to instant mode after applying the instant blacklist.", + Args: cobra.NoArgs, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + api := cli.LoadCachedAPI("surf") + if api == nil { + return fmt.Errorf("no cached API spec — run `surf sync` first") + } + + ops := filterInstantOperations(api.Operations) + if groupByTag { + printInstantOperationsGrouped(ops, detail, category) + } else { + printInstantOperationsFlat(ops, detail, category) + } + return nil + }, + } + cmd.Flags().BoolVarP(&groupByTag, "group", "g", false, "Group operations by category") + cmd.Flags().BoolVarP(&detail, "detail", "d", false, "Show description, parameter/request schemas, and response schema for each operation") + cmd.Flags().StringVarP(&category, "category", "c", "", "Filter by category name (case-insensitive substring match)") + return cmd +} + +func filterInstantOperations(ops []cli.Operation) []cli.Operation { + filtered := make([]cli.Operation, 0, len(ops)) + for _, op := range ops { + if op.Hidden || op.Deprecated != "" || instantOperationBlacklist[op.Name] { + continue + } + filtered = append(filtered, op) + } + return filtered +} + +func printInstantOperationsFlat(ops []cli.Operation, detail bool, category string) { + ops = filterOps(ops, category) + for _, op := range ops { + params := formatInstantParams(op) + fmt.Fprintf(os.Stdout, " %-6s %-35s %s%s\n", op.Method, op.Name, op.Short, params) + if detail { + printInstantOperationDetail(op) + } + } +} + +func printInstantOperationsGrouped(ops []cli.Operation, detail bool, category string) { + ops = filterOps(ops, category) + groups := map[string][]cli.Operation{} + var order []string + for _, op := range ops { + g := op.Group + if g == "" { + g = "other" + } + if _, seen := groups[g]; !seen { + order = append(order, g) + } + groups[g] = append(groups[g], op) + } + + for i, g := range order { + if i > 0 { + fmt.Println() + } + fmt.Printf("%s:\n", g) + for _, op := range groups[g] { + params := formatInstantParams(op) + fmt.Fprintf(os.Stdout, " %-6s %-35s %s%s\n", op.Method, op.Name, op.Short, params) + if detail { + printInstantOperationDetail(op) + } + } + } +} + +func formatInstantParams(op cli.Operation) string { + var names []string + for _, p := range op.PathParams { + name := p.Name + if p.Required { + name += "*" + } + names = append(names, "<"+name+">") + } + for _, p := range op.QueryParams { + name := "--" + p.OptionName() + if p.Required { + name += "*" + } + names = append(names, name) + } + for _, p := range op.HeaderParams { + name := "--" + p.OptionName() + if p.Required { + name += "*" + } + names = append(names, name) + } + if len(names) == 0 { + return "" + } + return " (" + strings.Join(names, ", ") + ")" +} + +func printInstantOperationDetail(op cli.Operation) { + if desc := firstParagraph(op.Long); desc != "" { + fmt.Fprintf(os.Stdout, " %s\n", desc) + } + if details := instantDetailSections(op.Long); details != "" { + fmt.Fprintf(os.Stdout, " Details:\n%s\n", indentForListOperations(details, " ")) + } + fmt.Fprintln(os.Stdout) +} + +func instantDetailSections(long string) string { + lines := strings.Split(long, "\n") + var out []string + inSection := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if isInstantDetailHeading(trimmed) { + inSection = true + } else if inSection && strings.HasPrefix(trimmed, "## ") { + inSection = false + } + if inSection { + out = append(out, line) + } + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func isInstantDetailHeading(heading string) bool { + return strings.HasPrefix(heading, "## Argument Schema") || + strings.HasPrefix(heading, "## Option Schema") || + strings.HasPrefix(heading, "## Input Example") || + strings.HasPrefix(heading, "## Request Schema") || + strings.HasPrefix(heading, "## Response") || + strings.HasPrefix(heading, "## Responses") +} + +func indentForListOperations(s string, prefix string) string { + if s == "" { + return "" + } + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = prefix + line + } + } + return strings.Join(lines, "\n") +} diff --git a/cmd/surf/instant_operations_test.go b/cmd/surf/instant_operations_test.go new file mode 100644 index 0000000..a5a078e --- /dev/null +++ b/cmd/surf/instant_operations_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "strings" + "testing" + + "github.com/asksurf-ai/surf-cli/cli" +) + +func TestListInstantOperationsCommandIsHidden(t *testing.T) { + cmd := newListInstantOperationsCmd() + if !cmd.Hidden { + t.Fatal("list-instant-operations should be hidden from root help") + } + if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "list-instant-operation" { + t.Fatalf("unexpected aliases: %v", cmd.Aliases) + } +} + +func TestFilterInstantOperationsUsesBlacklist(t *testing.T) { + ops := []cli.Operation{ + {Name: "market-price"}, + {Name: "onchain-sql"}, + {Name: "onchain-structured-query"}, + {Name: "hidden-operation", Hidden: true}, + {Name: "deprecated-operation", Deprecated: "do not use"}, + } + + got := filterInstantOperations(ops) + if len(got) != 1 || got[0].Name != "market-price" { + t.Fatalf("unexpected instant operations: %#v", got) + } +} + +func TestFormatInstantParamsMarksRequiredInputs(t *testing.T) { + op := cli.Operation{ + PathParams: []*cli.Param{ + {Name: "condition_id", Required: true}, + }, + QueryParams: []*cli.Param{ + {Name: "symbol", Required: true}, + {Name: "time_range"}, + }, + HeaderParams: []*cli.Param{ + {Name: "x-api-key", Required: true}, + }, + } + + got := formatInstantParams(op) + for _, want := range []string{"", "--symbol*", "--time-range", "--x-api-key*"} { + if !strings.Contains(got, want) { + t.Fatalf("formatInstantParams missing %q in %q", want, got) + } + } +} + +func TestInstantDetailSectionsIncludeInputsAndResponses(t *testing.T) { + long := `Intro paragraph. + +## Option Schema: +` + "```schema" + ` +{ + --symbol: (string) +} +` + "```" + ` + +## Notes + +Do not include this section. + +## Response 200 (application/json) + +ok + +` + "```schema" + ` +{ + data*: [ + { + price*: (number) + } + ] +} +` + "```" + ` +` + + got := instantDetailSections(long) + for _, want := range []string{"## Option Schema", "--symbol:", "## Response 200 (application/json)", "price*:"} { + if !strings.Contains(got, want) { + t.Fatalf("instantDetailSections missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "Do not include this section") { + t.Fatalf("instantDetailSections should skip unrelated sections:\n%s", got) + } +} diff --git a/cmd/surf/main.go b/cmd/surf/main.go index b1f71dd..970ade0 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -224,6 +224,7 @@ func main() { cli.Root.AddCommand(newVersionCmd()) cli.Root.AddCommand(newInstallCmd()) cli.Root.AddCommand(newListOperationsCmd()) + cli.Root.AddCommand(newListInstantOperationsCmd()) cli.Root.AddCommand(newCatalogCmd()) cli.Root.AddCommand(newTelemetryCmd()) cli.Root.AddCommand(newFeedbackCmd()) @@ -247,8 +248,8 @@ func main() { // needsCachedAPI reports whether the current argv invokes a command that // requires the cached OpenAPI spec. Meta commands (auth, sync, help, version, // install, completion, telemetry, feedback, catalog) work without it. -// list-operations DOES need it — it enumerates the spec — so it's not in -// the meta set and will trigger auto-sync on cache miss. +// list-operations and list-instant-operations DO need it: they enumerate the +// spec, so they are not in the meta set and will trigger auto-sync on cache miss. func needsCachedAPI() bool { meta := map[string]bool{ "auth": true, "sync": true, "catalog": true, @@ -274,7 +275,8 @@ func shouldInjectAPIName() bool { local := map[string]bool{ "auth": true, "sync": true, "catalog": true, "help": true, "completion": true, "version": true, "install": true, - "list-operations": true, "telemetry": true, "feedback": true, + "list-operations": true, "list-instant-operations": true, + "list-instant-operation": true, "telemetry": true, "feedback": true, } // If --help or -h appears anywhere, don't inject. for _, arg := range os.Args[1:] { diff --git a/cmd/surf/main_test.go b/cmd/surf/main_test.go index 8af2862..f0d4019 100644 --- a/cmd/surf/main_test.go +++ b/cmd/surf/main_test.go @@ -35,6 +35,16 @@ func TestShouldInjectAPIName(t *testing.T) { args: []string{"surf", "auth"}, want: false, }, + { + name: "list instant operations command does not inject", + args: []string{"surf", "list-instant-operations"}, + want: false, + }, + { + name: "list instant operation alias does not inject", + args: []string{"surf", "list-instant-operation"}, + want: false, + }, { name: "no args does not inject", args: []string{"surf"}, @@ -80,6 +90,8 @@ func TestNeedsCachedAPI(t *testing.T) { {"feedback is meta", []string{"surf", "feedback", "test"}, false}, {"API command needs cache", []string{"surf", "polymarket-markets"}, true}, {"list-operations needs cache", []string{"surf", "list-operations"}, true}, + {"list-instant-operations needs cache", []string{"surf", "list-instant-operations"}, true}, + {"list-instant-operation alias needs cache", []string{"surf", "list-instant-operation"}, true}, {"API command with --help needs cache", []string{"surf", "polymarket-markets", "--help"}, true}, {"leading flags skipped when finding command", []string{"surf", "--debug", "polymarket-markets"}, true}, {"leading flags skipped before meta command", []string{"surf", "--debug", "auth"}, false},