Files
openclaw/src/cli/completion-cli.ts

438 lines
13 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { Command, Option } from "commander";
import { routeLogsToStderr } from "../logging/console.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import {
buildFishOptionCompletionLine,
buildFishSubcommandCompletionLine,
} from "./completion-fish.js";
import {
COMPLETION_SHELLS,
installCompletion,
isCompletionShell,
resolveCompletionCachePath,
resolveShellFromEnv,
type CompletionShell,
} from "./completion-runtime.js";
import { getCoreCliCommandNames, registerCoreCliByName } from "./program/command-registry-core.js";
import { getProgramContext } from "./program/program-context.js";
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis-core.js";
export function getCompletionScript(shell: CompletionShell, program: Command): string {
if (shell === "zsh") {
return generateZshCompletion(program);
}
if (shell === "bash") {
return generateBashCompletion(program);
}
if (shell === "powershell") {
return generatePowerShellCompletion(program);
}
return generateFishCompletion(program);
}
async function writeCompletionCache(params: {
program: Command;
shells: CompletionShell[];
binName: string;
}): Promise<void> {
const firstShell = params.shells[0] ?? "zsh";
const cacheDir = path.dirname(resolveCompletionCachePath(firstShell, params.binName));
await fs.mkdir(cacheDir, { recursive: true });
for (const shell of params.shells) {
const script = getCompletionScript(shell, params.program);
const targetPath = resolveCompletionCachePath(shell, params.binName);
await fs.writeFile(targetPath, script, "utf-8");
}
}
function writeCompletionRegistrationWarning(message: string): void {
process.stderr.write(`[completion] ${message}\n`);
}
async function registerSubcommandsForCompletion(program: Command): Promise<void> {
const entries = getSubCliEntries();
for (const entry of entries) {
if (entry.name === "completion") {
continue;
}
try {
await registerSubCliByName(program, entry.name);
} catch (error) {
writeCompletionRegistrationWarning(
`skipping subcommand \`${entry.name}\` while building completion cache: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export function registerCompletionCli(program: Command) {
program
.command("completion")
.description("Generate shell completion script")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/completion", "docs.openclaw.ai/cli/completion")}\n`,
)
.addOption(
new Option("-s, --shell <shell>", "Shell to generate completion for (default: zsh)").choices(
COMPLETION_SHELLS,
),
)
.option("-i, --install", "Install completion script to shell profile")
.option(
"--write-state",
"Write completion scripts to $OPENCLAW_STATE_DIR/completions (no stdout)",
)
.option("-y, --yes", "Skip confirmation (non-interactive)", false)
.action(async (options) => {
// Route logs to stderr so plugin loading messages do not corrupt
// the completion script written to stdout.
routeLogsToStderr();
const shell = options.shell ?? "zsh";
// Completion needs the full Commander command tree (including nested subcommands).
// Our CLI defaults to lazy registration for perf; force-register core commands here.
const ctx = getProgramContext(program);
if (ctx) {
for (const name of getCoreCliCommandNames()) {
await registerCoreCliByName(program, ctx, name);
}
}
// Eagerly register all subcommands except completion itself to build the full tree.
await registerSubcommandsForCompletion(program);
const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js");
await registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, {
mode: "eager",
});
if (options.writeState) {
const writeShells = options.shell ? [shell] : [...COMPLETION_SHELLS];
await writeCompletionCache({
program,
shells: writeShells,
binName: program.name(),
});
}
if (options.install) {
const targetShell = options.shell ?? resolveShellFromEnv();
await installCompletion(targetShell, Boolean(options.yes), program.name());
return;
}
if (options.writeState) {
return;
}
if (!isCompletionShell(shell)) {
throw new Error(`Unsupported shell: ${shell}`);
}
const script = getCompletionScript(shell, program);
process.stdout.write(script + "\n");
});
}
function generateZshCompletion(program: Command): string {
const rootCmd = program.name();
const script = `
#compdef ${rootCmd}
_${rootCmd}_root_completion() {
local -a commands
local -a options
_arguments -C \\
${generateZshArgs(program)} \\
${generateZshSubcmdList(program)} \\
"*::arg:->args"
case $state in
(args)
case $line[1] in
${program.commands.map((cmd) => `(${cmd.name()}) _${rootCmd}_${cmd.name().replace(/-/g, "_")} ;;`).join("\n ")}
esac
;;
esac
}
${generateZshSubcommands(program, rootCmd)}
_${rootCmd}_register_completion() {
if (( ! $+functions[compdef] )); then
return 0
fi
compdef _${rootCmd}_root_completion ${rootCmd}
precmd_functions=(\${precmd_functions:#_${rootCmd}_register_completion})
unfunction _${rootCmd}_register_completion 2>/dev/null
}
_${rootCmd}_register_completion
if (( ! $+functions[compdef] )); then
typeset -ga precmd_functions
if [[ -z "\${precmd_functions[(r)_${rootCmd}_register_completion]}" ]]; then
precmd_functions+=(_${rootCmd}_register_completion)
fi
fi
`;
return script;
}
function generateZshArgs(cmd: Command): string {
return (cmd.options || [])
.map((opt) => {
const flags = opt.flags.split(/[ ,|]+/);
const name = flags.find((f) => f.startsWith("--")) || flags[0];
const short = flags.find((f) => f.startsWith("-") && !f.startsWith("--"));
const desc = opt.description
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/'/g, "'\\''")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
if (short) {
return `"(${name} ${short})"{${name},${short}}"[${desc}]"`;
}
return `"${name}[${desc}]"`;
})
.join(" \\\n ");
}
function generateZshSubcmdList(cmd: Command): string {
const list = cmd.commands
.map((c) => {
const desc = c
.description()
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
return `'${c.name()}[${desc}]'`;
})
.join(" ");
return `"1: :_values 'command' ${list}"`;
}
function generateZshSubcommands(program: Command, prefix: string): string {
const segments: string[] = [];
const visit = (current: Command, currentPrefix: string) => {
for (const cmd of current.commands) {
const cmdName = cmd.name();
const nextPrefix = `${currentPrefix}_${cmdName.replace(/-/g, "_")}`;
const funcName = `_${nextPrefix}`;
visit(cmd, nextPrefix);
const subCommands = cmd.commands;
if (subCommands.length > 0) {
segments.push(`
${funcName}() {
local -a commands
local -a options
_arguments -C \\
${generateZshArgs(cmd)} \\
${generateZshSubcmdList(cmd)} \\
"*::arg:->args"
case $state in
(args)
case $line[1] in
${subCommands.map((sub) => `(${sub.name()}) ${funcName}_${sub.name().replace(/-/g, "_")} ;;`).join("\n ")}
esac
;;
esac
}
`);
continue;
}
segments.push(`
${funcName}() {
_arguments -C \\
${generateZshArgs(cmd)}
}
`);
}
};
visit(program, prefix);
return segments.join("");
}
function generateBashCompletion(program: Command): string {
// Simplified Bash completion using dynamic iteration logic (often hardcoded in static scripts)
// For a robust implementation, usually one maps out the tree.
// This assumes a simple structure.
const rootCmd = program.name();
// We can use a recursive function to build the case statements
return `
_${rootCmd}_completion() {
local cur prev opts
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
# Simple top-level completion for now
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
case "\${prev}" in
${program.commands.map((cmd) => generateBashSubcommand(cmd)).join("\n ")}
esac
if [[ \${cur} == -* ]] ; then
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
return 0
fi
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
}
complete -F _${rootCmd}_completion ${rootCmd}
`;
}
function generateBashSubcommand(cmd: Command): string {
// This is a naive implementation; fully recursive bash completion is complex to generate as a single string without improved state tracking.
// For now, let's provide top-level command recognition.
return `${cmd.name()})
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
return 0
;;`;
}
function generatePowerShellCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [];
const visit = (cmd: Command, pathSegments: string[]) => {
const fullPath = pathSegments.join(" ");
// Command completion for this level
const subCommands = cmd.commands.map((c) => c.name());
const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
if (fullPath.length > 0 && allCompletions.length > 0) {
segments.push(`
if ($commandPath -eq '${fullPath}') {
$completions = @(${allCompletions})
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
}
}
`);
}
for (const sub of cmd.commands) {
visit(sub, [...pathSegments, sub.name()]);
}
};
visit(program, []);
const rootBody = segments.join("");
return `
Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$commandElements = $commandAst.CommandElements
$commandPath = ""
# Reconstruct command path (simple approximation)
# Skip the executable name
for ($i = 1; $i -lt $commandElements.Count; $i++) {
$element = $commandElements[$i].Extent.Text
if ($element -like "-*") { break }
if ($i -eq $commandElements.Count - 1 -and $wordToComplete -ne "") { break } # Don't include current word being typed
$commandPath += "$element "
}
$commandPath = $commandPath.Trim()
# Root command
if ($commandPath -eq "") {
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${o.flags.split(" ")[0]}'`).join(",")})
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
}
}
${rootBody}
}
`;
}
function generateFishCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [];
const visit = (cmd: Command, parents: string[]) => {
const cmdName = cmd.name();
// Root logic
if (parents.length === 0) {
// Subcommands of root
for (const sub of cmd.commands) {
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
name: sub.name(),
description: sub.description(),
}),
);
}
// Options of root
for (const opt of cmd.options) {
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
flags: opt.flags,
description: opt.description,
}),
);
}
} else {
// Subcommands
for (const sub of cmd.commands) {
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
name: sub.name(),
description: sub.description(),
}),
);
}
// Options
for (const opt of cmd.options) {
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
flags: opt.flags,
description: opt.description,
}),
);
}
}
for (const sub of cmd.commands) {
visit(sub, [...parents, cmdName]);
}
};
visit(program, []);
return segments.join("");
}