claude-session-backup 0.4.2__tar.gz → 0.4.6__tar.gz

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.
Files changed (66) hide show
  1. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/PKG-INFO +2 -2
  2. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/README.md +1 -1
  3. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/_version.py +2 -2
  4. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/cli.py +61 -5
  5. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/commands.py +100 -50
  6. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/config.py +12 -5
  7. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/fts5_db.py +9 -9
  8. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/fts5_importer.py +1 -1
  9. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/fts5_index.py +7 -5
  10. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/fts_paths.py +15 -16
  11. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/git_ops.py +28 -17
  12. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/index.py +2 -2
  13. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/lockfile.py +4 -2
  14. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/migrations.py +5 -6
  15. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/pathkit.py +193 -1
  16. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/scanner.py +13 -10
  17. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/search.py +1 -1
  18. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/sesslog_scanner.py +3 -1
  19. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/transcript_walker.py +2 -2
  20. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup.egg-info/PKG-INFO +2 -2
  21. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup.egg-info/SOURCES.txt +2 -0
  22. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_commands.py +4 -1
  23. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_fts_paths.py +10 -10
  24. claude_session_backup-0.4.6/tests/test_passthrough.py +152 -0
  25. claude_session_backup-0.4.6/tests/test_pathkit_layout.py +247 -0
  26. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_restore.py +44 -3
  27. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_session_sources.py +1 -1
  28. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_view.py +3 -3
  29. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/LICENSE +0 -0
  30. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/__init__.py +0 -0
  31. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/__main__.py +0 -0
  32. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/distill.py +0 -0
  33. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/fts5_migrations.py +0 -0
  34. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/ids.py +0 -0
  35. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/metadata.py +0 -0
  36. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/search_render.py +0 -0
  37. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/sesslog_parser.py +0 -0
  38. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup/timeline.py +0 -0
  39. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup.egg-info/dependency_links.txt +0 -0
  40. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup.egg-info/entry_points.txt +0 -0
  41. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup.egg-info/requires.txt +0 -0
  42. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/claude_session_backup.egg-info/top_level.txt +0 -0
  43. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/pyproject.toml +0 -0
  44. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/setup.cfg +0 -0
  45. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_backup_hook.py +0 -0
  46. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_cli.py +0 -0
  47. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_config.py +0 -0
  48. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_distill.py +0 -0
  49. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_fts5_db.py +0 -0
  50. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_fts5_importer.py +0 -0
  51. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_fts5_index.py +0 -0
  52. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_fts5_migrations.py +0 -0
  53. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_hook.py +0 -0
  54. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_ids.py +0 -0
  55. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_index.py +0 -0
  56. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_lockfile.py +0 -0
  57. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_metadata.py +0 -0
  58. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_migrations.py +0 -0
  59. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_pathkit.py +0 -0
  60. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_scanner.py +0 -0
  61. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_search.py +0 -0
  62. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_search_render.py +0 -0
  63. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_sesslog_parser.py +0 -0
  64. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_sesslog_scanner.py +0 -0
  65. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_timeline.py +0 -0
  66. {claude_session_backup-0.4.2 → claude_session_backup-0.4.6}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-session-backup
3
- Version: 0.4.2
3
+ Version: 0.4.6
4
4
  Summary: Git-backed Claude Code session backup with timeline view, folder analysis, deletion detection, and session restore.
5
5
  Author-email: djdarcy <6962246+djdarcy@users.noreply.github.com>
6
6
  License: GPL-3.0-or-later
@@ -246,7 +246,7 @@ Like the project?
246
246
 
247
247
  ## Acknowledgements
248
248
 
249
- - [claude-vault](https://github.com/kuroko1t/claude-vault) by [@kuroko1t](https://github.com/kuroko1t) -- FTS5 search design, JSONL parsing patterns, Claude Code hook integration. Serendipitously started development on `csb` a week or so before [kuroko1t's blog post](https://dev.to/kuroko1t/i-built-a-tool-to-stop-losing-my-claude-code-conversation-history-5500) laying out the problem.
249
+ - [claude-vault](https://github.com/kuroko1t/claude-vault) by [@kuroko1t](https://github.com/kuroko1t) -- Serendipitously started development on `csb` a week or so before [kuroko1t's blog post](https://dev.to/kuroko1t/i-built-a-tool-to-stop-losing-my-claude-code-conversation-history-5500) laying out the problem.
250
250
  - [claude-code-history-viewer](https://github.com/jhlee0409/claude-code-history-viewer) by [@jhlee0409](https://github.com/jhlee0409) - GUI session reader that `csb view` launches.
251
251
 
252
252
  ## License
@@ -214,7 +214,7 @@ Like the project?
214
214
 
215
215
  ## Acknowledgements
216
216
 
217
- - [claude-vault](https://github.com/kuroko1t/claude-vault) by [@kuroko1t](https://github.com/kuroko1t) -- FTS5 search design, JSONL parsing patterns, Claude Code hook integration. Serendipitously started development on `csb` a week or so before [kuroko1t's blog post](https://dev.to/kuroko1t/i-built-a-tool-to-stop-losing-my-claude-code-conversation-history-5500) laying out the problem.
217
+ - [claude-vault](https://github.com/kuroko1t/claude-vault) by [@kuroko1t](https://github.com/kuroko1t) -- Serendipitously started development on `csb` a week or so before [kuroko1t's blog post](https://dev.to/kuroko1t/i-built-a-tool-to-stop-losing-my-claude-code-conversation-history-5500) laying out the problem.
218
218
  - [claude-code-history-viewer](https://github.com/jhlee0409/claude-code-history-viewer) by [@jhlee0409](https://github.com/jhlee0409) - GUI session reader that `csb view` launches.
219
219
 
220
220
  ## License
@@ -15,13 +15,13 @@ To bump version: python scripts/sync-versions.py --bump patch
15
15
  # Version components - edit these for version bumps
16
16
  MAJOR = 0
17
17
  MINOR = 4
18
- PATCH = 2
18
+ PATCH = 6
19
19
  PHASE = "" # Per-MINOR feature set: None, "alpha", "beta", "rc1", etc.
20
20
  PRE_RELEASE_NUM = 1 # PEP 440 pre-release number (e.g., a1, b2)
21
21
  PROJECT_PHASE = "alpha" # Project-wide: "prealpha", "alpha", "beta", "stable"
22
22
 
23
23
  # Auto-updated by git hooks - do not edit manually
24
- __version__ = "0.4.2"
24
+ __version__ = "0.4.6_main_56-20260614-54d2a56b"
25
25
  __app_name__ = "claude-session-backup"
26
26
 
27
27
 
@@ -53,6 +53,12 @@ for flag, spec in _COMMON_FLAGS.items():
53
53
  if "short" in spec:
54
54
  _COMMON_FLAG_NAMES.add(spec["short"])
55
55
 
56
+ # Commands that launch a wrapped subtool and therefore accept `--` passthrough
57
+ # (#47): everything after a standalone `--` is forwarded verbatim to the child
58
+ # (resume -> claude, view -> the history viewer). Any other command rejects a
59
+ # passthrough rather than silently dropping it.
60
+ PASSTHROUGH_COMMANDS = {"resume", "view"}
61
+
56
62
 
57
63
  def _add_common_flags(parser):
58
64
  """Add common flags to a subcommand parser."""
@@ -227,7 +233,19 @@ def build_parser():
227
233
  )
228
234
 
229
235
  # resume
230
- p_resume = sub.add_parser("resume", help="Launch claude --resume with full UUID")
236
+ p_resume = sub.add_parser(
237
+ "resume",
238
+ help="Launch claude --resume with full UUID",
239
+ description=(
240
+ "Resolve a session, cd to its start folder, and launch\n"
241
+ "`claude --resume <uuid>`. Forward extra args to claude after `--`:\n\n"
242
+ " csb resume <query> resume it\n"
243
+ " csb resume <query> -- --fork-session resume + forward --fork-session to claude\n\n"
244
+ "Everything after `--` is passed verbatim to claude (don't re-pass\n"
245
+ "--resume / -r -- csb already supplies it)."
246
+ ),
247
+ formatter_class=argparse.RawDescriptionHelpFormatter,
248
+ )
231
249
  _add_common_flags(p_resume)
232
250
  p_resume.add_argument(
233
251
  "session_id", metavar="query",
@@ -270,7 +288,9 @@ def build_parser():
270
288
  "key (csb config viewer_path <path>), then platform install\n"
271
289
  "locations. Without a viewer, prints the transcript path.\n"
272
290
  "Pruned sessions offer restore-from-git first (same flags as\n"
273
- "`csb resume`)."
291
+ "`csb resume`).\n\n"
292
+ "Forward extra args to the viewer after `--`:\n"
293
+ " csb view <query> -- <viewer-args>"
274
294
  ),
275
295
  formatter_class=argparse.RawDescriptionHelpFormatter,
276
296
  )
@@ -612,9 +632,9 @@ def build_parser():
612
632
  p_update_rebuild.add_argument(
613
633
  "--include-fts5", action="store_true",
614
634
  help=(
615
- "Also refresh the per-project FTS5 indexes (currently a no-op "
616
- "stub on this branch -- main wires the actual refresh in "
617
- "post-merge)."
635
+ "Also force-rebuild the per-project FTS5 content indexes after "
636
+ "the index rebuild. Fails soft -- an FTS5 problem warns but never "
637
+ "fails the rebuild."
618
638
  ),
619
639
  )
620
640
  p_update_rebuild.add_argument(
@@ -732,21 +752,57 @@ def build_parser():
732
752
  return parser
733
753
 
734
754
 
755
+ def _split_passthrough(argv):
756
+ """Split argv at the first standalone ``--`` token (#47).
757
+
758
+ Returns ``(csb_argv, passthrough)`` -- everything before the ``--`` for
759
+ csb's own parsing, everything after it forwarded verbatim to a wrapped
760
+ subtool. No ``--`` -> ``(argv, [])``. Matches only the exact two-char
761
+ token, never ``--db``/``--force``. Must run BEFORE ``_hoist_common_flags``
762
+ so forwarded flags are never hoisted into csb's own options.
763
+ """
764
+ if "--" in argv:
765
+ i = argv.index("--")
766
+ return list(argv[:i]), list(argv[i + 1:])
767
+ return list(argv), []
768
+
769
+
735
770
  def main(argv=None):
736
771
  """Entry point for csb CLI."""
737
772
  # Hoist common flags from before the subcommand to after it.
738
773
  # This makes `csb --quiet backup` work the same as `csb backup --quiet`.
739
774
  if argv is None:
740
775
  argv = sys.argv[1:]
776
+
777
+ # `--` passthrough (#47): everything after the first standalone `--` token
778
+ # is forwarded verbatim to the wrapped subtool. Carve it off BEFORE
779
+ # flag-hoisting so a forwarded flag (e.g. `csb resume x -- --db /other`)
780
+ # is never mistaken for one of csb's own options. argparse never sees the
781
+ # `--` -- the tail is reattached to the namespace as `args.passthrough`.
782
+ argv, passthrough = _split_passthrough(argv)
783
+
741
784
  argv = _hoist_common_flags(argv)
742
785
 
743
786
  parser = build_parser()
744
787
  args = parser.parse_args(argv)
788
+ args.passthrough = passthrough
745
789
 
746
790
  if args.command is None:
747
791
  parser.print_help()
748
792
  return 0
749
793
 
794
+ # Only subtool-launchers can forward args; reject (never silently drop)
795
+ # a passthrough given to any other command.
796
+ if passthrough and args.command not in PASSTHROUGH_COMMANDS:
797
+ capable = ", ".join(sorted(PASSTHROUGH_COMMANDS))
798
+ print(
799
+ f"csb {args.command}: `--` passthrough is only supported by: "
800
+ f"{capable}. (Everything after `--` is forwarded to the wrapped "
801
+ f"tool, which `{args.command}` does not launch.)",
802
+ file=sys.stderr,
803
+ )
804
+ return 2
805
+
750
806
  # Import handlers lazily to keep startup fast
751
807
  if args.command == "backup":
752
808
  from .commands import cmd_backup
@@ -70,6 +70,7 @@ from .metadata import (
70
70
  read_name_cache,
71
71
  read_session_state,
72
72
  )
73
+ from .pathkit import ClaudePaths
73
74
  from .scanner import scan_projects
74
75
  from .timeline import format_session_line, format_timeline, render_timeline_rich, HAS_RICH
75
76
 
@@ -192,8 +193,10 @@ def _cmd_backup_inner(args, config, claude_dir, quiet) -> int:
192
193
  # Upsert into index. Store rel_path with forward slashes so it
193
194
  # works directly with `git show <commit>:<path>` (which rejects
194
195
  # backslash separators on Windows). Path operations downstream
195
- # accept either separator.
196
- rel_path = sf.jsonl_path.relative_to(claude_dir).as_posix()
196
+ # accept either separator. ClaudePaths.rel() also survives a
197
+ # junction/symlink claude_dir, where a resolved-vs-raw prefix
198
+ # mismatch made bare relative_to() raise ValueError (#46).
199
+ rel_path = ClaudePaths.from_dir(claude_dir).rel(sf.jsonl_path)
197
200
  # Restore-verify gate (v0.3.16): only let a reappeared JSONL
198
201
  # clear an existing deleted_at if it's a genuine transcript
199
202
  # (>=1 parsed event). A stub / garbage file (event_count == 0,
@@ -454,9 +457,9 @@ def find_unbacked_sessions(conn, claude_dir, exclude=None):
454
457
  aren't in the index at all) -- i.e. sessions with un-backed-up changes.
455
458
 
456
459
  The single source of truth for "what isn't backed up", shared by the
457
- SessionStart hook's `_check` and (future) user-facing surfacing in
458
- ``csb status`` / ``csb list``. ``exclude`` is a set of full session ids to
459
- skip (e.g. the currently-active session, whose JSONL is mid-write).
460
+ SessionStart hook's `_check` and ``csb status``'s un-backed-up section
461
+ (and, eventually, ``csb list``). ``exclude`` is a set of full session ids
462
+ to skip (e.g. the currently-active session, whose JSONL is mid-write).
460
463
  """
461
464
  exclude = set(exclude or [])
462
465
  stale = []
@@ -961,8 +964,9 @@ def cmd_restore(args) -> int:
961
964
  #
962
965
  # Single source of truth for the file-level restore policy. Used by:
963
966
  # - cmd_restore -- the `csb restore <uuid>` command
964
- # - cmd_resume -- (v0.3.14+) prompts-to-restore when session is pruned
965
- # - cmd_view -- (future, #14 + #34 phase B) same when viewing pruned
967
+ # - cmd_resume -- prompts-to-restore when the session is pruned
968
+ # - cmd_view -- same when viewing a pruned session (#14 / #34)
969
+ # - cmd_distill -- same when distilling a pruned session (#12)
966
970
  #
967
971
  # Callers are responsible for resolving the UUID, finding `jsonl_path` (DB
968
972
  # row OR git-history fallback), and finding `commit` (parent-of-deletion).
@@ -1020,8 +1024,8 @@ def _restore_session(
1020
1024
  from the DB row OR via `git_find_jsonl_by_uuid` fallback.
1021
1025
  commit: commit-ish to restore from. Caller obtains via
1022
1026
  `git_find_deleted_file(claude_dir, jsonl_path)`.
1023
- jsonl_only: if True, restore only the main transcript (v0.3.11
1024
- behavior preserved behind this flag).
1027
+ jsonl_only: if True, restore only the main transcript JSONL
1028
+ (skip the session subtree and logger sidecars).
1025
1029
  force: if True, overwrite present on-disk files from git.
1026
1030
  Default behavior preserves on-disk content (idempotent;
1027
1031
  never clobbers local content with newer-than-git writes).
@@ -1211,8 +1215,11 @@ def _recreate_transcript_symlink(
1211
1215
  from dazzle_filekit import create_symlink
1212
1216
  except ImportError:
1213
1217
  return False
1214
- link_path = Path(claude_dir) / link_rel
1215
- target_abs = (Path(claude_dir) / "projects" / slug / f"{uuid}.jsonl").resolve()
1218
+ cp = ClaudePaths.from_dir(claude_dir)
1219
+ link_path = cp.abs_of(link_rel)
1220
+ # .resolve() stays: the is_symlink comparison below resolves the live
1221
+ # link's target, so both sides must share resolve semantics (#46).
1222
+ target_abs = cp.jsonl(slug, uuid).resolve()
1216
1223
  # Skip work if a correct symlink already exists (idempotent, no churn).
1217
1224
  try:
1218
1225
  if link_path.is_symlink() and Path(os.readlink(link_path)).resolve() == target_abs:
@@ -1442,11 +1449,7 @@ def _extract_slug_from_jsonl_path(jsonl_path: str) -> str:
1442
1449
  """
1443
1450
  if not jsonl_path:
1444
1451
  return ""
1445
- norm = jsonl_path.replace("\\", "/")
1446
- parts = norm.split("/")
1447
- if len(parts) >= 3 and parts[0] == "projects":
1448
- return parts[1]
1449
- return ""
1452
+ return ClaudePaths.parse_rel(jsonl_path).slug or ""
1450
1453
 
1451
1454
 
1452
1455
  def _categorize_restored_paths(
@@ -1652,7 +1655,7 @@ def cmd_search(args) -> int:
1652
1655
 
1653
1656
 
1654
1657
  def cmd_build_fts5(args) -> int:
1655
- """Build / refresh per-project FTS5 content indices (Phase 2 of #3).
1658
+ """Build / refresh per-project FTS5 content indices.
1656
1659
 
1657
1660
  Idempotent: by default only re-indexes sessions whose JSONL mtime
1658
1661
  has advanced past ``indexed_sessions.last_jsonl_mtime``. Use
@@ -1739,16 +1742,49 @@ def cmd_update(args) -> int:
1739
1742
 
1740
1743
 
1741
1744
  def _maybe_refresh_fts5(args) -> None:
1742
- """Stub seam for main's FTS5 refresh integration (post-merge).
1743
-
1744
- Per the v0.3.11 handoff
1745
- (private/claude/2026-06-02__14-14-02__handoff__...md), main will wire
1746
- the actual `csb update rebuild-index --include-fts5` and
1747
- `csb backup --refresh-fts5` work on the unified base after the
1748
- reverse-merge. This stub is the agreed-on hook point. Currently a
1749
- no-op so the flag plumbs through without effect.
1745
+ """Wipe + rebuild the per-project FTS5 content indexes after a
1746
+ `csb update rebuild-index --include-fts5` (#3, the last open AC).
1747
+
1748
+ Force-rebuild on purpose: rebuild-index is the nuclear
1749
+ reconstruct-everything verb, so the content indexes are rebuilt
1750
+ unconditionally too rather than mtime-gated (that incremental path
1751
+ is `csb update build-fts5` without --force).
1752
+
1753
+ Fails SOFT in every case: by the time this seam runs the main index
1754
+ rebuild has already succeeded, so a missing-FTS5 SQLite build or an
1755
+ indexing error downgrades to a stderr warning with the manual
1756
+ command, never a non-zero rebuild exit.
1757
+
1758
+ (Backup-time incremental FTS5 indexing -- the other half of the
1759
+ original #3 Phase 2 spec -- was REJECTED by design: it would add
1760
+ latency inside the PreCompact/SessionEnd hooks, and v0.3.22's
1761
+ search-time freshness rescue makes it unnecessary.)
1750
1762
  """
1751
- pass
1763
+ from . import fts5_db, fts5_index
1764
+
1765
+ quiet = getattr(args, "quiet", False)
1766
+ if not fts5_db.fts5_available():
1767
+ print(
1768
+ "Warning: this Python's SQLite lacks FTS5; skipped the "
1769
+ "--include-fts5 refresh (main index rebuild is intact).",
1770
+ file=sys.stderr,
1771
+ )
1772
+ return
1773
+ config = _get_config(args)
1774
+ conn = open_db(config["index_path"])
1775
+ init_schema(conn, quiet=quiet)
1776
+ try:
1777
+ fts5_index.build_all(
1778
+ conn, Path(config["claude_dir"]), force=True, quiet=quiet,
1779
+ )
1780
+ except Exception as e: # noqa: BLE001 -- secondary refresh must not fail the rebuild
1781
+ print(
1782
+ f"Warning: FTS5 refresh failed ({e}); main index rebuild is "
1783
+ f"intact. Run `csb update build-fts5 --force` manually.",
1784
+ file=sys.stderr,
1785
+ )
1786
+ finally:
1787
+ conn.close()
1752
1788
 
1753
1789
 
1754
1790
  def cmd_backfill_deleted(args) -> int:
@@ -1776,11 +1812,12 @@ def cmd_backfill_deleted(args) -> int:
1776
1812
 
1777
1813
  Flags:
1778
1814
  --dry-run -- preview without writing anything
1779
- --full -- (currently identical to default; incremental refresh
1780
- via the last_refreshed_at marker is a follow-on)
1815
+ --full -- accepted but not yet differentiated from the default
1816
+ run (the last_refreshed_at marker is recorded but not
1817
+ yet used as an incremental-skip gate)
1781
1818
 
1782
- Plan ref: private/claude/2026-06-02__15-46-56__claude-plan__safe-
1783
- update-umbrella-and-backfill-v0.3.11.md (Phase 4)
1819
+ Plan ref: 2026-06-02__15-46-56__claude-plan__safe-update-umbrella-
1820
+ and-backfill-v0.3.11.md
1784
1821
  """
1785
1822
  from .index import (
1786
1823
  count_git_deleted_jsonls,
@@ -1907,8 +1944,7 @@ def cmd_backfill_deleted(args) -> int:
1907
1944
  continue
1908
1945
 
1909
1946
  # Derive the project slug from the path: projects/<slug>/<uuid>.jsonl
1910
- parts = jp.split("/")
1911
- project = parts[1] if len(parts) >= 3 and parts[0] == "projects" else ""
1947
+ project = ClaudePaths.parse_rel(jp).slug or ""
1912
1948
 
1913
1949
  meta = extract_metadata_from_bytes(blob, session_id=sid, project=project)
1914
1950
  new_folder_count = len(meta.folder_usage)
@@ -2009,14 +2045,14 @@ def cmd_rebuild_index(args) -> int:
2009
2045
  deleted-session rows back in (skipping any UUIDs the live
2010
2046
  rescan already repopulated, which would mean the JSONL came
2011
2047
  back somehow).
2012
- 7. Optionally runs ``_maybe_refresh_fts5`` (stub on this branch;
2013
- main wires the real refresh post-merge) if ``--include-fts5``.
2048
+ 7. Optionally runs ``_maybe_refresh_fts5`` (force wipe + rebuild
2049
+ of the per-project FTS5 DBs, fail-soft) if ``--include-fts5``.
2014
2050
  8. Optionally chains ``cmd_backfill_deleted`` if
2015
2051
  ``--include-backfill-deleted`` is set.
2016
2052
  9. Removes the ``.bak`` on full success.
2017
2053
 
2018
- Plan ref: private/claude/2026-06-02__15-46-56__claude-plan__safe-
2019
- update-umbrella-and-backfill-v0.3.11.md (Phase 2)
2054
+ Plan ref: 2026-06-02__15-46-56__claude-plan__safe-update-umbrella-
2055
+ and-backfill-v0.3.11.md
2020
2056
  """
2021
2057
  config = _get_config(args)
2022
2058
  claude_dir = config["claude_dir"]
@@ -2089,11 +2125,12 @@ def cmd_rebuild_index(args) -> int:
2089
2125
  print(f"Preserved {restored} deleted-session {noun} "
2090
2126
  f"across rebuild")
2091
2127
 
2092
- # 5. Optional --include-fts5 (stub seam; main fills this in post-merge)
2128
+ # 5. Optional --include-fts5: wipe + rebuild per-project FTS5 DBs
2129
+ # against the freshly rebuilt index (fails soft -- see the helper).
2093
2130
  if getattr(args, "include_fts5", False):
2094
2131
  _maybe_refresh_fts5(args)
2095
2132
 
2096
- # 6. Optional --include-backfill-deleted (Phase 4 fills in cmd_backfill_deleted)
2133
+ # 6. Optional --include-backfill-deleted -- chain cmd_backfill_deleted
2097
2134
  if getattr(args, "include_backfill_deleted", False):
2098
2135
  cmd_backfill_deleted(args)
2099
2136
 
@@ -2448,19 +2485,27 @@ def _find_viewer(config) -> Optional[dict]:
2448
2485
  return None
2449
2486
 
2450
2487
 
2451
- def _launch_viewer(viewer: dict, session_value: str) -> int:
2488
+ def _passthrough_args(args) -> list:
2489
+ """The args after a standalone `--` (#47), forwarded verbatim to the
2490
+ wrapped subtool. Empty list when none were given."""
2491
+ return list(getattr(args, "passthrough", None) or [])
2492
+
2493
+
2494
+ def _launch_viewer(viewer: dict, session_value: str, passthrough: list = None) -> int:
2452
2495
  """Launch CCHV focused on ``session_value`` (a full session UUID).
2453
2496
 
2454
2497
  Binary mode launches DETACHED so the viewer outlives this shell.
2455
2498
  Dev mode runs ``pnpm tauri:dev`` in the foreground (build output
2456
- visible; Ctrl-C stops it).
2499
+ visible; Ctrl-C stops it). ``passthrough`` (anything after `--`, #47)
2500
+ is appended verbatim to the viewer's argv.
2457
2501
  """
2458
2502
  import platform as _platform
2459
2503
  import subprocess
2460
2504
 
2505
+ extra = list(passthrough or [])
2461
2506
  mode, path = viewer["mode"], viewer["path"]
2462
2507
  if mode == "dev":
2463
- cmd = ["pnpm", "tauri:dev", "--", "--", "--session", session_value]
2508
+ cmd = ["pnpm", "tauri:dev", "--", "--", "--session", session_value] + extra
2464
2509
  print(f"Launching in dev mode from: {path}")
2465
2510
  print(" (Vite + cargo run -- Ctrl-C to stop)")
2466
2511
  try:
@@ -2470,7 +2515,7 @@ def _launch_viewer(viewer: dict, session_value: str) -> int:
2470
2515
  print(" Is pnpm installed?", file=sys.stderr)
2471
2516
  return 1
2472
2517
 
2473
- cmd = [path, "--session", session_value]
2518
+ cmd = [path, "--session", session_value] + extra
2474
2519
  try:
2475
2520
  if _platform.system() == "Windows":
2476
2521
  subprocess.Popen(
@@ -2696,7 +2741,7 @@ def cmd_view(args) -> int:
2696
2741
  print(" - install: https://github.com/jhlee0409/claude-code-history-viewer")
2697
2742
  return 0
2698
2743
 
2699
- return _launch_viewer(viewer, full_id)
2744
+ return _launch_viewer(viewer, full_id, _passthrough_args(args))
2700
2745
 
2701
2746
 
2702
2747
  # ── csb distill (#12): human-readable chat-log rendering ────────────────────
@@ -2714,7 +2759,7 @@ def _distill_canonical_path(claude_dir: str, session: dict) -> Path:
2714
2759
  jsonl_rel = session.get("jsonl_path") or ""
2715
2760
  slug = (Path(jsonl_rel).parent.name if jsonl_rel
2716
2761
  else (session.get("project") or "unknown"))
2717
- return Path(claude_dir) / "distilled" / slug / f"{session['session_id']}.md"
2762
+ return ClaudePaths.from_dir(claude_dir).distilled_md(slug, session["session_id"])
2718
2763
 
2719
2764
 
2720
2765
  def _safe_stdout_write(text: str) -> None:
@@ -2740,7 +2785,7 @@ def _render_session_distill(
2740
2785
 
2741
2786
  full_id = session["session_id"]
2742
2787
  jsonl_rel = session.get("jsonl_path") or ""
2743
- jsonl_abs = Path(claude_dir) / jsonl_rel if jsonl_rel else None
2788
+ jsonl_abs = ClaudePaths.from_dir(claude_dir).abs_of(jsonl_rel) if jsonl_rel else None
2744
2789
  convo_type, convo_path, tool_paths = pick_channels(
2745
2790
  src_rows, jsonl_abs, source_override,
2746
2791
  )
@@ -3132,11 +3177,16 @@ def cmd_resume(args) -> int:
3132
3177
  if target is None:
3133
3178
  target = session.get("start_folder")
3134
3179
 
3180
+ # Forward anything after `--` straight to claude (#47), e.g.
3181
+ # `csb resume <name> -- --fork-session`.
3182
+ claude_cmd = ["claude", "--resume", full_id] + _passthrough_args(args)
3183
+ launch_str = " ".join(claude_cmd)
3184
+
3135
3185
  print(f"Resuming: {name}")
3136
3186
  print(f" ID: {full_id}")
3137
3187
  if target:
3138
3188
  print(f" cd {target}")
3139
- print(f" claude --resume {full_id}")
3189
+ print(f" {launch_str}")
3140
3190
  print()
3141
3191
 
3142
3192
  # Launch claude --resume as a child process. We use subprocess.run with
@@ -3153,7 +3203,7 @@ def cmd_resume(args) -> int:
3153
3203
  import subprocess
3154
3204
  try:
3155
3205
  result = subprocess.run(
3156
- ["claude", "--resume", full_id],
3206
+ claude_cmd,
3157
3207
  cwd=target if target else None,
3158
3208
  check=False,
3159
3209
  )
@@ -3166,10 +3216,10 @@ def cmd_resume(args) -> int:
3166
3216
  if target and not os.path.isdir(target):
3167
3217
  print(f"Error: cannot cd to {target}: {e}", file=sys.stderr)
3168
3218
  print("The folder may have been deleted. Run manually:", file=sys.stderr)
3169
- print(f" cd <correct-folder> && claude --resume {full_id}", file=sys.stderr)
3219
+ print(f" cd <correct-folder> && {launch_str}", file=sys.stderr)
3170
3220
  return 1
3171
3221
  print("Error: 'claude' command not found in PATH.", file=sys.stderr)
3172
- print(f"Run manually: claude --resume {full_id}", file=sys.stderr)
3222
+ print(f"Run manually: {launch_str}", file=sys.stderr)
3173
3223
  return 1
3174
3224
  except NotADirectoryError as e:
3175
3225
  # Edge case: target exists but isn't a directory (file with same name).
@@ -3701,7 +3751,7 @@ def _bulk_restore_jsonls(results, args, config, scope_label: str, quiet: bool) -
3701
3751
  skipped = 0
3702
3752
  failed = 0
3703
3753
  for s, jsonl_rel in candidates:
3704
- full_path = Path(claude_dir) / jsonl_rel
3754
+ full_path = ClaudePaths.from_dir(claude_dir).abs_of(jsonl_rel)
3705
3755
  if full_path.exists() and not force:
3706
3756
  print(f" SKIP {s['session_id'][:8]} {jsonl_rel} "
3707
3757
  f"(already exists; use --force to overwrite)")
@@ -8,6 +8,8 @@ import json
8
8
  import os
9
9
  from pathlib import Path
10
10
 
11
+ from .pathkit import ClaudePaths
12
+
11
13
  DEFAULT_CONFIG = {
12
14
  "claude_dir": "~/.claude",
13
15
  "index_path": "~/.claude/session-backup.db",
@@ -52,7 +54,7 @@ ENV_CLAUDE_DIR = "CLAUDE_DIR"
52
54
  ENV_CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR"
53
55
  ENV_DB_PATH = "CLAUDE_SESSION_BACKUP_DB"
54
56
 
55
- CONFIG_FILENAME = "session-backup-config.json"
57
+ CONFIG_FILENAME = ClaudePaths.CONFIG_FILE # canonical spelling lives in pathkit (#46)
56
58
 
57
59
 
58
60
  def _env_claude_dir():
@@ -90,7 +92,7 @@ def get_settings_path(claude_dir=None):
90
92
  can edit the TTL through it.
91
93
  """
92
94
  base = Path(claude_dir).expanduser() if claude_dir else _default_claude_dir()
93
- return base / "settings.json"
95
+ return base / ClaudePaths.SETTINGS_FILE
94
96
 
95
97
 
96
98
  def load_config(claude_dir=None):
@@ -132,10 +134,15 @@ def load_config(claude_dir=None):
132
134
  # (#45): when index_path is still the stock default but claude_dir
133
135
  # was relocated (flag / CLAUDE_DIR / CLAUDE_CONFIG_DIR / config),
134
136
  # follow the relocation -- otherwise sessions would scan from the
135
- # new dir while the index silently pinned to ~/.claude.
136
- if config["index_path"] == DEFAULT_CONFIG["index_path"]:
137
+ # new dir while the index silently pinned to ~/.claude. Compared
138
+ # Path-normalized (#46): a config file carrying the EXPANDED default
139
+ # spelling still counts as "stock default" and follows relocation.
140
+ stock_db = DEFAULT_CONFIG["index_path"]
141
+ if (config["index_path"] == stock_db
142
+ or Path(str(config["index_path"])).expanduser()
143
+ == Path(stock_db).expanduser()):
137
144
  config["index_path"] = str(
138
- Path(config["claude_dir"]).expanduser() / "session-backup.db"
145
+ Path(config["claude_dir"]).expanduser() / ClaudePaths.DEFAULT_DB
139
146
  )
140
147
 
141
148
  return config
@@ -1,5 +1,5 @@
1
1
  """
2
- Per-project SQLite FTS5 content database (Phase 2 of #3).
2
+ Per-project SQLite FTS5 content database.
3
3
 
4
4
  One DB per project at ``~/.claude/csb-fts/<project>__<slug-hash>_<USER>.db``
5
5
  (path convention locked in :mod:`fts_paths`). Each DB is self-contained --
@@ -9,12 +9,12 @@ main DB tracks WHICH sessions have been indexed via the already-reserved
9
9
  per-project FTS5 DB independently tracks the same fact via its own
10
10
  ``indexed_sessions`` table (source of truth for "did this row land?").
11
11
 
12
- Why per-project (not one monolithic vault like claude-vault): smaller
13
- files, faster targeted queries, per-project archive/move/delete, no
14
- contention when multiple projects refresh in parallel. Locked at v0.2.5
15
- in :mod:`fts_paths`.
12
+ Why per-project (rather than one monolithic content store): smaller
13
+ files, faster targeted queries, per-project archive/move/delete, and no
14
+ contention when multiple projects refresh in parallel. The path
15
+ convention lives in :mod:`fts_paths`.
16
16
 
17
- Schema (mirrors claude-vault's pattern at ``db.rs``)::
17
+ Schema::
18
18
 
19
19
  messages(id PK, session_id, uuid, message_index, role,
20
20
  role_subtype, content, timestamp,
@@ -91,9 +91,9 @@ CREATE TABLE IF NOT EXISTS indexed_sessions (
91
91
  --
92
92
  -- `strength` column (v0.3.1): per-row importance weight assigned at
93
93
  -- import time. 3 = wrote/edited/notebook_edit (active modification),
94
- -- 2 = read (passive view), 1 = searched (Grep probe). Used by future
95
- -- ranking queries (`csb search -d` directory-scope mode). Default 2
96
- -- so legacy rows pre-migration land on the median.
94
+ -- 2 = read (passive view), 1 = searched (Grep probe). Used by
95
+ -- `csb search -d/-D` directory-scope ranking. Default 2 so legacy
96
+ -- rows pre-migration land on the median.
97
97
  CREATE TABLE IF NOT EXISTS file_operations (
98
98
  id INTEGER PRIMARY KEY AUTOINCREMENT,
99
99
  session_id TEXT NOT NULL,
@@ -1,5 +1,5 @@
1
1
  """
2
- JSONL -> FTS5 ingest (Phase 2 of #3; refactored for v0.3.1 parity).
2
+ JSONL -> FTS5 ingest.
3
3
 
4
4
  This module is now a thin shim over :mod:`transcript_walker`. The shared
5
5
  walker yields ``ImportRow`` + ``FileOpRow`` instances; we insert them
@@ -1,5 +1,5 @@
1
1
  """
2
- FTS5 build orchestrator (Phase 2 of #3).
2
+ FTS5 build orchestrator.
3
3
 
4
4
  Walks the main DB's ``sessions`` + ``session_sources`` tables to
5
5
  discover candidate sessions, opens the per-project FTS5 DB for each,
@@ -10,15 +10,17 @@ session import.
10
10
 
11
11
  The orchestrator is intentionally explicit -- no hidden background
12
12
  indexing, no triggers in the main DB. ``csb backup`` does NOT call
13
- this (would slow the hook). Users opt in via ``csb update build-fts5`` or
14
- the future ``csb backup --refresh-fts5`` flag.
13
+ this (would slow the hook). Users opt in via ``csb update build-fts5``,
14
+ or ``csb update rebuild-index --include-fts5`` to refresh alongside an
15
+ index rebuild.
15
16
 
16
17
  Source of truth (per design doc):
17
18
  - Per-project FTS5 DB's ``indexed_sessions`` table: authoritative
18
19
  "is this session in the index, at what mtime"
19
20
  - Main DB's ``session_sources.fts5_indexed_at`` / ``content_hash``:
20
- HINT columns (kept in sync as a UX nicety; not consulted by the
21
- runtime search dispatcher in v0.3.1)
21
+ HINT columns kept in sync for inspection; the search dispatcher
22
+ checks freshness against the per-project DB's ``indexed_sessions``,
23
+ not these columns.
22
24
  """
23
25
 
24
26
  from __future__ import annotations