feat(spec): better suggestion with cobra's cli __complete subcmd (#40)
Introduces dynamic completion inference for Cobra-based CLIs using the hidden `__complete` subcommand. Iris can now automatically suggest subcommands and flags for commands without hand-written specs. Completions are structured, cached by binary modification time (`mtime`), and safely isolated from hand-written specs. Try testing with un-specced commands: - `gh repo ` (suggests `clone`, `fork`, `sync`...) - `kubectl get ` (suggests `pods`, `nodes`, `configmaps`...) - `golangci-lint ` (suggests `run`, `fmt`, `linters`...)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type cobraCacheEntry struct {
|
||||
suggestions []Suggestion
|
||||
}
|
||||
|
||||
var (
|
||||
cobraCache = map[string]cobraCacheEntry{}
|
||||
cobraCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
func cobraBinKey(binName string) string {
|
||||
path, err := exec.LookPath(binName)
|
||||
if err != nil {
|
||||
return binName
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return binName
|
||||
}
|
||||
return binName + "|" + info.ModTime().String()
|
||||
}
|
||||
|
||||
// parseCobraOutput parses output from `<cmd> __complete <args>`.
|
||||
// each line is "value\tdesc", last line is ":N" (ShellCompDirective bitmask).
|
||||
// returns nil if output is not Cobra-style.
|
||||
func parseCobraOutput(raw string, prefix string) []Suggestion {
|
||||
lines := strings.Split(strings.TrimRight(raw, "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
lastLine := lines[len(lines)-1]
|
||||
if !strings.HasPrefix(lastLine, ":") {
|
||||
return nil
|
||||
}
|
||||
directive, err := strconv.Atoi(lastLine[1:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// ShellCompDirectiveError = 1
|
||||
if directive&1 != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := lines[:len(lines)-1]
|
||||
results := make([]Suggestion, 0, len(candidates))
|
||||
for _, line := range candidates {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
value, desc, _ := strings.Cut(line, "\t")
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
cmd := value
|
||||
if prefix != "" {
|
||||
cmd = prefix + " " + value
|
||||
}
|
||||
results = append(results, Suggestion{
|
||||
Cmd: cmd,
|
||||
Desc: desc,
|
||||
Source: "spec-inferred",
|
||||
Confidence: 50,
|
||||
Priority: 30,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func buildCobraCacheKey(binKey string, args []string, partial string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(binKey)
|
||||
for _, arg := range args {
|
||||
sb.WriteByte('\x00')
|
||||
sb.WriteString(arg)
|
||||
}
|
||||
sb.WriteByte('\x00')
|
||||
sb.WriteString(partial)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// QueryCobraComplete calls `binName __complete <args> <partial>` and returns
|
||||
// structured suggestions cached per binary mtime, args, and partial.
|
||||
// returns nil if the binary is not Cobra-based or times out.
|
||||
func QueryCobraComplete(binName string, args []string, partial string) []Suggestion {
|
||||
if strings.ContainsAny(binName, `/\`) {
|
||||
return nil
|
||||
}
|
||||
|
||||
binKey := cobraBinKey(binName)
|
||||
argKey := buildCobraCacheKey(binKey, args, partial)
|
||||
|
||||
cobraCacheMu.Lock()
|
||||
if entry, ok := cobraCache[argKey]; ok {
|
||||
cobraCacheMu.Unlock()
|
||||
return filterByPartial(entry.suggestions, partial)
|
||||
}
|
||||
cobraCacheMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmdArgs := append([]string{"__complete"}, args...)
|
||||
cmdArgs = append(cmdArgs, partial)
|
||||
out, err := exec.CommandContext(ctx, binName, cmdArgs...).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefixParts := append([]string{binName}, args...)
|
||||
prefix := strings.Join(prefixParts, " ")
|
||||
suggestions := parseCobraOutput(string(out), prefix)
|
||||
|
||||
cobraCacheMu.Lock()
|
||||
cobraCache[argKey] = cobraCacheEntry{suggestions: suggestions}
|
||||
cobraCacheMu.Unlock()
|
||||
|
||||
return filterByPartial(suggestions, partial)
|
||||
}
|
||||
|
||||
func filterByPartial(suggestions []Suggestion, partial string) []Suggestion {
|
||||
if partial == "" {
|
||||
return suggestions
|
||||
}
|
||||
filtered := make([]Suggestion, 0, len(suggestions))
|
||||
for _, s := range suggestions {
|
||||
lastWord := s.Cmd
|
||||
if idx := strings.LastIndex(s.Cmd, " "); idx >= 0 {
|
||||
lastWord = s.Cmd[idx+1:]
|
||||
}
|
||||
if HasPrefix(lastWord, partial) {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ResetCobraCache clears the completion cache — use in tests only
|
||||
func ResetCobraCache() {
|
||||
cobraCacheMu.Lock()
|
||||
cobraCache = map[string]cobraCacheEntry{}
|
||||
cobraCacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCobraOutput_ValidCobra(t *testing.T) {
|
||||
raw := "install\tinstall a chart\nupgrade\tupgrade a release\nstatus\tget release status\n:4\n"
|
||||
results := parseCobraOutput(raw, "helm")
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 suggestions, got %d", len(results))
|
||||
}
|
||||
if results[0].Cmd != "helm install" {
|
||||
t.Errorf("expected 'helm install', got %q", results[0].Cmd)
|
||||
}
|
||||
if results[0].Desc != "install a chart" {
|
||||
t.Errorf("expected desc 'install a chart', got %q", results[0].Desc)
|
||||
}
|
||||
if results[0].Source != "spec-inferred" {
|
||||
t.Errorf("expected source 'spec-inferred', got %q", results[0].Source)
|
||||
}
|
||||
if results[0].Priority != 30 {
|
||||
t.Errorf("expected priority 30, got %d", results[0].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCobraOutput_ErrorDirective(t *testing.T) {
|
||||
// directive bit 1 = ShellCompDirectiveError — not a Cobra CLI
|
||||
raw := "something\n:1\n"
|
||||
results := parseCobraOutput(raw, "mycmd")
|
||||
if results != nil {
|
||||
t.Errorf("expected nil for error directive, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCobraOutput_NoDirectiveLine(t *testing.T) {
|
||||
raw := "just some --help output\nno directive here\n"
|
||||
results := parseCobraOutput(raw, "mycmd")
|
||||
if results != nil {
|
||||
t.Errorf("expected nil for non-Cobra output, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCobraOutput_PartialFilter(t *testing.T) {
|
||||
raw := "get\tget resources\ndelete\tdelete resources\ndescribe\tdescribe resources\n:4\n"
|
||||
// parseCobraOutput returns all candidates; filterByPartial handles narrowing
|
||||
results := parseCobraOutput(raw, "kubectl")
|
||||
filtered := filterByPartial(results, "de")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("expected 2 results matching 'de', got %d: %v", len(filtered), filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryCobraComplete_PathTraversalBlocked(t *testing.T) {
|
||||
result := QueryCobraComplete("./malicious.sh", nil, "")
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for path traversal input, got %v", result)
|
||||
}
|
||||
result = QueryCobraComplete("/usr/bin/env", nil, "")
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for absolute path input, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryCobraComplete_NonCobraBinary(t *testing.T) {
|
||||
t.Cleanup(ResetCobraCache)
|
||||
// 'ls' is not Cobra-based, should return nil gracefully
|
||||
result := QueryCobraComplete("ls", nil, "")
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil for non-Cobra binary 'ls', got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterByPartial(t *testing.T) {
|
||||
suggestions := []Suggestion{
|
||||
{Cmd: "helm install"},
|
||||
{Cmd: "helm upgrade"},
|
||||
{Cmd: "helm status"},
|
||||
}
|
||||
filtered := filterByPartial(suggestions, "up")
|
||||
if len(filtered) != 1 || filtered[0].Cmd != "helm upgrade" {
|
||||
t.Errorf("expected [helm upgrade], got %v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterByPartial_EmptyPartial(t *testing.T) {
|
||||
suggestions := []Suggestion{{Cmd: "a"}, {Cmd: "b"}}
|
||||
filtered := filterByPartial(suggestions, "")
|
||||
if len(filtered) != 2 {
|
||||
t.Errorf("expected all suggestions for empty partial, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCobraCacheKey(t *testing.T) {
|
||||
key1 := buildCobraCacheKey("gh", []string{"repo", "foo bar"}, "baz")
|
||||
key2 := buildCobraCacheKey("gh", []string{"repo", "foo", "bar"}, "baz")
|
||||
key3 := buildCobraCacheKey("gh", []string{"repo", "foo bar"}, "other")
|
||||
|
||||
if key1 == key2 {
|
||||
t.Errorf("expected quoted argument key1 and key2 to be distinct, but were equal")
|
||||
}
|
||||
if key1 == key3 {
|
||||
t.Errorf("expected key1 and key3 with different partials to be distinct, but were equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_CobraRealBinary(t *testing.T) {
|
||||
// Test real lookup for 'gh' (GitHub CLI) which has no hand-written spec in Iris
|
||||
results := Lookup("gh repo ")
|
||||
if len(results) == 0 {
|
||||
t.Skip("gh binary not available or output no completions")
|
||||
}
|
||||
t.Logf("Got %d completions for 'gh repo ':", len(results))
|
||||
for _, r := range results {
|
||||
t.Logf(" - Cmd: %-25s | Source: %-15s | Priority: %d | Desc: %s", r.Cmd, r.Source, r.Priority, r.Desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_CobraKubectl(t *testing.T) {
|
||||
results := Lookup("kubectl get ")
|
||||
if len(results) == 0 {
|
||||
t.Skip("kubectl binary not available or output no completions")
|
||||
}
|
||||
t.Logf("Got %d completions for 'kubectl get ':", len(results))
|
||||
for i, r := range results {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... and %d more", len(results)-10)
|
||||
break
|
||||
}
|
||||
t.Logf(" - Cmd: %-30s | Source: %-15s | Priority: %d | Desc: %s", r.Cmd, r.Source, r.Priority, r.Desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_CobraGolangciLint(t *testing.T) {
|
||||
results := Lookup("golangci-lint ")
|
||||
if len(results) == 0 {
|
||||
t.Skip("golangci-lint binary not available or output no completions")
|
||||
}
|
||||
t.Logf("Got %d completions for 'golangci-lint ':", len(results))
|
||||
for _, r := range results {
|
||||
t.Logf(" - Cmd: %-30s | Source: %-15s | Priority: %d | Desc: %s", r.Cmd, r.Source, r.Priority, r.Desc)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,14 @@ func Lookup(input string) []Suggestion {
|
||||
spec, exists := Registry[rootCmdName]
|
||||
logger.Debugf("core lookup tokens: %v, registry exists: %v", tokens, exists)
|
||||
if !exists {
|
||||
partial := tokens[len(tokens)-1]
|
||||
args := tokens[1:]
|
||||
if len(args) > 0 && args[len(args)-1] == partial {
|
||||
args = args[:len(args)-1]
|
||||
}
|
||||
if cobSugg := QueryCobraComplete(rootCmdName, args, partial); len(cobSugg) > 0 {
|
||||
return cobSugg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user