Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions packages/opencode/src/cli/cmd/completion.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
3 changes: 2 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/cli/cmd/completion.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading