feat(completion): 自动安装 shell 补全,迁移旧配置并清理手动补全
This commit is contained in:
@@ -25,10 +25,11 @@ triggers:
|
|||||||
|
|
||||||
# MyToolkit 个人 CLI 工具集
|
# MyToolkit 个人 CLI 工具集
|
||||||
|
|
||||||
`mytoolkit` 是日常文件处理、格式转换、批量操作的便捷工具集。所有命令统一在 `mytoolkit` 命名空间下,支持 zsh tab completion。
|
`mytoolkit` 是日常文件处理、格式转换、批量操作的便捷工具集。所有命令统一在 `mytoolkit` 命名空间下,支持 zsh/bash tab completion。补全脚本由 `mytoolkit completion install` 自动安装到当前 Python prefix,首次运行任意命令时若缺失会自动补齐。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(_MYTOOLKIT_COMPLETE=zsh_source mytoolkit)"
|
mytoolkit completion doctor # 检查补全安装状态
|
||||||
|
mytoolkit completion zsh # 打印 zsh 补全脚本
|
||||||
```
|
```
|
||||||
|
|
||||||
## 与其他 Skill 的分工
|
## 与其他 Skill 的分工
|
||||||
@@ -54,7 +55,7 @@ eval "$(_MYTOOLKIT_COMPLETE=zsh_source mytoolkit)"
|
|||||||
| `video` | 视频格式转换 | `references/video.md` |
|
| `video` | 视频格式转换 | `references/video.md` |
|
||||||
| `webpage` | Docusaurus 网站构建、部署、server 管理 | `mytoolkit info docs webpage` |
|
| `webpage` | Docusaurus 网站构建、部署、server 管理 | `mytoolkit info docs webpage` |
|
||||||
| `mail` | 邮箱管理(inbox、read、draft) | `references/mail.md` |
|
| `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/` 仅保留速查。
|
> 注:`convert`、`pdf`、`webpage` 的完整参考文档已随 mytoolkit 包安装,通过 `mytoolkit info docs <name>` 直接读取;技能内 `references/` 仅保留速查。
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ mytoolkit ssh # SSH 快捷连接
|
|||||||
mytoolkit server # 开发服务器管理
|
mytoolkit server # 开发服务器管理
|
||||||
mytoolkit update # 从本地仓库更新
|
mytoolkit update # 从本地仓库更新
|
||||||
mytoolkit uninstall # 卸载
|
mytoolkit uninstall # 卸载
|
||||||
|
mytoolkit completion # 安装/查看/卸载 shell 补全(install / uninstall / doctor / zsh / bash)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ mytoolkit voice tts "文本" -v zh_male_wennuanahu_moon_bigtts --format mp3 --sp
|
|||||||
mytoolkit init paper <dir> # 默认 Elsevier elsarticle 模板(全平台可移植)
|
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)**,否则破坏可移植性;
|
- 文档类 **elsarticle**;正文+数学只用 **TeX Live 自带字体**(当前 Libertinus),**禁止写死系统/专有字体(如 Cambria)**,否则破坏可移植性;
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
ROOT_DIR := $(shell pwd)
|
ROOT_DIR := $(shell pwd)
|
||||||
COMP_DIR := $(HOME)/.local/bin/completions
|
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
|
|
||||||
# 手动安装的文件清单(与 bin/commands/self_mgmt.py 中的 _MANUAL_FILES 保持同步)
|
|
||||||
MANUAL_FILES := $(COMP_DIR)/_mytoolkit $(COMP_DIR)/mytoolkit.bash
|
|
||||||
|
|
||||||
.PHONY: help install uninstall sync-skill
|
.PHONY: help install uninstall sync-skill
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@@ -12,22 +8,12 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo " install Install mytoolkit in editable mode (current Python) and sync skill"
|
@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 " 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:
|
install:
|
||||||
$(PYTHON) -m pip install -e "$(ROOT_DIR)" --upgrade
|
$(PYTHON) -m pip install -e "$(ROOT_DIR)" --upgrade
|
||||||
@echo "Installing completions…"
|
@echo "Installing shell completions…"
|
||||||
@mkdir -p "$(COMP_DIR)"
|
@mytoolkit completion install || echo "Warning: completion install failed"
|
||||||
@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
|
|
||||||
@$(MAKE) -s sync-skill
|
@$(MAKE) -s sync-skill
|
||||||
|
|
||||||
sync-skill:
|
sync-skill:
|
||||||
@@ -41,11 +27,4 @@ sync-skill:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
$(PYTHON) -m pip uninstall mytoolkit -y
|
@mytoolkit 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"
|
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ mytoolkit templates list # verify bundled templates are fou
|
|||||||
|
|
||||||
`make install` 会同时:
|
`make install` 会同时:
|
||||||
1. 用 editable 模式安装 `mytoolkit`;
|
1. 用 editable 模式安装 `mytoolkit`;
|
||||||
2. 生成 zsh/bash 补全;
|
2. 自动安装 zsh/bash 补全到当前 Python prefix 的共享目录;
|
||||||
3. 把 bundled Claude skill 同步到 `~/.claude/skills/mytoolkit/`。
|
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 包):
|
单独同步 skill(不重装 Python 包):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -58,6 +66,7 @@ mytoolkit --help
|
|||||||
| `server` | Manage dev/serve servers |
|
| `server` | Manage dev/serve servers |
|
||||||
| `update` | Reinstall from local repo |
|
| `update` | Reinstall from local repo |
|
||||||
| `uninstall` | Uninstall |
|
| `uninstall` | Uninstall |
|
||||||
|
| `completion` | Install/inspect shell tab-completion scripts (`install` / `uninstall` / `doctor` / `zsh` / `bash`) |
|
||||||
|
|
||||||
For any subcommand: `mytoolkit <cmd> --help`.
|
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)
|
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
|
### Journal submission kits
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -16,6 +16,8 @@ from mytoolkit.commands.server import server_cmd
|
|||||||
from mytoolkit.commands.webpage import webpage_cmd
|
from mytoolkit.commands.webpage import webpage_cmd
|
||||||
from mytoolkit.commands.mycv import mycv_cmd
|
from mytoolkit.commands.mycv import mycv_cmd
|
||||||
from mytoolkit.commands.mail import mail
|
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
|
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(mail)
|
||||||
cli.add_command(update_cmd)
|
cli.add_command(update_cmd)
|
||||||
cli.add_command(uninstall_cmd)
|
cli.add_command(uninstall_cmd)
|
||||||
|
cli.add_command(build_completion_group(lambda: cli))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Self-management commands for bin."""
|
"""Self-management commands for bin."""
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -8,14 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from mytoolkit.commands.completion_install import uninstall_completions
|
||||||
# 手动安装的文件清单(pip 管理范围外,uninstall 时需要额外清理)
|
|
||||||
_MANUAL_FILES: dict[str, list[Path]] = {
|
|
||||||
"completions": [
|
|
||||||
Path.home() / ".local" / "bin" / "completions" / "_mytoolkit",
|
|
||||||
Path.home() / ".local" / "bin" / "completions" / "mytoolkit.bash",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_project_root() -> Path:
|
def _get_project_root() -> Path:
|
||||||
@@ -44,59 +36,41 @@ def _install(root: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _install_completions() -> None:
|
def _install_completions() -> None:
|
||||||
|
"""Install shell completions under the active Python prefix."""
|
||||||
venv_toolkit = shutil.which("mytoolkit")
|
venv_toolkit = shutil.which("mytoolkit")
|
||||||
if not venv_toolkit:
|
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
|
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(
|
result = subprocess.run(
|
||||||
[venv_toolkit],
|
[venv_toolkit, "completion", "install"],
|
||||||
env={**os.environ, "_MYTOOLKIT_COMPLETE": "zsh_source"},
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
comp_file.write_text(result.stdout)
|
for line in result.stdout.splitlines():
|
||||||
click.secho(f"Installed zsh completion: {comp_file}", fg="green")
|
click.secho(f"Installed completion: {line}", fg="green")
|
||||||
else:
|
else:
|
||||||
click.secho("Warning: failed to generate zsh completion", fg="yellow")
|
click.secho("Warning: failed to install completions", fg="yellow")
|
||||||
elif "bash" in shell:
|
if result.stderr:
|
||||||
comp_file = comp_dir / "mytoolkit.bash"
|
click.secho(result.stderr, fg="yellow")
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def _uninstall() -> None:
|
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")
|
click.secho("Uninstalling mytoolkit package…", fg="cyan")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "uninstall", "mytoolkit", "-y"],
|
[sys.executable, "-m", "pip", "uninstall", "mytoolkit", "-y"],
|
||||||
check=False,
|
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")
|
@click.command(name="update")
|
||||||
def update_cmd():
|
def update_cmd():
|
||||||
|
|||||||
+28
-2
@@ -18,10 +18,36 @@ class Config:
|
|||||||
self._data = self._load()
|
self._data = self._load()
|
||||||
|
|
||||||
def _migrate_legacy(self) -> None:
|
def _migrate_legacy(self) -> None:
|
||||||
"""One-shot move of legacy bin/config.json into the user config dir."""
|
"""One-shot move of legacy config files into env.json."""
|
||||||
if _LEGACY_PATH.exists() and not self._config_path.exists():
|
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)
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_LEGACY_PATH.rename(self._config_path)
|
_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:
|
def _load(self) -> dict:
|
||||||
if self._config_path.exists():
|
if self._config_path.exists():
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ fully portable) and compiles with `xelatex` via `latexmk`.
|
|||||||
- `diffpreamble.dtx`: `latexdiff` style preamble
|
- `diffpreamble.dtx`: `latexdiff` style preamble
|
||||||
- `references.bib`: bibliography
|
- `references.bib`: bibliography
|
||||||
- `figs/`: figures
|
- `figs/`: figures
|
||||||
- `analysis/`: analysis scripts and data
|
|
||||||
- `build/`: build output (auto-generated)
|
- `build/`: build output (auto-generated)
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|||||||
Reference in New Issue
Block a user