agent-sessions-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_sessions/cli.py ADDED
@@ -0,0 +1,858 @@
1
+ """Command-line interface: locate | write-summary | export | commit | list | init | doctor.
2
+
3
+ Every subcommand prints "STATUS: ok|error" as its first and last line. Exit code is
4
+ 0 on ok and 1 on error; scripts/sessions.sh swallows the code so skill preflights
5
+ never abort.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import glob
13
+ import json
14
+ import os
15
+ import platform
16
+ import shutil
17
+ import sys
18
+ from collections import OrderedDict
19
+ from typing import List, Optional, Tuple
20
+
21
+ from . import __version__
22
+ from .adapters import AGENT_CHOICES, REGISTRY, adapter_for, installed_adapters, resolve
23
+ from .adapters.base import SessionRef
24
+ from .core import (
25
+ OUTLINE_MAX_PROMPTS, PACKAGE_DIR, SESSIONS_REL, SUMMARY_MAX_BYTES, SUMMARY_MAX_LINES, TOOL_VERSION,
26
+ TRANSCRIPT_MAX_BYTES, UNSUPPORTED, PUSH_TIMEOUT, Redactor, Report, SessionsError, current_branch,
27
+ default_remote_branch, dump_frontmatter, existing_session_dir, git_dir, git_out, handle_from_name, head_exists,
28
+ home_to_tilde, in_progress_operation, load_template, parse_frontmatter, path_is_ignored, render_session,
29
+ repo_root, run_git, script_path, session_dir_name, slugify, upstream_of, version_tuple, write_text,
30
+ )
31
+ from .model import Session
32
+
33
+
34
+ # --------------------------------------------------------------------------- helpers
35
+
36
+
37
+ def clean_arg(value: Optional[str]) -> Optional[str]:
38
+ if value and value.strip() and not value.strip().startswith("${"):
39
+ return value.strip()
40
+ return None
41
+
42
+
43
+ def resolve_project_dir(explicit: Optional[str]) -> str:
44
+ for cand in (explicit, os.environ.get("CLAUDE_PROJECT_DIR")):
45
+ if cand and cand.strip() and not cand.startswith("${"):
46
+ return os.path.abspath(cand.strip())
47
+ return os.getcwd()
48
+
49
+
50
+ def detected_unsupported() -> List[Tuple[str, str, str]]:
51
+ out = []
52
+ for key, (label, paths) in UNSUPPORTED.items():
53
+ for p in paths:
54
+ full = os.path.expanduser(p)
55
+ if glob.glob(full):
56
+ out.append((key, label, p))
57
+ break
58
+ return out
59
+
60
+
61
+ def portable_skill_dir() -> Optional[str]:
62
+ """The portable skill folder, from a checkout or from inside a bundle."""
63
+ for cand in (PACKAGE_DIR.parent / "agents-skills" / "agent-sessions", PACKAGE_DIR.parent.parent):
64
+ if (cand / "SKILL.md").exists() and (cand / "scripts").is_dir():
65
+ return str(cand)
66
+ return None
67
+
68
+
69
+ def fmt_when(ts: Optional[dt.datetime]) -> str:
70
+ if not ts:
71
+ return "?"
72
+ delta = dt.datetime.now(dt.timezone.utc) - ts
73
+ mins = int(delta.total_seconds() // 60)
74
+ if mins < 60:
75
+ return f"{mins} min ago"
76
+ if mins < 60 * 48:
77
+ return f"{mins // 60} h ago"
78
+ return ts.strftime("%Y-%m-%d")
79
+
80
+
81
+ # --------------------------------------------------------------------------- locate
82
+
83
+
84
+ def cmd_locate(args: argparse.Namespace) -> Tuple[str, bool]:
85
+ rep = Report()
86
+ rep.kv("SCRIPT", script_path())
87
+ project_dir = resolve_project_dir(args.project_dir)
88
+ root = repo_root(project_dir)
89
+ agent = args.agent or "auto"
90
+ session_id = clean_arg(args.session_id)
91
+ try:
92
+ res = resolve(agent, session_id, root)
93
+ except KeyError:
94
+ return rep.finish(False, f"unknown agent {agent}; choose from {', '.join(AGENT_CHOICES)}"), False
95
+
96
+ if res.ref is None:
97
+ rep.kv("REPO_ROOT", root)
98
+ rep.kv("AGENT", agent)
99
+ for key, label, p in detected_unsupported():
100
+ rep.add(f" note: {label} detected ({p}); transcript export for it is not supported yet")
101
+ if res.candidates:
102
+ rep.add("CANDIDATES:")
103
+ for c in res.candidates:
104
+ rep.add(f" - {c.agent} | {c.session_id} | {c.title or '(untitled)'} | {fmt_when(c.updated)} | {home_to_tilde(c.cwd or '?')}")
105
+ rep.add("Ask the user which one, then re-run: locate --agent <agent> --session-id <id>")
106
+ return rep.finish(False, "several recent sessions match this repo; pick one"), False
107
+ return rep.finish(False, "no session found for this repo in any supported agent store"
108
+ " (Claude Code, Codex, OpenCode, Gemini CLI). Pass --agent and --session-id, or start from a session that has at least one turn."), False
109
+
110
+ adapter, ref = res.adapter, res.ref
111
+ assert adapter is not None
112
+ sess: Optional[Session] = ref.extra.get("session")
113
+ if sess is None and ref.on_disk:
114
+ sess = adapter.load(ref)
115
+ title = args.title or (sess.title if sess else None) or ref.title or "untitled session"
116
+ name = git_out(["config", "user.name"], root) or os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
117
+ email = git_out(["config", "user.email"], root)
118
+ handle = handle_from_name(name)
119
+ branch = current_branch(root)
120
+ existing = existing_session_dir(root, ref.session_id, ref.short_id)
121
+ dir_name = os.path.basename(existing) if existing else session_dir_name(sess.started if sess else None, title, handle, ref.short_id)
122
+
123
+ rep.kv("REPO_ROOT", root)
124
+ rep.kv("AGENT", adapter.name)
125
+ rep.kv("AGENT_LABEL", adapter.label)
126
+ rep.kv("SOURCE", ref.source or "(not written to disk yet; export will find it by session id)")
127
+ rep.kv("TRANSCRIPT", ref.source or "(not written to disk yet)")
128
+ rep.kv("FOUND_BY", res.how)
129
+ rep.kv("SESSION_ID", ref.session_id)
130
+ rep.kv("SHORT_ID", ref.short_id)
131
+ rep.kv("TITLE_DEFAULT", title)
132
+ rep.kv("AUTHOR", f"{name} <{email}>" if email else name)
133
+ rep.kv("HANDLE", handle)
134
+ rep.kv("BRANCH", branch or "(detached HEAD)")
135
+ rep.kv("SESSION_DIR", os.path.join(root, *SESSIONS_REL.split("/"), dir_name))
136
+ rep.kv("REPUSH", "yes" if existing else "no")
137
+ rep.kv("STARTED", sess.started.isoformat() if sess and sess.started else "?")
138
+ rep.kv("RECORDS", f"{sess.record_count} ({sess.bad_lines} unparsable lines skipped)" if sess else "0")
139
+ rep.kv("SUBAGENT_FILES", sess.subagent_files if sess else 0)
140
+
141
+ warnings: List[str] = []
142
+ if not sess:
143
+ warnings.append("this session's transcript is not on disk yet (nothing persisted before this command); the outline below is empty")
144
+ if sess:
145
+ warnings.extend(sess.warnings)
146
+ if sess and sess.cwd and not os.path.realpath(sess.cwd).startswith(os.path.realpath(root)):
147
+ warnings.append(f"session cwd {home_to_tilde(sess.cwd)} is outside this repo")
148
+ ignored = path_is_ignored(root, f"{SESSIONS_REL}/probe.md")
149
+ if ignored:
150
+ warnings.append(f"{SESSIONS_REL}/ is gitignored by {ignored}. Run /sessions:init (or `agent-sessions init`) in this repo first.")
151
+ op = in_progress_operation(root)
152
+ if op:
153
+ warnings.append(f"git operation in progress ({op}); the commit is still safe because it uses a temporary index")
154
+ if not head_exists(root):
155
+ warnings.append("repository has no commits yet; make an initial commit before pushing a session")
156
+ if not branch:
157
+ warnings.append("detached HEAD; pass --branch sessions/<handle>/<date> to push to a new branch")
158
+ elif not upstream_of(root, branch):
159
+ warnings.append(f"branch {branch} has no upstream; push will run `git push -u origin {branch}`")
160
+ if not email:
161
+ warnings.append("git user.email is not set; commits will use a default identity")
162
+ if git_out(["config", "--bool", "commit.gpgsign"], root) == "true":
163
+ warnings.append("commit.gpgsign is enabled; commit-tree will try to sign and may fail without an agent")
164
+ if sess and sess.agent_version and adapter.tested_version:
165
+ v = version_tuple(sess.agent_version)
166
+ if v and v > adapter.tested_version:
167
+ warnings.append(f"session written by {adapter.label} {sess.agent_version}, newer than the tested {'.'.join(map(str, adapter.tested_version))}")
168
+ if adapter.name == "gemini-cli":
169
+ warnings.append("Gemini CLI support is experimental (built from the source schema, no real sample yet); check the transcript before confirming")
170
+ for key, label, p in detected_unsupported():
171
+ warnings.append(f"{label} detected ({p}); transcript export for it is not supported yet")
172
+ rep.kv("WARNINGS", len(warnings))
173
+ for w in warnings:
174
+ rep.add(" -", w)
175
+
176
+ prompts = sess.user_prompts() if sess else []
177
+ files = sess.files_touched() if sess else []
178
+ tasks = sess.agent_tasks() if sess else []
179
+ rep.blank()
180
+ rep.add("OUTLINE")
181
+ rep.add(f"Prompts ({len(prompts)}):")
182
+ shown = prompts if len(prompts) <= OUTLINE_MAX_PROMPTS else prompts[: OUTLINE_MAX_PROMPTS // 2] + [(None, "…")] + prompts[-OUTLINE_MAX_PROMPTS // 2:]
183
+ for i, (ts, text) in enumerate(shown, 1):
184
+ stamp = ts.strftime("%H:%M") if ts else "--:--"
185
+ rep.add(f" {i:>3}. [{stamp}] {home_to_tilde(text)}")
186
+ rep.add(f"Files touched ({len(files)}, including subagents):")
187
+ for f in files[:60]:
188
+ rep.add(" -", f)
189
+ if len(files) > 60:
190
+ rep.add(f" … {len(files) - 60} more")
191
+ if tasks:
192
+ rep.add(f"Subagent tasks ({len(tasks)}):")
193
+ for t in tasks[:20]:
194
+ rep.add(" -", t)
195
+ blocking = ignored is not None or not head_exists(root)
196
+ return rep.finish(not blocking, "preflight blocked: see WARNINGS" if blocking else ""), not blocking
197
+
198
+
199
+ # --------------------------------------------------------------------------- write-summary
200
+
201
+
202
+ def cmd_write_summary(args: argparse.Namespace) -> Tuple[str, bool]:
203
+ rep = Report()
204
+ out_dir = os.path.abspath(args.out)
205
+ text = sys.stdin.read().strip("\n") + "\n"
206
+ if not text.strip() or not text.lstrip().startswith("---"):
207
+ return rep.finish(False, "summary must start with a YAML frontmatter block (---)"), False
208
+ lines = text.count("\n")
209
+ nbytes = len(text.encode("utf-8"))
210
+ rep.kv("SESSION_DIR", out_dir)
211
+ rep.kv("SUMMARY_LINES", lines)
212
+ rep.kv("SUMMARY_BYTES", nbytes)
213
+ if lines > SUMMARY_MAX_LINES or nbytes > SUMMARY_MAX_BYTES:
214
+ return rep.finish(False, f"summary exceeds the cap ({SUMMARY_MAX_LINES} lines / {SUMMARY_MAX_BYTES} bytes); trim it and write again"), False
215
+ write_text(os.path.join(out_dir, "summary.md"), text)
216
+ return rep.finish(True), True
217
+
218
+
219
+ # --------------------------------------------------------------------------- export
220
+
221
+
222
+ def _ref_for_export(agent: str, session_id: Optional[str], source: Optional[str], rep: Report) -> Tuple[Optional[SessionRef], Optional[object]]:
223
+ """Pick the session to export: explicit id through the named adapter first, then the given source."""
224
+ adapters = installed_adapters(agent) if agent == "auto" else [adapter_for(agent)]
225
+ if session_id:
226
+ for a in adapters:
227
+ ref = a.locate_by_id(session_id)
228
+ if ref:
229
+ if source and os.path.abspath(source) != os.path.abspath(ref.source) and not ref.source.startswith("sqlite:"):
230
+ rep.kv("TRANSCRIPT_SWITCHED", f"{home_to_tilde(source)} -> {home_to_tilde(ref.source)} (matched by session id)")
231
+ elif source and source != ref.source:
232
+ rep.kv("TRANSCRIPT_SWITCHED", f"{source} -> {ref.source} (matched by session id)")
233
+ return ref, a
234
+ rep.kv("TRANSCRIPT_WARNING", f"no session {session_id} found in the {agent} store; exporting the given source")
235
+ if source:
236
+ # a source path is self-describing: ask every adapter, even ones whose store is absent on this machine
237
+ pool = adapters if agent != "auto" else list(REGISTRY.values())
238
+ for a in pool:
239
+ ref = a.ref_from_source(source)
240
+ if ref:
241
+ return ref, a
242
+ return None, None
243
+
244
+
245
+ def cmd_export(args: argparse.Namespace) -> Tuple[str, bool]:
246
+ rep = Report()
247
+ out_dir = os.path.abspath(args.out)
248
+ root = repo_root(os.path.dirname(out_dir) if os.path.isdir(os.path.dirname(out_dir)) else os.getcwd())
249
+ agent = args.agent or "auto"
250
+ session_id = clean_arg(args.session_id)
251
+ source = clean_arg(args.source) or clean_arg(args.transcript)
252
+ if not session_id and not source:
253
+ return rep.finish(False, "pass --session-id (from locate) or --source"), False
254
+ try:
255
+ ref, adapter = _ref_for_export(agent, session_id, source, rep)
256
+ except KeyError:
257
+ return rep.finish(False, f"unknown agent {agent}"), False
258
+ if ref is None or adapter is None:
259
+ return rep.finish(False, f"could not resolve a session from --session-id {session_id or '-'} / --source {source or '-'}"), False
260
+ ref.extra["repo_root"] = root
261
+ sess: Session = adapter.load(ref) # type: ignore[union-attr]
262
+ rep.kv("AGENT", sess.agent)
263
+ rep.kv("SOURCE", ref.source)
264
+ rep.kv("TRANSCRIPT", ref.source)
265
+ if not sess.events and sess.record_count == 0:
266
+ return rep.finish(False, f"no records could be read from {ref.source}"), False
267
+
268
+ summary_path = os.path.join(out_dir, "summary.md")
269
+ if not os.path.exists(summary_path):
270
+ return rep.finish(False, f"summary.md not found in {out_dir}; write it first"), False
271
+ with open(summary_path, "r", encoding="utf-8", errors="replace") as fh:
272
+ summary_raw = fh.read()
273
+ fm, body = parse_frontmatter(summary_raw)
274
+
275
+ name = git_out(["config", "user.name"], root) or "unknown"
276
+ handle = handle_from_name(name)
277
+ branch = current_branch(root) or "(detached)"
278
+ title = str(args.title or fm.get("title") or sess.title or "untitled session").strip()
279
+ if title.startswith("<") or not title:
280
+ title = sess.title or "untitled session"
281
+ tags = fm.get("tags") if isinstance(fm.get("tags"), list) else []
282
+ tags = [slugify(str(t), 30) for t in tags if str(t).strip() and not str(t).startswith("<")]
283
+ outcome = str(fm.get("outcome") or "").strip()
284
+ if outcome.startswith("<"):
285
+ outcome = ""
286
+
287
+ desired = session_dir_name(sess.started, title, handle, sess.short_id)
288
+ renamed = False
289
+ if os.path.basename(out_dir) != desired:
290
+ rel_out = os.path.relpath(out_dir, root).replace(os.sep, "/")
291
+ tracked = head_exists(root) and run_git(["cat-file", "-e", f"HEAD:{rel_out}/meta.json"], root).ok
292
+ new_dir = os.path.join(os.path.dirname(out_dir), desired)
293
+ if not tracked and not os.path.exists(new_dir):
294
+ os.rename(out_dir, new_dir)
295
+ out_dir = new_dir
296
+ summary_path = os.path.join(out_dir, "summary.md")
297
+ renamed = True
298
+ # printed before any later failure so the caller never loses track of a renamed dir
299
+ rep.kv("SESSION_DIR", out_dir)
300
+ rep.kv("RENAMED", "yes (use this SESSION_DIR from now on)" if renamed else "no")
301
+
302
+ fm["title"] = title
303
+ fm["session_id"] = sess.session_id
304
+ fm["agent"] = sess.agent
305
+ fm["author"] = name
306
+ fm["handle"] = handle
307
+ fm["date"] = (sess.started or dt.datetime.now(dt.timezone.utc)).strftime("%Y-%m-%d")
308
+ fm["branch"] = branch
309
+ fm["tags"] = tags
310
+ fm["outcome"] = outcome or "(not stated)"
311
+ summary_text = dump_frontmatter(fm) + "\n" + body.lstrip("\n")
312
+
313
+ redactor = Redactor()
314
+ summary_text = redactor.redact(home_to_tilde(summary_text), "summary.md")
315
+ s_lines = summary_text.count("\n") + 1
316
+ s_bytes = len(summary_text.encode("utf-8"))
317
+ if s_lines > SUMMARY_MAX_LINES or s_bytes > SUMMARY_MAX_BYTES:
318
+ rep.kv("SUMMARY_LINES", s_lines)
319
+ rep.kv("SUMMARY_BYTES", s_bytes)
320
+ return rep.finish(False, f"summary.md exceeds the cap ({SUMMARY_MAX_LINES} lines / {SUMMARY_MAX_BYTES} bytes); trim it"), False
321
+
322
+ mode = "full" if args.include_results else "policy"
323
+ segments, stats = render_session(sess, redactor, mode, args.include_thinking, title)
324
+ transcript_text = "\n".join(segments)
325
+ if len(transcript_text.encode("utf-8")) > TRANSCRIPT_MAX_BYTES:
326
+ redactor2 = Redactor()
327
+ segments, stats = render_session(sess, redactor2, "stub", args.include_thinking, title)
328
+ redactor.hits.update(redactor2.hits)
329
+ redactor.review.extend(redactor2.review)
330
+ transcript_text = "\n".join(segments)
331
+ stats["degraded_to_stubs"] = 1
332
+ if stats.get("messages", 0) == 0 and not stats.get("stopped_at_push") and not stats.get("commands") and not stats.get("tool_calls"):
333
+ return rep.finish(False, "zero messages rendered; the session format may have changed"), False
334
+
335
+ files_written: List[str] = []
336
+ for old in glob.glob(os.path.join(out_dir, "transcript*.md")):
337
+ os.remove(old)
338
+ if len(transcript_text.encode("utf-8")) > TRANSCRIPT_MAX_BYTES:
339
+ chunk: List[str] = []
340
+ size = 0
341
+ part = 1
342
+ for seg in segments:
343
+ b = len(seg.encode("utf-8")) + 1
344
+ if chunk and size + b > TRANSCRIPT_MAX_BYTES * 0.9:
345
+ p = os.path.join(out_dir, f"transcript-{part}.md")
346
+ write_text(p, "\n".join(chunk))
347
+ files_written.append(p)
348
+ part += 1
349
+ chunk, size = [], 0
350
+ chunk.append(seg)
351
+ size += b
352
+ if chunk:
353
+ p = os.path.join(out_dir, f"transcript-{part}.md")
354
+ write_text(p, "\n".join(chunk))
355
+ files_written.append(p)
356
+ else:
357
+ p = os.path.join(out_dir, "transcript.md")
358
+ write_text(p, transcript_text)
359
+ files_written.append(p)
360
+
361
+ files_touched = sess.files_touched()
362
+ meta = OrderedDict([
363
+ ("session_id", sess.session_id),
364
+ ("short_id", sess.short_id),
365
+ ("agent", sess.agent),
366
+ ("agent_version", sess.agent_version),
367
+ ("originator", sess.originator),
368
+ ("parent_id", sess.parent_id),
369
+ ("title", title),
370
+ ("slug", slugify(title)),
371
+ ("dir", os.path.basename(out_dir)),
372
+ ("author", name),
373
+ ("handle", handle),
374
+ ("started", sess.started.isoformat() if sess.started else None),
375
+ ("ended", sess.ended.isoformat() if sess.ended else None),
376
+ ("branch", branch),
377
+ ("files_touched", files_touched),
378
+ ("tags", tags),
379
+ ("outcome", outcome or "(not stated)"),
380
+ ("tokens", sess.tokens),
381
+ ("pushed_at", dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()),
382
+ ("tool_version", TOOL_VERSION),
383
+ ("transcript_files", [os.path.basename(f) for f in files_written]),
384
+ ("stats", {k: v for k, v in stats.items() if not k.startswith("ignored:")}),
385
+ ])
386
+ meta_text = redactor.redact(json.dumps(meta, indent=2, ensure_ascii=False), "meta.json") + "\n"
387
+ write_text(summary_path, summary_text)
388
+ write_text(os.path.join(out_dir, "meta.json"), meta_text)
389
+
390
+ residual = []
391
+ for p in files_written + [summary_path, os.path.join(out_dir, "meta.json")]:
392
+ with open(p, "r", encoding="utf-8") as fh:
393
+ for pat in Redactor.residual_high(fh.read()):
394
+ residual.append(f"{os.path.basename(p)}:{pat}")
395
+
396
+ rep.kv("SHORT_ID", sess.short_id)
397
+ rep.kv("TITLE", title)
398
+ rep.kv("BRANCH", branch)
399
+ rep.kv("FILES", ", ".join(os.path.basename(f) for f in files_written) + ", summary.md, meta.json")
400
+ rep.kv("TRANSCRIPT_BYTES", sum(os.path.getsize(f) for f in files_written))
401
+ rep.kv("SUMMARY_LINES", s_lines)
402
+ rep.kv("MESSAGES", f"{stats.get('user_messages', 0)} user / {stats.get('commands', 0)} slash commands / "
403
+ f"{stats.get('assistant_messages', 0)} assistant / {stats.get('tool_calls', 0)} tool calls")
404
+ rep.kv("FILES_TOUCHED", len(files_touched))
405
+ rep.kv("COMPACTIONS", stats.get("compactions", 0))
406
+ rep.kv("WITHHELD_RESULTS", stats.get("withheld_results", 0))
407
+ rep.kv("RESULT_MODE", "full (opt-in)" if mode == "full" else ("stubs (size budget)" if stats.get("degraded_to_stubs") else "policy: read/web/MCP results stubbed, others truncated"))
408
+ rep.kv("REDACTED_HIGH", redactor.high_count())
409
+ rep.kv("REDACTED_REVIEW", redactor.review_count())
410
+ if stats.get("messages", 0) == 0 and stats.get("stopped_at_push"):
411
+ rep.kv("NOTE", "transcript is empty by design: the push command was the first turn of this session, and everything from the push onward is never exported. This is not an exporter fault; continue.")
412
+ if sess.agent == "gemini-cli":
413
+ rep.kv("EXPERIMENTAL", "Gemini CLI adapter is built from the source schema without a real sample; review the transcript")
414
+ if redactor.hits:
415
+ rep.add("Redactions by pattern:")
416
+ for k, v in sorted(redactor.hits.items(), key=lambda kv: -kv[1]):
417
+ rep.add(f" - {k}: {v}")
418
+ if redactor.review:
419
+ rep.add("Review these lines (keyword-based redactions, may be false positives):")
420
+ for label, line, pat in redactor.review[:20]:
421
+ rep.add(f" - {label}:{line} {pat}")
422
+ if len(redactor.review) > 20:
423
+ rep.add(f" … {len(redactor.review) - 20} more")
424
+ if residual:
425
+ for p in files_written:
426
+ os.remove(p)
427
+ return rep.finish(False, "high-confidence secret survived redaction: " + ", ".join(residual) + ". Transcript files removed; fix and re-run."), False
428
+ return rep.finish(True), True
429
+
430
+
431
+ # --------------------------------------------------------------------------- commit
432
+
433
+
434
+ def cmd_commit(args: argparse.Namespace) -> Tuple[str, bool]:
435
+ rep = Report()
436
+ session_dir = os.path.abspath(args.dir)
437
+ if not os.path.isdir(session_dir):
438
+ return rep.finish(False, f"{session_dir} does not exist"), False
439
+ root = repo_root(session_dir)
440
+ rel = os.path.relpath(session_dir, root).replace(os.sep, "/")
441
+ if not rel.startswith(SESSIONS_REL + "/"):
442
+ return rep.finish(False, f"{rel} is not under {SESSIONS_REL}/"), False
443
+ try:
444
+ with open(os.path.join(session_dir, "meta.json"), "r", encoding="utf-8") as fh:
445
+ meta = json.load(fh)
446
+ except (OSError, ValueError):
447
+ return rep.finish(False, "meta.json missing or invalid; run export first"), False
448
+ title = str(meta.get("title") or "session")
449
+ session_id = str(meta.get("session_id") or "")
450
+ agent = str(meta.get("agent") or "claude-code")
451
+ if not head_exists(root):
452
+ return rep.finish(False, "repository has no commits yet"), False
453
+ head = git_out(["rev-parse", "HEAD"], root)
454
+ branch = current_branch(root)
455
+ target_branch = args.branch
456
+ if not branch and not target_branch:
457
+ return rep.finish(False, "detached HEAD; pass --branch <name>"), False
458
+ is_update = run_git(["cat-file", "-e", f"HEAD:{rel}/meta.json"], root).ok
459
+
460
+ # temp index inside the git dir: writable even in sandboxes that block /tmp
461
+ tmp_index = os.path.join(git_dir(root), f"sessions-index.{os.getpid()}")
462
+ try:
463
+ env = {"GIT_INDEX_FILE": tmp_index}
464
+ r = run_git(["read-tree", "HEAD"], root, env_extra=env)
465
+ if not r.ok:
466
+ return rep.finish(False, "read-tree failed: " + r.err.strip()), False
467
+ r = run_git(["add", "-f", "--", rel], root, env_extra=env)
468
+ if not r.ok:
469
+ return rep.finish(False, "add failed: " + r.err.strip()), False
470
+ r = run_git(["write-tree"], root, env_extra=env)
471
+ if not r.ok:
472
+ return rep.finish(False, "write-tree failed: " + r.err.strip()), False
473
+ tree = r.out.strip()
474
+ finally:
475
+ try:
476
+ os.remove(tmp_index)
477
+ except OSError:
478
+ pass
479
+ if tree == git_out(["rev-parse", "HEAD^{tree}"], root):
480
+ return rep.finish(False, "nothing to commit: session files are identical to HEAD"), False
481
+
482
+ verb = "update" if is_update else "add"
483
+ trailers = [f"Agent-Session: {agent}:{session_id}", f"Agent-Session-Path: {rel}"]
484
+ if agent == "claude-code":
485
+ trailers.insert(0, f"Claude-Session: {session_id}")
486
+ message = f"docs(sessions): {verb} {title}\n\n" + "\n".join(trailers) + "\n"
487
+ r = run_git(["commit-tree", tree, "-p", head, "-F", "-"], root, stdin=message)
488
+ if not r.ok:
489
+ return rep.finish(False, "commit-tree failed: " + r.err.strip()), False
490
+ commit = r.out.strip()
491
+
492
+ if target_branch:
493
+ pushed_ref = target_branch
494
+ run_git(["update-ref", f"refs/heads/{target_branch}", commit], root)
495
+ else:
496
+ r = run_git(["update-ref", "-m", "sessions: push", f"refs/heads/{branch}", commit, head], root)
497
+ if not r.ok:
498
+ return rep.finish(False, "update-ref failed (HEAD moved during commit?): " + r.err.strip()), False
499
+ run_git(["add", "-f", "--", rel], root) # keep the user's real index in sync for these paths
500
+ pushed_ref = branch
501
+
502
+ rep.kv("COMMIT", commit)
503
+ rep.kv("BRANCH", pushed_ref)
504
+ rep.kv("ACTION", verb)
505
+ rep.kv("PATH", rel)
506
+ if not args.push:
507
+ rep.kv("PUSHED", "no (--push not given)")
508
+ return rep.finish(True), True
509
+
510
+ remote = args.remote
511
+ if target_branch:
512
+ push_args = ["push", remote, f"{commit}:refs/heads/{target_branch}"]
513
+ manual = f"git push {remote} {commit}:refs/heads/{target_branch}"
514
+ else:
515
+ up = upstream_of(root, branch or "")
516
+ push_args = ["push", remote, branch] if up else ["push", "-u", remote, branch]
517
+ manual = " ".join(["git"] + push_args)
518
+ r = run_git(push_args, root, timeout=PUSH_TIMEOUT)
519
+ if r.ok:
520
+ rep.kv("PUSHED", f"yes -> {remote}/{pushed_ref}")
521
+ return rep.finish(True), True
522
+ rep.kv("PUSHED", "no")
523
+ rep.add("Push output:")
524
+ for line in (r.err or r.out).strip().split("\n")[-8:]:
525
+ rep.add(" ", line)
526
+ rep.kv("MANUAL", manual)
527
+ hint = " If this is a sandboxed agent (Codex), approve network access or run MANUAL in your own terminal." \
528
+ if any(w in (r.err or "").lower() for w in ("could not resolve", "network", "timed out", "connection")) else ""
529
+ return rep.finish(False, "commit created locally but push failed; run the MANUAL command in your terminal." + hint), False
530
+
531
+
532
+ # --------------------------------------------------------------------------- list
533
+
534
+
535
+ def cmd_list(args: argparse.Namespace) -> Tuple[str, bool]:
536
+ rep = Report()
537
+ project_dir = resolve_project_dir(args.project_dir)
538
+ root = repo_root(project_dir)
539
+ remote = args.remote
540
+ has_remote = bool(git_out(["remote", "get-url", remote], root))
541
+ if has_remote and not args.no_fetch:
542
+ r = run_git(["fetch", remote, "--quiet"], root, timeout=45)
543
+ rep.kv("FETCH", "ok" if r.ok else f"failed ({(r.err or r.out).strip().split(chr(10))[-1][:120]}); using local refs")
544
+ else:
545
+ rep.kv("FETCH", "skipped" if has_remote else f"no remote named {remote}")
546
+ refs: List[str] = []
547
+ default = default_remote_branch(root, remote) if has_remote else None
548
+ if default:
549
+ refs.append(f"{remote}/{default}")
550
+ if head_exists(root):
551
+ refs.append("HEAD")
552
+ if args.all_branches and has_remote:
553
+ r = run_git(["for-each-ref", "--format=%(refname:short)", "--sort=-committerdate", f"refs/remotes/{remote}"], root)
554
+ for line in r.out.split("\n"):
555
+ line = line.strip()
556
+ if line and line != f"{remote}/HEAD" and line not in refs and len(refs) < 52:
557
+ refs.append(line)
558
+ rep.kv("REFS", ", ".join(refs) or "(none)")
559
+
560
+ sessions = {}
561
+ for ref in refs:
562
+ r = run_git(["ls-tree", "-r", "--name-only", ref, "--", SESSIONS_REL], root)
563
+ if not r.ok:
564
+ continue
565
+ for p in r.out.split("\n"):
566
+ p = p.strip()
567
+ if not p.endswith("/meta.json"):
568
+ continue
569
+ show = run_git(["show", f"{ref}:{p}"], root)
570
+ if not show.ok:
571
+ continue
572
+ try:
573
+ meta = json.loads(show.out)
574
+ except ValueError:
575
+ continue
576
+ sid = str(meta.get("session_id") or p)
577
+ entry = {"ref": ref, "path": p[: -len("/meta.json")], "meta": meta}
578
+ prev = sessions.get(sid)
579
+ if prev is None or str(meta.get("pushed_at") or "") > str(prev["meta"].get("pushed_at") or ""):
580
+ sessions[sid] = entry
581
+ ordered = sorted(sessions.values(), key=lambda e: str(e["meta"].get("pushed_at") or e["meta"].get("started") or ""), reverse=True)
582
+ rep.kv("COUNT", len(ordered))
583
+ rep.add("Read a summary with: git show <ref>:<path>/summary.md (transcript: <path>/transcript.md)")
584
+ rep.blank()
585
+ for e in ordered:
586
+ m = e["meta"]
587
+ sid = str(m.get("short_id") or str(m.get("session_id") or "")[:8])
588
+ date = str(m.get("started") or m.get("pushed_at") or "")[:10]
589
+ tags = ",".join(m.get("tags") or []) if isinstance(m.get("tags"), list) else ""
590
+ agent = m.get("agent") or "claude-code"
591
+ rep.add(f"- {sid} | {date} | {m.get('handle', '?')} | {agent} | {m.get('branch', '?')} | {m.get('title', '?')} | {m.get('outcome', '')}"
592
+ f"{' | #' + tags if tags else ''} | {e['ref']}:{e['path']}")
593
+ if args.json:
594
+ rep.blank()
595
+ rep.add("JSON: " + json.dumps([{"ref": e["ref"], "path": e["path"], **{k: e["meta"].get(k) for k in
596
+ ("session_id", "short_id", "agent", "title", "handle", "branch", "started", "pushed_at", "outcome", "tags")}} for e in ordered]))
597
+ return rep.finish(True), True
598
+
599
+
600
+ # --------------------------------------------------------------------------- init
601
+
602
+
603
+ def cmd_init(args: argparse.Namespace) -> Tuple[str, bool]:
604
+ rep = Report()
605
+ project_dir = resolve_project_dir(args.project_dir)
606
+ root = repo_root(project_dir)
607
+ changes: List[str] = []
608
+ touched: List[str] = []
609
+ gitignore = os.path.join(root, ".gitignore")
610
+
611
+ for probe in (f"{SESSIONS_REL}/probe.md", ".claude/settings.json"):
612
+ ignored = path_is_ignored(root, probe)
613
+ if not ignored:
614
+ continue
615
+ src, _, pattern = ignored.split(":", 2)
616
+ src_path = os.path.normpath(os.path.join(root, src))
617
+ if src_path != os.path.normpath(gitignore):
618
+ rep.add(f"{probe} is ignored by {src} (pattern {pattern}), which is outside the repo's .gitignore.")
619
+ rep.add(f"Add these lines to {src} or to {gitignore} manually: !{SESSIONS_REL}/ and !.claude/settings.json")
620
+ return rep.finish(False, "ignore rule lives outside .gitignore; fix it manually then re-run"), False
621
+ with open(gitignore, "r", encoding="utf-8", errors="replace") as fh:
622
+ lines = fh.read().split("\n")
623
+ new_lines = []
624
+ rewrote = False
625
+ for line in lines:
626
+ if line.strip() in (".claude", ".claude/", "/.claude", "/.claude/") and not rewrote:
627
+ new_lines.append(".claude/*")
628
+ rewrote = True
629
+ changes.append(f".gitignore: rewrote '{line.strip()}' to '.claude/*' so sub-paths can be re-included")
630
+ else:
631
+ new_lines.append(line)
632
+ for neg in (f"!{SESSIONS_REL}/", "!.claude/settings.json"):
633
+ if neg not in [l.strip() for l in new_lines]:
634
+ new_lines.append(neg)
635
+ changes.append(f".gitignore: added '{neg}'")
636
+ if not args.dry_run:
637
+ write_text(gitignore, "\n".join(new_lines))
638
+ touched.append(".gitignore")
639
+ if path_is_ignored(root, probe) and not args.dry_run:
640
+ return rep.finish(False, f"{probe} is still ignored after rewriting .gitignore (pattern {pattern}); edit .gitignore manually"), False
641
+ break
642
+
643
+ sessions_dir = os.path.join(root, SESSIONS_REL)
644
+ for fname, template in (("README.md", "sessions-README.md"), (".gitattributes", "gitattributes")):
645
+ dest = os.path.join(sessions_dir, fname)
646
+ if not os.path.exists(dest):
647
+ changes.append(f"create {SESSIONS_REL}/{fname}")
648
+ if not args.dry_run:
649
+ write_text(dest, load_template(template))
650
+ touched.append(f"{SESSIONS_REL}/{fname}")
651
+
652
+ settings_path = os.path.join(root, ".claude", "settings.json")
653
+ settings: dict = {}
654
+ if os.path.exists(settings_path):
655
+ try:
656
+ with open(settings_path, "r", encoding="utf-8") as fh:
657
+ settings = json.load(fh, object_pairs_hook=OrderedDict)
658
+ except ValueError:
659
+ return rep.finish(False, f"{settings_path} is not valid JSON; fix it before running init"), False
660
+ market_name, plugin_name, repo = args.marketplace_name, args.plugin_name, args.marketplace_repo
661
+ markets = settings.setdefault("extraKnownMarketplaces", OrderedDict())
662
+ if market_name not in markets:
663
+ markets[market_name] = OrderedDict([("source", OrderedDict([("source", "github"), ("repo", repo)]))])
664
+ changes.append(f"settings.json: registered marketplace {market_name} -> github:{repo}")
665
+ key = f"{plugin_name}@{market_name}"
666
+ enabled = settings.get("enabledPlugins")
667
+ if isinstance(enabled, list):
668
+ if key not in enabled:
669
+ enabled.append(key)
670
+ changes.append(f"settings.json: enabled plugin {key}")
671
+ else:
672
+ if not isinstance(enabled, dict):
673
+ enabled = OrderedDict()
674
+ settings["enabledPlugins"] = enabled
675
+ if not enabled.get(key):
676
+ enabled[key] = True
677
+ changes.append(f"settings.json: enabled plugin {key}")
678
+ note = f"agent-sessions: {SESSIONS_REL}/ holds shared session handoffs from any coding agent. Claude: /{plugin_name}:push and /{plugin_name}:pull. Others: the agent-sessions skill."
679
+ comment = settings.get("_comment")
680
+ if isinstance(comment, list):
681
+ if not any("agent-sessions:" in str(c) or "claude-sessions:" in str(c) for c in comment):
682
+ comment.append(note)
683
+ changes.append("settings.json: added _comment entry")
684
+ elif comment is None:
685
+ settings["_comment"] = [note]
686
+ changes.append("settings.json: added _comment entry")
687
+ if any(c.startswith("settings.json") for c in changes):
688
+ if not args.dry_run:
689
+ write_text(settings_path, json.dumps(settings, indent=2, ensure_ascii=False))
690
+ touched.append(".claude/settings.json")
691
+
692
+ if args.vendor_skill:
693
+ src = portable_skill_dir()
694
+ dest = os.path.join(root, ".agents", "skills", "agent-sessions")
695
+ if not src:
696
+ return rep.finish(False, "portable skill folder not found next to this installation; run from a checkout or ~/.agents/skills/agent-sessions"), False
697
+ changes.append(f"vendor the agent-sessions skill into .agents/skills/agent-sessions (from {home_to_tilde(src)})")
698
+ if not args.dry_run:
699
+ if os.path.isdir(dest):
700
+ shutil.rmtree(dest)
701
+ shutil.copytree(src, dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
702
+ touched.append(".agents/skills/agent-sessions")
703
+
704
+ rep.kv("REPO_ROOT", root)
705
+ rep.kv("MODE", "dry-run" if args.dry_run else "applied")
706
+ rep.kv("CHANGES", len(changes))
707
+ for c in changes:
708
+ rep.add(" -", c)
709
+ if not changes:
710
+ rep.add("Repo already set up; nothing to do.")
711
+ return rep.finish(True), True
712
+ if args.dry_run:
713
+ return rep.finish(True), True
714
+ if args.commit:
715
+ if not head_exists(root):
716
+ return rep.finish(False, "repository has no commits yet; commit the setup manually"), False
717
+ r = run_git(["add", "--"] + touched, root)
718
+ if not r.ok:
719
+ return rep.finish(False, "git add failed: " + r.err.strip()), False
720
+ r = run_git(["commit", "--only", "-m", "chore(agents): enable shared coding-agent sessions under .claude/sessions", "--"] + touched, root)
721
+ if not r.ok:
722
+ return rep.finish(False, "git commit failed: " + (r.err or r.out).strip()), False
723
+ rep.kv("COMMIT", git_out(["rev-parse", "--short", "HEAD"], root))
724
+ branch = current_branch(root) or "HEAD"
725
+ rep.kv("NEXT", f"git push origin {branch}")
726
+ else:
727
+ rep.kv("NEXT", "review the changes, then: git add " + " ".join(touched) + " && git commit -m 'chore(agents): enable shared coding-agent sessions'")
728
+ return rep.finish(True), True
729
+
730
+
731
+ # --------------------------------------------------------------------------- doctor
732
+
733
+
734
+ def cmd_doctor(args: argparse.Namespace) -> Tuple[str, bool]:
735
+ rep = Report()
736
+ rep.kv("TOOL", f"agent-sessions {TOOL_VERSION}")
737
+ rep.kv("SCRIPT", script_path())
738
+ rep.kv("PYTHON", f"{platform.python_version()} ({sys.executable})")
739
+ rep.kv("GIT", git_out(["--version"], os.getcwd()) or "(not found)")
740
+ rep.kv("PLATFORM", platform.platform())
741
+ root = None
742
+ try:
743
+ root = repo_root(resolve_project_dir(args.project_dir))
744
+ rep.kv("REPO_ROOT", root)
745
+ except SessionsError:
746
+ rep.kv("REPO_ROOT", "(not inside a git repo)")
747
+ rep.blank()
748
+ rep.add("Agents:")
749
+ for a in REGISTRY.values():
750
+ status = "installed" if a.installed() else "not found"
751
+ line = f" - {a.label} ({a.name}): {status}; store {home_to_tilde(a.data_dir())}"
752
+ env_id = a.in_env()
753
+ if env_id:
754
+ line += f"; running inside it (session {env_id[:12]}…)"
755
+ elif env_id == "":
756
+ line += "; running inside it"
757
+ rep.add(line)
758
+ if root and a.installed():
759
+ try:
760
+ cands = a.candidates(root, limit=1)
761
+ except Exception as e: # noqa: BLE001
762
+ cands = []
763
+ rep.add(f" could not scan: {type(e).__name__}: {e}")
764
+ if cands:
765
+ c = cands[0]
766
+ rep.add(f" newest session for this repo: {c.session_id} ({fmt_when(c.updated)})")
767
+ for key, label, p in detected_unsupported():
768
+ rep.add(f" - {label} ({key}): detected at {p}; pull and init work, push is not supported yet")
769
+ rep.blank()
770
+ skill = os.path.expanduser("~/.agents/skills/agent-sessions/SKILL.md")
771
+ rep.kv("PORTABLE_SKILL", "installed at ~/.agents/skills/agent-sessions" if os.path.exists(skill) else "not installed (scripts/setup.sh --skills)")
772
+ plugins = os.path.join(os.path.expanduser(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"), "plugins", "installed_plugins.json")
773
+ claude_plugin = "unknown"
774
+ try:
775
+ with open(plugins, "r", encoding="utf-8") as fh:
776
+ claude_plugin = "installed" if "sessions@" in fh.read() else "not installed"
777
+ except OSError:
778
+ claude_plugin = "Claude Code not found"
779
+ rep.kv("CLAUDE_PLUGIN", claude_plugin)
780
+ return rep.finish(True), True
781
+
782
+
783
+ # --------------------------------------------------------------------------- parser
784
+
785
+
786
+ def build_parser() -> argparse.ArgumentParser:
787
+ p = argparse.ArgumentParser(prog="agent-sessions", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
788
+ p.add_argument("--version", action="version", version=f"agent-sessions {__version__}")
789
+ sub = p.add_subparsers(dest="cmd", required=True)
790
+
791
+ s = sub.add_parser("locate", help="find the live session and report git facts")
792
+ s.add_argument("--agent", default="auto", choices=AGENT_CHOICES)
793
+ s.add_argument("--session-id")
794
+ s.add_argument("--project-dir")
795
+ s.add_argument("--title")
796
+ s.set_defaults(func=cmd_locate)
797
+
798
+ s = sub.add_parser("write-summary", help="write summary.md into a session dir from stdin")
799
+ s.add_argument("--out", required=True)
800
+ s.set_defaults(func=cmd_write_summary)
801
+
802
+ s = sub.add_parser("export", help="render + redact a session into a session dir (summary.md must exist)")
803
+ s.add_argument("--out", required=True)
804
+ s.add_argument("--agent", default="auto", choices=AGENT_CHOICES)
805
+ s.add_argument("--session-id")
806
+ s.add_argument("--source", help="SOURCE printed by locate (file path or sqlite ref)")
807
+ s.add_argument("--transcript", help="alias of --source")
808
+ s.add_argument("--title")
809
+ s.add_argument("--include-results", action="store_true", help="keep full tool results (capped at 20 KB each)")
810
+ s.add_argument("--include-thinking", action="store_true")
811
+ s.set_defaults(func=cmd_export)
812
+
813
+ s = sub.add_parser("commit", help="commit the session dir via a temporary index and push")
814
+ s.add_argument("--dir", required=True)
815
+ s.add_argument("--push", action="store_true")
816
+ s.add_argument("--branch")
817
+ s.add_argument("--remote", default="origin")
818
+ s.set_defaults(func=cmd_commit)
819
+
820
+ s = sub.add_parser("list", help="list sessions on origin/<default> and HEAD")
821
+ s.add_argument("--project-dir")
822
+ s.add_argument("--remote", default="origin")
823
+ s.add_argument("--all-branches", action="store_true")
824
+ s.add_argument("--no-fetch", action="store_true")
825
+ s.add_argument("--json", action="store_true")
826
+ s.set_defaults(func=cmd_list)
827
+
828
+ s = sub.add_parser("init", help="prepare a repo for shared sessions")
829
+ s.add_argument("--project-dir")
830
+ s.add_argument("--marketplace-repo", default="prajwalgajakesari/agent-sessions")
831
+ s.add_argument("--marketplace-name", default="agent-sessions")
832
+ s.add_argument("--plugin-name", default="sessions")
833
+ s.add_argument("--vendor-skill", action="store_true", help="also copy the portable skill into .agents/skills/")
834
+ s.add_argument("--commit", action="store_true")
835
+ s.add_argument("--dry-run", action="store_true")
836
+ s.set_defaults(func=cmd_init)
837
+
838
+ s = sub.add_parser("doctor", help="show detected agents, stores and installation state")
839
+ s.add_argument("--project-dir")
840
+ s.set_defaults(func=cmd_doctor)
841
+ return p
842
+
843
+
844
+ def main(argv: Optional[List[str]] = None) -> int:
845
+ args = build_parser().parse_args(argv)
846
+ try:
847
+ text, ok = args.func(args)
848
+ except SessionsError as e:
849
+ text, ok = Report().finish(False, str(e)), False
850
+ except Exception as e: # noqa: BLE001 - never leave the caller without a STATUS line
851
+ text, ok = Report().finish(False, f"unexpected {type(e).__name__}: {e}"), False
852
+ sys.stdout.write(text)
853
+ sys.stdout.flush()
854
+ return 0 if ok else 1
855
+
856
+
857
+ def entry() -> None:
858
+ sys.exit(main())