feat(cli): pass through claude flags and add --list

Bare `myclaude` already launched claude, but a leading unknown flag
(`--resume`, `-r <id>`, `--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/<munged-cwd>/.
This commit is contained in:
Zhengshou Lai
2026-06-27 21:39:43 +08:00
parent 9111a574e9
commit 54c60eb463
+132 -25
View File
@@ -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:
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-cwd>/."""
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 <session-id>[/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 <id>``, ``--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 <id>, --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)