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
+51
View File
@@ -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"
+88
View File
@@ -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
```
+3
View File
@@ -0,0 +1,3 @@
"""MyCLI - Personal CLI toolkit."""
__version__ = "0.1.0"
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.
+34
View File
@@ -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()
+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,
)
+34
View File
@@ -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"
}
+77
View File
@@ -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()
+42
View File
@@ -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)
+15
View File
@@ -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"
Generated
+58
View File
@@ -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" }]