diff --git a/docs/public/SHELL-COMPLETION.md b/docs/public/SHELL-COMPLETION.md index 22f66e9..a16aeb0 100644 --- a/docs/public/SHELL-COMPLETION.md +++ b/docs/public/SHELL-COMPLETION.md @@ -1,13 +1,13 @@ # Shell Completion for autospec -autospec provides built-in shell completion support for `bash`, `zsh`, `fish`, and `powershell` using Cobra's completion system. +autospec provides built-in shell completion support for `bash`, `zsh`, `fish`, and `powershell` using Cobra's completion system. Additionally, `nushell` and many other shells are supported via native generators and `carapace`. ## Features - **Automatic command completion**: Tab-complete all commands (`full`, `prep`, `specify`, `plan`, `tasks`, `implement`, etc.) - **Flag completion**: Complete command flags (e.g., `--max-retries`, `--debug`, `--specs-dir`) - **Stays in sync**: Automatically updates as commands change -- **Multiple shells**: Works with bash, zsh, fish, and powershell +- **Multiple shells**: Works with bash, zsh, fish, powershell, nushell, and many more via carapace - **One-command installation**: Use `autospec completion install` to automatically configure your shell ## Quick Start (Recommended) @@ -165,6 +165,56 @@ autospec completion powershell | Out-String | Invoke-Expression autospec completion powershell >> $PROFILE ``` +## Nushell Setup + +Nushell uses native `extern` definitions for completions. + +### 1. Generate Completion File + +```bash +mkdir -p ~/.cache/autospec +autospec completion nushell | save -f ~/.cache/autospec/completions.nu +``` + +### 2. Add to config.nu + +Add the following to your `~/.config/nushell/config.nu`: + +```nushell +source ~/.cache/autospec/completions.nu +``` + +### 3. Reload Configuration + +```nushell +source ~/.config/nushell/config.nu +``` + +## Carapace (Multi-Shell) Setup + +For shells not directly supported (Elvish, Ion, Oil, Tcsh, Xonsh) or for using carapace-bin: + +### 1. Generate Spec File + +```bash +mkdir -p ~/.config/carapace/specs +autospec completion carapace > ~/.config/carapace/specs/autospec.yaml +``` + +### 2. Initialize carapace-bin + +Follow the [carapace-bin documentation](https://carapace-sh.github.io/carapace-bin/) to set up carapace for your shell. + +### Supported Shells via Carapace + +- Elvish +- Ion +- Oil +- Tcsh +- Xonsh +- Nushell (alternative to native) +- And more + ## Verification After setup, test completion works: diff --git a/internal/cli/admin/completion_generators.go b/internal/cli/admin/completion_generators.go new file mode 100644 index 0000000..6c195a6 --- /dev/null +++ b/internal/cli/admin/completion_generators.go @@ -0,0 +1,330 @@ +package admin + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Nushell completion generator + +var completionNushellCmd = &cobra.Command{ + Use: "nushell", + Short: "Generate the autocompletion script for nushell", + Long: `Generate the autocompletion script for the nushell shell. + +To load completions in your current shell session: + + autospec completion nushell | save -f ~/.cache/autospec/completions.nu + source ~/.cache/autospec/completions.nu + +To load completions for every new session, add to your config.nu: + + source ~/.cache/autospec/completions.nu +`, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + return genNushellCompletion(cmd.OutOrStdout(), rootCmdRef) + }, +} + +// genNushellCompletion generates Nushell extern definitions for the command tree +func genNushellCompletion(w io.Writer, root *cobra.Command) error { + fmt.Fprintf(w, "# Nushell completions for %s\n", root.Name()) + fmt.Fprintf(w, "# Generated by autospec completion nushell\n\n") + + return genNushellCommand(w, root, "") +} + +func genNushellCommand(w io.Writer, cmd *cobra.Command, parentPath string) error { + if cmd.Hidden { + return nil + } + + cmdPath := cmd.Name() + if parentPath != "" { + cmdPath = parentPath + " " + cmd.Name() + } + + // Generate extern for this command + if err := genNushellExtern(w, cmd, cmdPath); err != nil { + return err + } + + // Recursively generate for subcommands + for _, subCmd := range cmd.Commands() { + if !subCmd.Hidden { + if err := genNushellCommand(w, subCmd, cmdPath); err != nil { + return err + } + } + } + + return nil +} + +func genNushellExtern(w io.Writer, cmd *cobra.Command, cmdPath string) error { + fmt.Fprintf(w, "# %s\n", cmd.Short) + fmt.Fprintf(w, "export extern \"%s\" [\n", cmdPath) + + // Collect flags + flags := collectFlags(cmd) + + // Write flags + for _, f := range flags { + writeNushellFlag(w, f) + } + + // Write positional arguments placeholder if command takes args + if cmd.Args != nil { + fmt.Fprintf(w, " ...args: string # Additional arguments\n") + } + + fmt.Fprintf(w, "]\n\n") + return nil +} + +func writeNushellFlag(w io.Writer, f *pflag.Flag) { + if f.Hidden { + return + } + + // Determine flag type + flagType := getNushellType(f) + + // Build flag definition + var flagDef string + if f.Shorthand != "" { + flagDef = fmt.Sprintf(" -%s, --%s", f.Shorthand, f.Name) + } else { + flagDef = fmt.Sprintf(" --%s", f.Name) + } + + // Add type annotation if not boolean + if flagType != "" { + flagDef += ": " + flagType + } + + // Add description as comment + if f.Usage != "" { + flagDef += fmt.Sprintf(" # %s", f.Usage) + } + + fmt.Fprintln(w, flagDef) +} + +func getNushellType(f *pflag.Flag) string { + switch f.Value.Type() { + case "bool": + return "" // Boolean flags don't need a type in nushell + case "int", "int8", "int16", "int32", "int64": + return "int" + case "uint", "uint8", "uint16", "uint32", "uint64": + return "int" + case "float32", "float64": + return "float" + case "duration": + return "duration" + default: + return "string" + } +} + +func collectFlags(cmd *cobra.Command) []*pflag.Flag { + var flags []*pflag.Flag + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + flags = append(flags, f) + }) + + // Sort flags by name for consistent output + sort.Slice(flags, func(i, j int) bool { + return flags[i].Name < flags[j].Name + }) + + return flags +} + +// Carapace spec generator + +var completionCarapaceCmd = &cobra.Command{ + Use: "carapace", + Short: "Generate a carapace spec YAML file for multi-shell completions", + Long: `Generate a carapace spec YAML file for autospec. + +This spec file can be used with carapace-bin to provide completions +for many shells including Nushell, Elvish, Ion, Oil, Tcsh, and Xonsh. + +To use with carapace-bin: + + 1. Generate the spec file: + autospec completion carapace > ~/.config/carapace/specs/autospec.yaml + + 2. Initialize carapace in your shell (see carapace-bin documentation) + +See https://carapace-sh.github.io/carapace-bin/ for more information. +`, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + return genCarapaceSpec(cmd.OutOrStdout(), rootCmdRef) + }, +} + +// genCarapaceSpec generates a Carapace YAML spec for the command tree +func genCarapaceSpec(w io.Writer, root *cobra.Command) error { + fmt.Fprintf(w, "# yaml-language-server: $schema=https://carapace.sh/schemas/command.json\n") + fmt.Fprintf(w, "name: %s\n", root.Name()) + fmt.Fprintf(w, "description: %s\n", escapeYAMLString(root.Short)) + + // Generate persistent flags + persistentFlags := collectPersistentFlags(root) + if len(persistentFlags) > 0 { + fmt.Fprintln(w, "persistentflags:") + for _, f := range persistentFlags { + writeCarapaceFlag(w, f) + } + } + + // Generate subcommands + subCmds := getVisibleSubcommands(root) + if len(subCmds) > 0 { + fmt.Fprintln(w, "commands:") + for _, subCmd := range subCmds { + if err := genCarapaceSubcommand(w, subCmd, 1); err != nil { + return err + } + } + } + + return nil +} + +func genCarapaceSubcommand(w io.Writer, cmd *cobra.Command, depth int) error { + indent := strings.Repeat(" ", depth) + + fmt.Fprintf(w, "%s- name: %s\n", indent, cmd.Name()) + if cmd.Short != "" { + fmt.Fprintf(w, "%s description: %s\n", indent, escapeYAMLString(cmd.Short)) + } + + // Add aliases + if len(cmd.Aliases) > 0 { + fmt.Fprintf(w, "%s aliases:\n", indent) + for _, alias := range cmd.Aliases { + fmt.Fprintf(w, "%s - %s\n", indent, alias) + } + } + + // Add local flags (non-persistent) + localFlags := collectLocalFlags(cmd) + if len(localFlags) > 0 { + fmt.Fprintf(w, "%s flags:\n", indent) + for _, f := range localFlags { + writeCarapaceFlagIndented(w, f, indent+" ") + } + } + + // Add subcommands recursively + subCmds := getVisibleSubcommands(cmd) + if len(subCmds) > 0 { + fmt.Fprintf(w, "%s commands:\n", indent) + for _, subCmd := range subCmds { + if err := genCarapaceSubcommand(w, subCmd, depth+2); err != nil { + return err + } + } + } + + return nil +} + +func writeCarapaceFlag(w io.Writer, f *pflag.Flag) { + writeCarapaceFlagIndented(w, f, " ") +} + +func writeCarapaceFlagIndented(w io.Writer, f *pflag.Flag, indent string) { + if f.Hidden { + return + } + + // Build flag specification + var flagSpec string + if f.Shorthand != "" { + flagSpec = fmt.Sprintf("-%s, --%s", f.Shorthand, f.Name) + } else { + flagSpec = fmt.Sprintf("--%s", f.Name) + } + + // Add value indicator for non-boolean flags + if f.Value.Type() != "bool" { + flagSpec += "=" + } + + // Write the flag with description + desc := f.Usage + if desc != "" { + fmt.Fprintf(w, "%s%s: %s\n", indent, flagSpec, escapeYAMLString(desc)) + } else { + fmt.Fprintf(w, "%s%s:\n", indent, flagSpec) + } +} + +func collectPersistentFlags(cmd *cobra.Command) []*pflag.Flag { + var flags []*pflag.Flag + + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + flags = append(flags, f) + } + }) + + sort.Slice(flags, func(i, j int) bool { + return flags[i].Name < flags[j].Name + }) + + return flags +} + +func collectLocalFlags(cmd *cobra.Command) []*pflag.Flag { + var flags []*pflag.Flag + + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + flags = append(flags, f) + } + }) + + sort.Slice(flags, func(i, j int) bool { + return flags[i].Name < flags[j].Name + }) + + return flags +} + +func getVisibleSubcommands(cmd *cobra.Command) []*cobra.Command { + var subCmds []*cobra.Command + + for _, subCmd := range cmd.Commands() { + if !subCmd.Hidden && subCmd.Name() != "help" { + subCmds = append(subCmds, subCmd) + } + } + + sort.Slice(subCmds, func(i, j int) bool { + return subCmds[i].Name() < subCmds[j].Name() + }) + + return subCmds +} + +func escapeYAMLString(s string) string { + // Simple YAML escaping - wrap in quotes if contains special characters + if strings.ContainsAny(s, ":#{}[]|>&*!?@`\"'\\") || strings.HasPrefix(s, "-") { + return fmt.Sprintf("%q", s) + } + return s +} diff --git a/internal/cli/admin/completion_install.go b/internal/cli/admin/completion_install.go index 0802d10..e9e8924 100644 --- a/internal/cli/admin/completion_install.go +++ b/internal/cli/admin/completion_install.go @@ -166,6 +166,8 @@ func init() { completionCmd.AddCommand(completionZshCmd) completionCmd.AddCommand(completionFishCmd) completionCmd.AddCommand(completionPowershellCmd) + completionCmd.AddCommand(completionNushellCmd) + completionCmd.AddCommand(completionCarapaceCmd) // Add the install subcommand completionCmd.AddCommand(completionInstallCmd) diff --git a/internal/cli/completion_install_test.go b/internal/cli/completion_install_test.go index 85a47fe..889ff95 100644 --- a/internal/cli/completion_install_test.go +++ b/internal/cli/completion_install_test.go @@ -235,3 +235,143 @@ func TestCompletionShellGeneration(t *testing.T) { }) } } + +func TestCompletionNushellGeneration(t *testing.T) { + cmd := rootCmd + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"completion", "nushell"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := buf.String() + + // Verify header + expectedStrings := []string{ + "# Nushell completions for autospec", + "export extern \"autospec\"", + "--config", + "--debug", + "--verbose", + } + + for _, want := range expectedStrings { + if !strings.Contains(output, want) { + t.Errorf("nushell output should contain %q", want) + } + } + + // Verify subcommands are included + subcommands := []string{ + "export extern \"autospec all\"", + "export extern \"autospec plan\"", + "export extern \"autospec implement\"", + "export extern \"autospec completion\"", + } + + for _, want := range subcommands { + if !strings.Contains(output, want) { + t.Errorf("nushell output should contain subcommand %q", want) + } + } +} + +func TestCompletionCarapaceGeneration(t *testing.T) { + cmd := rootCmd + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"completion", "carapace"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := buf.String() + + // Verify YAML structure + expectedStrings := []string{ + "# yaml-language-server: $schema=https://carapace.sh/schemas/command.json", + "name: autospec", + "description:", + "persistentflags:", + "commands:", + "--config=", + "--debug:", + } + + for _, want := range expectedStrings { + if !strings.Contains(output, want) { + t.Errorf("carapace output should contain %q", want) + } + } + + // Verify subcommands are included with proper format + subcommands := []string{ + "- name: all", + "- name: plan", + "- name: implement", + "- name: completion", + } + + for _, want := range subcommands { + if !strings.Contains(output, want) { + t.Errorf("carapace output should contain subcommand %q", want) + } + } +} + +func TestCompletionNushellFlagTypes(t *testing.T) { + cmd := rootCmd + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"completion", "nushell"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := buf.String() + + // Boolean flags should not have a type annotation + if strings.Contains(output, "--debug: bool") { + t.Error("boolean flags should not have type annotation in nushell") + } + + // String flags should have string type + if !strings.Contains(output, "--config: string") { + t.Error("string flags should have 'string' type in nushell") + } + + // Int flags should have int type + if !strings.Contains(output, "--max-retries: int") { + t.Error("int flags should have 'int' type in nushell") + } +} + +func TestCompletionCarapaceAliases(t *testing.T) { + cmd := rootCmd + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"completion", "carapace"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := buf.String() + + // Commands with aliases should include them + if !strings.Contains(output, "aliases:") { + t.Error("carapace output should include aliases section for commands with aliases") + } +}