Files
myagents/myclaude/cli.py
T
Zhengshou Lai 54c60eb463 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>/.
2026-06-27 21:39:43 +08:00

199 lines
6.0 KiB
Python

"""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
import click
from rich.console import Console
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:
try:
return version("myclaude")
except PackageNotFoundError:
return "0.0.0"
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,
"allow_extra_args": True,
},
)
@click.version_option(version=_package_version())
@click.option(
"--cwd",
"-C",
is_flag=False,
flag_value=".",
default=None,
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, list_sessions: bool) -> None:
"""Myclaude: CLI toolkit.
Run without subcommands to start Claude Code in workspace/.
Unknown arguments (--resume, -r <id>, --continue, …) pass through to claude.
"""
# Real subcommands (update / upgrade) handle themselves.
if ctx.invoked_subcommand not in (None, "__run__"):
return
chat_cwd = _resolve_chat_cwd(cwd)
if list_sessions:
_list_sessions(chat_cwd)
raise SystemExit(0)
# Bare `myclaude`: launch directly. The `__run__` branch handles passthrough.
if ctx.invoked_subcommand is None:
_launch(chat_cwd, [])
@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(upgrade_cmd, name="upgrade")
def main() -> None:
cli()
if __name__ == "__main__":
main()