"""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 = 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 [/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 ``, ``--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 , --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()