Files
jianda-proxy/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md
2026-05-28 17:59:24 +08:00

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 uv can 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_proxy works

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 tool uninstall 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.