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:
+133
-26
@@ -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-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)
|
||||
|
||||
Reference in New Issue
Block a user