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/__init__.py +3 -0
- agent_sessions/__main__.py +3 -0
- agent_sessions/adapters/__init__.py +136 -0
- agent_sessions/adapters/base.py +108 -0
- agent_sessions/adapters/claude_code.py +300 -0
- agent_sessions/adapters/codex.py +360 -0
- agent_sessions/adapters/gemini.py +176 -0
- agent_sessions/adapters/opencode.py +231 -0
- agent_sessions/cli.py +858 -0
- agent_sessions/core.py +748 -0
- agent_sessions/model.py +119 -0
- agent_sessions/templates/gitattributes +2 -0
- agent_sessions/templates/sessions-README.md +31 -0
- agent_sessions/templates/summary.md +31 -0
- agent_sessions_cli-0.2.0.dist-info/METADATA +141 -0
- agent_sessions_cli-0.2.0.dist-info/RECORD +19 -0
- agent_sessions_cli-0.2.0.dist-info/WHEEL +4 -0
- agent_sessions_cli-0.2.0.dist-info/entry_points.txt +2 -0
- agent_sessions_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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())
|