Files
LocalAI/core/cli/completion.go
LocalAI [bot] efd402207c feat: Add shell completion support for bash, zsh, and fish (#8851)
feat: add shell completion support for bash, zsh, and fish

- Add core/cli/completion.go with dynamic completion script generation
- Add core/cli/completion_test.go with unit tests
- Modify cmd/local-ai/main.go to support completion command
- Modify core/cli/cli.go to add Completion subcommand
- Add docs/content/features/shell-completion.md with installation instructions

The completion scripts are generated dynamically from the Kong CLI model,
so they automatically include all commands, subcommands, and flags.

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
2026-03-08 09:32:39 +01:00

397 lines
9.8 KiB
Go

package cli
import (
"fmt"
"strings"
"github.com/alecthomas/kong"
cliContext "github.com/mudler/LocalAI/core/cli/context"
)
type CompletionCMD struct {
Shell string `arg:"" enum:"bash,zsh,fish" help:"Shell to generate completions for (bash, zsh, fish)"`
app *kong.Application `kong:"-"`
}
func (c *CompletionCMD) SetApplication(app *kong.Application) {
c.app = app
}
func (c *CompletionCMD) Run(_ *cliContext.Context) error {
if c.app == nil {
return fmt.Errorf("application model not available")
}
var script string
switch c.Shell {
case "bash":
script = generateBashCompletion(c.app)
case "zsh":
script = generateZshCompletion(c.app)
case "fish":
script = generateFishCompletion(c.app)
default:
return fmt.Errorf("unsupported shell: %s", c.Shell)
}
fmt.Print(script)
return nil
}
func collectCommands(node *kong.Node, prefix string) []commandInfo {
var cmds []commandInfo
for _, child := range node.Children {
if child.Hidden {
continue
}
name := child.Name
fullName := name
if prefix != "" {
fullName = prefix + " " + name
}
help := child.Help
cmds = append(cmds, commandInfo{
name: name,
fullName: fullName,
help: help,
node: child,
})
cmds = append(cmds, collectCommands(child, fullName)...)
}
return cmds
}
type commandInfo struct {
name string
fullName string
help string
node *kong.Node
}
func collectFlags(node *kong.Node) []flagInfo {
var flags []flagInfo
seen := make(map[string]bool)
// Collect flags from this node and its ancestors
for n := node; n != nil; n = n.Parent {
for _, flag := range n.Flags {
if flag.Hidden || seen[flag.Name] {
continue
}
seen[flag.Name] = true
flags = append(flags, flagInfo{
name: flag.Name,
short: flag.Short,
help: flag.Help,
})
}
}
return flags
}
type flagInfo struct {
name string
short rune
help string
}
func generateBashCompletion(app *kong.Application) string {
var sb strings.Builder
cmds := collectCommands(app.Node, "")
topLevelCmds := []string{}
for _, cmd := range cmds {
if !strings.Contains(cmd.fullName, " ") {
topLevelCmds = append(topLevelCmds, cmd.name)
}
}
globalFlags := collectFlags(app.Node)
globalFlagNames := []string{}
for _, f := range globalFlags {
globalFlagNames = append(globalFlagNames, "--"+f.name)
if f.short != 0 {
globalFlagNames = append(globalFlagNames, "-"+string(f.short))
}
}
sb.WriteString(`# bash completion for local-ai
# Generated by local-ai completion bash
_local_ai_completions()
{
local cur prev words cword
_init_completion || return
local commands="` + strings.Join(topLevelCmds, " ") + `"
local global_flags="` + strings.Join(globalFlagNames, " ") + `"
# Find the subcommand
local subcmd=""
local subcmd_idx=0
for ((i=1; i < cword; i++)); do
case "${words[i]}" in
-*)
# Skip flags and their values
;;
*)
if [[ -z "$subcmd" ]]; then
subcmd="${words[i]}"
subcmd_idx=$i
fi
;;
esac
done
# If completing a flag value, don't suggest anything special
if [[ "$cur" == -* ]]; then
case "$subcmd" in
`)
// Generate flag completions per top-level command
for _, cmd := range cmds {
if strings.Contains(cmd.fullName, " ") {
continue
}
flags := collectFlags(cmd.node)
flagNames := []string{}
for _, f := range flags {
flagNames = append(flagNames, "--"+f.name)
if f.short != 0 {
flagNames = append(flagNames, "-"+string(f.short))
}
}
sb.WriteString(fmt.Sprintf(" %s)\n", cmd.name))
sb.WriteString(fmt.Sprintf(" COMPREPLY=($(compgen -W \"%s\" -- \"$cur\"))\n", strings.Join(flagNames, " ")))
sb.WriteString(" return\n")
sb.WriteString(" ;;\n")
}
sb.WriteString(` *)
COMPREPLY=($(compgen -W "$global_flags" -- "$cur"))
return
;;
esac
fi
# Complete subcommands for top-level commands
case "$subcmd" in
`)
// Generate subcommand completions
for _, cmd := range cmds {
if strings.Contains(cmd.fullName, " ") {
continue
}
subcmds := []string{}
for _, sub := range cmds {
parts := strings.SplitN(sub.fullName, " ", 2)
if len(parts) == 2 && parts[0] == cmd.name && !strings.Contains(parts[1], " ") {
subcmds = append(subcmds, parts[1])
}
}
if len(subcmds) > 0 {
sb.WriteString(fmt.Sprintf(" %s)\n", cmd.name))
sb.WriteString(fmt.Sprintf(" COMPREPLY=($(compgen -W \"%s\" -- \"$cur\"))\n", strings.Join(subcmds, " ")))
sb.WriteString(" return\n")
sb.WriteString(" ;;\n")
}
}
sb.WriteString(` "")
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return
;;
esac
}
complete -F _local_ai_completions local-ai
`)
return sb.String()
}
func generateZshCompletion(app *kong.Application) string {
var sb strings.Builder
cmds := collectCommands(app.Node, "")
globalFlags := collectFlags(app.Node)
sb.WriteString(`#compdef local-ai
# Generated by local-ai completion zsh
_local_ai() {
local -a commands
local -a global_flags
global_flags=(
`)
for _, f := range globalFlags {
help := strings.ReplaceAll(f.help, "'", "'\\''")
help = strings.ReplaceAll(help, "[", "\\[")
help = strings.ReplaceAll(help, "]", "\\]")
sb.WriteString(fmt.Sprintf(" '--%s[%s]'\n", f.name, help))
if f.short != 0 {
sb.WriteString(fmt.Sprintf(" '-%s[%s]'\n", string(f.short), help))
}
}
sb.WriteString(` )
commands=(
`)
for _, cmd := range cmds {
if strings.Contains(cmd.fullName, " ") {
continue
}
help := strings.ReplaceAll(cmd.help, "'", "'\\''")
help = strings.ReplaceAll(help, "[", "\\[")
help = strings.ReplaceAll(help, "]", "\\]")
sb.WriteString(fmt.Sprintf(" '%s:%s'\n", cmd.name, help))
}
sb.WriteString(` )
_arguments -C \
$global_flags \
'1:command:->command' \
'*::arg:->args'
case $state in
command)
_describe -t commands 'local-ai commands' commands
;;
args)
case $words[1] in
`)
// Per-command completions
for _, cmd := range cmds {
if strings.Contains(cmd.fullName, " ") {
continue
}
sb.WriteString(fmt.Sprintf(" %s)\n", cmd.name))
// Check for subcommands
subcmds := []commandInfo{}
for _, sub := range cmds {
parts := strings.SplitN(sub.fullName, " ", 2)
if len(parts) == 2 && parts[0] == cmd.name && !strings.Contains(parts[1], " ") {
subcmds = append(subcmds, sub)
}
}
if len(subcmds) > 0 {
sb.WriteString(" local -a subcmds\n")
sb.WriteString(" subcmds=(\n")
for _, sub := range subcmds {
parts := strings.SplitN(sub.fullName, " ", 2)
help := strings.ReplaceAll(sub.help, "'", "'\\''")
help = strings.ReplaceAll(help, "[", "\\[")
help = strings.ReplaceAll(help, "]", "\\]")
sb.WriteString(fmt.Sprintf(" '%s:%s'\n", parts[1], help))
}
sb.WriteString(" )\n")
sb.WriteString(" _describe -t commands 'subcommands' subcmds\n")
}
flags := collectFlags(cmd.node)
if len(flags) > 0 {
sb.WriteString(" _arguments \\\n")
for i, f := range flags {
help := strings.ReplaceAll(f.help, "'", "'\\''")
help = strings.ReplaceAll(help, "[", "\\[")
help = strings.ReplaceAll(help, "]", "\\]")
suffix := " \\"
if i == len(flags)-1 {
suffix = ""
}
sb.WriteString(fmt.Sprintf(" '--%s[%s]'%s\n", f.name, help, suffix))
}
}
sb.WriteString(" ;;\n")
}
sb.WriteString(` esac
;;
esac
}
_local_ai "$@"
`)
return sb.String()
}
func generateFishCompletion(app *kong.Application) string {
var sb strings.Builder
cmds := collectCommands(app.Node, "")
globalFlags := collectFlags(app.Node)
sb.WriteString("# fish completion for local-ai\n")
sb.WriteString("# Generated by local-ai completion fish\n\n")
// Disable file completions by default
sb.WriteString("complete -c local-ai -f\n\n")
// Global flags
for _, f := range globalFlags {
help := strings.ReplaceAll(f.help, "'", "\\'")
args := fmt.Sprintf("complete -c local-ai -l %s", f.name)
if f.short != 0 {
args += fmt.Sprintf(" -s %s", string(f.short))
}
args += fmt.Sprintf(" -d '%s'", help)
sb.WriteString(args + "\n")
}
sb.WriteString("\n")
// Top-level commands (no condition means they show when no subcommand is given)
topLevelCmds := []string{}
for _, cmd := range cmds {
if strings.Contains(cmd.fullName, " ") {
continue
}
topLevelCmds = append(topLevelCmds, cmd.name)
help := strings.ReplaceAll(cmd.help, "'", "\\'")
sb.WriteString(fmt.Sprintf("complete -c local-ai -n '__fish_use_subcommand' -a %s -d '%s'\n", cmd.name, help))
}
sb.WriteString("\n")
// Subcommands and per-command flags
for _, cmd := range cmds {
if strings.Contains(cmd.fullName, " ") {
continue
}
// Subcommands
for _, sub := range cmds {
parts := strings.SplitN(sub.fullName, " ", 2)
if len(parts) == 2 && parts[0] == cmd.name && !strings.Contains(parts[1], " ") {
help := strings.ReplaceAll(sub.help, "'", "\\'")
sb.WriteString(fmt.Sprintf("complete -c local-ai -n '__fish_seen_subcommand_from %s' -a %s -d '%s'\n", cmd.name, parts[1], help))
}
}
// Per-command flags
flags := collectFlags(cmd.node)
for _, f := range flags {
help := strings.ReplaceAll(f.help, "'", "\\'")
args := fmt.Sprintf("complete -c local-ai -n '__fish_seen_subcommand_from %s' -l %s", cmd.name, f.name)
if f.short != 0 {
args += fmt.Sprintf(" -s %s", string(f.short))
}
args += fmt.Sprintf(" -d '%s'", help)
sb.WriteString(args + "\n")
}
}
return sb.String()
}