aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-10-03 13:07:58 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-10-03 13:07:58 -0300
commit2f8bc985b79e036ca6d34bb680a8858154c17a3b (patch)
treeb82815f7349209fc60e729c41b3ac43a1555f931
parent471e20a03503d3951a6fe12c1f3a354043a42e05 (diff)
downloadclaude-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.md7
-rw-r--r--README.md9
-rw-r--r--claude_logbook/__init__.py2
-rw-r--r--claude_logbook/cli.py59
-rw-r--r--tests/test_cli.py56
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. |
diff --git a/README.md b/README.md
index bba8906..c41738a 100644
--- a/README.md
+++ b/README.md
@@ -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,