cc-session-tools 3.3.2__tar.gz → 3.5.2__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 (282) hide show
  1. {cc_session_tools-3.3.2/src/cc_session_tools.egg-info → cc_session_tools-3.5.2}/PKG-INFO +4 -4
  2. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/README.md +3 -3
  3. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/pyproject.toml +11 -2
  4. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/ccst.py +51 -3
  5. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/doctor.py +32 -4
  6. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/resolve.py +1 -1
  7. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/ledger.py +1 -1
  8. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/telemetry_store.py +4 -4
  9. cc_session_tools-3.5.2/src/cc_session_tools/skills/extract-pptx/SKILL.md +111 -0
  10. cc_session_tools-3.5.2/src/cc_session_tools/skills/extract-pptx/scripts/extract_pptx.py +219 -0
  11. cc_session_tools-3.5.2/src/cc_session_tools/skills/extract-to-text/SKILL.md +112 -0
  12. cc_session_tools-3.5.2/src/cc_session_tools/skills/extract-to-text/scripts/extract_to_text.py +367 -0
  13. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-pdata-do-init/SKILL.md +11 -11
  14. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-pdata-resolve-conflicts/SKILL.md +13 -13
  15. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/update-command-cache/SKILL.md +4 -4
  16. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/update-command-cache/scripts/update_command_cache.py +5 -5
  17. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2/src/cc_session_tools.egg-info}/PKG-INFO +4 -4
  18. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools.egg-info/SOURCES.txt +4 -0
  19. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/bash_hard_deny.py +4 -4
  20. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/bash_security_review.py +7 -7
  21. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/cache.py +2 -2
  22. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/catchup.py +3 -3
  23. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/confirm_8digit.py +23 -14
  24. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/markers.py +3 -3
  25. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/pdata_sync.py +3 -3
  26. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/telemetry.py +1 -1
  27. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/telemetry_query.py +1 -1
  28. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/telemetry_trim.py +1 -1
  29. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/worklog_guard.py +3 -3
  30. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_bash_hard_deny.py +12 -12
  31. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_bash_security_review.py +29 -29
  32. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cache_sqlite.py +4 -4
  33. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_doctor.py +95 -0
  34. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_install_everything.py +19 -0
  35. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_sync_cli.py +1 -1
  36. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_skills_install.py +68 -0
  37. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_confirm_8digit.py +130 -64
  38. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_marker_allow.py +1 -1
  39. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_migrate_telemetry.py +2 -2
  40. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_telemetry.py +1 -1
  41. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_telemetry_store.py +3 -3
  42. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_worklog_guard.py +5 -5
  43. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/LICENSE +0 -0
  44. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/setup.cfg +0 -0
  45. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/__init__.py +0 -0
  46. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/__init__.py +0 -0
  47. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/ccd.py +0 -0
  48. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/ccmsg.py +0 -0
  49. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/ccr.py +0 -0
  50. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/ccs.py +0 -0
  51. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/ccsched.py +0 -0
  52. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/migrate_ccmsg.py +0 -0
  53. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/migrate_ccsched.py +0 -0
  54. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/migrate_sessions_db.py +0 -0
  55. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/cli/migrate_telemetry.py +0 -0
  56. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/config/hooks-bundle.json +0 -0
  57. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/config/session-tag-hook.json +0 -0
  58. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/hooks_install.py +0 -0
  59. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/__init__.py +0 -0
  60. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/claude_flags.py +0 -0
  61. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/claude_md_install.py +0 -0
  62. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/context_overrides.py +0 -0
  63. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/db.py +0 -0
  64. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/debug.py +0 -0
  65. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/doctor_mutes.py +0 -0
  66. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/hook_registry.py +0 -0
  67. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/install_sync.py +0 -0
  68. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/levenshtein.py +0 -0
  69. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/machine_identity.py +0 -0
  70. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/__init__.py +0 -0
  71. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/addressing.py +0 -0
  72. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/cursor.py +0 -0
  73. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/lock.py +0 -0
  74. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/message.py +0 -0
  75. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/move_safety.py +0 -0
  76. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/repository.py +0 -0
  77. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/retention.py +0 -0
  78. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/service.py +0 -0
  79. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/messaging/store.py +0 -0
  80. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/occupancy.py +0 -0
  81. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/paths.py +0 -0
  82. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/__init__.py +0 -0
  83. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/backup.py +0 -0
  84. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/classify.py +0 -0
  85. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/cutover.py +0 -0
  86. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/dump.py +0 -0
  87. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/formatting.py +0 -0
  88. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/importers.py +0 -0
  89. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/init_paths.py +0 -0
  90. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/init_service.py +0 -0
  91. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/manifest.py +0 -0
  92. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/naming.py +0 -0
  93. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/rehydrate.py +0 -0
  94. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/rename_group.py +0 -0
  95. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/reorganize.py +0 -0
  96. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/repository.py +0 -0
  97. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/service.py +0 -0
  98. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/session_output.py +0 -0
  99. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/store.py +0 -0
  100. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/sync_check.py +0 -0
  101. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/sync_lock.py +0 -0
  102. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/sync_notify.py +0 -0
  103. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/vector_clock.py +0 -0
  104. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/vector_clock_store.py +0 -0
  105. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/verify.py +0 -0
  106. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/pdata/write_log.py +0 -0
  107. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/picker.py +0 -0
  108. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/proc_lock.py +0 -0
  109. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/prompts.py +0 -0
  110. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/roots.py +0 -0
  111. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/rules.py +0 -0
  112. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/__init__.py +0 -0
  113. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/bundled_jobs.py +0 -0
  114. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/cadence.py +0 -0
  115. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/cursor.py +0 -0
  116. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/digest.py +0 -0
  117. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/due.py +0 -0
  118. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/duration.py +0 -0
  119. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/jobspec.py +0 -0
  120. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/lock.py +0 -0
  121. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/notify.py +0 -0
  122. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/reconcile.py +0 -0
  123. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/registry.py +0 -0
  124. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/runner.py +0 -0
  125. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/state.py +0 -0
  126. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/store.py +0 -0
  127. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/surface.py +0 -0
  128. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/throttle.py +0 -0
  129. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/scheduler/worker.py +0 -0
  130. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/session_gc.py +0 -0
  131. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/sessions.py +0 -0
  132. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/sessions_db.py +0 -0
  133. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/sessions_repair.py +0 -0
  134. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/shell_install.py +0 -0
  135. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/lib/tasklist.py +0 -0
  136. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/prompts/pdata-migration-claude-md-update.md +0 -0
  137. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/prompts/pdata-migration-skills-update.md +0 -0
  138. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/analyse-cc-usage/SKILL.md +0 -0
  139. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/clean-hook-sessions/SKILL.md +0 -0
  140. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/clean-hook-sessions/scripts/clean-hook-sessions.py +0 -0
  141. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/compact-smart/SKILL.md +0 -0
  142. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/context-override/SKILL.md +0 -0
  143. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/delete-sessions/SKILL.md +0 -0
  144. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/delete-sessions/scripts/delete_sessions.py +0 -0
  145. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/do-executor-critic-assessor-loop/SKILL.md +0 -0
  146. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/find-claude-code-session/SKILL.md +0 -0
  147. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/generate-8digit-code/SKILL.md +0 -0
  148. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/generate-8digit-code/scripts/generate_8digit_code.py +0 -0
  149. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/list-empty-sessions/SKILL.md +0 -0
  150. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/list-empty-sessions/scripts/list_empty_sessions.py +0 -0
  151. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/manage-recurring-cc-jobs-using-ccsched/SKILL.md +0 -0
  152. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/move-session/SKILL.md +0 -0
  153. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/move-session/hooks/sessionstart-pending-rename.sh +0 -0
  154. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/move-session/scripts/cc_session_rules.py +0 -0
  155. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/move-session/scripts/move_session.py +0 -0
  156. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-pdata-design-schema/SKILL.md +0 -0
  157. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-pdata-do-audit-and-prepare-to-migrate/SKILL.md +0 -0
  158. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-pdata-do-manual-migration/SKILL.md +0 -0
  159. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-project-layout-reference/SKILL.md +0 -0
  160. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/pm-update-central-files/SKILL.md +0 -0
  161. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/CALIBRATION.md +0 -0
  162. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/SKILL.md +0 -0
  163. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/analyze_context.py +0 -0
  164. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/duplicates.py +0 -0
  165. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/measure.py +0 -0
  166. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/report.py +0 -0
  167. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/tokens.py +0 -0
  168. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/reduce-persistent-context/scripts/usage.py +0 -0
  169. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/select-agent-model/SKILL.md +0 -0
  170. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools/skills/send-session-message/SKILL.md +0 -0
  171. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools.egg-info/dependency_links.txt +0 -0
  172. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools.egg-info/entry_points.txt +0 -0
  173. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools.egg-info/requires.txt +0 -0
  174. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/cc_session_tools.egg-info/top_level.txt +0 -0
  175. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/__init__.py +0 -0
  176. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/attribution.py +0 -0
  177. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/cache.py +0 -0
  178. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/ccusage_wrapper.py +0 -0
  179. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/cli.py +0 -0
  180. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/data/pricing.json +0 -0
  181. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/parent_inference.py +0 -0
  182. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/parser.py +0 -0
  183. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/pricing.py +0 -0
  184. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/query.py +0 -0
  185. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/report.py +0 -0
  186. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/schema.py +0 -0
  187. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/claude_code_usage/session_names.py +0 -0
  188. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/__init__.py +0 -0
  189. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/after_response.py +0 -0
  190. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/context_window_warning.py +0 -0
  191. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/last_screenshot.py +0 -0
  192. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/marker_allow.py +0 -0
  193. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/messaging_deliver.py +0 -0
  194. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/model_info.py +0 -0
  195. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/normalise.py +0 -0
  196. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/pending_migration.py +0 -0
  197. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/pending_rename.py +0 -0
  198. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/session_tag.py +0 -0
  199. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/stats.py +0 -0
  200. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/src/hooks/transcript.py +0 -0
  201. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_after_response.py +0 -0
  202. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_attribution.py +0 -0
  203. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cache.py +0 -0
  204. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccr_orphans.py +0 -0
  205. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccs_emptiness.py +0 -0
  206. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccs_eta.py +0 -0
  207. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccs_list_mode.py +0 -0
  208. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccs_scope_flags.py +0 -0
  209. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccs_sentinel_sort.py +0 -0
  210. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccs_session_counts.py +0 -0
  211. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_bundle_discovery.py +0 -0
  212. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_ccsched_jobs_cli.py +0 -0
  213. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_cli.py +0 -0
  214. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_context_override_cli.py +0 -0
  215. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_gc_prune.py +0 -0
  216. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_gc_report.py +0 -0
  217. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_hook_dispatcher.py +0 -0
  218. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_hooks_install_prune.py +0 -0
  219. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_hooks_install_zero_arg.py +0 -0
  220. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_hooks_uninstall.py +0 -0
  221. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_machine_identity_cli.py +0 -0
  222. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_main_gate.py +0 -0
  223. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_cli.py +0 -0
  224. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_init_cli.py +0 -0
  225. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_reconcile_cli.py +0 -0
  226. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_rename_group_cli.py +0 -0
  227. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_reorganize_cli.py +0 -0
  228. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_pdata_verify_cli.py +0 -0
  229. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_repair_sessions_cli.py +0 -0
  230. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_sessions_cli.py +0 -0
  231. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_shell_install.py +0 -0
  232. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_skills_uninstall.py +0 -0
  233. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_telemetry_query.py +0 -0
  234. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccst_telemetry_trim.py +0 -0
  235. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_ccusage_wrapper.py +0 -0
  236. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_claude_flags.py +0 -0
  237. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_claude_md_install.py +0 -0
  238. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cli.py +0 -0
  239. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cli_ccd.py +0 -0
  240. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cli_ccd_dry_run.py +0 -0
  241. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cli_ccr.py +0 -0
  242. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cli_ccs.py +0 -0
  243. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_cli_version.py +0 -0
  244. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_context_overrides.py +0 -0
  245. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_context_window_warning.py +0 -0
  246. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_db.py +0 -0
  247. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_debug.py +0 -0
  248. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_doctor_mutes.py +0 -0
  249. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_empty_session.py +0 -0
  250. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_hook_cache.py +0 -0
  251. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_hooks_install.py +0 -0
  252. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_install_sync.py +0 -0
  253. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_last_screenshot.py +0 -0
  254. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_levenshtein.py +0 -0
  255. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_machine_identity.py +0 -0
  256. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_migrate_ccmsg.py +0 -0
  257. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_migrate_ccsched.py +0 -0
  258. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_migrate_sessions_db.py +0 -0
  259. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_model_info.py +0 -0
  260. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_normalise.py +0 -0
  261. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_occupancy.py +0 -0
  262. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_parent_inference.py +0 -0
  263. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_parser.py +0 -0
  264. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_paths.py +0 -0
  265. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_pending_migration_hook.py +0 -0
  266. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_picker.py +0 -0
  267. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_pricing.py +0 -0
  268. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_proc_lock.py +0 -0
  269. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_prompts.py +0 -0
  270. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_query.py +0 -0
  271. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_report.py +0 -0
  272. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_roots.py +0 -0
  273. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_rules.py +0 -0
  274. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_scheduler_bundled_jobs.py +0 -0
  275. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_schema.py +0 -0
  276. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_session_names.py +0 -0
  277. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_session_tag.py +0 -0
  278. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_sessions.py +0 -0
  279. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_sessions_db.py +0 -0
  280. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_sessions_repair.py +0 -0
  281. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_tasklist.py +0 -0
  282. {cc_session_tools-3.3.2 → cc_session_tools-3.5.2}/tests/test_transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-tools
3
- Version: 3.3.2
3
+ Version: 3.5.2
4
4
  Summary: Claude Code session management CLI tools (ccd, ccr, ccs, claude-code-usage, ccst) and hook library (hooks package)
5
5
  Author: raffishquartan
6
6
  License: MIT License
@@ -569,7 +569,7 @@ Translates a natural-language cadence request ("run my Tesco shop every other Su
569
569
 
570
570
  ### `update-command-cache`
571
571
 
572
- Curates the SHA-256 command cache the `bash-security-review` hook reads (`CCCS_USE_COMMAND_CACHE=1`). Triggers on "update the command cache", "sweep the fires log for cacheable commands", "promote my recent claude-CLI safe fires". Reads recent `safe`-verdict fires from `telemetry.db`, identifies commands not yet cached, presents them for approval, and records approved ones. Also supports manual `--remove`/`--flip` on existing entries.
572
+ Curates the SHA-256 command cache the `bash-security-review` hook reads (`CCST_USE_COMMAND_CACHE=1`). Triggers on "update the command cache", "sweep the fires log for cacheable commands", "promote my recent claude-CLI safe fires". Reads recent `safe`-verdict fires from `telemetry.db`, identifies commands not yet cached, presents them for approval, and records approved ones. Also supports manual `--remove`/`--flip` on existing entries.
573
573
 
574
574
  ### `clean-hook-sessions`
575
575
 
@@ -679,7 +679,7 @@ Both hooks are included in the standard bundle and installed by
679
679
  `ccst hooks install --apply`. Surfacing is per-session (per-session cursor), so
680
680
  each session sees each completed run exactly once. Failures never block the
681
681
  session; every action is recorded to the shared `telemetry.db` (SQLite, under
682
- `~/.local/share/claude/`, overridable via `CCCS_HOOKS_DIR`) telemetry ledger —
682
+ `~/.local/share/claude/`, overridable via `CCST_HOOKS_DIR`) telemetry ledger —
683
683
  query it with `ccst telemetry query`.
684
684
 
685
685
  ### `manage-recurring-cc-jobs-using-ccsched` skill
@@ -840,7 +840,7 @@ real risk, which is why this hook blocks rather than just warning like
840
840
  - **Only fires if a WORKLOG.md already exists** - it never forces you to
841
841
  create one, and never blocks automatic (non-`/compact`) compaction, which
842
842
  can happen mid-task in an unattended session with nobody present to react.
843
- - **Escape hatch:** set `CCCS_ALLOW_STALE_WORKLOG=1` to bypass the block for
843
+ - **Escape hatch:** set `CCST_ALLOW_STALE_WORKLOG=1` to bypass the block for
844
844
  one `/compact`.
845
845
 
846
846
  ### Running modules directly (debugging only)
@@ -512,7 +512,7 @@ Translates a natural-language cadence request ("run my Tesco shop every other Su
512
512
 
513
513
  ### `update-command-cache`
514
514
 
515
- Curates the SHA-256 command cache the `bash-security-review` hook reads (`CCCS_USE_COMMAND_CACHE=1`). Triggers on "update the command cache", "sweep the fires log for cacheable commands", "promote my recent claude-CLI safe fires". Reads recent `safe`-verdict fires from `telemetry.db`, identifies commands not yet cached, presents them for approval, and records approved ones. Also supports manual `--remove`/`--flip` on existing entries.
515
+ Curates the SHA-256 command cache the `bash-security-review` hook reads (`CCST_USE_COMMAND_CACHE=1`). Triggers on "update the command cache", "sweep the fires log for cacheable commands", "promote my recent claude-CLI safe fires". Reads recent `safe`-verdict fires from `telemetry.db`, identifies commands not yet cached, presents them for approval, and records approved ones. Also supports manual `--remove`/`--flip` on existing entries.
516
516
 
517
517
  ### `clean-hook-sessions`
518
518
 
@@ -622,7 +622,7 @@ Both hooks are included in the standard bundle and installed by
622
622
  `ccst hooks install --apply`. Surfacing is per-session (per-session cursor), so
623
623
  each session sees each completed run exactly once. Failures never block the
624
624
  session; every action is recorded to the shared `telemetry.db` (SQLite, under
625
- `~/.local/share/claude/`, overridable via `CCCS_HOOKS_DIR`) telemetry ledger —
625
+ `~/.local/share/claude/`, overridable via `CCST_HOOKS_DIR`) telemetry ledger —
626
626
  query it with `ccst telemetry query`.
627
627
 
628
628
  ### `manage-recurring-cc-jobs-using-ccsched` skill
@@ -783,7 +783,7 @@ real risk, which is why this hook blocks rather than just warning like
783
783
  - **Only fires if a WORKLOG.md already exists** - it never forces you to
784
784
  create one, and never blocks automatic (non-`/compact`) compaction, which
785
785
  can happen mid-task in an unattended session with nobody present to react.
786
- - **Escape hatch:** set `CCCS_ALLOW_STALE_WORKLOG=1` to bypass the block for
786
+ - **Escape hatch:** set `CCST_ALLOW_STALE_WORKLOG=1` to bypass the block for
787
787
  one `/compact`.
788
788
 
789
789
  ### Running modules directly (debugging only)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cc-session-tools"
7
- version = "3.3.2"
7
+ version = "3.5.2"
8
8
  description = "Claude Code session management CLI tools (ccd, ccr, ccs, claude-code-usage, ccst) and hook library (hooks package)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -72,7 +72,7 @@ cc_session_tools = ["skills/**/*", "config/*", "prompts/**/*"]
72
72
  cc_session_tools = ["**/tests/*", "**/__pycache__/*"]
73
73
 
74
74
  [tool.pytest.ini_options]
75
- testpaths = ["tests", "tests/scheduler", "src/cc_session_tools/skills/move-session/tests", "src/cc_session_tools/skills/list-empty-sessions/tests", "src/cc_session_tools/skills/delete-sessions/tests", "src/cc_session_tools/skills/generate-8digit-code/tests", "src/cc_session_tools/skills/reduce-persistent-context/tests", "src/cc_session_tools/skills/update-command-cache/tests"]
75
+ testpaths = ["tests", "tests/scheduler", "src/cc_session_tools/skills/move-session/tests", "src/cc_session_tools/skills/list-empty-sessions/tests", "src/cc_session_tools/skills/delete-sessions/tests", "src/cc_session_tools/skills/generate-8digit-code/tests", "src/cc_session_tools/skills/reduce-persistent-context/tests", "src/cc_session_tools/skills/update-command-cache/tests", "src/cc_session_tools/skills/extract-pptx/tests", "src/cc_session_tools/skills/extract-to-text/tests"]
76
76
  addopts = "-q"
77
77
  pythonpath = ["src"]
78
78
 
@@ -82,8 +82,17 @@ dev = [
82
82
  "pytest>=7",
83
83
  "pytest-mock>=3.12",
84
84
  "types-PyYAML>=6.0",
85
+ "python-pptx>=1.0",
86
+ "extract-msg>=0.45",
87
+ "pillow>=10.0",
88
+ "pytesseract>=0.3",
89
+ "types-openpyxl>=3.1",
85
90
  ]
86
91
 
87
92
  [[tool.mypy.overrides]]
88
93
  module = "tiktoken_tools.*"
89
94
  ignore_missing_imports = true
95
+
96
+ [[tool.mypy.overrides]]
97
+ module = "pytesseract.*"
98
+ ignore_missing_imports = true
@@ -96,6 +96,7 @@ import importlib
96
96
  import json
97
97
  import os
98
98
  import sys
99
+ from dataclasses import dataclass
99
100
  from enum import Enum
100
101
  from pathlib import Path
101
102
  from typing import TYPE_CHECKING, Any, TextIO, cast
@@ -239,6 +240,37 @@ def _decide_action(skill_src: Path, target_dir: Path) -> tuple[SkillAction, Path
239
240
  return SkillAction.NON_SYMLINK_EXISTS, dest
240
241
 
241
242
 
243
+ @dataclass(frozen=True)
244
+ class StaleSkillSymlink:
245
+ dest: Path
246
+ target: Path
247
+
248
+
249
+ def _find_stale_skill_symlinks(source_dir: Path, target_dir: Path) -> list[StaleSkillSymlink]:
250
+ """Return every symlink under target_dir left behind by a rename or removal from
251
+ source_dir: dangling (its target no longer exists) AND created by a previous install from
252
+ source_dir (its stored target's parent resolves to source_dir). `ccst skills install` only
253
+ ever creates symlinks for skills currently in source_dir, so a name it no longer bundles is
254
+ never revisited by the create/repoint loop above — this is the matching subtractive pass.
255
+
256
+ A dangling symlink pointing somewhere other than source_dir is not ours to remove (e.g. a
257
+ user's own broken symlink), and neither is a non-symlink entry — both are left untouched.
258
+ """
259
+ if not target_dir.is_dir():
260
+ return []
261
+ resolved_source = source_dir.resolve()
262
+ stale: list[StaleSkillSymlink] = []
263
+ for entry in sorted(target_dir.iterdir()):
264
+ if not entry.is_symlink() or entry.exists():
265
+ continue
266
+ raw_target = Path(os.readlink(entry))
267
+ target = raw_target if raw_target.is_absolute() else (entry.parent / raw_target)
268
+ if target.parent.resolve() != resolved_source:
269
+ continue
270
+ stale.append(StaleSkillSymlink(dest=entry, target=target))
271
+ return stale
272
+
273
+
242
274
  def _cmd_skills_install(args: argparse.Namespace) -> int:
243
275
  # Resolve source
244
276
  if args.source:
@@ -273,8 +305,16 @@ def _cmd_skills_install(args: argparse.Namespace) -> int:
273
305
  for action, skill_src, dest in decisions:
274
306
  print(f"{skill_src.name:<{col_w}} {action.value}")
275
307
 
308
+ stale = _find_stale_skill_symlinks(source_dir, target_dir)
309
+ if stale:
310
+ print("\nStale symlink(s) (skill no longer bundled):")
311
+ for entry in stale:
312
+ print(f" {entry.dest.name} -> {entry.target}")
313
+
276
314
  if not args.apply:
277
315
  print(f"\nDry run — re-run with --apply to create symlinks in {target_dir}")
316
+ if stale:
317
+ print(f"({len(stale)} stale symlink(s) above would also be removed)")
278
318
  return 0
279
319
 
280
320
  # Perform writes
@@ -315,12 +355,20 @@ def _cmd_skills_install(args: argparse.Namespace) -> int:
315
355
  linked.append(dest)
316
356
  print(f" linked: {dest} -> {skill_src}")
317
357
 
358
+ pruned: list[Path] = []
359
+ for entry in stale:
360
+ entry.dest.unlink()
361
+ pruned.append(entry.dest)
362
+ print(f" removed stale: {entry.dest}")
363
+
318
364
  print()
319
365
  if linked:
320
366
  print(f"Linked {len(linked)} skill(s) in {target_dir}")
321
367
  if skipped:
322
368
  print(f"Skipped {len(skipped)} (already correct)")
323
- if not linked and not skipped and not failed:
369
+ if pruned:
370
+ print(f"Removed {len(pruned)} stale symlink(s) no longer bundled")
371
+ if not linked and not skipped and not pruned and not failed:
324
372
  print(f"Nothing to do in {target_dir}")
325
373
 
326
374
  if failed:
@@ -2757,7 +2805,7 @@ def _build_parser() -> argparse.ArgumentParser:
2757
2805
  "--hooks-dir",
2758
2806
  default=None,
2759
2807
  metavar="DIR",
2760
- help="telemetry.db directory (default: CCCS_HOOKS_DIR or ~/.local/share/claude/)",
2808
+ help="telemetry.db directory (default: CCST_HOOKS_DIR or ~/.local/share/claude/)",
2761
2809
  )
2762
2810
 
2763
2811
  telemetry_query_parser = telemetry_sub.add_parser(
@@ -2784,7 +2832,7 @@ def _build_parser() -> argparse.ArgumentParser:
2784
2832
  )
2785
2833
  telemetry_query_parser.add_argument(
2786
2834
  "--hooks-dir", default=None, metavar="DIR",
2787
- help="telemetry.db directory (default: CCCS_HOOKS_DIR or ~/.local/share/claude/)",
2835
+ help="telemetry.db directory (default: CCST_HOOKS_DIR or ~/.local/share/claude/)",
2788
2836
  )
2789
2837
 
2790
2838
  # ---- gc ----
@@ -14,6 +14,7 @@ import os
14
14
  import shutil
15
15
  import sqlite3
16
16
  import subprocess
17
+ import textwrap
17
18
  import time
18
19
  from dataclasses import dataclass
19
20
  from enum import Enum
@@ -1148,6 +1149,21 @@ def _extract_bundle_hook_names(bundle_path: Path) -> list[str]:
1148
1149
  return names
1149
1150
 
1150
1151
 
1152
+ def _wrap_reason(reason: str, *, indent: str = " ") -> str:
1153
+ """Word-wrap a check's reason to the detected terminal width.
1154
+
1155
+ Reasons are long free-text sentences (often 100-250+ characters, with
1156
+ embedded paths and remediation commands); printed on a single line they
1157
+ hard-wrap at the terminal edge with no continuation indent, making a
1158
+ wrapped reason indistinguishable from a new, unrelated result. Wrapping
1159
+ to the real terminal width — falling back to 80 columns for non-tty
1160
+ output (piped, redirected, or the scheduled drift-monitor job whose
1161
+ stdout is forwarded to Telegram) — and indenting every line fixes that.
1162
+ """
1163
+ width = shutil.get_terminal_size(fallback=(80, 24)).columns
1164
+ return textwrap.fill(reason, width=width, initial_indent=indent, subsequent_indent=indent)
1165
+
1166
+
1151
1167
  def format_results(results: list[CheckResult], *, show_all: bool = False) -> str:
1152
1168
  """Return a human-readable table of check results.
1153
1169
 
@@ -1156,15 +1172,27 @@ def format_results(results: list[CheckResult], *, show_all: bool = False) -> str
1156
1172
  otherwise dozens of [OK] lines a user has to scroll past to find the one
1157
1173
  thing that needs attention. show_all=True reproduces the full table this
1158
1174
  function always printed before this parameter existed.
1175
+
1176
+ A non-OK result renders as a two-line record — `[STATUS] name` on its own
1177
+ line, then the reason wrapped and indented below it — since its reason is
1178
+ the long free-text kind `_wrap_reason` exists for. OK rows (only ever
1179
+ shown via show_all) stay a single fixed-width `[OK] name reason` line;
1180
+ their reasons are short and there's nothing to disambiguate from a
1181
+ neighbouring result.
1159
1182
  """
1160
1183
  if not results:
1161
1184
  return "(no checks ran)"
1162
1185
  shown = results if show_all else [r for r in results if r.status is not Status.OK]
1163
1186
  lines = []
1164
1187
  if shown:
1165
- name_w = max(len(r.name) for r in shown)
1188
+ ok_shown = [r for r in shown if r.status is Status.OK]
1189
+ name_w = max((len(r.name) for r in ok_shown), default=0)
1166
1190
  for r in shown:
1167
- lines.append(f"[{r.status.value:<4}] {r.name:<{name_w}} {r.reason}")
1191
+ if r.status is Status.OK:
1192
+ lines.append(f"[{r.status.value:<4}] {r.name:<{name_w}} {r.reason}")
1193
+ else:
1194
+ lines.append(f"[{r.status.value:<4}] {r.name}")
1195
+ lines.append(_wrap_reason(r.reason))
1168
1196
  elif not show_all:
1169
1197
  lines.append(f"All {len(results)} checks OK.")
1170
1198
  has_issues = any(r.status in (Status.WARN, Status.FAIL) for r in results)
@@ -1201,10 +1229,10 @@ def format_drift_report(unmuted: list[CheckResult], *, muted_count: int) -> str:
1201
1229
  """
1202
1230
  if not unmuted:
1203
1231
  return ""
1204
- name_w = max(len(r.name) for r in unmuted)
1205
1232
  lines = ["ccst doctor: un-muted drift detected —"]
1206
1233
  for r in unmuted:
1207
- lines.append(f" [{r.status.value:<4}] {r.name:<{name_w}} {r.reason}")
1234
+ lines.append(f" [{r.status.value:<4}] {r.name}")
1235
+ lines.append(_wrap_reason(r.reason))
1208
1236
  lines.append("")
1209
1237
  lines.append(
1210
1238
  "Acknowledge an item with: ccst doctor --mute <name>"
@@ -7,7 +7,7 @@ overwrite (that is rehydrate.py's job for the clean fast-forward case; this modu
7
7
  `ccst pdata resolve` reaches for once vector_clock.compare() has already reported a genuine FORK).
8
8
 
9
9
  Never auto-merges, never silently keeps one side and discards the other — every record surfaced
10
- here needs an explicit choice from the caller (ultimately Chris, via the CLI/skill), matching the
10
+ here needs an explicit choice from the caller (ultimately the user, via the CLI/skill), matching the
11
11
  existing `pm-pdata-resolve-conflicts` skill's single-file-conflict framing exactly. Every
12
12
  record: `apply_resolution` is all-or-nothing over the whole diff, for the reasons in its own
13
13
  docstring.
@@ -50,7 +50,7 @@ class LedgerEntry:
50
50
 
51
51
 
52
52
  def _hooks_dir() -> Path:
53
- """The telemetry.db directory: the CCCS_HOOKS_DIR override when set,
53
+ """The telemetry.db directory: the CCST_HOOKS_DIR override when set,
54
54
  else telemetry_store's default. Kept as a thin wrapper so catchup.py's
55
55
  existing ``ledger._hooks_dir()`` call keeps working unchanged."""
56
56
  return telemetry_store.hooks_dir()
@@ -1,4 +1,4 @@
1
- """Shared schema, CCCS_HOOKS_DIR resolution, and connection helper for
1
+ """Shared schema, CCST_HOOKS_DIR resolution, and connection helper for
2
2
  telemetry.db. Single source of truth so hooks.telemetry (writer),
3
3
  hooks.telemetry_trim, hooks.telemetry_query, and
4
4
  lib.scheduler.ledger (catch-up reader/writer) can never point at different
@@ -35,7 +35,7 @@ from pathlib import Path
35
35
 
36
36
  from cc_session_tools.lib import db, paths
37
37
 
38
- HOOKS_DIR_ENV = "CCCS_HOOKS_DIR"
38
+ HOOKS_DIR_ENV = "CCST_HOOKS_DIR"
39
39
  DB_FILENAME = "telemetry.db"
40
40
 
41
41
  # Marker name recorded in the migrations table by cli.migrate_telemetry once
@@ -80,14 +80,14 @@ CREATE INDEX IF NOT EXISTS idx_catchup_events_job_id ON catchup_events(job_id);
80
80
 
81
81
  # Computed once at import time; tests override it with
82
82
  # monkeypatch.setattr(telemetry_store, "_DEFAULT_HOOKS_DIR", tmp_path) to
83
- # exercise the "CCCS_HOOKS_DIR unset" production-default path, matching the
83
+ # exercise the "CCST_HOOKS_DIR unset" production-default path, matching the
84
84
  # existing repo-wide convention for a module-level default.
85
85
  _DEFAULT_HOOKS_DIR = paths.data_home()
86
86
 
87
87
 
88
88
  def hooks_dir(explicit: Path | None = None) -> Path:
89
89
  """Resolve the telemetry.db directory: explicit override, else
90
- CCCS_HOOKS_DIR, else the module default."""
90
+ CCST_HOOKS_DIR, else the module default."""
91
91
  if explicit is not None:
92
92
  return explicit
93
93
  raw = os.environ.get(HOOKS_DIR_ENV)
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: extract-pptx
3
+ description: Use when text needs to be extracted from a .pptx PowerPoint file - slide text, tables, speaker notes, text baked into images (OCR), or embedded file objects (embedded workbooks/documents). Also use when a .pptx needs a sibling print-to-PDF. Triggers on "extract this pptx", "read this powerpoint", "pptx won't extract", "pandoc can't read pptx", empty/failed pptx mirrors in a text-extraction pipeline, or any pptx-to-markdown/pptx-to-pdf task. Do NOT use pandoc for pptx input - pandoc has a markdown writer but no pptx reader.
4
+ ---
5
+
6
+ # Extract PPTX
7
+
8
+ ## Why this exists
9
+
10
+ Pandoc has a markdown *writer* but no `.pptx` *input reader*. Any pipeline that
11
+ routes Office formats through `pandoc -t markdown` (a correct, common pattern
12
+ for preserving Word/RTF tables) silently or loudly fails on every `.pptx` file.
13
+ There is no pandoc flag that fixes this - it needs a different extractor
14
+ entirely. This skill wraps a `python-pptx`-based one. The sibling `extract-to-text`
15
+ skill delegates its own `.pptx`/`.pptm` handling to this script rather than
16
+ re-implementing pptx extraction.
17
+
18
+ ## When to use
19
+
20
+ - A `.pptx` file needs its text extracted and pandoc has failed, returned empty
21
+ output, or isn't an option.
22
+ - A text-extraction pipeline's `--check`/audit step shows `.pptx` mirrors as
23
+ empty, missing, or `FAILED`.
24
+ - Text is baked into an image on a slide (a chart export, a scanned page
25
+ pasted as a picture) and needs OCR to be readable.
26
+ - A slide has an embedded object (an embedded Excel sheet, a Word doc) whose
27
+ content needs to be pulled out, not just referenced.
28
+ - A clean, paginated PDF render of a `.pptx` is needed alongside the text
29
+ extraction (evidence packs, archival records).
30
+
31
+ **Do NOT use** `pandoc -t markdown <file>.pptx` - it will fail or silently
32
+ produce nothing useful. Do not hand-roll a second one-off pptx extractor
33
+ either; use or extend this one so behaviour stays consistent across projects.
34
+
35
+ ## How it works
36
+
37
+ `scripts/extract_pptx.py` opens the file with `python-pptx` and, per slide,
38
+ in order:
39
+
40
+ 1. Walks every shape (text frames, tables, and groups recursively) and
41
+ collects text in slide order. Table cells are extracted cell-by-cell, not
42
+ just shape text - this matters the same way it matters for docx tables
43
+ (see the sibling `extract-to-text` skill's docx-table-loss motivation).
44
+ 2. If OCR is enabled (default), runs Tesseract over every picture shape's
45
+ image bytes and appends the result labelled as OCR text, distinct from
46
+ slide body text.
47
+ 3. Extracts speaker notes if present, labelled distinctly from slide body
48
+ text.
49
+ 4. Extracts embedded OLE/package objects (embedded workbooks, documents)
50
+ referenced by the slide's relationships, if `--embeds-dir` is given.
51
+ 5. Optionally renders a sibling PDF via headless LibreOffice (`soffice`), if
52
+ `--pdf-out` is given.
53
+
54
+ ## Procedure
55
+
56
+ ```bash
57
+ python3 ~/.claude/skills/extract-pptx/scripts/extract_pptx.py <source.pptx> \
58
+ --out-md <output.md> \
59
+ [--embeds-dir <dir-for-embedded-files>] \
60
+ [--pdf-out <output.pdf>] \
61
+ [--no-ocr]
62
+ ```
63
+
64
+ - `--out-md` is required; the other three are opt-in.
65
+ - OCR is **on by default** and fails loudly (non-zero exit, clear message) if
66
+ `pytesseract`/Pillow or the `tesseract` binary is missing - it does not
67
+ silently skip OCR and produce an incomplete-looking-complete file. Pass
68
+ `--no-ocr` explicitly if OCR isn't wanted for this run.
69
+ - `--embeds-dir` is only written to if the slide actually has embedded
70
+ objects; it's never created empty.
71
+ - `--pdf-out` requires `soffice` (LibreOffice) on `PATH`. If it's missing the
72
+ script exits non-zero with a clear message rather than skipping the PDF
73
+ silently - report that to the user rather than treating the run as
74
+ complete.
75
+
76
+ Verify the output: open `--out-md` and confirm slide count and non-trivial
77
+ content; a `.pptx` with no extractable text on any slide is possible
78
+ (image-only decks) but should be rare - treat an entirely-empty result the
79
+ same way a hand-rolled pipeline treats `empty-output-CHECK-THIS`, not as a
80
+ quiet success.
81
+
82
+ ## Refusing fallbacks
83
+
84
+ Do not silently drop OCR, embeds, or the PDF render when a dependency is
85
+ missing and substitute a partial result without saying so. The script is
86
+ built to fail loudly for exactly this reason - surface the failure to the
87
+ user rather than working around it.
88
+
89
+ ## Reusing this in a project-local extractor
90
+
91
+ If a project has its own canonical text-extraction script (e.g. one that
92
+ mirrors an archive into markdown files with status metadata), do not import
93
+ this script into it directly across a network/OneDrive boundary - a
94
+ project's canonical script needs to run standalone on every machine it syncs
95
+ to, independent of whether this bundled skill happens to be installed there.
96
+ Port the relevant logic (shape/table/notes walking at minimum) into the
97
+ project's own script instead, matching that script's existing conventions for
98
+ status/output. Treat OCR, embeds, and PDF rendering as separate, explicitly
99
+ opted-into capabilities when porting - they add real dependencies and runtime
100
+ cost that a project's canonical extractor may not want by default.
101
+
102
+ ## Troubleshooting
103
+
104
+ | Symptom | Likely cause | Fix |
105
+ |---|---|---|
106
+ | `pandoc failed` / empty pandoc output on a `.pptx` | pandoc has no pptx reader | Don't use pandoc for pptx; use this skill instead |
107
+ | `ModuleNotFoundError: pytesseract` | OCR deps not installed | `pip install --user pytesseract pillow` (add `--break-system-packages` if the environment is externally-managed) |
108
+ | `tesseract binary is not on PATH` | Tesseract OCR engine not installed | Install `tesseract-ocr` (apt/brew/winget) |
109
+ | `no soffice/libreoffice binary found` | LibreOffice not installed | Install LibreOffice, or run in a container that has it |
110
+ | OCR text is garbled/partial | Low-res or stylised image text | Expected - Tesseract accuracy depends on image quality; note it as approximate in any evidence use |
111
+ | Embeds dir stays empty | Slide genuinely has no embedded OLE/package objects | Not a bug - only shapes with an actual embed relationship produce output |
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Extract a .pptx file's full content: slide text, tables, speaker notes, OCR of
4
+ embedded images, embedded file objects, and (optionally) a sibling print-to-PDF.
5
+
6
+ Used by the `extract-pptx` skill. Exists because pandoc has a markdown *writer*
7
+ but no `.pptx` *reader* - there is no pandoc input format for PowerPoint, so any
8
+ pipeline that routes Office formats through `pandoc -t markdown` (a common,
9
+ otherwise-correct pattern for preserving Word/RTF tables) silently or loudly
10
+ fails on every .pptx file. This script is a standalone python-pptx-based
11
+ replacement for the .pptx case specifically.
12
+
13
+ Dependencies:
14
+ python-pptx pip install python-pptx
15
+ Pillow pip install pillow (only needed for --ocr)
16
+ pytesseract pip install pytesseract (only needed for --ocr)
17
+ tesseract-ocr binary on PATH (only needed for --ocr; apt/brew/winget package)
18
+ soffice (LibreOffice) on PATH (only needed for --pdf-out)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ from pptx import Presentation
31
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
32
+
33
+ EMBED_RELTYPE_MARKERS = ("package", "oleObject")
34
+
35
+
36
+ def shape_text(shape) -> list[str]:
37
+ """Return this shape's own text content as a list of lines. Recurses into
38
+ groups; handles plain text frames and tables. Does not touch images."""
39
+ lines: list[str] = []
40
+
41
+ if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
42
+ for sub in shape.shapes:
43
+ lines.extend(shape_text(sub))
44
+ return lines
45
+
46
+ if getattr(shape, "has_table", False) and shape.has_table:
47
+ table = shape.table
48
+ for row in table.rows:
49
+ cells = [cell.text.strip() for cell in row.cells]
50
+ if any(cells):
51
+ lines.append(" | ".join(cells))
52
+ return lines
53
+
54
+ if getattr(shape, "has_text_frame", False) and shape.has_text_frame:
55
+ text = shape.text_frame.text
56
+ if text.strip():
57
+ lines.extend(text.split("\n"))
58
+ return lines
59
+
60
+ return lines
61
+
62
+
63
+ def extract_slide_images(slide) -> list[tuple[str, bytes, str]]:
64
+ """Return (shape_name, image_bytes, image_ext) for every picture shape on
65
+ this slide, recursing into groups."""
66
+ found: list[tuple[str, bytes, str]] = []
67
+
68
+ def walk(shapes):
69
+ for shape in shapes:
70
+ if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
71
+ walk(shape.shapes)
72
+ elif shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
73
+ image = shape.image
74
+ found.append((shape.name, image.blob, image.ext))
75
+
76
+ walk(slide.shapes)
77
+ return found
78
+
79
+
80
+ def extract_slide_embeds(slide) -> list[tuple[str, bytes]]:
81
+ """Return (filename, bytes) for every embedded OLE/package object
82
+ (embedded workbooks, documents, etc.) referenced by this slide."""
83
+ found: list[tuple[str, bytes]] = []
84
+ for rel in slide.part.rels.values():
85
+ if rel.is_external:
86
+ continue
87
+ if not any(marker in rel.reltype for marker in EMBED_RELTYPE_MARKERS):
88
+ continue
89
+ target = rel.target_part
90
+ fname = Path(str(target.partname)).name
91
+ found.append((fname, target.blob))
92
+ return found
93
+
94
+
95
+ def ocr_image(image_bytes: bytes) -> str:
96
+ import io
97
+
98
+ from PIL import Image
99
+ import pytesseract
100
+
101
+ img = Image.open(io.BytesIO(image_bytes))
102
+ return pytesseract.image_to_string(img).strip()
103
+
104
+
105
+ def render_pdf(src: Path, pdf_out: Path, timeout: int) -> None:
106
+ soffice = shutil.which("soffice") or shutil.which("libreoffice")
107
+ if not soffice:
108
+ sys.exit(
109
+ "ERROR: --pdf-out requested but no `soffice` / `libreoffice` binary found on PATH. "
110
+ "Install LibreOffice, or run this script inside a container that has it. "
111
+ "Refusing to silently skip the PDF - report this to the user."
112
+ )
113
+ with tempfile.TemporaryDirectory() as tmp:
114
+ result = subprocess.run(
115
+ [soffice, "--headless", "--convert-to", "pdf", "--outdir", tmp, str(src)],
116
+ capture_output=True, text=True, timeout=timeout,
117
+ )
118
+ produced = Path(tmp) / (src.stem + ".pdf")
119
+ if result.returncode != 0 or not produced.exists():
120
+ sys.exit(
121
+ f"ERROR: soffice failed to render PDF.\nstdout: {result.stdout}\nstderr: {result.stderr}"
122
+ )
123
+ pdf_out.parent.mkdir(parents=True, exist_ok=True)
124
+ shutil.move(str(produced), str(pdf_out))
125
+
126
+
127
+ def main() -> int:
128
+ p = argparse.ArgumentParser(description="Extract text, tables, notes, OCR, embeds, and (optionally) a sibling PDF from a .pptx file.")
129
+ p.add_argument("pptx", type=Path, help="Path to the source .pptx file.")
130
+ p.add_argument("--out-md", type=Path, required=True, help="Output markdown path.")
131
+ p.add_argument("--pdf-out", type=Path, default=None, help="Also render a sibling PDF at this path (requires soffice/LibreOffice).")
132
+ p.add_argument("--embeds-dir", type=Path, default=None, help="Directory to extract embedded OLE/package objects into. Skipped if none are found.")
133
+ p.add_argument("--no-ocr", action="store_true", help="Skip OCR of embedded images (default: OCR is attempted; missing tesseract is a hard error, not a silent skip).")
134
+ p.add_argument("--timeout", type=int, default=180, help="Subprocess timeout in seconds for the PDF render step (default 180).")
135
+ args = p.parse_args()
136
+
137
+ if not args.pptx.exists():
138
+ sys.exit(f"ERROR: source file does not exist: {args.pptx}")
139
+
140
+ if not args.no_ocr:
141
+ try:
142
+ import pytesseract # noqa: F401
143
+ from PIL import Image # noqa: F401
144
+ except ImportError as e:
145
+ sys.exit(f"ERROR: OCR requested (default) but a dependency is missing ({e}). "
146
+ "Install pillow + pytesseract, or pass --no-ocr to skip OCR explicitly.")
147
+ if not shutil.which("tesseract"):
148
+ sys.exit("ERROR: OCR requested (default) but the `tesseract` binary is not on PATH. "
149
+ "Install tesseract-ocr, or pass --no-ocr to skip OCR explicitly.")
150
+
151
+ prs = Presentation(str(args.pptx))
152
+
153
+ out_lines: list[str] = [f"# {args.pptx.name}", ""]
154
+ total_embeds: list[tuple[int, str, Path]] = []
155
+
156
+ for idx, slide in enumerate(prs.slides, start=1):
157
+ out_lines.append(f"## Slide {idx}")
158
+ out_lines.append("")
159
+
160
+ body_lines = shape_text_all(slide)
161
+ if body_lines:
162
+ out_lines.extend(body_lines)
163
+ else:
164
+ out_lines.append("*(no extractable text on this slide)*")
165
+ out_lines.append("")
166
+
167
+ if not args.no_ocr:
168
+ images = extract_slide_images(slide)
169
+ for shape_name, blob, ext in images:
170
+ text = ocr_image(blob)
171
+ if text:
172
+ out_lines.append(f"**OCR text from image `{shape_name}` (slide {idx}):**")
173
+ out_lines.append("")
174
+ out_lines.append(text)
175
+ out_lines.append("")
176
+
177
+ if slide.has_notes_slide:
178
+ notes = slide.notes_slide.notes_text_frame.text.strip()
179
+ if notes:
180
+ out_lines.append(f"**Speaker notes (slide {idx}):**")
181
+ out_lines.append("")
182
+ out_lines.append(notes)
183
+ out_lines.append("")
184
+
185
+ embeds = extract_slide_embeds(slide)
186
+ for fname, blob in embeds:
187
+ if args.embeds_dir:
188
+ dest = args.embeds_dir / f"slide{idx:03d}-{fname}"
189
+ dest.parent.mkdir(parents=True, exist_ok=True)
190
+ dest.write_bytes(blob)
191
+ total_embeds.append((idx, fname, dest))
192
+
193
+ if total_embeds:
194
+ out_lines.append("## Embedded file objects")
195
+ out_lines.append("")
196
+ for idx, fname, dest in total_embeds:
197
+ out_lines.append(f"- Slide {idx}: `{fname}` -> `{dest}`")
198
+ out_lines.append("")
199
+
200
+ args.out_md.parent.mkdir(parents=True, exist_ok=True)
201
+ args.out_md.write_text("\n".join(out_lines), encoding="utf-8")
202
+ print(f"OK: wrote {args.out_md} ({len(prs.slides)} slides, {len(total_embeds)} embed(s))")
203
+
204
+ if args.pdf_out:
205
+ render_pdf(args.pptx, args.pdf_out, args.timeout)
206
+ print(f"OK: wrote {args.pdf_out}")
207
+
208
+ return 0
209
+
210
+
211
+ def shape_text_all(slide) -> list[str]:
212
+ lines: list[str] = []
213
+ for shape in slide.shapes:
214
+ lines.extend(shape_text(shape))
215
+ return lines
216
+
217
+
218
+ if __name__ == "__main__":
219
+ sys.exit(main())