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