@vetterjulius/cagg 1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julius Vetter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # cagg — Code Aggregator
2
+
3
+ Bundles every relevant text/code file of a repository into a single output
4
+ file, each preceded by a clear header showing which file follows:
5
+
6
+ ```
7
+ -------------- src/index.js -----------------
8
+ 1: 'use strict';
9
+ 2:
10
+ ...
11
+ ```
12
+
13
+ Built for feeding a codebase to an LLM or for a quick, readable overview of
14
+ a project — the output is kept as small as possible (no unnecessary blank
15
+ lines, no binaries, no build artifacts).
16
+
17
+ ## Example
18
+ An example aggregation of the source code for the tool is included. See [agg.txt](agg.txt).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install -g cagg
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ cagg # aggregate CWD into ./agg.txt
30
+ cagg -i path/to/project # aggregate a different folder
31
+ cagg -o context.md # change output name/extension
32
+ cagg -a # use absolute paths in headers instead of CWD-relative
33
+ cagg --no-line-numbers # don't prefix lines with numbers
34
+ cagg --max-size 1 # skip files larger than 1 MB
35
+ cagg --max-lines 2000 # skip files with more than 2000 lines
36
+ ```
37
+
38
+ ## Ignoring files
39
+
40
+ By default, `cagg` ignores an extensive built-in list of common clutter:
41
+ `.git`, `node_modules`, build/dist output, caches, lockfiles, IDE folders,
42
+ OS files, and common binary/media file types (images, fonts, archives,
43
+ documents, ...). Files that don't look like text (checked by content, not
44
+ just extension) are always skipped and reported.
45
+
46
+ - `--no-ignore` — disable all ignoring; every file is included. Prints a
47
+ warning since this will likely pull in dependencies and binaries.
48
+ - `--ignore-patterns "pattern1,pattern2"` — supply your own
49
+ `.gitignore`-style patterns. This **replaces** the built-in default list.
50
+ Can be repeated / combined with commas.
51
+ - `--ignore-file <path>` — same idea, but patterns come from a file (e.g.
52
+ point it at your real `.gitignore`). Also **replaces** the default list.
53
+ - If the current directory has a `.gitignore`, its entries are
54
+ automatically merged into whichever ignore list is active (default or
55
+ custom). Disable this with `--no-merge-gitignore`.
56
+
57
+ The output file itself is always excluded from its own aggregation.
58
+
59
+ ## All options
60
+
61
+ ```
62
+ -i, --input <path> input directory to scan (relative to CWD or absolute) (default: ".")
63
+ -o, --output <path> output file name/path (default: "agg.txt")
64
+ -a, --absolute-paths use absolute paths in file headers instead of CWD-relative paths
65
+ --no-ignore disable all ignoring; include every file (prints a warning)
66
+ --ignore-patterns <patterns> comma-separated gitignore-style patterns, replaces default ignore list
67
+ --ignore-file <path> file with gitignore-style patterns, replaces default ignore list
68
+ --no-merge-gitignore don't merge a CWD .gitignore into the active ignore list
69
+ --no-line-numbers do not prefix each line with its line number
70
+ --max-size <mb> skip files larger than this size in MB
71
+ --max-lines <n> skip files with more lines than this
72
+ -h, --help display help
73
+ -V, --version output the version number
74
+ ```
package/bin/cagg.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../src/cli').run(process.argv);
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@vetterjulius/cagg",
3
+ "version": "1.0.0",
4
+ "description": "Code Aggregator - bundles all text/code files of a repository into a single .txt file, with gitignore-aware filtering",
5
+ "keywords": ["cli", "aggregator", "code", "llm", "context", "gitignore"],
6
+ "license": "MIT",
7
+ "bin": {
8
+ "cagg": "./bin/cagg.js"
9
+ },
10
+ "main": "src/index.js",
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=16"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^12.1.0",
21
+ "ignore": "^5.3.2",
22
+ "isbinaryfile": "^5.0.2"
23
+ }
24
+ }
package/src/cli.js ADDED
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ const { Command } = require('commander');
4
+ const path = require('path');
5
+ const { aggregate } = require('./index');
6
+ const pkg = require('../package.json');
7
+
8
+ function splitList(value, previous) {
9
+ const items = value.split(',').map((s) => s.trim()).filter(Boolean);
10
+ return (previous || []).concat(items);
11
+ }
12
+
13
+ function parseFloatOpt(value) {
14
+ const n = parseFloat(value);
15
+ if (Number.isNaN(n) || n <= 0) {
16
+ throw new Error(`Invalid numeric value: ${value}`);
17
+ }
18
+ return n;
19
+ }
20
+
21
+ function parseIntOpt(value) {
22
+ const n = parseInt(value, 10);
23
+ if (Number.isNaN(n) || n <= 0) {
24
+ throw new Error(`Invalid integer value: ${value}`);
25
+ }
26
+ return n;
27
+ }
28
+
29
+ function run(argv) {
30
+ const program = new Command();
31
+
32
+ program
33
+ .name('cagg')
34
+ .description(
35
+ 'Code Aggregator: bundles all text/code files of a repository into a single ' +
36
+ 'output file, separated by clear file-path headers.'
37
+ )
38
+ .version(pkg.version)
39
+ .option('-i, --input <path>', 'input directory to scan (relative to CWD or absolute)', '.')
40
+ .option('-o, --output <path>', 'output file name/path (extension can be changed)', 'agg.txt')
41
+ .option('-a, --absolute-paths', 'use absolute paths in file headers instead of CWD-relative paths', false)
42
+ .option('--no-ignore', 'disable all ignoring; include every file (prints a warning)')
43
+ .option(
44
+ '--ignore-patterns <patterns>',
45
+ 'comma-separated .gitignore-style patterns; REPLACES the built-in default ignore list ' +
46
+ '(repeatable)',
47
+ splitList,
48
+ []
49
+ )
50
+ .option(
51
+ '--ignore-file <path>',
52
+ 'path to a file with .gitignore-style patterns (e.g. your real .gitignore); ' +
53
+ 'REPLACES the built-in default ignore list'
54
+ )
55
+ .option(
56
+ '--no-merge-gitignore',
57
+ "don't additionally merge a .gitignore found in the CWD into the active ignore list"
58
+ )
59
+ .option('--no-line-numbers', 'do not prefix each line with its line number')
60
+ .option('--max-size <mb>', 'skip files larger than this size in MB', parseFloatOpt)
61
+ .option('--max-lines <n>', 'skip files with more lines than this', parseIntOpt);
62
+
63
+ let opts;
64
+ try {
65
+ program.parse(argv);
66
+ opts = program.opts();
67
+
68
+ const result = aggregate({
69
+ input: opts.input,
70
+ output: opts.output,
71
+ absolutePaths: Boolean(opts.absolutePaths),
72
+ noIgnore: opts.ignore === false,
73
+ ignorePatterns: opts.ignorePatterns,
74
+ ignoreFile: opts.ignoreFile,
75
+ mergeGitignore: opts.mergeGitignore !== false,
76
+ lineNumbers: opts.lineNumbers !== false,
77
+ maxSizeMb: opts.maxSize,
78
+ maxLines: opts.maxLines,
79
+ });
80
+
81
+ const relOut = path.relative(process.cwd(), result.outputPath) || result.outputPath;
82
+ console.log(`Aggregated ${result.includedCount} file(s) into ${relOut}`);
83
+ if (result.skipped.length) {
84
+ console.log(`Skipped ${result.skipped.length} file(s):`);
85
+ for (const { file, reason } of result.skipped) {
86
+ console.log(` - ${path.relative(process.cwd(), file)} (${reason})`);
87
+ }
88
+ }
89
+ } catch (err) {
90
+ console.error(`cagg: ${err.message}`);
91
+ process.exitCode = 1;
92
+ }
93
+ }
94
+
95
+ module.exports = { run };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // Extensive built-in default ignore list, gitignore-pattern-style.
4
+ // Covers common VCS/IDE/OS clutter, build artifacts for several ecosystems,
5
+ // dependency directories, lockfiles and binary/media file types that add
6
+ // bulk without helping a reader understand the source code.
7
+ module.exports = `
8
+ # --- version control ---
9
+ .git/
10
+ .svn/
11
+ .hg/
12
+
13
+ # --- OS clutter ---
14
+ .DS_Store
15
+ Thumbs.db
16
+ desktop.ini
17
+
18
+ # --- editors / IDEs ---
19
+ .vscode/
20
+ .idea/
21
+ *.iml
22
+ *.swp
23
+ *.swo
24
+
25
+ # --- node ---
26
+ node_modules/
27
+ npm-debug.log*
28
+ yarn-debug.log*
29
+ yarn-error.log*
30
+ .pnpm-debug.log*
31
+ package-lock.json
32
+ yarn.lock
33
+ pnpm-lock.yaml
34
+ dist/
35
+ build/
36
+ out/
37
+ coverage/
38
+ .next/
39
+ .nuxt/
40
+ .cache/
41
+
42
+ # --- python ---
43
+ __pycache__/
44
+ *.pyc
45
+ *.pyo
46
+ .venv/
47
+ venv/
48
+ env/
49
+ .mypy_cache/
50
+ .pytest_cache/
51
+ .tox/
52
+ poetry.lock
53
+ Pipfile.lock
54
+ *.egg-info/
55
+
56
+ # --- java / jvm ---
57
+ target/
58
+ *.class
59
+ *.jar
60
+ .gradle/
61
+
62
+ # --- go ---
63
+ vendor/
64
+
65
+ # --- rust ---
66
+ Cargo.lock
67
+ /target/
68
+
69
+ # --- ruby ---
70
+ .bundle/
71
+ Gemfile.lock
72
+
73
+ # --- dotnet ---
74
+ # scoped (not a bare "bin/") since Node CLI tools legitimately use a bin/ dir
75
+ [Bb]in/Debug/
76
+ [Bb]in/Release/
77
+ obj/
78
+
79
+ # --- logs / env ---
80
+ *.log
81
+ .env
82
+ .env.*
83
+
84
+ # --- archives ---
85
+ *.zip
86
+ *.tar
87
+ *.tar.gz
88
+ *.tgz
89
+ *.rar
90
+ *.7z
91
+
92
+ # --- compiled / binaries ---
93
+ *.exe
94
+ *.dll
95
+ *.so
96
+ *.dylib
97
+ *.o
98
+ *.a
99
+
100
+ # --- images / media / fonts ---
101
+ *.png
102
+ *.jpg
103
+ *.jpeg
104
+ *.gif
105
+ *.bmp
106
+ *.ico
107
+ *.webp
108
+ *.svg
109
+ *.mp3
110
+ *.mp4
111
+ *.mov
112
+ *.avi
113
+ *.woff
114
+ *.woff2
115
+ *.ttf
116
+ *.eot
117
+
118
+ # --- documents ---
119
+ *.pdf
120
+ *.doc
121
+ *.docx
122
+ *.xls
123
+ *.xlsx
124
+ *.ppt
125
+ *.pptx
126
+
127
+ # --- generated / minified ---
128
+ *.min.js
129
+ *.min.css
130
+ *.map
131
+ `;
package/src/index.js ADDED
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const ignore = require('ignore');
6
+ const { isBinaryFileSync } = require('isbinaryfile');
7
+
8
+ const DEFAULT_IGNORE = require('./defaultIgnore');
9
+
10
+ /**
11
+ * Reads a gitignore-pattern-style file and returns its non-empty lines.
12
+ * Missing files simply yield no extra patterns.
13
+ */
14
+ function readPatternFile(filePath) {
15
+ try {
16
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
17
+ } catch (err) {
18
+ if (err.code === 'ENOENT') return [];
19
+ throw err;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Builds the `ignore` matcher according to the resolved options.
25
+ * Returns { ig, warnings } where warnings is a list of strings to print.
26
+ */
27
+ function buildIgnoreMatcher(opts) {
28
+ const warnings = [];
29
+
30
+ if (opts.noIgnore) {
31
+ warnings.push(
32
+ 'WARNING: ignoring is disabled (--no-ignore). Every file will be included, ' +
33
+ 'including build artifacts, dependencies and binaries.'
34
+ );
35
+ return { ig: ignore(), warnings };
36
+ }
37
+
38
+ const ig = ignore();
39
+ const hasCustomPatterns = opts.ignorePatterns.length > 0 || Boolean(opts.ignoreFile);
40
+
41
+ if (hasCustomPatterns) {
42
+ // Custom patterns replace the built-in default list ("Alternativ ...").
43
+ if (opts.ignoreFile) {
44
+ ig.add(readPatternFile(path.resolve(opts.ignoreFile)));
45
+ }
46
+ if (opts.ignorePatterns.length > 0) {
47
+ ig.add(opts.ignorePatterns);
48
+ }
49
+ } else {
50
+ ig.add(DEFAULT_IGNORE);
51
+ }
52
+
53
+ if (opts.mergeGitignore) {
54
+ const projectGitignore = path.join(process.cwd(), '.gitignore');
55
+ const lines = readPatternFile(projectGitignore);
56
+ if (lines.some((l) => l.trim() && !l.trim().startsWith('#'))) {
57
+ ig.add(lines);
58
+ }
59
+ }
60
+
61
+ return { ig, warnings };
62
+ }
63
+
64
+ /** Normalizes a path to forward slashes, as expected by the `ignore` package. */
65
+ function toPosix(p) {
66
+ return p.split(path.sep).join('/');
67
+ }
68
+
69
+ /**
70
+ * Recursively walks `dir`, collecting absolute file paths.
71
+ * Paths are checked against `ig` relative to `root` so whole directories
72
+ * (e.g. node_modules/) can be pruned without descending into them.
73
+ */
74
+ function walk(dir, root, ig, results) {
75
+ let entries;
76
+ try {
77
+ entries = fs.readdirSync(dir, { withFileTypes: true });
78
+ } catch (err) {
79
+ return; // unreadable directory (permissions, broken symlink, ...): skip
80
+ }
81
+
82
+ for (const entry of entries) {
83
+ const abs = path.join(dir, entry.name);
84
+ const rel = toPosix(path.relative(root, abs));
85
+
86
+ // ignore() expects directory patterns to be testable without a trailing
87
+ // slash mismatch; passing the relative dir path is enough for pruning.
88
+ if (rel && ig.ignores(entry.isDirectory() ? `${rel}/` : rel)) continue;
89
+
90
+ if (entry.isDirectory()) {
91
+ walk(abs, root, ig, results);
92
+ } else if (entry.isFile()) {
93
+ results.push(abs);
94
+ }
95
+ }
96
+ }
97
+
98
+ /** Formats a byte count as a human-readable MB string for messages. */
99
+ function toMb(bytes) {
100
+ return (bytes / (1024 * 1024)).toFixed(2);
101
+ }
102
+
103
+ /**
104
+ * Runs the aggregation with resolved options and writes the output file.
105
+ * `opts` fields:
106
+ * input, output, absolutePaths, noIgnore, ignorePatterns, ignoreFile,
107
+ * mergeGitignore, lineNumbers, maxSizeMb, maxLines
108
+ */
109
+ function aggregate(opts) {
110
+ const cwd = process.cwd();
111
+ const inputRoot = path.resolve(opts.input);
112
+ const outputPath = path.resolve(opts.output);
113
+
114
+ const { ig, warnings } = buildIgnoreMatcher(opts);
115
+ warnings.forEach((w) => console.warn(w));
116
+
117
+ const allFiles = [];
118
+ walk(inputRoot, inputRoot, ig, allFiles);
119
+
120
+ // Never re-ingest our own output file, even across repeated runs.
121
+ const filtered = allFiles.filter((f) => path.resolve(f) !== outputPath);
122
+
123
+ const included = [];
124
+ const skipped = [];
125
+ const maxBytes = opts.maxSizeMb ? opts.maxSizeMb * 1024 * 1024 : null;
126
+
127
+ for (const file of filtered) {
128
+ let stat;
129
+ try {
130
+ stat = fs.statSync(file);
131
+ } catch (err) {
132
+ skipped.push({ file, reason: 'unreadable' });
133
+ continue;
134
+ }
135
+
136
+ if (maxBytes && stat.size > maxBytes) {
137
+ skipped.push({ file, reason: `exceeds max size (${toMb(stat.size)} MB)` });
138
+ continue;
139
+ }
140
+
141
+ let buffer;
142
+ try {
143
+ buffer = fs.readFileSync(file);
144
+ } catch (err) {
145
+ skipped.push({ file, reason: 'unreadable' });
146
+ continue;
147
+ }
148
+
149
+ if (isBinaryFileSync(buffer, buffer.length)) {
150
+ skipped.push({ file, reason: 'binary file' });
151
+ continue;
152
+ }
153
+
154
+ const content = buffer.toString('utf8');
155
+ const lines = content.length === 0 ? [] : content.split(/\r?\n/);
156
+
157
+ if (opts.maxLines && lines.length > opts.maxLines) {
158
+ skipped.push({ file, reason: `exceeds max lines (${lines.length})` });
159
+ continue;
160
+ }
161
+
162
+ included.push({ file, lines });
163
+ }
164
+
165
+ // Sort for deterministic, readable output.
166
+ included.sort((a, b) => a.file.localeCompare(b.file));
167
+
168
+ const parts = [];
169
+ for (const { file, lines } of included) {
170
+ const displayPath = toPosix(
171
+ opts.absolutePaths ? file : path.relative(cwd, file)
172
+ );
173
+ const header = `-------------- ${displayPath} -----------------`;
174
+
175
+ let body = lines
176
+ .map((line, i) => (opts.lineNumbers ? `${i + 1}: ${line}` : line))
177
+ .join('\n');
178
+ body = body.replace(/\n+$/, ''); // drop trailing blank lines, keep file small
179
+
180
+ parts.push(`${header}\n${body}`);
181
+ }
182
+
183
+ const output = parts.join('\n\n') + (parts.length ? '\n' : '');
184
+ fs.writeFileSync(outputPath, output, 'utf8');
185
+
186
+ return { outputPath, includedCount: included.length, skipped };
187
+ }
188
+
189
+ module.exports = { aggregate, buildIgnoreMatcher, walk };