From d0fd69fbbc2ef6758512b316634e3480a24211a5 Mon Sep 17 00:00:00 2001 From: vadimsolovey Date: Thu, 1 Oct 2026 13:28:20 +0300 Subject: [PATCH] feat: dci commands --search finds the command for a task in plain words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dci commands --search ""` ranks the command catalog against a plain-words query so an agent (or a human) finds the right command without loading the ~270-entry catalog into context. The index is built on the fly from the catalog entries themselves — name, curated keywords, summary, examples, argument and flag text, notes, related — with weighted keyword matching, prefix matching in place of stemming, a small synonym table, and coverage scaling so an entry that matches every query word outranks one that nails a single word. Beta commands are always searchable, marked with their stage and early-access note. Agent mode (or --json) gets a compact envelope: query, ranked results with the matched words, destructive flag, one example each, and a next-step hint. Humans get a short text listing. Command docs gain an optional `keywords:` list, indexed at the same weight as the command name and exposed additively in the catalog JSON, for commands whose spec summary is too terse to be found ("Run a query"). Tagged query, status, login, validate, customer-context show, and budgets-at-risk. The agent learns about search on first contact: the one-time onboarding hint leads with it, and the root help, `dci docs`, the commands help, the README, and the embedded skill all mention it. Co-Authored-By: Claude Fable 5.1 --- COMMAND-DOCS-SPEC.md | 6 + README.md | 3 + command-docs/budgets-at-risk.yaml | 2 + command-docs/commands.yaml | 4 + command-docs/customer-context-show.yaml | 2 + command-docs/login.yaml | 2 + command-docs/query.yaml | 21 ++ command-docs/status.yaml | 2 + command-docs/validate.yaml | 2 + command_catalog.go | 24 +- command_docs.go | 6 + command_search.go | 465 ++++++++++++++++++++++++ command_search_test.go | 308 ++++++++++++++++ docs_command.go | 1 + docs_command_test.go | 1 + main.go | 5 +- skills/dci-cli/SKILL.md | 5 +- skills/dci-cli/references/examples.md | 3 + 18 files changed, 855 insertions(+), 7 deletions(-) create mode 100644 command_search.go create mode 100644 command_search_test.go diff --git a/COMMAND-DOCS-SPEC.md b/COMMAND-DOCS-SPEC.md index fe9aa8b..aa00325 100644 --- a/COMMAND-DOCS-SPEC.md +++ b/COMMAND-DOCS-SPEC.md @@ -162,6 +162,12 @@ notes: | # Optional. Defaults to the other commands in the same group. related: [get-anomaly, list-anomalies, anomalies-recent] + +# Optional. Plain words a user reaches for that neither the command name nor +# the spec summary contains. They feed `dci commands --search` at the same +# weight as the command name and are not rendered in --help or on the web. +# Add them when a search for the command's obvious task misses it. +keywords: [spike, unusual spend, review, resolve] ``` Rules: diff --git a/README.md b/README.md index 4a8f779..32ce1f3 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ dci --help # Get help for a specific command dci list-budgets --help +# Find the command for a task described in plain words +dci commands --search "budgets about to overspend" + # Get the machine-readable command catalog dci commands --json ``` diff --git a/command-docs/budgets-at-risk.yaml b/command-docs/budgets-at-risk.yaml index 1ada61a..8b644b8 100644 --- a/command-docs/budgets-at-risk.yaml +++ b/command-docs/budgets-at-risk.yaml @@ -8,4 +8,6 @@ examples: - description: Just the first five. command: "dci budgets-at-risk --max-results 5" +keywords: [overspend, breach, over budget, forecast, projected] + related: [list-budgets, get-budget] diff --git a/command-docs/commands.yaml b/command-docs/commands.yaml index 2abbb1c..af51a25 100644 --- a/command-docs/commands.yaml +++ b/command-docs/commands.yaml @@ -1,6 +1,10 @@ command: commands examples: + - description: Find the command for a task described in plain words — the best matches, with a curated example each. + command: 'dci commands --search "budgets about to overspend"' + - description: The same search as JSON (what agents get by default), with the matched words and a next-step hint. + command: 'dci commands --search "delete a report" --json' - description: The machine-readable catalog of every command, with arguments, flags, and curated examples. command: "dci commands --json" - description: Include beta commands (invoked as dci beta ). diff --git a/command-docs/customer-context-show.yaml b/command-docs/customer-context-show.yaml index ae58b6e..832dd50 100644 --- a/command-docs/customer-context-show.yaml +++ b/command-docs/customer-context-show.yaml @@ -3,3 +3,5 @@ command: customer-context show examples: - description: Show the persisted default customer context. command: "dci customer-context show" + +keywords: [current customer, which customer, active customer, tenant] diff --git a/command-docs/login.yaml b/command-docs/login.yaml index d273518..e08cec2 100644 --- a/command-docs/login.yaml +++ b/command-docs/login.yaml @@ -3,3 +3,5 @@ command: login examples: - description: Sign in through the DoiT Console in your browser. command: "dci login" + +keywords: [sign in, log in, authenticate, browser, oauth, session] diff --git a/command-docs/query.yaml b/command-docs/query.yaml index fd3e0a2..6da70a0 100644 --- a/command-docs/query.yaml +++ b/command-docs/query.yaml @@ -12,4 +12,25 @@ examples: - description: A stacked chart under the table (human mode only). command: "dci query --chart < query.json" +keywords: + [ + spend, + cost, + usage, + how much, + breakdown, + by service, + by project, + by label, + last month, + trend, + analytics, + cloud analytics, + report config, + gcp, + aws, + azure, + cloud provider, + ] + related: [get-report, get-report-config, update-report] diff --git a/command-docs/status.yaml b/command-docs/status.yaml index 3e8c894..0905346 100644 --- a/command-docs/status.yaml +++ b/command-docs/status.yaml @@ -5,3 +5,5 @@ examples: command: "dci status" - description: Force agent mode to see the machine-oriented output. command: "dci status --agent" + +keywords: [logged in, signed in, who am i, current customer, which customer, active context, agent mode, configuration] diff --git a/command-docs/validate.yaml b/command-docs/validate.yaml index 0639788..d05705c 100644 --- a/command-docs/validate.yaml +++ b/command-docs/validate.yaml @@ -11,6 +11,8 @@ examples: - description: As JSON, for a CI preflight step. command: "dci validate --output json" +keywords: [logged in, who am i, whoami, access check, permissions, credentials, api key] + notes: | :::tip Authentication vs. access `dci login` proves authentication; `dci validate` confirms both identity and access under diff --git a/command_catalog.go b/command_catalog.go index 48b5535..2f6ad6f 100644 --- a/command_catalog.go +++ b/command_catalog.go @@ -54,6 +54,8 @@ type commandCatalogEntry struct { Examples []commandCatalogExample `json:"examples,omitempty"` Notes string `json:"notes,omitempty"` Related []string `json:"related,omitempty"` + // Keywords are the curated search words from the command doc; additive. + Keywords []string `json:"keywords,omitempty"` } type commandCatalogExample struct { @@ -91,8 +93,12 @@ var requiredOperationFlags = map[string]map[string]bool{ func registerCommandCatalog() { command := &cobra.Command{ Use: "commands", - Short: "Print the machine-readable command catalog", - Args: cobra.NoArgs, + Short: "Print the machine-readable command catalog, or search it in plain words", + Long: "Print the machine-readable command catalog (every command with its arguments, flags, " + + "and curated examples), or pass --search to find the command for a task described in " + + "plain words — the catalog is large, so search first and read one command's --help " + + "rather than loading the whole catalog.", + Args: cobra.NoArgs, RunE: func(command *cobra.Command, args []string) error { apiCommand := findDCICommand() if apiCommand == nil { @@ -103,7 +109,12 @@ func registerCommandCatalog() { return fmt.Errorf("load DCI command catalog: %w", err) } catalog := buildCommandCatalog(api) - if includeBeta, _ := command.Flags().GetBool("beta"); includeBeta { + query, searching := commandSearchRequested(command) + includeBeta, _ := command.Flags().GetBool("beta") + // Search always covers beta commands: a hit it hides is a command + // the caller never learns exists. Results carry stage: beta and the + // early-access note, so a gated hit is still an informed one. + if includeBeta || searching { betaAPI, err := loadBetaAPI() if err != nil { return fmt.Errorf("load beta command catalog: %w", err) @@ -117,13 +128,17 @@ func registerCommandCatalog() { return strings.Join(catalog.Commands[i].Path, " ") < strings.Join(catalog.Commands[j].Path, " ") }) } + if searching { + return runCommandSearch(command, catalog.Commands, query, os.Stdout) + } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(catalog) }, } - command.Flags().Bool("json", false, "Emit JSON (the catalog's stable wire format)") + command.Flags().Bool("json", false, "Emit JSON (the catalog's stable wire format; with --search, the ranked results as JSON even at a terminal)") command.Flags().Bool("beta", false, "Include beta commands (invoked as dci beta ; entries carry stage: beta)") + registerCommandSearchFlags(command) cli.Root.AddCommand(command) } @@ -262,6 +277,7 @@ func applyCommandDocToCatalogEntry(entry *commandCatalogEntry) { } entry.Notes = doc.Notes entry.Related = doc.Related + entry.Keywords = doc.Keywords positional := 0 for _, argument := range entry.Arguments { if argument.Location == "path" { diff --git a/command_docs.go b/command_docs.go index 18fa042..a5aab89 100644 --- a/command_docs.go +++ b/command_docs.go @@ -46,6 +46,12 @@ type commandDoc struct { // replaces omni's former command-notes/.mdx overlays. Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` Related []string `yaml:"related,omitempty" json:"related,omitempty"` + // Keywords are the plain words a user reaches for that the command name + // and spec summary do not contain ("spend", "how much", "logged in"). + // They feed `dci commands --search` (command_search.go) at the same + // weight as the command name, and nothing else: --help and the Help + // Center page do not render them. + Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"` } type commandDocExample struct { diff --git a/command_search.go b/command_search.go new file mode 100644 index 0000000..8b260f3 --- /dev/null +++ b/command_search.go @@ -0,0 +1,465 @@ +// Chapter: natural-language command discovery. `dci commands --search ""` ranks the command catalog against a plain-words query so +// an agent (or a human) can find the right command without loading the whole +// catalog — the ~270-entry JSON that `dci commands --json` prints — into +// context. The index is built on the fly from the same catalog entries the +// JSON exposes (names, curated keywords, summaries, curated examples, +// argument and flag text, Help Center notes, related commands), so it needs +// no model, no network beyond the spec fetch the catalog already does, and +// nothing to keep in sync: a new command or a curated doc is searchable the +// moment it exists. When a spec summary is too terse to be found ("Run a +// query"), the fix is a `keywords:` list in the command's doc file +// (command-docs/.yaml), not a rule here. +// +// Ranking is deliberately simple and deterministic: weighted keyword match +// with a small synonym table and prefix matching standing in for stemming. +// Results carry the matched words so a caller can tell a strong hit from a +// coincidental one. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math" + "slices" + "sort" + "strings" + "unicode" + + "github.com/spf13/cobra" +) + +const ( + commandSearchDefaultLimit = 10 + commandSearchMaxLimit = 50 +) + +// commandSearchResult is one ranked hit. Command is the path as typed after +// `dci` ("list-budgets", "beta run-report-async"), so it pastes straight into +// the next invocation. +type commandSearchResult struct { + Command string `json:"command"` + Summary string `json:"summary,omitempty"` + Score float64 `json:"score"` + Matched []string `json:"matched"` + Destructive bool `json:"destructive"` + Stage string `json:"stage,omitempty"` + EarlyAccess string `json:"early_access,omitempty"` + Example string `json:"example,omitempty"` + Related []string `json:"related,omitempty"` +} + +// commandSearchResponse is the JSON envelope for `--search`. Version is the +// catalog schema version: the results are a projection of catalog entries. +type commandSearchResponse struct { + Version string `json:"version"` + CLIVersion string `json:"cli_version"` + Query string `json:"query"` + Results []commandSearchResult `json:"results"` + Hint string `json:"hint"` +} + +const commandSearchNextStepHint = "Run `dci --help` for arguments and flags, " + + "or `dci commands --json` for the full catalog." + +const commandSearchNoMatchHint = "No command matched. Try other words for the resource or action " + + "(e.g. \"budget\", \"anomaly\", \"report\", \"create\", \"delete\"), " + + "or browse the full catalog with `dci commands --json`." + +// commandSearchFieldWeights ranks where a query word was found. A hit in the +// command name outranks one in a flag description by design: names are the +// resource-and-verb vocabulary the API itself uses. +var commandSearchFieldWeights = map[string]float64{ + "name": 6.0, + "keywords": 6.0, + "summary": 3.0, + "examples": 1.5, + "arguments": 0.75, + "notes": 0.25, + "related": 0.25, +} + +// commandSearchPrefixScore is the credit a prefix match earns relative to an +// exact one ("anomal" against "anomaly", "report" against "reports"). +const commandSearchPrefixScore = 0.6 + +// commandSearchSynonymScore scales a hit reached through the synonym table. +const commandSearchSynonymScore = 0.8 + +// commandSearchMinPrefixLength keeps short query words from matching every +// token that happens to start with them ("re" against "report", "remove"). +const commandSearchMinPrefixLength = 4 + +// commandSearchStopwords are query words that carry no command meaning. +var commandSearchStopwords = map[string]bool{ + "a": true, "an": true, "the": true, "to": true, "of": true, "for": true, + "in": true, "on": true, "my": true, "me": true, "i": true, "is": true, + "are": true, "what": true, "which": true, "how": true, "do": true, "did": true, + "does": true, "can": true, "want": true, "need": true, "all": true, + "with": true, "and": true, "or": true, "by": true, "from": true, + "at": true, "this": true, "that": true, "it": true, "its": true, "am": true, + "dci": true, "command": true, "commands": true, "cli": true, "please": true, + "should": true, "would": true, "could": true, "use": true, "using": true, + "via": true, "into": true, "about": true, "some": true, "any": true, + "be": true, "our": true, "your": true, "we": true, "you": true, +} + +// commandSearchSynonyms maps a normalized query word to the catalog +// vocabulary it most often stands for. Values are already normalized. +var commandSearchSynonyms = map[string][]string{ + // Verbs: everyday English → the API's operation verbs. + "spend": {"cost"}, "spending": {"cost"}, "cost": {"spend"}, "charge": {"cost", "invoice"}, + "bill": {"invoice", "cost"}, "billing": {"invoice", "cost"}, + "remove": {"delete"}, "destroy": {"delete"}, "drop": {"delete"}, + "show": {"get", "list"}, "view": {"get", "list"}, "fetch": {"get"}, "describe": {"get"}, + "detail": {"get"}, "read": {"get"}, "lookup": {"get", "search"}, + "find": {"list", "search"}, "browse": {"list"}, "enumerate": {"list"}, + "make": {"create"}, "add": {"create"}, "new": {"create"}, + "edit": {"update"}, "change": {"update"}, "modify": {"update"}, "rename": {"update"}, + "upload": {"ingest", "import"}, "download": {"export"}, "dump": {"export"}, + "explain": {"explanation", "explainer"}, "why": {"explanation", "explainer"}, + "recommend": {"recommendation"}, "suggestion": {"recommendation"}, + // Resources: common aliases → the catalog's nouns. + "warning": {"alert"}, "notification": {"alert"}, "notify": {"alert"}, + "spike": {"anomaly"}, "unusual": {"anomaly"}, "unexpected": {"anomaly"}, + "forecast": {"budget"}, "breach": {"budget", "risk"}, "overspend": {"budget", "risk"}, + "tenant": {"customer"}, "org": {"customer", "organization"}, + "saving": {"recommendation", "optimization"}, "savings": {"recommendation", "optimization"}, + "optimize": {"recommendation", "optimization"}, "optimise": {"recommendation", "optimization"}, + "waste": {"recommendation", "optimization"}, "cheaper": {"recommendation", "optimization"}, + "workflow": {"cloudflow", "flow"}, "automation": {"cloudflow", "flow"}, + "signin": {"login"}, "authenticate": {"login", "validate"}, "auth": {"login", "validate"}, + "logged": {"login", "status", "validate"}, "signed": {"login", "status", "validate"}, + "authenticated": {"login", "status", "validate"}, "whoami": {"status", "validate"}, + "credential": {"login", "service", "account"}, "apikey": {"token", "service", "account"}, + "key": {"token"}, "secret": {"token"}, + "current": {"context", "status"}, "active": {"context", "status"}, "selected": {"context", "status"}, + "doc": {"docs"}, "documentation": {"docs"}, + "upgrade": {"update"}, "outdated": {"update"}, + "people": {"user"}, "member": {"user"}, "teammate": {"user"}, + "analytics": {"report", "query"}, "analysis": {"report", "query"}, "breakdown": {"report", "query"}, + "tag": {"label"}, "chargeback": {"allocation"}, "showback": {"allocation"}, + "commitment": {"commitment", "cud", "reserved"}, "reservation": {"reserved", "commitment"}, + "subscription": {"contract"}, + "region": {"geographic", "region"}, "country": {"geographic", "country"}, "geo": {"geographic"}, + "permission": {"role", "permission"}, "rbac": {"role"}, + "google": {"gcp"}, "amazon": {"aws"}, "microsoft": {"azure"}, + "outage": {"incident"}, "downtime": {"incident"}, + "architecture": {"diagram"}, "topology": {"diagram"}, + "note": {"annotation"}, + "background": {"async", "operation"}, "poll": {"async", "operation"}, + "directory": {"folder"}, "dashboard": {"widget", "report"}, + "console": {"open"}, "browser": {"open"}, + "agent": {"skill"}, "llm": {"skill"}, +} + +// registerCommandSearchFlags adds --search and --limit to the `commands` +// command (command_catalog.go owns the command itself). +func registerCommandSearchFlags(command *cobra.Command) { + command.Flags().String("search", "", "Find commands for a task described in plain words (e.g. --search \"budgets about to overspend\"); prints the best matches instead of the full catalog") + command.Flags().Int("limit", commandSearchDefaultLimit, "Maximum number of --search results (1-50)") +} + +// commandSearchRequested reports whether this `commands` invocation is a +// search, and returns the query. +func commandSearchRequested(command *cobra.Command) (string, bool) { + flag := command.Flags().Lookup("search") + if flag == nil || !flag.Changed { + return "", false + } + return flag.Value.String(), true +} + +// runCommandSearch ranks entries against query and writes the result to out, +// as JSON (agent mode, or --json) or as a short text listing. +func runCommandSearch(command *cobra.Command, entries []commandCatalogEntry, query string, out io.Writer) error { + limit, _ := command.Flags().GetInt("limit") + if limit < 1 || limit > commandSearchMaxLimit { + return fmt.Errorf("--limit must be between 1 and %d", commandSearchMaxLimit) + } + results, err := searchCommandCatalog(entries, query, limit) + if err != nil { + return err + } + asJSON, _ := command.Flags().GetBool("json") + if asJSON || agentMode { + response := commandSearchResponse{ + Version: catalogSchemaVersion, + CLIVersion: version, + Query: query, + Results: results, + Hint: commandSearchNextStepHint, + } + if len(results) == 0 { + response.Hint = commandSearchNoMatchHint + } + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + // Examples carry "" placeholders; the HTML-safe \u003c + // form is noise for the agent reading them. + encoder.SetEscapeHTML(false) + return encoder.Encode(response) + } + renderCommandSearchText(out, query, results) + return nil +} + +// renderCommandSearchText prints results for a human: name, summary, and +// the first curated example when there is one. +func renderCommandSearchText(out io.Writer, query string, results []commandSearchResult) { + if len(results) == 0 { + fmt.Fprintf(out, "No commands match %q.\n%s\n", query, strings.ReplaceAll(commandSearchNoMatchHint, "No command matched. ", "")) + return + } + width := 0 + for _, result := range results { + if len(result.Command) > width { + width = len(result.Command) + } + } + for _, result := range results { + summary := result.Summary + if result.Destructive { + summary += " (destructive)" + } + if result.Stage != "" { + summary += " [" + result.Stage + "]" + } + fmt.Fprintf(out, "%-*s %s\n", width, result.Command, strings.TrimSpace(summary)) + if result.Example != "" { + fmt.Fprintf(out, "%-*s e.g. %s\n", width, "", result.Example) + } + } + fmt.Fprintf(out, "\n%s\n", commandSearchNextStepHint) +} + +// searchCommandCatalog scores every entry against query and returns the top +// limit hits, best first. An empty or stopword-only query is an error. +func searchCommandCatalog(entries []commandCatalogEntry, query string, limit int) ([]commandSearchResult, error) { + terms := commandSearchQueryTerms(query) + if len(terms) == 0 { + return nil, errors.New("--search needs at least one word describing the task (e.g. --search \"budgets at risk\")") + } + results := make([]commandSearchResult, 0, limit) + for _, entry := range entries { + score, matched := scoreCommandEntry(entry, terms) + if score <= 0 { + continue + } + result := commandSearchResult{ + Command: strings.Join(entry.Path, " "), + Summary: entry.Summary, + Score: math.Round(score*100) / 100, + Matched: matched, + Destructive: entry.Destructive, + Stage: entry.Stage, + EarlyAccess: entry.EarlyAccess, + Related: entry.Related, + } + if len(entry.Examples) > 0 { + result.Example = entry.Examples[0].Command + } + results = append(results, result) + } + sort.SliceStable(results, func(i, j int) bool { + if results[i].Score != results[j].Score { + return results[i].Score > results[j].Score + } + // Shorter names first on a tie: `list-budgets` before + // `list-budget-suggestions` when both match "budgets" equally. + if len(results[i].Command) != len(results[j].Command) { + return len(results[i].Command) < len(results[j].Command) + } + return results[i].Command < results[j].Command + }) + if len(results) > limit { + results = results[:limit] + } + return results, nil +} + +// commandSearchTerm is one normalized query word plus the synonyms it +// expands to. +type commandSearchTerm struct { + word string + synonyms []string +} + +// commandSearchQueryTerms normalizes a query into search terms: tokenized, +// lower-cased, stopwords dropped, lightly stemmed, deduplicated, with +// synonyms attached. A synonym that is itself another query word is dropped: +// "budgets about to overspend" must not let "overspend" re-score "budget" on +// every budget command — its distinctive expansion is "risk". +func commandSearchQueryTerms(query string) []commandSearchTerm { + seen := map[string]bool{} + terms := make([]commandSearchTerm, 0) + for _, raw := range commandSearchTokenize(query) { + if commandSearchStopwords[raw] { + continue + } + word := commandSearchStem(raw) + if word == "" || seen[word] { + continue + } + seen[word] = true + term := commandSearchTerm{word: word} + for _, source := range []string{raw, word} { + for _, synonym := range commandSearchSynonyms[source] { + synonym = commandSearchStem(synonym) + if synonym != word && !slices.Contains(term.synonyms, synonym) { + term.synonyms = append(term.synonyms, synonym) + } + } + } + terms = append(terms, term) + } + for index := range terms { + kept := terms[index].synonyms[:0] + for _, synonym := range terms[index].synonyms { + if !seen[synonym] { + kept = append(kept, synonym) + } + } + terms[index].synonyms = kept + } + return terms +} + +// commandSearchTokenize splits text into lower-case word tokens on anything +// that is not a letter or digit, and additionally at camelCase boundaries +// so "riskStatus" and "atRisk" yield "risk", "status", "at". +func commandSearchTokenize(text string) []string { + tokens := make([]string, 0, 16) + var current strings.Builder + flush := func() { + if current.Len() >= 2 { + tokens = append(tokens, strings.ToLower(current.String())) + } + current.Reset() + } + var previous rune + for _, r := range text { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + if unicode.IsUpper(r) && previous != 0 && unicode.IsLower(previous) { + flush() + } + current.WriteRune(r) + default: + flush() + } + previous = r + } + flush() + return tokens +} + +// commandSearchStem trims the plural and gerund endings that separate a +// query word from the catalog's vocabulary ("budgets" → "budget", +// "anomalies" → "anomaly", "listing" → "list"). It is intentionally crude: +// prefix matching in scoreTokens covers what it misses. +func commandSearchStem(word string) string { + word = strings.TrimSuffix(word, "'s") + switch { + case len(word) > 4 && strings.HasSuffix(word, "ies"): + return word[:len(word)-3] + "y" + case len(word) > 5 && strings.HasSuffix(word, "ing"): + return word[:len(word)-3] + case len(word) > 3 && strings.HasSuffix(word, "s") && !strings.HasSuffix(word, "ss"): + return word[:len(word)-1] + } + return word +} + +// commandSearchGenericFlags are present on every API command through the +// agent contract; indexing them would make every query about output or +// confirmation match every command equally. +var commandSearchGenericFlags = map[string]bool{ + "--dry-run": true, "--output": true, "--yes": true, "--fields": true, "--exclude": true, + "--help": true, "--json": true, +} + +// commandEntryFields assembles the weighted text fields of one entry, each +// already tokenized and stemmed. +func commandEntryFields(entry commandCatalogEntry) map[string][]string { + fields := map[string][]string{} + add := func(field, text string) { + for _, token := range commandSearchTokenize(text) { + fields[field] = append(fields[field], commandSearchStem(token)) + } + } + add("name", strings.Join(entry.Path, " ")) + add("keywords", strings.Join(entry.Keywords, " ")) + add("summary", entry.Summary) + for _, example := range entry.Examples { + add("examples", example.Description) + add("examples", example.Command) + } + for _, argument := range entry.Arguments { + add("arguments", argument.Name) + add("arguments", argument.Description) + } + for _, flag := range entry.Flags { + if commandSearchGenericFlags[flag.Name] { + continue + } + add("arguments", flag.Name) + add("arguments", flag.Description) + } + add("notes", entry.Notes) + add("related", strings.Join(entry.Related, " ")) + return fields +} + +// scoreCommandEntry returns the entry's relevance to terms and the query +// words that matched. Each term earns, per field, the field's weight scaled +// by its best match in that field (exact 1.0, prefix 0.6, synonym ×0.8); +// the sum is then scaled by how much of the query matched, so an entry that +// covers every word outranks one that nails a single word. +func scoreCommandEntry(entry commandCatalogEntry, terms []commandSearchTerm) (float64, []string) { + fields := commandEntryFields(entry) + total := 0.0 + matched := make([]string, 0, len(terms)) + for _, term := range terms { + termScore := 0.0 + for field, tokens := range fields { + best := scoreTokens(term.word, tokens) + for _, synonym := range term.synonyms { + if synonymScore := scoreTokens(synonym, tokens) * commandSearchSynonymScore; synonymScore > best { + best = synonymScore + } + } + termScore += commandSearchFieldWeights[field] * best + } + if termScore > 0 { + matched = append(matched, term.word) + total += termScore + } + } + if total == 0 { + return 0, nil + } + coverage := float64(len(matched)) / float64(len(terms)) + return total * (0.5 + coverage), matched +} + +// scoreTokens is the best match of word against tokens: 1 for an exact +// token, commandSearchPrefixScore when one is a prefix of the other and the +// shorter side is long enough to mean something, 0 otherwise. +func scoreTokens(word string, tokens []string) float64 { + best := 0.0 + for _, token := range tokens { + switch { + case token == word: + return 1 + case len(word) >= commandSearchMinPrefixLength && strings.HasPrefix(token, word), + len(token) >= commandSearchMinPrefixLength && strings.HasPrefix(word, token): + if commandSearchPrefixScore > best { + best = commandSearchPrefixScore + } + } + } + return best +} diff --git a/command_search_test.go b/command_search_test.go new file mode 100644 index 0000000..2b6e852 --- /dev/null +++ b/command_search_test.go @@ -0,0 +1,308 @@ +package main + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestCommandSearchTokenizeSplitsPunctuationAndCamelCase(t *testing.T) { + got := commandSearchTokenize("dci list-budgets --filter riskStatus:atRisk, owner's \"Team Backyard\"") + want := []string{"dci", "list", "budgets", "filter", "risk", "status", "at", "risk", "owner", "team", "backyard"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("tokens = %v, want %v", got, want) + } +} + +func TestCommandSearchStem(t *testing.T) { + for word, want := range map[string]string{ + "budgets": "budget", + "anomalies": "anomaly", + "listing": "list", + "invoices": "invoice", + "status": "statu", // crude on purpose; prefix matching recovers it + "access": "access", + "gcp": "gcp", + "owner's": "owner", + } { + if got := commandSearchStem(word); got != want { + t.Errorf("stem(%q) = %q, want %q", word, got, want) + } + } +} + +func TestCommandSearchQueryTermsDropStopwordsAndDuplicateSynonyms(t *testing.T) { + terms := commandSearchQueryTerms("What budgets are about to overspend?") + if len(terms) != 2 || terms[0].word != "budget" || terms[1].word != "overspend" { + t.Fatalf("terms = %+v, want budget and overspend", terms) + } + if len(terms[0].synonyms) != 0 { + t.Errorf("budget synonyms = %v, want none", terms[0].synonyms) + } + // "overspend" expands to budget and risk; budget is already a query word + // and must not score every budget command a second time. + if !reflect.DeepEqual(terms[1].synonyms, []string{"risk"}) { + t.Errorf("overspend synonyms = %v, want [risk]", terms[1].synonyms) + } + + if terms := commandSearchQueryTerms("the a to of"); len(terms) != 0 { + t.Errorf("stopword-only query produced %+v", terms) + } +} + +// searchFixtureCatalog mirrors the shape of real entries: a list command with +// rich notes and flags, a question command whose name carries the task, a +// terse spec summary rescued by curated keywords, and a destructive sibling. +func searchFixtureCatalog() []commandCatalogEntry { + return []commandCatalogEntry{ + { + Path: []string{"list-budgets"}, + Summary: "List budgets", + Flags: []commandCatalogFlag{ + {Name: "--filter", Description: "Filter by owner, budgetName, lastModified, or riskStatus (atRisk, onTrack, unknown)"}, + {Name: "--output", Description: "Select output"}, + }, + Examples: []commandCatalogExample{ + {Description: "Your budgets with amount, spend to date, and risk.", Command: "dci list-budgets"}, + {Description: "Budgets projected to breach, earliest first.", Command: "dci list-budgets --filter riskStatus:atRisk"}, + }, + Notes: "The CLI default view shows budget name, owner, amount, spend to date, risk, and updated.", + Related: []string{"budgets-at-risk", "get-budget", "delete-budget"}, + }, + { + Path: []string{"budgets-at-risk"}, + Summary: "List budgets projected to breach their configured amount before the period ends", + Examples: []commandCatalogExample{{Description: "Budgets at risk, earliest breach first.", Command: "dci budgets-at-risk"}}, + Keywords: []string{"overspend", "breach", "over budget"}, + Related: []string{"list-budgets", "get-budget"}, + }, + { + Path: []string{"delete-budget"}, + Summary: "Delete a budget", + Destructive: true, + Examples: []commandCatalogExample{{Description: "Preview the deletion.", Command: "dci delete-budget \"Team Backyard\" --dry-run"}}, + }, + { + Path: []string{"query"}, + Summary: "Run a query", + Examples: []commandCatalogExample{{Description: "Run an ad-hoc Cloud Analytics query from a JSON config.", Command: "dci query < query.json"}}, + Keywords: []string{"spend", "cost", "how much", "by service", "last month"}, + }, + { + Path: []string{"get-cloud-diagram-cost-snapshot"}, + Summary: "Get diagram cost snapshot", + Examples: []commandCatalogExample{{Description: "Cost snapshot for a layer.", Command: "dci get-cloud-diagram-cost-snapshot "}}, + }, + { + Path: []string{"beta", "run-report"}, + Summary: "Run a saved report asynchronously", + Stage: "beta", + EarlyAccess: "Ask your account manager for access.", + }, + {Path: []string{"status"}, Summary: "Show DoiT CLI configuration and active context", Keywords: []string{"logged in", "who am i"}}, + } +} + +func TestSearchCommandCatalogRanking(t *testing.T) { + entries := searchFixtureCatalog() + cases := map[string]string{ + "budgets about to overspend": "budgets-at-risk", + "delete a budget": "delete-budget", + "remove a budget": "delete-budget", // synonym + "how much did we spend last month": "query", // curated keywords beat a name hit on "cost" + "run a saved report in background": "beta run-report", + "am I logged in": "status", + "budgt": "", // typo: no prefix of length 4 matches + "list my budgets": "list-budgets", + } + for query, want := range cases { + results, err := searchCommandCatalog(entries, query, 5) + if err != nil { + t.Fatalf("%q: %v", query, err) + } + if want == "" { + if len(results) != 0 { + t.Errorf("%q: got %+v, want no results", query, results) + } + continue + } + if len(results) == 0 || results[0].Command != want { + got := make([]string, 0, len(results)) + for _, result := range results { + got = append(got, result.Command) + } + t.Errorf("%q: top results %v, want %q first", query, got, want) + } + } +} + +func TestSearchCommandCatalogResultShape(t *testing.T) { + results, err := searchCommandCatalog(searchFixtureCatalog(), "overspending budgets", 2) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("limit not applied: %d results", len(results)) + } + top := results[0] + if top.Command != "budgets-at-risk" || top.Example != "dci budgets-at-risk" || top.Score <= results[1].Score { + t.Fatalf("top = %+v", top) + } + if !reflect.DeepEqual(top.Matched, []string{"overspend", "budget"}) { + t.Errorf("matched = %v", top.Matched) + } + if !reflect.DeepEqual(top.Related, []string{"list-budgets", "get-budget"}) { + t.Errorf("related = %v", top.Related) + } + + beta, err := searchCommandCatalog(searchFixtureCatalog(), "run report", 1) + if err != nil { + t.Fatal(err) + } + if beta[0].Stage != "beta" || beta[0].EarlyAccess == "" || beta[0].Example != "" { + t.Errorf("beta result = %+v, want stage, early_access, no example", beta[0]) + } +} + +func TestSearchCommandCatalogRejectsEmptyQuery(t *testing.T) { + for _, query := range []string{"", " ", "the a to"} { + if _, err := searchCommandCatalog(searchFixtureCatalog(), query, 5); err == nil { + t.Errorf("query %q accepted", query) + } + } +} + +func newCommandSearchTestCommand() *cobra.Command { + command := &cobra.Command{Use: "commands", Args: cobra.NoArgs, Run: func(*cobra.Command, []string) {}} + command.Flags().Bool("json", false, "") + command.Flags().Bool("beta", false, "") + registerCommandSearchFlags(command) + return command +} + +func TestRunCommandSearchEmitsJSONInAgentMode(t *testing.T) { + oldAgentMode := agentMode + agentMode = true + t.Cleanup(func() { agentMode = oldAgentMode }) + + command := newCommandSearchTestCommand() + if err := command.Flags().Set("search", "delete a budget"); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runCommandSearch(command, searchFixtureCatalog(), "delete a budget", &out); err != nil { + t.Fatal(err) + } + var response commandSearchResponse + if err := json.Unmarshal(out.Bytes(), &response); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out.String()) + } + if response.Version != catalogSchemaVersion || response.Query != "delete a budget" || response.Hint != commandSearchNextStepHint { + t.Fatalf("envelope = %+v", response) + } + if len(response.Results) == 0 || response.Results[0].Command != "delete-budget" || !response.Results[0].Destructive { + t.Fatalf("results = %+v", response.Results) + } +} + +func TestRunCommandSearchJSONNoMatchCarriesHint(t *testing.T) { + command := newCommandSearchTestCommand() + if err := command.Flags().Set("json", "true"); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runCommandSearch(command, searchFixtureCatalog(), "xylophone", &out); err != nil { + t.Fatal(err) + } + var response commandSearchResponse + if err := json.Unmarshal(out.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Results) != 0 || response.Hint != commandSearchNoMatchHint { + t.Fatalf("response = %+v", response) + } + if !strings.Contains(out.String(), `"results": []`) { + t.Errorf("results must be an empty array, not null:\n%s", out.String()) + } +} + +func TestRunCommandSearchTextForHumans(t *testing.T) { + oldAgentMode := agentMode + agentMode = false + t.Cleanup(func() { agentMode = oldAgentMode }) + + command := newCommandSearchTestCommand() + var out bytes.Buffer + if err := runCommandSearch(command, searchFixtureCatalog(), "budgets about to overspend", &out); err != nil { + t.Fatal(err) + } + text := out.String() + for _, want := range []string{ + "budgets-at-risk", + "e.g. dci budgets-at-risk", + "delete-budget", + "(destructive)", + commandSearchNextStepHint, + } { + if !strings.Contains(text, want) { + t.Errorf("text output missing %q:\n%s", want, text) + } + } + if strings.Contains(text, "{") { + t.Errorf("human output looks like JSON:\n%s", text) + } + + out.Reset() + if err := runCommandSearch(command, searchFixtureCatalog(), "xylophone", &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "No commands match") || !strings.Contains(out.String(), "dci commands --json") { + t.Errorf("no-match text = %q", out.String()) + } +} + +func TestRunCommandSearchRejectsBadLimit(t *testing.T) { + for _, limit := range []string{"0", "51", "-3"} { + command := newCommandSearchTestCommand() + if err := command.Flags().Set("limit", limit); err != nil { + t.Fatal(err) + } + if err := runCommandSearch(command, searchFixtureCatalog(), "budget", &bytes.Buffer{}); err == nil { + t.Errorf("--limit %s accepted", limit) + } + } +} + +func TestCommandSearchRequested(t *testing.T) { + command := newCommandSearchTestCommand() + if _, ok := commandSearchRequested(command); ok { + t.Fatal("search reported without --search") + } + if err := command.Flags().Set("search", "budgets"); err != nil { + t.Fatal(err) + } + if query, ok := commandSearchRequested(command); !ok || query != "budgets" { + t.Fatalf("query = %q, ok = %v", query, ok) + } +} + +func TestCatalogEntryCarriesCuratedKeywords(t *testing.T) { + entry := commandCatalogEntry{Path: []string{"query"}} + applyCommandDocToCatalogEntry(&entry) + if len(entry.Keywords) == 0 { + t.Fatal("query doc keywords not copied onto the catalog entry") + } + found := false + for _, keyword := range entry.Keywords { + if keyword == "spend" { + found = true + } + } + if !found { + t.Errorf("query keywords = %v, want to include spend", entry.Keywords) + } +} diff --git a/docs_command.go b/docs_command.go index b297d32..d1c2d85 100644 --- a/docs_command.go +++ b/docs_command.go @@ -15,6 +15,7 @@ var docsEntryPoints = [][2]string{ {"Agent documentation index", "https://help.doit.com/llms.txt"}, {"Agent documentation corpus", "https://help.doit.com/llms-full.txt"}, {"Embedded agent guidance", "dci skill "}, + {"Find a command for a task", "dci commands --search \"\""}, {"Machine-readable commands", "dci commands --json"}, } diff --git a/docs_command_test.go b/docs_command_test.go index ace85dc..0b81a23 100644 --- a/docs_command_test.go +++ b/docs_command_test.go @@ -24,6 +24,7 @@ func TestDocsCommandListsHumanAndAgentEntryPoints(t *testing.T) { "https://help.doit.com/llms.txt", "https://help.doit.com/llms-full.txt", "dci skill", + "dci commands --search", "dci commands --json", } { if !strings.Contains(output.String(), expected) { diff --git a/main.go b/main.go index 0e4b357..5c3e119 100644 --- a/main.go +++ b/main.go @@ -1401,6 +1401,7 @@ func maybeAgentOnboardingHint(configDir string) { return // cannot persist the marker; stay silent rather than repeat forever } fmt.Fprintln(os.Stderr, "Agent mode is active. Useful entry points:") + fmt.Fprintln(os.Stderr, " dci commands --search \"\" find the command for a task (e.g. --search \"budgets about to overspend\")") fmt.Fprintln(os.Stderr, " dci skill install CLI usage guidance for this agent (claude, codex, cursor, gemini, kiro, opencode)") fmt.Fprintln(os.Stderr, " dci commands --json machine-readable command catalog (args, flags, destructive metadata)") fmt.Fprintln(os.Stderr, " dci docs documentation entry points, incl. https://help.doit.com/llms.txt") @@ -1443,13 +1444,15 @@ const dciLongDescription = "Command-line interface for the Cloud Intelligence™ "(ask questions in plain English; /default help restores this screen instead).\n" + "In pipes, scripts, and CI, bare `dci` prints this help.\n\n" + "Documentation: https://help.doit.com/docs/cli or run `dci docs` for every entry point.\n" + - "AI agents: `dci skill ` installs usage guidance; `dci commands --json` prints the machine-readable catalog." + "AI agents: `dci commands --search \"\"` finds the command for a task described in plain words;\n" + + "`dci skill ` installs usage guidance; `dci commands --json` prints the machine-readable catalog." var rootExamples = []string{ " dci (interactive AI session)", " dci status", " dci list-budgets", " dci list-reports --output table", + " dci commands --search \"budgets about to overspend\"", } var apiExamples = []string{ diff --git a/skills/dci-cli/SKILL.md b/skills/dci-cli/SKILL.md index dce1f69..17e0dca 100644 --- a/skills/dci-cli/SKILL.md +++ b/skills/dci-cli/SKILL.md @@ -100,7 +100,8 @@ anatomy, and the flow archetypes. - Prefer env-scoped `DCI_CUSTOMER_CONTEXT= dci ...` over `dci customer-context set` unless the user explicitly wants a persistent local change. - Treat `create-*`, `update-*`, `delete-*`, invite, ingest, and comment-post commands as side-effectful. -- Use `dci commands --json` when you need machine-readable argument, flag, output-shape, authentication, and destructive-operation metadata. +- Use `dci commands --search ""` to find the command for a task (e.g. `--search "budgets about to overspend"`): it returns the best-matching commands with a curated example each, so you never need the full catalog in context. Then read that one command's `--help`. +- Use `dci commands --json` only when you need machine-readable argument, flag, output-shape, authentication, and destructive-operation metadata for the whole surface; it is large. - Run a side-effectful command with `--dry-run` first. Most commands print a local preview without sending a request; commands with an API-native `dryRun` parameter send a simulation request and return an action marked `"dry_run": true`. - Pass `--yes` only after the user has approved a command classified as destructive. Do not set `DCI_CONFIRM_DESTRUCTIVE=1` as a blanket bypass. - A destructive command given a name resolves it first: the confirmation names the true target (e.g. `delete-report targets report "Monthly Spend" ()`), and the agent-mode error envelope carries `resolved: {input, name, id}`. Re-run with the ID from the hint, never the original fuzzy input. @@ -114,7 +115,7 @@ anatomy, and the flow archetypes. - CLI guide: https://help.doit.com/docs/cli (append `.md` to any Help Center URL for plain Markdown, e.g. https://help.doit.com/docs/cli.md) - Machine-readable Help Center index: https://help.doit.com/llms.txt (full corpus: https://help.doit.com/llms-full.txt) - API reference: https://developer.doit.com/ -- From the terminal: `dci docs` prints these entry points; `dci --help` is terse by default (`--help-full` adds the complete request/response schemas); `dci commands --json` is the machine-readable catalog. +- From the terminal: `dci docs` prints these entry points; `dci commands --search ""` finds the command for a task; `dci --help` is terse by default (`--help-full` adds the complete request/response schemas); `dci commands --json` is the machine-readable catalog. ## Reference Map diff --git a/skills/dci-cli/references/examples.md b/skills/dci-cli/references/examples.md index 7131bb9..15e8210 100644 --- a/skills/dci-cli/references/examples.md +++ b/skills/dci-cli/references/examples.md @@ -26,6 +26,9 @@ dci customer-context set ## Discovery and Read-Only Navigation ```bash +# Find the command for a task before reading any --help +dci commands --search "budgets about to overspend" +dci commands --search "delete a report" --json dci list-alerts --output toon dci get-alert --output toon dci list-dimensions --output toon