30 KiB
jianda-proxy CLI Tool Packaging — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Transform the single-file lfs-proxy.py script into a distributable CLI tool with uvx/uv tool install support, INI-based config, and cross-platform background daemon management.
Architecture: Four focused modules — config.py (INI config CRUD), proxy.py (HTTP proxy server, refactored from lfs-proxy.py), daemon.py (PID file + cross-platform process lifecycle), cli.py (argparse entry point). Zero external dependencies. hatchling build backend.
Tech Stack: Python 3.8+, configparser, argparse, logging, http.server, ctypes (Windows process checks)
File Map
| File | Responsibility |
|---|---|
pyproject.toml |
Package metadata, entry point, build config |
src/jianda_proxy/__init__.py |
Package marker, version |
src/jianda_proxy/config.py |
Config directory resolution, INI read/write, defaults |
src/jianda_proxy/proxy.py |
HTTP proxy server (from lfs-proxy.py), accepts config dict |
src/jianda_proxy/daemon.py |
PID file CRUD, cross-platform process check/kill, daemonize |
src/jianda_proxy/cli.py |
argparse subcommands, entry point main() |
tests/test_config.py |
Tests for config module |
tests/test_daemon.py |
Tests for daemon module |
tests/test_proxy.py |
Tests for proxy module |
tests/test_cli.py |
Integration tests for CLI |
Task 1: Project Scaffolding
Files:
-
Create:
pyproject.toml -
Create:
src/jianda_proxy/__init__.py -
Step 1: Create
pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "jianda-proxy"
version = "0.1.0"
description = "HTTP proxy for Gitea LFS — rewrites Host header to bypass ICP domain block"
requires-python = ">=3.8"
dependencies = []
[project.scripts]
jianda-proxy = "jianda_proxy.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/jianda_proxy"]
- Step 2: Create
src/jianda_proxy/__init__.py
__version__ = "0.1.0"
- Step 3: Verify
uvcan see the package
Run: uv pip install -e . (from project root, in a venv)
Expected: Package installs successfully
- Step 4: Verify entry point works (will fail, that's OK)
Run: jianda-proxy or python -m jianda_proxy
Expected: ModuleNotFoundError: No module named 'jianda_proxy.cli' (confirms entry point wiring)
- Step 5: Commit
git add pyproject.toml src/jianda_proxy/__init__.py
git commit -m "feat: add project scaffolding with pyproject.toml and src layout"
Task 2: Config Module
Files:
-
Create:
src/jianda_proxy/config.py -
Create:
tests/test_config.py -
Step 1: Write tests for config module
import os
import tempfile
import pytest
from configparser import ConfigParser
from jianda_proxy.config import (
get_config_dir,
get_config_path,
get_pid_path,
get_log_path,
load_config,
set_config_value,
get_config_value,
list_config,
DEFAULTS,
)
FIXTURES = {
"remote_domain": "git.zz.com",
"upstream_host": "62.234.191.215",
"upstream_port": "3000",
"listen_port": "13000",
"listen_host": "127.0.0.1",
}
class TestConfigDir:
def test_returns_path_with_app_name(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
result = get_config_dir()
assert "jianda-proxy" in result
def test_windows_uses_appdata(self, tmp_path, monkeypatch):
monkeypatch.setenv("APPDATA", str(tmp_path), raising=False)
monkeypatch.delenv("HOME", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
result = get_config_dir()
assert "jianda-proxy" in result
class TestConfigCRUD:
def test_load_creates_default_config(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
config = load_config()
for key, val in FIXTURES.items():
assert config.get("proxy", key) == val
def test_load_creates_config_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
load_config()
assert (tmp_path / "config.ini").exists()
def test_set_and_get_value(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
set_config_value("listen_port", "8080")
assert get_config_value("listen_port") == "8080"
def test_set_unknown_key_raises(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
with pytest.raises(ValueError, match="Unknown config key"):
set_config_value("nonexistent_key", "value")
def test_list_config(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
config = load_config()
result = list_config()
assert isinstance(result, dict)
for key, val in FIXTURES.items():
assert result[key] == val
class TestPaths:
def test_config_path(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
assert get_config_path().endswith("config.ini")
def test_pid_path(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
assert get_pid_path().endswith("daemon.pid")
def test_log_path(self, tmp_path, monkeypatch):
monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path))
assert get_log_path().endswith("proxy.log")
- Step 2: Run tests to verify they fail
Run: pytest tests/test_config.py -v
Expected: FAIL with ModuleNotFoundError
- Step 3: Write config module implementation
import os
import configparser
from pathlib import Path
SECTION = "proxy"
DEFAULTS = {
"remote_domain": "git.zz.com",
"upstream_host": "62.234.191.215",
"upstream_port": "3000",
"listen_port": "13000",
"listen_host": "127.0.0.1",
}
def get_config_dir():
if os.name == "nt":
base = os.environ.get("APPDATA", os.path.expanduser("~"))
else:
base = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
return os.path.join(base, "jianda-proxy")
def _ensure_dir(path):
os.makedirs(path, exist_ok=True)
def get_config_path():
return os.path.join(get_config_dir(), "config.ini")
def get_pid_path():
return os.path.join(get_config_dir(), "daemon.pid")
def get_log_path():
return os.path.join(get_config_dir(), "proxy.log")
def load_config():
path = get_config_path()
_ensure_dir(get_config_dir())
parser = configparser.ConfigParser()
if not os.path.exists(path):
parser[SECTION] = dict(DEFAULTS)
with open(path, "w") as f:
parser.write(f)
else:
parser.read(path)
if SECTION not in parser:
parser[SECTION] = dict(DEFAULTS)
return parser
def set_config_value(key, value):
if key not in DEFAULTS:
raise ValueError(f"Unknown config key: {key}. Valid keys: {', '.join(DEFAULTS)}")
parser = load_config()
parser[SECTION][key] = value
with open(get_config_path(), "w") as f:
parser.write(f)
def get_config_value(key):
parser = load_config()
return parser.get(SECTION, key)
def list_config():
parser = load_config()
return dict(parser[SECTION])
- Step 4: Run tests to verify they pass
Run: pytest tests/test_config.py -v
Expected: All PASS
- Step 5: Commit
git add src/jianda_proxy/config.py tests/test_config.py
git commit -m "feat: add config module with INI-based configuration management"
Task 3: Proxy Module
Files:
-
Create:
src/jianda_proxy/proxy.py -
Create:
tests/test_proxy.py -
Step 1: Write tests for proxy module
import threading
import http.client
import time
import pytest
from unittest.mock import MagicMock, patch
from jianda_proxy.proxy import create_server
class TestCreateServer:
def test_creates_server_with_config(self):
config = {
"remote_domain": "git.example.com",
"upstream_host": "1.2.3.4",
"upstream_port": "9000",
"listen_host": "127.0.0.1",
"listen_port": "19999",
}
server = create_server(config)
assert server is not None
server.server_close()
def test_binds_to_correct_port(self):
config = {
"remote_domain": "git.example.com",
"upstream_host": "1.2.3.4",
"upstream_port": "9000",
"listen_host": "127.0.0.1",
"listen_port": "19998",
}
server = create_server(config)
assert server.server_address[1] == 19998
server.server_close()
class TestProxyHandler:
def test_forward_proxy_mode(self):
"""When request URL has a hostname matching remote_domain, rewrite to upstream IP."""
from jianda_proxy.proxy import ProxyHandler
config = {
"remote_domain": "git.example.com",
"upstream_host": "1.2.3.4",
"upstream_port": "9000",
"listen_host": "127.0.0.1",
"listen_port": "19997",
}
server = create_server(config)
handler = ProxyHandler
# The handler should have the correct config set as class attributes
assert hasattr(handler, "REMOTE_DOMAIN")
server.server_close()
- Step 2: Run tests to verify they fail
Run: pytest tests/test_proxy.py -v
Expected: FAIL with ModuleNotFoundError
- Step 3: Write proxy module — extract and refactor from
lfs-proxy.py
Read lfs-proxy.py and refactor into src/jianda_proxy/proxy.py:
import http.client
import http.server
import logging
from urllib.parse import urlparse
logger = logging.getLogger("jianda_proxy")
class ProxyHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
# Set by create_server() before serving
REMOTE_DOMAIN = ""
UPSTREAM_IP = ""
UPSTREAM_PORT = 0
def _proxy_request(self, target_host, target_port, target_path, body=None):
body_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(body_len) if body_len else None
conn = http.client.HTTPConnection(target_host, target_port, timeout=120)
try:
headers = {}
skip = {"host", "transfer-encoding", "connection"}
for k in self.headers:
if k.lower() not in skip:
headers[k] = self.headers[k]
headers["Host"] = f"{ProxyHandler.UPSTREAM_IP}:{ProxyHandler.UPSTREAM_PORT}"
if body_len:
headers["Content-Length"] = str(body_len)
conn.request(self.command, target_path, body=body, headers=headers)
resp = conn.getresponse()
resp_body = resp.read()
self.send_response(resp.status)
sent_cl = False
for k, v in resp.getheaders():
kl = k.lower()
if kl in ("transfer-encoding", "connection"):
continue
if kl == "content-length":
self.send_header(k, len(resp_body))
sent_cl = True
continue
self.send_header(k, v)
if not sent_cl:
self.send_header("Content-Length", len(resp_body))
self.end_headers()
self.wfile.write(resp_body)
finally:
conn.close()
def _do(self):
raw_path = self.path
parsed = urlparse(raw_path)
if (
parsed.hostname
and parsed.hostname != "127.0.0.1"
and parsed.hostname != "localhost"
):
if parsed.hostname == ProxyHandler.REMOTE_DOMAIN:
target_host = ProxyHandler.UPSTREAM_IP
else:
target_host = parsed.hostname
target_port = parsed.port or 80
target_path = parsed.path + ("?" + parsed.query if parsed.query else "")
self._proxy_request(target_host, target_port, target_path)
else:
self._proxy_request(
ProxyHandler.UPSTREAM_IP, ProxyHandler.UPSTREAM_PORT, raw_path
)
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _do
def log_message(self, fmt, *args):
logger.info("%s - %s", self.address_string(), fmt % args)
def create_server(config):
"""Create (but don't start) the proxy server.
Args:
config: dict with keys remote_domain, upstream_host, upstream_port,
listen_host, listen_port (all strings)
Returns:
ThreadingHTTPServer instance
"""
ProxyHandler.REMOTE_DOMAIN = config["remote_domain"]
ProxyHandler.UPSTREAM_IP = config["upstream_host"]
ProxyHandler.UPSTREAM_PORT = int(config["upstream_port"])
listen_host = config.get("listen_host", "127.0.0.1")
listen_port = int(config["listen_port"])
server = http.server.ThreadingHTTPServer((listen_host, listen_port), ProxyHandler)
logger.info(
"Proxy: %s:%s -> %s:%s",
listen_host,
listen_port,
ProxyHandler.UPSTREAM_IP,
ProxyHandler.UPSTREAM_PORT,
)
return server
- Step 4: Run tests to verify they pass
Run: pytest tests/test_proxy.py -v
Expected: All PASS
- Step 5: Commit
git add src/jianda_proxy/proxy.py tests/test_proxy.py
git commit -m "feat: extract proxy server from lfs-proxy.py into module"
Task 4: Daemon Module
Files:
-
Create:
src/jianda_proxy/daemon.py -
Create:
tests/test_daemon.py -
Step 1: Write tests for daemon module
import os
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from jianda_proxy.daemon import (
read_pid,
write_pid,
remove_pid,
is_process_alive,
is_running,
stop_daemon,
get_status,
)
class TestPidFile:
def test_write_and_read_pid(self, tmp_path, monkeypatch):
pid_file = str(tmp_path / "test.pid")
monkeypatch.setattr("jianda_proxy.daemon.get_pid_path", lambda: pid_file)
write_pid(12345)
assert read_pid() == 12345
def test_read_missing_pid_returns_none(self, tmp_path, monkeypatch):
pid_file = str(tmp_path / "nonexistent.pid")
monkeypatch.setattr("jianda_proxy.daemon.get_pid_path", lambda: pid_file)
assert read_pid() is None
def test_remove_pid(self, tmp_path, monkeypatch):
pid_file = str(tmp_path / "test.pid")
monkeypatch.setattr("jianda_proxy.daemon.get_pid_path", lambda: pid_file)
write_pid(12345)
remove_pid()
assert read_pid() is None
class TestProcessCheck:
@patch("os.kill")
def test_alive_on_unix(self, mock_kill):
mock_kill.return_value = None
assert is_process_alive(12345) is True
@patch("os.kill", side_effect=ProcessLookupError)
def test_dead_on_unix(self, mock_kill):
assert is_process_alive(12345) is False
@patch("os.kill", side_effect=PermissionError)
def test_permission_means_alive(self, mock_kill):
# Can't send signal but process exists
assert is_process_alive(12345) is True
class TestIsRunning:
@patch("jianda_proxy.daemon.is_process_alive", return_value=True)
@patch("jianda_proxy.daemon.read_pid", return_value=12345)
def test_running_when_pid_and_alive(self, mock_read, mock_alive):
assert is_running() is True
@patch("jianda_proxy.daemon.is_process_alive", return_value=False)
@patch("jianda_proxy.daemon.read_pid", return_value=12345)
def test_not_running_when_pid_dead(self, mock_read, mock_alive):
assert is_running() is False
@patch("jianda_proxy.daemon.read_pid", return_value=None)
def test_not_running_when_no_pid(self, mock_read):
assert is_running() is False
class TestStopDaemon:
@patch("jianda_proxy.daemon.is_process_alive", return_value=True)
@patch("jianda_proxy.daemon.read_pid", return_value=12345)
@patch("time.sleep")
@patch("os.kill")
def test_stop_sends_sigterm(self, mock_kill, mock_sleep, mock_read, mock_alive):
# First call: alive, second call: dead
mock_alive.side_effect = [True, False]
stop_daemon()
mock_kill.assert_called_once_with(12345, 15) # SIGTERM=15
@patch("jianda_proxy.daemon.remove_pid")
@patch("jianda_proxy.daemon.is_process_alive", return_value=False)
@patch("jianda_proxy.daemon.read_pid", return_value=None)
def test_stop_noop_when_not_running(self, mock_read, mock_alive, mock_remove):
result = stop_daemon()
assert result == "not_running"
class TestGetStatus:
@patch("jianda_proxy.daemon.is_process_alive", return_value=True)
@patch("jianda_proxy.daemon.read_pid", return_value=12345)
def test_status_running(self, mock_read, mock_alive):
status = get_status()
assert status["running"] is True
assert status["pid"] == 12345
@patch("jianda_proxy.daemon.read_pid", return_value=None)
def test_status_not_running(self, mock_read):
status = get_status()
assert status["running"] is False
- Step 2: Run tests to verify they fail
Run: pytest tests/test_daemon.py -v
Expected: FAIL with ModuleNotFoundError
- Step 3: Write daemon module implementation
import os
import sys
import time
import signal
from jianda_proxy.config import get_pid_path
def read_pid():
path = get_pid_path()
if not os.path.exists(path):
return None
try:
with open(path) as f:
return int(f.read().strip())
except (ValueError, OSError):
return None
def write_pid(pid):
path = get_pid_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(str(pid))
def remove_pid():
path = get_pid_path()
if os.path.exists(path):
os.remove(path)
def is_process_alive(pid):
if os.name == "nt":
import ctypes
import ctypes.wintypes
kernel32 = ctypes.windll.kernel32
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
exit_code = ctypes.wintypes.DWORD()
kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
return exit_code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
else:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def is_running():
pid = read_pid()
if pid is None:
return False
return is_process_alive(pid)
def stop_daemon():
pid = read_pid()
if pid is None:
return "not_running"
if not is_process_alive(pid):
remove_pid()
return "not_running"
if os.name == "nt":
os.system(f"taskkill /PID {pid} /T")
else:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
remove_pid()
return "stopped"
# Wait up to 3 seconds for graceful shutdown
for _ in range(6):
time.sleep(0.5)
if not is_process_alive(pid):
remove_pid()
return "stopped"
# Force kill
if os.name == "nt":
os.system(f"taskkill /F /PID {pid} /T")
else:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
remove_pid()
return "stopped"
def get_status():
pid = read_pid()
if pid is None or not is_process_alive(pid):
return {"running": False, "pid": None}
return {"running": True, "pid": pid}
def daemonize():
"""Fork/detach process for background operation."""
if os.name == "nt":
# Windows: relaunch self with detached flag
import subprocess
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
subprocess.Popen(
[sys.executable] + sys.argv + ["--_daemon_child"],
creationflags=creation_flags,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Unix: double fork
pid = os.fork()
if pid > 0:
# Parent exits
os._exit(0)
os.setsid()
pid = os.fork()
if pid > 0:
os._exit(0)
- Step 4: Run tests to verify they pass
Run: pytest tests/test_daemon.py -v
Expected: All PASS
- Step 5: Commit
git add src/jianda_proxy/daemon.py tests/test_daemon.py
git commit -m "feat: add daemon module with cross-platform process lifecycle"
Task 5: CLI Entry Point
Files:
-
Create:
src/jianda_proxy/cli.py -
Create:
tests/test_cli.py -
Step 1: Write tests for CLI subcommands
import os
import tempfile
import subprocess
import pytest
from unittest.mock import patch, MagicMock
from jianda_proxy.cli import build_parser
class TestParser:
def test_start_subcommand_exists(self):
parser = build_parser()
args = parser.parse_args(["start"])
assert args.command == "start"
assert args.foreground is False
def test_start_foreground_flag(self):
parser = build_parser()
args = parser.parse_args(["start", "--foreground"])
assert args.foreground is True
def test_stop_subcommand(self):
parser = build_parser()
args = parser.parse_args(["stop"])
assert args.command == "stop"
def test_status_subcommand(self):
parser = build_parser()
args = parser.parse_args(["status"])
assert args.command == "status"
def test_config_set(self):
parser = build_parser()
args = parser.parse_args(["config", "set", "listen_port", "8080"])
assert args.config_command == "set"
assert args.key == "listen_port"
assert args.value == "8080"
def test_config_get(self):
parser = build_parser()
args = parser.parse_args(["config", "get", "listen_port"])
assert args.config_command == "get"
assert args.key == "listen_port"
def test_config_list(self):
parser = build_parser()
args = parser.parse_args(["config", "list"])
assert args.config_command == "list"
def test_config_path(self):
parser = build_parser()
args = parser.parse_args(["config", "path"])
assert args.config_command == "path"
- Step 2: Run tests to verify they fail
Run: pytest tests/test_cli.py -v
Expected: FAIL with ModuleNotFoundError
- Step 3: Write CLI entry point
import argparse
import logging
import sys
from jianda_proxy.config import (
load_config,
set_config_value,
get_config_value,
list_config,
get_config_path,
get_log_path,
)
from jianda_proxy.proxy import create_server
from jianda_proxy.daemon import (
write_pid,
is_running,
stop_daemon,
get_status,
daemonize,
)
def build_parser():
parser = argparse.ArgumentParser(prog="jianda-proxy", description="HTTP proxy for Gitea LFS")
sub = parser.add_subparsers(dest="command")
# start
p_start = sub.add_parser("start", help="Start the proxy daemon")
p_start.add_argument("--foreground", action="store_true", help="Run in foreground (no daemon)")
p_start.add_argument("--port", type=str, default=None, help="Override listen port")
# stop
sub.add_parser("stop", help="Stop the proxy daemon")
# status
sub.add_parser("status", help="Show proxy daemon status")
# config
p_config = sub.add_parser("config", help="Manage configuration")
config_sub = p_config.add_subparsers(dest="config_command")
p_set = config_sub.add_parser("set", help="Set a config value")
p_set.add_argument("key", help="Config key")
p_set.add_argument("value", help="Config value")
p_get = config_sub.add_parser("get", help="Get a config value")
p_get.add_argument("key", help="Config key")
config_sub.add_parser("list", help="List all config values")
config_sub.add_parser("path", help="Show config file path")
return parser
def cmd_start(args):
if is_running():
print("Error: jianda-proxy is already running.", file=sys.stderr)
sys.exit(1)
config = load_config()
config_dict = dict(config["proxy"])
if args.port:
config_dict["listen_port"] = args.port
if args.foreground:
_run_foreground(config_dict)
else:
_run_daemonized(config_dict)
def _run_foreground(config_dict):
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s - %(message)s",
)
server = create_server(config_dict)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def _run_daemonized(config_dict):
from jianda_proxy.daemon import daemonize
# Set up file logging before daemonizing
from logging.handlers import RotatingFileHandler
log_path = get_log_path()
handler = RotatingFileHandler(log_path, maxBytes=5 * 1024 * 1024, backupCount=3)
handler.setFormatter(
logging.Formatter("%(asctime)s [%(name)s] %(levelname)s - %(message)s")
)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
daemonize()
# This runs in the child process
pid = os.getpid()
write_pid(pid)
server = create_server(config_dict)
try:
server.serve_forever()
finally:
server.server_close()
from jianda_proxy.daemon import remove_pid
remove_pid()
def cmd_stop(_args):
result = stop_daemon()
if result == "not_running":
print("jianda-proxy is not running.", file=sys.stderr)
sys.exit(1)
else:
print("jianda-proxy stopped.")
def cmd_status(_args):
status = get_status()
if status["running"]:
print(f"jianda-proxy is running (PID: {status['pid']})")
else:
print("jianda-proxy is not running.")
def cmd_config(args):
if args.config_command == "set":
set_config_value(args.key, args.value)
print(f"{args.key} = {args.value}")
elif args.config_command == "get":
print(get_config_value(args.key))
elif args.config_command == "list":
for k, v in list_config().items():
print(f"{k} = {v}")
elif args.config_command == "path":
print(get_config_path())
else:
print("Unknown config command. Use set/get/list/path.", file=sys.stderr)
sys.exit(1)
def main():
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
commands = {
"start": cmd_start,
"stop": cmd_stop,
"status": cmd_status,
"config": cmd_config,
}
fn = commands.get(args.command)
if fn:
fn(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
Note: cli.py needs import os at the top. Make sure it's included.
- Step 4: Run CLI parser tests
Run: pytest tests/test_cli.py -v
Expected: All PASS
- Step 5: Manual smoke test — foreground mode
Run: jianda-proxy start --foreground in one terminal, verify it starts and logs to terminal. Ctrl+C to stop.
- Step 6: Manual smoke test — config commands
Run:
jianda-proxy config list
jianda-proxy config path
jianda-proxy config set listen_port 14000
jianda-proxy config get listen_port
jianda-proxy config set listen_port 13000 # restore
- Step 7: Manual smoke test — daemon lifecycle
Run:
jianda-proxy start
jianda-proxy status
jianda-proxy stop
jianda-proxy status # should say not running
- Step 8: Commit
git add src/jianda_proxy/cli.py tests/test_cli.py
git commit -m "feat: add CLI entry point with start/stop/status/config subcommands"
Task 6: Integration Verification & Cleanup
Files:
-
Modify:
README.md -
Step 1: Verify
uv tool install .works
Run: uv tool install . --force
Expected: jianda-proxy command available globally
- Step 2: Verify
python -m jianda_proxyworks
Run: python -m jianda_proxy --help
Expected: Help text displayed
- Step 3: Run full test suite
Run: pytest tests/ -v
Expected: All PASS
- Step 4: Update README.md
Replace README content with updated usage instructions reflecting the new CLI interface (start, stop, status, config subcommands, installation instructions).
- Step 5: Remove old
lfs-proxy.py
git rm lfs-proxy.py
- Step 6: Final commit
git add README.md
git commit -m "docs: update README for CLI tool usage, remove old script"
Task 7: Verify and Finish
- Step 1: Run all tests one final time
Run: pytest tests/ -v
Expected: All PASS
- Step 2: Full lifecycle manual test
Run the complete workflow:
jianda-proxy config list
jianda-proxy start
jianda-proxy status
# Verify proxy works: curl through it
jianda-proxy stop
jianda-proxy status
- Step 3: Verify
uv tooluninstall and reinstall
uv tool uninstall jianda-proxy
uv tool install .
jianda-proxy --help
uv tool uninstall jianda-proxy
- Step 4: Run verification-before-completion skill
Invoke the superpowers:verification-before-completion skill to confirm all requirements are met.