feat(completion): 自动安装 shell 补全,迁移旧配置并清理手动补全

This commit is contained in:
Zhengshou Lai
2026-07-04 19:24:58 +08:00
parent 9b7439fdc6
commit 1ee5a31ccf
12 changed files with 478 additions and 84 deletions
+4 -3
View File
@@ -25,10 +25,11 @@ triggers:
# MyToolkit 个人 CLI 工具集
`mytoolkit` 是日常文件处理、格式转换、批量操作的便捷工具集。所有命令统一在 `mytoolkit` 命名空间下,支持 zsh tab completion。
`mytoolkit` 是日常文件处理、格式转换、批量操作的便捷工具集。所有命令统一在 `mytoolkit` 命名空间下,支持 zsh/bash tab completion。补全脚本由 `mytoolkit completion install` 自动安装到当前 Python prefix,首次运行任意命令时若缺失会自动补齐。
```bash
eval "$(_MYTOOLKIT_COMPLETE=zsh_source mytoolkit)"
mytoolkit completion doctor # 检查补全安装状态
mytoolkit completion zsh # 打印 zsh 补全脚本
```
## 与其他 Skill 的分工
@@ -54,7 +55,7 @@ eval "$(_MYTOOLKIT_COMPLETE=zsh_source mytoolkit)"
| `video` | 视频格式转换 | `references/video.md` |
| `webpage` | Docusaurus 网站构建、部署、server 管理 | `mytoolkit info docs webpage` |
| `mail` | 邮箱管理(inbox、read、draft | `references/mail.md` |
| `env/git/ssh/server/update/uninstall` | 其他辅助命令 | `references/others.md` |
| `env/git/ssh/server/update/uninstall/completion` | 其他辅助命令 | `references/others.md` |
> 注:`convert`、`pdf`、`webpage` 的完整参考文档已随 mytoolkit 包安装,通过 `mytoolkit info docs <name>` 直接读取;技能内 `references/` 仅保留速查。
@@ -10,4 +10,5 @@ mytoolkit ssh # SSH 快捷连接
mytoolkit server # 开发服务器管理
mytoolkit update # 从本地仓库更新
mytoolkit uninstall # 卸载
mytoolkit completion # 安装/查看/卸载 shell 补全(install / uninstall / doctor / zsh / bash
```
+1 -1
View File
@@ -78,7 +78,7 @@ mytoolkit voice tts "文本" -v zh_male_wennuanahu_moon_bigtts --format mp3 --sp
mytoolkit init paper <dir> # 默认 Elsevier elsarticle 模板(全平台可移植)
```
生成 `main.tex`/`Makefile`/`diffpreamble.dtx`/`references.bib`/`figs/`/`analysis/`。**精确配置(文档类选项、字号、字体、书签层级)以脚手架 `main.tex` 头部注释为准,本文档不复述以免漂移。**
生成 `main.tex`/`Makefile`/`diffpreamble.dtx`/`references.bib`/`figs/`。**精确配置(文档类选项、字号、字体、书签层级)以脚手架 `main.tex` 头部注释为准,本文档不复述以免漂移。**
改脚手架时须保持的**设计约束**
- 文档类 **elsarticle**;正文+数学只用 **TeX Live 自带字体**(当前 Libertinus),**禁止写死系统/专有字体(如 Cambria)**,否则破坏可移植性;
+4 -25
View File
@@ -1,10 +1,6 @@
ROOT_DIR := $(shell pwd)
COMP_DIR := $(HOME)/.local/bin/completions
PYTHON ?= python3
# 手动安装的文件清单(与 bin/commands/self_mgmt.py 中的 _MANUAL_FILES 保持同步)
MANUAL_FILES := $(COMP_DIR)/_mytoolkit $(COMP_DIR)/mytoolkit.bash
.PHONY: help install uninstall sync-skill
help:
@@ -12,22 +8,12 @@ help:
@echo ""
@echo " install Install mytoolkit in editable mode (current Python) and sync skill"
@echo " sync-skill Copy the bundled Claude skill to ~/.claude/skills/mytoolkit/"
@echo " uninstall Uninstall mytoolkit and remove completions"
@echo " uninstall Uninstall mytoolkit and remove shell completions"
install:
$(PYTHON) -m pip install -e "$(ROOT_DIR)" --upgrade
@echo "Installing completions…"
@mkdir -p "$(COMP_DIR)"
@SHELL_NAME=$$(basename "$$SHELL"); \
if [ "$$SHELL_NAME" = "zsh" ]; then \
_MYTOOLKIT_COMPLETE=zsh_source mytoolkit > "$(COMP_DIR)/_mytoolkit" && \
echo "zsh completion: $(COMP_DIR)/_mytoolkit" || \
echo "Warning: zsh completion failed"; \
elif [ "$$SHELL_NAME" = "bash" ]; then \
_MYTOOLKIT_COMPLETE=bash_source mytoolkit > "$(COMP_DIR)/mytoolkit.bash" && \
echo "bash completion: $(COMP_DIR)/mytoolkit.bash" || \
echo "Warning: bash completion failed"; \
fi
@echo "Installing shell completions…"
@mytoolkit completion install || echo "Warning: completion install failed"
@$(MAKE) -s sync-skill
sync-skill:
@@ -41,11 +27,4 @@ sync-skill:
fi
uninstall:
$(PYTHON) -m pip uninstall mytoolkit -y
@for f in $(MANUAL_FILES); do \
if [ -e "$$f" ] || [ -L "$$f" ]; then \
rm -f "$$f"; \
echo "Removed $$f"; \
fi; \
done
@echo "Uninstalled mytoolkit and cleaned up manual files"
@mytoolkit uninstall || $(PYTHON) -m pip uninstall mytoolkit -y
+11 -2
View File
@@ -12,9 +12,17 @@ mytoolkit templates list # verify bundled templates are fou
`make install` 会同时:
1. 用 editable 模式安装 `mytoolkit`
2. 生成 zsh/bash 补全;
2. 自动安装 zsh/bash 补全到当前 Python prefix 的共享目录
3. 把 bundled Claude skill 同步到 `~/.claude/skills/mytoolkit/`
补全由 `mytoolkit` 自己管理,无需手动写 `~/.local/bin/completions`,也无需在 `.zshrc``eval`。首次运行任意 `mytoolkit` 命令时会自动补齐;也可以手动检查状态:
```bash
mytoolkit completion doctor
mytoolkit completion install # 手动重装补全
mytoolkit completion zsh # 打印 zsh 补全脚本
```
单独同步 skill(不重装 Python 包):
```bash
@@ -58,6 +66,7 @@ mytoolkit --help
| `server` | Manage dev/serve servers |
| `update` | Reinstall from local repo |
| `uninstall` | Uninstall |
| `completion` | Install/inspect shell tab-completion scripts (`install` / `uninstall` / `doctor` / `zsh` / `bash`) |
For any subcommand: `mytoolkit <cmd> --help`.
@@ -85,7 +94,7 @@ Registry is stored at `~/.mytoolkit/templates.json` (or `$MYTOOLKIT_HOME/templat
mytoolkit init paper <dir> # create a LaTeX paper project (Elsevier elsarticle, xelatex, fully portable)
```
This generates `main.tex`, `Makefile`, `references.bib`, `diffpreamble.dtx`, `figs/`, `analysis/`.
This generates `main.tex`, `Makefile`, `references.bib`, `diffpreamble.dtx`, `figs/`.
### Journal submission kits
+9 -1
View File
@@ -16,6 +16,8 @@ from mytoolkit.commands.server import server_cmd
from mytoolkit.commands.webpage import webpage_cmd
from mytoolkit.commands.mycv import mycv_cmd
from mytoolkit.commands.mail import mail
from mytoolkit.commands.completion import build_completion_group
from mytoolkit.commands.completion_install import ensure_completions_installed
from mytoolkit.commands.self_mgmt import update_cmd, uninstall_cmd
@@ -47,10 +49,16 @@ cli.add_command(mycv_cmd)
cli.add_command(mail)
cli.add_command(update_cmd)
cli.add_command(uninstall_cmd)
cli.add_command(build_completion_group(lambda: cli))
def main():
cli()
import sys
argv = sys.argv[1:]
if not argv or argv[0] != "completion":
ensure_completions_installed(cli)
cli.main(args=argv, prog_name="mytoolkit", standalone_mode=True)
if __name__ == "__main__":
+139
View File
@@ -0,0 +1,139 @@
"""Shell tab-completion CLI for mytoolkit."""
from __future__ import annotations
import shlex
import subprocess
import sys
from typing import TYPE_CHECKING
import click
from click.shell_completion import get_completion_class
from mytoolkit.commands.completion_install import (
CLICK_COMPLETE_VAR,
PROG_NAME,
completion_targets,
completions_installed,
install_completions,
python_prefix,
python_share_dir,
uninstall_completions,
)
if TYPE_CHECKING:
from click.shell_completion import CompletionItem
def _completion_script(cli: click.Command, shell: 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, CLICK_COMPLETE_VAR)
script = comp.source()
if not script.endswith("\n"):
script += "\n"
return script
def _smoke_subcommands(cli: click.Command) -> list[str]:
shell_class = get_completion_class("bash")
if shell_class is None:
return []
comp = shell_class(cli, {}, PROG_NAME, CLICK_COMPLETE_VAR)
items: list[CompletionItem] = comp.get_completions([], "")
return sorted({item.value for item in items})
def run_completion_doctor(cli: click.Command) -> None:
prefix = python_prefix()
share = python_share_dir()
click.echo(click.style("Python prefix", bold=True) + f": {prefix}")
click.echo(click.style("Share dir", bold=True) + f": {share}")
click.echo()
click.echo(click.style("Completion scripts", fg="blue", bold=True))
for target in completion_targets():
if target.installed and CLICK_COMPLETE_VAR 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.path} [{status}]")
click.echo()
zsh_dir = share / "zsh" / "site-functions"
if zsh_dir.is_dir():
try:
quoted = shlex.quote(str(zsh_dir))
check = subprocess.run(
[
"zsh",
"-c",
f"source ~/.zshrc >/dev/null 2>&1; "
f"[[ -n ${{fpath[(r){quoted}]}} ]] && echo yes",
],
capture_output=True,
text=True,
check=False,
)
in_fpath = "yes" in check.stdout
except (OSError, subprocess.SubprocessError):
in_fpath = True # be permissive if we cannot check
if not in_fpath:
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():
click.echo(
click.style("Fix", fg="yellow", bold=True)
+ ": run `mytoolkit completion install` or reinstall the package."
)
return
subs = _smoke_subcommands(cli)
click.echo(
click.style("Smoke (subcommands)", fg="blue", bold=True) + f": {', '.join(subs)}"
)
def build_completion_group(cli_factory):
@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 under the active Python prefix."""
installed = install_completions(cli_factory())
if not installed:
raise click.ClickException("No completion files were written (prefix not writable?).")
for path in installed:
click.echo(path)
@completion.command("uninstall")
def uninstall_cmd() -> None:
"""Remove completion scripts installed by this package."""
removed = uninstall_completions()
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 and run smoke checks."""
run_completion_doctor(cli_factory())
for shell in ("bash", "zsh"):
@completion.command(name=shell, help=f"Print the {shell} completion script to stdout.")
def print_shell(shell_name: str = shell) -> None:
script = _completion_script(cli_factory(), shell_name)
sys.stdout.write(script)
return completion
+258
View File
@@ -0,0 +1,258 @@
"""Install shell tab-completion files under the active Python prefix.
Adapted from phynexis-server's completion_install.py to work with the
mytoolkit Click CLI and hatchling-based editable installs.
"""
from __future__ import annotations
import base64
import hashlib
import os
import site
import sysconfig
from dataclasses import dataclass
from pathlib import Path
CLICK_COMPLETE_VAR = "_MYTOOLKIT_COMPLETE"
PROG_NAME = "mytoolkit"
MANIFEST_FILENAME = "completion-paths.txt"
@dataclass(frozen=True)
class _CompletionConfig:
zsh_filename: str
bash_filename: str
click_complete_var: str
dist_info_globs: tuple[str, ...]
_CONFIG = _CompletionConfig(
zsh_filename="_mytoolkit",
bash_filename="mytoolkit",
click_complete_var=CLICK_COMPLETE_VAR,
dist_info_globs=("mytoolkit-*.dist-info",),
)
ZSH_FILENAME = _CONFIG.zsh_filename
BASH_FILENAME = _CONFIG.bash_filename
@dataclass(frozen=True)
class CompletionTarget:
shell: str
path: Path
@property
def installed(self) -> bool:
return self.path.is_file()
def python_share_dir(*, install_root: Path | None = None) -> Path:
if install_root is not None:
return install_root / "share"
data = Path(sysconfig.get_path("data"))
if data.name == "share":
return data
return data / "share"
def python_prefix(*, install_root: Path | None = None) -> Path:
if install_root is not None:
return install_root
data = Path(sysconfig.get_path("data"))
if data.name == "share":
return data.parent
return data
def completion_targets(*, install_root: Path | None = None) -> list[CompletionTarget]:
share = python_share_dir(install_root=install_root)
return [
CompletionTarget("zsh", share / "zsh" / "site-functions" / _CONFIG.zsh_filename),
CompletionTarget(
"bash",
share / "bash-completion" / "completions" / _CONFIG.bash_filename,
),
]
def _script_for_shell(shell: str, cli) -> str:
from click.shell_completion import get_completion_class
shell_class = get_completion_class(shell)
if shell_class is None:
msg = f"unsupported shell for completion: {shell}"
raise ValueError(msg)
comp = shell_class(cli, {}, PROG_NAME, CLICK_COMPLETE_VAR)
script = comp.source()
if not script.endswith("\n"):
script += "\n"
return script
def _record_line(record_path: str, content: bytes) -> str:
digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode().rstrip("=")
return f"{record_path},{digest},{len(content)}"
def _record_path(install_root: Path, target: Path) -> str:
return os.path.relpath(target, install_root).replace(os.sep, "/")
def append_record_entries(
record_path: Path,
installed: list[Path],
install_root: Path,
) -> None:
if not installed:
return
existing = record_path.read_text(encoding="utf-8") if record_path.is_file() else ""
lines = [line for line in existing.splitlines() if line.strip()]
known = {line.split(",", 1)[0] for line in lines if "," in line}
for path in installed:
record_key = _record_path(install_root, path)
if record_key in known:
continue
content = path.read_bytes()
lines.append(_record_line(record_key, content))
known.add(record_key)
record_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_manifest(dist_info_dir: Path, installed: list[Path]) -> None:
manifest = dist_info_dir / MANIFEST_FILENAME
manifest.write_text("\n".join(str(path) for path in installed) + "\n", encoding="utf-8")
def read_manifest(dist_info_dir: Path | None) -> list[Path]:
if dist_info_dir is None:
return []
manifest = dist_info_dir / MANIFEST_FILENAME
if not manifest.is_file():
return []
return [
Path(line.strip())
for line in manifest.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def _site_package_roots() -> list[Path]:
roots: list[Path] = []
seen: set[Path] = set()
for raw in (*site.getsitepackages(), site.getusersitepackages()):
if not raw:
continue
root = Path(raw)
if root in seen:
continue
seen.add(root)
roots.append(root)
return roots
def find_dist_info_dir() -> Path | None:
for root in _site_package_roots():
if not root.is_dir():
continue
for pattern in _CONFIG.dist_info_globs:
matches = sorted(root.glob(pattern))
if matches:
return matches[-1]
return None
def install_root_from_install_lib(install_lib: str | None) -> Path | None:
if not install_lib:
return None
path = Path(install_lib).resolve()
if path.name != "site-packages":
return None
return path.parent.parent.parent
def install_completions(
cli,
*,
install_root: Path | None = None,
dist_info_dir: Path | None = None,
) -> list[Path]:
root = install_root or python_prefix()
scripts = {shell: _script_for_shell(shell, cli) for shell in ("zsh", "bash")}
installed: list[Path] = []
for target in completion_targets(install_root=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.shell], encoding="utf-8")
installed.append(target.path)
dist_info = dist_info_dir or find_dist_info_dir()
if dist_info is not None:
write_manifest(dist_info, installed)
record_path = dist_info / "RECORD"
if record_path.is_file():
append_record_entries(record_path, installed, root)
return installed
def _completion_script_is_current(path: Path, marker: str) -> bool:
if not path.is_file():
return False
return marker in path.read_text(encoding="utf-8")
def completions_installed(*, install_root: Path | None = None) -> bool:
for target in completion_targets(install_root=install_root):
if target.shell == "zsh" and _completion_script_is_current(
target.path,
_CONFIG.click_complete_var,
):
return True
return False
def ensure_completions_installed(cli) -> None:
if completions_installed():
return
try:
install_completions(cli, dist_info_dir=find_dist_info_dir())
except (OSError, ValueError):
return
def uninstall_completions(*, dist_info_dir: Path | None = None) -> list[Path]:
dist_info = dist_info_dir or find_dist_info_dir()
removed: list[Path] = []
manifest_paths = read_manifest(dist_info)
candidates = manifest_paths or [target.path for target in completion_targets()]
seen: set[Path] = set()
for path in candidates:
if path in seen:
continue
seen.add(path)
if path.is_file():
path.unlink()
removed.append(path)
if dist_info is not None:
record_path = dist_info / "RECORD"
if record_path.is_file():
root = install_root_from_install_lib(str(dist_info.parent))
if root is None:
root = dist_info.parent.parent.parent
removed_keys = {_record_path(root, path) for path in removed}
kept = [
line
for line in record_path.read_text(encoding="utf-8").splitlines()
if line.strip() and line.split(",", 1)[0] not in removed_keys
]
record_path.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
manifest = dist_info / MANIFEST_FILENAME
if manifest.is_file():
manifest.unlink()
return removed
+17 -43
View File
@@ -1,6 +1,5 @@
"""Self-management commands for bin."""
import os
import shutil
import subprocess
import sys
@@ -8,14 +7,7 @@ from pathlib import Path
import click
# 手动安装的文件清单(pip 管理范围外,uninstall 时需要额外清理)
_MANUAL_FILES: dict[str, list[Path]] = {
"completions": [
Path.home() / ".local" / "bin" / "completions" / "_mytoolkit",
Path.home() / ".local" / "bin" / "completions" / "mytoolkit.bash",
],
}
from mytoolkit.commands.completion_install import uninstall_completions
def _get_project_root() -> Path:
@@ -44,59 +36,41 @@ def _install(root: Path) -> None:
def _install_completions() -> None:
"""Install shell completions under the active Python prefix."""
venv_toolkit = shutil.which("mytoolkit")
if not venv_toolkit:
click.secho("Warning: cannot generate completions (mytoolkit not in PATH)", fg="yellow")
click.secho("Warning: cannot install completions (mytoolkit not in PATH)", fg="yellow")
return
comp_dir = Path.home() / ".local" / "bin" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
shell = os.environ.get("SHELL", "")
if "zsh" in shell:
comp_file = comp_dir / "_mytoolkit"
result = subprocess.run(
[venv_toolkit],
env={**os.environ, "_MYTOOLKIT_COMPLETE": "zsh_source"},
[venv_toolkit, "completion", "install"],
capture_output=True,
text=True,
)
if result.returncode == 0:
comp_file.write_text(result.stdout)
click.secho(f"Installed zsh completion: {comp_file}", fg="green")
for line in result.stdout.splitlines():
click.secho(f"Installed completion: {line}", fg="green")
else:
click.secho("Warning: failed to generate zsh completion", fg="yellow")
elif "bash" in shell:
comp_file = comp_dir / "mytoolkit.bash"
result = subprocess.run(
[venv_toolkit],
env={**os.environ, "_MYTOOLKIT_COMPLETE": "bash_source"},
capture_output=True,
text=True,
)
if result.returncode == 0:
comp_file.write_text(result.stdout)
click.secho(f"Installed bash completion: {comp_file}", fg="green")
else:
click.secho("Warning: failed to generate bash completion", fg="yellow")
click.secho("Warning: failed to install completions", fg="yellow")
if result.stderr:
click.secho(result.stderr, fg="yellow")
def _uninstall() -> None:
# Remove completion scripts first so pip uninstall keeps things clean.
try:
removed = uninstall_completions()
for path in removed:
click.secho(f"Removed completion: {path}", fg="green")
except (OSError, ValueError) as exc:
click.secho(f"Warning: failed to remove completions: {exc}", fg="yellow")
click.secho("Uninstalling mytoolkit package…", fg="cyan")
subprocess.run(
[sys.executable, "-m", "pip", "uninstall", "mytoolkit", "-y"],
check=False,
)
removed = 0
for paths in _MANUAL_FILES.values():
for f in paths:
if f.exists() or f.is_symlink():
f.unlink()
click.secho(f"Removed {f}", fg="green")
removed += 1
if removed == 0:
click.secho("No manual files to clean up.", fg="cyan")
@click.command(name="update")
def update_cmd():
+28 -2
View File
@@ -18,10 +18,36 @@ class Config:
self._data = self._load()
def _migrate_legacy(self) -> None:
"""One-shot move of legacy bin/config.json into the user config dir."""
if _LEGACY_PATH.exists() and not self._config_path.exists():
"""One-shot move of legacy config files into env.json."""
if self._config_path.exists():
return
migrated = False
# 1) Legacy bundled config.json next to this module.
if _LEGACY_PATH.exists():
self._config_path.parent.mkdir(parents=True, exist_ok=True)
_LEGACY_PATH.rename(self._config_path)
migrated = True
# 2) Old ~/.mytoolkit/config.json (sectioned layout) -> flat env.json vars.
legacy_user = self._config_path.parent / "config.json"
if not migrated and legacy_user.exists():
try:
with open(legacy_user, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
data = {}
flat_vars: dict[str, str] = {}
for section in ("paths", "keys", "tokens", "other"):
section_data = data.get(section)
if isinstance(section_data, dict):
flat_vars.update(section_data)
if flat_vars:
self._data = {"vars": flat_vars}
self.save()
legacy_user.unlink()
migrated = True
def _load(self) -> dict:
if self._config_path.exists():
-1
View File
@@ -11,7 +11,6 @@ fully portable) and compiles with `xelatex` via `latexmk`.
- `diffpreamble.dtx`: `latexdiff` style preamble
- `references.bib`: bibliography
- `figs/`: figures
- `analysis/`: analysis scripts and data
- `build/`: build output (auto-generated)
## Build