diff --git a/docs/api-spec/parser.go b/docs/api-spec/parser.go index 104550c5c..dbe1dd09c 100644 --- a/docs/api-spec/parser.go +++ b/docs/api-spec/parser.go @@ -19,10 +19,29 @@ var httpMethods = map[string]bool{ // Parameter describes a single OpenAPI operation parameter. type Parameter struct { - Name string `yaml:"name"` - In string `yaml:"in"` - Required bool `yaml:"required"` - Description string `yaml:"description"` + Name string `yaml:"name" json:"name"` + In string `yaml:"in" json:"in"` + Required bool `yaml:"required" json:"required"` + Description string `yaml:"description" json:"description,omitempty"` +} + +// Property describes a single top-level field of an operation's JSON request +// body. Type is either a JSON-schema primitive ("string", "boolean", +// "integer", "object", ...), "array" for arrays, or the name of a +// referenced component schema (e.g. "BuildTarget") when the property itself +// is a $ref -- that nested schema's own fields are not flattened further. +type Property struct { + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description,omitempty"` + Default string `json:"default,omitempty"` +} + +// RequestBody describes an operation's JSON request body payload. +type RequestBody struct { + Required bool `json:"required"` + Properties []Property `json:"properties,omitempty"` } // Operation describes a single OpenAPI path+method operation. @@ -33,6 +52,9 @@ type Operation struct { Tags []string OperationId string Parameters []Parameter + // RequestBody is nil for operations with no application/json request body + // (typically GET/DELETE). + RequestBody *RequestBody } // Metadata describes which spec bundle is embedded in this binary. @@ -42,14 +64,42 @@ type Metadata struct { } type rawDoc struct { - Paths map[string]map[string]yaml.Node `yaml:"paths"` + Paths map[string]map[string]yaml.Node `yaml:"paths"` + Components struct { + Schemas map[string]rawSchema `yaml:"schemas"` + } `yaml:"components"` } type rawOperation struct { - Summary string `yaml:"summary"` - OperationId string `yaml:"operationId"` - Tags []string `yaml:"tags"` - Parameters []Parameter `yaml:"parameters"` + Summary string `yaml:"summary"` + OperationId string `yaml:"operationId"` + Tags []string `yaml:"tags"` + Parameters []Parameter `yaml:"parameters"` + RequestBody *rawRequestBody `yaml:"requestBody"` +} + +type rawRequestBody struct { + Required bool `yaml:"required"` + Content map[string]rawMediaTypeItem `yaml:"content"` +} + +type rawMediaTypeItem struct { + Schema rawSchema `yaml:"schema"` +} + +// rawSchema is a deliberately narrow subset of OpenAPI's Schema Object: only +// what's needed to flatten a request body's top-level properties into +// Property. Nested object properties are identified by referenced schema name +// (see Property) rather than recursively flattened, to keep output compact +// and sidestep any $ref cycles in the full (non-stub) bundle. +type rawSchema struct { + Ref string `yaml:"$ref"` + Type string `yaml:"type"` + Description string `yaml:"description"` + Default any `yaml:"default"` + Required []string `yaml:"required"` + Properties map[string]rawSchema `yaml:"properties"` + Items *rawSchema `yaml:"items"` } var ( @@ -139,8 +189,90 @@ func parseFile(name string) ([]Operation, error) { Tags: op.Tags, OperationId: op.OperationId, Parameters: op.Parameters, + RequestBody: buildRequestBody(op.RequestBody, doc.Components.Schemas), }) } } return ops, nil } + +// buildRequestBody flattens a JSON request body's top-level schema properties +// into a RequestBody. Returns nil when there's no application/json content +// (the only content type used across today's rdme-admin reference bundle). +func buildRequestBody(raw *rawRequestBody, schemas map[string]rawSchema) *RequestBody { + if raw == nil { + return nil + } + media, ok := raw.Content["application/json"] + if !ok { + return nil + } + + schema := media.Schema + if schema.Ref != "" { + if resolved, ok := schemas[schemaRefName(schema.Ref)]; ok { + schema = resolved + } + } + + required := make(map[string]bool, len(schema.Required)) + for _, name := range schema.Required { + required[name] = true + } + + properties := make([]Property, 0, len(schema.Properties)) + for name, prop := range schema.Properties { + properties = append(properties, Property{ + Name: name, + Type: propertyType(prop), + Required: required[name], + Description: prop.Description, + Default: stringifyDefault(prop.Default), + }) + } + sort.Slice(properties, func(i, j int) bool { return properties[i].Name < properties[j].Name }) + + return &RequestBody{Required: raw.Required, Properties: properties} +} + +// schemaRefName extracts "Foo" from a local-document ref like +// "#/components/schemas/Foo". +func schemaRefName(ref string) string { + if idx := strings.LastIndex(ref, "/"); idx != -1 { + return ref[idx+1:] + } + return ref +} + +// propertyType renders a rawSchema property as a compact type hint: a $ref +// becomes the referenced schema's name (not flattened further), an array +// becomes "array", and anything else falls back to its declared +// type or "object" when untyped. +func propertyType(p rawSchema) string { + if p.Ref != "" { + return schemaRefName(p.Ref) + } + if p.Type == "array" { + itemType := "object" + if p.Items != nil { + switch { + case p.Items.Ref != "": + itemType = schemaRefName(p.Items.Ref) + case p.Items.Type != "": + itemType = p.Items.Type + } + } + return "array<" + itemType + ">" + } + if p.Type == "" { + return "object" + } + return p.Type +} + +func stringifyDefault(v any) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} diff --git a/docs/api-spec/parser_test.go b/docs/api-spec/parser_test.go index 42b1ce1d1..758750085 100644 --- a/docs/api-spec/parser_test.go +++ b/docs/api-spec/parser_test.go @@ -30,11 +30,37 @@ func TestOperations_Stub(t *testing.T) { assert.Equal(t, []string{"Users"}, getUserList.Tags) assert.Len(t, getUserList.Parameters, 10) + assert.Nil(t, getUserList.RequestBody, "a GET operation should have no request body") + createUser, ok := byOperationId["createUser"] require.True(t, ok, "createUser should be present") assert.Equal(t, "POST", createUser.Method) assert.Equal(t, "/access/api/v2/users", createUser.Path) + require.NotNil(t, createUser.RequestBody, "createUser's requestBody ($ref: UserCreateRequest) should resolve") + assert.True(t, createUser.RequestBody.Required) + propsByName := make(map[string]Property, len(createUser.RequestBody.Properties)) + for _, p := range createUser.RequestBody.Properties { + propsByName[p.Name] = p + } + username, ok := propsByName["username"] + require.True(t, ok, "username should be a flattened property of UserCreateRequest") + assert.Equal(t, "string", username.Type) + assert.True(t, username.Required, "username is in UserCreateRequest's required list") + + password, ok := propsByName["password"] + require.True(t, ok) + assert.False(t, password.Required, "password is not in UserCreateRequest's required list") + + admin, ok := propsByName["admin"] + require.True(t, ok) + assert.Equal(t, "boolean", admin.Type) + assert.Equal(t, "false", admin.Default) + + groups, ok := propsByName["groups"] + require.True(t, ok) + assert.Equal(t, "array", groups.Type) + deleteWorker, ok := byOperationId["deleteWorker"] require.True(t, ok, "deleteWorker should be present") assert.Equal(t, "DELETE", deleteWorker.Method) diff --git a/docs/api-spec/requestbody_test.go b/docs/api-spec/requestbody_test.go new file mode 100644 index 000000000..82edc242d --- /dev/null +++ b/docs/api-spec/requestbody_test.go @@ -0,0 +1,116 @@ +package apispec + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// parseRawDoc is a test-only helper: unlike parseFile, it takes a literal YAML +// string instead of reading an embedded file, so these tests can exercise +// buildRequestBody against synthetic fixtures without needing new stub files. +func parseRawDoc(t *testing.T, doc string) rawDoc { + t.Helper() + var d rawDoc + require.NoError(t, yaml.Unmarshal([]byte(doc), &d)) + return d +} + +func TestBuildRequestBody_NilWhenNoRequestBody(t *testing.T) { + assert.Nil(t, buildRequestBody(nil, nil)) +} + +func TestBuildRequestBody_NilWhenNoJSONContent(t *testing.T) { + raw := &rawRequestBody{Required: true, Content: map[string]rawMediaTypeItem{ + "multipart/form-data": {Schema: rawSchema{Type: "object"}}, + }} + assert.Nil(t, buildRequestBody(raw, nil)) +} + +func TestBuildRequestBody_RefResolution(t *testing.T) { + d := parseRawDoc(t, ` +components: + schemas: + Widget: + type: object + required: [name] + properties: + name: + type: string + description: Widget name + active: + type: boolean + default: true +`) + raw := &rawRequestBody{Required: true, Content: map[string]rawMediaTypeItem{ + "application/json": {Schema: rawSchema{Ref: "#/components/schemas/Widget"}}, + }} + + rb := buildRequestBody(raw, d.Components.Schemas) + require.NotNil(t, rb) + assert.True(t, rb.Required) + require.Len(t, rb.Properties, 2) + + byName := make(map[string]Property, len(rb.Properties)) + for _, p := range rb.Properties { + byName[p.Name] = p + } + assert.True(t, byName["name"].Required) + assert.Equal(t, "string", byName["name"].Type) + assert.Equal(t, "Widget name", byName["name"].Description) + assert.False(t, byName["active"].Required) + assert.Equal(t, "true", byName["active"].Default) +} + +func TestBuildRequestBody_InlineSchemaNoRef(t *testing.T) { + raw := &rawRequestBody{Required: false, Content: map[string]rawMediaTypeItem{ + "application/json": {Schema: rawSchema{ + Type: "object", + Required: []string{"password"}, + Properties: map[string]rawSchema{ + "password": {Type: "string"}, + }, + }}, + }} + + rb := buildRequestBody(raw, nil) + require.NotNil(t, rb) + assert.False(t, rb.Required) + require.Len(t, rb.Properties, 1) + assert.Equal(t, "password", rb.Properties[0].Name) + assert.True(t, rb.Properties[0].Required) +} + +func TestPropertyType(t *testing.T) { + tests := []struct { + name string + in rawSchema + want string + }{ + {"primitive string", rawSchema{Type: "string"}, "string"}, + {"untyped falls back to object", rawSchema{}, "object"}, + {"nested $ref is not flattened", rawSchema{Ref: "#/components/schemas/BuildTarget"}, "BuildTarget"}, + {"array of primitives", rawSchema{Type: "array", Items: &rawSchema{Type: "string"}}, "array"}, + {"array of refs", rawSchema{Type: "array", Items: &rawSchema{Ref: "#/components/schemas/PatternFilter"}}, "array"}, + {"array with no items info", rawSchema{Type: "array"}, "array"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, propertyType(tt.in)) + }) + } +} + +func TestSchemaRefName(t *testing.T) { + assert.Equal(t, "Widget", schemaRefName("#/components/schemas/Widget")) + assert.Equal(t, "no-slash", schemaRefName("no-slash")) +} + +func TestStringifyDefault(t *testing.T) { + assert.Equal(t, "", stringifyDefault(nil)) + assert.Equal(t, "true", stringifyDefault(true)) + assert.Equal(t, "0", stringifyDefault(0)) + assert.Equal(t, "hello", stringifyDefault("hello")) +} diff --git a/docs/general/api/help.go b/docs/general/api/help.go index a9af3c0e5..644fdf0be 100644 --- a/docs/general/api/help.go +++ b/docs/general/api/help.go @@ -70,6 +70,8 @@ When to use: - Scripting platform admin tasks where 'jf rt' or 'jf c' don't cover the operation. - Debugging API responses with full visibility into status code and body. +Before guessing at a path: if you don't already know the exact endpoint/method, run 'jf api docs search ' first (e.g. 'jf api docs search user') — it's a local, offline lookup over this binary's embedded OpenAPI spec that returns ranked matches with a ready-to-run 'jf api' command for each. + Prerequisites: - A configured server (jf c add or jf login) or explicit --url / --access-token / --server-id. - The caller's identity must have the privileges the target endpoint requires. @@ -85,6 +87,7 @@ Gotchas: - -d/--data and --input are mutually exclusive. - HTTP status goes to stderr (one line), body to stdout. Non-2xx exits with status 1 but still prints the body. - Some APIs require trailing slashes or specific Accept headers; check the API reference before scripting. +- The bare, slash-less path 'docs' (e.g. 'jf api docs') routes to 'jf api docs search' instead of issuing an HTTP call. This does not affect the leading-slash form: 'jf api -X GET /docs' still reaches the platform normally, since no real JFrog REST path is bare '/docs'. -Related: jf c add, jf rt, jf c show` +Related: jf api docs search, jf c add, jf rt, jf c show` } diff --git a/docs/general/apidocs/help.go b/docs/general/apidocs/help.go new file mode 100644 index 000000000..79ad50692 --- /dev/null +++ b/docs/general/apidocs/help.go @@ -0,0 +1,13 @@ +package apidocs + +var Usage = []string{"api docs search [command options]"} + +func GetDescription() string { + return "Discover JFrog Platform REST API operations. Run 'jf api docs search ' to find the right endpoint before using 'jf api '." +} + +func GetAIDescription() string { + return `Namespace for API-discovery subcommands. Run 'jf api docs search ' to look up a REST endpoint by keyword before guessing at 'jf api '. + +See 'jf api docs search --help' for the full set of options.` +} diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go new file mode 100644 index 000000000..d5fbcd2ef --- /dev/null +++ b/docs/general/apidocssearch/help.go @@ -0,0 +1,57 @@ +package apidocssearch + +var Usage = []string{"api docs search [--tag ] [--method ] [--limit ] [--format table|json]"} + +func GetDescription() string { + return "Search the OpenAPI operations embedded in this jf binary for a keyword, and print ranked matches with a ready-to-run 'jf api' command for each. Local and offline: no server configuration or network call is involved." +} + +func GetArguments() string { + return ` query + Keyword to search for. Matched (case-insensitive, contains or fuzzy) against each operation's path, summary, tags, and operationId. + +EXAMPLES + # Find operations related to users + $ jf api docs search user + + # Narrow to a specific product/tag + $ jf api docs search token --tag Users + + # Narrow to a specific HTTP method + $ jf api docs search worker --method DELETE + + # Cap the number of results + $ jf api docs search repository --limit 3 + + # Human-readable table instead of the default JSON + $ jf api docs search user --format table + +OUTPUT + JSON by default (this command exists primarily for agent consumption); pass --format table for a human-readable table instead. Each match includes the operation's method, path, summary, tags, a relevance score, and a "jf_api" field with a ready-to-run 'jf api' invocation for that operation. When the operation takes path/query parameters, they're listed under "parameters" (required ones marked). When it takes a JSON request body, its top-level fields (name, type, required, description, default) are listed under "request_body", and "jf_api" already includes a minimal -d '{...}' skeleton covering just the required fields — fill in real values before running it. Table view shows this as compact PARAMS/BODY columns ("*" marks a required field). An empty result set still reports which spec bundle was searched (spec_bundle) — a "stub" bundle may simply be missing the operation. Exits 0 even when no matches are found.` +} + +func GetAIDescription() string { + return `Search the OpenAPI operations embedded in this jf binary and return ranked matches with a ready-to-run 'jf api' command for each — use this before guessing at a 'jf api ' invocation. + +When to use: +- You know roughly what you want to do (e.g. "list users", "delete a worker") but not the exact REST path/method. +- Before calling 'jf api ' with a path you're not fully sure exists. +- Before calling a POST/PUT/PATCH endpoint whose payload shape you don't already know. + +Prerequisites: none. This command is fully local/offline — no server configuration, credentials, or network call. + +Common patterns: + $ jf api docs search user + $ jf api docs search token --tag Users --method GET + $ jf api docs search repository --limit 3 --format json + +Gotchas: +- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface. An empty match list includes spec_bundle so you know whether that's the likely cause. +- Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. +- Filters (--tag, --method) are hard excludes, applied before ranking/scoring. +- A query with no contains-match anywhere falls back to fuzzy (typo-tolerant) matching, gated by a similarity floor to avoid coincidental false positives (e.g. "evidence" vs "environments"). Advanced: override the floor (0-1, default 0.6) with $JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN. +- A match's "jf_api" one-liner only fills in required request-body fields with type-appropriate placeholders (e.g. "" for string, false for boolean) -- inspect the full "request_body"/"parameters" fields for optional ones, descriptions, and defaults before running it for real. +- A request body property that is itself a nested object is reported by its type name (e.g. "PermissionResource") or "object" rather than being recursively flattened -- only top-level fields are listed. + +Related: jf api, jf api --ai-help` +} diff --git a/general/api/cli_test.go b/general/api/cli_test.go index 02a4c59d9..cf8c5e1e5 100644 --- a/general/api/cli_test.go +++ b/general/api/cli_test.go @@ -536,6 +536,71 @@ func TestApiTimeoutExpired(t *testing.T) { assert.Error(t, err, "expected a timeout error") } +// TestApiDispatch_LeadingSlashDocsPathReachesParentAction is a permanent guard +// for the "jf api docs search" dispatch design: urfave/cli v1.22.17 matches a +// subcommand against the leftover positional arg *string*, not against path +// semantics, so a leading-slash path like "/docs" must fall through to api's +// own Action rather than being swallowed by the "docs" subcommand. This drives +// the real vendored urfave/cli library (not a mock), so a future dependency +// bump that changes this behavior would fail here. +func TestApiDispatch_LeadingSlashDocsPathReachesParentAction(t *testing.T) { + var gotArgs []string + app := cli.NewApp() + app.Commands = []cli.Command{ + { + Name: "api", + Flags: []cli.Flag{cli.StringFlag{Name: "method, X"}}, + Action: func(c *cli.Context) error { + gotArgs = c.Args() + return nil + }, + Subcommands: []cli.Command{ + { + Name: "docs", + Action: func(c *cli.Context) error { + t.Fatal("docs subcommand must not be invoked for a leading-slash path") + return nil + }, + }, + }, + }, + } + + require.NoError(t, app.Run([]string{"jf", "api", "-X", "GET", "/docs"})) + require.Equal(t, []string{"/docs"}, gotArgs) +} + +// TestApiDispatch_BareDocsRoutesToSubcommand is the mirror image: the bare, +// slash-less literal "docs" is the one case that *is* intercepted by the new +// subcommand. Documented as an accepted caveat, not a bug. +func TestApiDispatch_BareDocsRoutesToSubcommand(t *testing.T) { + var parentCalled, docsCalled bool + app := cli.NewApp() + app.Commands = []cli.Command{ + { + Name: "api", + Flags: []cli.Flag{cli.StringFlag{Name: "method, X"}}, + Action: func(c *cli.Context) error { + parentCalled = true + return nil + }, + Subcommands: []cli.Command{ + { + Name: "docs", + Action: func(c *cli.Context) error { + docsCalled = true + return nil + }, + }, + }, + }, + } + + require.NoError(t, app.Run([]string{"jf", "api", "docs"})) + assert.True(t, docsCalled, "bare 'docs' literal should route to the docs subcommand") + assert.False(t, parentCalled, "api's own Action should not run when 'docs' subcommand matches") +} + type commandArgs struct { path string method string diff --git a/general/api/docs_cmd.go b/general/api/docs_cmd.go new file mode 100644 index 000000000..b434e7e1f --- /dev/null +++ b/general/api/docs_cmd.go @@ -0,0 +1,17 @@ +package api + +import ( + "fmt" + + "github.com/urfave/cli" +) + +// DocsCommand is the fallback Action for the bare "docs" node (jf api docs). +// It has no behavior of its own beyond dispatching to "search" — same pattern +// as jf hf's bare-subcommand fallback in buildtools/cli.go. +func DocsCommand(c *cli.Context) error { + if c.Args().Present() { + return fmt.Errorf("'%s %s' is not a valid subcommand. Run 'jf api docs --help' for usage", c.App.Name, c.Args().First()) + } + return cli.ShowSubcommandHelp(c) +} diff --git a/general/api/docs_search.go b/general/api/docs_search.go new file mode 100644 index 000000000..74daefebe --- /dev/null +++ b/general/api/docs_search.go @@ -0,0 +1,400 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strconv" + "strings" + "text/tabwriter" + + "github.com/agnivade/levenshtein" + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + "github.com/jfrog/jfrog-cli/utils/cliutils" + clientUtils "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/urfave/cli" +) + +const ( + flagTag = "tag" + flagLimit = "limit" +) + +// envFuzzySimilarityMin overrides fuzzySimilarityMin for experimentation -- +// accepts any value parseable as a float64 in [0, 1]. Unset or invalid falls +// back to the default, same convention as corecommon.AIHelpEnabled's handling +// of JFROG_CLI_AI_HELP. +const envFuzzySimilarityMin = "JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN" + +// Per-field contains-match weights. They sum (an operation matching in two +// fields ranks above one matching in only its strongest field) and are capped +// at 100. Fuzzy-fallback scores are confined to fuzzyScoreMax and below, so an +// exact/substring hit on even the weakest field (tag) always outranks a pure +// fuzzy hit. +const ( + weightOperationId = 40 + weightPath = 30 + weightSummary = 20 + weightTag = 10 + fuzzyScoreMax = 9 + // fuzzySimilarityMin is deliberately high: whole-string Levenshtein + // similarity between two semantically unrelated but coincidentally + // similar-length words (e.g. "evidence" vs "environments", 0.42) crosses a + // low bar easily, flooding results with false positives that also mask a + // legitimate empty-result response. 0.6 still catches real typos + // ("usres"->"users" 0.6, "workrs"->"workers" 0.86) while rejecting + // unrelated words of similar length ("evidence"->"environments" 0.42, + // "token"->"worker" 0.5, "scan"->"scim" 0.5). + fuzzySimilarityMin = 0.6 + defaultLimit = 10 +) + +// match is a single scored, ranked apispec.Operation ready for rendering. +type match struct { + Method string `json:"method"` + Path string `json:"path"` + Summary string `json:"summary"` + Tags []string `json:"tags"` + Score int `json:"score"` + JfApi string `json:"jf_api"` + // Parameters and RequestBody are the payload/parameter data an agent needs + // to actually call this operation, not just find it -- omitted when absent + // (e.g. Parameters for a path with none, RequestBody for GET/DELETE). + Parameters []apispec.Parameter `json:"parameters,omitempty"` + RequestBody *apispec.RequestBody `json:"request_body,omitempty"` +} + +// searchResult is the JSON/table rendering payload for `jf api docs search`. +type searchResult struct { + SpecBundle string `json:"spec_bundle"` + SpecVersion string `json:"spec_version"` + Query string `json:"query"` + Matches []match `json:"matches"` + Message string `json:"message,omitempty"` +} + +// SearchCommand implements `jf api docs search `. It ranks operations +// from the embedded OpenAPI spec bundle by relevance to query -- this is a +// local, offline lookup; no server configuration or network call is involved. +func SearchCommand(c *cli.Context) error { + return runSearchCmd(c, os.Stdout) +} + +// runSearchCmd is split out from SearchCommand so tests can supply their own +// stdOut without hijacking the real os.Stdout -- same split as +// Command/runApiCmd in cli.go. JSON rendering still goes through the shared +// client logger's Output channel (see renderJSON), matching +// printTokenResponse's convention in general/token/cli.go. +func runSearchCmd(c *cli.Context, stdOut io.Writer) error { + if c.NArg() != 1 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + query := c.Args().First() + tag := c.String(flagTag) + method := c.String(flagMethod) + limit := c.Int(flagLimit) + if limit <= 0 { + limit = defaultLimit + } + + ops, err := apispec.Operations() + if err != nil { + return errorutils.CheckError(err) + } + + matches := filterAndScore(ops, query, tag, method) + if len(matches) > limit { + matches = matches[:limit] + } + + info := apispec.Info() + result := searchResult{ + SpecBundle: info.SpecBundle, + SpecVersion: info.SpecVersion, + Query: query, + Matches: matches, + } + if len(matches) == 0 { + result.Message = fmt.Sprintf( + "No matching operations found in the embedded %q OpenAPI spec bundle for query %q. "+ + "The bundle may be incomplete (see spec_bundle) -- try 'jf api ' directly if you already know the endpoint.", + result.SpecBundle, query) + } + + // JSON is the default output format -- this command exists primarily for + // agent consumption; --format table is available for humans who want it. + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Json) + if err != nil { + return err + } + + switch outputFormat { + case coreformat.Json: + return renderJSON(result) + case coreformat.Table: + return renderTable(result, stdOut) + default: + return errorutils.CheckErrorf("unsupported format '%s' for api docs search. Accepted values: table, json", outputFormat) + } +} + +// filterAndScore applies the --tag/--method hard filters, scores every +// remaining operation against query, drops zero-scoring operations, and +// returns matches sorted best-first (score desc, then path/method asc). +func filterAndScore(ops []apispec.Operation, query, tag, method string) []match { + q := strings.ToLower(strings.TrimSpace(query)) + tagFilter := strings.ToLower(strings.TrimSpace(tag)) + methodFilter := strings.ToUpper(strings.TrimSpace(method)) + + var matches []match + for _, op := range ops { + if methodFilter != "" && op.Method != methodFilter { + continue + } + if tagFilter != "" && !hasTag(op.Tags, tagFilter) { + continue + } + score := scoreOperation(op, q) + if score == 0 { + continue + } + matches = append(matches, match{ + Method: op.Method, + Path: op.Path, + Summary: op.Summary, + Tags: op.Tags, + Score: score, + JfApi: jfApiOneLiner(op), + Parameters: op.Parameters, + RequestBody: op.RequestBody, + }) + } + + sort.Slice(matches, func(i, j int) bool { + if matches[i].Score != matches[j].Score { + return matches[i].Score > matches[j].Score + } + if matches[i].Path != matches[j].Path { + return matches[i].Path < matches[j].Path + } + return matches[i].Method < matches[j].Method + }) + return matches +} + +// scoreOperation returns 0-100. An empty query matches every operation with +// the maximum score (a convenience "list everything" behavior, not a +// documented feature). A non-empty query that neither contains-matches nor +// clears the fuzzy similarity threshold on any field scores 0 (excluded). +func scoreOperation(op apispec.Operation, q string) int { + if q == "" { + return weightOperationId + weightPath + weightSummary + weightTag + } + + score := 0 + matchedAnyField := false + if strings.Contains(strings.ToLower(op.OperationId), q) { + score += weightOperationId + matchedAnyField = true + } + if strings.Contains(strings.ToLower(op.Path), q) { + score += weightPath + matchedAnyField = true + } + if strings.Contains(strings.ToLower(op.Summary), q) { + score += weightSummary + matchedAnyField = true + } + for _, t := range op.Tags { + if strings.Contains(strings.ToLower(t), q) { + score += weightTag + matchedAnyField = true + break + } + } + if matchedAnyField { + return min(score, 100) + } + + return fuzzyScore(op, q) +} + +// fuzzyScore is only consulted when no field contains q as a substring. It +// returns a value in [0, fuzzyScoreMax], where 0 means "too dissimilar to +// count as a match at all" (excludes the operation from results). +func fuzzyScore(op apispec.Operation, q string) int { + fields := append([]string{op.OperationId, op.Path, op.Summary}, op.Tags...) + best := 0.0 + for _, field := range fields { + if sim := similarity(q, strings.ToLower(field)); sim > best { + best = sim + } + } + if best < fuzzySimilarityThreshold() { + return 0 + } + return min(max(int(best*fuzzyScoreMax), 1), fuzzyScoreMax) +} + +// fuzzySimilarityThreshold resolves the effective fuzzy-match floor: the +// JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN env var when it parses as a float64 in +// [0, 1], otherwise fuzzySimilarityMin. +func fuzzySimilarityThreshold() float64 { + v := strings.TrimSpace(os.Getenv(envFuzzySimilarityMin)) + if v == "" { + return fuzzySimilarityMin + } + parsed, err := strconv.ParseFloat(v, 64) + if err != nil || parsed < 0 || parsed > 1 { + return fuzzySimilarityMin + } + return parsed +} + +// similarity is a normalized Levenshtein similarity in [0, 1]; 1 means +// identical strings, 0 means maximally different. +func similarity(a, b string) float64 { + maxLen := max(len(a), len(b)) + if maxLen == 0 { + return 1 + } + dist := levenshtein.ComputeDistance(a, b) + return 1 - float64(dist)/float64(maxLen) +} + +func hasTag(tags []string, tagFilter string) bool { + for _, t := range tags { + if strings.Contains(strings.ToLower(t), tagFilter) { + return true + } + } + return false +} + +// jfApiOneLiner is a ready-to-run `jf api` invocation for op: the method is +// omitted for GET, since that's runApiCmd's own default. When op has a +// request body with required fields, a minimal placeholder JSON payload is +// appended via -d so the command is runnable (after filling in real values), +// not just a bare skeleton the caller has to guess the shape of. +func jfApiOneLiner(op apispec.Operation) string { + var b strings.Builder + b.WriteString("jf api ") + b.WriteString(op.Path) + if op.Method != http.MethodGet { + b.WriteString(" -X ") + b.WriteString(op.Method) + } + if skeleton := requestBodySkeleton(op.RequestBody); skeleton != "" { + b.WriteString(` -H "Content-Type: application/json" -d '`) + b.WriteString(skeleton) + b.WriteString(`'`) + } + return b.String() +} + +// requestBodySkeleton renders a minimal JSON object covering just rb's +// required fields with type-appropriate placeholder values, or "" when rb is +// nil or has no required fields (an all-optional body isn't worth guessing at +// in a one-liner -- the full field list is still in the match's request_body). +func requestBodySkeleton(rb *apispec.RequestBody) string { + if rb == nil { + return "" + } + var fields []string + for _, p := range rb.Properties { + if p.Required { + fields = append(fields, fmt.Sprintf("%q:%s", p.Name, placeholderJSONValue(p.Type))) + } + } + if len(fields) == 0 { + return "" + } + return "{" + strings.Join(fields, ",") + "}" +} + +// placeholderJSONValue returns a type-appropriate placeholder JSON literal. +// A referenced schema name (e.g. "BuildTarget") isn't a JSON-schema +// primitive, so it falls back to the empty-string placeholder like any other +// unrecognized type. +func placeholderJSONValue(propType string) string { + switch { + case propType == "boolean": + return "false" + case propType == "integer" || propType == "number": + return "0" + case propType == "array" || strings.HasPrefix(propType, "array<"): + return "[]" + case propType == "object": + return "{}" + default: + return `""` + } +} + +// renderJSON writes result as indented JSON via the shared client logger -- +// same pattern as printTokenResponse's JSON branch in general/token/cli.go. +func renderJSON(result searchResult) error { + data, err := json.Marshal(result) + if err != nil { + return errorutils.CheckErrorf("failed to marshal api docs search result: %s", err.Error()) + } + log.Output(clientUtils.IndentJson(data)) + return nil +} + +// renderTable writes result as a tabwriter-rendered table to w, mirroring +// printTokenTable's construction style in general/token/cli.go. +func renderTable(result searchResult, w io.Writer) error { + if len(result.Matches) == 0 { + _, err := fmt.Fprintf(w, "%s (spec_bundle=%s)\n", result.Message, result.SpecBundle) + return errorutils.CheckError(err) + } + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "METHOD\tPATH\tSUMMARY\tTAGS\tSCORE\tPARAMS\tBODY\tJF API") + for _, m := range result.Matches { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", + m.Method, m.Path, m.Summary, strings.Join(m.Tags, ","), m.Score, + formatParams(m.Parameters), formatRequestBody(m.RequestBody), m.JfApi) + } + return tw.Flush() +} + +// formatParams and formatRequestBody render a compact, single-line summary of +// field names for the table view (a "*" suffix marks a required field) -- +// the JSON view carries the full type/description/default detail instead. +func formatParams(params []apispec.Parameter) string { + if len(params) == 0 { + return "-" + } + names := make([]string, len(params)) + for i, p := range params { + names[i] = requiredMark(p.Name, p.Required) + } + return strings.Join(names, ",") +} + +func formatRequestBody(rb *apispec.RequestBody) string { + if rb == nil || len(rb.Properties) == 0 { + return "-" + } + names := make([]string, len(rb.Properties)) + for i, p := range rb.Properties { + names[i] = requiredMark(p.Name, p.Required) + } + return strings.Join(names, ",") +} + +func requiredMark(name string, required bool) string { + if required { + return name + "*" + } + return name +} diff --git a/general/api/docs_search_test.go b/general/api/docs_search_test.go new file mode 100644 index 000000000..0d288d360 --- /dev/null +++ b/general/api/docs_search_test.go @@ -0,0 +1,261 @@ +package api + +import ( + "bytes" + "encoding/json" + "slices" + "testing" + + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + clientlog "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +func stubOps(t *testing.T) []apispec.Operation { + t.Helper() + ops, err := apispec.Operations() + require.NoError(t, err) + require.NotEmpty(t, ops) + return ops +} + +func hasPath(matches []match, path string) bool { + return slices.ContainsFunc(matches, func(m match) bool { return m.Path == path }) +} + +func TestFilterAndScore_ContainsMatchOnKnownStubOps(t *testing.T) { + matches := filterAndScore(stubOps(t), "user", "", "") + require.NotEmpty(t, matches, "query 'user' should match the stub's user operations") + assert.True(t, hasPath(matches, "/access/api/v2/users"), "expected /access/api/v2/users (getUserList/createUser) in results") + + for i := 1; i < len(matches); i++ { + assert.GreaterOrEqual(t, matches[i-1].Score, matches[i].Score, "matches must be sorted by score desc") + } +} + +func TestFilterAndScore_TagFilter(t *testing.T) { + matches := filterAndScore(stubOps(t), "", "Users", "") + require.NotEmpty(t, matches) + for _, m := range matches { + assert.True(t, slices.Contains(m.Tags, "Users"), "match %s %s should carry the Users tag", m.Method, m.Path) + } +} + +func TestFilterAndScore_MethodFilter(t *testing.T) { + matches := filterAndScore(stubOps(t), "", "", "DELETE") + require.NotEmpty(t, matches) + for _, m := range matches { + assert.Equal(t, "DELETE", m.Method) + } + assert.True(t, hasPath(matches, "/worker/api/v1/workers/{workerKey}"), "expected deleteWorker in DELETE-filtered results") +} + +func TestFilterAndScore_MethodFilterCaseInsensitive(t *testing.T) { + matches := filterAndScore(stubOps(t), "", "", "delete") + require.NotEmpty(t, matches) + for _, m := range matches { + assert.Equal(t, "DELETE", m.Method) + } +} + +func TestFilterAndScore_CombinedFiltersNarrowResults(t *testing.T) { + all := filterAndScore(stubOps(t), "", "", "") + narrowed := filterAndScore(stubOps(t), "", "Users", "GET") + assert.Less(t, len(narrowed), len(all)) + for _, m := range narrowed { + assert.Equal(t, "GET", m.Method) + } +} + +func TestFilterAndScore_NoMatchIsEmpty(t *testing.T) { + matches := filterAndScore(stubOps(t), "zzzznotreal", "", "") + assert.Empty(t, matches, "a nonsense query should not fuzzy-match any stub operation") +} + +func TestFilterAndScore_EmptyQueryMatchesEverything(t *testing.T) { + ops := stubOps(t) + matches := filterAndScore(ops, "", "", "") + assert.Len(t, matches, len(ops)) +} + +func TestScoreOperation_MultiFieldContainsBeatsSingleField(t *testing.T) { + // "user" contains-matches all four fields of multi; "widget" contains-matches + // only the Summary of single -- weights must sum, not just take the best field. + multi := apispec.Operation{OperationId: "getUserList", Path: "/access/api/v2/users", Summary: "Get User List", Tags: []string{"Users"}} + single := apispec.Operation{OperationId: "createThing", Path: "/api/v2/things", Summary: "Create a Widget", Tags: []string{"Things"}} + + multiScore := scoreOperation(multi, "user") + singleScore := scoreOperation(single, "widget") + assert.Equal(t, 100, multiScore) + assert.Equal(t, weightSummary, singleScore) + assert.Greater(t, multiScore, singleScore, "matching operationId+path+summary+tag should outscore a single-field match") +} + +func TestScoreOperation_ContainsAlwaysBeatsFuzzy(t *testing.T) { + weakContains := apispec.Operation{OperationId: "x", Path: "/a", Summary: "s", Tags: []string{"usery"}} + // "usrr" doesn't contain "user" as a substring (order differs), but is a + // one-substitution Levenshtein neighbor of it -- a pure fuzzy match. + closeFuzzy := apispec.Operation{OperationId: "usrr", Path: "/b", Summary: "s", Tags: []string{"z"}} + + containsScore := scoreOperation(weakContains, "user") + fuzzyScoreVal := scoreOperation(closeFuzzy, "user") + require.Greater(t, containsScore, 0) + require.Greater(t, fuzzyScoreVal, 0) + assert.Greater(t, containsScore, fuzzyScoreVal, "any contains-match must outrank a pure fuzzy-match, regardless of field") +} + +func TestScoreOperation_DissimilarQueryScoresZero(t *testing.T) { + op := apispec.Operation{OperationId: "getUserList", Path: "/access/api/v2/users", Summary: "Get User List", Tags: []string{"Users"}} + assert.Equal(t, 0, scoreOperation(op, "zzzznotreal")) +} + +// TestScoreOperation_UnrelatedSimilarLengthWordDoesNotFuzzyMatch guards a real +// false positive found against the full bundle: "evidence" has no contains-match +// anywhere, but its whole-string Levenshtein similarity to "Environments" (0.42) +// used to clear the old, too-low fuzzy threshold, flooding "jf api docs search +// evidence" with unrelated Environments-tagged operations instead of correctly +// reporting no match. +func TestScoreOperation_UnrelatedSimilarLengthWordDoesNotFuzzyMatch(t *testing.T) { + op := apispec.Operation{OperationId: "getGlobalEnvironments", Path: "/access/api/v1/environments", Summary: "Get Global Environments", Tags: []string{"Environments"}} + assert.Equal(t, 0, scoreOperation(op, "evidence")) +} + +// TestScoreOperation_RealTypoStillFuzzyMatches is the flip side of the above: +// the raised fuzzy threshold must still tolerate genuine near-typos. +func TestScoreOperation_RealTypoStillFuzzyMatches(t *testing.T) { + op := apispec.Operation{OperationId: "getWorkers", Path: "/worker/api/v1/workers", Summary: "Get Workers", Tags: []string{"Workers"}} + assert.Greater(t, scoreOperation(op, "workrs"), 0, "a one-letter-dropped typo of 'workers' should still fuzzy-match") +} + +func TestFuzzySimilarityThreshold_DefaultAndOverride(t *testing.T) { + t.Run("unset uses default", func(t *testing.T) { + t.Setenv(envFuzzySimilarityMin, "") + assert.Equal(t, fuzzySimilarityMin, fuzzySimilarityThreshold()) + }) + t.Run("valid override wins", func(t *testing.T) { + t.Setenv(envFuzzySimilarityMin, "0.2") + assert.Equal(t, 0.2, fuzzySimilarityThreshold()) + }) + for _, invalid := range []string{"not-a-number", "-0.1", "1.5"} { + t.Run("invalid falls back: "+invalid, func(t *testing.T) { + t.Setenv(envFuzzySimilarityMin, invalid) + assert.Equal(t, fuzzySimilarityMin, fuzzySimilarityThreshold()) + }) + } +} + +func TestFuzzyScore_RespectsEnvOverride(t *testing.T) { + // "evidence" vs "environments" sits at ~0.42 similarity -- below the 0.6 + // default (excluded) but above a permissive 0.3 override (included). + op := apispec.Operation{OperationId: "getGlobalEnvironments", Path: "/access/api/v1/environments", Summary: "Get Global Environments", Tags: []string{"Environments"}} + + t.Setenv(envFuzzySimilarityMin, "") + assert.Equal(t, 0, scoreOperation(op, "evidence"), "default threshold should reject this coincidental match") + + t.Setenv(envFuzzySimilarityMin, "0.3") + assert.Greater(t, scoreOperation(op, "evidence"), 0, "a lowered override should admit it") +} + +func TestJfApiOneLiner(t *testing.T) { + assert.Equal(t, "jf api /access/api/v2/users", jfApiOneLiner(apispec.Operation{Method: "GET", Path: "/access/api/v2/users"})) + assert.Equal(t, "jf api /access/api/v2/users -X POST", jfApiOneLiner(apispec.Operation{Method: "POST", Path: "/access/api/v2/users"})) +} + +func TestHasTag(t *testing.T) { + assert.True(t, hasTag([]string{"Users", "Access"}, "users")) + assert.False(t, hasTag([]string{"Users"}, "workers")) +} + +// newSearchApp builds a minimal cli.App exercising runSearchCmd exactly like +// the real "search" subcommand's flag set, without going through main.go's +// full command tree -- same technique as TestResolveRequestBody in +// cli_test.go. +func newSearchApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { + app := cli.NewApp() + app.Flags = []cli.Flag{ + cli.StringFlag{Name: flagTag}, + cli.StringFlag{Name: flagMethod}, + cli.IntFlag{Name: flagLimit, Value: defaultLimit}, + cli.StringFlag{Name: "format"}, + } + app.Action = func(c *cli.Context) error { + *capturedErr = runSearchCmd(c, stdOut) + return nil + } + return app +} + +// TestRunSearchCmd_DefaultsToJSON verifies JSON is the default output format +// when --format is omitted entirely -- this command exists primarily for +// agent consumption, unlike most other jf commands whose JSON default is +// gated on --ai-help/$JFROG_CLI_AI_HELP. Swaps the shared client logger since +// the JSON path writes via its Output channel, same technique as +// TestApiJSONErrorMode_EmitsJSONOnStdout in cli_test.go. +func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { + var out bytes.Buffer + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(clientlog.NewLoggerWithFlags(clientlog.INFO, &out, 0)) + + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "user"})) + require.NoError(t, runErr) + + var result map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &result), "default output should be parseable JSON") + assert.Equal(t, "stub", result["spec_bundle"]) + assert.Empty(t, stdOut.String(), "JSON goes through the logger's Output channel, not the stdOut writer") +} + +func TestRunSearchCmd_TableOutput(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "user"})) + require.NoError(t, runErr) + assert.Contains(t, stdOut.String(), "METHOD") + assert.Contains(t, stdOut.String(), "/access/api/v2/users") +} + +func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "zzzznotreal"})) + require.NoError(t, runErr, "empty results must not be treated as a command failure") + assert.Contains(t, stdOut.String(), "spec_bundle=") + assert.Contains(t, stdOut.String(), "stub") +} + +func TestRunSearchCmd_LimitTruncates(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) + require.NoError(t, runErr) + // header + exactly one data row + lineCount := 0 + for _, b := range stdOut.Bytes() { + if b == '\n' { + lineCount++ + } + } + assert.Equal(t, 2, lineCount, "expected a header row plus exactly one match row") +} + +func TestRunSearchCmd_WrongNumberOfArguments(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd"})) + assert.Error(t, runErr) +} diff --git a/main.go b/main.go index a9eccda7e..7a4715193 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,8 @@ import ( "github.com/jfrog/jfrog-cli/config" "github.com/jfrog/jfrog-cli/docs/common" apiDocs "github.com/jfrog/jfrog-cli/docs/general/api" + apiDocsNodeDocs "github.com/jfrog/jfrog-cli/docs/general/apidocs" + apiDocsSearchDocs "github.com/jfrog/jfrog-cli/docs/general/apidocssearch" loginDocs "github.com/jfrog/jfrog-cli/docs/general/login" oidcDocs "github.com/jfrog/jfrog-cli/docs/general/oidc" summaryDocs "github.com/jfrog/jfrog-cli/docs/general/summary" @@ -387,6 +389,25 @@ func getCommands() ([]cli.Command, error) { BashComplete: corecommon.CreateBashCompletionFunc(), Category: otherCategory, Action: api.Command, + Subcommands: []cli.Command{ + { + Name: "docs", + Usage: corecommon.ResolveDescription(apiDocsNodeDocs.GetDescription(), apiDocsNodeDocs.GetAIDescription()), + HelpName: corecommon.CreateUsage("api docs", corecommon.ResolveDescription(apiDocsNodeDocs.GetDescription(), apiDocsNodeDocs.GetAIDescription()), apiDocsNodeDocs.Usage), + Action: api.DocsCommand, + Subcommands: []cli.Command{ + { + Name: "search", + Flags: cliutils.GetCommandFlags(cliutils.ApiDocsSearch), + Usage: corecommon.ResolveDescription(apiDocsSearchDocs.GetDescription(), apiDocsSearchDocs.GetAIDescription()), + HelpName: corecommon.CreateUsage("api docs search", corecommon.ResolveDescription(apiDocsSearchDocs.GetDescription(), apiDocsSearchDocs.GetAIDescription()), apiDocsSearchDocs.Usage), + UsageText: apiDocsSearchDocs.GetArguments(), + BashComplete: corecommon.CreateBashCompletionFunc(), + Action: api.SearchCommand, + }, + }, + }, + }, }, { Name: "exchange-oidc-token", diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 4cf45994e..55f765258 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -136,6 +136,7 @@ const ( AccessTokenCreate = "access-token-create" ExchangeOidcToken = "exchange-oidc-token" Api = "api" + ApiDocsSearch = "api-docs-search" // MCP commands keys McpShow = "mcp-show" @@ -659,6 +660,12 @@ const ( apiVerbose = "api-verbose" apiTimeout = "api-timeout" + // API docs search command flags + apiDocsSearchTag = "api-docs-search-tag" + apiDocsSearchMethod = "api-docs-search-method" + apiDocsSearchLimit = "api-docs-search-limit" + apiDocsSearchFormat = "api-docs-search-format" + // MCP command flags mcpUrl = "mcp-url" mcpAgent = "mcp-agent" @@ -809,6 +816,23 @@ var flagsMap = map[string]cli.Flag{ Name: "timeout", Usage: "[Default: 0] Overall HTTP request timeout in seconds. 0 means no timeout.` `", }, + apiDocsSearchTag: cli.StringFlag{ + Name: "tag", + Usage: "[Optional] Filter results to operations whose tags include this product/tag (case-insensitive).` `", + }, + apiDocsSearchMethod: cli.StringFlag{ + Name: "method", + Usage: "[Optional] Filter results to this HTTP method (GET, POST, PUT, DELETE, ...).` `", + }, + apiDocsSearchLimit: cli.IntFlag{ + Name: "limit", + Value: 10, + Usage: "[Default: 10] Maximum number of ranked matches to return.` `", + }, + apiDocsSearchFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", + }, mcpShowFormat: cli.StringFlag{ Name: Format, Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", @@ -2251,6 +2275,9 @@ var commandFlags = map[string][]string{ ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, apiHeader, apiInput, apiData, apiMethod, apiVerbose, apiTimeout, }, + ApiDocsSearch: { + apiDocsSearchTag, apiDocsSearchMethod, apiDocsSearchLimit, apiDocsSearchFormat, + }, McpShow: { platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken,