Initial commit: mycli toolkit with all commands

This commit is contained in:
Zhengshou Lai
2026-04-13 11:38:01 +08:00
commit ab8067f0c5
53 changed files with 1240 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
"""MyCLI subcommands."""
from . import pdf, image, latex, video, bib, utils
from .env import env_cmd
from .ssh import ssh_cmd
from .git import git_cmd
from .self_mgmt import self_cmd
from .config_cmd import config_cmd
__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "self_cmd", "config_cmd"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
"""BibTeX utilities."""
import subprocess
from pathlib import Path
import click
from mycli.config import config
from mycli.utils import handle_errors, run_command
@click.group()
def bib():
"""BibTeX and bibliography commands."""
pass
@bib.command("to-markdown")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def to_markdown(files, dry_run):
"""Convert BibTeX files to Markdown."""
for pattern in files:
for bib_file in Path(".").glob(pattern):
if bib_file.suffix.lower() != ".bib":
continue
output = bib_file.with_suffix(".md")
if dry_run:
click.echo(f"Would convert: {bib_file} -> {output}")
continue
click.echo(f"Converting: {bib_file} -> {output}")
content = bib_file.read_text()
md_content = f"# Bibliography\n\n```bibtex\n{content}\n```\n"
output.write_text(md_content)
@bib.command("cv-update")
@handle_errors
def cv_update():
"""Update CV bibliography using biber."""
cv_path = config.get_path("cv")
if not cv_path:
# Fallback to hardcoded path
cv_path = Path.home() / "Documents/Personal/resume_cv/cv_lai_maintaining_latex/cv_lai_maintaining"
if not cv_path.exists():
click.echo(f"CV file not found: {cv_path}")
return
click.echo(f"Updating CV bibliography: {cv_path}")
run_command(["biber", str(cv_path)], check=False)
+58
View File
@@ -0,0 +1,58 @@
"""Configuration management commands."""
import json
import os
import click
from mycli.config import config
@click.group(name="config")
def config_cmd():
"""Manage mycli configuration."""
pass
@config_cmd.command("show")
def show():
"""Show current configuration."""
config.show()
@config_cmd.command("get")
@click.argument("key")
def get_value(key):
"""Get a configuration value by key (dot notation)."""
value = config.get(key)
if value is not None:
if isinstance(value, dict):
print(json.dumps(value, indent=2, ensure_ascii=False))
else:
print(value)
else:
click.echo(f"Key not found: {key}", err=True)
raise click.Exit(1)
@config_cmd.command("paths")
def list_paths():
"""List all configured paths."""
paths = config.get_all_paths()
max_len = max(len(name) for name in paths.keys())
from pathlib import Path
for name, path in sorted(paths.items()):
exists = "" if path.exists() else ""
click.echo(f"{name:{max_len}} [{exists}] {path}")
@config_cmd.command("edit")
def edit():
"""Open configuration file in editor."""
config.edit()
@config_cmd.command("where")
def where():
"""Show configuration file location."""
click.echo(config._config_path)
+85
View File
@@ -0,0 +1,85 @@
"""Environment paths and quick navigation."""
import functools
import os
import subprocess
from pathlib import Path
import click
from mycli.config import config
from mycli.utils import handle_errors
@functools.lru_cache(maxsize=1)
def _get_paths() -> dict[str, Path]:
"""Lazy load paths from config."""
return config.get_all_paths()
@click.group(name="env")
def env_cmd():
"""Environment paths and navigation."""
pass
@env_cmd.command("goto")
@click.argument("name")
@handle_errors
def goto(name):
"""Print path for cd (use: cd $(mycli env goto <name>))."""
paths = _get_paths()
if name not in paths:
click.echo(f"Unknown path: {name}", err=True)
click.echo(f"Available: {', '.join(paths.keys())}", err=True)
raise click.Exit(1)
click.echo(paths[name])
@env_cmd.command("list")
@handle_errors
def list_paths():
"""List all configured paths."""
paths = _get_paths()
max_len = max(len(k) for k in paths.keys())
for name, path in sorted(paths.items()):
exists = "" if path.exists() else ""
click.echo(f"{name:{max_len}} {exists} {path}")
@env_cmd.command("cd")
@click.argument("name")
@handle_errors
def cd_path(name):
"""Change directory (launches new shell)."""
paths = _get_paths()
if name not in paths:
click.echo(f"Unknown path: {name}", err=True)
raise click.Exit(1)
target = paths[name]
if not target.exists():
click.echo(f"Path does not exist: {target}", err=True)
raise click.Exit(1)
shell = os.environ.get("SHELL", "/bin/zsh")
click.echo(f"Starting shell in: {target}")
subprocess.run([shell], cwd=target)
@env_cmd.command("status")
@handle_errors
def status():
"""Show environment status."""
paths = _get_paths()
click.secho("=== Paths ===", fg="cyan")
for name, path in sorted(paths.items()):
exists = "" if path.exists() else ""
click.echo(f" {name:12} [{exists}] {path}")
click.secho("\n=== OpenFOAM ===", fg="cyan")
openfoam_path = "/Volumes/OpenFOAM/openfoam/build"
if Path("/Volumes/OpenFOAM").exists():
click.secho(f" Mounted: {openfoam_path}", fg="green")
else:
click.secho(" Not mounted", fg="red")
+98
View File
@@ -0,0 +1,98 @@
"""Git utilities including proxy management."""
import subprocess
import click
from mycli.config import config
def _get_proxies():
"""Get git proxies from config."""
return config.get_all_git_proxies()
@click.group(name="git")
def git_cmd():
"""Git utilities."""
pass
@git_cmd.command("proxy")
@click.argument("name", required=False)
@click.option("--unset", "-u", is_flag=True, help="Unset proxy")
@click.option("--status", "-s", is_flag=True, help="Show current proxy status")
def proxy_cmd(name, unset, status):
"""Manage git proxy settings.
Examples:
mycli git proxy fastgithub # Set fastgithub proxy
mycli git proxy pandafan # Set pandafan proxy
mycli git proxy -u # Unset proxy
mycli git proxy -s # Show status
"""
if status or (not name and not unset):
_show_status()
return
if unset:
subprocess.run(["git", "config", "--global", "--unset", "http.proxy"], capture_output=True)
subprocess.run(["git", "config", "--global", "--unset", "https.proxy"], capture_output=True)
click.secho("Git proxy unset", fg="green")
return
proxies = _get_proxies()
if name not in proxies:
click.echo(f"Unknown proxy: {name}", err=True)
click.echo(f"Available: {', '.join(proxies.keys())}", err=True)
raise click.Exit(1)
proxy_url = proxies[name]
subprocess.run(["git", "config", "--global", "http.proxy", proxy_url], check=True)
subprocess.run(["git", "config", "--global", "https.proxy", proxy_url], check=True)
click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green")
def _show_status():
"""Show current git proxy status."""
result = subprocess.run(
["git", "config", "--global", "http.proxy"],
capture_output=True,
text=True,
)
http_proxy = result.stdout.strip() if result.returncode == 0 else "(not set)"
result = subprocess.run(
["git", "config", "--global", "https.proxy"],
capture_output=True,
text=True,
)
https_proxy = result.stdout.strip() if result.returncode == 0 else "(not set)"
# 检查当前设置的代理名称
proxies = _get_proxies()
current_name = None
for proxy_name, url in proxies.items():
if http_proxy == url:
current_name = proxy_name
break
click.secho("Git proxy status:", fg="cyan")
if current_name:
click.echo(f" Active: {current_name}")
click.echo(f" http.proxy: {http_proxy}")
click.echo(f" https.proxy: {https_proxy}")
click.secho("\nAvailable proxies:", fg="cyan")
for proxy_name, url in proxies.items():
marker = "*" if proxy_name == current_name else " "
click.echo(f" [{marker}] {proxy_name:12} {url}")
@git_cmd.command("proxy-list")
def proxy_list():
"""List available git proxies."""
proxies = _get_proxies()
click.secho("Available proxies:", fg="cyan")
for name, url in proxies.items():
click.echo(f" {name:12} {url}")
+131
View File
@@ -0,0 +1,131 @@
"""Image utilities."""
import subprocess
from pathlib import Path
import click
from mycli.utils import handle_errors, run_command
@click.group()
def image():
"""Image manipulation commands."""
pass
@image.command("eps-to-pdf")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def eps_to_pdf(files, dry_run):
"""Convert EPS files to PDF."""
for pattern in files:
for eps_file in Path(".").glob(pattern):
if eps_file.suffix.lower() not in (".eps", ".ps"):
continue
output = eps_file.with_suffix(".pdf")
if dry_run:
click.echo(f"Would convert: {eps_file} -> {output}")
continue
click.echo(f"Converting: {eps_file} -> {output}")
run_command(["epstopdf", str(eps_file)], check=False)
@image.command("eps-fix")
@click.argument("file", type=click.Path(exists=True))
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def eps_fix(file, dry_run):
"""Fix EPS files created by print command."""
path = Path(file)
content = path.read_text()
if "/f/fill" not in content:
click.echo("File already fixed or not created by print command")
return
if dry_run:
click.echo(f"Would fix: {file}")
return
lines = content.split("\n")
new_lines = []
for line in lines:
if "/f/fill" in line:
line = line.replace("/f/fill", "")
new_lines.append(line)
path.write_text("\n".join(new_lines))
click.echo(f"Fixed: {file}")
@image.command("jpg-to-pdf")
@click.argument("files", nargs=-1, required=True)
@click.option("--output", "-o", help="Output PDF file")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def jpg_to_pdf(files, output, dry_run):
"""Convert JPG files to PDF."""
images = []
for pattern in files:
for jpg_file in Path(".").glob(pattern):
if jpg_file.suffix.lower() in (".jpg", ".jpeg"):
images.append(str(jpg_file))
if not images:
click.echo("No JPG files found")
return
output = output or "output.pdf"
if dry_run:
click.echo(f"Would convert {len(images)} images to {output}")
return
click.echo(f"Converting {len(images)} images to {output}")
run_command(["convert"] + images + [output], check=False)
@image.command("tiff-to-pdf")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def tiff_to_pdf(files, dry_run):
"""Convert TIFF files to PDF."""
for pattern in files:
for tiff_file in Path(".").glob(pattern):
if tiff_file.suffix.lower() not in (".tif", ".tiff"):
continue
output = tiff_file.with_suffix(".pdf")
if dry_run:
click.echo(f"Would convert: {tiff_file} -> {output}")
continue
click.echo(f"Converting: {tiff_file} -> {output}")
run_command(["convert", str(tiff_file), str(output)], check=False)
@image.command("tiff-compress")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def tiff_compress(files, dry_run):
"""Compress TIFF files."""
for pattern in files:
for tiff_file in Path(".").glob(pattern):
if tiff_file.suffix.lower() not in (".tif", ".tiff"):
continue
if dry_run:
click.echo(f"Would compress: {tiff_file}")
continue
click.echo(f"Compressing: {tiff_file}")
run_command(
["convert", str(tiff_file), "-compress", "zip", str(tiff_file)],
check=False,
)
+49
View File
@@ -0,0 +1,49 @@
"""LaTeX utilities."""
import subprocess
from pathlib import Path
import click
from mycli.utils import handle_errors, run_command
@click.group()
def latex():
"""LaTeX compilation and utilities."""
pass
@latex.command("compile")
@click.argument("files", nargs=-1, required=True)
@click.option("--engine", "-e", default="pdflatex", help="LaTeX engine")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def compile(files, engine, dry_run):
"""Compile LaTeX files."""
for pattern in files:
for tex_file in Path(".").glob(pattern):
if tex_file.suffix.lower() != ".tex":
continue
if dry_run:
click.echo(f"Would compile: {tex_file}")
continue
click.echo(f"Compiling: {tex_file}")
run_command([engine, str(tex_file)], check=False)
@latex.command("count")
@click.argument("file", type=click.Path(exists=True))
@handle_errors
def count(file):
"""Count words in a LaTeX file (excluding commands)."""
result = run_command(
["detex", file],
capture_output=True,
text=True,
check=False,
)
words = len(result.stdout.split())
click.echo(f"Words: {words}")
+98
View File
@@ -0,0 +1,98 @@
"""PDF utilities."""
import subprocess
from pathlib import Path
import click
from mycli.utils import handle_errors, run_command
@click.group()
def pdf():
"""PDF manipulation commands."""
pass
@pdf.command("compress")
@click.argument("files", nargs=-1, required=True)
@click.option(
"--quality",
"-q",
type=click.Choice(["screen", "ebook", "printer", "prepress"]),
default="screen",
help="Compression quality level",
)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def compress(files, quality, dry_run):
"""Compress PDF files using ghostscript."""
for pattern in files:
for pdf_file in Path(".").glob(pattern):
if pdf_file.suffix.lower() != ".pdf":
continue
output = pdf_file.with_suffix(".compressed.pdf")
cmd = [
"gs",
"-sDEVICE=pdfwrite",
"-dNOPAUSE",
"-dQUIET",
"-dBATCH",
f"-dPDFSETTINGS=/{quality}",
"-dCompatibilityLevel=1.4",
f"-sOutputFile={output}",
str(pdf_file),
]
if dry_run:
click.echo(f"Would compress: {pdf_file} -> {output}")
continue
click.echo(f"Compressing: {pdf_file}")
run_command(cmd, check=True)
output.replace(pdf_file)
click.echo(f" Done: {pdf_file}")
@pdf.command("crop")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def crop(files, dry_run):
"""Crop PDF files."""
for pattern in files:
for pdf_file in Path(".").glob(pattern):
if pdf_file.suffix.lower() != ".pdf":
continue
if dry_run:
click.echo(f"Would crop: {pdf_file}")
continue
click.echo(f"Cropping: {pdf_file}")
run_command(["pdfcrop", str(pdf_file), str(pdf_file)], check=False)
@pdf.command("to-tiff")
@click.argument("files", nargs=-1, required=True)
@click.option("--density", "-d", default="300", help="DPI density")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def to_tiff(files, density, dry_run):
"""Convert PDF to TIFF."""
for pattern in files:
for pdf_file in Path(".").glob(pattern):
if pdf_file.suffix.lower() != ".pdf":
continue
output = pdf_file.with_suffix(".tiff")
if dry_run:
click.echo(f"Would convert: {pdf_file} -> {output}")
continue
click.echo(f"Converting: {pdf_file} -> {output}")
run_command(
["convert", "-density", density, str(pdf_file), "-compress", "zip", str(output)],
check=False,
)
+86
View File
@@ -0,0 +1,86 @@
"""Self-management commands for mycli."""
import os
import subprocess
import sys
from pathlib import Path
import click
def _get_project_root() -> Path:
"""Get mycli project root."""
# This file is at mycli/commands/self.py
return Path(__file__).parent.parent.parent
@click.group(name="self")
def self_cmd():
"""Manage mycli itself."""
pass
@self_cmd.command("update")
def update():
"""Update mycli from the local repository."""
root = _get_project_root()
click.secho("Updating mycli...", fg="cyan")
# Run make install
result = subprocess.run(
["make", "install"],
cwd=str(root),
capture_output=True,
text=True,
)
if result.returncode == 0:
click.secho("mycli updated successfully.", fg="green")
else:
click.secho("Update failed:", fg="red", err=True)
click.echo(result.stderr, err=True)
raise click.Exit(1)
@self_cmd.command("uninstall")
def uninstall():
"""Uninstall mycli."""
root = _get_project_root()
click.secho("Uninstalling mycli...", fg="cyan")
result = subprocess.run(
["make", "uninstall"],
cwd=str(root),
capture_output=True,
text=True,
)
if result.returncode == 0:
click.secho("mycli uninstalled.", fg="green")
else:
click.secho("Uninstall failed:", fg="red", err=True)
click.echo(result.stderr, err=True)
raise click.Exit(1)
@self_cmd.command("info")
def info():
"""Show mycli installation info."""
root = _get_project_root()
click.secho("MyCLI Info:", fg="cyan")
click.echo(f" Project root: {root}")
click.echo(f" Commands dir: {Path(__file__).parent}")
# Check if in PATH
mycli_path = subprocess.run(
["which", "mycli"],
capture_output=True,
text=True,
)
if mycli_path.returncode == 0:
click.echo(f" Executable: {mycli_path.stdout.strip()}")
else:
click.secho(" Executable: not in PATH", fg="yellow")
+100
View File
@@ -0,0 +1,100 @@
"""SSH connection utilities."""
import subprocess
from pathlib import Path
import click
from mycli.config import config
from mycli.utils import handle_errors
def _get_hosts() -> dict:
"""Get SSH hosts from config."""
return config.get("ssh_hosts", {})
def _build_ssh_base_cmd(host_config: dict) -> list[str]:
"""Build base SSH command with host and port."""
cmd = ["ssh", "-p", str(host_config["port"])]
if "key" in host_config:
key_path = Path(host_config["key"]).expanduser()
cmd.extend(["-i", str(key_path)])
cmd.append(host_config["host"])
return cmd
@click.group(name="ssh")
def ssh_cmd():
"""SSH connection utilities."""
pass
@ssh_cmd.command("connect")
@click.argument("name")
@handle_errors
def connect(name):
"""Connect to a configured host."""
hosts = _get_hosts()
if name not in hosts:
click.echo(f"Unknown host: {name}", err=True)
click.echo(f"Available: {', '.join(hosts.keys())}", err=True)
raise click.Exit(1)
cmd = _build_ssh_base_cmd(hosts[name])
click.secho(f"Connecting to {name}...", fg="cyan")
subprocess.run(cmd)
@ssh_cmd.command("tunnel")
@click.argument("name")
@click.option("--local-port", "-l", default=11111, help="Local port")
@click.option("--remote-port", "-r", default=11111, help="Remote port")
@handle_errors
def tunnel(name, local_port, remote_port):
"""Create SSH tunnel with port forwarding."""
hosts = _get_hosts()
if name not in hosts:
click.echo(f"Unknown host: {name}", err=True)
raise click.Exit(1)
host_config = hosts[name]
cmd = ["ssh", "-p", str(host_config["port"]), "-L", f"{local_port}:localhost:{remote_port}"]
if "key" in host_config:
key_path = Path(host_config["key"]).expanduser()
cmd.extend(["-i", str(key_path)])
cmd.append(host_config["host"])
click.secho(f"Creating tunnel {local_port} -> {remote_port} on {name}...", fg="cyan")
click.secho(f"Command: {' '.join(cmd)}", fg="dim")
subprocess.run(cmd)
@ssh_cmd.command("list")
def list_hosts():
"""List configured SSH hosts."""
hosts = _get_hosts()
click.secho("Configured hosts:", fg="cyan")
for name, host_config in sorted(hosts.items()):
click.echo(f" {name:12} {host_config['host']}:{host_config['port']}")
@ssh_cmd.command("cmd")
@click.argument("name")
@click.argument("command")
@handle_errors
def run_cmd(name, command):
"""Run a command on remote host."""
hosts = _get_hosts()
if name not in hosts:
click.echo(f"Unknown host: {name}", err=True)
raise click.Exit(1)
cmd = _build_ssh_base_cmd(hosts[name])
cmd.append(command)
subprocess.run(cmd)
+32
View File
@@ -0,0 +1,32 @@
"""Utility commands."""
from pathlib import Path
import click
from mycli.utils import handle_errors
@click.group()
def utils():
"""Miscellaneous utilities."""
pass
@utils.command("albany-cleanup")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def albany_cleanup(dry_run):
"""Clean up Albany temporary files (phalanx_*)."""
count = 0
for file in Path(".").glob("phalanx_*"):
if dry_run:
click.echo(f"Would remove: {file}")
else:
file.unlink()
count += 1
if dry_run:
click.echo("Dry run completed")
else:
click.echo(f"Removed {count} Albany temporary files")
+37
View File
@@ -0,0 +1,37 @@
"""Video utilities."""
import subprocess
from pathlib import Path
import click
from mycli.utils import handle_errors, run_command
@click.group()
def video():
"""Video conversion commands."""
pass
@video.command("avi-to-mp4")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def avi_to_mp4(files, dry_run):
"""Convert AVI files to MP4 using ffmpeg."""
for pattern in files:
for avi_file in Path(".").glob(pattern):
if avi_file.suffix.lower() != ".avi":
continue
output = avi_file.with_suffix(".mp4")
if dry_run:
click.echo(f"Would convert: {avi_file} -> {output}")
continue
click.echo(f"Converting: {avi_file} -> {output}")
run_command(
["ffmpeg", "-i", str(avi_file), "-c:v", "libx264", "-c:a", "aac", str(output)],
check=False,
)