summon.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  1. #!/usr/bin/env python3
  2. """summon — pull Claude Desktop Code-tab sessions from another account into the active one.
  3. See SKILL.md for full documentation.
  4. Defaults:
  5. - move (not copy)
  6. - last 14 days only
  7. - skip remote-VM sessions (cwd starts with /sessions/)
  8. - prompt for confirmation
  9. - hierarchy display: Account -> Project -> Session
  10. Auto-detects destination as the most-recently-active account.
  11. Source defaults to "all other accounts" — narrow with --from.
  12. Output rendering follows docs/TERMINAL-DESIGN.md (Terminal Panel Design System).
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import json
  17. import os
  18. import re
  19. import shutil
  20. import sys
  21. import time
  22. import uuid as uuidlib
  23. from collections import OrderedDict
  24. from dataclasses import dataclass
  25. from pathlib import Path
  26. from typing import Iterable
  27. # ============================================================
  28. # DESIGN: terminal panel rendering (per docs/TERMINAL-DESIGN.md)
  29. # ============================================================
  30. def _stdout_supports_unicode() -> bool:
  31. enc = (getattr(sys.stdout, "encoding", "") or "").lower()
  32. return "utf" in enc or "cp65001" in enc
  33. class Term:
  34. WIDTH = 80
  35. USE_ASCII = (
  36. os.environ.get("TERM_ASCII") == "1"
  37. or os.environ.get("TERM") == "dumb"
  38. or not _stdout_supports_unicode()
  39. )
  40. USE_COLOR = (
  41. sys.stdout.isatty()
  42. and os.environ.get("NO_COLOR") is None
  43. and os.environ.get("TERM") != "dumb"
  44. and os.environ.get("FORCE_COLOR") != "0"
  45. )
  46. @classmethod
  47. def g(cls, uni: str, asc: str) -> str:
  48. return asc if cls.USE_ASCII else uni
  49. @classmethod
  50. def color(cls, token: str, text: str) -> str:
  51. if not cls.USE_COLOR:
  52. return text
  53. codes = {
  54. "accent": "36", # cyan
  55. "ok": "32", # green
  56. "warn": "33", # yellow
  57. "alarm": "31", # red
  58. "tag": "35", # magenta
  59. "meta": "2", # dim
  60. "dim": "2",
  61. "default": "",
  62. }
  63. c = codes.get(token, "")
  64. if not c:
  65. return text
  66. return f"\033[{c}m{text}\033[0m"
  67. _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
  68. def vlen(s: str) -> int:
  69. """Visible length, excluding ANSI escape codes."""
  70. return len(_ANSI_RE.sub("", s))
  71. def trunc(s: str, width: int) -> str:
  72. """Truncate with ellipsis if too long."""
  73. if vlen(s) <= width:
  74. return s
  75. ell = Term.g("…", "...")
  76. return s[: width - vlen(ell)] + ell
  77. # Brand emoji for summon (not in TERMINAL-DESIGN.md registry yet — registering here)
  78. BRAND_EMOJI = "🪄"
  79. BRAND_ASCII = "[S]"
  80. def panel_open(name: str, indicator: str = "") -> str:
  81. """╭── 🪄 summon ───────────────── indicator ───●"""
  82. em = Term.g(BRAND_EMOJI, BRAND_ASCII)
  83. tl = Term.g("╭", "+")
  84. h = Term.g("─", "-")
  85. term = Term.g("●", "*")
  86. left = f"{tl}{h}{h} {em} {Term.color('accent', name)} "
  87. if indicator:
  88. right = f" {Term.color('meta', indicator)} {h}{h}{h}{term}"
  89. else:
  90. right = f" {h}{h}{h}{term}"
  91. fill_count = max(2, Term.WIDTH - vlen(left) - vlen(right))
  92. return left + (h * fill_count) + right
  93. def panel_close(hotkeys: list[tuple[str, str]] | None = None,
  94. healths: list[tuple[str, str]] | None = None) -> str:
  95. """╰── y confirm · n cancel ───── • 5 ready ───●"""
  96. bl = Term.g("╰", "+")
  97. h = Term.g("─", "-")
  98. term = Term.g("●", "*")
  99. bullet = Term.g("•", "(+)")
  100. hotkeys = hotkeys or []
  101. healths = healths or []
  102. sep = Term.g(" · ", " | ")
  103. hot_str = sep.join(f"{Term.color('accent', k)} {v}" for k, v in hotkeys)
  104. health_str = " ".join(f"{Term.color(c, bullet)} {v}" for c, v in healths)
  105. left = f"{bl}{h}{h} {hot_str}" if hot_str else f"{bl}{h}{h}"
  106. if hot_str:
  107. left += " "
  108. right = f" {health_str} {h}{h}{h}{term}" if health_str else f" {h}{h}{h}{term}"
  109. fill = max(2, Term.WIDTH - vlen(left) - vlen(right))
  110. return left + (h * fill) + right
  111. def panel_blank() -> str:
  112. return Term.g("│", "|")
  113. def section(label: str, count: int = -1, color_token: str = "accent") -> str:
  114. """├── LABEL (count)"""
  115. tee = Term.g("├", "+")
  116. h = Term.g("─", "-")
  117. parens = f" ({count})" if count >= 0 else ""
  118. return f"{tee}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
  119. def sub_section(label: str, count: int = -1, color_token: str = "default") -> str:
  120. """│ ├── LABEL (count) — second-level grouping"""
  121. pipe = Term.g("│", "|")
  122. tee = Term.g("├", "+")
  123. h = Term.g("─", "-")
  124. parens = f" ({count})" if count >= 0 else ""
  125. return f"{pipe} {tee}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
  126. def sub_section_last(label: str, count: int = -1, color_token: str = "default") -> str:
  127. """│ └── LABEL (count) — last sub-section"""
  128. pipe = Term.g("│", "|")
  129. corner = Term.g("└", "`")
  130. h = Term.g("─", "-")
  131. parens = f" ({count})" if count >= 0 else ""
  132. return f"{pipe} {corner}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
  133. def leaf(num: int, name: str, *, meta: str = "", age: str = "",
  134. last: bool = False, depth: int = 2,
  135. parent_last: bool = False,
  136. meta_color: str = "meta", age_color: str = "meta") -> str:
  137. """│ │ ├── 3. session-name meta age
  138. parent_last: when at depth 2 inside a last-sub-section, drop the inner
  139. pipe so it reads as siblings of the corner `└──` rather than continuing.
  140. """
  141. pipe = Term.g("│", "|")
  142. h = Term.g("─", "-")
  143. conn = Term.g("└", "`") if last else Term.g("├", "+")
  144. if depth == 1:
  145. prefix = f"{pipe} "
  146. elif depth == 2:
  147. inner = " " if parent_last else f"{pipe} "
  148. prefix = f"{pipe} {inner}"
  149. else:
  150. prefix = pipe + (" " * depth)
  151. num_str = f"{num:>2}." if num else " "
  152. name_field = trunc(name, 32).ljust(32)
  153. # Tight meta column — turn count only (e.g. "30t").
  154. meta_width = 8
  155. meta_visible = vlen(meta)
  156. if meta_visible <= meta_width:
  157. pad = " " * (meta_width - meta_visible)
  158. meta_field = Term.color(meta_color, meta) + pad
  159. else:
  160. meta_field = Term.color(meta_color, meta)
  161. age_field = Term.color(age_color, age).rjust(6) if age else " " * 6
  162. return f"{prefix}{conn}{h}{h} {num_str} {name_field} {meta_field} {age_field}"
  163. def summary_line(text: str) -> str:
  164. """├── 4 lanes · 3 active (dim)"""
  165. tee = Term.g("├", "+")
  166. h = Term.g("─", "-")
  167. return f"{tee}{h}{h} {Term.color('meta', text)}"
  168. # Hint registry — each entry has a `when` predicate (over a context dict) and
  169. # a `text` template (str.format-able). Predicates returning True make the hint
  170. # eligible; one is picked at random.
  171. HINTS: list[dict] = [
  172. # --- Conditional ---
  173. {
  174. "id": "density",
  175. "when": lambda c: c["count"] > 30,
  176. "text": "{count} sessions — narrow with --cwd <pat> or --title <pat>, "
  177. "or shorten the window with --1d/--3d/--7d",
  178. },
  179. {
  180. "id": "generic-titles",
  181. "when": lambda c: c["generic_count"] >= 3,
  182. "text": "{generic_count} sessions have generic titles (dev, general, untitled). "
  183. "Use `summon --peek <id>` to preview the last messages before pulling",
  184. },
  185. {
  186. "id": "default-window",
  187. "when": lambda c: c["window_days"] == 3 and c["count"] >= 5,
  188. "text": "default window is 3 days — `--all` to see everything, "
  189. "`--1d` for just today, `--7d` for a week, or `--days N` for custom",
  190. },
  191. {
  192. "id": "remote-skipped",
  193. "when": lambda c: c["remote_count"] > 0,
  194. "text": "{remote_count} remote-VM session(s) auto-skipped — they have no "
  195. "local transcript to bridge, so cross-account transfer isn't possible",
  196. },
  197. # --- Always-eligible (rotate as background tips) ---
  198. {
  199. "id": "peek",
  200. "when": lambda _: True,
  201. "text": "preview a session's last messages with `summon --peek <id>` — handy "
  202. "when titles like 'dev' don't tell you which one is which",
  203. },
  204. {
  205. "id": "copy-vs-move",
  206. "when": lambda _: True,
  207. "text": "default is copy (visible from both accounts) — pass `--move` "
  208. "to delete the source for lean cleanup",
  209. },
  210. {
  211. "id": "logout-login",
  212. "when": lambda _: True,
  213. "text": "Desktop only loads sessions at login — Cowork/Code toggle, Ctrl+R, "
  214. "and tab clicks won't rescan. Plan for Logout/Login when you switch",
  215. },
  216. {
  217. "id": "proactive",
  218. "when": lambda _: True,
  219. "text": "best run BEFORE switching accounts: copy sessions to the next "
  220. "account first, then Logout/Login (the switch you were doing anyway)",
  221. },
  222. {
  223. "id": "dry-run",
  224. "when": lambda _: True,
  225. "text": "`--dry-run` previews a move without touching files — pair it with "
  226. "`--pick` to rehearse the picker without committing",
  227. },
  228. ]
  229. def _pick_hint(context: dict) -> str:
  230. """Pick one hint from HINTS whose predicate matches the context, or '' if none."""
  231. import random
  232. eligible = [h for h in HINTS if _hint_safe(h["when"], context)]
  233. if not eligible:
  234. return ""
  235. chosen = random.choice(eligible)
  236. try:
  237. return chosen["text"].format(**context)
  238. except (KeyError, ValueError):
  239. return chosen["text"]
  240. def _hint_safe(predicate, context) -> bool:
  241. try:
  242. return bool(predicate(context))
  243. except Exception:
  244. return False
  245. def hint(text: str, width: int = 70) -> str:
  246. """│ 💡 text — tip riding the panel rail.
  247. Continuation lines wrap under the text, not under the icon, so the eye
  248. follows the message rather than re-finding column alignment.
  249. """
  250. pipe = Term.g("│", "|")
  251. bulb = Term.g("💡", "(i)")
  252. # Visual cells: pipe(1) + 3sp + bulb(2 if emoji, 3 if ASCII) + 2sp
  253. bulb_cells = 3 if Term.USE_ASCII else 2
  254. indent_after_pipe = 3 + bulb_cells + 2 # spaces between pipe and text
  255. cont_pad = " " * indent_after_pipe
  256. # Word-wrap to `width` chars per content line.
  257. words = text.split(" ")
  258. lines: list[str] = []
  259. current = ""
  260. for w in words:
  261. candidate = f"{current} {w}".strip()
  262. if len(candidate) <= width:
  263. current = candidate
  264. else:
  265. if current:
  266. lines.append(current)
  267. current = w
  268. if current:
  269. lines.append(current)
  270. if not lines:
  271. return ""
  272. out = [f"{pipe} {bulb} {Term.color('meta', lines[0])}"]
  273. for line in lines[1:]:
  274. out.append(f"{pipe}{cont_pad}{Term.color('meta', line)}")
  275. return "\n".join(out)
  276. def echo(*lines):
  277. if not lines:
  278. print()
  279. return
  280. for line in lines:
  281. print(line)
  282. # ============================================================
  283. # Path discovery
  284. # ============================================================
  285. def appdata_claude() -> Path:
  286. plat = str(sys.platform)
  287. if plat == "win32":
  288. appdata = os.environ.get("APPDATA")
  289. if not appdata:
  290. sys.exit("APPDATA env var not set; can't locate Claude Desktop dir")
  291. return Path(appdata) / "Claude"
  292. if plat == "darwin":
  293. return Path.home() / "Library/Application Support/Claude"
  294. return Path.home() / ".config/Claude"
  295. def cli_jsonl_root() -> Path:
  296. return Path.home() / ".claude" / "projects"
  297. def encode_cwd(cwd: str) -> str:
  298. """Convert cwd to ~/.claude/projects/ subdir name.
  299. Each ':', '\\', '/', '.' becomes '-'; consecutive separators stay consecutive.
  300. 'X:\\Forge\\Axiom\\.claude\\worktrees\\foo' -> 'X--Forge-Axiom--claude-worktrees-foo'
  301. """
  302. return (cwd
  303. .replace(":", "-")
  304. .replace("\\", "-")
  305. .replace("/", "-")
  306. .replace(".", "-"))
  307. # ============================================================
  308. # Account discovery
  309. # ============================================================
  310. @dataclass
  311. class Account:
  312. uuid: str
  313. sessions_dir: Path
  314. email: str = ""
  315. last_activity: float = 0.0
  316. session_count: int = 0
  317. @property
  318. def short(self) -> str:
  319. return self.uuid[:8]
  320. @property
  321. def label(self) -> str:
  322. sep = Term.g("·", "|")
  323. return f"{self.email or '(unknown)'} {sep} {self.short}"
  324. def _iter_session_files(account_dir: Path) -> Iterable[Path]:
  325. for ws in account_dir.iterdir():
  326. if not ws.is_dir():
  327. continue
  328. yield from ws.glob("local_*.json")
  329. def _find_account_email(agent_root: Path, account_uuid: str) -> str:
  330. acct_dir = agent_root / account_uuid
  331. if not acct_dir.is_dir():
  332. return ""
  333. for ws in acct_dir.iterdir():
  334. if not ws.is_dir():
  335. continue
  336. for f in ws.glob("local_*.json"):
  337. try:
  338. d = json.loads(f.read_text(encoding="utf-8"))
  339. email = d.get("emailAddress", "")
  340. if email:
  341. return email
  342. except (json.JSONDecodeError, OSError):
  343. continue
  344. return ""
  345. def discover_accounts(claude_dir: Path) -> list[Account]:
  346. sessions_root = claude_dir / "claude-code-sessions"
  347. if not sessions_root.is_dir():
  348. return []
  349. agent_root = claude_dir / "local-agent-mode-sessions"
  350. accounts: list[Account] = []
  351. for acct_dir in sessions_root.iterdir():
  352. if not acct_dir.is_dir():
  353. continue
  354. sessions = list(_iter_session_files(acct_dir))
  355. if not sessions:
  356. continue
  357. last = max((s.stat().st_mtime for s in sessions), default=0.0)
  358. accounts.append(Account(
  359. uuid=acct_dir.name,
  360. sessions_dir=acct_dir,
  361. email=_find_account_email(agent_root, acct_dir.name),
  362. last_activity=last,
  363. session_count=len(sessions),
  364. ))
  365. return sorted(accounts, key=lambda a: -a.last_activity)
  366. def detect_destination(accounts: list[Account]) -> Account | None:
  367. return accounts[0] if accounts else None
  368. def resolve_account(query: str, accounts: list[Account]) -> Account | None:
  369. q = query.lower()
  370. for a in accounts:
  371. if a.uuid == query:
  372. return a
  373. for a in accounts:
  374. if a.uuid.startswith(query):
  375. return a
  376. for a in accounts:
  377. if q in a.email.lower():
  378. return a
  379. return None
  380. # ============================================================
  381. # Sessions
  382. # ============================================================
  383. @dataclass
  384. class Session:
  385. path: Path
  386. data: dict
  387. account: Account
  388. @property
  389. def sid(self) -> str:
  390. return self.data.get("sessionId", "")
  391. @property
  392. def cli_id(self) -> str:
  393. return self.data.get("cliSessionId", "")
  394. @property
  395. def cwd(self) -> str:
  396. return self.data.get("cwd", "")
  397. @property
  398. def title(self) -> str:
  399. return self.data.get("title", "(untitled)")
  400. @property
  401. def turns(self) -> int:
  402. return int(self.data.get("completedTurns", 0))
  403. @property
  404. def last_activity_ms(self) -> int:
  405. return int(self.data.get("lastActivityAt", 0))
  406. @property
  407. def is_remote(self) -> bool:
  408. return self.cwd.startswith("/sessions/")
  409. def transcript_path(self) -> Path | None:
  410. if not self.cli_id or not self.cwd:
  411. return None
  412. return cli_jsonl_root() / encode_cwd(self.cwd) / f"{self.cli_id}.jsonl"
  413. def load_sessions(account: Account) -> list[Session]:
  414. out: list[Session] = []
  415. for f in _iter_session_files(account.sessions_dir):
  416. try:
  417. data = json.loads(f.read_text(encoding="utf-8"))
  418. except (json.JSONDecodeError, OSError):
  419. continue
  420. out.append(Session(path=f, data=data, account=account))
  421. return out
  422. def filter_sessions(
  423. sessions: list[Session],
  424. *,
  425. days: int | None,
  426. cwd_pattern: str = "",
  427. title_pattern: str = "",
  428. ) -> list[Session]:
  429. now_ms = int(time.time() * 1000)
  430. cutoff_ms = now_ms - (days * 86_400_000) if days is not None else 0
  431. out = []
  432. for s in sessions:
  433. if s.is_remote:
  434. continue
  435. if days is not None and s.last_activity_ms < cutoff_ms:
  436. continue
  437. if cwd_pattern and cwd_pattern.lower() not in s.cwd.lower():
  438. continue
  439. if title_pattern and title_pattern.lower() not in s.title.lower():
  440. continue
  441. out.append(s)
  442. return sorted(out, key=lambda s: -s.last_activity_ms)
  443. # ============================================================
  444. # Grouping
  445. # ============================================================
  446. _WORKTREE_MARKERS = (
  447. "\\.claude\\worktrees\\",
  448. "/.claude/worktrees/",
  449. )
  450. def project_root(cwd: str) -> str:
  451. for marker in _WORKTREE_MARKERS:
  452. if marker in cwd:
  453. return cwd.split(marker)[0]
  454. return cwd
  455. def relative_under_root(cwd: str, root: str) -> str:
  456. if cwd == root:
  457. return ""
  458. if cwd.startswith(root):
  459. return cwd[len(root):].lstrip("\\/")
  460. return cwd
  461. def worktree_name(cwd: str) -> str:
  462. """If cwd is inside a `.claude/worktrees/<name>/...` path, return <name>; else ''."""
  463. for marker in _WORKTREE_MARKERS:
  464. if marker in cwd:
  465. tail = cwd.split(marker, 1)[1]
  466. # First path segment is the worktree name; strip any deeper subpath.
  467. return tail.split("\\", 1)[0].split("/", 1)[0]
  468. return ""
  469. # ============================================================
  470. # Listing
  471. # ============================================================
  472. def render_hierarchy(sessions: list[Session], *, grouped: bool) -> dict[int, Session]:
  473. """Print sessions; return {1-based-index: session}."""
  474. if grouped:
  475. return _render_grouped(sessions)
  476. index_map: dict[int, Session] = {}
  477. for n, s in enumerate(sessions, 1):
  478. index_map[n] = s
  479. ago = _ago(s.last_activity_ms)
  480. meta = f"{s.turns} turns"
  481. display = f"{s.title} ({s.cwd})"
  482. echo(leaf(n, display, meta=meta, age=ago, depth=1))
  483. return index_map
  484. def _render_grouped(sessions: list[Session]) -> dict[int, Session]:
  485. """3-level hierarchy: Account -> Project -> Session."""
  486. index_map: dict[int, Session] = {}
  487. by_account: "OrderedDict[str, list[Session]]" = OrderedDict()
  488. for s in sessions:
  489. by_account.setdefault(s.account.uuid, []).append(s)
  490. n = 0
  491. for _, acct_sessions in by_account.items():
  492. acct = acct_sessions[0].account
  493. # Group within account by project root
  494. by_project: "OrderedDict[str, list[Session]]" = OrderedDict()
  495. for s in acct_sessions:
  496. by_project.setdefault(project_root(s.cwd), []).append(s)
  497. # Account header
  498. echo(panel_blank())
  499. echo(section(acct.email or "(unknown)", len(acct_sessions), color_token="accent"))
  500. proj_items = list(by_project.items())
  501. for pi, (root, members) in enumerate(proj_items):
  502. is_last_proj = pi == len(proj_items) - 1
  503. sub_func = sub_section_last if is_last_proj else sub_section
  504. echo(sub_func(root, len(members), color_token="default"))
  505. for li, s in enumerate(members):
  506. n += 1
  507. index_map[n] = s
  508. is_last_session = li == len(members) - 1
  509. ago = _ago(s.last_activity_ms)
  510. meta = f"{s.turns}t"
  511. echo(leaf(n, s.title, meta=meta, age=ago,
  512. last=is_last_session, depth=2,
  513. parent_last=is_last_proj))
  514. echo(panel_blank())
  515. return index_map
  516. def _window_label(days: int | None) -> str:
  517. """Render the active time-window filter label."""
  518. if days is None:
  519. return "all time"
  520. if days <= 1:
  521. return "last 24h"
  522. return f"last {days}d"
  523. def _ago(ms: int) -> str:
  524. if ms == 0:
  525. return "?"
  526. delta_s = max(0, int(time.time()) - (ms // 1000))
  527. if delta_s < 60:
  528. return f"{delta_s}s"
  529. if delta_s < 3600:
  530. return f"{delta_s // 60}m"
  531. if delta_s < 86400:
  532. return f"{delta_s // 3600}h"
  533. return f"{delta_s // 86400}d"
  534. # ============================================================
  535. # Picker
  536. # ============================================================
  537. def interactive_pick(sessions: list[Session], *, grouped: bool) -> list[Session]:
  538. if not sessions:
  539. return []
  540. index_map = _render_grouped(sessions) if grouped else render_hierarchy(sessions, grouped=False)
  541. print()
  542. raw = input(Term.color("accent", "select> ")
  543. + "(numbers like '3,5,7', 'a' for all, blank to cancel): ").strip()
  544. if not raw:
  545. return []
  546. if raw.lower() == "a":
  547. return sessions
  548. picks = []
  549. for tok in raw.split(","):
  550. tok = tok.strip()
  551. if not tok:
  552. continue
  553. try:
  554. i = int(tok)
  555. if i in index_map:
  556. picks.append(index_map[i])
  557. except ValueError:
  558. continue
  559. return picks
  560. # ============================================================
  561. # Workspace selection
  562. # ============================================================
  563. def pick_destination_workspace(account: Account) -> Path:
  564. workspaces = [w for w in account.sessions_dir.iterdir() if w.is_dir()]
  565. if not workspaces:
  566. new_ws = account.sessions_dir / str(uuidlib.uuid4())
  567. new_ws.mkdir(parents=True)
  568. return new_ws
  569. workspaces.sort(key=lambda w: -w.stat().st_mtime)
  570. return workspaces[0]
  571. # ============================================================
  572. # Operate
  573. # ============================================================
  574. def summon_session(s: Session, dest_workspace: Path, *, move: bool, dry_run: bool) -> str:
  575. target = dest_workspace / s.path.name
  576. if target.exists():
  577. return "skip (already there)"
  578. if not s.cli_id:
  579. return "skip (no cliSessionId)"
  580. transcript = s.transcript_path()
  581. if transcript and not transcript.exists():
  582. return "skip (transcript missing)"
  583. if dry_run:
  584. return "would " + ("move" if move else "copy")
  585. op = shutil.move if move else shutil.copy2
  586. op(str(s.path), str(target))
  587. return "moved" if move else "copied"
  588. def nudge_watcher(workspace_dir: Path, moved_files: list[Path] | None = None) -> None:
  589. """Force fs.watch to fire on the destination workspace dir.
  590. Desktop's fs.watch is finicky — sometimes it picks up move-in events
  591. immediately, sometimes it doesn't. We throw the kitchen sink at it:
  592. 1. mtime update on each moved file (write event)
  593. 2. Rename ping-pong on each moved file (move-out + move-in events)
  594. 3. Sentinel create+delete in workspace dir (dir-mod event)
  595. 4. Sentinel create+delete in account dir (parent dir-mod event)
  596. 5. mtime update on workspace dir (dir-mod event)
  597. 6. mtime update on account dir (parent dir-mod event)
  598. All paths are tried; failures are silent.
  599. Empirically: even with all of these, Desktop's renderer may still
  600. require a Logout -> Login cycle to refresh the sidebar. That's
  601. documented in SKILL.md as the canonical fallback.
  602. """
  603. now = time.time()
  604. account_dir = workspace_dir.parent
  605. # 1. mtime update on moved files
  606. for f in (moved_files or []):
  607. try:
  608. os.utime(f, (now, now))
  609. except OSError:
  610. pass
  611. # 2. Rename ping-pong on moved files
  612. for f in (moved_files or []):
  613. if not f.exists():
  614. continue
  615. tmp = f.with_name(f.name + ".summon-tmp")
  616. try:
  617. f.rename(tmp)
  618. tmp.rename(f)
  619. except OSError:
  620. try:
  621. if tmp.exists():
  622. tmp.rename(f)
  623. except OSError:
  624. pass
  625. # 3 + 4. Sentinel pings at workspace AND account level
  626. for parent in (workspace_dir, account_dir):
  627. sentinel = parent / f".summon-nudge-{uuidlib.uuid4().hex[:8]}"
  628. try:
  629. sentinel.touch()
  630. sentinel.unlink()
  631. except OSError:
  632. pass
  633. # 5 + 6. mtime touch on workspace and account dirs
  634. for d in (workspace_dir, account_dir):
  635. try:
  636. os.utime(d, (now, now))
  637. except OSError:
  638. pass
  639. # ============================================================
  640. # Peek
  641. # ============================================================
  642. def find_session_by_id(query: str, accounts: list[Account]) -> Session | None:
  643. q = query.lower().removeprefix("local_")
  644. for acct in accounts:
  645. for s in load_sessions(acct):
  646. sid = s.sid.lower().removeprefix("local_")
  647. cli = s.cli_id.lower()
  648. if sid == q or cli == query.lower():
  649. return s
  650. if sid.startswith(q) or cli.startswith(q):
  651. return s
  652. return None
  653. def peek_session(query: str, accounts: list[Account], turns: int = 3) -> int:
  654. s = find_session_by_id(query, accounts)
  655. if not s:
  656. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator="not found"))
  657. echo(panel_blank())
  658. echo(f" no session matching: {Term.color('alarm', query)}")
  659. echo(panel_blank())
  660. echo(panel_close())
  661. return 1
  662. transcript = s.transcript_path()
  663. if not transcript or not transcript.exists():
  664. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator=f"{s.account.short} {Term.g('·', '|')} {s.title}"))
  665. echo(panel_blank())
  666. echo(f" transcript missing: {Term.color('alarm', str(transcript))}")
  667. echo(panel_blank())
  668. echo(panel_close())
  669. return 2
  670. exchanges: list[tuple[str, str]] = []
  671. try:
  672. with transcript.open("r", encoding="utf-8") as f:
  673. for line in f:
  674. try:
  675. rec = json.loads(line)
  676. except json.JSONDecodeError:
  677. continue
  678. t = rec.get("type")
  679. if t not in ("user", "assistant"):
  680. continue
  681. msg = rec.get("message", {})
  682. text = _extract_text(msg.get("content"))
  683. if text:
  684. exchanges.append((t, text))
  685. except OSError as e:
  686. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator="read error"))
  687. echo(panel_blank())
  688. echo(f" {Term.color('alarm', str(e))}")
  689. echo(panel_blank())
  690. echo(panel_close())
  691. return 2
  692. indicator = f"{s.account.email or s.account.short}"
  693. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator=indicator))
  694. echo(panel_blank())
  695. sep = Term.g("·", "|")
  696. echo(summary_line(f"{s.title!r} {s.cwd}"))
  697. echo(summary_line(f"{s.turns} turns {sep} last activity {_ago(s.last_activity_ms)}"))
  698. echo(panel_blank())
  699. if not exchanges:
  700. echo(f" {Term.color('meta', '(transcript has no readable user/assistant messages)')}")
  701. echo(panel_blank())
  702. echo(panel_close())
  703. return 0
  704. tail = exchanges[-(turns * 2):]
  705. echo(section(f"last {len(tail)} message(s)", color_token="accent"))
  706. for role, text in tail:
  707. marker = Term.color("accent", ">>") if role == "user" else Term.color("ok", "<<")
  708. snippet = text.strip().replace("\n", " ")
  709. if len(snippet) > 600:
  710. snippet = snippet[:597] + "..."
  711. echo(panel_blank())
  712. # Wrap to 70 chars per line
  713. words = snippet.split(" ")
  714. line_width = 70
  715. line = ""
  716. first = True
  717. for w in words:
  718. candidate = (line + " " + w) if line else w
  719. if len(candidate) <= line_width:
  720. line = candidate
  721. else:
  722. pipe = Term.g("│", "|")
  723. lead = f"{pipe} {marker} " if first else f"{pipe} "
  724. echo(f"{lead}{line}")
  725. line = w
  726. first = False
  727. if line:
  728. pipe = Term.g("│", "|")
  729. lead = f"{pipe} {marker} " if first else f"{pipe} "
  730. echo(f"{lead}{line}")
  731. echo(panel_blank())
  732. echo(panel_close(hotkeys=[("q", "quit")]))
  733. return 0
  734. def _extract_text(content) -> str:
  735. if isinstance(content, str):
  736. return content
  737. if isinstance(content, list):
  738. chunks = []
  739. for block in content:
  740. if isinstance(block, dict):
  741. t = block.get("type")
  742. if t == "text":
  743. chunks.append(block.get("text", ""))
  744. elif t == "tool_use":
  745. chunks.append(f"[tool_use: {block.get('name', '?')}]")
  746. elif t == "tool_result":
  747. chunks.append("[tool_result]")
  748. return " ".join(chunks)
  749. return ""
  750. # ============================================================
  751. # Main
  752. # ============================================================
  753. def main():
  754. p = argparse.ArgumentParser(
  755. description="Summon Claude Desktop sessions from another account.",
  756. )
  757. p.add_argument("--to", help="Destination account (UUID prefix or email substring)")
  758. p.add_argument("--from", dest="from_",
  759. help="Restrict source to one account (default: all non-destination accounts)")
  760. # Time-window filter: --days N (custom) or one of the convenience aliases.
  761. # Defaults to 14 days; --all disables.
  762. p.add_argument("--days", type=int, default=3, help="Time window in days (default 3)")
  763. p.add_argument("--all", action="store_true", help="Disable time filter (any age)")
  764. p.add_argument("--1d", dest="window_1d", action="store_true", help="Last 24h (alias)")
  765. p.add_argument("--3d", dest="window_3d", action="store_true", help="Last 3 days (alias)")
  766. p.add_argument("--7d", dest="window_7d", action="store_true", help="Last 7 days (alias)")
  767. p.add_argument("--30d", dest="window_30d", action="store_true", help="Last 30 days (alias)")
  768. p.add_argument("--cwd", default="", help="Substring match against cwd")
  769. p.add_argument("--title", default="", help="Substring match against title")
  770. p.add_argument("--pick", action="store_true", help=argparse.SUPPRESS) # legacy flag — default behavior now
  771. p.add_argument("--move", action="store_true",
  772. help="Move semantics — delete source after copying (lean cleanup)")
  773. p.add_argument("--dry-run", action="store_true", help="Preview without touching files")
  774. p.add_argument("--list-accounts", action="store_true", help="List all accounts and exit")
  775. p.add_argument("--peek", metavar="ID", help="Preview a session's last messages and exit (id prefix or full)")
  776. p.add_argument("--flat", action="store_true", help="Flat list instead of grouped hierarchy")
  777. p.add_argument("--yes", action="store_true",
  778. help="Non-interactive: select ALL candidates and proceed without prompting")
  779. args = p.parse_args()
  780. claude_dir = appdata_claude()
  781. if not claude_dir.is_dir():
  782. sys.exit(f"Claude dir not found: {claude_dir}")
  783. accounts = discover_accounts(claude_dir)
  784. if not accounts:
  785. sys.exit(f"No accounts with sessions under {claude_dir}/claude-code-sessions/")
  786. # --- Modes that exit early ---
  787. if args.list_accounts:
  788. echo(panel_open(f"summon {Term.g('·', '|')} accounts"))
  789. echo(panel_blank())
  790. echo(section("accounts", len(accounts), color_token="accent"))
  791. for i, a in enumerate(accounts):
  792. is_last = (i == len(accounts) - 1)
  793. ago = _ago(int(a.last_activity * 1000))
  794. echo(leaf(0, a.email or "(unknown)",
  795. meta=f"{a.short} {Term.g('·', '|')} {a.session_count}",
  796. age=ago, last=is_last, depth=1))
  797. echo(panel_blank())
  798. echo(panel_close(healths=[("ok", f"{len(accounts)} active")]))
  799. return
  800. if args.peek:
  801. sys.exit(peek_session(args.peek, accounts))
  802. # --- Pull mode ---
  803. # Resolve destination
  804. if not args.to:
  805. dest = detect_destination(accounts)
  806. else:
  807. dest = resolve_account(args.to, accounts)
  808. if not dest:
  809. sys.exit(f"Cannot resolve destination account: {args.to}")
  810. # Resolve source(s)
  811. if args.from_:
  812. src = resolve_account(args.from_, accounts)
  813. if not src:
  814. sys.exit(f"Cannot resolve source account: {args.from_}")
  815. if src.uuid == dest.uuid:
  816. sys.exit("Source and destination are the same; remove --from or pick another --to")
  817. source_accounts = [src]
  818. else:
  819. source_accounts = [a for a in accounts if a.uuid != dest.uuid]
  820. if not source_accounts:
  821. sys.exit("No source accounts available (only one account exists)")
  822. # Load + filter sessions
  823. all_sessions: list[Session] = []
  824. for src in source_accounts:
  825. all_sessions.extend(load_sessions(src))
  826. # Resolve recency window: --all > convenience alias > --days
  827. if args.all:
  828. days = None
  829. elif args.window_1d:
  830. days = 1
  831. elif args.window_3d:
  832. days = 3
  833. elif args.window_7d:
  834. days = 7
  835. elif args.window_30d:
  836. days = 30
  837. else:
  838. days = args.days
  839. candidates = filter_sessions(all_sessions, days=days,
  840. cwd_pattern=args.cwd, title_pattern=args.title)
  841. # Header: short destination tag (just the email's local part or UUID short).
  842. # Source detail goes into the summary line — header stays clean.
  843. arrow = Term.g("→", "->")
  844. dest_short = (dest.email.split("@")[0] if dest.email else dest.short)
  845. indicator = f"{arrow} {dest_short}"
  846. echo(panel_open("summon", indicator=indicator))
  847. if not candidates:
  848. echo(panel_blank())
  849. echo(summary_line(f"no matching sessions ({_window_label(days)})"))
  850. echo(panel_blank())
  851. echo(panel_close())
  852. return
  853. # Summary line at the TOP per TERMINAL-DESIGN.md.
  854. sep = Term.g("·", "|")
  855. if len(source_accounts) == 1:
  856. src_email = source_accounts[0].email or source_accounts[0].short
  857. src_label = f"from {src_email}"
  858. else:
  859. src_label = f"from {len(source_accounts)} accounts"
  860. summary_text = f"{len(candidates)} sessions {sep} {src_label} {sep} {_window_label(days)}"
  861. echo(panel_blank())
  862. echo(summary_line(summary_text))
  863. # Render hierarchy + capture index_map
  864. index_map = _render_grouped(candidates) if not args.flat else render_hierarchy(candidates, grouped=False)
  865. # Pick a hint — conditional ones win when relevant, otherwise rotate background tips
  866. generic_titles = {"dev", "general", "untitled", "(untitled)", ""}
  867. generic_count = sum(1 for s in candidates if s.title.lower() in generic_titles)
  868. remote_count = sum(1 for sess in all_sessions if sess.is_remote)
  869. hint_ctx = {
  870. "count": len(candidates),
  871. "generic_count": generic_count,
  872. "remote_count": remote_count,
  873. "window_days": days if days is not None else 0,
  874. "source_count": len(source_accounts),
  875. }
  876. hint_text = _pick_hint(hint_ctx)
  877. if hint_text:
  878. echo(hint(hint_text))
  879. echo(panel_blank())
  880. echo(panel_close(hotkeys=[("#", "select"), ("a", "all"), ("blank", "cancel")]))
  881. # Selection (default) — prompt for picks unless --yes (auto-all).
  882. if args.yes:
  883. chosen = list(candidates)
  884. else:
  885. print()
  886. prompt = Term.color("accent", "select> ") + \
  887. "(numbers like '3,5,7', 'a' for all, blank to cancel): "
  888. raw = input(prompt).strip()
  889. if not raw:
  890. print(Term.color("meta", "cancelled."))
  891. return
  892. chosen: list[Session] = []
  893. if raw.lower() == "a":
  894. chosen = list(candidates)
  895. else:
  896. for tok in raw.split(","):
  897. tok = tok.strip()
  898. if not tok:
  899. continue
  900. try:
  901. i = int(tok)
  902. if i in index_map:
  903. chosen.append(index_map[i])
  904. except ValueError:
  905. continue
  906. if not chosen:
  907. print(Term.color("meta", "nothing selected — cancelled."))
  908. return
  909. candidates = chosen
  910. dest_ws = pick_destination_workspace(dest)
  911. # Operate + render results in a fresh stacked panel (DESIGN: 2 blank lines)
  912. print()
  913. print()
  914. echo(panel_open(f"summon {Term.g('·', '|')} results", indicator=dest_ws.name[:8]))
  915. echo(panel_blank())
  916. success_states = {"copied", "moved", "would copy", "would move"}
  917. skip_re = re.compile(r"^skip")
  918. moved = 0
  919. skipped = 0
  920. moved_files: list[Path] = []
  921. for i, s in enumerate(candidates):
  922. is_last = i == len(candidates) - 1
  923. status = summon_session(s, dest_ws, move=args.move, dry_run=args.dry_run)
  924. if status in success_states:
  925. moved += 1
  926. color = "ok"
  927. target = dest_ws / s.path.name
  928. if not args.dry_run and target.exists():
  929. moved_files.append(target)
  930. elif skip_re.match(status):
  931. skipped += 1
  932. color = "warn"
  933. else:
  934. color = "alarm"
  935. echo(leaf(0, s.title, meta=Term.color(color, status),
  936. age=s.account.short, last=is_last, depth=1))
  937. echo(panel_blank())
  938. # Nudge fs.watch — sentinel + rename ping-pong on each moved file
  939. if moved and not args.dry_run:
  940. nudge_watcher(dest_ws, moved_files=moved_files)
  941. healths = []
  942. if moved:
  943. if args.dry_run:
  944. verb = "would " + ("move" if args.move else "copy")
  945. else:
  946. verb = "moved" if args.move else "copied"
  947. healths.append(("ok", f"{moved} {verb}"))
  948. if skipped:
  949. healths.append(("warn", f"{skipped} skipped"))
  950. echo(panel_close(healths=healths))
  951. if moved and not args.dry_run:
  952. echo()
  953. echo(Term.color("warn",
  954. "next: switch accounts in Desktop. Logout from current, login to destination."))
  955. echo(Term.color("meta",
  956. " the new sessions appear when destination's sidebar populates on login."))
  957. echo(Term.color("meta",
  958. " (Desktop caches session list at login; tab toggles and Ctrl+R won't rescan.)"))
  959. if __name__ == "__main__":
  960. main()