commit ab8067f0c58bf1d69d9abe5699c2f796a0f22502 Author: Zhengshou Lai Date: Mon Apr 13 11:38:01 2026 +0800 Initial commit: mycli toolkit with all commands diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0034b5f --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +ROOT_DIR := $(shell pwd) +VENV_MYCLI := $(ROOT_DIR)/.venv/bin/mycli +USER_LOCAL_MYCLI := $(HOME)/.local/bin/mycli +COMP_DIR := $(HOME)/.local/bin/completions +# Use user's login shell for completion detection +SHELL_NAME := $(notdir $(basename $(shell echo $$SHELL))) + +.PHONY: help install uninstall _symlink-mycli _install-completions _uninstall-completions + +help: + @echo "Usage: make [target]" + @echo "" + @echo " install Sync venv, symlink bin, install completions" + @echo " uninstall Remove bin and completions" + +install: + @cd "$(ROOT_DIR)" && \ + if command -v uv >/dev/null 2>&1; then \ + uv sync; \ + else \ + test -x .venv/bin/pip || python3 -m venv .venv; \ + .venv/bin/pip install -e .; \ + fi + @$(MAKE) _symlink-mycli + @$(MAKE) _install-completions + +_symlink-mycli: + @test -x "$(VENV_MYCLI)" || { echo "error: missing $(VENV_MYCLI)"; exit 1; } + @mkdir -p "$(HOME)/.local/bin" + @ln -sf "$(VENV_MYCLI)" "$(USER_LOCAL_MYCLI)" + @echo "Linked $(USER_LOCAL_MYCLI) -> $(VENV_MYCLI)" + +_install-completions: + @mkdir -p "$(COMP_DIR)" +ifeq ($(SHELL_NAME),zsh) + @_MYCLI_COMPLETE=zsh_source mycli > "$(COMP_DIR)/_mycli" 2>/dev/null && \ + echo "Installed zsh completion: $(COMP_DIR)/_mycli" || \ + echo "Warning: failed to generate zsh completion" +else ifeq ($(SHELL_NAME),bash) + @_MYCLI_COMPLETE=bash_source mycli > "$(COMP_DIR)/mycli.bash" 2>/dev/null && \ + echo "Installed bash completion: $(COMP_DIR)/mycli.bash" || \ + echo "Warning: failed to generate bash completion" +endif + +uninstall: _uninstall-completions + @rm -f "$(USER_LOCAL_MYCLI)" + @echo "Removed $(USER_LOCAL_MYCLI)" + +_uninstall-completions: + @rm -f "$(COMP_DIR)/_mycli" "$(COMP_DIR)/mycli.bash" + @echo "Removed completions" diff --git a/README.md b/README.md new file mode 100644 index 0000000..649eba0 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# MyCLI + +Personal CLI toolkit - unified interface for various utility scripts. + +## Installation + +```bash +make install +``` + +## Commands + +```bash +mycli --help + +# PDF utilities +mycli pdf compress *.pdf +mycli pdf crop *.pdf +mycli pdf to-tiff *.pdf + +# Image utilities +mycli image eps-to-pdf *.eps +mycli image jpg-to-pdf *.jpg -o output.pdf +mycli image tiff-to-pdf *.tiff +mycli image tiff-compress *.tiff + +# LaTeX utilities +mycli latex compile *.tex +mycli latex count document.tex + +# Video utilities +mycli video avi-to-mp4 *.avi + +# BibTeX utilities +mycli bib to-markdown *.bib +mycli bib cv-update + +# Utilities +mycli utils albany-cleanup + +# Environment & Navigation +mycli env list # List configured paths +mycli env goto research # Print path for cd +mycli env cd apaam # Start shell in path +mycli env status # Show environment status +cd $(mycli env goto study) # Use in scripts + +# SSH Connections +mycli ssh list # List configured hosts +mycli ssh connect tianhe # SSH to host +mycli ssh connect starlight +mycli ssh tunnel tianhe # Create port tunnel +mycli ssh cmd tianhe "ls" # Run remote command + +# Git Proxy Management +mycli git proxy # Show proxy status +mycli git proxy fastgithub # Set proxy +mycli git proxy pandafan +mycli git proxy -u # Unset proxy +mycli git proxy-list # List available proxies + +# Self Management +mycli self update # Update mycli +mycli self uninstall # Uninstall mycli +mycli self info # Show installation info + +# Configuration (config.json) +mycli config show # Show full config +mycli config get paths # Get paths section +mycli config get paths.apaam # Get specific path +mycli config paths # List all paths with existence check +mycli config where # Show config file location +mycli config edit # Edit config in $EDITOR +``` + +## Configuration + +All settings are stored in `mycli/config.json`: +- `paths`: Quick navigation paths +- `ssh_hosts`: SSH connection settings +- `git_proxies`: Git proxy URLs +- `openfoam`: OpenFOAM mount settings + +## Uninstall + +```bash +make uninstall +``` diff --git a/mycli/__init__.py b/mycli/__init__.py new file mode 100644 index 0000000..a9d68bf --- /dev/null +++ b/mycli/__init__.py @@ -0,0 +1,3 @@ +"""MyCLI - Personal CLI toolkit.""" + +__version__ = "0.1.0" diff --git a/mycli/__pycache__/__init__.cpython-310.pyc b/mycli/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..de2e24a Binary files /dev/null and b/mycli/__pycache__/__init__.cpython-310.pyc differ diff --git a/mycli/__pycache__/__init__.cpython-314.pyc b/mycli/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0d956d7 Binary files /dev/null and b/mycli/__pycache__/__init__.cpython-314.pyc differ diff --git a/mycli/__pycache__/cli.cpython-310.pyc b/mycli/__pycache__/cli.cpython-310.pyc new file mode 100644 index 0000000..a4b3076 Binary files /dev/null and b/mycli/__pycache__/cli.cpython-310.pyc differ diff --git a/mycli/__pycache__/cli.cpython-314.pyc b/mycli/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000..027f203 Binary files /dev/null and b/mycli/__pycache__/cli.cpython-314.pyc differ diff --git a/mycli/__pycache__/config.cpython-310.pyc b/mycli/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..93b9c17 Binary files /dev/null and b/mycli/__pycache__/config.cpython-310.pyc differ diff --git a/mycli/__pycache__/config.cpython-314.pyc b/mycli/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..8a7bf93 Binary files /dev/null and b/mycli/__pycache__/config.cpython-314.pyc differ diff --git a/mycli/__pycache__/utils.cpython-310.pyc b/mycli/__pycache__/utils.cpython-310.pyc new file mode 100644 index 0000000..24dfad9 Binary files /dev/null and b/mycli/__pycache__/utils.cpython-310.pyc differ diff --git a/mycli/__pycache__/utils.cpython-314.pyc b/mycli/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000..dda4fdb Binary files /dev/null and b/mycli/__pycache__/utils.cpython-314.pyc differ diff --git a/mycli/cli.py b/mycli/cli.py new file mode 100644 index 0000000..8b3c61f --- /dev/null +++ b/mycli/cli.py @@ -0,0 +1,34 @@ +"""MyCLI main entry point.""" + +import click + +from mycli.commands import pdf, image, latex, video, bib, utils, env_cmd, ssh_cmd, git_cmd, self_cmd, config_cmd + + +@click.group() +@click.version_option(version="0.1.0", prog_name="mycli") +def cli(): + """Personal CLI toolkit.""" + pass + + +# Register command groups +cli.add_command(pdf.pdf) +cli.add_command(image.image) +cli.add_command(latex.latex) +cli.add_command(video.video) +cli.add_command(bib.bib) +cli.add_command(utils.utils) +cli.add_command(env_cmd) +cli.add_command(ssh_cmd) +cli.add_command(git_cmd) +cli.add_command(self_cmd) +cli.add_command(config_cmd) + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/mycli/commands/__init__.py b/mycli/commands/__init__.py new file mode 100644 index 0000000..79bfce1 --- /dev/null +++ b/mycli/commands/__init__.py @@ -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"] diff --git a/mycli/commands/__pycache__/__init__.cpython-310.pyc b/mycli/commands/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..d08acca Binary files /dev/null and b/mycli/commands/__pycache__/__init__.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/__init__.cpython-314.pyc b/mycli/commands/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..d6306df Binary files /dev/null and b/mycli/commands/__pycache__/__init__.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/bib.cpython-310.pyc b/mycli/commands/__pycache__/bib.cpython-310.pyc new file mode 100644 index 0000000..f2a7edb Binary files /dev/null and b/mycli/commands/__pycache__/bib.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/bib.cpython-314.pyc b/mycli/commands/__pycache__/bib.cpython-314.pyc new file mode 100644 index 0000000..eae59f2 Binary files /dev/null and b/mycli/commands/__pycache__/bib.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/config_cmd.cpython-310.pyc b/mycli/commands/__pycache__/config_cmd.cpython-310.pyc new file mode 100644 index 0000000..0315113 Binary files /dev/null and b/mycli/commands/__pycache__/config_cmd.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/config_cmd.cpython-314.pyc b/mycli/commands/__pycache__/config_cmd.cpython-314.pyc new file mode 100644 index 0000000..4b39f8c Binary files /dev/null and b/mycli/commands/__pycache__/config_cmd.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/env.cpython-310.pyc b/mycli/commands/__pycache__/env.cpython-310.pyc new file mode 100644 index 0000000..44ee59e Binary files /dev/null and b/mycli/commands/__pycache__/env.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/env.cpython-314.pyc b/mycli/commands/__pycache__/env.cpython-314.pyc new file mode 100644 index 0000000..735d24a Binary files /dev/null and b/mycli/commands/__pycache__/env.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/git.cpython-310.pyc b/mycli/commands/__pycache__/git.cpython-310.pyc new file mode 100644 index 0000000..41f1ba2 Binary files /dev/null and b/mycli/commands/__pycache__/git.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/git.cpython-314.pyc b/mycli/commands/__pycache__/git.cpython-314.pyc new file mode 100644 index 0000000..6c0b5c1 Binary files /dev/null and b/mycli/commands/__pycache__/git.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/image.cpython-310.pyc b/mycli/commands/__pycache__/image.cpython-310.pyc new file mode 100644 index 0000000..57fbce9 Binary files /dev/null and b/mycli/commands/__pycache__/image.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/image.cpython-314.pyc b/mycli/commands/__pycache__/image.cpython-314.pyc new file mode 100644 index 0000000..6699881 Binary files /dev/null and b/mycli/commands/__pycache__/image.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/latex.cpython-310.pyc b/mycli/commands/__pycache__/latex.cpython-310.pyc new file mode 100644 index 0000000..1f830b8 Binary files /dev/null and b/mycli/commands/__pycache__/latex.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/latex.cpython-314.pyc b/mycli/commands/__pycache__/latex.cpython-314.pyc new file mode 100644 index 0000000..57408be Binary files /dev/null and b/mycli/commands/__pycache__/latex.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/pdf.cpython-310.pyc b/mycli/commands/__pycache__/pdf.cpython-310.pyc new file mode 100644 index 0000000..d239a8c Binary files /dev/null and b/mycli/commands/__pycache__/pdf.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/pdf.cpython-314.pyc b/mycli/commands/__pycache__/pdf.cpython-314.pyc new file mode 100644 index 0000000..cfd52cc Binary files /dev/null and b/mycli/commands/__pycache__/pdf.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/self_mgmt.cpython-310.pyc b/mycli/commands/__pycache__/self_mgmt.cpython-310.pyc new file mode 100644 index 0000000..dfe354e Binary files /dev/null and b/mycli/commands/__pycache__/self_mgmt.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/self_mgmt.cpython-314.pyc b/mycli/commands/__pycache__/self_mgmt.cpython-314.pyc new file mode 100644 index 0000000..6fcdbdd Binary files /dev/null and b/mycli/commands/__pycache__/self_mgmt.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/ssh.cpython-310.pyc b/mycli/commands/__pycache__/ssh.cpython-310.pyc new file mode 100644 index 0000000..42e3b83 Binary files /dev/null and b/mycli/commands/__pycache__/ssh.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/ssh.cpython-314.pyc b/mycli/commands/__pycache__/ssh.cpython-314.pyc new file mode 100644 index 0000000..db94551 Binary files /dev/null and b/mycli/commands/__pycache__/ssh.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/utils.cpython-310.pyc b/mycli/commands/__pycache__/utils.cpython-310.pyc new file mode 100644 index 0000000..3139527 Binary files /dev/null and b/mycli/commands/__pycache__/utils.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/utils.cpython-314.pyc b/mycli/commands/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000..4b0e84e Binary files /dev/null and b/mycli/commands/__pycache__/utils.cpython-314.pyc differ diff --git a/mycli/commands/__pycache__/video.cpython-310.pyc b/mycli/commands/__pycache__/video.cpython-310.pyc new file mode 100644 index 0000000..1cf8c90 Binary files /dev/null and b/mycli/commands/__pycache__/video.cpython-310.pyc differ diff --git a/mycli/commands/__pycache__/video.cpython-314.pyc b/mycli/commands/__pycache__/video.cpython-314.pyc new file mode 100644 index 0000000..45e2fd7 Binary files /dev/null and b/mycli/commands/__pycache__/video.cpython-314.pyc differ diff --git a/mycli/commands/bib.py b/mycli/commands/bib.py new file mode 100644 index 0000000..be0851a --- /dev/null +++ b/mycli/commands/bib.py @@ -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) diff --git a/mycli/commands/config_cmd.py b/mycli/commands/config_cmd.py new file mode 100644 index 0000000..adb3a7f --- /dev/null +++ b/mycli/commands/config_cmd.py @@ -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) diff --git a/mycli/commands/env.py b/mycli/commands/env.py new file mode 100644 index 0000000..2d67add --- /dev/null +++ b/mycli/commands/env.py @@ -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 )).""" + 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") diff --git a/mycli/commands/git.py b/mycli/commands/git.py new file mode 100644 index 0000000..fd3bd8b --- /dev/null +++ b/mycli/commands/git.py @@ -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}") diff --git a/mycli/commands/image.py b/mycli/commands/image.py new file mode 100644 index 0000000..2b5ac30 --- /dev/null +++ b/mycli/commands/image.py @@ -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, + ) diff --git a/mycli/commands/latex.py b/mycli/commands/latex.py new file mode 100644 index 0000000..e2cec03 --- /dev/null +++ b/mycli/commands/latex.py @@ -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}") diff --git a/mycli/commands/pdf.py b/mycli/commands/pdf.py new file mode 100644 index 0000000..99e14be --- /dev/null +++ b/mycli/commands/pdf.py @@ -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, + ) diff --git a/mycli/commands/self_mgmt.py b/mycli/commands/self_mgmt.py new file mode 100644 index 0000000..a42c8e4 --- /dev/null +++ b/mycli/commands/self_mgmt.py @@ -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") diff --git a/mycli/commands/ssh.py b/mycli/commands/ssh.py new file mode 100644 index 0000000..8dd2378 --- /dev/null +++ b/mycli/commands/ssh.py @@ -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) diff --git a/mycli/commands/utils.py b/mycli/commands/utils.py new file mode 100644 index 0000000..f3e5bf6 --- /dev/null +++ b/mycli/commands/utils.py @@ -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") diff --git a/mycli/commands/video.py b/mycli/commands/video.py new file mode 100644 index 0000000..e9f8450 --- /dev/null +++ b/mycli/commands/video.py @@ -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, + ) diff --git a/mycli/config.json b/mycli/config.json new file mode 100644 index 0000000..3cdff66 --- /dev/null +++ b/mycli/config.json @@ -0,0 +1,34 @@ +{ + "paths": { + "study": "~/Documents/myStudy/self_learning", + "research": "~/Documents/myResearch", + "apaam": "~/Documents/myResearch/myProjects/apaam", + "tianhe": "~/Documents/myWork/sysu/0_中山大学/hpc_tianhe", + "academia": "~/Library/Mobile Documents/iCloud~md~obsidian/Documents/myacademia", + "webpage": "~/Documents/myWork/sysu/4_成果归档/mywebpage", + "slides": "~/Documents/myWork/sysu/4_成果归档/myslides", + "myclaude": "~/Documents/myResearch/myProjects/apaam/repo/myclaude", + "myagent": "~/Documents/myResearch/myProjects/apaam/repo/myagent" + }, + "ssh_hosts": { + "tianhe": { + "host": "sysu_lchuangxy_1@172.16.31.31", + "port": 6666, + "key": "~/Documents/myWork/sysu/0_中山大学/hpc_tianhe/sysu_lchuangxy_1.id" + }, + "starlight": { + "host": "sysu_lchuang_1@proxy.nscc-gz.cn", + "port": 23 + } + }, + "git_proxies": { + "fastgithub": "http://127.0.0.1:38457", + "pandafan": "http://127.0.0.1:10080" + }, + "openfoam": { + "dmg_path": "~/Documents/myResearch/myProjects/apaam/repo/OpenFOAM.dmg", + "mount_point": "/Volumes/OpenFOAM", + "build_path": "/Volumes/OpenFOAM/openfoam/build" + }, + "cv": "~/Documents/Personal/resume_cv/cv_lai_maintaining_latex/cv_lai_maintaining" +} diff --git a/mycli/config.py b/mycli/config.py new file mode 100644 index 0000000..793a2b3 --- /dev/null +++ b/mycli/config.py @@ -0,0 +1,77 @@ +"""Configuration management for mycli.""" + +import json +import os +from pathlib import Path +from typing import Any + + +class Config: + """Configuration manager.""" + + def __init__(self): + self._data: dict = {} + self._config_path = Path(__file__).parent / "config.json" + self._load() + + def _load(self): + """Load configuration from JSON file.""" + if self._config_path.exists(): + with open(self._config_path) as f: + self._data = json.load(f) + else: + self._data = {} + + def get(self, key: str, default: Any = None) -> Any: + """Get configuration value by dot notation key. + + Examples: + config.get("paths.research") + config.get("ssh_hosts.tianhe.port") + """ + keys = key.split(".") + value = self._data + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + return value + + def get_path(self, name: str) -> Path | None: + """Get a path and expand ~ to home directory.""" + path_str = self.get(f"paths.{name}") + if path_str: + return Path(path_str).expanduser() + return None + + def get_all_paths(self) -> dict[str, Path]: + """Get all paths as Path objects.""" + paths = self.get("paths", {}) + return {name: Path(path).expanduser() for name, path in paths.items()} + + def get_ssh_host(self, name: str) -> dict | None: + """Get SSH host configuration.""" + return self.get(f"ssh_hosts.{name}") + + def get_git_proxy(self, name: str) -> str | None: + """Get Git proxy URL.""" + return self.get(f"git_proxies.{name}") + + def get_all_git_proxies(self) -> dict[str, str]: + """Get all Git proxy configurations.""" + return self.get("git_proxies", {}) + + def edit(self): + """Open config file in default editor.""" + editor = os.environ.get("EDITOR", "vim") + os.system(f"{editor} {self._config_path}") + + def show(self): + """Display configuration.""" + import json + print(json.dumps(self._data, indent=2, ensure_ascii=False)) + + +# Global config instance +config = Config() diff --git a/mycli/utils.py b/mycli/utils.py new file mode 100644 index 0000000..fc7022b --- /dev/null +++ b/mycli/utils.py @@ -0,0 +1,42 @@ +"""Utility functions and decorators for mycli.""" + +import functools +import subprocess + +import click + + +def handle_errors(func): + """Decorator to handle common errors gracefully.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except FileNotFoundError as e: + click.secho(f"Command not found: {e.filename}", fg="red", err=True) + raise click.Exit(1) + except subprocess.CalledProcessError as e: + click.secho(f"Command failed with exit code {e.returncode}", fg="red", err=True) + if e.stderr: + click.echo(e.stderr, err=True) + raise click.Exit(1) + except KeyboardInterrupt: + click.echo("\nAborted.") + raise click.Exit(130) + + return wrapper + + +def run_command(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: + """Run a subprocess command with consistent error handling. + + Args: + cmd: Command and arguments as list + check: Whether to check return code + **kwargs: Additional args for subprocess.run + + Returns: + CompletedProcess instance + """ + return subprocess.run(cmd, check=check, **kwargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bc77df9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "mycli" +version = "0.1.0" +description = "Personal CLI toolkit" +requires-python = ">=3.9" +dependencies = [ + "click>=8.0", +] + +[project.scripts] +mycli = "mycli.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..be2a933 --- /dev/null +++ b/uv.lock @@ -0,0 +1,58 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "mycli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [{ name = "click", specifier = ">=8.0" }]