From 54c60eb4630a04bbc3a0e1947bcec047cf798fc2 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sat, 27 Jun 2026 21:39:43 +0800 Subject: [PATCH] feat(cli): pass through claude flags and add --list Bare `myclaude` already launched claude, but a leading unknown flag (`--resume`, `-r `, `--continue`) made click raise "No such command" because it was parsed as a subcommand name. click 8.3 raises this before the group callback runs, so it can't be intercepted there. - Add LaunchGroup overriding resolve_command: any non-subcommand leading token routes the raw args to a hidden `__run__` passthrough that forwards them to claude, run in the resolved cwd (--cwd, default workspace/). - Add `--list/-l` to list resumable sessions (id, mtime, first prompt) for the working directory, read from ~/.claude/projects//. --- myclaude/cli.py | 159 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 26 deletions(-) diff --git a/myclaude/cli.py b/myclaude/cli.py index adad350..2786425 100644 --- a/myclaude/cli.py +++ b/myclaude/cli.py @@ -1,8 +1,11 @@ """Myclaude CLI entrypoint.""" +import json import os +import re import shutil import subprocess +from datetime import datetime from importlib.metadata import PackageNotFoundError, version from pathlib import Path @@ -13,6 +16,7 @@ from myclaude.commands import update_cmd, upgrade_cmd from myclaude.project_root import get_workspace_root stderr_console = Console(stderr=True) +console = Console() def _package_version() -> str: @@ -22,11 +26,109 @@ def _package_version() -> str: return "0.0.0" -def _resolve_chat_cwd() -> Path: - return get_workspace_root() +def _resolve_chat_cwd(cwd: str | None) -> Path: + """Working directory for claude: --cwd if given, else workspace/.""" + if cwd is None: + return get_workspace_root() + chat_cwd = Path(cwd).resolve() + if not chat_cwd.is_dir(): + stderr_console.print(f"[red]Not a directory:[/red] {cwd}") + raise SystemExit(1) + return chat_cwd + + +def _launch(chat_cwd: Path, extra: list[str]) -> None: + """Run claude in chat_cwd, forwarding extra args. Never returns.""" + binary = os.environ.get("CLAUDE_BIN") or shutil.which("claude") + if not binary: + stderr_console.print( + "[red]claude CLI not found in PATH.[/red] Install Claude Code or set " + "[cyan]CLAUDE_BIN[/cyan].", + ) + raise SystemExit(127) + + cmd = [binary, "--dangerously-skip-permissions", *extra] + proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) + raise SystemExit(proc.returncode) + + +def _sessions_dir(chat_cwd: Path) -> Path: + """Claude stores session logs under ~/.claude/projects//.""" + munged = re.sub(r"[^A-Za-z0-9]", "-", str(chat_cwd)) + return Path.home() / ".claude" / "projects" / munged + + +def _first_prompt(session_file: Path) -> str: + """Best-effort snippet of the first human prompt in a session log.""" + try: + with session_file.open(encoding="utf-8", errors="ignore") as fh: + for line in fh: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") != "user": + continue + content = entry.get("message", {}).get("content") + if isinstance(content, list): + content = " ".join( + block.get("text", "") + for block in content + if isinstance(block, dict) + and block.get("type") == "text" + ) + if isinstance(content, str) and content.strip(): + return " ".join(content.split())[:80] + except OSError: + pass + return "" + + +def _list_sessions(chat_cwd: Path) -> None: + """Print resumable claude sessions for chat_cwd, newest first.""" + files = sorted( + _sessions_dir(chat_cwd).glob("*.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not files: + stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}") + return + + console.print(f"[bold]Sessions in[/bold] {chat_cwd}") + for session_file in files: + mtime = datetime.fromtimestamp(session_file.stat().st_mtime) + snippet = _first_prompt(session_file) or "[dim](empty)[/dim]" + console.print( + f" [cyan]{session_file.stem}[/cyan] " + f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" + ) + console.print( + "\n[dim]Resume with[/dim] [green]myclaude -r [/green] " + "[dim](add --cwd if not workspace).[/dim]" + ) + + +class LaunchGroup(click.Group): + """Group that forwards any non-subcommand invocation to claude. + + Unknown leading tokens (``--resume``, ``-r ``, ``--continue`` …) would + otherwise make click raise "No such command". Instead we stash the raw + tokens and route them to the hidden ``__run__`` command, which launches + claude with them as passthrough. + """ + + def resolve_command(self, ctx, args): # type: ignore[override] + if args and not args[0].startswith("-") and args[0] in self.commands: + return super().resolve_command(ctx, args) + ctx.meta["passthrough"] = list(args) + run = self.get_command(ctx, "__run__") + assert run is not None + return run.name, run, [] @click.group( + cls=LaunchGroup, invoke_without_command=True, context_settings={ "ignore_unknown_options": True, @@ -43,40 +145,45 @@ def _resolve_chat_cwd() -> Path: type=click.Path(dir_okay=True, file_okay=False), help="Use specified path as working directory (default: workspace/).", ) +@click.option( + "--list", + "-l", + "list_sessions", + is_flag=True, + default=False, + help="List resumable claude sessions for the working directory and exit.", +) @click.pass_context -def cli( - ctx: click.Context, - cwd: str | None, -) -> None: +def cli(ctx: click.Context, cwd: str | None, list_sessions: bool) -> None: """Myclaude: CLI toolkit. Run without subcommands to start Claude Code in workspace/. - Extra arguments pass through to claude. + Unknown arguments (--resume, -r , --continue, …) pass through to claude. """ - if ctx.invoked_subcommand is not None: + # Real subcommands (update / upgrade) handle themselves. + if ctx.invoked_subcommand not in (None, "__run__"): return - binary = os.environ.get("CLAUDE_BIN") or shutil.which("claude") - if not binary: - stderr_console.print( - "[red]claude CLI not found in PATH.[/red] Install Claude Code or set " - "[cyan]CLAUDE_BIN[/cyan].", - ) - raise SystemExit(127) + chat_cwd = _resolve_chat_cwd(cwd) - if cwd is None: - chat_cwd = _resolve_chat_cwd() - else: - chat_cwd = Path(cwd).resolve() - if not chat_cwd.is_dir(): - stderr_console.print(f"[red]Not a directory:[/red] {cwd}") - raise SystemExit(1) + if list_sessions: + _list_sessions(chat_cwd) + raise SystemExit(0) - cmd: list[str] = [binary, "--dangerously-skip-permissions"] - cmd.extend(ctx.args) + # Bare `myclaude`: launch directly. The `__run__` branch handles passthrough. + if ctx.invoked_subcommand is None: + _launch(chat_cwd, []) - proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) - raise SystemExit(proc.returncode) + +@cli.command(name="__run__", hidden=True) +@click.pass_context +def _run(ctx: click.Context) -> None: + """Hidden passthrough target: launch claude with stashed raw args.""" + parent = ctx.parent + assert parent is not None + chat_cwd = _resolve_chat_cwd(parent.params.get("cwd")) + extra = list(ctx.meta.get("passthrough", [])) + _launch(chat_cwd, extra) cli.add_command(update_cmd)