Skip to content
Merged
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
178 changes: 178 additions & 0 deletions cmd/surf/instant_operations.go
Original file line number Diff line number Diff line change
@@ -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")
}
95 changes: 95 additions & 0 deletions cmd/surf/instant_operations_test.go
Original file line number Diff line number Diff line change
@@ -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{"<condition_id*>", "--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)
}
}
8 changes: 5 additions & 3 deletions cmd/surf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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,
Expand All @@ -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:] {
Expand Down
12 changes: 12 additions & 0 deletions cmd/surf/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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},
Expand Down
Loading