diff --git a/Makefile b/Makefile index 79636fc..2b5feec 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,15 @@ ROOT_DIR := $(shell pwd) VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin USER_BIN_DIR := $(HOME)/.local/bin COMMANDS := myagents myclaude mykimi -COMP_DIR := $(HOME)/.local/bin/completions -.PHONY: help install uninstall _symlink-commands _install-completions _uninstall-completions +.PHONY: help install uninstall _symlink-commands help: @echo "Usage: make [target]" @echo "" - @echo " install Sync venv, symlink bin, install completions" - @echo " uninstall Remove bin and completions" + @echo " install Sync venv, symlink bin, install shell completions" + @echo " uninstall Remove bin, completions and user symlinks" + install: @cd "$(ROOT_DIR)" && \ if command -v uv >/dev/null 2>&1; then \ @@ -20,7 +20,8 @@ install: PIP_USER=0 .venv/bin/pip install -e .; \ fi @$(MAKE) _symlink-commands - @$(MAKE) _install-completions + @echo "Installing shell completions…" + @myagents completion install || echo "Warning: completion install failed" _symlink-commands: @mkdir -p "$(USER_BIN_DIR)" @@ -32,15 +33,6 @@ _symlink-commands: echo "Linked $$dst -> $$src"; \ done -_install-completions: - @$(ROOT_DIR)/scripts/install_completion.sh "$(VENV_BIN_DIR)" "$(COMP_DIR)" - -uninstall: _uninstall-completions +uninstall: + @myagents completion uninstall || true @ROOT_DIR="$(ROOT_DIR)" python3 "$(ROOT_DIR)/scripts/rm_user_local_myagents.py" - -_uninstall-completions: - @rm -f \ - "$(COMP_DIR)/_myagents" "$(COMP_DIR)/myagents.bash" \ - "$(COMP_DIR)/_myclaude" "$(COMP_DIR)/myclaude.bash" \ - "$(COMP_DIR)/_mykimi" "$(COMP_DIR)/mykimi.bash" - @echo "Removed completions" diff --git a/README.md b/README.md index 5921a1e..6189871 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,24 @@ make install `make install` 会: 1. 同步/创建虚拟环境并做可编辑安装 2. 在 `~/.local/bin/` 创建 `myagents`、`myclaude`、`mykimi` 符号链接 -3. 安装 shell 补全(zsh/bash) +3. 自动安装 shell 补全到 `~/.local/share/zsh/site-functions` 和 `~/.local/share/bash-completion/completions` -请确保 `~/.local/bin` 在 `PATH` 中。移除链接:`make uninstall`。 +请确保 `~/.local/bin` 在 `PATH` 中,并在 `~/.zshrc` 里加上: + +```bash +fpath=(~/.local/share/zsh/site-functions $fpath) +autoload -Uz compinit && compinit +``` + +移除链接与补全:`make uninstall`。 + +首次运行任意 `myagents` / `myclaude` / `mykimi` 命令时,若补全缺失会自动补齐。也可以手动管理: + +```bash +myagents completion doctor # 查看补全状态 +myagents completion install # 手动重装补全 +myagents completion zsh # 打印 zsh 补全脚本 +``` > 💡 **提示**:没有 uv 时会创建项目 `.venv` 并用其中的 pip 做可编辑安装。 @@ -68,6 +83,7 @@ myagents kimi # 启动 Kimi Code CLI myagents kimi -S # 恢复 Kimi session myagents update # 重装 myagents 并同步 workspace 链接 myagents upgrade # update 别名 +myagents completion # 安装/查看/卸载 shell 补全 # 独立入口(与上面完全等价) myclaude @@ -91,18 +107,6 @@ mykimi -S export MYAGENTS_WORKSPACE_ROOT="/your/custom/workspace" ``` -## Shell 补全(可选) - -在 `~/.zshrc` 或 `~/.bashrc` 中加入: - -```bash -if command -v myagents >/dev/null 2>&1; then - eval "$(_MYAGENTS_COMPLETE=zsh_source myagents)" -fi -``` - -Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 配置文件。 - ## 开发 ```bash diff --git a/myagents/cli.py b/myagents/cli.py index 74f9ae8..bb1beb8 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -5,9 +5,21 @@ from importlib.metadata import PackageNotFoundError, version import click from myagents.commands import update_cmd, upgrade_cmd +from myagents.commands.completion import build_completion_group from myagents.launcher import build_cli +def _progs(): + """Return (prog_name, cli_factory) pairs that need shell completions.""" + from myagents.entrypoints import claude_cli, kimi_cli + + return [ + ("myagents", lambda: cli), + ("myclaude", lambda: claude_cli), + ("mykimi", lambda: kimi_cli), + ] + + def _package_version() -> str: try: return version("myagents") @@ -32,9 +44,13 @@ cli.add_command(build_cli("claude"), name="claude") cli.add_command(build_cli("kimi"), name="kimi") cli.add_command(update_cmd) cli.add_command(upgrade_cmd, name="upgrade") +cli.add_command(build_completion_group(_progs)) def main() -> None: + from myagents.commands.completion_install import ensure_completions_installed + + ensure_completions_installed(_progs()) cli() diff --git a/myagents/commands/completion.py b/myagents/commands/completion.py new file mode 100644 index 0000000..69b3d3a --- /dev/null +++ b/myagents/commands/completion.py @@ -0,0 +1,92 @@ +"""Shell tab-completion CLI for myagents.""" + +from __future__ import annotations + +import sys +from typing import Protocol + +import click +from click.shell_completion import get_completion_class + +from myagents.commands.completion_install import ( + ProgList, + install_completions, + run_completion_doctor, + uninstall_completions, +) + + +class ProgListFn(Protocol): + def __call__(self) -> ProgList: ... + + +def _print_script(prog_name: str, shell: str) -> None: + """Print the completion script for one prog/shell pair.""" + # Import the requested CLI object on demand to avoid circular imports. + if prog_name == "myagents": + from myagents.cli import cli + + cli_obj = cli + elif prog_name == "myclaude": + from myagents.entrypoints import claude_cli + + cli_obj = claude_cli + elif prog_name == "mykimi": + from myagents.entrypoints import kimi_cli + + cli_obj = kimi_cli + else: + raise click.ClickException(f"Unknown command: {prog_name}") + + shell_class = get_completion_class(shell) + if shell_class is None: + raise click.ClickException(f"Unsupported shell: {shell}") + complete_var = f"_{prog_name.upper()}_COMPLETE" + comp = shell_class(cli_obj, {}, prog_name, complete_var) + script = comp.source() + if not script.endswith("\n"): + script += "\n" + sys.stdout.write(script) + + +def build_completion_group(progs_fn: ProgListFn) -> click.Group: + @click.group(name="completion", help="Install or inspect shell tab-completion scripts.") + def completion() -> None: + pass + + @completion.command("install") + def install_cmd() -> None: + """Write completion scripts for myagents, myclaude and mykimi.""" + installed = install_completions(progs_fn()) + if not installed: + raise click.ClickException( + "No completion files were written (directory not writable?)." + ) + for path in installed: + click.echo(path) + + @completion.command("uninstall") + def uninstall_cmd() -> None: + """Remove completion scripts.""" + removed = uninstall_completions(progs_fn()) + for path in removed: + click.echo(path) + if not removed: + click.echo("(nothing to remove)") + + @completion.command("doctor") + def doctor_cmd() -> None: + """Report completion install state.""" + run_completion_doctor(progs_fn()) + + for shell in ("bash", "zsh"): + + @completion.command( + name=shell, + help=f"Print the {shell} completion script for a command.", + ) + @click.argument("command", default="myagents") + def print_shell(command: str, shell_name: str = shell) -> None: + _print_script(command, shell_name) + + return completion diff --git a/myagents/commands/completion_install.py b/myagents/commands/completion_install.py new file mode 100644 index 0000000..547ba38 --- /dev/null +++ b/myagents/commands/completion_install.py @@ -0,0 +1,213 @@ +"""Install shell tab-completion files for myagents entrypoints. + +Completions are written to the user's XDG data directory by default +(``~/.local/share``), which keeps them independent of the active venv path. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + +import click +from click.shell_completion import get_completion_class + + +ProgList = Sequence[tuple[str, Callable[[], click.Command]]] + + +MANIFEST_FILENAME = "completion-paths.txt" + + +def _default_install_root() -> Path: + data_home = os.environ.get("XDG_DATA_HOME") + if data_home: + return Path(data_home) + return Path.home() / ".local" / "share" + + +@dataclass(frozen=True) +class CompletionTarget: + shell: str + prog_name: str + path: Path + + @property + def installed(self) -> bool: + return self.path.is_file() + + +def completion_targets( + prog_name: str, + *, + install_root: Path | None = None, +) -> list[CompletionTarget]: + root = install_root or _default_install_root() + return [ + CompletionTarget( + "zsh", + prog_name, + root / "zsh" / "site-functions" / f"_{prog_name}", + ), + CompletionTarget( + "bash", + prog_name, + root / "bash-completion" / "completions" / prog_name, + ), + ] + + +def _completion_script(cli: click.Command, shell: str, prog_name: str) -> str: + shell_class = get_completion_class(shell) + if shell_class is None: + raise ValueError(f"unsupported shell for completion: {shell}") + comp = shell_class(cli, {}, prog_name, f"_{prog_name.upper()}_COMPLETE") + script = comp.source() + if not script.endswith("\n"): + script += "\n" + return script + + +def _all_targets( + progs: ProgList, + install_root: Path | None, +) -> list[CompletionTarget]: + targets: list[CompletionTarget] = [] + for prog_name, _ in progs: + targets.extend(completion_targets(prog_name, install_root=install_root)) + return targets + + +def install_completions( + progs: ProgList, + *, + install_root: Path | None = None, +) -> list[Path]: + """Write completion scripts for all progs and return installed paths.""" + scripts: dict[tuple[str, str], str] = {} + for prog_name, cli_factory in progs: + cli = cli_factory() + for shell in ("zsh", "bash"): + scripts[(prog_name, shell)] = _completion_script(cli, shell, prog_name) + + installed: list[Path] = [] + for target in _all_targets(progs, install_root): + try: + target.path.parent.mkdir(parents=True, exist_ok=True) + except OSError: + continue + if not os.access(target.path.parent, os.W_OK): + continue + target.path.write_text( + scripts[(target.prog_name, target.shell)], + encoding="utf-8", + ) + installed.append(target.path) + return installed + + +def uninstall_completions( + progs: ProgList, + *, + install_root: Path | None = None, +) -> list[Path]: + """Remove completion scripts for all progs and return removed paths.""" + removed: list[Path] = [] + seen: set[Path] = set() + for target in _all_targets(progs, install_root): + if target.path in seen: + continue + seen.add(target.path) + if target.path.is_file(): + target.path.unlink() + removed.append(target.path) + return removed + + +def completions_installed( + progs: ProgList, + *, + install_root: Path | None = None, +) -> bool: + """Check whether the zsh completion for the first prog is current.""" + if not progs: + return True + prog_name, _ = progs[0] + for target in completion_targets(prog_name, install_root=install_root): + if target.shell == "zsh" and target.installed: + marker = f"_{prog_name.upper()}_COMPLETE" + if marker in target.path.read_text(encoding="utf-8"): + return True + return False + + +def ensure_completions_installed( + progs: ProgList, +) -> None: + """Install completions once if they are missing.""" + if completions_installed(progs): + return + try: + install_completions(progs) + except (OSError, ValueError): + return + + +def _in_zsh_fpath(zsh_dir: Path) -> bool: + """Check whether ``zsh_dir`` is on the interactive zsh fpath.""" + try: + check = subprocess.run( + [ + "zsh", + "-c", + f"source ~/.zshrc >/dev/null 2>&1; " + f"[[ -n ${{fpath[(r){shlex.quote(str(zsh_dir))}]}} ]] && echo yes", + ], + capture_output=True, + text=True, + check=False, + ) + return "yes" in check.stdout + except (OSError, subprocess.SubprocessError): + return True + + +def run_completion_doctor(progs: ProgList) -> None: + """Report completion install state.""" + install_root = _default_install_root() + click.echo(click.style("Install root", bold=True) + f": {install_root}") + click.echo() + + click.echo(click.style("Completion scripts", fg="blue", bold=True)) + for prog_name, _ in progs: + for target in completion_targets(prog_name): + marker = f"_{prog_name.upper()}_COMPLETE" + if target.installed and marker in target.path.read_text(encoding="utf-8"): + status = click.style("ok", fg="green") + elif target.installed: + status = click.style("stale", fg="yellow") + else: + status = click.style("missing", fg="red") + click.echo(f" {target.shell:5} {target.prog_name:10} {target.path} [{status}]") + click.echo() + + zsh_dir = install_root / "zsh" / "site-functions" + if zsh_dir.is_dir() and not _in_zsh_fpath(zsh_dir): + click.echo( + click.style("Zsh", fg="yellow", bold=True) + + f": add to fpath if Tab does not work:\n fpath=({zsh_dir} $fpath)" + ) + click.echo() + + if not completions_installed(progs): + click.echo( + click.style("Fix", fg="yellow", bold=True) + + ": run `myagents completion install` or reinstall the package." + ) + return + + click.echo(click.style("Completions are installed.", fg="green", bold=True)) diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index e61f60f..0c56218 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -6,11 +6,27 @@ claude_cli = build_cli("claude", prog_name="myclaude") kimi_cli = build_cli("kimi", prog_name="mykimi") +def _progs(): + from myagents.cli import cli + + return [ + ("myagents", lambda: cli), + ("myclaude", lambda: claude_cli), + ("mykimi", lambda: kimi_cli), + ] + + def claude_main() -> None: """Run ``myclaude``.""" + from myagents.commands.completion_install import ensure_completions_installed + + ensure_completions_installed(_progs()) claude_cli() def kimi_main() -> None: """Run ``mykimi``.""" + from myagents.commands.completion_install import ensure_completions_installed + + ensure_completions_installed(_progs()) kimi_cli() diff --git a/scripts/install_completion.sh b/scripts/install_completion.sh deleted file mode 100755 index 4490532..0000000 --- a/scripts/install_completion.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash -# Install shell completions for myagents, myclaude, mykimi to user directory only -# Usage: install_completion.sh - -VENV_BIN_DIR="$1" -COMP_DIR="$2" - -if [[ -z "$VENV_BIN_DIR" || -z "$COMP_DIR" ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -USER_SHELL=$(basename "$SHELL") - -# Portable uppercase helper (macOS bash 3.2 lacks ${var^^}). -_upcase() { - printf '%s' "$1" | tr '[:lower:]' '[:upper:]' -} - -install_zsh_completion() { - local cmd="$1" - local out="$2" - local var="_$(_upcase "$cmd")_COMPLETE" - mkdir -p "$COMP_DIR" 2>/dev/null || { echo "Error: cannot create $COMP_DIR" >&2; return 1; } - if eval "$var=zsh_source ${VENV_BIN_DIR}/${cmd}" > "$out" 2>/dev/null; then - echo "Installed zsh completion: $out" - return 0 - else - echo "Warning: failed to generate zsh completion for $cmd" >&2 - return 1 - fi -} - -install_bash_completion() { - local cmd="$1" - local out="$2" - local var="_$(_upcase "$cmd")_COMPLETE" - mkdir -p "$COMP_DIR" 2>/dev/null || { echo "Error: cannot create $COMP_DIR" >&2; return 1; } - if eval "$var=bash_source ${VENV_BIN_DIR}/${cmd}" > "$out" 2>/dev/null; then - echo "Installed bash completion: $out" - return 0 - else - echo "Warning: failed to generate bash completion for $cmd" >&2 - return 1 - fi -} - -if [[ "$USER_SHELL" == "zsh" ]]; then - install_zsh_completion myagents "$COMP_DIR/_myagents" - install_zsh_completion myclaude "$COMP_DIR/_myclaude" - install_zsh_completion mykimi "$COMP_DIR/_mykimi" -elif [[ "$USER_SHELL" == "bash" ]]; then - install_bash_completion myagents "$COMP_DIR/myagents.bash" - install_bash_completion myclaude "$COMP_DIR/myclaude.bash" - install_bash_completion mykimi "$COMP_DIR/mykimi.bash" -else - echo "Shell '$USER_SHELL' is not supported for automatic completion installation." - echo "To install completions manually, run one of the following commands:" - for cmd in myagents myclaude mykimi; do - var="_$(_upcase "$cmd")_COMPLETE" - echo " zsh: $var=zsh_source ${VENV_BIN_DIR}/${cmd} > /path/to/completions/_${cmd}" - echo " bash: $var=bash_source ${VENV_BIN_DIR}/${cmd} > /path/to/completions/${cmd}.bash" - done -fi diff --git a/scripts/rm_user_local_myagents.py b/scripts/rm_user_local_myagents.py index e050ab8..38a3af5 100644 --- a/scripts/rm_user_local_myagents.py +++ b/scripts/rm_user_local_myagents.py @@ -43,6 +43,16 @@ def main() -> int: print("Skip:", link, "is not a symlink, leaving untouched") skipped += 1 + # Clean up legacy completion files from the old manual install location. + legacy_comp_dir = os.path.expanduser("~/.local/bin/completions") + for command in _COMMANDS: + for filename in (f"_{command}", f"{command}.bash"): + path = os.path.join(legacy_comp_dir, filename) + if os.path.isfile(path): + os.unlink(path) + print("Removed legacy completion", path) + removed += 1 + if removed == 0 and skipped == 0: print("Nothing to remove") return 0