From 3e4cf618a8c9c4ba5e877644486b2b1154f412d6 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Mon, 4 May 2026 17:19:32 +0800 Subject: [PATCH] refactor: restructure mytoolkit with new subcommands and md-to-pdf improvements - Add new subcommands: convert, preflight, server, templates, webpage - Migrate config from bin/config.json to ~/.config/mytoolkit - Fix expand_bookmarks to modify writer objects instead of reader - Improve md_to_pdf with CJK bookmark support - Update README and project metadata --- .gitignore | 3 - README.md | 175 ++++++--------- bin/cli.py | 21 +- bin/commands/__init__.py | 4 +- bin/commands/convert.py | 206 ++++++++++++++++++ bin/commands/env.py | 4 +- bin/commands/image.py | 53 +++++ bin/commands/pdf.py | 46 +++- bin/commands/preflight.py | 119 +++++++++++ bin/commands/self_mgmt.py | 123 +++++++---- bin/commands/server.py | 403 +++++++++++++++++++++++++++++++++++ bin/commands/templates.py | 77 +++++++ bin/commands/webpage.py | 89 ++++++++ bin/config.json.example | 48 ----- bin/config.py | 17 +- bin/md_to_pdf.py | 168 ++++++++++++++- bin/templates_registry.py | 60 ++++++ bin/utils.py | 7 +- pyproject.toml | 1 + uv.lock | 436 ++++++++++++++++++++++++++++++++++++++ 20 files changed, 1846 insertions(+), 214 deletions(-) create mode 100644 bin/commands/convert.py create mode 100644 bin/commands/preflight.py create mode 100644 bin/commands/server.py create mode 100644 bin/commands/templates.py create mode 100644 bin/commands/webpage.py delete mode 100644 bin/config.json.example create mode 100644 bin/templates_registry.py diff --git a/.gitignore b/.gitignore index 34e8e84..f45e8b0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,3 @@ build/ .venv/ .pytest_cache/ .mypy_cache/ - -# Config contains sensitive data (API keys) -bin/config.json diff --git a/README.md b/README.md index 179018b..28206db 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,89 @@ -# MyCLI +# mytoolkit -Personal CLI toolkit - unified interface for various utility scripts. +Personal CLI toolkit — unified entry point for everyday file conversion, format wrangling, and project chores. -## Installation +## Install ```bash -make install +make install # builds + symlinks ~/.local/bin/mytoolkit +mytoolkit --version +mytoolkit templates register ~/workspace/templates # one-time, points at template root ``` -## Commands +## Top-level commands ```bash mytoolkit --help - -# PDF utilities -mytoolkit pdf compress *.pdf -mytoolkit pdf crop *.pdf -mytoolkit pdf to-tiff *.pdf - -# Image utilities -mytoolkit image eps-to-pdf *.eps -mytoolkit image jpg-to-pdf *.jpg -o output.pdf -mytoolkit image tiff-to-pdf *.tiff -mytoolkit image tiff-compress *.tiff - -# LaTeX utilities -mytoolkit latex compile *.tex -mytoolkit latex count document.tex - -# Video utilities -mytoolkit video avi-to-mp4 *.avi - -# BibTeX utilities -mytoolkit bib to-markdown *.bib -mytoolkit bib cv-update - -# Utilities -mytoolkit utils albany-cleanup - -# Environment & Navigation -mytoolkit env list # List configured paths -mytoolkit env goto research # Print path for cd -mytoolkit env cd apaam # Start shell in path -mytoolkit env status # Show environment status -cd $(mytoolkit env goto study) # Use in scripts - -# SSH Connections -mytoolkit ssh list # List configured hosts -mytoolkit ssh connect tianhe # SSH to host -mytoolkit ssh connect starlight -mytoolkit ssh tunnel tianhe # Create port tunnel -mytoolkit ssh cmd tianhe "ls" # Run remote command - -# Git Proxy Management -# Git Proxy Management -mytoolkit git proxy status # Show proxy status -mytoolkit git proxy set fastgithub # Set proxy -mytoolkit git proxy set pandafan -mytoolkit git proxy unset # Unset proxy -mytoolkit git proxy list # List available proxies - -# Self Management -mytoolkit self update # Update mytoolkit -mytoolkit self uninstall # Uninstall mytoolkit -mytoolkit self info # Show installation info - -# Configuration (config.json) - 增删改查 -# 方式1: 交互式向导(推荐,无需记忆命令) -mytoolkit wizard # 启动交互式配置向导 -# 方式2: 命令行直接操作 -mytoolkit config show # Show full config -mytoolkit config list # List top-level sections -mytoolkit config get paths # Get paths section -mytoolkit config get paths.apaam # Get specific path -mytoolkit config get api_keys.deepseek # Get API key -mytoolkit config set paths.new ~/Documents/new # Set simple value -mytoolkit config set api_keys.newkey abc123 # Set new API key -mytoolkit config set --json ssh_hosts.new '{"host":"user@host","port":22}' -mytoolkit config delete paths.old # Delete a key -mytoolkit config delete api_keys.old -y # Delete without confirmation -mytoolkit config paths # List all paths with existence check -mytoolkit config where # Show config file location -mytoolkit config edit # Edit config in $EDITOR -mytoolkit config init # Create config from template - -# Environment Variables (API Keys) -mytoolkit env status # Show paths, API keys, Feishu status -mytoolkit env export # Print all export statements -mytoolkit env export deepseek # Export specific API key -mytoolkit env export qwen -mytoolkit env export feishu # Export all Feishu apps -mytoolkit env export claude # Export specific Feishu app -eval "$(mytoolkit env export)" # Load all in current shell -mytoolkit env init # Print shell init snippet ``` -## Configuration +| Command | What it does | +|---------|--------------| +| `convert` | Universal format conversion (md/img/pdf/eps/avi/bib → pdf/docx/tiff/mp4/md). Auto-detects from extensions. | +| `pdf` | `compress` / `crop` / `merge` / `bookmark` / `to-tiff` | +| `image` | `compress` / `eps-fix` / `eps-to-pdf` / `jpg-to-pdf` / `tiff-to-pdf` / `tiff-compress` / `generate` (Doubao/Seedream) | +| `latex` | `compile` / `count` | +| `bib` | `to-markdown` / `cv-update` | +| `video` | `avi-to-mp4` | +| `webpage` | Docusaurus build/list/rebuild | +| `mail` | IMAP/SMTP client (read/draft/forward/reply, no auto-send) | +| `templates` | Register and inspect external template root | +| `env` | Manage stored vars (api keys, paths, feishu app credentials) | +| `ssh` | `list` / `connect` / `tunnel` / `cmd` | +| `git` | `proxy` (set/unset/list/status) | +| `server` | Manage dev/serve servers | +| `preflight` | Pre-flight checks for work-order execution | +| `utils` | `albany-cleanup` | +| `update` | Reinstall from local repo | +| `uninstall` | Uninstall | -All settings are stored in `bin/config.json`: -- `paths`: Quick navigation paths -- `ssh_hosts`: SSH connection settings -- `git_proxies`: Git proxy URLs -- `openfoam`: OpenFOAM mount settings -- `api_keys`: API keys (deepseek, qwen) -- `feishu_apps`: Feishu app credentials +For any subcommand: `mytoolkit --help`. -### Security Note +## Templates -`config.json` contains sensitive information (API keys) and is **gitignored**. -The repository includes `config.json.example` as a template. +`mytoolkit convert -t ` reads templates from an external directory you register (templates are not bundled). Layout: -### Setup +``` +/md-to-pdf//template.typ +/md-to-docx//template.docx +``` -1. Copy the example config: - ```bash - cp bin/config.json.example bin/config.json - ``` +```bash +mytoolkit templates register /path/to/templates +mytoolkit templates show # lists each template with its absolute path +``` -2. Edit with your actual API keys: - ```bash - mytoolkit config edit - ``` +Registry is stored at `~/.config/mytoolkit/templates.json`. -3. Add to your `~/.zshrc` to auto-load: - ```bash - eval "$(mytoolkit env export 2>/dev/null)" - ``` +## User config + +All user-level config lives under `~/.config/mytoolkit/`: + +| File | Owner | Purpose | +|------|-------|---------| +| `env.json` | `mytoolkit env` | API keys, paths, feishu credentials (flat key/value) | +| `mail.json` | `mytoolkit mail` | IMAP/SMTP server settings | +| `templates.json` | `mytoolkit templates` | Pointer to external template root | + +Manage env vars via the CLI — no need to hand-edit: + +```bash +mytoolkit env list # show all (Storage path printed at top, secrets masked) +mytoolkit env get apikey_ark # read one +mytoolkit env set apikey_ark # write one +mytoolkit env set apikey_ark --secret # prompt for value, hidden input +mytoolkit env remove apikey_ark # delete one +mytoolkit env export # print MYCLI_FOO=... export statements +eval "$(mytoolkit env export)" # load into current shell +``` + +Legacy `bin/config.json` (in the source dir) is auto-migrated to `~/.config/mytoolkit/env.json` on first run after upgrade. ## Uninstall ```bash +mytoolkit uninstall +# or make uninstall ``` + +User config under `~/.config/mytoolkit/` is left in place. diff --git a/bin/cli.py b/bin/cli.py index 58e4912..8eff44c 100644 --- a/bin/cli.py +++ b/bin/cli.py @@ -1,9 +1,21 @@ """MyCLI main entry point.""" +from pathlib import Path + import click -from bin.commands import pdf, image, latex, video, bib, utils, env_cmd, ssh_cmd, git_cmd, mail +from bin.commands import pdf, image, latex, video, bib, utils +from bin.commands.convert import convert_cmd +from bin.commands.env import env_cmd +from bin.commands.templates import templates_cmd +from bin.commands.ssh import ssh_cmd +from bin.commands.git import git_cmd +from bin.commands.server import server_cmd +from bin.commands.webpage import webpage_cmd +from bin.commands.mail import mail +from bin.commands.preflight import preflight_cmd from bin.commands.self_mgmt import update_cmd, uninstall_cmd, self_cmd +from bin.utils import handle_errors @click.group() @@ -14,6 +26,7 @@ def cli(): # Register command groups +cli.add_command(convert_cmd) cli.add_command(pdf.pdf) cli.add_command(image.image) cli.add_command(latex.latex) @@ -21,9 +34,13 @@ cli.add_command(video.video) cli.add_command(bib.bib) cli.add_command(utils.utils) cli.add_command(env_cmd) +cli.add_command(templates_cmd) cli.add_command(ssh_cmd) cli.add_command(git_cmd) -cli.add_command(mail.mail) +cli.add_command(server_cmd) +cli.add_command(webpage_cmd) +cli.add_command(mail) +cli.add_command(preflight_cmd) cli.add_command(update_cmd) cli.add_command(uninstall_cmd) cli.add_command(self_cmd) diff --git a/bin/commands/__init__.py b/bin/commands/__init__.py index ff87eda..a628508 100644 --- a/bin/commands/__init__.py +++ b/bin/commands/__init__.py @@ -4,6 +4,8 @@ from . import pdf, image, latex, video, bib, utils from .env import env_cmd from .ssh import ssh_cmd from .git import git_cmd +from .server import server_cmd +from .webpage import webpage_cmd from .self_mgmt import self_cmd, update_cmd, uninstall_cmd -__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "self_cmd", "update_cmd", "uninstall_cmd"] +__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "server_cmd", "webpage_cmd", "self_cmd", "update_cmd", "uninstall_cmd"] diff --git a/bin/commands/convert.py b/bin/commands/convert.py new file mode 100644 index 0000000..b8fb799 --- /dev/null +++ b/bin/commands/convert.py @@ -0,0 +1,206 @@ +"""Universal format conversion.""" + +from pathlib import Path + +import click +from pypdf import PdfReader, PdfWriter +from PIL import Image + +from bin.md_to_pdf import build_pdf +from bin import templates_registry +from bin.utils import handle_errors, run_command + + +@click.command("convert") +@click.argument("inputs", nargs=-1, required=True) +@click.option("-o", "--output", required=True, help="Output file path") +@click.option( + "-t", + "--template", + type=click.Choice(["default", "cv", "textbook", "manual", "review"]), + default="default", + help="Template for md→pdf / md→docx", +) +@click.option( + "-q", + "--quality", + type=click.Choice(["screen", "ebook", "printer", "prepress"]), + default="screen", + help="PDF compression quality", +) +@click.option("-d", "--density", default="300", help="DPI for PDF→TIFF") +@click.option("--reference-doc", help="Reference docx template for md→docx") +@click.option( + "--bookmark-depth", + type=int, + default=1, + help="PDF bookmark expansion depth (md→pdf only). 1=top-level only, 2=expand to second level, etc.", +) +@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done") +@handle_errors +def convert_cmd(inputs, output, template, quality, density, reference_doc, bookmark_depth, dry_run): + """Universal format conversion. Auto-detects from file extensions. + + \b + Examples: + mytoolkit convert doc.md -o doc.pdf + mytoolkit convert doc.md -o doc.pdf -t cv + mytoolkit convert doc.md -o doc.docx + mytoolkit convert doc.md -o doc.docx -t review + mytoolkit convert *.jpg -o album.pdf + mytoolkit convert fig.eps -o fig.pdf + mytoolkit convert scan.tiff -o scan.pdf + mytoolkit convert scan.pdf -o scan.tiff + mytoolkit convert video.avi -o video.mp4 + """ + out_path = Path(output) + out_ext = out_path.suffix.lower() + + # Resolve input globs + input_paths = [] + for pattern in inputs: + p = Path(pattern) + if p.exists(): + input_paths.append(p) + else: + # Try glob + matches = list(Path(".").glob(pattern)) + input_paths.extend(sorted(matches)) + + if not input_paths: + click.secho("No input files found", fg="red", err=True) + raise SystemExit(1) + + # --- pdf → pdf (compress) --- + if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".pdf" and out_ext == ".pdf": + if dry_run: + click.echo(f"Would compress {input_paths[0]} to {output} (quality={quality})") + return + run_command( + [ + "gs", + "-sDEVICE=pdfwrite", + "-dNOPAUSE", + "-dQUIET", + "-dBATCH", + f"-dPDFSETTINGS=/{quality}", + "-dCompatibilityLevel=1.4", + f"-sOutputFile={out_path}", + str(input_paths[0]), + ], + check=True, + ) + click.secho(f"Compressed PDF: {output}", fg="green") + return + + # --- md → docx --- + if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".docx": + ref_doc = reference_doc + if not ref_doc and template != "default": + ref_path = templates_registry.get_md_to_docx_root() / template / "template.docx" + if ref_path.exists(): + ref_doc = str(ref_path) + else: + click.secho(f"Docx template not found for '{template}': {ref_path}", fg="red", err=True) + raise SystemExit(1) + if dry_run: + msg = f"Would convert {len(input_paths)} markdown file(s) to {output}" + if ref_doc: + msg += f" (template={ref_doc})" + click.echo(msg) + return + cmd = ["pandoc"] + [str(p) for p in input_paths] + ["-o", str(out_path)] + if ref_doc: + cmd.extend(["--reference-doc", ref_doc]) + run_command(cmd, check=True) + click.secho(f"Word document generated: {output}", fg="green") + return + + # --- md → pdf --- + if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".pdf": + if template == "review": + click.secho("Template 'review' is only available for md→docx", fg="red", err=True) + raise SystemExit(1) + if dry_run: + click.echo(f"Would convert {len(input_paths)} markdown file(s) to {output}") + return + build_pdf(template, input_paths, out_path, bookmark_depth=bookmark_depth) + click.secho(f"PDF generated: {output}", fg="green") + return + + # --- images → pdf --- + img_exts = (".jpg", ".jpeg", ".png", ".tif", ".tiff") + if all(p.suffix.lower() in img_exts for p in input_paths) and out_ext == ".pdf": + if dry_run: + click.echo(f"Would convert {len(input_paths)} image(s) to {output}") + return + _images_to_pdf(input_paths, out_path) + click.secho(f"PDF generated: {output}", fg="green") + return + + # --- eps → pdf --- + if len(input_paths) == 1 and input_paths[0].suffix.lower() in (".eps", ".ps") and out_ext == ".pdf": + if dry_run: + click.echo(f"Would convert {input_paths[0]} to {output}") + return + run_command(["epstopdf", str(input_paths[0]), "--outfile", str(out_path)], check=True) + click.secho(f"PDF generated: {output}", fg="green") + return + + # --- pdf → tiff --- + if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".pdf" and out_ext in (".tiff", ".tif"): + if dry_run: + click.echo(f"Would convert {input_paths[0]} to {output}") + return + run_command( + ["convert", "-density", density, str(input_paths[0]), "-compress", "zip", str(out_path)], + check=False, + ) + click.secho(f"TIFF generated: {output}", fg="green") + return + + # --- avi → mp4 --- + if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".avi" and out_ext == ".mp4": + if dry_run: + click.echo(f"Would convert {input_paths[0]} to {output}") + return + run_command( + ["ffmpeg", "-i", str(input_paths[0]), "-c:v", "libx264", "-c:a", "aac", str(out_path)], + check=False, + ) + click.secho(f"MP4 generated: {output}", fg="green") + return + + # --- bib → md --- + if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".bib" and out_ext == ".md": + if dry_run: + click.echo(f"Would convert {input_paths[0]} to {output}") + return + content = input_paths[0].read_text() + md_content = f"# Bibliography\n\n```bibtex\n{content}\n```\n" + out_path.write_text(md_content) + click.secho(f"Markdown generated: {output}", fg="green") + return + + click.secho( + f"Unsupported conversion: {[p.suffix for p in input_paths]} → {out_ext}", + fg="red", + err=True, + ) + raise SystemExit(1) + + +def _images_to_pdf(images: list[Path], output: Path) -> None: + """Merge images into a single PDF.""" + writer = PdfWriter() + for img_path in images: + img = Image.open(img_path) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + temp_pdf = img_path.with_suffix(".temp.pdf") + img.save(temp_pdf, "PDF", resolution=150.0) + reader = PdfReader(str(temp_pdf)) + writer.add_page(reader.pages[0]) + temp_pdf.unlink() + with open(output, "wb") as f: + writer.write(f) diff --git a/bin/commands/env.py b/bin/commands/env.py index 32f26f9..2b2e4a1 100644 --- a/bin/commands/env.py +++ b/bin/commands/env.py @@ -2,7 +2,7 @@ import click -from bin.config import config +from bin.config import CONFIG_PATH, config @click.group(name="env") @@ -14,10 +14,12 @@ def env_cmd(): @env_cmd.command("list") def env_list(): """List all vars.""" + click.echo(f"Storage: {CONFIG_PATH}") vars = config.get_all() if not vars: click.echo("No vars configured.") return + click.echo() for name, value in sorted(vars.items()): masked = "***" if any(x in name.lower() for x in ["key", "secret", "token", "pass"]) else value click.echo(f"{name:20} = {masked}") diff --git a/bin/commands/image.py b/bin/commands/image.py index 1d7a522..b02bcbd 100644 --- a/bin/commands/image.py +++ b/bin/commands/image.py @@ -1,10 +1,14 @@ """Image utilities.""" +import base64 +import os import subprocess +import urllib.request from pathlib import Path import click +from bin.config import config from bin.utils import handle_errors, run_command @@ -181,3 +185,52 @@ def compress_images(files, target_mb, max_width, quality, suffix, dry_run): size_mb = output.stat().st_size / (1024 * 1024) click.echo(f"Compressed: {img_file} -> {output} ({size_mb:.2f} MB)") + + +@image.command("generate") +@click.argument("prompt", required=False) +@click.option("-f", "--file", type=click.Path(exists=True), help="从文件读取 prompt") +@click.option("-s", "--size", default="2K", type=click.Choice(["1K", "2K"]), help="图片尺寸") +@click.option("-o", "--output", help="保存路径(默认只输出 URL)") +@click.option("--no-watermark", is_flag=True, help="不添加豆包水印") +@click.option("--b64", is_flag=True, help="使用 b64_json 格式获取图片数据") +@handle_errors +def generate_image_cmd(prompt, file, size, output, no_watermark, b64): + """使用豆包/Seedream 模型生成图片。""" + from openai import OpenAI + + if file: + prompt = Path(file).read_text(encoding="utf-8").strip() + elif not prompt: + raise click.UsageError("必须提供 prompt 或使用 -f/--file 从文件读取") + + api_key = config.get("apikey_ark") + if not api_key: + click.echo("Error: apikey_ark not set. Run: mytoolkit env set apikey_ark ", err=True) + raise click.Abort() + + client = OpenAI( + base_url="https://ark.cn-beijing.volces.com/api/v3", + api_key=api_key, + ) + + resp = client.images.generate( + model="doubao-seedream-5-0-260128", + prompt=prompt, + size=size, + response_format="b64_json" if b64 else "url", + extra_body={"watermark": not no_watermark}, + ) + + if b64: + image_bytes = base64.b64decode(resp.data[0].b64_json) + save_path = output or "generated_image.png" + Path(save_path).write_bytes(image_bytes) + click.echo(os.path.abspath(save_path)) + else: + url = resp.data[0].url + if output: + urllib.request.urlretrieve(url, output) + click.echo(os.path.abspath(output)) + else: + click.echo(url) diff --git a/bin/commands/pdf.py b/bin/commands/pdf.py index 3bff36b..0a2b18b 100644 --- a/bin/commands/pdf.py +++ b/bin/commands/pdf.py @@ -1,11 +1,13 @@ """PDF utilities.""" +import shutil from pathlib import Path import click from pypdf import PdfReader, PdfWriter from PIL import Image +from bin.md_to_pdf import expand_bookmarks from bin.utils import handle_errors, run_command @@ -24,9 +26,15 @@ def pdf(): default="screen", help="Compression quality level", ) +@click.option( + "--bookmark-depth", + type=int, + default=1, + help="Expand PDF bookmarks to this depth after compression (1=no expansion).", +) @click.option("--dry-run", "-n", is_flag=True, help="Show what would be done") @handle_errors -def compress(files, quality, dry_run): +def compress(files, quality, bookmark_depth, dry_run): """Compress PDF files using ghostscript.""" for pattern in files: for pdf_file in Path(".").glob(pattern): @@ -53,6 +61,8 @@ def compress(files, quality, dry_run): click.echo(f"Compressing: {pdf_file}") run_command(cmd, check=True) output.replace(pdf_file) + if bookmark_depth > 1: + expand_bookmarks(pdf_file, bookmark_depth) click.echo(f" Done: {pdf_file}") @@ -102,9 +112,15 @@ def to_tiff(files, density, dry_run): @pdf.command("merge") @click.argument("files", nargs=-1, required=True) @click.option("--output", "-o", required=True, help="Output PDF file") +@click.option( + "--bookmark-depth", + type=int, + default=1, + help="Expand PDF bookmarks to this depth after merge (1=no expansion).", +) @click.option("--dry-run", "-n", is_flag=True, help="Show what would be done") @handle_errors -def merge_pdfs(files, output, dry_run): +def merge_pdfs(files, output, bookmark_depth, dry_run): """Merge PDFs and/or images into a single PDF.""" writer = PdfWriter() processed = [] @@ -144,4 +160,30 @@ def merge_pdfs(files, output, dry_run): with open(output, "wb") as f: writer.write(f) + if bookmark_depth > 1: + expand_bookmarks(Path(output), bookmark_depth) click.echo(f"Merged {len(processed)} files into {output}") + + +@pdf.command("bookmark") +@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--depth", + type=int, + required=True, + help="Bookmark expansion depth. 1=top-level only, 2=expand to second level, etc.", +) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False, path_type=Path), + help="Output PDF path. Defaults to overwriting the input file.", +) +@handle_errors +def bookmark(input_file, depth, output): + """Set PDF bookmark outline expansion depth.""" + target = output if output else input_file + if output and output.resolve() != input_file.resolve(): + shutil.copy2(str(input_file), str(target)) + expand_bookmarks(target, depth) + click.echo(f"Bookmarks expanded to depth {depth}: {target}") diff --git a/bin/commands/preflight.py b/bin/commands/preflight.py new file mode 100644 index 0000000..0490b9a --- /dev/null +++ b/bin/commands/preflight.py @@ -0,0 +1,119 @@ +"""Pre-flight check for work-order consistency and system health.""" + +import json +import os +import re +import sys +from pathlib import Path + +import click + + +@click.command(name="preflight") +def preflight_cmd(): + """Run pre-flight checks before executing work orders.""" + issues = [] + warnings = [] + + click.secho("Running pre-flight checks...", fg="cyan") + click.echo("") + + # === Check 1: work-order.md vs checkpoint JSON consistency === + work_order = Path.home() / "workspace/assistant/work-order.md" + + if work_order.exists(): + content = work_order.read_text() + + # Find progress numbers like "进度: X/Y 步骤" + progress_match = re.search(r"进度[::]\s*(\d+)/(\d+)\s*步骤", content) + if progress_match: + wo_current = int(progress_match.group(1)) + wo_total = int(progress_match.group(2)) + + # Find checkpoint files referenced in work-order + cp_match = re.search(r"进度档[::]\s*(\S+)", content) + if cp_match: + cp_path = Path.home() / "workspace" / cp_match.group(1) + if cp_path.exists(): + try: + cp = json.loads(cp_path.read_text()) + cp_current = cp.get("currentStep", 0) + cp_total = cp.get("totalSteps", 0) + cp_completed = len(cp.get("completedSteps", [])) + + if wo_current != cp_current or wo_total != cp_total: + issues.append( + f"进度不一致: 工作单 {wo_current}/{wo_total} vs 进度档 {cp_current}/{cp_total}" + ) + elif cp_current != cp_completed + 1 and cp_current <= cp_total: + # currentStep should be next step to do (completed + 1) + # unless all completed + pass + except json.JSONDecodeError: + warnings.append(f"进度档 JSON 损坏: {cp_path}") + + # Check for duplicate step numbers in step list + step_lines = re.findall(r"-\s*\[[x ]\]\s*\d+\.", content) + step_nums = [] + for line in step_lines: + m = re.search(r"(\d+)\.", line) + if m: + step_nums.append(int(m.group(1))) + + seen = set() + for num in step_nums: + if num in seen: + issues.append(f"步骤 {num} 在清单中重复出现") + seen.add(num) + + # Check for gaps in step numbering + if step_nums: + expected = list(range(1, max(step_nums) + 1)) + missing = [n for n in expected if n not in seen] + if missing: + warnings.append(f"步骤编号缺失: {missing}") + else: + warnings.append("工作单文件不存在") + + # === Check 2: .zshrc PATH references === + zshrc = Path.home() / ".zshrc" + if zshrc.exists(): + zshrc_content = zshrc.read_text() + # Find paths in PATH exports (lines starting with export PATH=) + for line in zshrc_content.splitlines(): + if not line.strip().startswith("export PATH="): + continue + refs = re.findall(r"/Users/\S+", line) + for ref in refs: + full_path = Path(ref).expanduser() + if not full_path.exists(): + issues.append(f".zshrc PATH 引用失效: {ref}") + else: + warnings.append(".zshrc 不存在") + + # === Check 3: Workspace symlink health === + workspace = Path.home() / "workspace" + if workspace.exists(): + for item in workspace.iterdir(): + if item.is_symlink(): + if not item.exists(): + issues.append(f"符号链接失效: {item.name} -> {os.readlink(item)}") + + # === Report === + click.echo("-" * 50) + if issues: + click.secho(f"发现 {len(issues)} 个问题:", fg="red") + for issue in issues: + click.secho(f" [x] {issue}", fg="red") + else: + click.secho("未发现严重问题", fg="green") + + if warnings: + click.secho(f"\n{len(warnings)} 个警告:", fg="yellow") + for w in warnings: + click.secho(f" [!] {w}", fg="yellow") + + click.echo("-" * 50) + + if issues: + sys.exit(1) diff --git a/bin/commands/self_mgmt.py b/bin/commands/self_mgmt.py index 336c989..b48c651 100644 --- a/bin/commands/self_mgmt.py +++ b/bin/commands/self_mgmt.py @@ -1,60 +1,110 @@ """Self-management commands for bin.""" +import os +import shutil import subprocess +import sys from pathlib import Path import click def _get_project_root() -> Path: - """Get mytoolkit project root.""" - # This file is at bin/commands/self_mgmt.py + """Get mytoolkit project root from source tree.""" return Path(__file__).parent.parent.parent +def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> None: + result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=False) + if check and result.returncode != 0: + raise click.Exit(result.returncode) + + +def _has_uv() -> bool: + return shutil.which("uv") is not None + + +def _install(root: Path) -> None: + venv_toolkit = root / ".venv" / "bin" / "mytoolkit" + user_local = Path.home() / ".local" / "bin" / "mytoolkit" + comp_dir = Path.home() / ".local" / "bin" / "completions" + + # 1. Sync venv / install package + click.secho("Installing package…", fg="cyan") + if _has_uv(): + _run(["uv", "sync"], cwd=root) + else: + venv_pip = root / ".venv" / "bin" / "pip" + if not venv_pip.exists(): + _run([sys.executable, "-m", "venv", str(root / ".venv")]) + _run([str(venv_pip), "install", "-e", str(root)], cwd=root) + + if not venv_toolkit.exists(): + click.secho(f"error: missing {venv_toolkit}", fg="red", err=True) + raise click.Exit(1) + + # 2. Symlink to ~/.local/bin + click.secho(f"Linking {user_local} → {venv_toolkit}…", fg="cyan") + user_local.parent.mkdir(parents=True, exist_ok=True) + if user_local.exists() or user_local.is_symlink(): + user_local.unlink() + user_local.symlink_to(venv_toolkit) + + # 3. Install 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( + [str(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( + [str(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: + user_local = Path.home() / ".local" / "bin" / "mytoolkit" + comp_dir = Path.home() / ".local" / "bin" / "completions" + for f in (comp_dir / "_mytoolkit", comp_dir / "mytoolkit.bash", user_local): + if f.exists() or f.is_symlink(): + f.unlink() + click.secho(f"Removed {f}", fg="green") + + @click.command(name="update") def update_cmd(): """Update mytoolkit from the local repository.""" root = _get_project_root() - - click.secho("Updating mytoolkit...", fg="cyan") - - # Run make install - result = subprocess.run( - ["make", "install"], - cwd=str(root), - capture_output=True, - text=True, - ) - - if result.returncode == 0: - click.secho("mytoolkit updated successfully.", fg="green") - else: - click.secho("Update failed:", fg="red", err=True) - click.echo(result.stderr, err=True) - raise click.Exit(1) + click.secho("Updating mytoolkit…", fg="cyan") + _install(root) + click.secho("mytoolkit updated.", fg="green") @click.command(name="uninstall") def uninstall_cmd(): """Uninstall mytoolkit.""" - root = _get_project_root() - - click.secho("Uninstalling mytoolkit...", fg="cyan") - - result = subprocess.run( - ["make", "uninstall"], - cwd=str(root), - capture_output=True, - text=True, - ) - - if result.returncode == 0: - click.secho("mytoolkit uninstalled.", fg="green") - else: - click.secho("Uninstall failed:", fg="red", err=True) - click.echo(result.stderr, err=True) - raise click.Exit(1) + click.secho("Uninstalling mytoolkit…", fg="cyan") + _uninstall() + click.secho("mytoolkit removed.", fg="green") # Backward compatibility: keep self_cmd group for any existing scripts @@ -64,6 +114,5 @@ def self_cmd(): pass -# Register commands in the group for backward compatibility self_cmd.add_command(update_cmd, name="update") self_cmd.add_command(uninstall_cmd, name="uninstall") diff --git a/bin/commands/server.py b/bin/commands/server.py new file mode 100644 index 0000000..3f0592c --- /dev/null +++ b/bin/commands/server.py @@ -0,0 +1,403 @@ +"""Server management for workspace projects.""" + +import os +import signal +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Optional + +import click + + +Mode = Literal["dev", "serve"] + + +@dataclass(frozen=True) +class Service: + name: str + serve_port: int + dev_port: Optional[int] = None + description: str = "" + project_dir: Optional[Path] = None + serve_cmd: Optional[list[str]] = None + dev_cmd: Optional[list[str]] = None + + @property + def manageable(self) -> bool: + return self.project_dir is not None and self.serve_cmd is not None + + @property + def has_dev(self) -> bool: + return self.dev_port is not None and self.dev_cmd is not None + + def port_for(self, dev: bool) -> Optional[int]: + return self.dev_port if dev else self.serve_port + + def cmd_for(self, dev: bool) -> Optional[list[str]]: + return self.dev_cmd if dev else self.serve_cmd + + +KNOWN_SERVICES: list[Service] = [ + Service("metabot-api", 9100, description="MetaBot HTTP API"), + Service("metabot-memory", 8100, description="MetaBot MetaMemory"), + Service( + "mywebpage", + serve_port=3010, + dev_port=3000, + description="APAAM Lab website (Docusaurus)", + project_dir=Path.home() / "Documents" / "myWork" / "sysu" / "04-学术积累" / "mywebpage", + serve_cmd=["npm", "run", "serve"], + dev_cmd=["npm", "run", "dev"], + ), + Service( + "apaam-webpage", + serve_port=3011, + dev_port=3001, + description="Phynexis docs (Docusaurus)", + project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "webpage", + serve_cmd=["npm", "run", "serve"], + dev_cmd=["npm", "run", "dev"], + ), + Service( + "xiaohe-webpage", + serve_port=3012, + dev_port=3002, + description="Xiaohe company website (Docusaurus)", + project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "xiaohe-webpage", + serve_cmd=["npm", "run", "serve"], + dev_cmd=["npm", "run", "dev"], + ), + Service("myslides", 3456, description="Slidev dev server"), +] + +_BY_NAME: dict[str, Service] = {s.name: s for s in KNOWN_SERVICES} +_ALL_NAMES = list(_BY_NAME.keys()) +_MANAGEABLE_NAMES = [s.name for s in KNOWN_SERVICES if s.manageable] + + +def manageable_services() -> list[Service]: + return [s for s in KNOWN_SERVICES if s.manageable] + + +def get_service(name: str) -> Service: + return _BY_NAME[name] + + +def no_color_env() -> dict: + env = os.environ.copy() + env["NO_COLOR"] = "1" + return env + + +def _listening_map() -> dict[int, dict]: + """Single batched lsof query for all TCP listening sockets, keyed by port.""" + try: + result = subprocess.run( + ["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], + capture_output=True, + text=True, + errors="replace", + timeout=5, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return {} + if result.returncode != 0 or not result.stdout.strip(): + return {} + + by_port: dict[int, dict] = {} + for line in result.stdout.strip().split("\n")[1:]: + parts = line.split() + if len(parts) < 9: + continue + listen = next((t for t in reversed(parts) if ":" in t and not t.startswith("(")), None) + if not listen: + continue + try: + port = int(listen.rsplit(":", 1)[1]) + except (ValueError, IndexError): + continue + by_port[port] = { + "command": parts[0], + "pid": parts[1], + "user": parts[2], + "listen": listen, + } + return by_port + + +def _port_status(port: int) -> Optional[dict]: + return _listening_map().get(port) + + +def _pid_command(pid: str) -> str: + try: + result = subprocess.run( + ["ps", "-p", pid, "-o", "comm="], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return "" + + +def _resolve(svc: Service, dev: bool) -> tuple[Optional[int], Optional[list[str]], Mode]: + return svc.port_for(dev), svc.cmd_for(dev), "dev" if dev else "serve" + + +@click.group(name="server") +def server_cmd(): + """Manage workspace dev/serve servers.""" + pass + + +def _print_row(name: str, mode: str, port: int, desc: str, info: Optional[dict]) -> bool: + if info: + pid = info.get("pid", "?") + click.secho( + f"{name:<16} {mode:<6} {port:<6} {'running':<10} {pid:<8} {desc}", + fg="green", + ) + return True + click.secho( + f"{name:<16} {mode:<6} {port:<6} {'stopped':<10} {'-':<8} {desc}", + fg="black", + ) + return False + + +@server_cmd.command("list") +def server_list(): + """List all known services (serve + dev modes) and their status.""" + click.echo("") + click.secho( + f"{'Service':<16} {'Mode':<6} {'Port':<6} {'Status':<10} {'PID':<8} {'Description'}", + fg="bright_blue", + bold=True, + ) + click.echo("-" * 90) + + listening = _listening_map() + running = 0 + total = 0 + for svc in KNOWN_SERVICES: + primary_label = "serve" if svc.serve_cmd is not None else "-" + if _print_row(svc.name, primary_label, svc.serve_port, svc.description, listening.get(svc.serve_port)): + running += 1 + total += 1 + + if svc.has_dev: + assert svc.dev_port is not None + if _print_row("", "dev", svc.dev_port, "(hot reload)", listening.get(svc.dev_port)): + running += 1 + total += 1 + + click.echo("-" * 90) + click.echo(f"Total: {total} ports, {running} running, {total - running} stopped") + click.echo("") + + +@server_cmd.command("check") +@click.argument("port", type=int) +def server_check(port): + """Check if a specific port is open.""" + info = _port_status(port) + if info: + cmd = _pid_command(info.get("pid", "")) + click.secho( + f"Port {port} is open (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})", + fg="green", + ) + else: + click.secho(f"Port {port} is not listening", fg="red") + + +@server_cmd.command("scan") +@click.argument("start", type=int, default=3000) +@click.argument("end", type=int, default=4000) +def server_scan(start, end): + """Scan a port range for listening services.""" + click.echo(f"Scanning ports {start}-{end} ...") + listening = _listening_map() + found = 0 + for port in range(start, end + 1): + info = listening.get(port) + if info: + cmd = _pid_command(info.get("pid", "")) + click.secho( + f" {port:<5} {cmd or info.get('command', '')} (PID {info.get('pid', '?')})", + fg="green", + ) + found += 1 + if found == 0: + click.echo("No listening ports found.") + else: + click.echo(f"\nFound {found} open port(s).") + + +@server_cmd.command("start") +@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False)) +@click.option("--dev", is_flag=True, help="Start hot-reload dev server on the dev port (default: serve)") +@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)") +def server_start(name, dev, rebuild): + """Start a server in the background. + + By default, serve mode is used (production build) on the serve port. + Use --dev to start the hot-reload dev server on the dev port instead. + """ + svc = get_service(name) + port, cmd, mode = _resolve(svc, dev) + + if port is None or cmd is None: + click.secho(f"{name} has no {mode} mode configured", fg="red") + return + + assert svc.project_dir is not None # _MANAGEABLE_NAMES choice guarantees this + + if _port_status(port) is not None: + click.secho(f"{name} ({mode}) is already running on port {port}", fg="yellow") + return + + if not svc.project_dir.exists(): + click.secho(f"Project directory not found: {svc.project_dir}", fg="red") + return + + env = no_color_env() + + if not dev: + build_dir = svc.project_dir / "build" + if rebuild or not build_dir.exists(): + reason = "forced rebuild" if rebuild else "build directory missing" + click.secho(f"Running npm run build ({reason}) ...", fg="cyan") + build_proc = subprocess.run( + ["npm", "run", "build"], + cwd=svc.project_dir, + env=env, + capture_output=True, + text=True, + ) + if build_proc.returncode != 0: + click.secho(f"Build failed for {name}", fg="red") + click.echo(build_proc.stderr[-500:] if build_proc.stderr else "") + return + + log_file = svc.project_dir / "logs" / f"{svc.name}-{mode}.log" + log_file.parent.mkdir(exist_ok=True) + full_cmd = ["nohup"] + cmd + click.secho(f"Starting {name} ({svc.description}) on port {port} ({mode}) ...", fg="cyan") + + with open(log_file, "a") as log: + proc = subprocess.Popen( + full_cmd, + cwd=svc.project_dir, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + env=env, + ) + + max_wait = 20 if dev else 30 + for _ in range(max_wait * 2): + time.sleep(0.5) + if _port_status(port) is not None: + click.secho(f"{name} ({mode}) started successfully (PID {proc.pid})", fg="green") + return + + click.secho(f"{name} may have failed to start (check {log_file})", fg="yellow") + + +def _kill_port(name: str, mode: Mode, port: int): + info = _port_status(port) + if info is None: + click.secho(f"{name} ({mode}) is not running on port {port}", fg="yellow") + return + + pid = info["pid"] + try: + os.kill(int(pid), signal.SIGTERM) + click.secho(f"Sent SIGTERM to {name} ({mode}, PID {pid})", fg="green") + except ProcessLookupError: + click.secho(f"Process {pid} not found", fg="yellow") + return + + for _ in range(10): + time.sleep(0.5) + if _port_status(port) is None: + click.secho(f"{name} ({mode}) stopped", fg="green") + return + + try: + os.kill(int(pid), signal.SIGKILL) + click.secho(f"Sent SIGKILL to {name} ({mode}, PID {pid})", fg="red") + except ProcessLookupError: + pass + + +@server_cmd.command("stop") +@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False)) +@click.option("--dev", is_flag=True, help="Stop the dev-port instance instead of serve") +@click.option("--all", "all_modes", is_flag=True, help="Stop both dev and serve instances") +def server_stop(name, dev, all_modes): + """Stop a running server.""" + svc = get_service(name) + + if all_modes: + targets: list[tuple[Mode, int]] = [("serve", svc.serve_port)] + if svc.has_dev: + assert svc.dev_port is not None + targets.append(("dev", svc.dev_port)) + else: + port, _, mode = _resolve(svc, dev) + if port is None: + click.secho(f"{name} has no {mode} mode configured", fg="red") + return + targets = [(mode, port)] + + for target_mode, target_port in targets: + _kill_port(name, target_mode, target_port) + + +@server_cmd.command("restart") +@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False)) +@click.option("--dev", is_flag=True, help="Restart the dev instance (default: serve)") +@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)") +def server_restart(name, dev, rebuild): + """Restart a server.""" + ctx = click.get_current_context() + ctx.invoke(server_stop, name=name, dev=dev, all_modes=False) + ctx.invoke(server_start, name=name, dev=dev, rebuild=rebuild) + + +@server_cmd.command("status") +@click.argument("name", type=click.Choice(_ALL_NAMES, case_sensitive=False)) +def server_status(name): + """Show detailed status of a service (both serve and dev ports).""" + svc = get_service(name) + + click.echo("") + click.secho(f"Service: {svc.name}", bold=True) + click.echo(f"Desc: {svc.description}") + if svc.project_dir: + click.echo(f"Project: {svc.project_dir}") + + def _show(label: str, port: Optional[int]): + if port is None: + return + info = _port_status(port) + if info: + cmd = _pid_command(info.get("pid", "")) + click.secho( + f"{label:<8} {port} running (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})", + fg="green", + ) + else: + click.secho(f"{label:<8} {port} stopped", fg="black") + + _show("Serve:", svc.serve_port) + _show("Dev:", svc.dev_port) + click.echo("") diff --git a/bin/commands/templates.py b/bin/commands/templates.py new file mode 100644 index 0000000..d3d4ada --- /dev/null +++ b/bin/commands/templates.py @@ -0,0 +1,77 @@ +"""Templates registry CLI.""" + +from pathlib import Path + +import click + +from bin import templates_registry + + +@click.group(name="templates") +def templates_cmd(): + """Manage external templates registry.""" + pass + + +@templates_cmd.command("register") +@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +def templates_register(path): + """Register a directory as the templates root.""" + resolved = templates_registry.set_root(Path(path)) + click.secho(f"Registered: {resolved}", fg="green") + + +@templates_cmd.command("show") +def templates_show(): + """Show the current registry and available templates.""" + if not templates_registry.CONFIG_PATH.exists(): + click.echo("No registry. Run: mytoolkit templates register ") + return + + import json + + with open(templates_registry.CONFIG_PATH, encoding="utf-8") as f: + data = json.load(f) + raw = data.get("dir") + click.echo(f"Registry: {templates_registry.CONFIG_PATH}") + click.echo(f"Root: {raw}") + + if not raw or not Path(raw).is_dir(): + click.secho("(root path missing)", fg="red") + return + + root = Path(raw) + pdf_dir = root / "md-to-pdf" + docx_dir = root / "md-to-docx" + + click.echo("\nmd-to-pdf:") + if pdf_dir.is_dir(): + entries = sorted( + (p.name, p / "template.typ") + for p in pdf_dir.iterdir() + if (p / "template.typ").exists() + ) + if not entries: + click.echo(" (none)") + else: + width = max(len(n) for n, _ in entries) + for name, path in entries: + click.echo(f" - {name:<{width}} {path}") + else: + click.secho(" (subdirectory missing)", fg="yellow") + + click.echo("\nmd-to-docx:") + if docx_dir.is_dir(): + entries = sorted( + (p.name, p / "template.docx") + for p in docx_dir.iterdir() + if (p / "template.docx").exists() + ) + if not entries: + click.echo(" (none)") + else: + width = max(len(n) for n, _ in entries) + for name, path in entries: + click.echo(f" - {name:<{width}} {path}") + else: + click.secho(" (subdirectory missing)", fg="yellow") diff --git a/bin/commands/webpage.py b/bin/commands/webpage.py new file mode 100644 index 0000000..32d1c57 --- /dev/null +++ b/bin/commands/webpage.py @@ -0,0 +1,89 @@ +"""Webpage project utilities (Docusaurus build / clear / etc.). + +Project metadata lives in `server.py` so that adding a new webpage there +automatically wires it up here. +""" + +import subprocess +import time + +import click + +from bin.commands.server import manageable_services, get_service, no_color_env + + +_PROJECTS = manageable_services() +_NAMES = [s.name for s in _PROJECTS] + + +def _run_npm(name: str, script: str, label: str) -> None: + """Run `npm run