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.""" """Myclaude CLI entrypoint."""
import json
import os import os
import re
import shutil import shutil
import subprocess import subprocess
from datetime import datetime
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
@@ -13,6 +16,7 @@ from myclaude.commands import update_cmd, upgrade_cmd
from myclaude.project_root import get_workspace_root from myclaude.project_root import get_workspace_root
stderr_console = Console(stderr=True) stderr_console = Console(stderr=True)
console = Console()
def _package_version() -> str: def _package_version() -> str:
@@ -22,11 +26,109 @@ def _package_version() -> str:
return "0.0.0" 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() 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( @click.group(
cls=LaunchGroup,
invoke_without_command=True, invoke_without_command=True,
context_settings={ context_settings={
"ignore_unknown_options": True, "ignore_unknown_options": True,
@@ -43,40 +145,45 @@ def _resolve_chat_cwd() -> Path:
type=click.Path(dir_okay=True, file_okay=False), type=click.Path(dir_okay=True, file_okay=False),
help="Use specified path as working directory (default: workspace/).", 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 @click.pass_context
def cli( def cli(ctx: click.Context, cwd: str | None, list_sessions: bool) -> None:
ctx: click.Context,
cwd: str | None,
) -> None:
"""Myclaude: CLI toolkit. """Myclaude: CLI toolkit.
Run without subcommands to start Claude Code in workspace/. 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 return
binary = os.environ.get("CLAUDE_BIN") or shutil.which("claude") chat_cwd = _resolve_chat_cwd(cwd)
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)
if cwd is None: if list_sessions:
chat_cwd = _resolve_chat_cwd() _list_sessions(chat_cwd)
else: raise SystemExit(0)
chat_cwd = Path(cwd).resolve()
if not chat_cwd.is_dir():
stderr_console.print(f"[red]Not a directory:[/red] {cwd}")
raise SystemExit(1)
cmd: list[str] = [binary, "--dangerously-skip-permissions"] # Bare `myclaude`: launch directly. The `__run__` branch handles passthrough.
cmd.extend(ctx.args) 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) cli.add_command(update_cmd)