From 1ee5a31ccf45e138cda9790a5fc46f20b013eb69 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sat, 4 Jul 2026 19:24:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(completion):=20=E8=87=AA=E5=8A=A8=E5=AE=89?= =?UTF-8?q?=E8=A3=85=20shell=20=E8=A1=A5=E5=85=A8=EF=BC=8C=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=97=A7=E9=85=8D=E7=BD=AE=E5=B9=B6=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/mytoolkit/SKILL.md | 7 +- .claude/skills/mytoolkit/references/others.md | 1 + CLAUDE.md | 2 +- Makefile | 29 +- README.md | 13 +- mytoolkit/cli.py | 10 +- mytoolkit/commands/completion.py | 139 ++++++++++ mytoolkit/commands/completion_install.py | 258 ++++++++++++++++++ mytoolkit/commands/self_mgmt.py | 72 ++--- mytoolkit/config.py | 30 +- mytoolkit/scaffolds/paper/README.md | 1 - mytoolkit/scaffolds/paper/analysis/.gitkeep | 0 12 files changed, 478 insertions(+), 84 deletions(-) create mode 100644 mytoolkit/commands/completion.py create mode 100644 mytoolkit/commands/completion_install.py delete mode 100644 mytoolkit/scaffolds/paper/analysis/.gitkeep diff --git a/.claude/skills/mytoolkit/SKILL.md b/.claude/skills/mytoolkit/SKILL.md index c5c190a..820018b 100644 --- a/.claude/skills/mytoolkit/SKILL.md +++ b/.claude/skills/mytoolkit/SKILL.md @@ -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 ` 直接读取;技能内 `references/` 仅保留速查。 diff --git a/.claude/skills/mytoolkit/references/others.md b/.claude/skills/mytoolkit/references/others.md index fab560b..355237c 100644 --- a/.claude/skills/mytoolkit/references/others.md +++ b/.claude/skills/mytoolkit/references/others.md @@ -10,4 +10,5 @@ mytoolkit ssh # SSH 快捷连接 mytoolkit server # 开发服务器管理 mytoolkit update # 从本地仓库更新 mytoolkit uninstall # 卸载 +mytoolkit completion # 安装/查看/卸载 shell 补全(install / uninstall / doctor / zsh / bash) ``` diff --git a/CLAUDE.md b/CLAUDE.md index 4686e89..5deaee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ mytoolkit voice tts "文本" -v zh_male_wennuanahu_moon_bigtts --format mp3 --sp mytoolkit init paper # 默认 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)**,否则破坏可移植性; diff --git a/Makefile b/Makefile index 415f6e1..a365085 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index a93d2f4..aa90a14 100644 --- a/README.md +++ b/README.md @@ -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 --help`. @@ -85,7 +94,7 @@ Registry is stored at `~/.mytoolkit/templates.json` (or `$MYTOOLKIT_HOME/templat mytoolkit init paper # 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 diff --git a/mytoolkit/cli.py b/mytoolkit/cli.py index 2c4f450..f2d52dd 100644 --- a/mytoolkit/cli.py +++ b/mytoolkit/cli.py @@ -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__": diff --git a/mytoolkit/commands/completion.py b/mytoolkit/commands/completion.py new file mode 100644 index 0000000..d492bad --- /dev/null +++ b/mytoolkit/commands/completion.py @@ -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 diff --git a/mytoolkit/commands/completion_install.py b/mytoolkit/commands/completion_install.py new file mode 100644 index 0000000..0c1cc79 --- /dev/null +++ b/mytoolkit/commands/completion_install.py @@ -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 diff --git a/mytoolkit/commands/self_mgmt.py b/mytoolkit/commands/self_mgmt.py index 4fde17b..aac74db 100644 --- a/mytoolkit/commands/self_mgmt.py +++ b/mytoolkit/commands/self_mgmt.py @@ -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"}, - 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") - 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") + result = subprocess.run( + [venv_toolkit, "completion", "install"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + click.secho(f"Installed completion: {line}", fg="green") + else: + 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(): diff --git a/mytoolkit/config.py b/mytoolkit/config.py index 5f2832d..98347ac 100644 --- a/mytoolkit/config.py +++ b/mytoolkit/config.py @@ -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(): diff --git a/mytoolkit/scaffolds/paper/README.md b/mytoolkit/scaffolds/paper/README.md index 02c5f20..2e0dc44 100644 --- a/mytoolkit/scaffolds/paper/README.md +++ b/mytoolkit/scaffolds/paper/README.md @@ -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 diff --git a/mytoolkit/scaffolds/paper/analysis/.gitkeep b/mytoolkit/scaffolds/paper/analysis/.gitkeep deleted file mode 100644 index e69de29..0000000