diff --git a/spec/cobra_complete.go b/spec/cobra_complete.go new file mode 100644 index 0000000..8aeb19b --- /dev/null +++ b/spec/cobra_complete.go @@ -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 ` __complete `. +// 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 ` 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() +} diff --git a/spec/cobra_complete_test.go b/spec/cobra_complete_test.go new file mode 100644 index 0000000..0100e66 --- /dev/null +++ b/spec/cobra_complete_test.go @@ -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) + } +} diff --git a/spec/lookup.go b/spec/lookup.go index 8cca396..9e10f00 100644 --- a/spec/lookup.go +++ b/spec/lookup.go @@ -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 }