@kevinxyz/code-snapshot 1.0.0 → 2.0.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.
Files changed (4) hide show
  1. package/README.md +143 -27
  2. package/docs/index.html +214 -0
  3. package/index.js +385 -137
  4. package/package.json +22 -6
package/README.md CHANGED
@@ -1,55 +1,149 @@
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
+ **Just run it in your project root (no arguments = current dir):**
12
+
13
+ ```bash
14
+ snap
15
+ ```
16
+
17
+ Then pipe directly to any AI (replace `.` with your folder):
18
+
19
+ ```bash
20
+ snap . | claude # Pipe to Claude Code
21
+ snap . -i --tokens # Interactive + token estimate
22
+ snap . --git-only -o ctx.txt # Just changed files
23
+ snap . --copy # Copy to clipboard
24
+ ```
25
+
26
+ > šŸ’” **Tip:** `snap` with no args uses the current directory. If you get `ENOENT`, it means the folder doesn't exist — just run `snap .` in your project root.
27
+
28
+ ## Why code-snapshot?
16
29
 
17
30
  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
31
 
19
- **code-snapshot** automates this. One command gives you a clean, structured text block your AI agent can immediately understand.
32
+ **code-snapshot automates this.** One command gives you a clean, structured text block your AI agent can immediately understand.
33
+
34
+ ## Real Scenario
35
+
36
+ You have a project and want AI to help add a new feature. Without code-snapshot:
37
+
38
+ ```bash
39
+ # Manual: open each file, copy-paste one by one to AI
40
+ src/index.js → copy → paste to AI
41
+ src/utils/parser.js → copy → paste to AI
42
+ src/config.js → copy → paste to AI
43
+ package.json → copy → paste to AI
44
+ ...
45
+ # 10 files = 10 copy-pastes, easy to miss something
46
+ # AI says "I can't find the definition" → go back, find the file, copy again
47
+ ```
48
+
49
+ With code-snapshot:
50
+
51
+ ```bash
52
+ # One command, everything in one place
53
+ snap . -o context.txt
54
+ # Then drag context.txt into your AI chat
55
+ # AI gets full context in one shot, no back-and-forth
56
+ ```
57
+
58
+ ### Real output example
20
59
 
21
- ## Features
60
+ Run `snap` in your project, and you get:
61
+
62
+ ```
63
+ ============================================================
64
+ šŸ“ø CODE SNAPSHOT
65
+ ============================================================
66
+ Generated: 2026-06-07T12:33:00.000Z
67
+ Source: /my-project
68
+ Files scanned: 12
69
+ ============================================================
70
+
71
+ // [FILE] package.json
72
+ // [LANG] JSON
73
+ // [SIZE] 800 chars | ~200 tokens
74
+ { "name": "my-project", ... }
75
+
76
+ // [FILE] src/index.js
77
+ // [LANG] JavaScript
78
+ // [SIZE] 3400 chars | ~850 tokens
79
+ import { parseFile } from './utils/parser';
80
+ ...
81
+
82
+ // [FILE] src/utils/parser.js
83
+ // [LANG] JavaScript
84
+ // [SIZE] 5200 chars | ~1300 tokens
85
+ export function parseFile(path) { ... }
86
+ ...
87
+
88
+ ============================================================
89
+ END OF SNAPSHOT
90
+ ============================================================
91
+ 12 files | ~15000 chars | ~3750 tokens
92
+ ```
93
+
94
+ Then you tell AI: *"Above is my full codebase. Please add a feature to export each page as an image."*
95
+
96
+ **AI understands instantly** — no need to paste files one by one, no missing context.
97
+
98
+ ## Features (v2)
22
99
 
23
100
  | Feature | CLI Flag | Description |
24
101
  |---------|----------|-------------|
25
- | Directory walk | `snap <dir>` | Recursive with `.gitignore` awareness |
102
+ | Directory walk | `snap <dir>` | Recursive with `.gitignore` + `.snapignore` awareness |
26
103
  | Git-only | `--git-only` | Only modified/new files since last commit |
27
- | Minify | `--minify` | Strip blank lines and single-line comments (save tokens) |
104
+ | Minify | `--minify` | Strip blank lines + single-line comments to save tokens |
105
+ | Token estimate | `--tokens` | See estimated token count in output |
106
+ | Interactive picker | `-i` or `--interactive` | Pick files and options interactively |
107
+ | Top files report | `--top-files` | Show largest files before generating |
28
108
  | Output file | `-o <file>` | Write to file instead of stdout |
29
109
  | Clipboard | `--copy` | Copy to clipboard (macOS/Linux) |
30
110
  | Include filter | `--include "*.js,*.ts"` | Only matching file patterns |
31
111
  | Exclude filter | `--exclude "*.test.*"` | Skip matching patterns |
32
112
  | Max file size | `--max-size <bytes>` | Skip large files (default: 512KB) |
33
113
  | Skip binary | `--no-binary` | Auto-detect and skip binary files (default: on) |
34
- | Ignore gitignore | `--no-gitignore` | Don't read .gitignore rules |
114
+ | JSON output | `--json` | Machine-readable JSON format |
115
+ | Sort options | `--sort alpha\|size\|type` | Control output file order |
116
+ | Output formats | `--format marker\|xml\|plain` | Choose your output format |
35
117
 
36
118
  ## Quick Examples
37
119
 
38
120
  ```bash
39
121
  # Everything you need for your AI prompt
40
- snap ./src -o context.txt
122
+ snap . -o context.txt
41
123
 
42
- # Just what changed today
124
+ # Just what changed today (git diff)
43
125
  snap . --git-only
44
126
 
45
- # Clean it up (save tokens)
46
- snap ./lib --minify --exclude "*.test.js,*.spec.js"
127
+ # Interactive mode — pick files, set options
128
+ snap . -i
129
+
130
+ # Clean it up to save tokens
131
+ snap . --minify --exclude "*.test.js,*.spec.js"
132
+
133
+ # See token count before you use it
134
+ snap . --tokens
135
+
136
+ # Find the big files
137
+ snap . --top-files
47
138
 
48
- # Pipe directly to Claude Code (or any AI)
49
- snap . | claude
139
+ # JSON for programmatic use
140
+ snap . --json -o codebase.json
50
141
 
51
142
  # Quick clipboard
52
- snap ./src --copy
143
+ snap . --copy
144
+
145
+ # Different output format
146
+ snap . --format xml
53
147
  ```
54
148
 
55
149
  ## Output Format
@@ -59,24 +153,46 @@ Every file is wrapped with clear markers:
59
153
  ```
60
154
  // [FILE] src/utils.ts
61
155
  // [LANG] TypeScript
62
- // [SIZE] 1234 chars
156
+ // [SIZE] 1234 chars | ~308 tokens
63
157
  export function hello() { ... }
64
158
  // [/FILE] src/utils.ts
65
159
  ```
66
160
 
67
- The header includes a summary for token estimation. Footer gives total stats.
161
+ The header includes generation info and token estimation. Footer gives total statistics.
68
162
 
69
163
  ## Ignored by Default
70
164
 
71
- `node_modules`, `.git`, `.DS_Store`, `dist`, `build`, `.next`, `.cache`, `__pycache__`, `.venv`, `venv`, `env`, `coverage`, plus everything in your `.gitignore`.
165
+ `node_modules`, `.git`, `.DS_Store`, `dist`, `build`, `.next`, `.cache`, `__pycache__`, `.venv`, `venv`, `env`, `.env`, `coverage`, `*.log`, plus everything in your `.gitignore` **and** `.snapignore`.
166
+
167
+ ### .snapignore
168
+
169
+ Create a `.snapignore` file in your project root for custom exclusions:
170
+
171
+ ```
172
+ # .snapignore
173
+ generated/
174
+ *.graphql
175
+ **/*.stories.*
176
+ ```
177
+
178
+ ## How it works
179
+
180
+ 1. **Scans** your directory, respecting `.gitignore` and `.snapignore`
181
+ 2. **Wraps** every file with FILE/LANG/SIZE markers
182
+ 3. **Outputs** one clean text block — pipe it, save it, copy it
183
+ 4. **Your AI agent** gets full context in one shot
72
184
 
73
185
  ## Roadmap
74
186
 
75
- - [ ] Interactive file picker
76
- - [ ] Token-aware chunking (split large outputs for context limits)
187
+ - [x] Interactive file picker
188
+ - [x] Token-aware output
189
+ - [x] .snapignore file support
77
190
  - [ ] VS Code extension
78
- - [ ] .snapignore file support
191
+ - [ ] Token-aware chunking (auto-split for context limits)
192
+ - [ ] GUI landing page
79
193
 
80
194
  ## License
81
195
 
82
- MIT — free for everyone. If you find it useful, a GitHub star goes a long way.
196
+ MIT — free for everyone.
197
+
198
+ 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,30 @@
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.0",
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", "ai", "claude", "chatgpt", "context",
18
+ "codebase", "agent", "claude-code", "cursor", "copilot",
19
+ "codex", "prompt", "llm", "developer-tool"
20
+ ],
10
21
  "author": "Kevin-X00",
11
22
  "license": "MIT",
12
23
  "publishConfig": { "access": "public" },
13
- "repository": { "type": "git", "url": "git+ssh://git@github.com:Kevin-X00/code-snapshot.git" }
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+ssh://git@github.com:Kevin-X00/code-snapshot.git"
27
+ },
28
+ "homepage": "https://kevin-x00.github.io/code-snapshot",
29
+ "engines": { "node": ">=18" }
14
30
  }