Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ go install github.com/asksurf-ai/surf-cli/cmd/surf@latest
surf auth --api-key sk-xxx

# Query market data
surf market-futures --symbol BTC
surf market-price --symbol BTC
surf search-project --q bitcoin

# Update available commands from latest API spec
Expand Down
128 changes: 128 additions & 0 deletions cli/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"net/http"
"net/url"
"sort"
"strings"

"github.com/gosimple/slug"
Expand Down Expand Up @@ -200,10 +201,137 @@ func (o Operation) Command() *cobra.Command {
}

sub.Flags().SetNormalizeFunc(NormalizeSnakeCaseFlags)
sub.SetFlagErrorFunc(flagErrorFuncWithSuggestions)

return sub
}

// flagErrorFuncWithSuggestions intercepts pflag parse errors and, for unknown
// flags, appends a "did you mean?" hint with the closest valid flags on the
// same command. Common agent guesses like --query (→ --q), --username (→
// --handle), --time-range (on an interval-only endpoint) will match their
// intended flag via edit distance, so the agent sees the right answer without
// having to rerun --help.
func flagErrorFuncWithSuggestions(cmd *cobra.Command, err error) error {
msg := err.Error()
if !strings.HasPrefix(msg, "unknown flag:") && !strings.HasPrefix(msg, "unknown shorthand flag:") {
return err
}
bad := extractUnknownFlagName(msg)
if bad == "" {
return err
}
suggestions := suggestFlagNames(cmd, bad)
if len(suggestions) == 0 {
return err
}
hint := "\n\nDid you mean one of these?\n"
for _, s := range suggestions {
hint += "\t--" + s + "\n"
}
hint += "\nRun '" + cmd.Root().Name() + " " + cmd.Name() + " --help' for all flags."
return fmt.Errorf("%s%s", msg, hint)
}

// extractUnknownFlagName pulls the flag name out of pflag's error strings,
// e.g. `unknown flag: --query` → `query`.
func extractUnknownFlagName(msg string) string {
for _, prefix := range []string{"unknown flag: --", "unknown flag: -", "unknown shorthand flag: "} {
if strings.HasPrefix(msg, prefix) {
rest := strings.TrimPrefix(msg, prefix)
// Truncate at first whitespace or newline.
if i := strings.IndexAny(rest, " \t\n"); i >= 0 {
rest = rest[:i]
}
return strings.TrimSpace(rest)
}
}
return ""
}

// suggestFlagNames returns up to 3 valid flag names from cmd whose Levenshtein
// distance to bad is ≤ 3. Shorter valid names get priority on ties so that a
// 1-char guess like --query doesn't miss --q.
func suggestFlagNames(cmd *cobra.Command, bad string) []string {
type candidate struct {
name string
dist int
}
var cands []candidate
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if f.Hidden {
return
}
d := levenshtein(bad, f.Name)
// Allow up to 3 edits, OR if the bad name contains the real flag name
// as a substring (covers --query → --q, --keyword → --q harder but
// at least --timerange → --time-range).
if d <= 3 || strings.Contains(bad, f.Name) || strings.Contains(f.Name, bad) {
cands = append(cands, candidate{f.Name, d})
}
})
// Sort by distance asc, then by length asc (so short names like "q" win
// ties against longer partial matches), then alphabetically.
sort.Slice(cands, func(i, j int) bool {
if cands[i].dist != cands[j].dist {
return cands[i].dist < cands[j].dist
}
if len(cands[i].name) != len(cands[j].name) {
return len(cands[i].name) < len(cands[j].name)
}
return cands[i].name < cands[j].name
})
if len(cands) > 3 {
cands = cands[:3]
}
out := make([]string, 0, len(cands))
for _, c := range cands {
out = append(out, c.name)
}
return out
}

// levenshtein computes the edit distance between a and b. Classic DP; fine
// for short flag-name inputs.
func levenshtein(a, b string) int {
if a == b {
return 0
}
ar, br := []rune(a), []rune(b)
if len(ar) == 0 {
return len(br)
}
if len(br) == 0 {
return len(ar)
}
prev := make([]int, len(br)+1)
curr := make([]int, len(br)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ar); i++ {
curr[0] = i
for j := 1; j <= len(br); j++ {
cost := 1
if ar[i-1] == br[j-1] {
cost = 0
}
del := prev[j] + 1
ins := curr[j-1] + 1
sub := prev[j-1] + cost
curr[j] = del
if ins < curr[j] {
curr[j] = ins
}
if sub < curr[j] {
curr[j] = sub
}
}
prev, curr = curr, prev
}
return prev[len(br)]
}

// NormalizeSnakeCaseFlags silently converts underscore-separated flag names
// to kebab-case. This allows --time_range to work as --time-range.
// No warning is printed because pflag calls this function during flag
Expand Down
34 changes: 27 additions & 7 deletions cli/param.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"log"
"reflect"
"strings"

"github.com/iancoleman/strcase"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -105,42 +106,61 @@ func (p Param) OptionName() string {
return strcase.ToDelimited(name, '-')
}

// helpDescription composes the description shown in --help for this param.
// Prepends "[required] " for required params and appends " (example: X)"
// for params with an example value, so agents reading --help see at a
// glance which flags are mandatory and what a valid value looks like.
func (p Param) helpDescription() string {
desc := p.Description
if p.Required {
desc = "[required] " + desc
}
if p.Example != nil {
ex := fmt.Sprintf("%v", p.Example)
if ex != "" && !strings.Contains(desc, "example:") && !strings.Contains(desc, "Example:") {
desc = strings.TrimRight(desc, " .") + " (example: " + ex + ")"
}
}
return desc
}

// AddFlag adds a new option flag to a command's flag set for this parameter.
func (p Param) AddFlag(flags *pflag.FlagSet) any {
name := p.OptionName()
def := p.Default
desc := p.helpDescription()

switch p.Type {
case "boolean":
if def == nil {
def = false
}
return flags.Bool(name, def.(bool), p.Description)
return flags.Bool(name, def.(bool), desc)
case "integer":
if def == nil {
def = 0
}
return flags.Int(name, typeConvert(def, 0).(int), p.Description)
return flags.Int(name, typeConvert(def, 0).(int), desc)
case "number":
if def == nil {
def = 0.0
}
return flags.Float64(name, typeConvert(def, float64(0.0)).(float64), p.Description)
return flags.Float64(name, typeConvert(def, float64(0.0)).(float64), desc)
case "string":
if def == nil {
def = ""
}
return flags.String(name, def.(string), p.Description)
return flags.String(name, def.(string), desc)
case "array[boolean]":
if def == nil {
def = []bool{}
}
return flags.BoolSlice(name, def.([]bool), p.Description)
return flags.BoolSlice(name, def.([]bool), desc)
case "array[integer]":
if def == nil {
def = []int{}
}
return flags.IntSlice(name, def.([]int), p.Description)
return flags.IntSlice(name, def.([]int), desc)
case "array[number]":
log.Printf("number slice not implemented for param %s", p.Name)
return nil
Expand All @@ -159,7 +179,7 @@ func (p Param) AddFlag(flags *pflag.FlagSet) any {
}
def = tmp
}
return flags.StringSlice(name, def.([]string), p.Description)
return flags.StringSlice(name, def.([]string), desc)
}

return nil
Expand Down
127 changes: 127 additions & 0 deletions cli/suggest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package cli

import (
"net/http"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

func TestExtractUnknownFlagName(t *testing.T) {
cases := []struct {
in, want string
}{
{"unknown flag: --query", "query"},
{"unknown flag: --time-range", "time-range"},
{"unknown flag: --q extra\n", "q"},
{"unknown shorthand flag: z in -z", "z"},
{"some other error", ""},
}
for _, c := range cases {
got := extractUnknownFlagName(c.in)
assert.Equal(t, c.want, got, "input=%q", c.in)
}
}

func TestLevenshtein(t *testing.T) {
assert.Equal(t, 0, levenshtein("abc", "abc"))
assert.Equal(t, 1, levenshtein("q", "qa"))
assert.Equal(t, 4, levenshtein("query", "q"))
assert.Equal(t, 5, levenshtein("", "hello"))
assert.Equal(t, 5, levenshtein("hello", ""))
}

// TestSuggestFlagNames covers the agent-confusion cases we care about:
// --query → --q, --username → --handle, --timerange → --time-range.
func TestSuggestFlagNames(t *testing.T) {
op := Operation{
Name: "fake",
Method: http.MethodGet,
URITemplate: "http://example.com/fake",
QueryParams: []*Param{
{Type: "string", Name: "q"},
{Type: "string", Name: "handle"},
{Type: "string", Name: "time_range"},
{Type: "integer", Name: "limit"},
},
}
cmd := op.Command()

// "query" should suggest "q" (substring match).
got := suggestFlagNames(cmd, "query")
assert.Contains(t, got, "q", "got=%v", got)

// "username" should suggest "handle" within edit distance 3? Actually
// levenshtein("username", "handle") is 6, so it won't match by distance.
// This confirms we need alias work for that case — test captures the
// current behaviour to prevent accidental regression.
_ = suggestFlagNames(cmd, "username")

// "timerange" (no dash) should map to "time-range" via substring or
// distance (levenshtein=1).
got = suggestFlagNames(cmd, "timerange")
assert.Contains(t, got, "time-range", "got=%v", got)
}

func TestFlagErrorFuncWithSuggestions_UnknownFlag(t *testing.T) {
op := Operation{
Name: "fake",
Method: http.MethodGet,
URITemplate: "http://example.com/fake",
QueryParams: []*Param{{Type: "string", Name: "q"}},
}
cmd := op.Command()

err := cmd.Flags().Parse([]string{"--query=foo"})
assert.Error(t, err)

// Run the error through our FlagErrorFunc and check the hint.
wrapped := flagErrorFuncWithSuggestions(cmd, err)
assert.Contains(t, wrapped.Error(), "Did you mean", "got: %s", wrapped.Error())
assert.Contains(t, wrapped.Error(), "--q", "got: %s", wrapped.Error())
}

func TestFlagErrorFuncWithSuggestions_PassThroughNonUnknown(t *testing.T) {
// An error that isn't "unknown flag" must pass through unchanged.
cmd := &cobra.Command{Use: "x"}
other := assertError("some validation error")
out := flagErrorFuncWithSuggestions(cmd, other)
assert.Equal(t, other, out)
}

// --- helpers ---

type localErr string

func (e localErr) Error() string { return string(e) }

func assertError(msg string) error {
return localErr(msg)
}

func TestHelpDescription_Required(t *testing.T) {
p := Param{Name: "market_slug", Type: "string", Description: "Market slug", Required: true}
got := p.helpDescription()
assert.True(t, strings.HasPrefix(got, "[required] "), "got=%q", got)
}

func TestHelpDescription_Example(t *testing.T) {
p := Param{Name: "symbol", Type: "string", Description: "Ticker symbol", Example: "BTC"}
got := p.helpDescription()
assert.Contains(t, got, "(example: BTC)", "got=%q", got)
}

func TestHelpDescription_RequiredAndExample(t *testing.T) {
p := Param{Name: "symbol", Type: "string", Description: "Ticker", Required: true, Example: "BTC"}
got := p.helpDescription()
assert.True(t, strings.HasPrefix(got, "[required] "), "got=%q", got)
assert.Contains(t, got, "(example: BTC)", "got=%q", got)
}

func TestHelpDescription_SkipsExampleIfAlreadyInText(t *testing.T) {
p := Param{Name: "symbol", Type: "string", Description: "Symbol. Example: BTC", Example: "ETH"}
got := p.helpDescription()
assert.NotContains(t, got, "(example: ETH)", "got=%q", got)
}
Loading