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
This commit is contained in:
Zhengshou Lai
2026-05-04 17:19:32 +08:00
parent 38328c28d0
commit 3e4cf618a8
20 changed files with 1846 additions and 214 deletions
+3 -1
View File
@@ -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"]
+206
View File
@@ -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)
+3 -1
View File
@@ -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}")
+53
View File
@@ -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 <value>", 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)
+44 -2
View File
@@ -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}")
+119
View File
@@ -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)
+86 -37
View File
@@ -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")
+403
View File
@@ -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("")
+77
View File
@@ -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 <path>")
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")
+89
View File
@@ -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 <script>` in the project's directory, streaming output."""
svc = get_service(name)
assert svc.project_dir is not None # _NAMES is manageable services only
if not svc.project_dir.exists():
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
raise click.Abort()
click.secho(f"{label} {svc.name} ({svc.description}) ...", fg="cyan")
started = time.time()
proc = subprocess.run(["npm", "run", script], cwd=svc.project_dir, env=no_color_env())
elapsed = time.time() - started
if proc.returncode != 0:
click.secho(f"{script} failed for {svc.name} (exit {proc.returncode})", fg="red")
raise click.Abort()
click.secho(f"{svc.name} {script} done in {elapsed:.1f}s", fg="green")
@click.group(name="webpage")
def webpage_cmd():
"""Manage Docusaurus webpage projects."""
pass
@webpage_cmd.command("list")
def webpage_list():
"""List managed webpage projects and their build status."""
click.echo("")
click.secho(
f"{'Name':<16} {'Build':<12} {'Description':<36} {'Project'}",
fg="bright_blue",
bold=True,
)
click.echo("-" * 100)
for svc in _PROJECTS:
assert svc.project_dir is not None # _PROJECTS is manageable services only
build_dir = svc.project_dir / "build"
if build_dir.exists():
build_status = time.strftime("%m-%d %H:%M", time.localtime(build_dir.stat().st_mtime))
color = "green"
else:
build_status = "missing"
color = "yellow"
click.secho(
f"{svc.name:<16} {build_status:<12} {svc.description:<36} {svc.project_dir}",
fg=color,
)
click.echo("")
@webpage_cmd.command("build")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_build(name):
"""Build a webpage project (`npm run build`)."""
_run_npm(name, "build", "Building")
@webpage_cmd.command("clear")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_clear(name):
"""Clear Docusaurus build artifacts and cache (`npm run clear`)."""
_run_npm(name, "clear", "Clearing")
@webpage_cmd.command("rebuild")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_rebuild(name):
"""Clear cache then build (`npm run clear && npm run build`)."""
_run_npm(name, "clear", "Clearing")
_run_npm(name, "build", "Building")