diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-10-03 13:07:58 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-10-03 13:07:58 -0300 |
| commit | 2f8bc985b79e036ca6d34bb680a8858154c17a3b (patch) | |
| tree | b82815f7349209fc60e729c41b3ac43a1555f931 | |
| parent | 471e20a03503d3951a6fe12c1f3a354043a42e05 (diff) | |
| download | claude-logbook-2f8bc985b79e036ca6d34bb680a8858154c17a3b.tar.gz claude-logbook-2f8bc985b79e036ca6d34bb680a8858154c17a3b.zip | |
Export a single session, or only what the filters select
--json and --html now honour -s, the text filter, -p, -g, -E and -n. With -s
the page defaults to session-<id>.html. A partial export carries only the
memories of the projects it includes; with no filter everything goes, as
before.
| -rw-r--r-- | README.es.md | 7 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | claude_logbook/__init__.py | 2 | ||||
| -rw-r--r-- | claude_logbook/cli.py | 59 | ||||
| -rw-r--r-- | tests/test_cli.py | 56 |
5 files changed, 113 insertions, 20 deletions
diff --git a/README.es.md b/README.es.md index 8bd8da5..73923f8 100644 --- a/README.es.md +++ b/README.es.md @@ -26,6 +26,8 @@ Español · **[English](README.md)** · Sin dependencias, solo biblioteca están memorias de tus proyectos**: prompts, respuestas, rutas de archivos, nombres de ramas. El `sessions.html` que sale es una copia legible de todo lo que escribiste alguna vez en Claude Code. +Exportar una sola sesión (`-s 3 --html`) sigue siendo esa conversación entera: +leela antes de compartirla. No lo commitees, no lo subas, no lo pegues en un issue. El `.gitignore` del repo ya excluye `sessions.html` y `data.json`, pero el archivo lo cuidás vos. @@ -60,6 +62,7 @@ claude-logbook -g "port already" # busca dentro de las conversaciones claude-logbook -r 3 # imprime el comando para reanudarla eval "$(claude-logbook -r 3)" # …o la reanuda directamente claude-logbook --html --open # genera sessions.html y lo abre +claude-logbook -s 3 --html # exporta solo la #3 → session-<id>.html claude-logbook -m # las memorias de tus proyectos claude-logbook -P # permisos, directorios extra y confianza ``` @@ -85,8 +88,8 @@ claude-logbook docker -s 2 # lee el 2º de esos tres | `--no-tools` | En el chat, oculta las llamadas a herramientas. | | `--no-pager` | No manda el chat a `$PAGER`. | | `--no-color` | Salida sin color (también respeta `NO_COLOR`). | -| `--json` | Vuelca todas las sesiones en JSON por stdout. | -| `--html [FILE]` | Genera la página autocontenida (por defecto `sessions.html`). | +| `--json` | Vuelca las sesiones en JSON por stdout: todas, o solo `-s` / lo que dejen los filtros. | +| `--html [FILE]` | Genera la página autocontenida con la misma selección que `--json` (por defecto `sessions.html`, o `session-<id>.html` con `-s`). | | `--template FILE` | Usa tu propio template para `--html`. | | `--open` | Abre en el navegador lo que haya generado `--html`. | | `--no-cache` | Ignora el caché y re-parsea todo. | @@ -24,7 +24,9 @@ The command reads local files and never sends anything anywhere. `--json` and `--html` write out **the full text of your conversations and your projects' memories**: prompts, answers, file paths, branch names. The generated `sessions.html` is a complete, -readable copy of everything you ever typed into Claude Code. +readable copy of everything you ever typed into Claude Code. Exporting a single +session (`-s 3 --html`) is still that whole conversation: read it before you +share it. Do not commit it, do not upload it, do not paste it into a bug report. The repository's `.gitignore` already excludes `sessions.html` and `data.json`, but @@ -60,6 +62,7 @@ claude-logbook -g "port already" # search inside the conversations claude-logbook -r 3 # print the command that resumes it eval "$(claude-logbook -r 3)" # …or resume it right away claude-logbook --html --open # build sessions.html and open it +claude-logbook -s 3 --html # export only #3 → session-<id>.html claude-logbook -m # your projects' memories claude-logbook -P # permissions, extra directories and trust ``` @@ -85,8 +88,8 @@ claude-logbook docker -s 2 # reads the 2nd of those three | `--no-tools` | Hide tool calls when printing a conversation. | | `--no-pager` | Do not pipe the conversation through `$PAGER`. | | `--no-color` | Plain output (`NO_COLOR` is honoured too). | -| `--json` | Dump every session as JSON on stdout. | -| `--html [FILE]` | Build the standalone page (default `sessions.html`). | +| `--json` | Dump the sessions as JSON on stdout: all of them, or only `-s` / what the filters keep. | +| `--html [FILE]` | Build the standalone page with the same selection as `--json` (default `sessions.html`, or `session-<id>.html` with `-s`). | | `--template FILE` | Use your own template for `--html`. | | `--open` | Open whatever `--html` produced in your browser. | | `--no-cache` | Ignore the cache and re-parse everything. | diff --git a/claude_logbook/__init__.py b/claude_logbook/__init__.py index abe9c71..f7d4458 100644 --- a/claude_logbook/__init__.py +++ b/claude_logbook/__init__.py @@ -3,6 +3,6 @@ No dependencies: standard library only. """ -__version__ = "1.3.0" +__version__ = "1.4.0" __all__ = ["__version__"] diff --git a/claude_logbook/cli.py b/claude_logbook/cli.py index 43b08eb..f90ac26 100644 --- a/claude_logbook/cli.py +++ b/claude_logbook/cli.py @@ -37,6 +37,8 @@ examples: claude-logbook -r 3 command to resume #3 eval "$(claude-logbook -r 3)" resume it right away claude-logbook --html --open write sessions.html and open it + claude-logbook -s 3 --html export only #3 (session-<id>.html) + claude-logbook -p api --json export only that project's sessions the # is the position in the table you are looking at, so if you filtered you have to repeat the filter to read that row: @@ -131,9 +133,11 @@ def build_parser(): output_path = ap.add_argument_group("export") output_path.add_argument("--json", action="store_true", - help="dump every session as JSON") - output_path.add_argument("--html", nargs="?", const=DEFAULT_HTML, metavar="FILE", - help=f"write a self-contained page (default {DEFAULT_HTML})") + help="dump the sessions as JSON (all, or what -s and " + "the filters select)") + output_path.add_argument("--html", nargs="?", const="", metavar="FILE", + help=f"write a self-contained page (default {DEFAULT_HTML}, " + f"or session-<id>.html with -s)") output_path.add_argument("--template", metavar="FILE", help="use another template for --html") output_path.add_argument("--open", action="store_true", @@ -266,19 +270,44 @@ def delete_targets(pool, args): # ──────────────────────────────── commands ──────────────────────────────── -def cmd_json(sessions): +def export_selection(sessions, args): + """What --json and --html export: the -s session, or what the filters and + -n leave. With no filter at all, everything, memories included; otherwise + only the memories of the projects that made it.""" + if args.show: + chosen = [pick(filtered(sessions, args), args.show)] + else: + chosen = filtered(sessions, args) + if args.limit: + chosen = chosen[: args.limit] + + memories = mem.load_memories(sessions) + if len(chosen) != len(sessions): + dirs = {s["project_dir"] for s in chosen} + memories = [m for m in memories if m["project_dir"] in dirs] + return chosen, memories + + +def cmd_json(sessions, memories): import json payload = webpage.build_payload( - public_records(sessions), - mem.public_records(mem.load_memories(sessions))) + public_records(sessions), mem.public_records(memories)) json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) sys.stdout.write("\n") return 0 -def cmd_html(sessions, args): - out = args.html - memories = mem.public_records(mem.load_memories(sessions)) +def html_path(chosen, args): + if args.html: + return args.html + if args.show: + return f"session-{chosen[0]['id'][:8]}.html" + return DEFAULT_HTML + + +def cmd_html(sessions, memories, args): + out = html_path(sessions, args) + memories = mem.public_records(memories) stats = webpage.write(public_records(sessions), out, memories=memories, template=webpage.template_text(args.template)) print(f"{stats['sessions']} sessions · {stats['projects']} projects · " @@ -558,10 +587,14 @@ def run(args): if args.memory: return run_memory(sessions, args, st) - if args.json: - return cmd_json(sessions) - if args.html: - return cmd_html(sessions, args) + if args.json or args.html is not None: + chosen, memories = export_selection(sessions, args) + if not chosen: + print("No session matches that filter.", file=sys.stderr) + return 1 + if args.json: + return cmd_json(chosen, memories) + return cmd_html(chosen, memories, args) if args.delete or args.delete_empty: targets = delete_targets(filtered(sessions, args), args) diff --git a/tests/test_cli.py b/tests/test_cli.py index 204038c..4538f24 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -100,6 +100,57 @@ class TestExport(CliCase): self.assertEqual(len(payload["s"]), 3) self.assertEqual(payload["m"], []) + def test_json_of_one_session(self): + simple_tree(self.root) + code, out, _ = self.run_cli("-s", "cccccccc", "--json") + self.assertEqual(code, 0) + data = json.loads(out) + self.assertEqual([r["id"][:8] for r in data["s"]], ["cccccccc"]) + + def test_json_honours_filters_and_limit(self): + simple_tree(self.root) + _, out, _ = self.run_cli("-p", "/home/u/proj", "--json") + self.assertTrue(all(r["p"] == "/home/u/proj" for r in json.loads(out)["s"])) + _, out, _ = self.run_cli("-n", "1", "--json") + self.assertEqual(len(json.loads(out)["s"]), 1) + + def test_export_with_no_match(self): + simple_tree(self.root) + code, _, err = self.run_cli("nothing-like-this", "--json") + self.assertEqual(code, 1) + self.assertIn("No session matches", err) + + def test_html_of_one_session_gets_its_own_name(self): + simple_tree(self.root) + cwd = os.getcwd() + os.chdir(self.home) + self.addCleanup(os.chdir, cwd) + code, _, err = self.run_cli("-s", "aaaaaaaa", "--html") + self.assertEqual(code, 0) + self.assertIn("1 sessions", err) + with open("session-aaaaaaaa.html", encoding="utf-8") as f: + payload = json.loads(PAYLOAD_RE.findall(f.read())[0]) + self.assertEqual(len(payload["s"]), 1) + + def test_default_html_name_without_show(self): + simple_tree(self.root) + cwd = os.getcwd() + os.chdir(self.home) + self.addCleanup(os.chdir, cwd) + self.run_cli("--html") + self.assertTrue(os.path.exists("sessions.html")) + + def test_memories_follow_the_selection(self): + simple_tree(self.root) + memory_tree(self.root) + _, out, _ = self.run_cli("--json") + everything = json.loads(out)["m"] + _, out, _ = self.run_cli("-p", "/home/u/proj", "--json") + some = json.loads(out)["m"] + self.assertTrue(everything) + self.assertTrue(all(m["p"] == "/home/u/proj" for m in some)) + self.assertLess(len(some), len(everything)) + def test_html_leaves_stdout_alone(self): # The summary goes to stderr so `--html /dev/stdout` keeps working. simple_tree(self.root) @@ -201,7 +252,10 @@ class TestDeletion(CliCase): class TestParser(unittest.TestCase): def test_html_without_value_uses_the_default_name(self): args = cli.build_parser().parse_args(["--html"]) - self.assertEqual(args.html, cli.DEFAULT_HTML) + self.assertEqual(cli.html_path([{"id": "abcdef0123"}], args), cli.DEFAULT_HTML) + args = cli.build_parser().parse_args(["--html", "-s", "1"]) + self.assertEqual(cli.html_path([{"id": "abcdef0123"}], args), + "session-abcdef01.html") def test_html_with_value(self): self.assertEqual(cli.build_parser().parse_args(["--html", "x.html"]).html, |