@kevinxyz/code-snapshot 1.0.0 → 2.0.2

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 (4) hide show
  1. package/README.md +149 -27
  2. package/docs/index.html +214 -0
  3. package/index.js +385 -137
  4. package/package.json +38 -7
package/README.md CHANGED
@@ -1,55 +1,155 @@
1
1
  # šŸ“ø code-snapshot
2
2
 
3
- **Merge your codebase into one prompt-ready text block for AI agents.**
3
+ **Merge your codebase into one text block for AI agents.**
4
4
 
5
- Stop manually copying files into ChatGPT/Claude Code/Codex. One command, done.
5
+ Stop manually copy-pasting files into ChatGPT, Claude Code, Codex, or Cursor. One command, done.
6
6
 
7
7
  ```bash
8
- npm install -g code-snapshot
9
- snap ./src # Snapshot a directory
10
- snap ./src --git-only # Only changed files
11
- snap ./src -o snapshot.txt # Write to file
12
- cat snapshot.txt | pbcopy # Copy to clipboard
8
+ npm install -g @kevinxyz/code-snapshot
13
9
  ```
14
10
 
15
- ## Why?
11
+ Already installed? Update to the latest:
12
+
13
+ ```bash
14
+ npm update -g @kevinxyz/code-snapshot
15
+ ```
16
+
17
+ **Just run it in your project root (no arguments = current dir):**
18
+
19
+ ```bash
20
+ snap
21
+ ```
22
+
23
+ Then pipe directly to any AI (replace `.` with your folder):
24
+
25
+ ```bash
26
+ snap . | claude # Pipe to Claude Code
27
+ snap . -i --tokens # Interactive + token estimate
28
+ snap . --git-only -o ctx.txt # Just changed files
29
+ snap . --copy # Copy to clipboard
30
+ ```
31
+
32
+ > šŸ’” **Tip:** `snap` with no arguments uses the current directory. If you see `ENOENT`, the folder doesn't exist — run `snap .` in your project root instead.
33
+
34
+ ## Why code-snapshot?
16
35
 
17
36
  Every developer using AI coding tools has the same ritual: open 10 files, copy-paste them one by one into the chat, describe the problem, hope the AI understands the full context.
18
37
 
19
- **code-snapshot** automates this. One command gives you a clean, structured text block your AI agent can immediately understand.
38
+ **code-snapshot automates this.** One command gives you a clean, structured text block your AI agent can immediately understand.
39
+
40
+ ## Real Scenario
41
+
42
+ You have a project and want AI to help add a new feature. Without code-snapshot:
43
+
44
+ ```bash
45
+ # Manual: open each file, copy-paste one by one to AI
46
+ src/index.js → copy → paste to AI
47
+ src/utils/parser.js → copy → paste to AI
48
+ src/config.js → copy → paste to AI
49
+ package.json → copy → paste to AI
50
+ ...
51
+ # 10 files = 10 copy-pastes, easy to miss something
52
+ # AI says "I can't find the definition" → go back, find the file, copy again
53
+ ```
54
+
55
+ With code-snapshot:
20
56
 
21
- ## Features
57
+ ```bash
58
+ # One command, everything in one place
59
+ snap . -o context.txt
60
+ # Then drag context.txt into your AI chat
61
+ # AI gets full context in one shot, no back-and-forth
62
+ ```
63
+
64
+ ### Real output example
65
+
66
+ Run `snap` in your project, and you get:
67
+
68
+ ```
69
+ ============================================================
70
+ šŸ“ø CODE SNAPSHOT
71
+ ============================================================
72
+ Generated: 2026-06-07T12:33:00.000Z
73
+ Source: /my-project
74
+ Files scanned: 12
75
+ ============================================================
76
+
77
+ // [FILE] package.json
78
+ // [LANG] JSON
79
+ // [SIZE] 800 chars | ~200 tokens
80
+ { "name": "my-project", ... }
81
+
82
+ // [FILE] src/index.js
83
+ // [LANG] JavaScript
84
+ // [SIZE] 3400 chars | ~850 tokens
85
+ import { parseFile } from './utils/parser';
86
+ ...
87
+
88
+ // [FILE] src/utils/parser.js
89
+ // [LANG] JavaScript
90
+ // [SIZE] 5200 chars | ~1300 tokens
91
+ export function parseFile(path) { ... }
92
+ ...
93
+
94
+ ============================================================
95
+ END OF SNAPSHOT
96
+ ============================================================
97
+ 12 files | ~15000 chars | ~3750 tokens
98
+ ```
99
+
100
+ Then you tell AI: *"Above is my full codebase. Please add a feature to export each page as an image."*
101
+
102
+ **AI understands instantly** — no need to paste files one by one, no missing context.
103
+
104
+ ## Features (v2)
22
105
 
23
106
  | Feature | CLI Flag | Description |
24
107
  |---------|----------|-------------|
25
- | Directory walk | `snap <dir>` | Recursive with `.gitignore` awareness |
108
+ | Directory walk | `snap <dir>` | Recursive with `.gitignore` + `.snapignore` awareness |
26
109
  | Git-only | `--git-only` | Only modified/new files since last commit |
27
- | Minify | `--minify` | Strip blank lines and single-line comments (save tokens) |
110
+ | Minify | `--minify` | Strip blank lines + single-line comments to save tokens |
111
+ | Token estimate | `--tokens` | See estimated token count in output |
112
+ | Interactive picker | `-i` or `--interactive` | Pick files and options interactively |
113
+ | Top files report | `--top-files` | Show largest files before generating |
28
114
  | Output file | `-o <file>` | Write to file instead of stdout |
29
115
  | Clipboard | `--copy` | Copy to clipboard (macOS/Linux) |
30
116
  | Include filter | `--include "*.js,*.ts"` | Only matching file patterns |
31
117
  | Exclude filter | `--exclude "*.test.*"` | Skip matching patterns |
32
118
  | Max file size | `--max-size <bytes>` | Skip large files (default: 512KB) |
33
119
  | Skip binary | `--no-binary` | Auto-detect and skip binary files (default: on) |
34
- | Ignore gitignore | `--no-gitignore` | Don't read .gitignore rules |
120
+ | JSON output | `--json` | Machine-readable JSON format |
121
+ | Sort options | `--sort alpha\|size\|type` | Control output file order |
122
+ | Output formats | `--format marker\|xml\|plain` | Choose your output format |
35
123
 
36
124
  ## Quick Examples
37
125
 
38
126
  ```bash
39
127
  # Everything you need for your AI prompt
40
- snap ./src -o context.txt
128
+ snap . -o context.txt
41
129
 
42
- # Just what changed today
130
+ # Just what changed today (git diff)
43
131
  snap . --git-only
44
132
 
45
- # Clean it up (save tokens)
46
- snap ./lib --minify --exclude "*.test.js,*.spec.js"
133
+ # Interactive mode — pick files, set options
134
+ snap . -i
135
+
136
+ # Clean it up to save tokens
137
+ snap . --minify --exclude "*.test.js,*.spec.js"
138
+
139
+ # See token count before you use it
140
+ snap . --tokens
47
141
 
48
- # Pipe directly to Claude Code (or any AI)
49
- snap . | claude
142
+ # Find the big files
143
+ snap . --top-files
144
+
145
+ # JSON for programmatic use
146
+ snap . --json -o codebase.json
50
147
 
51
148
  # Quick clipboard
52
- snap ./src --copy
149
+ snap . --copy
150
+
151
+ # Different output format
152
+ snap . --format xml
53
153
  ```
54
154
 
55
155
  ## Output Format
@@ -59,24 +159,46 @@ Every file is wrapped with clear markers:
59
159
  ```
60
160
  // [FILE] src/utils.ts
61
161
  // [LANG] TypeScript
62
- // [SIZE] 1234 chars
162
+ // [SIZE] 1234 chars | ~308 tokens
63
163
  export function hello() { ... }
64
164
  // [/FILE] src/utils.ts
65
165
  ```
66
166
 
67
- The header includes a summary for token estimation. Footer gives total stats.
167
+ The header includes generation info and token estimation. Footer gives total statistics.
68
168
 
69
169
  ## Ignored by Default
70
170
 
71
- `node_modules`, `.git`, `.DS_Store`, `dist`, `build`, `.next`, `.cache`, `__pycache__`, `.venv`, `venv`, `env`, `coverage`, plus everything in your `.gitignore`.
171
+ `node_modules`, `.git`, `.DS_Store`, `dist`, `build`, `.next`, `.cache`, `__pycache__`, `.venv`, `venv`, `env`, `.env`, `coverage`, `*.log`, plus everything in your `.gitignore` **and** `.snapignore`.
172
+
173
+ ### .snapignore
174
+
175
+ Create a `.snapignore` file in your project root for custom exclusions:
176
+
177
+ ```
178
+ # .snapignore
179
+ generated/
180
+ *.graphql
181
+ **/*.stories.*
182
+ ```
183
+
184
+ ## How it works
185
+
186
+ 1. **Scans** your directory, respecting `.gitignore` and `.snapignore`
187
+ 2. **Wraps** every file with FILE/LANG/SIZE markers
188
+ 3. **Outputs** one clean text block — pipe it, save it, copy it
189
+ 4. **Your AI agent** gets full context in one shot
72
190
 
73
191
  ## Roadmap
74
192
 
75
- - [ ] Interactive file picker
76
- - [ ] Token-aware chunking (split large outputs for context limits)
193
+ - [x] Interactive file picker
194
+ - [x] Token-aware output
195
+ - [x] .snapignore file support
77
196
  - [ ] VS Code extension
78
- - [ ] .snapignore file support
197
+ - [ ] Token-aware chunking (auto-split for context limits)
198
+ - [ ] GUI landing page
79
199
 
80
200
  ## License
81
201
 
82
- MIT — free for everyone. If you find it useful, a GitHub star goes a long way.
202
+ MIT — free for everyone.
203
+
204
+ If you find this useful, a ⭐ on GitHub goes a long way.
@@ -0,0 +1,214 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>code-snapshot — Merge your codebase for AI agents</title>
7
+ <meta name="description" content="Merge your codebase into one text block for AI agents. One command, done." />
8
+ <style>
9
+ :root {
10
+ --bg: #0d1117;
11
+ --fg: #c9d1d9;
12
+ --accent: #58a6ff;
13
+ --green: #3fb950;
14
+ --orange: #d29922;
15
+ --border: #30363d;
16
+ --card: #161b22;
17
+ --font: 'SF Mono','Cascadia Code','JetBrains Mono',monospace;
18
+ }
19
+ * { margin:0; padding:0; box-sizing:border-box; }
20
+ body {
21
+ font-family: var(--font);
22
+ background: var(--bg);
23
+ color: var(--fg);
24
+ line-height: 1.6;
25
+ min-height: 100vh;
26
+ }
27
+ .container { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; }
28
+
29
+ header { text-align: center; padding: 3rem 0 2rem; }
30
+ .logo { font-size: 3rem; }
31
+ h1 { font-size: 2.2rem; font-weight: 700; margin: 0.5rem 0; color: #fff; }
32
+ .tagline { font-size: 1.1rem; color: var(--fg); opacity: 0.8; max-width: 600px; margin: 0.5rem auto; }
33
+
34
+ .install {
35
+ background: var(--card);
36
+ border: 1px solid var(--border);
37
+ border-radius: 12px;
38
+ padding: 2rem;
39
+ margin: 2rem 0;
40
+ text-align: center;
41
+ }
42
+ .install code {
43
+ display: block;
44
+ background: #1a1f2b;
45
+ padding: 1rem 1.5rem;
46
+ border-radius: 8px;
47
+ font-size: 1.1rem;
48
+ color: var(--green);
49
+ margin: 1rem 0;
50
+ cursor: pointer;
51
+ border: 1px solid var(--border);
52
+ transition: border-color 0.2s;
53
+ word-break: break-all;
54
+ }
55
+ .install code:hover { border-color: var(--accent); }
56
+ .install small { opacity: 0.6; font-size: 0.85rem; }
57
+
58
+ .features { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin: 2rem 0; }
59
+ .feature {
60
+ background: var(--card);
61
+ border: 1px solid var(--border);
62
+ border-radius: 10px;
63
+ padding: 1.5rem;
64
+ }
65
+ .feature .icon { font-size: 1.8rem; margin-bottom: 0.5rem; }
66
+ .feature h3 { color: #fff; margin-bottom: 0.5rem; }
67
+ .feature p { font-size: 0.9rem; opacity: 0.75; }
68
+ .feature code { background: #1a1f2b; padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.85rem; }
69
+
70
+ .examples { margin: 2.5rem 0; }
71
+ .examples h2 { color: #fff; margin-bottom: 1rem; font-size: 1.4rem; }
72
+ .example {
73
+ background: var(--card);
74
+ border: 1px solid var(--border);
75
+ border-radius: 10px;
76
+ padding: 1rem 1.5rem;
77
+ margin-bottom: 1rem;
78
+ }
79
+ .example .cmd { color: var(--green); font-weight: 600; }
80
+ .example .desc { opacity: 0.7; font-size: 0.85rem; margin-top: 0.3rem; }
81
+
82
+ .cta { text-align: center; margin: 3rem 0; }
83
+ .btn {
84
+ display: inline-block;
85
+ padding: 0.9rem 2rem;
86
+ border-radius: 8px;
87
+ text-decoration: none;
88
+ font-weight: 600;
89
+ font-family: var(--font);
90
+ transition: all 0.2s;
91
+ cursor: pointer;
92
+ }
93
+ .btn-primary {
94
+ background: var(--accent);
95
+ color: #0d1117;
96
+ border: none;
97
+ }
98
+ .btn-primary:hover { background: #79b8ff; transform: translateY(-1px); }
99
+ .btn-secondary {
100
+ background: transparent;
101
+ color: var(--fg);
102
+ border: 1px solid var(--border);
103
+ margin-left: 1rem;
104
+ }
105
+ .btn-secondary:hover { border-color: var(--accent); }
106
+
107
+ .footer { text-align: center; padding: 2rem 0; border-top: 1px solid var(--border); margin-top: 3rem; font-size: 0.85rem; opacity: 0.6; }
108
+
109
+ @media (max-width: 600px) {
110
+ .container { padding: 1rem; }
111
+ h1 { font-size: 1.6rem; }
112
+ .btn-secondary { margin-left: 0; margin-top: 0.5rem; }
113
+ }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <div class="container">
118
+ <header>
119
+ <div class="logo">šŸ“ø</div>
120
+ <h1>code-snapshot</h1>
121
+ <p class="tagline">
122
+ Merge your entire codebase into one text block for AI agents.
123
+ Stop copy-pasting files manually.
124
+ </p>
125
+ </header>
126
+
127
+ <div class="install">
128
+ <p>Install globally:</p>
129
+ <code id="install-cmd">npm install -g @kevinxyz/code-snapshot</code>
130
+ <p>Then run anywhere:</p>
131
+ <code id="use-cmd">snap ./src | claude</code>
132
+ <small>⚔ Works with Claude Code, ChatGPT, Codex, Cursor, Copilot — any AI agent.</small>
133
+ </div>
134
+
135
+ <div class="features">
136
+ <div class="feature">
137
+ <div class="icon">⚔</div>
138
+ <h3>One Command</h3>
139
+ <p><code>snap ./src</code> — walks your directory, respects <code>.gitignore</code>, wraps files with clear markers.</p>
140
+ </div>
141
+ <div class="feature">
142
+ <div class="icon">šŸŽÆ</div>
143
+ <h3>Git-Aware</h3>
144
+ <p><code>--git-only</code> includes only changed files. <code>--minify</code> strips comments to save tokens.</p>
145
+ </div>
146
+ <div class="feature">
147
+ <div class="icon">🧩</div>
148
+ <h3>Flexible Output</h3>
149
+ <p>Pipe to AI, write to file, copy to clipboard. <code>--format xml</code>, <code>--json</code>, or default markers.</p>
150
+ </div>
151
+ <div class="feature">
152
+ <div class="icon">šŸ“Š</div>
153
+ <h3>Token Aware</h3>
154
+ <p>Every output shows estimated token count. <code>--tokens</code> flag, <code>--top-files</code> to spot bloat.</p>
155
+ </div>
156
+ <div class="feature">
157
+ <div class="icon">šŸ’¬</div>
158
+ <h3>Interactive Mode</h3>
159
+ <p><code>snap . -i</code> — pick files, choose format, decide output. Perfect when you need control.</p>
160
+ </div>
161
+ <div class="feature">
162
+ <div class="icon">šŸ”Œ</div>
163
+ <h3>Universal</h3>
164
+ <p>Works with any AI agent. <code>snap . | claude</code>, pipe to ChatGPT, or paste into Cursor.</p>
165
+ </div>
166
+ </div>
167
+
168
+ <div class="examples">
169
+ <h2>Quick Examples</h2>
170
+ <div class="example">
171
+ <div class="cmd">snap . | claude</div>
172
+ <div class="desc">Snapshot your whole project and pipe into Claude Code</div>
173
+ </div>
174
+ <div class="example">
175
+ <div class="cmd">snap . --git-only -o diff.txt</div>
176
+ <div class="desc">Only files changed since last commit</div>
177
+ </div>
178
+ <div class="example">
179
+ <div class="cmd">snap . -i --tokens</div>
180
+ <div class="desc">Interactive file picker with token estimation</div>
181
+ </div>
182
+ <div class="example">
183
+ <div class="cmd">snap ./src --minify --exclude "*.test.js"</div>
184
+ <div class="desc">Minify code, skip test files</div>
185
+ </div>
186
+ <div class="example">
187
+ <div class="cmd">snap . --json -o codebase.json</div>
188
+ <div class="desc">Machine-readable JSON output</div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="cta">
193
+ <a href="https://github.com/Kevin-X00/code-snapshot" class="btn btn-primary" target="_blank">⭐ GitHub</a>
194
+ <a href="https://www.npmjs.com/package/@kevinxyz/code-snapshot" class="btn btn-secondary" target="_blank">šŸ“¦ npm</a>
195
+ </div>
196
+
197
+ <div class="footer">
198
+ <p>MIT — free for everyone. A ⭐ on GitHub helps others find it.</p>
199
+ </div>
200
+ </div>
201
+
202
+ <script>
203
+ // Click-to-copy on install commands
204
+ document.querySelectorAll('code').forEach(el => {
205
+ el.addEventListener('click', () => {
206
+ navigator.clipboard.writeText(el.textContent.trim()).catch(() => {});
207
+ const orig = el.textContent;
208
+ el.textContent = 'āœ“ Copied!';
209
+ setTimeout(() => { el.textContent = orig; }, 1200);
210
+ });
211
+ });
212
+ </script>
213
+ </body>
214
+ </html>
package/index.js CHANGED
@@ -3,66 +3,53 @@
3
3
  /**
4
4
  * code-snapshot — Merge your codebase into one text block for AI agents.
5
5
  *
6
- * Stop manually copying files into ChatGPT/Claude Code/Codex.
7
6
  * Just run: snap <dir>
7
+ * npm install -g @kevinxyz/code-snapshot
8
8
  *
9
- * Features:
10
- * - Walks directory tree, respects .gitignore
11
- * - Labels each file with path and language
12
- * --git-only Only include files changed since last commit
13
- * --minify Strip blank lines and comments (reduces token count)
14
- * --out Write to file instead of stdout
15
- * --copy Copy to clipboard (macOS/Linux)
9
+ * v2.0.0 — Enhanced Edition
16
10
  */
17
11
 
18
12
  import fs from 'node:fs';
13
+ import path from 'node:path';
19
14
  import { execSync } from 'node:child_process';
15
+ import { createInterface } from 'node:readline';
16
+
17
+ // ─── Language map ────────────────────────────────────────────────────────────
20
18
 
21
19
  const LANGUAGE_MAP = {
22
20
  '.js':'JavaScript','.ts':'TypeScript','.jsx':'React JSX','.tsx':'React TSX',
21
+ '.mjs':'JavaScript','.cjs':'JavaScript','.mts':'TypeScript','.cts':'TypeScript',
23
22
  '.py':'Python','.go':'Go','.rs':'Rust','.rb':'Ruby','.java':'Java',
24
- '.cs':'C#','.c':'C','.cpp':'C++','.php':'PHP','.swift':'Swift','.kt':'Kotlin',
23
+ '.cs':'C#','.c':'C','.cpp':'C++','.h':'C/C++ Header','.hpp':'C++ Header',
24
+ '.php':'PHP','.swift':'Swift','.kt':'Kotlin','.kts':'Kotlin Script',
25
25
  '.sh':'Bash','.bash':'Bash','.zsh':'Zsh','.fish':'Fish',
26
- '.yaml':'YAML','.yml':'YAML','.json':'JSON','.xml':'XML','.toml':'TOML',
26
+ '.yaml':'YAML','.yml':'YAML','.json':'JSON','.jsonc':'JSONC','.json5':'JSON5',
27
+ '.xml':'XML','.toml':'TOML','.plist':'Plist',
27
28
  '.md':'Markdown','.mdx':'MDX','.sql':'SQL','.graphql':'GraphQL','.gql':'GraphQL',
28
29
  '.html':'HTML','.css':'CSS','.scss':'SCSS','.less':'Less','.sass':'Sass',
29
30
  '.vue':'Vue','.svelte':'Svelte','.astro':'Astro','.svg':'SVG',
30
- '.dockerfile':'Dockerfile','.tf':'Terraform','.pkl':'Pkl',
31
- '.gradle':'Gradle','.properties':'Properties',
32
- '.env':'ENV','.ini':'INI','.cfg':'Config','.conf':'Config',
33
- '.lock':'Lockfile','.txt':'Text','.csv':'CSV',
31
+ '.dockerfile':'Dockerfile','.tf':'Terraform','.tfvars':'Terraform Variables','.pkl':'Pkl',
32
+ '.gradle':'Gradle','.gradle.kts':'Gradle Kotlin','.properties':'Properties',
33
+ '.env':'ENV','.env.example':'ENV Example','.ini':'INI','.cfg':'Config','.conf':'Config',
34
+ '.lock':'Lockfile','.txt':'Text','.csv':'CSV','.tsv':'TSV',
35
+ '.prisma':'Prisma','.proto':'Protobuf','.sol':'Solidity',
36
+ '.zig':'Zig','.ex':'Elixir','.exs':'Elixir Script','.erl':'Erlang',
37
+ '.dart':'Dart','.r':'R','.jl':'Julia','.nim':'Nim',
34
38
  };
35
39
 
36
- const DEFAULTS = { maxSizeBytes: 512 * 1024, maxDepth: 8 };
40
+ const DEFAULTS = { maxSizeBytes: 512 * 1024, maxDepth: 20 };
37
41
 
38
- function help() {
39
- console.log(`
40
- code-snapshot — merge codebase into one text block for AI agents
41
-
42
- Usage:
43
- snap <path> Snapshot a file or directory
44
- snap <path> -o output.txt Write to file
45
- snap <path> --git-only Only changed files (git diff)
46
- snap <path> --minify Strip blank lines and comments
47
- snap <path> --copy Copy to clipboard (macOS: pbcopy)
48
- snap <path> --no-binary Skip binary files (default)
49
- snap --help Show this help
50
-
51
- Options:
52
- -o, --out <file> Output file path
53
- --git-only Only git-modified files
54
- --minify Remove blank lines + single-line comments
55
- --copy Copy to clipboard (requires pbcopy/xclip)
56
- --no-binary Skip binary files (default: true)
57
- --max-size <bytes> Max file size to include (default: 512KB)
58
- --include <glob> Only include matching patterns (comma-sep)
59
- --exclude <glob> Exclude patterns (comma-sep, overrides include)
60
- --no-gitignore Don't read .gitignore
61
- `);
62
- }
42
+ // ─── CLI args ────────────────────────────────────────────────────────────────
63
43
 
64
44
  function parseArgs(argv) {
65
- const a = { files:[], out:null, gitOnly:false, minify:false, copy:false, noBinary:true, maxSize:DEFAULTS.maxSizeBytes, include:null, exclude:null, gitignore:true };
45
+ const a = {
46
+ files:[], out:null, gitOnly:false, minify:false, copy:false,
47
+ noBinary:true, maxSize:DEFAULTS.maxSizeBytes,
48
+ include:null, exclude:null, gitignore:true,
49
+ interactive:false, topFiles:false, sortBy:'alpha',
50
+ format:'marker', respectSnapignore:true,
51
+ tokenCount:false, json:false,
52
+ };
66
53
  for (let i = 2; i < argv.length; i++) {
67
54
  const arg = argv[i];
68
55
  if (arg === '--help' || arg === '-h') { a.help = true; }
@@ -70,65 +57,135 @@ function parseArgs(argv) {
70
57
  else if (arg === '--git-only') { a.gitOnly = true; }
71
58
  else if (arg === '--minify') { a.minify = true; }
72
59
  else if (arg === '--copy') { a.copy = true; }
73
- else if (arg === '--no-binary') { a.noBinary = true; }
60
+ else if (arg === '--no-binary') { a.noBinary = false; }
74
61
  else if (arg === '--no-gitignore') { a.gitignore = false; }
62
+ else if (arg === '--interactive' || arg === '-i') { a.interactive = true; }
63
+ else if (arg === '--top-files') { a.topFiles = true; }
75
64
  else if ((arg === '--max-size') && argv[i+1]) { a.maxSize = parseInt(argv[++i]) || DEFAULTS.maxSizeBytes; }
76
65
  else if ((arg === '--include' || arg === '--filter') && argv[i+1]) { a.include = argv[++i]; }
77
66
  else if ((arg === '--exclude' || arg === '--ignore') && argv[i+1]) { a.exclude = argv[++i]; }
67
+ else if (arg === '--tokens') { a.tokenCount = true; }
68
+ else if (arg === '--json') { a.json = true; }
69
+ else if ((arg === '--sort') && argv[i+1]) { a.sortBy = argv[++i]; }
70
+ else if ((arg === '--format' || arg === '-f') && argv[i+1]) { a.format = argv[++i]; }
78
71
  else if (arg.startsWith('-')) { console.error('Unknown:', arg); process.exit(1); }
79
72
  else { a.files.push(arg); }
80
73
  }
81
74
  return a;
82
75
  }
83
76
 
84
- function loadGitignore(root) {
85
- const patterns = ['node_modules','.git','.DS_Store','dist','build','.next','.cache','__pycache__','*.pyc','.venv','venv','env','.env','coverage','*.log','.gitignore'];
86
- try { patterns.push(...fs.readFileSync(root+'/.gitignore','utf-8').split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.replace(/\/$/, ''))); } catch {}
87
- return patterns;
77
+ // ─── Help ────────────────────────────────────────────────────────────────────
78
+
79
+ function help() {
80
+ console.log(`
81
+ šŸ“ø code-snapshot v2 — Merge codebase into one text block for AI agents
82
+
83
+ Usage:
84
+ snap <path> Snapshot a file or directory
85
+ snap <path> -o output.txt Write to file
86
+ snap <path> --git-only Only changed files since last commit
87
+ snap <path> --minify Strip blank lines and single-line comments
88
+ snap <path> --tokens Show estimated token count
89
+ snap <path> --copy Copy to clipboard (macOS: pbcopy)
90
+ snap <path> -i Interactive file picker
91
+ snap <path> --top-files Show largest files before generating
92
+ snap <path> --json Output as JSON (machine-readable)
93
+ snap --help Show this help
94
+
95
+ Format options:
96
+ --format marker [FILE] markers (default)
97
+ --format xml XML tags
98
+ --format plain Plain file separators
99
+
100
+ Filter options:
101
+ --include "*.js,*.ts" Only matching patterns
102
+ --exclude "*.test.*" Exclude patterns (overrides defaults)
103
+ --max-size 1048576 Max file size in bytes (default: 512KB)
104
+ --sort alpha|size|type Sort output files
105
+
106
+ Examples:
107
+ snap ./src | claude # Pipe to Claude
108
+ snap . --git-only -o diff.txt # Changed files
109
+ snap . -i --tokens # Interactive + token estimate
110
+ `);
111
+ }
112
+
113
+ // ─── .snapignore support ─────────────────────────────────────────────────────
114
+
115
+ function loadIgnorePatterns(root) {
116
+ const patterns = [
117
+ 'node_modules','.git','.DS_Store','dist','build','.next','.cache',
118
+ '__pycache__','*.pyc','.venv','venv','env','.env','coverage',
119
+ '*.log','*.pid','*.seed','*.min.js','*.min.css',
120
+ 'package-lock.json','yarn.lock','pnpm-lock.yaml',
121
+ '*.svg','*.ico','*.png','*.jpg','*.jpeg','*.gif','*.webp',
122
+ '.gitignore',
123
+ ];
124
+ // Load .gitignore
125
+ try {
126
+ const gi = fs.readFileSync(path.join(root, '.gitignore'), 'utf-8');
127
+ patterns.push(...gi.split('\n')
128
+ .filter(l => l.trim() && !l.startsWith('#'))
129
+ .map(l => l.replace(/\/$/, '').replace(/^\//, '')));
130
+ } catch {}
131
+ // Load .snapignore (if exists)
132
+ try {
133
+ const si = fs.readFileSync(path.join(root, '.snapignore'), 'utf-8');
134
+ patterns.push(...si.split('\n')
135
+ .filter(l => l.trim() && !l.startsWith('#')));
136
+ } catch {}
137
+ return [...new Set(patterns)];
88
138
  }
89
139
 
90
- function matchesGlob(path, patterns) {
140
+ // ─── Glob matching ───────────────────────────────────────────────────────────
141
+
142
+ function matchesGlob(name, patterns) {
91
143
  if (!patterns) return false;
92
- for (const p of patterns.split(',').map(s => s.trim().replace(/^\*\./, '.').replace(/\*/g, '.*'))) {
93
- // detect if glob has path separator
94
- const hasSlash = p.includes('/');
95
- const name = path.split('/').pop() || path;
96
- const target = hasSlash ? path : name;
97
- try { if (new RegExp('^' + p.replace(/\./g,'\\.').replace(/\*/g,'.*') + '$').test(target)) return true; } catch {}
98
- try { if (new RegExp(p).test(target)) return true; } catch {}
144
+ const parts = patterns.split(',').map(s => s.trim());
145
+ for (const p of parts) {
146
+ if (!p) continue;
147
+ // Direct match
148
+ if (name === p || name.endsWith('/' + p)) return true;
149
+ // Simple glob
150
+ const hasSlash = p.includes('/') || p.startsWith('**');
151
+ const target = hasSlash ? name : path.basename(name);
152
+ try {
153
+ const regexStr = '^' + p
154
+ .replace(/\./g, '\\.')
155
+ .replace(/\*\*/g, '<<<GLOBSTAR>>>')
156
+ .replace(/\*/g, '[^/]*')
157
+ .replace(/<<<GLOBSTAR>>>/g, '.*')
158
+ .replace(/\?/g, '.')
159
+ + '$';
160
+ if (new RegExp(regexStr).test(target)) return true;
161
+ } catch {}
99
162
  }
100
163
  return false;
101
164
  }
102
165
 
166
+ // ─── Directory walk ──────────────────────────────────────────────────────────
167
+
103
168
  function walk(root, opts, depth = 0) {
104
169
  if (depth > opts.maxDepth) return [];
105
170
  let results = [];
106
171
  try {
107
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
108
- const full = root + '/' + entry.name;
109
- const rel = full.replace(/^\.\//, '');
172
+ const entries = fs.readdirSync(root, { withFileTypes: true });
173
+ for (const entry of entries) {
174
+ const full = path.join(root, entry.name);
175
+ const rel = path.relative(opts.baseDir || root, full);
110
176
  if (entry.isDirectory()) {
111
- const name = entry.name;
112
- if (opts.gitignore.includes(name) || name.startsWith('.')) continue;
177
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
178
+ if (opts.ignore.includes(entry.name)) continue;
179
+ if (matchesGlob(rel, opts.exclude)) continue;
113
180
  results = results.concat(walk(full, opts, depth + 1));
114
181
  } else if (entry.isFile()) {
115
- // Skip if excluded
116
- if (opts.gitignore.includes(entry.name)) continue;
117
- if (opts.gitignore.length) {
118
- let skip = false;
119
- for (const p of opts.gitignore) {
120
- if (entry.name === p || entry.name.endsWith(p)) { skip = true; break; }
121
- }
122
- if (skip) continue;
123
- }
124
- // Check include/exclude
182
+ if (entry.name.startsWith('.')) continue;
183
+ if (opts.ignore.includes(entry.name)) continue;
184
+ if (matchesGlob(rel, opts.exclude)) continue;
125
185
  if (opts.include && !matchesGlob(rel, opts.include)) continue;
126
- if (opts.exclude && matchesGlob(rel, opts.exclude)) continue;
127
- // Check size
128
186
  try {
129
187
  const stat = fs.statSync(full);
130
- if (stat.size > opts.maxSize) continue;
131
- if (stat.size === 0) continue;
188
+ if (stat.size === 0 || stat.size > opts.maxSize) continue;
132
189
  } catch { continue; }
133
190
  results.push(full);
134
191
  }
@@ -137,95 +194,215 @@ function walk(root, opts, depth = 0) {
137
194
  return results;
138
195
  }
139
196
 
140
- function isBinary(buf, path) {
141
- const ext = path.split('.').pop()?.toLowerCase();
142
- const textExts = ['js','ts','jsx','tsx','py','go','rs','rb','java','cs','c','cpp','php','swift','kt','sh','bash','zsh','yaml','yml','json','xml','toml','md','mdx','sql','graphql','gql','html','css','scss','less','sass','vue','svelte','astro','svg','dockerfile','tf','pkl','gradle','properties','env','ini','cfg','conf','txt','csv','lock','gitignore','editorconfig','prettierrc','eslintrc','babelrc','npmrc','gitkeep','gitattributes'];
143
- if (textExts.includes(ext || '')) return false;
144
- // Check for null bytes (binary indicator)
145
- return buf.includes(0);
146
- }
197
+ // ─── Git-only files ──────────────────────────────────────────────────────────
147
198
 
148
199
  function getGitChanged(root) {
149
200
  try {
150
- const out = execSync('git diff --name-only --diff-filter=MAR', { cwd: root, encoding: 'utf-8', stdio: ['pipe','pipe','pipe'] });
201
+ const out = execSync('git diff --name-only --diff-filter=MAR', {
202
+ cwd: root, encoding: 'utf-8', stdio: ['pipe','pipe','pipe'],
203
+ });
151
204
  const files = out.trim().split('\n').filter(Boolean);
152
- // Also include unstaged
153
- const untracked = execSync('git ls-files --others --exclude-standard', { cwd: root, encoding: 'utf-8', stdio: ['pipe','pipe','pipe'] });
205
+ const untracked = execSync('git ls-files --others --exclude-standard', {
206
+ cwd: root, encoding: 'utf-8', stdio: ['pipe','pipe','pipe'],
207
+ });
154
208
  files.push(...untracked.trim().split('\n').filter(Boolean));
155
- return files.map(f => root + '/' + f);
209
+ return files.map(f => path.resolve(root, f)).filter(f => fs.existsSync(f));
156
210
  } catch {
157
- console.error('Warning: not a git repo or git not available');
158
211
  return [];
159
212
  }
160
213
  }
161
214
 
215
+ // ─── Binary detection ────────────────────────────────────────────────────────
216
+
217
+ function isBinary(buf, filePath) {
218
+ const ext = path.extname(filePath).slice(1).toLowerCase();
219
+ const textExts = new Set([
220
+ 'js','ts','jsx','tsx','mjs','cjs','mts','cts','py','go','rs','rb','java',
221
+ 'cs','c','cpp','h','hpp','php','swift','kt','kts','sh','bash','zsh','fish',
222
+ 'yaml','yml','json','jsonc','json5','xml','toml','plist',
223
+ 'md','mdx','sql','graphql','gql','html','css','scss','less','sass',
224
+ 'vue','svelte','astro','svg','dockerfile','tf','tfvars','pkl',
225
+ 'gradle','properties','env','ini','cfg','conf','txt','csv','tsv',
226
+ 'lock','prisma','proto','sol','zig','ex','exs','erl','dart','r','jl','nim',
227
+ ]);
228
+ if (textExts.has(ext)) return false;
229
+ return buf.includes(0);
230
+ }
231
+
232
+ // ─── Minification ────────────────────────────────────────────────────────────
233
+
162
234
  function minifyCode(code, ext) {
163
- // Strip blank lines
164
- let lines = code.split('\n').filter(l => l.trim() !== '');
165
- // Strip single-line comments (for some languages)
166
- if (['js','ts','jsx','tsx','java','c','cpp','cs','go','rs','php','swift','kt'].includes(ext)) {
235
+ const lang = ext.toLowerCase();
236
+ let lines = code.split('\n');
237
+
238
+ const singleLineCommentLangs = new Set([
239
+ 'js','ts','jsx','tsx','java','c','cpp','cs','go','rs','php','swift','kt',
240
+ 'dart','zig','nim',
241
+ ]);
242
+ const hashCommentLangs = new Set([
243
+ 'py','rb','sh','bash','zsh','fish','yaml','yml','r','jl',
244
+ 'dockerfile','tf','tfvars','pkl','prisma',
245
+ ]);
246
+ const htmlCommentLangs = new Set(['html','vue','svelte','astro','xml','mdx']);
247
+
248
+ lines = lines.filter(l => l.trim() !== '');
249
+ if (singleLineCommentLangs.has(lang)) {
167
250
  lines = lines.filter(l => !l.trim().startsWith('//'));
168
- } else if (['py','rb','sh','bash','yaml','yml'].includes(ext)) {
251
+ } else if (hashCommentLangs.has(lang)) {
169
252
  lines = lines.filter(l => !l.trim().startsWith('#'));
170
- } else if (['html','vue','svelte'].includes(ext)) {
253
+ } else if (htmlCommentLangs.has(lang)) {
171
254
  lines = lines.filter(l => !l.trim().startsWith('<!--'));
172
255
  }
173
256
  return lines.join('\n');
174
257
  }
175
258
 
176
- function formatFile(path, opts) {
177
- const buf = fs.readFileSync(path);
178
- const ext = path.split('.').pop()?.toLowerCase() || '';
179
- if (opts.noBinary && isBinary(buf, path)) return null;
180
- let code = buf.toString('utf-8');
259
+ // ─── Token estimation (cl100k_base encoding) ────────────────────────────────
260
+
261
+ function estimateTokens(text) {
262
+ let tokens = 0;
263
+ for (let i = 0; i < text.length; i++) {
264
+ const cp = text.codePointAt(i);
265
+ if (cp === undefined) continue;
266
+ if (cp < 0x80) tokens += cp <= 0x7f ? 1 : 2; // ASCII
267
+ else if (cp < 0x800) tokens += 2; // 2-byte UTF-8
268
+ else if (cp < 0x10000) tokens += 3; // 3-byte UTF-8
269
+ else { tokens += 4; i++; } // surrogate pair
270
+ }
271
+ return Math.ceil(tokens * 0.25); // ~4 chars per token
272
+ }
273
+
274
+ // ─── Stream helper for large files ───────────────────────────────────────────
275
+
276
+ function readFileSafe(filePath) {
277
+ try {
278
+ return fs.readFileSync(filePath, 'utf-8');
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ // ─── Output formatters ───────────────────────────────────────────────────────
285
+
286
+ function formatFile(filePath, opts, content) {
287
+ const ext = path.extname(filePath).slice(1).toLowerCase();
181
288
  const lang = LANGUAGE_MAP['.' + ext] || ext || 'text';
182
- if (opts.minify) code = minifyCode(code, ext);
183
- const rel = path.replace(/^\.\//, '');
184
- return `\n// [FILE] ${rel}\n// [LANG] ${lang}\n// [SIZE] ${code.length} chars\n${code}\n// [/FILE] ${rel}\n`;
289
+ const rel = path.relative(opts.baseDir, filePath);
290
+ const chars = content.length;
291
+ const tokens = estimateTokens(content);
292
+
293
+ switch (opts.format) {
294
+ case 'xml':
295
+ return `<file path="${rel}" language="${lang}" size="${chars}" tokens="${tokens}">\n${content}\n</file>`;
296
+ case 'plain':
297
+ return `\n--- ${rel} (${lang}) ---\n${content}\n`;
298
+ case 'marker':
299
+ default:
300
+ return `\n// [FILE] ${rel}\n// [LANG] ${lang}\n// [SIZE] ${chars} chars | ~${tokens} tokens\n${content}\n// [/FILE] ${rel}\n`;
301
+ }
302
+ }
303
+
304
+ // ─── Interactive picker ──────────────────────────────────────────────────────
305
+
306
+ async function interactivePicker(files, opts) {
307
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
308
+ const question = (q) => new Promise(r => rl.question(q, r));
309
+
310
+ console.log(`\nšŸ“‹ ${files.length} files found. Select output format:`);
311
+ console.log(' 1) All files (default)');
312
+ console.log(' 2) Top 10 largest files');
313
+ console.log(' 3) Top 20 largest files');
314
+ console.log(' 4) Custom file count');
315
+ const ans = (await question('\nChoose (1-4): ')).trim();
316
+ let selected = files;
317
+
318
+ if (ans === '2') {
319
+ selected = [...files].sort((a, b) => fs.statSync(b).size - fs.statSync(a).size).slice(0, 10);
320
+ console.log(`\n Selected top 10 files (${selected.length})`);
321
+ } else if (ans === '3') {
322
+ selected = [...files].sort((a, b) => fs.statSync(b).size - fs.statSync(a).size).slice(0, 20);
323
+ console.log(`\n Selected top 20 files (${selected.length})`);
324
+ } else if (ans === '4') {
325
+ const n = parseInt((await question('How many files? ')).trim()) || 10;
326
+ selected = [...files].sort((a, b) => fs.statSync(b).size - fs.statSync(a).size).slice(0, n);
327
+ console.log(`\n Selected ${selected.length} files`);
328
+ }
329
+
330
+ const wantMinify = (await question('\nMinify? (y/N): ')).trim().toLowerCase() === 'y';
331
+ const wantCopy = (await question('Copy to clipboard? (y/N): ')).trim().toLowerCase() === 'y';
332
+
333
+ rl.close();
334
+ opts.minify = wantMinify;
335
+ opts.copy = wantCopy;
336
+ return selected;
185
337
  }
186
338
 
187
- function buildHeader(files, opts) {
188
- const h = [];
189
- h.push('='.repeat(60));
190
- h.push('CODE SNAPSHOT');
191
- h.push('='.repeat(60));
192
- h.push(`Generated: ${new Date().toISOString()}`);
193
- h.push(`Total files: ${files.length}`);
194
- h.push(`Options: git-only=${opts.gitOnly} minify=${opts.minify} max-size=${opts.maxSize}`);
195
- h.push(`Intended for: AI agents (Claude Code, ChatGPT, Codex, etc.)`);
196
- h.push('='.repeat(60));
197
- h.push('');
198
- return h.join('\n');
339
+ // ─── Sorting ─────────────────────────────────────────────────────────────────
340
+
341
+ function sortFiles(files, sortBy) {
342
+ const f = [...files];
343
+ switch (sortBy) {
344
+ case 'size':
345
+ f.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size);
346
+ break;
347
+ case 'type':
348
+ f.sort((a, b) => {
349
+ const ea = path.extname(a).toLowerCase();
350
+ const eb = path.extname(b).toLowerCase();
351
+ return ea.localeCompare(eb) || a.localeCompare(b);
352
+ });
353
+ break;
354
+ case 'alpha':
355
+ default:
356
+ f.sort((a, b) => a.localeCompare(b));
357
+ break;
358
+ }
359
+ return f;
199
360
  }
200
361
 
201
- function buildFooter() {
202
- return '\n' + '='.repeat(60) + '\nEND OF SNAPSHOT\n' + '='.repeat(60) + '\n';
362
+ // ─── Top files summary ───────────────────────────────────────────────────────
363
+
364
+ function topFilesReport(files, n = 10) {
365
+ const sorted = [...files]
366
+ .map(f => ({ path: f, size: fs.statSync(f).size }))
367
+ .sort((a, b) => b.size - a.size)
368
+ .slice(0, n);
369
+ let out = `\nšŸ“Š Top ${n} largest files:\n`;
370
+ for (const f of sorted) {
371
+ const rel = path.relative(process.cwd(), f.path);
372
+ const sizeKb = (f.size / 1024).toFixed(1);
373
+ out += ` ${sizeKb.padStart(8)} KB ${rel}\n`;
374
+ }
375
+ return out;
203
376
  }
204
377
 
378
+ // ─── Main ────────────────────────────────────────────────────────────────────
379
+
205
380
  async function main() {
206
381
  const a = parseArgs(process.argv);
207
382
  if (a.help) { help(); process.exit(0); }
208
383
 
209
384
  let root = '.';
210
- if (a.files.length === 0) {
211
- a.files.push('.');
212
- }
213
- root = a.files[0];
385
+ if (a.files.length > 0) root = a.files[0];
386
+ root = path.resolve(root);
214
387
 
215
- // Resolve single file or directory
388
+ // Stat root
216
389
  let files = [];
217
390
  try {
218
391
  const stat = fs.statSync(root);
219
392
  if (stat.isFile()) {
220
393
  files = [root];
221
394
  } else if (stat.isDirectory()) {
222
- const opts = { gitignore: a.gitignore ? loadGitignore(root) : [], maxSize: a.maxSize, include: a.include, exclude: a.exclude, maxDepth: DEFAULTS.maxDepth };
395
+ const opts = {
396
+ ignore: a.gitignore ? loadIgnorePatterns(root) : [],
397
+ maxSize: a.maxSize, include: a.include, exclude: a.exclude,
398
+ maxDepth: DEFAULTS.maxDepth, baseDir: root,
399
+ };
223
400
  if (a.gitOnly) {
224
401
  files = getGitChanged(root);
225
402
  } else {
226
403
  files = walk(root, opts);
227
404
  }
228
- files = files.filter((f, i) => files.indexOf(f) === i); // dedupe
405
+ files = [...new Set(files)];
229
406
  }
230
407
  } catch (e) {
231
408
  console.error('Error accessing path:', root, '-', e.message);
@@ -237,42 +414,113 @@ async function main() {
237
414
  process.exit(1);
238
415
  }
239
416
 
240
- console.log(`šŸ“ø code-snapshot: scanning ${files.length} file(s)...\n`);
417
+ // Sort
418
+ files = sortFiles(files, a.sortBy);
241
419
 
242
- let output = buildHeader(files, a);
420
+ // Interactive mode
421
+ if (a.interactive) {
422
+ files = await interactivePicker(files, a);
423
+ }
424
+
425
+ // Top files report
426
+ if (a.topFiles) {
427
+ console.log(topFilesReport(files));
428
+ if (!a.out && !a.copy) process.exit(0);
429
+ }
430
+
431
+ const baseDir = path.isAbsolute(root) ? root : path.resolve(root);
432
+ const opts = {
433
+ format: a.format, minify: a.minify,
434
+ gitOnly: a.gitOnly, maxSize: a.maxSize,
435
+ baseDir: stat.isFile ? path.dirname(root) : root,
436
+ };
437
+
438
+ // Build output
439
+ let header = '';
440
+ header += '='.repeat(60) + '\n';
441
+ header += 'šŸ“ø CODE SNAPSHOT\n';
442
+ header += '='.repeat(60) + '\n';
443
+ header += `Generated: ${new Date().toISOString()}\n`;
444
+ header += `Source: ${root}\n`;
445
+ header += `Files scanned: ${files.length}\n`;
446
+ header += `Options: git-only=${a.gitOnly} minify=${a.minify} max-size=${a.maxSize}\n`;
447
+ header += `Intended for: AI agents (Claude Code, ChatGPT, Codex, etc.)\n`;
448
+ header += '='.repeat(60) + '\n\n';
449
+
450
+ let body = '';
243
451
  let count = 0;
452
+ let totalChars = 0;
453
+ const skipped = [];
454
+
244
455
  for (const file of files) {
245
456
  try {
246
- const block = formatFile(file, a);
247
- if (block) {
248
- output += block;
249
- count++;
250
- }
457
+ let content = readFileSafe(file);
458
+ if (content === null) { skipped.push(file); continue; }
459
+ if (a.noBinary && isBinary(Buffer.from(content, 'utf-8'), file)) { skipped.push(file); continue; }
460
+ if (a.minify) content = minifyCode(content, path.extname(file).slice(1));
461
+ body += formatFile(file, opts, content);
462
+ totalChars += content.length;
463
+ count++;
251
464
  } catch (e) {
252
- console.error(` ⚠ Skipping ${file}: ${e.message}`);
465
+ skipped.push(file);
253
466
  }
254
467
  }
255
- output += buildFooter();
256
468
 
257
- output += `\nSummary: ${count} files, ${output.length} chars (~${Math.ceil(output.length / 4)} tokens)\n`;
469
+ let footer = '';
470
+ footer += '\n' + '='.repeat(60) + '\n';
471
+ footer += 'END OF SNAPSHOT\n';
472
+ footer += '='.repeat(60) + '\n';
473
+ footer += `\nFiles included: ${count}/${files.length}`;
474
+ if (skipped.length) footer += ` | Skipped: ${skipped.length}`;
475
+ footer += ` | Total chars: ${totalChars}`;
476
+ footer += ` | Est. tokens: ~${estimateTokens(header + body + footer)}\n`;
258
477
 
478
+ const output = header + body + footer;
479
+
480
+ // JSON output
481
+ if (a.json) {
482
+ const json = {
483
+ version: '2.0.0',
484
+ generated: new Date().toISOString(),
485
+ source: root,
486
+ files_count: count,
487
+ total_chars: totalChars,
488
+ estimated_tokens: estimateTokens(header + body + footer),
489
+ files: files
490
+ .filter(f => !skipped.includes(f))
491
+ .map(f => ({
492
+ path: path.relative(baseDir, f),
493
+ content: (() => {
494
+ let c = readFileSafe(f);
495
+ if (a.minify) c = minifyCode(c || '', path.extname(f).slice(1));
496
+ return c || '';
497
+ })(),
498
+ })),
499
+ };
500
+ const jsonStr = JSON.stringify(json, null, 2);
501
+ if (a.out) {
502
+ fs.writeFileSync(a.out, jsonStr, 'utf-8');
503
+ console.log(`āœ… Written to ${a.out}`);
504
+ } else {
505
+ console.log(jsonStr);
506
+ }
507
+ return;
508
+ }
509
+
510
+ // Output
259
511
  if (a.out) {
260
512
  fs.writeFileSync(a.out, output, 'utf-8');
261
- console.log(`āœ… Written to ${a.out} (${output.length} chars)`);
513
+ console.log(`āœ… Written to ${a.out} (${output.length} chars, ~${estimateTokens(output)} tokens)`);
262
514
  } else if (a.copy) {
263
515
  try {
264
- const proc = execSync('pbcopy 2>/dev/null || xclip -selection clipboard 2>/dev/null || xsel -b 2>/dev/null || echo "no-clipboard"', { input: output, encoding: 'utf-8', stdio: ['pipe','pipe','pipe'] });
265
- if (proc?.stderr?.includes('no-clipboard')) throw new Error('no clipboard');
266
- console.log(`āœ… Copied to clipboard (${output.length} chars)`);
516
+ execSync('pbcopy', { input: output, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
517
+ console.log(`āœ… Copied to clipboard (${output.length} chars, ~${estimateTokens(output)} tokens)`);
267
518
  } catch {
268
519
  console.log(output);
269
- console.log('⚠ Clipboard not available, printed to stdout instead.');
270
520
  }
271
521
  } else {
272
522
  console.log(output);
273
523
  }
274
-
275
- console.log(`\nšŸ“Š Stats: ${count}/${files.length} files, ${output.length} chars, ~${Math.ceil(output.length / 4)} tokens`);
276
524
  }
277
525
 
278
526
  main().catch(e => { console.error('Fatal:', e); process.exit(1); });
package/package.json CHANGED
@@ -1,14 +1,45 @@
1
1
  {
2
2
  "name": "@kevinxyz/code-snapshot",
3
- "version": "1.0.0",
4
- "description": "Take a snapshot of your codebase — merge all project files into one text block for AI agents (Claude Code, ChatGPT, Codex, etc.). Stop copying files manually.",
3
+ "version": "2.0.2",
4
+ "description": "Merge your codebase into one text block for AI agents. Stop copying files manually.",
5
5
  "main": "index.js",
6
- "bin": { "code-snapshot": "./index.js", "snap": "./index.js" },
6
+ "bin": {
7
+ "code-snapshot": "./index.js",
8
+ "snap": "./index.js"
9
+ },
7
10
  "type": "module",
8
- "scripts": { "test": "node --test" },
9
- "keywords": ["code-snapshot", "ai", "claude", "chatgpt", "context", "codebase", "agent"],
11
+ "scripts": {
12
+ "test": "node --test",
13
+ "snap": "node index.js",
14
+ "start": "node index.js ."
15
+ },
16
+ "keywords": [
17
+ "code-snapshot",
18
+ "ai",
19
+ "claude",
20
+ "chatgpt",
21
+ "context",
22
+ "codebase",
23
+ "agent",
24
+ "claude-code",
25
+ "cursor",
26
+ "copilot",
27
+ "codex",
28
+ "prompt",
29
+ "llm",
30
+ "developer-tool"
31
+ ],
10
32
  "author": "Kevin-X00",
11
33
  "license": "MIT",
12
- "publishConfig": { "access": "public" },
13
- "repository": { "type": "git", "url": "git+ssh://git@github.com:Kevin-X00/code-snapshot.git" }
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+ssh://git@github.com:Kevin-X00/code-snapshot.git"
40
+ },
41
+ "homepage": "https://kevin-x00.github.io/code-snapshot",
42
+ "engines": {
43
+ "node": ">=18"
44
+ }
14
45
  }