feat: implement completion command and shell generators
This commit is contained in:
parent
14c77f8295
commit
beafaef92f
1 changed files with 86 additions and 5 deletions
|
|
@ -1,4 +1,7 @@
|
||||||
import { Command, Option } from "commander";
|
import { Command, Option } from "commander";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js";
|
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js";
|
||||||
|
|
||||||
export function registerCompletionCli(program: Command) {
|
export function registerCompletionCli(program: Command) {
|
||||||
|
|
@ -10,6 +13,8 @@ export function registerCompletionCli(program: Command) {
|
||||||
.choices(["zsh", "bash", "powershell", "fish"])
|
.choices(["zsh", "bash", "powershell", "fish"])
|
||||||
.default("zsh"),
|
.default("zsh"),
|
||||||
)
|
)
|
||||||
|
.option("-i, --install", "Install completion script to shell profile")
|
||||||
|
.option("-y, --yes", "Skip confirmation (non-interactive)", false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const shell = options.shell;
|
const shell = options.shell;
|
||||||
// Eagerly register all subcommands to build the full tree
|
// Eagerly register all subcommands to build the full tree
|
||||||
|
|
@ -20,6 +25,11 @@ export function registerCompletionCli(program: Command) {
|
||||||
await registerSubCliByName(program, entry.name);
|
await registerSubCliByName(program, entry.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.install) {
|
||||||
|
await installCompletion(shell, Boolean(options.yes), program.name());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let script = "";
|
let script = "";
|
||||||
if (shell === "zsh") {
|
if (shell === "zsh") {
|
||||||
script = generateZshCompletion(program);
|
script = generateZshCompletion(program);
|
||||||
|
|
@ -35,18 +45,75 @@ export function registerCompletionCli(program: Command) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function installCompletion(shell: string, yes: boolean, binName = "openclaw") {
|
||||||
|
const home = process.env.HOME || os.homedir();
|
||||||
|
let profilePath = "";
|
||||||
|
let sourceLine = "";
|
||||||
|
|
||||||
|
if (shell === "zsh") {
|
||||||
|
profilePath = path.join(home, ".zshrc");
|
||||||
|
sourceLine = `source <(${binName} completion --shell zsh)`;
|
||||||
|
} else if (shell === "bash") {
|
||||||
|
// Try .bashrc first, then .bash_profile
|
||||||
|
profilePath = path.join(home, ".bashrc");
|
||||||
|
try {
|
||||||
|
await fs.access(profilePath);
|
||||||
|
} catch {
|
||||||
|
profilePath = path.join(home, ".bash_profile");
|
||||||
|
}
|
||||||
|
sourceLine = `source <(${binName} completion --shell bash)`;
|
||||||
|
} else if (shell === "fish") {
|
||||||
|
profilePath = path.join(home, ".config", "fish", "config.fish");
|
||||||
|
sourceLine = `${binName} completion --shell fish | source`;
|
||||||
|
} else {
|
||||||
|
console.error(`Automated installation not supported for ${shell} yet.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if profile exists
|
||||||
|
try {
|
||||||
|
await fs.access(profilePath);
|
||||||
|
} catch {
|
||||||
|
if (!yes) {
|
||||||
|
console.warn(`Profile not found at ${profilePath}. Created a new one.`);
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||||
|
await fs.writeFile(profilePath, "", "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(profilePath, "utf-8");
|
||||||
|
if (content.includes(`${binName} completion`)) {
|
||||||
|
if (!yes) console.log(`Completion already installed in ${profilePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!yes) {
|
||||||
|
// Simple confirmation could go here if we had a prompter,
|
||||||
|
// but for now we assume --yes or manual invocation implies consent or we print info.
|
||||||
|
// Since we don't have a prompter passed in here easily without adding deps, we'll log.
|
||||||
|
console.log(`Installing completion to ${profilePath}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.appendFile(profilePath, `\n# OpenClaw Completion\n${sourceLine}\n`);
|
||||||
|
console.log(`Completion installed. Restart your shell or run: source ${profilePath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to install completion: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateZshCompletion(program: Command): string {
|
function generateZshCompletion(program: Command): string {
|
||||||
const rootCmd = program.name();
|
const rootCmd = program.name();
|
||||||
const script = `
|
const script = `
|
||||||
#compdef ${rootCmd}
|
#compdef ${rootCmd}
|
||||||
|
|
||||||
_${rootCmd}_completion() {
|
_${rootCmd}_root_completion() {
|
||||||
local -a commands
|
local -a commands
|
||||||
local -a options
|
local -a options
|
||||||
|
|
||||||
_arguments -C \\
|
_arguments -C \\
|
||||||
${generateZshArgs(program)} \\
|
${generateZshArgs(program)} \\
|
||||||
"1: :_commands" \\
|
${generateZshSubcmdList(program)} \\
|
||||||
"*::arg:->args"
|
"*::arg:->args"
|
||||||
|
|
||||||
case $state in
|
case $state in
|
||||||
|
|
@ -60,7 +127,7 @@ _${rootCmd}_completion() {
|
||||||
|
|
||||||
${generateZshSubcommands(program, rootCmd)}
|
${generateZshSubcommands(program, rootCmd)}
|
||||||
|
|
||||||
compdef _${rootCmd}_completion ${rootCmd}
|
compdef _${rootCmd}_root_completion ${rootCmd}
|
||||||
`;
|
`;
|
||||||
return script;
|
return script;
|
||||||
}
|
}
|
||||||
|
|
@ -73,13 +140,27 @@ function generateZshArgs(cmd: Command): string {
|
||||||
const short = flags.find((f) => f.startsWith("-") && !f.startsWith("--"));
|
const short = flags.find((f) => f.startsWith("-") && !f.startsWith("--"));
|
||||||
const desc = opt.description.replace(/'/g, "'\\''");
|
const desc = opt.description.replace(/'/g, "'\\''");
|
||||||
if (short) {
|
if (short) {
|
||||||
return `"(${name} ${short})"'{${name},${short}}'[${desc}]"`;
|
return `"(${name} ${short})"{${name},${short}}"[${desc}]"`;
|
||||||
}
|
}
|
||||||
return `"${name}[${desc}]"`;
|
return `"${name}[${desc}]"`;
|
||||||
})
|
})
|
||||||
.join(" \\\n ");
|
.join(" \\\n ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateZshSubcmdList(cmd: Command): string {
|
||||||
|
const list = cmd.commands
|
||||||
|
.map((c) => {
|
||||||
|
const desc = c
|
||||||
|
.description()
|
||||||
|
.replace(/'/g, "'\\''")
|
||||||
|
.replace(/\[/g, "\\[")
|
||||||
|
.replace(/\]/g, "\\]");
|
||||||
|
return `'${c.name()}[${desc}]'`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
return `"1: :_values 'command' ${list}"`;
|
||||||
|
}
|
||||||
|
|
||||||
function generateZshSubcommands(program: Command, prefix: string): string {
|
function generateZshSubcommands(program: Command, prefix: string): string {
|
||||||
let script = "";
|
let script = "";
|
||||||
for (const cmd of program.commands) {
|
for (const cmd of program.commands) {
|
||||||
|
|
@ -98,7 +179,7 @@ ${funcName}() {
|
||||||
|
|
||||||
_arguments -C \\
|
_arguments -C \\
|
||||||
${generateZshArgs(cmd)} \\
|
${generateZshArgs(cmd)} \\
|
||||||
"1: :_commands" \\
|
${generateZshSubcmdList(cmd)} \\
|
||||||
"*::arg:->args"
|
"*::arg:->args"
|
||||||
|
|
||||||
case $state in
|
case $state in
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue