feat(completion): 自动安装 shell 补全,迁移旧配置并清理手动补全
This commit is contained in:
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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)**,否则破坏可移植性;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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."""
|
||||
|
||||
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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user