diff --git a/packages/opencode/src/cli/cmd/completion.ts b/packages/opencode/src/cli/cmd/completion.ts new file mode 100644 index 000000000000..0dbf6322a812 --- /dev/null +++ b/packages/opencode/src/cli/cmd/completion.ts @@ -0,0 +1,114 @@ +import { EOL } from "os" +import { cmd } from "./cmd" + +// Shell completion scripts. Templates mirror yargs' own generation +// (lib/completion-templates.ts, MIT) but select the template from the +// positional shell argument. yargs picks a template from $SHELL only, +// so `opencode completion fish` prints a bash or zsh script when $SHELL +// does not name fish. The fish template below is the one yargs uses on +// main (https://github.com/yargs/yargs/pull/2568), unreleased as of +// yargs 18.2.0. + +const bashTemplate = `###-begin-{{app_name}}-completions-### +# +# yargs command completion script +# +# Installation: {{app_path}} {{completion_command}} >> ~/.bashrc +# or {{app_path}} {{completion_command}} >> ~/.bash_profile on OSX. +# +_{{app_name}}_yargs_completions() +{ + local cur_word args type_list + + cur_word="\${COMP_WORDS[COMP_CWORD]}" + args=("\${COMP_WORDS[@]}") + + # ask yargs to generate completions. + # see https://stackoverflow.com/a/40944195/7080036 for the spaces-handling awk + mapfile -t type_list < <({{app_path}} --get-yargs-completions "\${args[@]}") + mapfile -t COMPREPLY < <(compgen -W "$( printf '%q ' "\${type_list[@]}" )" -- "\${cur_word}" | + awk '/ / { print "\\""$0"\\"" } /^[^ ]+$/ { print $0 }') + + # if no match was found, fall back to filename completion + if [ \${#COMPREPLY[@]} -eq 0 ]; then + COMPREPLY=() + fi + + return 0 +} +complete -o bashdefault -o default -F _{{app_name}}_yargs_completions {{app_name}} +###-end-{{app_name}}-completions-### +` + +const zshTemplate = `#compdef {{app_name}} +###-begin-{{app_name}}-completions-### +# +# yargs command completion script +# +# Installation: {{app_path}} {{completion_command}} >> ~/.zshrc +# or {{app_path}} {{completion_command}} >> ~/.zprofile on OSX. +# +_{{app_name}}_yargs_completions() +{ + local reply + local si=$IFS + IFS=$'\n' reply=($(COMP_CWORD="$((CURRENT-1))" COMP_LINE="$BUFFER" COMP_POINT="$CURSOR" {{app_path}} --get-yargs-completions "\${words[@]}")) + IFS=$si + if [[ \${#reply} -gt 0 ]]; then + _describe 'values' reply + else + _default + fi +} +if [[ "'\${zsh_eval_context[-1]}" == "loadautofunc" ]]; then + _{{app_name}}_yargs_completions "$@" +else + compdef _{{app_name}}_yargs_completions {{app_name}} +fi +###-end-{{app_name}}-completions-### +` + +const fishTemplate = `###-begin-{{app_name}}-completions-### +# +# yargs command completion script +# +# Installation: {{app_path}} {{completion_command}} > ~/.config/fish/completions/{{app_name}}.fish +# +complete -f -c {{app_name}} -a '({{app_path}} --get-yargs-completions (commandline -o)[2..-1])' +###-end-{{app_name}}-completions-### +` + +function templateForShell(shell: string): string { + if (shell === "fish") return fishTemplate + if (shell === "zsh") return zshTemplate + return bashTemplate +} + +export function detectShell(shellEnv: string | undefined): string { + const shell = (shellEnv ?? "").split("/").pop() ?? "" + if (shell.includes("fish")) return "fish" + if (shell.includes("zsh")) return "zsh" + return "bash" +} + +export function generateCompletionScript(shell: string): string { + return templateForShell(shell) + .replaceAll("{{app_name}}", "opencode") + .replaceAll("{{completion_command}}", "completion") + .replaceAll("{{app_path}}", "opencode") +} + +export const CompletionCommand = cmd({ + command: "completion [shell]", + describe: "generate shell completion script", + builder: (yargs) => + yargs.positional("shell", { + type: "string", + describe: "shell to generate completions for (defaults to $SHELL, unknown values fall back to bash)", + }), + handler: (args) => { + const shell = args.shell ?? detectShell(process.env.SHELL) + process.stdout.write(generateCompletionScript(shell)) + process.stdout.write(EOL) + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..0d1bf28d08a5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,7 @@ import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" +import { CompletionCommand } from "./cli/cmd/completion" import { Heap } from "./cli/heap" const args = hideBin(process.argv) @@ -77,7 +78,7 @@ const cli = yargs(args) process.env.OPENCODE_PID = String(process.pid) }) .usage("") - .completion("completion", "generate shell completion script") + .command(CompletionCommand) .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) diff --git a/packages/opencode/test/cli/cmd/completion.test.ts b/packages/opencode/test/cli/cmd/completion.test.ts new file mode 100644 index 000000000000..af1b8aa38665 --- /dev/null +++ b/packages/opencode/test/cli/cmd/completion.test.ts @@ -0,0 +1,52 @@ +import { test, expect, describe } from "bun:test" +import { CompletionCommand, detectShell, generateCompletionScript } from "@/cli/cmd/completion" + +describe("completion command", () => { + test("registers with a positional shell argument", () => { + expect(CompletionCommand.command).toBe("completion [shell]") + expect(CompletionCommand.describe).toBe("generate shell completion script") + }) + + test("fish template is valid fish syntax", () => { + const script = generateCompletionScript("fish") + expect(script).toContain("complete -f -c opencode -a '(opencode --get-yargs-completions (commandline -o)[2..-1])'") + // no bash constructs + expect(script).not.toContain("COMP_WORDS") + expect(script).not.toContain("mapfile") + }) + + test("zsh template starts with compdef header", () => { + const script = generateCompletionScript("zsh") + expect(script.startsWith("#compdef opencode")).toBe(true) + expect(script).toContain("compdef _opencode_yargs_completions opencode") + }) + + test("bash template registers the completion function", () => { + const script = generateCompletionScript("bash") + expect(script).toContain("complete -o bashdefault -o default -F _opencode_yargs_completions opencode") + expect(script).toContain("COMP_WORDS") + }) + + test("unknown shell falls back to bash template", () => { + const script = generateCompletionScript("tcsh") + expect(script).toContain("complete -o bashdefault -o default -F _opencode_yargs_completions opencode") + }) +}) + +describe("detectShell", () => { + test("recognizes fish from any path", () => { + expect(detectShell("/usr/bin/fish")).toBe("fish") + expect(detectShell("/opt/homebrew/bin/fish")).toBe("fish") + }) + + test("recognizes zsh", () => { + expect(detectShell("/bin/zsh")).toBe("zsh") + expect(detectShell("/usr/local/bin/zsh")).toBe("zsh") + }) + + test("defaults to bash", () => { + expect(detectShell(undefined)).toBe("bash") + expect(detectShell("/bin/bash")).toBe("bash") + expect(detectShell("")).toBe("bash") + }) +})