diff --git a/cmd/surf/instant_operations.go b/cmd/surf/instant_operations.go index f31290a..4234d26 100644 --- a/cmd/surf/instant_operations.go +++ b/cmd/surf/instant_operations.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "fmt" "os" + "sort" "strings" "github.com/asksurf-ai/surf-cli/cli" @@ -18,6 +20,7 @@ func newListInstantOperationsCmd() *cobra.Command { var groupByTag bool var detail bool var category string + var jsonOut bool cmd := &cobra.Command{ Use: "list-instant-operations", Aliases: []string{"list-instant-operation"}, @@ -32,7 +35,9 @@ func newListInstantOperationsCmd() *cobra.Command { } ops := filterInstantOperations(api.Operations) - if groupByTag { + if jsonOut { + printInstantOperationsJSON(ops, detail, category) + } else if groupByTag { printInstantOperationsGrouped(ops, detail, category) } else { printInstantOperationsFlat(ops, detail, category) @@ -43,6 +48,7 @@ func newListInstantOperationsCmd() *cobra.Command { 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)") + cmd.Flags().BoolVar(&jsonOut, "json", false, "Output the instant operation contract as JSON") return cmd } @@ -59,6 +65,9 @@ func filterInstantOperations(ops []cli.Operation) []cli.Operation { func printInstantOperationsFlat(ops []cli.Operation, detail bool, category string) { ops = filterOps(ops, category) + if detail { + printInstantContractHeader() + } for _, op := range ops { params := formatInstantParams(op) fmt.Fprintf(os.Stdout, " %-6s %-35s %s%s\n", op.Method, op.Name, op.Short, params) @@ -70,6 +79,9 @@ func printInstantOperationsFlat(ops []cli.Operation, detail bool, category strin func printInstantOperationsGrouped(ops []cli.Operation, detail bool, category string) { ops = filterOps(ops, category) + if detail { + printInstantContractHeader() + } groups := map[string][]cli.Operation{} var order []string for _, op := range ops { @@ -134,16 +146,49 @@ func printInstantOperationDetail(op cli.Operation) { if details := instantDetailSections(op.Long); details != "" { fmt.Fprintf(os.Stdout, " Details:\n%s\n", indentForListOperations(details, " ")) } + if hints := instantOperationHints(op); len(hints) > 0 { + fmt.Fprintf(os.Stdout, " Instant Contract Hints:\n") + for _, hint := range hints { + fmt.Fprintf(os.Stdout, " - %s\n", hint) + } + } fmt.Fprintln(os.Stdout) } +func printInstantContractHeader() { + fmt.Fprintln(os.Stdout, "Instant mode contract:") + fmt.Fprintln(os.Stdout, " - Use only operations listed by this command; it is hidden from `surf --help` but callable.") + fmt.Fprintln(os.Stdout, " - `*` marks required args/flags. Use `--detail`/`--json` for schemas and response fields.") + fmt.Fprintf(os.Stdout, " - Unavailable in instant mode: %s. Use task/research handoff for custom SQL/cohort/chain-wide aggregate work.\n\n", strings.Join(sortedInstantBlacklist(), ", ")) +} + func instantDetailSections(long string) string { + return instantSections(long, isInstantDetailHeading) +} + +func instantOptionSections(long string) string { + return instantSections(long, func(heading string) bool { + return strings.HasPrefix(heading, "## Argument Schema") || + strings.HasPrefix(heading, "## Option Schema") || + strings.HasPrefix(heading, "## Request Schema") || + strings.HasPrefix(heading, "## Input Example") + }) +} + +func instantResponseSections(long string) string { + return instantSections(long, func(heading string) bool { + return strings.HasPrefix(heading, "## Response") || + strings.HasPrefix(heading, "## Responses") + }) +} + +func instantSections(long string, includeHeading func(string) bool) string { lines := strings.Split(long, "\n") var out []string inSection := false for _, line := range lines { trimmed := strings.TrimSpace(line) - if isInstantDetailHeading(trimmed) { + if includeHeading(trimmed) { inSection = true } else if inSection && strings.HasPrefix(trimmed, "## ") { inSection = false @@ -176,3 +221,193 @@ func indentForListOperations(s string, prefix string) string { } return strings.Join(lines, "\n") } + +type instantContract struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Source string `json:"source"` + Category string `json:"category,omitempty"` + OperationCount int `json:"operation_count"` + Unavailable []instantUnavailableOperation `json:"unavailable"` + Operations []instantContractOperation `json:"operations"` +} + +type instantUnavailableOperation struct { + Name string `json:"name"` + Reason string `json:"reason"` +} + +type instantContractOperation struct { + Name string `json:"name"` + Group string `json:"group"` + Method string `json:"method"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Usage string `json:"usage"` + Params []instantContractParam `json:"params,omitempty"` + OptionSchema string `json:"option_schema,omitempty"` + ResponseSchema string `json:"response_schema,omitempty"` + Hints []string `json:"hints,omitempty"` +} + +type instantContractParam struct { + Name string `json:"name"` + Flag string `json:"flag,omitempty"` + Location string `json:"location"` + Type string `json:"type,omitempty"` + Required bool `json:"required"` + Default any `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + Description string `json:"description,omitempty"` +} + +func printInstantOperationsJSON(ops []cli.Operation, detail bool, category string) { + ops = filterOps(ops, category) + contract := instantContract{ + SchemaVersion: 1, + Mode: "instant", + Source: "surf cached OpenAPI spec plus surf-cli instant blacklist", + Category: category, + OperationCount: len(ops), + Unavailable: instantUnavailableOperations(), + Operations: make([]instantContractOperation, 0, len(ops)), + } + for _, op := range ops { + entry := instantContractOperation{ + Name: op.Name, + Group: instantGroup(op), + Method: op.Method, + Summary: op.Short, + Description: firstParagraph(op.Long), + Usage: instantUsage(op), + Params: instantContractParams(op), + Hints: instantOperationHints(op), + } + if detail { + entry.OptionSchema = instantOptionSections(op.Long) + entry.ResponseSchema = instantResponseSections(op.Long) + } + contract.Operations = append(contract.Operations, entry) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(contract); err != nil { + fmt.Fprintf(os.Stderr, "failed to encode instant operation contract: %v\n", err) + } +} + +func instantGroup(op cli.Operation) string { + if op.Group == "" { + return "other" + } + return op.Group +} + +func instantUsage(op cli.Operation) string { + parts := []string{"surf", op.Name} + for _, p := range op.PathParams { + parts = append(parts, "<"+p.Name+">") + } + for _, p := range append(append([]*cli.Param{}, op.QueryParams...), op.HeaderParams...) { + if p.Required { + parts = append(parts, "--"+p.OptionName(), "<"+p.Name+">") + } + } + return strings.Join(parts, " ") +} + +func instantContractParams(op cli.Operation) []instantContractParam { + params := make([]instantContractParam, 0, len(op.PathParams)+len(op.QueryParams)+len(op.HeaderParams)) + for _, p := range op.PathParams { + params = append(params, instantContractParam{ + Name: p.Name, + Location: "path", + Type: p.Type, + Required: p.Required, + Default: p.Default, + Enum: p.Enum, + Description: p.Description, + }) + } + for _, p := range op.QueryParams { + params = append(params, instantParamContract(p, "query")) + } + for _, p := range op.HeaderParams { + params = append(params, instantParamContract(p, "header")) + } + return params +} + +func instantParamContract(p *cli.Param, location string) instantContractParam { + return instantContractParam{ + Name: p.Name, + Flag: "--" + p.OptionName(), + Location: location, + Type: p.Type, + Required: p.Required, + Default: p.Default, + Enum: p.Enum, + Description: p.Description, + } +} + +func instantOperationHints(op cli.Operation) []string { + var hints []string + if hasParam(op, "limit") || hasParam(op, "offset") || hasParam(op, "cursor") || hasParam(op, "pagination-key") { + hints = append(hints, "Paginated response: inspect meta.has_more/meta.total/meta.limit/meta.offset when present; continue with offset/cursor/pagination-key when the requested answer needs more rows.") + } + if hasParam(op, "from") || hasParam(op, "to") || hasParam(op, "time-range") || hasParam(op, "start-time") || hasParam(op, "end-time") { + hints = append(hints, "Date/time scoped response: use explicit window flags from the user question; verify returned timestamps cover the requested period before totals or comparisons.") + } + switch op.Name { + case "project-defi-metrics": + hints = append(hints, + "Historical DeFi metric time series: for monthly/quarterly/half-year totals or threshold checks, use explicit --from/--to and the largest --limit allowed by the option schema.", + "Fetch fees and revenue as separate calls when both are required; do not treat a default last-20-row page as a full requested period.", + "If the returned timestamp range does not cover the requested window, report the covered range and gap instead of a full-period total.", + ) + case "wallet-detail": + hints = append(hints, + "Wallet allowance questions: run with --fields approvals, filter token + spender, and answer 0/no active allowance when no matching approval is present.", + ) + case "onchain-tx": + hints = append(hints, + "Direct transaction facts: answer only fields returned for the exact --hash/--chain. Hex fields are strings; convert hex safely instead of piping 0x values into jq tonumber.", + ) + case "search-news", "news-feed", "web-fetch", "search-web": + hints = append(hints, + "Exact-date news/headline questions: include the target date/entity in the query or fetched page; a current homepage is not historical evidence.", + ) + } + return hints +} + +func hasParam(op cli.Operation, name string) bool { + for _, p := range append(append(append([]*cli.Param{}, op.PathParams...), op.QueryParams...), op.HeaderParams...) { + if p.OptionName() == name || strings.EqualFold(p.Name, name) { + return true + } + } + return false +} + +func instantUnavailableOperations() []instantUnavailableOperation { + names := sortedInstantBlacklist() + out := make([]instantUnavailableOperation, 0, len(names)) + for _, name := range names { + out = append(out, instantUnavailableOperation{ + Name: name, + Reason: "blacklisted from instant mode; use task/research handoff for custom SQL, cohorts, chain-wide aggregates, or table exploration", + }) + } + return out +} + +func sortedInstantBlacklist() []string { + names := make([]string, 0, len(instantOperationBlacklist)) + for name := range instantOperationBlacklist { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cmd/surf/instant_operations_test.go b/cmd/surf/instant_operations_test.go index a5a078e..ed84de5 100644 --- a/cmd/surf/instant_operations_test.go +++ b/cmd/surf/instant_operations_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "strings" "testing" @@ -15,6 +16,9 @@ func TestListInstantOperationsCommandIsHidden(t *testing.T) { if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "list-instant-operation" { t.Fatalf("unexpected aliases: %v", cmd.Aliases) } + if cmd.Flags().Lookup("json") == nil { + t.Fatal("list-instant-operations should expose internal --json output") + } } func TestFilterInstantOperationsUsesBlacklist(t *testing.T) { @@ -32,6 +36,100 @@ func TestFilterInstantOperationsUsesBlacklist(t *testing.T) { } } +func TestInstantOperationHintsCaptureKnownContracts(t *testing.T) { + op := cli.Operation{ + Name: "project-defi-metrics", + Group: "Project", + QueryParams: []*cli.Param{ + {Name: "from"}, + {Name: "to"}, + {Name: "limit"}, + {Name: "offset"}, + }, + } + got := strings.Join(instantOperationHints(op), "\n") + for _, want := range []string{ + "Paginated response", + "Date/time scoped response", + "Historical DeFi metric time series", + "default last-20-row page", + "timestamp range", + } { + if !strings.Contains(got, want) { + t.Fatalf("instantOperationHints missing %q in:\n%s", want, got) + } + } +} + +func TestPrintInstantOperationsJSONIncludesContract(t *testing.T) { + op := cli.Operation{ + Name: "project-defi-metrics", + Group: "Project", + Method: "GET", + Short: "Project DeFi Metrics", + Long: `Historical metric. + +## Option Schema: +` + "```schema" + ` +{ + --q: (string) + --limit: (integer min:1 max:100 default:20) +} +` + "```" + ` + +## Response 200 (application/json) + +OK + +` + "```schema" + ` +{ + data*: [ + { + timestamp*: (integer) + value*: (number) + } + ] +} +` + "```" + ` +`, + QueryParams: []*cli.Param{ + {Name: "q", Type: "string"}, + {Name: "limit", Type: "integer", Default: 20}, + }, + } + + out := captureStdout(t, func() { + printInstantOperationsJSON([]cli.Operation{op}, true, "") + }) + + var contract instantContract + if err := json.Unmarshal([]byte(out), &contract); err != nil { + t.Fatalf("invalid JSON contract: %v\n%s", err, out) + } + if contract.SchemaVersion != 1 || contract.Mode != "instant" { + t.Fatalf("unexpected contract header: %#v", contract) + } + if len(contract.Unavailable) != 2 { + t.Fatalf("expected blacklisted operations in JSON contract: %#v", contract.Unavailable) + } + if len(contract.Operations) != 1 { + t.Fatalf("expected one operation, got %#v", contract.Operations) + } + got := contract.Operations[0] + if got.Name != "project-defi-metrics" || got.Usage != "surf project-defi-metrics" { + t.Fatalf("unexpected operation contract: %#v", got) + } + if len(got.Params) != 2 || got.Params[1].Flag != "--limit" || got.Params[1].Default != float64(20) { + t.Fatalf("unexpected params: %#v", got.Params) + } + if !strings.Contains(got.OptionSchema, "--limit") || !strings.Contains(got.ResponseSchema, "timestamp*") { + t.Fatalf("schemas missing expected content: %#v", got) + } + if !strings.Contains(strings.Join(got.Hints, "\n"), "Historical DeFi metric time series") { + t.Fatalf("operation hints missing DeFi metric guidance: %#v", got.Hints) + } +} + func TestFormatInstantParamsMarksRequiredInputs(t *testing.T) { op := cli.Operation{ PathParams: []*cli.Param{