#!/usr/bin/env python3 """cc — copy claude. Copies text out of your most recent Claude Code conversation into the clipboard. cc copy Claude's most recent output (alias: cc last) cc prompt copy the original prompt of the conversation cc convo copy the whole conversation: every prompt you typed and Claude's final reply to each — no tools, thinking, or mid-turn status updates cc list show recent sessions (so you can pick one with -s) cc info token usage, turn count, transcript path and resume command cc share upload the conversation and copy its share link Sharing: when a share token is configured, every copy also uploads the conversation to a viewer page (unlisted, stable URL per session; re-running updates it) and prints the link. Configure with `cc setup ` or env CC_SHARE_URL / CC_SHARE_TOKEN. Use --no-share to skip, --minimal to upload only your prompts and Claude's final replies (no mid-turn text, tool calls or thinking). --live makes the page a live window: it follows the transcript as the session runs and viewers can send messages into the session (via session-bridge); --chat-code CODE requires viewers to enter CODE before they can send. Put live=1 in the config to make every share live. Session selection: inside a Claude Code shell (`! cc` or the Bash tool) it uses that exact session. Otherwise: the most recently active session whose working directory is the current directory (or a parent of it). Use -a to ignore the directory, -s to pick a session by id prefix, -n for the Nth most recent. Anything cc doesn't understand is passed through to the next `cc` on your PATH (usually the C compiler), so shadowing /usr/bin/cc is harmless. """ import json import os import re import shutil import subprocess import sys from pathlib import Path __version__ = "0.5.0" SUBCOMMANDS = { "last": "last", "out": "last", "output": "last", "response": "last", "prompt": "prompt", "first": "prompt", "original": "prompt", "convo": "convo", "conversation": "convo", "all": "convo", "full": "convo", "list": "list", "ls": "list", "info": "info", "stats": "info", "share": "share", "link": "share", "setup": "setup", "config": "setup", "help": "help", "version": "version", } FLAGS_WITH_VALUE = {"-s", "--session", "-n", "--nth", "-d", "--dir", "--chat-code"} FLAGS_BOOL = {"-a", "--any", "-p", "--print", "-q", "--quiet", "-h", "--help", "--version", "--no-clip", "--paths", "--no-share", "--minimal", "--live", "--no-live"} # --------------------------------------------------------------------------- # # argv handling + passthrough # --------------------------------------------------------------------------- # def parse_argv(argv): """Return (opts, subcommand) or None if argv is not for us.""" opts = {"session": None, "nth": None, "any": False, "print": False, "quiet": False, "help": False, "version": False, "dir": None, "paths": False, "no_share": False, "minimal": False, "extra": [], "live": None, "chat_code": None} sub = None i = 0 while i < len(argv): a = argv[i] if a in FLAGS_WITH_VALUE: if i + 1 >= len(argv): die("%s needs a value" % a, 2) val = argv[i + 1] i += 2 if a in ("-s", "--session"): if not val: die("-s needs a session id prefix", 2) opts["session"] = val elif a in ("-n", "--nth"): if not val.isdigit() or int(val) < 1: die("-n needs a positive integer, got %r" % val, 2) opts["nth"] = int(val) elif a == "--chat-code": opts["chat_code"] = val else: opts["dir"] = val continue if a.startswith("--session="): opts["session"] = a.split("=", 1)[1] elif a.startswith("--nth="): v = a.split("=", 1)[1] if not v.isdigit() or int(v) < 1: die("--nth needs a positive integer, got %r" % v, 2) opts["nth"] = int(v) elif a.startswith("--dir="): opts["dir"] = a.split("=", 1)[1] elif a in ("-a", "--any"): opts["any"] = True elif a in ("-p", "--print", "--no-clip"): opts["print"] = True elif a in ("-q", "--quiet"): opts["quiet"] = True elif a in ("-h", "--help"): opts["help"] = True elif a == "--version": opts["version"] = True elif a == "--paths": opts["paths"] = True elif a == "--no-share": opts["no_share"] = True elif a == "--minimal": opts["minimal"] = True elif a == "--live": opts["live"] = True elif a == "--no-live": opts["live"] = False elif sub == "setup" and not a.startswith("-"): opts["extra"].append(a) elif a in SUBCOMMANDS and sub is None: sub = SUBCOMMANDS[a] elif a in SUBCOMMANDS: die("only one subcommand allowed (got %r and %r)" % (sub, a), 2) else: return None i += 1 if sub == "help": opts["help"] = True sub = None if sub == "version": opts["version"] = True sub = None return opts, (sub or "last") def find_next_cc(): """Find the next executable named `cc` on PATH that isn't this script.""" me = os.path.realpath(sys.argv[0]) if sys.argv and sys.argv[0] else None exe = "cc.exe" if os.name == "nt" else "cc" for d in os.environ.get("PATH", "").split(os.pathsep): if not d: continue cand = os.path.join(d, exe) if not (os.path.isfile(cand) and os.access(cand, os.X_OK)): continue real = os.path.realpath(cand) if me and real == me: continue # A copy of ourselves under another dir also counts as "us". try: with open(cand, "rb") as fh: head = fh.read(200) if b"copy claude" in head: continue except OSError: continue return cand return None def invoked_as(): return os.path.splitext(os.path.basename(sys.argv[0] or ""))[0].lower() def passthrough(argv): nxt = find_next_cc() if invoked_as() == "cc" else None if nxt is None: sys.stderr.write("cc: unrecognized arguments: %s\n" % " ".join(argv)) sys.stderr.write("cc: (no other `cc` found on PATH to pass them to)\n") sys.stderr.write("cc: try `cc --help`\n") return 2 if os.name == "nt": return subprocess.call([nxt] + argv) os.execv(nxt, [nxt] + argv) return 1 # not reached # --------------------------------------------------------------------------- # # session discovery # --------------------------------------------------------------------------- # UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$") def projects_root(): cfg = os.environ.get("CLAUDE_CONFIG_DIR") base = Path(cfg) if cfg else Path.home() / ".claude" return base / "projects" def all_session_files(): root = projects_root() if not root.is_dir(): return [] files = [] for proj in root.iterdir(): if not proj.is_dir(): continue try: for f in proj.iterdir(): if UUID_RE.match(f.name): try: files.append((f.stat().st_mtime, f)) except OSError: pass except OSError: continue files.sort(key=lambda t: t[0], reverse=True) return [f for _, f in files] def peek_cwd(path, max_lines=60): """Return the cwd recorded in the first few records of a session file.""" try: with open(path, "r", encoding="utf-8", errors="replace") as fh: for _ in range(max_lines): line = fh.readline() if not line: break if '"cwd"' not in line: continue try: d = json.loads(line) except ValueError: continue cwd = d.get("cwd") if cwd: return cwd except OSError: pass return None def norm(p): try: return os.path.normcase(os.path.realpath(p)) except OSError: return os.path.normcase(p) def dir_matches(session_cwd, here): """True if the session's cwd is `here` or an ancestor of `here`.""" if not session_cwd: return False s = norm(session_cwd).rstrip(os.sep) h = norm(here).rstrip(os.sep) return h == s or h.startswith(s + os.sep) def pick_session(opts): files = all_session_files() if not files: die("no Claude Code sessions found under %s" % projects_root()) if not opts["session"] and not opts["any"] and opts["nth"] is None \ and os.environ.get("CLAUDE_CODE_SESSION_ID"): # Running inside a Claude Code shell (Bash tool or `!` prefix): exact. sid = os.environ["CLAUDE_CODE_SESSION_ID"].lower() for f in files: if f.stem.lower() == sid: return f, "CLAUDE_CODE_SESSION_ID" if not opts["quiet"]: sys.stderr.write("cc: CLAUDE_CODE_SESSION_ID=%s has no transcript yet — falling back\n" % sid[:8]) if opts["session"]: pref = opts["session"].lower() hits = [f for f in files if f.stem.lower().startswith(pref)] if not hits: die("no session id starts with %r" % opts["session"]) if len(hits) > 1 and len({h.stem for h in hits}) > 1: die("ambiguous session prefix %r (%d matches)" % (opts["session"], len(hits))) return hits[0], "by id" here = opts["dir"] or os.getcwd() nth = opts["nth"] or 1 if not opts["any"]: # Prefer sessions rooted at cwd (or an ancestor). Rank: exact cwd first # among the top matches, otherwise nearest ancestor by recency. exact, ancestors = [], [] h = norm(here).rstrip(os.sep) for f in files: cwd = peek_cwd(f) if not dir_matches(cwd, here): continue if norm(cwd).rstrip(os.sep) == h: exact.append(f) if len(exact) >= nth: break else: ancestors.append(f) matches = exact + ancestors if len(matches) >= nth: return matches[nth - 1], "cwd match" if nth <= len(exact) else "parent-dir match" if matches: die("only %d session(s) match this directory (asked for #%d)" % (len(matches), nth)) # fall through: nothing for this dir if not opts["quiet"]: sys.stderr.write("cc: no session for %s — using most recent session anywhere\n" % here) if len(files) < nth: die("only %d session(s) exist (asked for #%d)" % (len(files), nth)) return files[nth - 1], "most recent" # --------------------------------------------------------------------------- # # transcript parsing # --------------------------------------------------------------------------- # SYSTEM_REMINDER_RE = re.compile(r".*?\s*", re.S) CMD_NAME_RE = re.compile(r"(.*?)", re.S) CMD_ARGS_RE = re.compile(r"(.*?)", re.S) CHANNEL_RE = re.compile(r']*)>\s*(.*?)\s*', re.S) CHANNEL_ATTR_RE = re.compile(r'(\w+)="([^"]*)"') VIEWER_MSG_RE = re.compile(r'^\[cc-share viewer "([^"]*)"[^\]]*\]\s*\n?(.*)$', re.S) BASH_IN_RE = re.compile(r"^\s*(.*?)\s*$", re.S) BASH_OUT_RE = re.compile(r"(.*?)", re.S) SKIP_USER_MARKERS = ( "", "", "", "[Request interrupted by user", ) def iter_records(path): with open(path, "r", encoding="utf-8", errors="replace") as fh: for line in fh: line = line.strip() if not line: continue try: yield json.loads(line) except ValueError: continue def user_text(rec): """Return the human-typed text of a user record, or None if it isn't one.""" if rec.get("type") != "user": return None if rec.get("isMeta") or rec.get("isSidechain") or rec.get("isCompactSummary"): return None origin = rec.get("origin") or {} if isinstance(origin, dict) and origin.get("kind") not in (None, "human"): return None # task notifications, channel events, etc. if rec.get("promptSource") == "system": return None msg = rec.get("message") or {} content = msg.get("content") parts = [] if isinstance(content, str): parts.append(content) elif isinstance(content, list): for blk in content: if not isinstance(blk, dict): continue t = blk.get("type") if t == "tool_result": return None if t == "text": parts.append(blk.get("text", "")) elif t == "image": parts.append("[image]") else: return None text = "\n".join(p for p in parts if p is not None) text = SYSTEM_REMINDER_RE.sub("", text) if any(m in text for m in SKIP_USER_MARKERS): return None # `! cmd` shell escapes: input and output are separate records m = BASH_IN_RE.match(text) if m: return "! " + m.group(1).strip() if "" in text or "" in text: outs = [b.strip("\n") for _, b in BASH_OUT_RE.findall(text) if b.strip()] body = "\n".join(outs).strip("\n") return "```\n" + body + "\n```" if body else "```\n(no output)\n```" # Slash commands are stored as /x... — flatten. m = CMD_NAME_RE.search(text) if m: name = m.group(1).strip() am = CMD_ARGS_RE.search(text) args = am.group(1).strip() if am else "" text = (name + " " + args).strip() text = text.strip() if not text: return None return text def channel_message(rec): """If rec carries a message that arrived over a channel (session-bridge etc.), return {"name", "text", "source", "chat_id", "delivered"} else None.""" text = None delivered = True if rec.get("type") == "attachment": att = rec.get("attachment") or {} if att.get("type") == "queued_command" and (att.get("origin") or {}).get("kind") == "channel": text = att.get("prompt") or "" elif rec.get("type") == "queue-operation" and rec.get("operation") == "enqueue": text = rec.get("content") or "" delivered = False elif rec.get("type") == "user" and not rec.get("isSidechain"): origin = rec.get("origin") or {} if isinstance(origin, dict) and origin.get("kind") == "channel": c = (rec.get("message") or {}).get("content") text = c if isinstance(c, str) else "\n".join( b.get("text", "") for b in (c or []) if isinstance(b, dict) and b.get("type") == "text") if not text: return None m = CHANNEL_RE.search(text) if not m: return None attrs = dict(CHANNEL_ATTR_RE.findall(m.group(1))) body = m.group(2).strip() name = attrs.get("from_name") or attrs.get("source", "channel").split(":")[-1] vm = VIEWER_MSG_RE.match(body) viewer = False if vm: name, body, viewer = vm.group(1).strip() or "viewer", vm.group(2).strip(), True # drop the delivery note the share server appends for Claude's benefit body = re.split(r"\n\s*\(This came from a viewer of your shared conversation page", body, 1)[0].strip() return {"name": name, "text": body, "source": attrs.get("source", ""), "chat_id": attrs.get("chat_id"), "delivered": delivered, "viewer": viewer} def reply_tool_text(blk): """Text of a session-bridge `reply` tool call (Claude answering a channel).""" name = blk.get("name") or "" if not (name.endswith("__reply") and "session-bridge" in name): return None inp = blk.get("input") or {} for k in ("text", "message", "content", "body"): if isinstance(inp.get(k), str) and inp[k].strip(): return inp[k] return None def assistant_text_block(rec): """Return (message_id, text) if this record is an assistant text block.""" if rec.get("type") != "assistant" or rec.get("isSidechain"): return None msg = rec.get("message") or {} content = msg.get("content") if not isinstance(content, list): return None texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] if not texts: return None return msg.get("id") or rec.get("uuid"), "\n".join(texts) def tool_summary(blk): inp = blk.get("input") if isinstance(inp, dict): # most tools have one obvious "main" argument; show all, compactly try: txt = json.dumps(inp, ensure_ascii=False, indent=None) except (TypeError, ValueError): txt = str(inp) else: txt = str(inp) return txt def result_text(content): if isinstance(content, str): return content if isinstance(content, list): out = [] for b in content: if isinstance(b, dict): if b.get("type") == "text": out.append(b.get("text", "")) elif b.get("type") == "image": out.append("[image]") return "\n".join(out) return "" if content is None else str(content) def clip(s, n): if s is None: return None if len(s) <= n: return s return s[:n] + "\n… [%s more chars]" % "{:,}".format(len(s) - n) def load_turns(path): """Return a list of turns: {"prompt": str, "response": str|None, "ts": ...}. A turn starts at each human prompt. Its response is the text of the LAST assistant message in that turn that contains text (i.e. the final reply, not mid-turn narration between tool calls). If the last assistant message with text is followed by tool use in the same message id, it still counts as the most recent thing Claude said. """ turns = [] cur = None tool_index = {} # tool_use_id -> event dict, to attach results for rec in iter_records(path): # tool results ride in user records; attach to the matching tool event if rec.get("type") == "user" and not rec.get("isSidechain"): c = (rec.get("message") or {}).get("content") if isinstance(c, list): for b in c: if isinstance(b, dict) and b.get("type") == "tool_result": ev = tool_index.get(b.get("tool_use_id")) if ev is not None: ev["result"] = clip(result_text(b.get("content")), 4000) ev["error"] = bool(b.get("is_error")) ch = channel_message(rec) if ch is not None: # a message from outside (share-page viewer, another session): starts a turn if not ch["delivered"]: # enqueue record — the delivered attachment/user record follows later; # keep as pending so live viewers see it immediately if ch["chat_id"] and any(t.get("chat_id") == ch["chat_id"] for t in turns): continue elif ch["chat_id"]: dup = next((t for t in turns if t.get("chat_id") == ch["chat_id"]), None) if dup is not None: dup["pending"] = False continue cur = {"prompt": ch["text"], "response": None, "_id": None, "_pid": None, "ts": rec.get("timestamp"), "events": [], "from": ch["name"], "role": "viewer" if ch["viewer"] else "channel", "chat_id": ch["chat_id"], "pending": not ch["delivered"]} turns.append(cur) continue ut = user_text(rec) if ut is not None: pid = rec.get("promptId") if (cur is not None and cur["response"] is None and pid and cur.get("_pid") == pid): # same promptId twice: `! cmd` input then its output → merge; # slash commands (plain + form) → keep the fuller one if ut.startswith("```") and (cur["prompt"] or "").startswith("! "): cur["prompt"] = cur["prompt"] + "\n" + ut elif len(ut) > len(cur["prompt"] or ""): cur["prompt"] = ut continue cur = {"prompt": ut, "response": None, "_id": None, "_pid": pid, "ts": rec.get("timestamp"), "events": []} turns.append(cur) continue if rec.get("type") != "assistant" or rec.get("isSidechain"): continue msg = rec.get("message") or {} content = msg.get("content") if not isinstance(content, list): continue mid = msg.get("id") or rec.get("uuid") if cur is None: # assistant text before any human prompt (e.g. resumed file) — # keep it under a synthetic turn so `cc last` still works cur = {"prompt": None, "response": None, "_id": None, "_pid": None, "ts": rec.get("timestamp"), "events": []} turns.append(cur) for b in content: if not isinstance(b, dict): continue kind = b.get("type") if kind == "text": text = b.get("text", "") if not text: continue if cur["_id"] == mid and cur["response"] is not None: cur["response"] += "\n" + text cur["events"][-1]["text"] += "\n" + text else: cur["_id"] = mid cur["response"] = text cur["events"].append({"kind": "text", "text": text, "mid": mid}) elif kind == "thinking": th = b.get("thinking", "") if th: cur["events"].append({"kind": "thinking", "text": th}) elif kind == "tool_use" and reply_tool_text(b): # Claude answered a channel message with the reply tool — that's a reply text = reply_tool_text(b) cur["_id"] = mid cur["response"] = text cur["events"].append({"kind": "text", "text": text, "mid": mid, "reply": True}) elif kind == "tool_use": ev = {"kind": "tool", "name": b.get("name", "?"), "input": clip(tool_summary(b), 4000), "result": None, "error": False} cur["events"].append(ev) if b.get("id"): tool_index[b["id"]] = ev for t in turns: # the final reply is the last text event; everything else is "mid-turn" final_mid = t.pop("_id", None) t.pop("_pid", None) for ev in t["events"]: if ev["kind"] == "text": ev["final"] = (ev.get("mid") == final_mid) ev.pop("mid", None) return turns def session_stats(path): """Token usage from assistant `usage` blocks (deduped by message id).""" per_msg = {} for rec in iter_records(path): if rec.get("type") != "assistant" or rec.get("isSidechain"): continue msg = rec.get("message") or {} u = msg.get("usage") if not isinstance(u, dict): continue per_msg[msg.get("id") or rec.get("uuid")] = u total = 0 context = 0 out_total = 0 for u in per_msg.values(): i = u.get("input_tokens", 0) or 0 cc = u.get("cache_creation_input_tokens", 0) or 0 cr = u.get("cache_read_input_tokens", 0) or 0 o = u.get("output_tokens", 0) or 0 total += i + cc + cr + o out_total += o context = i + cc + cr + o return {"messages": len(per_msg), "total": total, "context": context, "output": out_total} def fmt_tokens(n): if n >= 1_000_000: return "%.1fM" % (n / 1_000_000) if n >= 1_000: return "%dk" % round(n / 1_000) return str(n) # --------------------------------------------------------------------------- # # clipboard # --------------------------------------------------------------------------- # def _run(cmd, data): try: p = subprocess.run(cmd, input=data.encode("utf-8"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) return p.returncode == 0 except (OSError, subprocess.TimeoutExpired): return False def copy_to_clipboard(text): """Return the name of the backend used, or None if everything failed.""" is_wsl = "microsoft" in os.uname().release.lower() if hasattr(os, "uname") else False if os.name == "nt" or is_wsl: ps = shutil.which("powershell.exe") or shutil.which("pwsh.exe") or shutil.which("powershell") or shutil.which("pwsh") if ps: script = ("[Console]::InputEncoding=[System.Text.Encoding]::UTF8;" "$t=[Console]::In.ReadToEnd();Set-Clipboard -Value $t") if _run([ps, "-NoProfile", "-NonInteractive", "-Command", script], text): return "powershell" clip = shutil.which("clip.exe") or shutil.which("clip") if clip: # clip.exe reads the console codepage; UTF-16LE with BOM is safest try: p = subprocess.run([clip], input=text.encode("utf-16"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) if p.returncode == 0: return "clip" except (OSError, subprocess.TimeoutExpired): pass if sys.platform == "darwin" and shutil.which("pbcopy"): if _run(["pbcopy"], text): return "pbcopy" if shutil.which("termux-clipboard-set"): if _run(["termux-clipboard-set"], text): return "termux" if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"): if _run(["wl-copy"], text): return "wl-copy" if os.environ.get("DISPLAY"): if shutil.which("xclip") and _run(["xclip", "-selection", "clipboard"], text): return "xclip" if shutil.which("xsel") and _run(["xsel", "--clipboard", "--input"], text): return "xsel" # wl-copy without WAYLAND_DISPLAY set (e.g. odd env) — still try if shutil.which("wl-copy") and _run(["wl-copy"], text): return "wl-copy" # KDE klipper over D-Bus (works even without wl-clipboard/xclip installed) for qd in ("qdbus6", "qdbus", "qdbus-qt6", "qdbus-qt5"): q = shutil.which(qd) if q and len(text.encode("utf-8")) < 1_000_000: try: p = subprocess.run([q, "org.kde.klipper", "/klipper", "setClipboardContents", text], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) if p.returncode == 0: return "klipper" except (OSError, subprocess.TimeoutExpired): pass # Last resort: OSC 52 (works in many terminals, including over SSH) try: import base64 b64 = base64.b64encode(text.encode("utf-8")).decode("ascii") if len(b64) < 1_000_000: with open("/dev/tty", "w") as tty: tty.write("\033]52;c;%s\a" % b64) tty.flush() return "osc52" except OSError: pass return None # --------------------------------------------------------------------------- # # sharing # --------------------------------------------------------------------------- # def config_path(): if os.name == "nt": base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming")) else: base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) return base / "cc" / "config" def load_config(): cfg = {} p = config_path() try: with open(p, "r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) cfg[k.strip()] = v.strip() except OSError: pass if os.environ.get("CC_SHARE_URL"): cfg["share_url"] = os.environ["CC_SHARE_URL"] if os.environ.get("CC_SHARE_TOKEN"): cfg["share_token"] = os.environ["CC_SHARE_TOKEN"] return cfg def save_config(cfg): p = config_path() p.parent.mkdir(parents=True, exist_ok=True) with open(p, "w", encoding="utf-8") as fh: for k, v in cfg.items(): fh.write("%s=%s\n" % (k, v)) try: os.chmod(p, 0o600) except OSError: pass def build_share_payload(path, turns, minimal=False, live=False, chat_code=None): st = session_stats(path) out_turns = [] for t in turns: item = {"prompt": t["prompt"], "final": t["response"], "ts": t["ts"]} for k in ("role", "from", "pending", "chat_id"): if t.get(k): item[k] = t[k] if not minimal: item["events"] = t["events"] out_turns.append(item) first = next((t["prompt"] for t in turns if t["prompt"] is not None), None) return { "v": 1, "session": path.stem, "cwd": peek_cwd(path), "title": preview(first or "Claude Code conversation", 80), "stats": st, "minimal": bool(minimal), "live": bool(live), "chat_code": chat_code or None, "path": str(path) if live else None, "tool": "cc %s" % __version__, "turns": out_turns, } def share(path, turns, cfg, minimal=False, live=None, chat_code=None): """Upload the conversation. Returns the URL. Raises RuntimeError on failure.""" import urllib.request import urllib.error url = cfg.get("share_url", "").rstrip("/") token = cfg.get("share_token", "") if not url or not token: raise RuntimeError("sharing not configured — run: cc setup ") if live is None: live = str(cfg.get("live", "")).lower() in ("1", "true", "yes", "on") if chat_code is None and cfg.get("chat_code"): chat_code = cfg["chat_code"] payload = build_share_payload(path, turns, minimal, live=live, chat_code=chat_code) body = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request(url + "/api/cc/share", data=body, method="POST", headers={"Content-Type": "application/json", "Authorization": "Bearer " + token, "User-Agent": "cc/" + __version__}) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: try: msg = json.loads(e.read().decode("utf-8")).get("error", "") except Exception: msg = "" raise RuntimeError("share failed: HTTP %s %s" % (e.code, msg or e.reason)) except (urllib.error.URLError, OSError, ValueError) as e: raise RuntimeError("share failed: %s" % e) if not data.get("url"): raise RuntimeError("share failed: bad response") return data["url"] # --------------------------------------------------------------------------- # # output formatting # --------------------------------------------------------------------------- # def format_convo(turns): out = [] for t in turns: if t["prompt"] is not None: who = "User" if t.get("role") == "viewer": who = "%s (via share link)" % t.get("from", "viewer") elif t.get("role") == "channel": who = "%s (channel)" % t.get("from", "channel") out.append("## %s\n\n%s" % (who, t["prompt"].rstrip())) if t["response"]: out.append("## Claude\n\n" + t["response"].rstrip()) return "\n\n".join(out) + "\n" def preview(s, n=70): s = " ".join((s or "").split()) return s if len(s) <= n else s[: n - 1] + "…" def die(msg, code=1): sys.stderr.write("cc: %s\n" % msg) sys.exit(code) def cmd_list(opts): files = all_session_files() here = opts["dir"] or os.getcwd() limit = opts["nth"] or 20 shown = 0 for f in files: cwd = peek_cwd(f) if not opts["any"] and not dir_matches(cwd, here): continue first = None for rec in iter_records(f): first = user_text(rec) if first is not None: break import datetime mtime = datetime.datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M") print("%s %s %-28s %s" % (f.stem[:8], mtime, preview(cwd or "?", 28), preview(first or "(no prompt)"))) shown += 1 if shown >= limit: more = sum(1 for g in files[files.index(f) + 1:] if opts["any"] or dir_matches(peek_cwd(g), here)) if more: print("… %d more (cc list -n %d, or -a for all dirs)" % (more, limit + more)) break if shown == 0: print("no sessions%s" % ("" if opts["any"] else " for %s (try -a)" % here)) return 0 def main(argv): parsed = parse_argv(argv) if parsed is None: return passthrough(argv) opts, sub = parsed if opts["help"]: print(__doc__.strip()) print("\nOptions:\n -s, --session ID pick a session by id prefix\n" " -n, --nth N pick the Nth most recent matching session\n" " -a, --any ignore the current directory when choosing\n" " -d, --dir PATH match sessions for PATH instead of cwd\n" " -p, --print print to stdout instead of copying\n" " -q, --quiet no confirmation message\n" " --paths show the session file being used\n" " --version, -h, --help") return 0 if opts["version"]: # Build systems (meson, autoconf) probe `cc --version` to identify the # compiler. When we're shadowing one and nobody's watching, let the real # compiler answer first so those probes still work. nxt = find_next_cc() if not sys.stdout.isatty() else None if nxt: sys.stdout.flush() subprocess.call([nxt, "--version"]) print("cc (copy claude) %s" % __version__) return 0 if sub == "list": return cmd_list(opts) if sub == "setup": cfg = load_config() args = opts["extra"] kv = [a for a in args if "=" in a] args = [a for a in args if "=" not in a] if len(args) >= 2: cfg["share_url"], cfg["share_token"] = args[0], args[1] for a in kv: k, v = a.split("=", 1) if v == "": cfg.pop(k, None) else: cfg[k] = v if len(args) >= 2 or kv: save_config(cfg) print("cc: saved share config to %s" % config_path()) elif len(args) == 1: die("usage: cc setup ", 2) else: print("config file: %s" % config_path()) print("share_url: %s" % (cfg.get("share_url") or "(unset)")) print("share_token: %s" % ("set" if cfg.get("share_token") else "(unset)")) print("live: %s" % (cfg.get("live") or "0")) print("chat_code: %s" % ("set" if cfg.get("chat_code") else "(unset)")) print("\nset with: cc setup https://cdn.softwaresoftware.dev [live=1] [chat_code=XYZ]") return 0 path, how = pick_session(opts) if opts["paths"]: sys.stderr.write("cc: session %s (%s)\n" % (path, how)) turns = load_turns(path) if sub == "info": st = session_stats(path) first = next((t["prompt"] for t in turns if t["prompt"] is not None), None) print("session %s" % path.stem) print("cwd %s" % (peek_cwd(path) or "?")) print("file %s" % path) print("turns %d" % len(turns)) print("tokens %s context now · %s total processed · %s output" % (fmt_tokens(st["context"]), fmt_tokens(st["total"]), fmt_tokens(st["output"]))) print("prompt %s" % preview(first or "(none)", 90)) print("resume claude --resume %s" % path.stem) return 0 if sub == "share": try: link = share(path, turns, load_config(), opts["minimal"], opts["live"], opts["chat_code"]) except RuntimeError as e: die(str(e)) if opts["print"]: print(link) return 0 backend = copy_to_clipboard(link + "\n") sys.stderr.write("cc: %s%s\n" % (link, " (copied)" if backend else "")) return 0 if not turns: die("session %s has no messages yet" % path.stem[:8]) if sub == "last": resp = None for t in reversed(turns): if t["response"]: resp = t["response"] break if resp is None: die("Claude hasn't replied in session %s yet" % path.stem[:8]) text = resp.rstrip() + "\n" label = "Claude's last output" elif sub == "prompt": first = next((t["prompt"] for t in turns if t["prompt"] is not None), None) if first is None: die("no prompt found in session %s" % path.stem[:8]) text = first.rstrip() + "\n" label = "original prompt" else: text = format_convo(turns) label = "conversation (%d turn%s)" % (len(turns), "" if len(turns) == 1 else "s") if opts["print"]: sys.stdout.write(text) return 0 backend = copy_to_clipboard(text) if backend is None: sys.stderr.write("cc: could not reach a clipboard (tried the platform tools; on Linux install wl-clipboard or xclip, or run inside a terminal for OSC52) — printing instead:\n") sys.stdout.write(text) return 1 if not opts["quiet"]: st = session_stats(path) sys.stderr.write("cc: copied %s — %s chars via %s\n" % (label, "{:,}".format(len(text)), backend)) sys.stderr.write("cc: session %s · %d turn%s · %s context · %s tokens total · claude --resume %s\n" % (path.stem[:8], len(turns), "" if len(turns) == 1 else "s", fmt_tokens(st["context"]), fmt_tokens(st["total"]), path.stem)) cfg = load_config() if not opts["no_share"] and cfg.get("share_url") and cfg.get("share_token"): try: link = share(path, turns, cfg, opts["minimal"], opts["live"], opts["chat_code"]) if not opts["quiet"]: sys.stderr.write("cc: %s\n" % link) except RuntimeError as e: sys.stderr.write("cc: %s (text was still copied)\n" % e) return 0 if __name__ == "__main__": for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8", errors="replace") except (AttributeError, ValueError): pass try: rc = main(sys.argv[1:]) try: sys.stdout.flush() except BrokenPipeError: os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) sys.exit(rc) except KeyboardInterrupt: sys.exit(130) except BrokenPipeError: try: os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) except OSError: pass sys.exit(0)