ccexport 0.1.0 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ccc4798123b459b9c2f9eb8637d17c28d5168ac7f0ea965c23b56543636f832e
4
- data.tar.gz: 30e8610415bf25386cd49dfec6a0e6f4b0ad8bfa401d1968dfdea4cff50443a9
3
+ metadata.gz: 6d1615cbaeacf17cc0f054e7d997e0e1a53b739511f2086c410340cfb2816d2d
4
+ data.tar.gz: e4b856098953850b6857395c3ca9a5054c91033161f1d1bb74bf8c7bf85fe0f8
5
5
  SHA512:
6
- metadata.gz: f33cbe1749351761a15927f669728b4fde3ff8e0c4043691b8ce53dd4ca990e14aff410d241e5269639661d587668e8f348d6b01cc1bfd73211d40b13a3da256
7
- data.tar.gz: d687d278ab543a97b1ec4e875654029c922f1a069aa588f998977181fb27c2778296288e1009e8afdc42e9098f4a0997c8047cddeaf40919c36faa23b95cfdf7
6
+ metadata.gz: 7bf47b9c630fcb1f06efcfd4cd4ab3a23f6881812fdbe733a1272193af23f47f231ca0751b60586766cdd99a8e3649630639fd667ebc2cf81252b4719dffc85a
7
+ data.tar.gz: d9694653205b5b27818f0721dd43df868375d3c4a878beed201d234427600df1e6dda751626e4f07d84f1660a1f58ccc5a5ec49e245a5c76f21355667d2c14ef
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-05-06
11
+
12
+ - Add `--session ID` option to process a specific session. Thanks to @kopyl
13
+ - Add `--clean` option to remove thinking and tool use from output. Thanks to @kopyl
14
+ - Add `--stdout` option to write markdown to stdout instead of a file (implies `--silent`), enabling piping to other tools such as `gh gist create`. Inspired by @kopyl
15
+
10
16
  ## [0.1.0] - 2024-08-17
11
17
 
12
18
  ### Added
@@ -20,4 +26,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
26
  - Command-line interface with extensive options
21
27
  - Support for both single session and multi-session exports
22
28
  - Path relativization for project files
23
- - Comprehensive test suite with RSpec
29
+ - Comprehensive test suite with RSpec
data/CLAUDE.md CHANGED
@@ -153,4 +153,44 @@ brew install cmark-gfm
153
153
  - 96 RSpec tests covering all major functionality
154
154
  - Test fixtures in `spec/fixtures/` with real JSONL data samples
155
155
  - Mocking of external dependencies (TruffleHog, cmark-gfm, system commands)
156
- - Comprehensive secret detection testing with realistic secret formats
156
+ - Comprehensive secret detection testing with realistic secret formats
157
+
158
+ ## Gem Distribution
159
+
160
+ ### RubyGem Structure
161
+ - **Gem Name**: `ccexport`
162
+ - **Version**: Managed in `lib/ccexport/version.rb`
163
+ - **Executable**: `exe/ccexport` (installed globally as `ccexport` command)
164
+ - **Entry Point**: `lib/ccexport.rb` (requires all necessary components)
165
+
166
+ ### Dependency Management
167
+ **Automatic Installation**: The `exe/ccexport` executable includes automatic dependency checking and installation:
168
+
169
+ 1. **Dependency Detection**: Checks for `trufflehog` and `cmark-gfm` using `which` command
170
+ 2. **Auto-Install**: If Homebrew is available, automatically runs `brew install` for missing dependencies
171
+ 3. **Graceful Fallback**: Provides installation instructions and exits if dependencies can't be auto-installed
172
+ 4. **Skip Option**: `--skip-dependency-check` flag for advanced users or CI environments
173
+ 5. **Silent Mode**: Respects `--silent` flag for dependency operations
174
+
175
+ ### Build and Release Process
176
+ ```bash
177
+ # Build gem locally
178
+ gem build ccexport.gemspec
179
+
180
+ # Install locally for testing
181
+ gem install ./ccexport-0.1.0.gem
182
+
183
+ # Test functionality
184
+ ccexport --help
185
+ ```
186
+
187
+ **Files Excluded from Gem**:
188
+ - Test files (`spec/`)
189
+ - Development scripts (`debug_compacted.rb`, `generate_vibe_samples`)
190
+ - Example outputs (`VIBE*`, `claude-conversations/`)
191
+ - Build artifacts (`*.gem`)
192
+
193
+ ### Installation Paths
194
+ - **Quick Install**: `gem install ccexport` (dependencies auto-installed with Homebrew)
195
+ - **Development**: Clone repo, `bundle install`, manual dependency installation
196
+ - **Manual Setup**: Detailed Ruby + Homebrew installation instructions for non-developers
data/README.md CHANGED
@@ -45,6 +45,8 @@ I created this tool because, as I'm exploring using agentic coding tools, I've f
45
45
  gem install ccexport
46
46
  ```
47
47
 
48
+ **Note**: If you have Homebrew installed, ccexport will automatically install missing dependencies (TruffleHog and cmark-gfm) when you first run it. If you don't have Homebrew, see the full setup instructions below.
49
+
48
50
  ### Full Setup (For non-Ruby developers)
49
51
 
50
52
  If you don't have Ruby installed or aren't familiar with Ruby development, follow these steps:
@@ -173,18 +175,72 @@ rbenv global 3.4.3
173
175
  ruby --version # Should show Ruby 3.4.3
174
176
  ```
175
177
 
176
- #### 4. Install ccexport and dependencies
178
+ #### 4. Install ccexport
177
179
 
178
180
  ```bash
179
181
  # Install the gem
180
182
  gem install ccexport
181
183
 
182
- # Install required external tools (if you installed Homebrew)
183
- brew install trufflehog cmark-gfm
184
+ # Run ccexport - it will automatically install dependencies via Homebrew if needed
185
+ ccexport --help
186
+ ```
187
+
188
+ **If the `ccexport` command is not found after installation:**
189
+
190
+ <details>
191
+ <summary>Refresh your Ruby version manager (click to expand)</summary>
192
+
193
+ ```bash
194
+ # rbenv users
195
+ rbenv rehash
196
+
197
+ # RVM users
198
+ rvm reload
199
+
200
+ # asdf users
201
+ asdf reshim ruby
202
+
203
+ # mise users
204
+ mise reshim
205
+ ```
206
+
207
+ Then try running `ccexport --help` again.
208
+ </details>
209
+
210
+ <details>
211
+ <summary>Add the gem bin directory to your PATH (plain Homebrew Ruby, no version manager)</summary>
212
+
213
+ If you installed Ruby directly via `brew install ruby` and aren't using a version manager, the gem executables may not be on your PATH. Add this line to your shell profile:
214
+
215
+ ```bash
216
+ export PATH="$(gem environment gemdir)/bin:$PATH"
217
+ ```
218
+
219
+ Which file to add it to depends on your shell:
220
+
221
+ - **zsh** (default on macOS Catalina and later):
222
+ ```bash
223
+ echo 'export PATH="$(gem environment gemdir)/bin:$PATH"' >> ~/.zprofile
224
+ ```
225
+ - **bash** (default on older macOS or if you've switched):
226
+ ```bash
227
+ echo 'export PATH="$(gem environment gemdir)/bin:$PATH"' >> ~/.bash_profile
228
+ ```
229
+ - **fish**: `~/.config/fish/config.fish`, using `fish_add_path (gem environment gemdir)/bin`
230
+
231
+ After running the command, restart your terminal or run `source ~/.zprofile` (adjust for your shell), then try `ccexport --help` again.
232
+ </details>
184
233
 
234
+ **That's it!** When you first run ccexport, it will automatically detect and install any missing dependencies (TruffleHog and cmark-gfm) if you have Homebrew installed.
235
+
236
+ **Manual dependency installation** (only needed if you don't have Homebrew):
237
+ ```bash
185
238
  # If you're using system package managers instead of Homebrew:
186
239
  # - TruffleHog: https://github.com/trufflesecurity/trufflehog#installation
187
240
  # - cmark-gfm: May require building from source on some Linux distributions
241
+
242
+ # To skip dependency checking entirely:
243
+ ccexport --skip-dependency-check
188
244
  ```
189
245
 
190
246
  ### From Source (Development)
@@ -196,8 +252,12 @@ brew install trufflehog cmark-gfm
196
252
  ### Prerequisites Summary
197
253
 
198
254
  - **Ruby**: 3.0.0 or higher (3.4.3 recommended)
255
+ - **Homebrew**: Recommended for automatic dependency installation ([installation guide](https://brew.sh))
256
+ - **TruffleHog** and **cmark-gfm**: Automatically installed via Homebrew when you first run ccexport
257
+
258
+ **Manual installation only needed if you don't have Homebrew:**
199
259
  - **TruffleHog**: For secret detection ([installation guide](https://github.com/trufflesecurity/trufflehog#installation))
200
- - **cmark-gfm**: For HTML preview generation (`brew install cmark-gfm` on macOS)
260
+ - **cmark-gfm**: For HTML preview generation
201
261
 
202
262
  ## Usage
203
263
 
@@ -260,6 +320,15 @@ ccexport --preview --template github
260
320
  # Process specific JSONL file
261
321
  ccexport --jsonl /path/to/conversation.jsonl --out specific-conversation.md
262
322
 
323
+ # Process specific session
324
+ ccexport --session aee5189e-cdc7-42fd-9aa1-96ae42c12826
325
+
326
+ # Write markdown to stdout (implies --silent)
327
+ ccexport --today --stdout
328
+
329
+ # Create a GitHub Gist from today's conversations
330
+ ccexport --today --stdout | gh gist create -f claude.md
331
+
263
332
  # Silent mode (suppress all output)
264
333
  ccexport --silent
265
334
 
@@ -273,12 +342,15 @@ ccexport --in /path/to/project --from 2024-01-15 --out ./my-exports --timestamps
273
342
  - `--from DATE`: Filter messages from this date (YYYY-MM-DD or timestamp format from --timestamps output)
274
343
  - `--to DATE`: Filter messages to this date (YYYY-MM-DD or timestamp format from --timestamps output)
275
344
  - `--today`: Filter messages from today only (in your local timezone)
345
+ - `--session ID`: Export a specific session
276
346
  - `--out PATH`: Custom output directory or specific file path (supports relative paths, use .md extension for specific file)
277
347
  - `--timestamps`: Show precise timestamps with each message for easy reference
348
+ - `--clean`: Suppress the output of thinking and tool use
278
349
  - `--preview`: Generate HTML preview and open in browser automatically
279
350
  - `--no-open`: Generate HTML preview without opening in browser (requires --preview)
280
351
  - `--template NAME_OR_PATH`: HTML template name (from templates dir) or file path (default: default)
281
352
  - `--jsonl FILE`: Process a specific JSONL file instead of scanning directories
353
+ - `--stdout`: Write markdown to stdout instead of a file (implies `--silent`); useful for piping to other tools
282
354
  - `-s`, `--silent`: Silent mode - suppress all output except errors
283
355
  - `--help`: Show usage information
284
356
 
data/TODO.md ADDED
@@ -0,0 +1,5 @@
1
+ # TODO
2
+
3
+ ## Investigate JSONL entries without timestamps
4
+
5
+ Some JSONL entries have no `timestamp` key. Currently `message_in_date_range?` treats them as in-range (includes them). It may be more correct to inherit the timestamp of the preceding entry — investigate whether that's the pattern in practice and update accordingly.
data/bin/ccexport CHANGED
@@ -1,95 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require_relative '../lib/claude_conversation_exporter'
4
- require 'optparse'
5
- require 'time'
3
+ require_relative '../lib/ccexport'
6
4
 
7
- options = {}
8
-
9
- OptionParser.new do |parser|
10
- parser.banner = "Usage: ccexport [options]"
11
-
12
- parser.on("--from DATE", "Filter messages from this date (YYYY-MM-DD)") do |date|
13
- options[:from] = date
14
- end
15
-
16
- parser.on("--to DATE", "Filter messages to this date (YYYY-MM-DD)") do |date|
17
- options[:to] = date
18
- end
19
-
20
- parser.on("--today", "Filter messages from today only") do
21
- options[:today] = true
22
- end
23
-
24
- parser.on("--in PATH", "Project path to export conversations from (default: current directory)") do |path|
25
- options[:in] = path
26
- end
27
-
28
- parser.on("--out PATH", "Output directory or file path (default: claude-conversations)") do |path|
29
- options[:out] = path
30
- end
31
-
32
- parser.on("--timestamps", "Show timestamps with each message") do
33
- options[:timestamps] = true
34
- end
35
-
36
- parser.on("--preview", "Generate HTML preview and open in browser") do
37
- options[:preview] = true
38
- end
39
-
40
- parser.on("--no-open", "Generate HTML preview without opening in browser (requires --preview)") do
41
- options[:no_open] = true
42
- end
43
-
44
- parser.on("--template NAME_OR_PATH", "HTML template name (from templates dir) or file path (default: default)") do |template|
45
- options[:template] = template
46
- end
47
-
48
- parser.on("--jsonl FILE", "Process a specific JSONL file instead of scanning directories") do |file|
49
- options[:jsonl] = file
50
- end
51
-
52
- parser.on("-s", "--silent", "Silent mode - suppress all output") do
53
- options[:silent] = true
54
- end
55
-
56
- parser.on("-h", "--help", "Show this help message") do
57
- puts parser
58
- exit
59
- end
60
- end.parse!
61
-
62
- begin
63
- # Validate options
64
- if options[:no_open] && !options[:preview]
65
- puts "Error: --no-open requires --preview" unless options[:silent]
66
- exit 1
67
- end
68
-
69
- if options[:jsonl] && !File.exist?(options[:jsonl])
70
- puts "Error: JSONL file not found: #{options[:jsonl]}" unless options[:silent]
71
- exit 1
72
- end
73
-
74
- if options[:jsonl] && !options[:jsonl].end_with?('.jsonl')
75
- puts "Error: File must have .jsonl extension: #{options[:jsonl]}" unless options[:silent]
76
- exit 1
77
- end
78
-
79
- project_path = options[:in] || Dir.pwd
80
- output_dir = options[:out] || 'claude-conversations'
81
- result = ClaudeConversationExporter.export(project_path, output_dir, options)
82
-
83
- # Generate preview if requested
84
- if options[:preview]
85
- # Use the actual output file if it was specified, otherwise use the directory
86
- preview_target = result[:output_file] || output_dir
87
- template_name = options[:template] || 'default'
88
- ClaudeConversationExporter.generate_preview(preview_target, !options[:no_open], result[:leaf_summaries] || [], template_name, options[:silent])
89
- end
90
-
91
- exit 0
92
- rescue StandardError => e
93
- puts "Error: #{e.message}" unless options[:silent]
94
- exit 1
95
- end
5
+ CcExport::CLI.run
data/exe/ccexport CHANGED
@@ -1,156 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'ccexport'
4
- require 'optparse'
5
- require 'time'
6
4
 
7
- # Check for required external dependencies
8
- def check_dependencies(silent = false)
9
- missing_deps = []
10
-
11
- # Check for TruffleHog
12
- unless system('which trufflehog > /dev/null 2>&1')
13
- missing_deps << 'trufflehog'
14
- end
15
-
16
- # Check for cmark-gfm
17
- unless system('which cmark-gfm > /dev/null 2>&1')
18
- missing_deps << 'cmark-gfm'
19
- end
20
-
21
- return if missing_deps.empty?
22
-
23
- puts "Missing required dependencies: #{missing_deps.join(', ')}" unless silent
24
-
25
- # Check if Homebrew is available
26
- if system('which brew > /dev/null 2>&1')
27
- puts "\nAttempting to install missing dependencies via Homebrew..." unless silent
28
-
29
- success = true
30
- missing_deps.each do |dep|
31
- puts "Installing #{dep}..." unless silent
32
- unless system(silent ? "brew install #{dep} > /dev/null 2>&1" : "brew install #{dep}")
33
- puts "Failed to install #{dep}" unless silent
34
- success = false
35
- end
36
- end
37
-
38
- if success
39
- puts "✅ All dependencies installed successfully!" unless silent
40
- return
41
- else
42
- puts "❌ Some dependencies failed to install" unless silent
43
- end
44
- else
45
- puts "\nHomebrew not found." unless silent
46
- end
47
-
48
- unless silent
49
- puts "\nPlease install the missing dependencies manually:"
50
- puts "- TruffleHog: https://github.com/trufflesecurity/trufflehog#installation"
51
- puts "- cmark-gfm: Install via your system package manager or from source"
52
- puts "\nFor detailed installation instructions, see:"
53
- puts "https://github.com/marcheiligers/ccexport#installation"
54
- puts "\nAlternatively, install Homebrew and run ccexport again for automatic installation:"
55
- puts "https://brew.sh"
56
- end
57
-
58
- exit 1
59
- end
60
-
61
- options = {}
62
-
63
- OptionParser.new do |parser|
64
- parser.banner = "Usage: ccexport [options]"
65
-
66
- parser.on("--from DATE", "Filter messages from this date (YYYY-MM-DD)") do |date|
67
- options[:from] = date
68
- end
69
-
70
- parser.on("--to DATE", "Filter messages to this date (YYYY-MM-DD)") do |date|
71
- options[:to] = date
72
- end
73
-
74
- parser.on("--today", "Filter messages from today only") do
75
- options[:today] = true
76
- end
77
-
78
- parser.on("--in PATH", "Project path to export conversations from (default: current directory)") do |path|
79
- options[:in] = path
80
- end
81
-
82
- parser.on("--out PATH", "Output directory or file path (default: claude-conversations)") do |path|
83
- options[:out] = path
84
- end
85
-
86
- parser.on("--timestamps", "Show timestamps with each message") do
87
- options[:timestamps] = true
88
- end
89
-
90
- parser.on("--preview", "Generate HTML preview and open in browser") do
91
- options[:preview] = true
92
- end
93
-
94
- parser.on("--no-open", "Generate HTML preview without opening in browser (requires --preview)") do
95
- options[:no_open] = true
96
- end
97
-
98
- parser.on("--template NAME_OR_PATH", "HTML template name (from templates dir) or file path (default: default)") do |template|
99
- options[:template] = template
100
- end
101
-
102
- parser.on("--jsonl FILE", "Process a specific JSONL file instead of scanning directories") do |file|
103
- options[:jsonl] = file
104
- end
105
-
106
- parser.on("-s", "--silent", "Silent mode - suppress all output") do
107
- options[:silent] = true
108
- end
109
-
110
- parser.on("--skip-dependency-check", "Skip checking for external dependencies") do
111
- options[:skip_dependency_check] = true
112
- end
113
-
114
- parser.on("-h", "--help", "Show this help message") do
115
- puts parser
116
- exit
117
- end
118
- end.parse!
119
-
120
- # Check dependencies before proceeding (unless skipped)
121
- check_dependencies(options[:silent]) unless options[:skip_dependency_check]
122
-
123
- begin
124
- # Validate options
125
- if options[:no_open] && !options[:preview]
126
- puts "Error: --no-open requires --preview" unless options[:silent]
127
- exit 1
128
- end
129
-
130
- if options[:jsonl] && !File.exist?(options[:jsonl])
131
- puts "Error: JSONL file not found: #{options[:jsonl]}" unless options[:silent]
132
- exit 1
133
- end
134
-
135
- if options[:jsonl] && !options[:jsonl].end_with?('.jsonl')
136
- puts "Error: File must have .jsonl extension: #{options[:jsonl]}" unless options[:silent]
137
- exit 1
138
- end
139
-
140
- project_path = options[:in] || Dir.pwd
141
- output_dir = options[:out] || 'claude-conversations'
142
- result = ClaudeConversationExporter.export(project_path, output_dir, options)
143
-
144
- # Generate preview if requested
145
- if options[:preview]
146
- # Use the actual output file if it was specified, otherwise use the directory
147
- preview_target = result[:output_file] || output_dir
148
- template_name = options[:template] || 'default'
149
- ClaudeConversationExporter.generate_preview(preview_target, !options[:no_open], result[:leaf_summaries] || [], template_name, options[:silent])
150
- end
151
-
152
- exit 0
153
- rescue StandardError => e
154
- puts "Error: #{e.message}" unless options[:silent]
155
- exit 1
156
- end
5
+ CcExport::CLI.run
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'time'
5
+
6
+ module CcExport
7
+ module CLI
8
+ def self.run(argv = ARGV)
9
+ options = {}
10
+
11
+ OptionParser.new do |parser|
12
+ parser.banner = "Usage: ccexport [options]"
13
+
14
+ parser.on("--from DATE", "Filter messages from this date (YYYY-MM-DD)") do |date|
15
+ options[:from] = date
16
+ end
17
+
18
+ parser.on("--to DATE", "Filter messages to this date (YYYY-MM-DD)") do |date|
19
+ options[:to] = date
20
+ end
21
+
22
+ parser.on("--today", "Filter messages from today only") do
23
+ options[:today] = true
24
+ end
25
+
26
+ parser.on("--in PATH", "Project path to export conversations from (default: current directory)") do |path|
27
+ options[:in] = path
28
+ end
29
+
30
+ parser.on("--out PATH", "Output directory or file path (default: claude-conversations)") do |path|
31
+ options[:out] = path
32
+ end
33
+
34
+ parser.on("--timestamps", "Show timestamps with each message") do
35
+ options[:timestamps] = true
36
+ end
37
+
38
+ parser.on("--preview", "Generate HTML preview and open in browser") do
39
+ options[:preview] = true
40
+ end
41
+
42
+ parser.on("--no-open", "Generate HTML preview without opening in browser (requires --preview)") do
43
+ options[:no_open] = true
44
+ end
45
+
46
+ parser.on("--template NAME_OR_PATH", "HTML template name (from templates dir) or file path (default: default)") do |template|
47
+ options[:template] = template
48
+ end
49
+
50
+ parser.on("--jsonl FILE", "Process a specific JSONL file instead of scanning directories") do |file|
51
+ options[:jsonl] = file
52
+ end
53
+
54
+ parser.on("--session ID", "Export a specific session by its UUID") do |id|
55
+ options[:session] = id
56
+ end
57
+
58
+ parser.on("--clean", "Export only visible conversation (no tool calls, thinking, or metadata)") do
59
+ options[:clean] = true
60
+ end
61
+
62
+ parser.on("--stdout", "Write markdown to stdout instead of a file (implies --silent)") do
63
+ options[:stdout] = true
64
+ end
65
+
66
+ parser.on("-s", "--silent", "Silent mode - suppress all output") do
67
+ options[:silent] = true
68
+ end
69
+
70
+ parser.on("--skip-dependency-check", "Skip checking for external dependencies") do
71
+ options[:skip_dependency_check] = true
72
+ end
73
+
74
+ parser.on("-h", "--help", "Show this help message") do
75
+ puts parser
76
+ exit
77
+ end
78
+ end.parse!(argv)
79
+
80
+ check_dependencies(options[:silent]) unless options[:skip_dependency_check]
81
+
82
+ begin
83
+ if options[:no_open] && !options[:preview]
84
+ puts "Error: --no-open requires --preview" unless options[:silent]
85
+ exit 1
86
+ end
87
+
88
+ if options[:jsonl] && !File.exist?(options[:jsonl])
89
+ puts "Error: JSONL file not found: #{options[:jsonl]}" unless options[:silent]
90
+ exit 1
91
+ end
92
+
93
+ if options[:jsonl] && !options[:jsonl].end_with?('.jsonl')
94
+ puts "Error: File must have .jsonl extension: #{options[:jsonl]}" unless options[:silent]
95
+ exit 1
96
+ end
97
+
98
+ project_path = options[:in] || Dir.pwd
99
+ output_dir = options[:out] || 'claude-conversations'
100
+ result = ClaudeConversationExporter.export(project_path, output_dir, options)
101
+
102
+ if options[:preview]
103
+ preview_target = result[:output_file] || output_dir
104
+ template_name = options[:template] || 'default'
105
+ ClaudeConversationExporter.generate_preview(preview_target, !options[:no_open], result[:leaf_summaries] || [], template_name, options[:silent])
106
+ end
107
+
108
+ exit 0
109
+ rescue StandardError => e
110
+ puts "Error: #{e.message}" unless options[:silent]
111
+ exit 1
112
+ end
113
+ end
114
+
115
+ def self.check_dependencies(silent = false)
116
+ missing_deps = []
117
+ missing_deps << 'trufflehog' unless system('which trufflehog > /dev/null 2>&1')
118
+ missing_deps << 'cmark-gfm' unless system('which cmark-gfm > /dev/null 2>&1')
119
+ return if missing_deps.empty?
120
+
121
+ puts "Missing required dependencies: #{missing_deps.join(', ')}" unless silent
122
+
123
+ if system('which brew > /dev/null 2>&1')
124
+ puts "\nAttempting to install missing dependencies via Homebrew..." unless silent
125
+
126
+ success = true
127
+ missing_deps.each do |dep|
128
+ puts "Installing #{dep}..." unless silent
129
+ unless system(silent ? "brew install #{dep} > /dev/null 2>&1" : "brew install #{dep}")
130
+ puts "Failed to install #{dep}" unless silent
131
+ success = false
132
+ end
133
+ end
134
+
135
+ if success
136
+ puts "✅ All dependencies installed successfully!" unless silent
137
+ return
138
+ else
139
+ puts "❌ Some dependencies failed to install" unless silent
140
+ end
141
+ else
142
+ puts "\nHomebrew not found." unless silent
143
+ end
144
+
145
+ unless silent
146
+ puts "\nPlease install the missing dependencies manually:"
147
+ puts "- TruffleHog: https://github.com/trufflesecurity/trufflehog#installation"
148
+ puts "- cmark-gfm: Install via your system package manager or from source"
149
+ puts "\nFor detailed installation instructions, see:"
150
+ puts "https://github.com/marcheiligers/ccexport#installation"
151
+ puts "\nAlternatively, install Homebrew and run ccexport again for automatic installation:"
152
+ puts "https://brew.sh"
153
+ end
154
+
155
+ exit 1
156
+ end
157
+ end
158
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CcExport
4
- VERSION = "0.1.0"
5
- end
4
+ VERSION = "0.2.0"
5
+ end
data/lib/ccexport.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ccexport/version"
4
+ require_relative "ccexport/cli"
4
5
  require_relative "claude_conversation_exporter"
5
6
  require_relative "secret_detector"
6
7
 
@@ -167,7 +167,9 @@ class ClaudeConversationExporter
167
167
  @compacted_conversation_processed = false
168
168
  @options = options
169
169
  @show_timestamps = options[:timestamps] || false
170
- @silent = options[:silent] || false
170
+ @clean = options[:clean] || false
171
+ @stdout = options[:stdout] || false
172
+ @silent = @stdout || options[:silent] || false
171
173
  @leaf_summaries = []
172
174
  @skipped_messages = []
173
175
  @secrets_detected = []
@@ -176,7 +178,11 @@ class ClaudeConversationExporter
176
178
  end
177
179
 
178
180
  def export
179
- if @options[:jsonl]
181
+ if @options[:session]
182
+ session_file = find_session_file(@options[:session])
183
+ session_files = [session_file]
184
+ session_dir = File.dirname(session_file)
185
+ elsif @options[:jsonl]
180
186
  # Process specific JSONL file
181
187
  session_files = [File.expand_path(@options[:jsonl])]
182
188
  session_dir = File.dirname(session_files.first)
@@ -226,13 +232,20 @@ class ClaudeConversationExporter
226
232
  return { sessions_exported: 0, total_messages: 0 }
227
233
  end
228
234
 
235
+ markdown = format_combined_markdown(sessions)
236
+
237
+ if @stdout
238
+ $stdout.print(markdown)
239
+ return { sessions_exported: sessions.length, total_messages: total_messages, leaf_summaries: @leaf_summaries, output_file: nil }
240
+ end
241
+
229
242
  # Generate output path if not already specified
230
243
  if output_path.nil?
231
244
  filename = generate_combined_filename(sessions)
232
245
  output_path = File.join(output_dir, filename)
233
246
  end
234
247
 
235
- File.write(output_path, format_combined_markdown(sessions))
248
+ File.write(output_path, markdown)
236
249
 
237
250
  # Write skip log if there were any skipped messages
238
251
  write_skip_log(output_path)
@@ -378,6 +391,7 @@ class ClaudeConversationExporter
378
391
 
379
392
  def message_in_date_range?(timestamp)
380
393
  return true unless @from_time || @to_time
394
+ return true if timestamp.nil?
381
395
 
382
396
  begin
383
397
  message_time = Time.parse(timestamp)
@@ -428,6 +442,17 @@ class ClaudeConversationExporter
428
442
  candidates.first
429
443
  end
430
444
 
445
+ def find_session_file(session_id)
446
+ projects_dir = File.join(@claude_home, 'projects')
447
+ filename = "#{session_id}.jsonl"
448
+
449
+ Dir.glob(File.join(projects_dir, '*', filename)).each do |path|
450
+ return path
451
+ end
452
+
453
+ raise "No session found with ID: #{session_id}"
454
+ end
455
+
431
456
  def encode_path(path)
432
457
  path.gsub('/', '-').gsub('_', '-')
433
458
  end
@@ -479,15 +504,26 @@ class ClaudeConversationExporter
479
504
  @compacted_conversation_processed = true
480
505
  end
481
506
  elsif data.key?('toolUseResult')
507
+ if @clean
508
+ # In clean mode, skip tool results entirely
509
+ next
510
+ end
482
511
  # Pair with previous tool_use
483
512
  if pending_tool_use
484
- messages << format_combined_tool_use(pending_tool_use, data)
513
+ message = format_combined_tool_use(pending_tool_use, data)
514
+ messages << message if message
485
515
  pending_tool_use = nil
486
516
  end
487
517
  elsif data.key?('requestId') || regular_message?(data)
488
518
  # Check if this assistant message contains tool_use
489
519
  if tool_use_message?(data)
490
- pending_tool_use = data # Hold for pairing with next toolUseResult
520
+ if @clean
521
+ # In clean mode, extract only text content from tool_use messages
522
+ message = format_regular_message(data)
523
+ messages << message if message
524
+ else
525
+ pending_tool_use = data # Hold for pairing with next toolUseResult
526
+ end
491
527
  else
492
528
  message = format_regular_message(data)
493
529
  if message
@@ -577,6 +613,8 @@ class ClaudeConversationExporter
577
613
 
578
614
  message_id = data.dig('message', 'id') || 'unknown'
579
615
 
616
+ return nil if content.nil?
617
+
580
618
  if content.is_a?(Array)
581
619
  result = extract_text_content(content, "message_#{message_id}")
582
620
  processed_content = result[:content]
@@ -590,7 +628,7 @@ class ClaudeConversationExporter
590
628
  processed_content = JSON.pretty_generate(content)
591
629
  end
592
630
 
593
- return nil if processed_content.strip.empty?
631
+ return nil if processed_content.nil? || processed_content.strip.empty?
594
632
 
595
633
  # Fix nested backticks in regular content
596
634
  processed_content = fix_nested_backticks_in_content(processed_content)
@@ -680,14 +718,17 @@ class ClaudeConversationExporter
680
718
  if item.is_a?(Hash) && item['type'] == 'text' && item['text']
681
719
  parts << item['text']
682
720
  elsif item.is_a?(Hash) && item['type'] == 'thinking' && item['thinking']
721
+ next if @clean
683
722
  # Format thinking content as blockquote
684
723
  thinking_lines = item['thinking'].split("\n").map { |line| "> #{line}" }
685
724
  parts << thinking_lines.join("\n")
686
725
  has_thinking = true
687
726
  elsif item.is_a?(Hash) && item['type'] == 'tool_use'
727
+ next if @clean
688
728
  # Format tool_use without tool_result (will be paired later at message level)
689
729
  parts << format_tool_use(item, nil)
690
730
  else
731
+ next if @clean
691
732
  # Preserve other content types as JSON for now
692
733
  parts << JSON.pretty_generate(item)
693
734
  end
@@ -994,38 +1035,41 @@ class ClaudeConversationExporter
994
1035
 
995
1036
  def format_combined_markdown(sessions)
996
1037
  md = []
997
- title = get_markdown_title
998
- md << "# #{title}"
999
- md << ""
1000
1038
 
1001
1039
  if sessions.length == 1
1002
1040
  # Single session - use original format
1003
1041
  return format_markdown(sessions.first)
1004
1042
  end
1005
1043
 
1006
- # Multiple sessions - combined format
1007
- total_messages = sessions.sum { |s| s[:messages].length }
1008
- total_user = sessions.sum { |s| s[:messages].count { |m| m[:role] == 'user' } }
1009
- total_assistant = sessions.sum { |s| s[:messages].count { |m| m[:role] == 'assistant' } }
1044
+ unless @clean
1045
+ title = get_markdown_title
1046
+ md << "# #{title}"
1047
+ md << ""
1010
1048
 
1011
- md << "**Sessions:** #{sessions.length}"
1012
- md << "**Total Messages:** #{total_messages} (#{total_user} user, #{total_assistant} assistant)"
1013
- md << ""
1049
+ # Multiple sessions - combined format
1050
+ total_messages = sessions.sum { |s| s[:messages].length }
1051
+ total_user = sessions.sum { |s| s[:messages].count { |m| m[:role] == 'user' } }
1052
+ total_assistant = sessions.sum { |s| s[:messages].count { |m| m[:role] == 'assistant' } }
1014
1053
 
1015
- first_timestamp = sessions.map { |s| s[:first_timestamp] }.compact.min
1016
- last_timestamp = sessions.map { |s| s[:last_timestamp] }.compact.max
1054
+ md << "**Sessions:** #{sessions.length}"
1055
+ md << "**Total Messages:** #{total_messages} (#{total_user} user, #{total_assistant} assistant)"
1056
+ md << ""
1017
1057
 
1018
- if first_timestamp
1019
- md << "**Started:** #{Time.parse(first_timestamp).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1020
- end
1058
+ first_timestamp = sessions.map { |s| s[:first_timestamp] }.compact.min
1059
+ last_timestamp = sessions.map { |s| s[:last_timestamp] }.compact.max
1021
1060
 
1022
- if last_timestamp
1023
- md << "**Last activity:** #{Time.parse(last_timestamp).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1024
- end
1061
+ if first_timestamp
1062
+ md << "**Started:** #{Time.parse(first_timestamp).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1063
+ end
1025
1064
 
1026
- md << ""
1027
- md << "---"
1028
- md << ""
1065
+ if last_timestamp
1066
+ md << "**Last activity:** #{Time.parse(last_timestamp).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1067
+ end
1068
+
1069
+ md << ""
1070
+ md << "---"
1071
+ md << ""
1072
+ end
1029
1073
 
1030
1074
  # Process each session with separators
1031
1075
  sessions.each_with_index do |session, session_index|
@@ -1033,19 +1077,22 @@ class ClaudeConversationExporter
1033
1077
  md << ""
1034
1078
  md << "---"
1035
1079
  md << ""
1036
- md << "# Session #{session_index + 1}"
1037
- md << ""
1038
- md << "**Session ID:** `#{session[:session_id]}`"
1039
1080
 
1040
- if session[:first_timestamp]
1041
- md << "**Started:** #{Time.parse(session[:first_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1042
- end
1081
+ unless @clean
1082
+ md << "# Session #{session_index + 1}"
1083
+ md << ""
1084
+ md << "**Session ID:** `#{session[:session_id]}`"
1043
1085
 
1044
- user_count = session[:messages].count { |m| m[:role] == 'user' }
1045
- assistant_count = session[:messages].count { |m| m[:role] == 'assistant' }
1086
+ if session[:first_timestamp]
1087
+ md << "**Started:** #{Time.parse(session[:first_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1088
+ end
1046
1089
 
1047
- md << "**Messages:** #{session[:messages].length} (#{user_count} user, #{assistant_count} assistant)"
1048
- md << ""
1090
+ user_count = session[:messages].count { |m| m[:role] == 'user' }
1091
+ assistant_count = session[:messages].count { |m| m[:role] == 'assistant' }
1092
+
1093
+ md << "**Messages:** #{session[:messages].length} (#{user_count} user, #{assistant_count} assistant)"
1094
+ md << ""
1095
+ end
1049
1096
  end
1050
1097
 
1051
1098
  # Add messages for this session
@@ -1064,27 +1111,30 @@ class ClaudeConversationExporter
1064
1111
 
1065
1112
  def format_markdown(session)
1066
1113
  md = []
1067
- title = get_markdown_title
1068
- md << "# #{title}"
1069
- md << ""
1070
- md << "**Session:** `#{session[:session_id]}`"
1071
- md << ""
1072
-
1073
- if session[:first_timestamp]
1074
- md << "**Started:** #{Time.parse(session[:first_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1075
- end
1076
1114
 
1077
- if session[:last_timestamp]
1078
- md << "**Last activity:** #{Time.parse(session[:last_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1079
- end
1115
+ unless @clean
1116
+ title = get_markdown_title
1117
+ md << "# #{title}"
1118
+ md << ""
1119
+ md << "**Session:** `#{session[:session_id]}`"
1120
+ md << ""
1121
+
1122
+ if session[:first_timestamp]
1123
+ md << "**Started:** #{Time.parse(session[:first_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1124
+ end
1080
1125
 
1081
- user_count = session[:messages].count { |m| m[:role] == 'user' }
1082
- assistant_count = session[:messages].count { |m| m[:role] == 'assistant' }
1126
+ if session[:last_timestamp]
1127
+ md << "**Last activity:** #{Time.parse(session[:last_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1128
+ end
1129
+
1130
+ user_count = session[:messages].count { |m| m[:role] == 'user' }
1131
+ assistant_count = session[:messages].count { |m| m[:role] == 'assistant' }
1083
1132
 
1084
- md << "**Messages:** #{session[:messages].length} (#{user_count} user, #{assistant_count} assistant)"
1085
- md << ""
1086
- md << "---"
1087
- md << ""
1133
+ md << "**Messages:** #{session[:messages].length} (#{user_count} user, #{assistant_count} assistant)"
1134
+ md << ""
1135
+ md << "---"
1136
+ md << ""
1137
+ end
1088
1138
 
1089
1139
  # Process messages linearly - they're already processed and paired
1090
1140
  session[:messages].each_with_index do |message, index|
@@ -1115,8 +1165,10 @@ class ClaudeConversationExporter
1115
1165
  when 'assistant'
1116
1166
  "## 🤖 Assistant"
1117
1167
  when 'assistant_thinking'
1168
+ return [] if @clean
1118
1169
  "## 🤖💭 Assistant"
1119
1170
  when 'system'
1171
+ return [] if @clean
1120
1172
  "## ⚙️ System"
1121
1173
  end
1122
1174
 
@@ -1133,15 +1185,19 @@ class ClaudeConversationExporter
1133
1185
 
1134
1186
  lines << role_header
1135
1187
 
1136
- # Add message ID as HTML comment if available
1137
- if message[:message_id]
1138
- lines << "<!-- #{message[:message_id]} -->"
1188
+ unless @clean
1189
+ # Add message ID as HTML comment if available
1190
+ if message[:message_id]
1191
+ lines << "<!-- #{message[:message_id]} -->"
1192
+ end
1139
1193
  end
1140
1194
 
1141
1195
  lines << ""
1142
1196
  end
1143
1197
 
1144
- lines << message[:content]
1198
+ content = message[:content]
1199
+ content = content.gsub(/\A[[:space:] ]+/, '') if @clean
1200
+ lines << content
1145
1201
  lines << ""
1146
1202
 
1147
1203
  lines
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ccexport
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marc Heiligers
@@ -55,8 +55,8 @@ files:
55
55
  - LICENSE
56
56
  - README.md
57
57
  - Rakefile
58
+ - TODO.md
58
59
  - bin/ccexport
59
- - ccexport.gemspec
60
60
  - exe/ccexport
61
61
  - lib/assets/prism-bash.js
62
62
  - lib/assets/prism-json.js
@@ -67,6 +67,7 @@ files:
67
67
  - lib/assets/prism.css
68
68
  - lib/assets/prism.js
69
69
  - lib/ccexport.rb
70
+ - lib/ccexport/cli.rb
70
71
  - lib/ccexport/version.rb
71
72
  - lib/claude_conversation_exporter.rb
72
73
  - lib/markdown_code_block_parser.rb
data/ccexport.gemspec DELETED
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/ccexport/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "ccexport"
7
- spec.version = CcExport::VERSION
8
- spec.authors = ["Marc Heiligers"]
9
- spec.email = ["marc@silvermerc.net"]
10
-
11
- spec.summary = "Export and preview Claude Code conversations with syntax highlighting"
12
- spec.description = <<~DESC
13
- A Ruby tool to export Claude Code conversations from JSONL session files
14
- into beautifully formatted Markdown and HTML files with syntax highlighting,
15
- secret detection, and multiple template options.
16
- DESC
17
- spec.homepage = "https://github.com/marcheiligers/ccexport"
18
- spec.license = "MIT"
19
- spec.required_ruby_version = ">= 3.0.0"
20
-
21
- spec.metadata["homepage_uri"] = spec.homepage
22
- spec.metadata["source_code_uri"] = "#{spec.homepage}.git"
23
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
24
-
25
- # Specify which files should be added to the gem when it is released.
26
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
27
- spec.files = Dir.chdir(__dir__) do
28
- `git ls-files -z`.split("\x0").reject do |f|
29
- (File.expand_path(f) == __FILE__) ||
30
- f.start_with?(*%w[bin/fetch_secrets_patterns spec/ VIBE claude-conversations/ debug_compacted.rb discover_structure.out discover_structure.rb generate_vibe_samples]) ||
31
- f.end_with?('.gem')
32
- end
33
- end
34
- spec.bindir = "exe"
35
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
- spec.require_paths = ["lib"]
37
-
38
- # Runtime dependencies
39
- spec.add_dependency "json", "~> 2.0"
40
-
41
- # Development dependencies
42
- spec.add_development_dependency "rspec", "~> 3.12"
43
-
44
- # Specify the minimum version of RubyGems required
45
- spec.required_rubygems_version = ">= 1.8.11"
46
-
47
- # For more information and examples about making a new gem, check out our
48
- # guide at: https://bundler.io/guides/creating_gem.html
49
- end