filemerger-cli 0.3.0__py3-none-any.whl

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.
filemerger/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ __all__ = [
2
+ "collect_files",
3
+ "merge_files",
4
+ ]
filemerger/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ import argparse
2
+ import sys
3
+ import os
4
+
5
+ from .core import collect_files, merge_files
6
+ from .utils import normalize_output_filename
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(
10
+ description="Consolidate project files into a single text output"
11
+ )
12
+ parser.add_argument(
13
+ "paths",
14
+ nargs="+",
15
+ help="Files or directories to include"
16
+ )
17
+ parser.add_argument(
18
+ "-o", "--output",
19
+ help="Output file name (always saved as .txt)"
20
+ )
21
+ parser.add_argument(
22
+ "--dry-run",
23
+ action="store_true",
24
+ help="Show files that would be included without writing output"
25
+ )
26
+ parser.add_argument(
27
+ "--stats",
28
+ action="store_true",
29
+ help="Print merge statistics"
30
+ )
31
+ parser.add_argument(
32
+ "--llm",
33
+ action="store_true",
34
+ help="Optimize output for LLM context ingestion"
35
+ )
36
+ parser.add_argument(
37
+ "--llm-compact",
38
+ action="store_true",
39
+ help="More compact LLM output with fewer blank lines"
40
+ )
41
+
42
+ args = parser.parse_args()
43
+
44
+ if args.llm_compact:
45
+ args.llm = True
46
+
47
+ output_file = normalize_output_filename(args.output)
48
+ output_file = os.path.join(os.getcwd(), output_file)
49
+
50
+ files = collect_files(args.paths, output_file=output_file)
51
+
52
+ if not files:
53
+ print("No valid files found.")
54
+ sys.exit(2)
55
+
56
+ if args.dry_run:
57
+ print("Files to be included:")
58
+ for f in files:
59
+ print(f" - {f}")
60
+
61
+ if args.stats:
62
+ print("\nStats:")
63
+ print(f" Files: {len(files)}")
64
+ sys.exit(0)
65
+
66
+ stats = merge_files(
67
+ files,
68
+ output_file,
69
+ llm_mode=args.llm,
70
+ llm_compact=args.llm_compact,
71
+ )
72
+
73
+ print(f"✔ Merged {stats.files} files into {output_file}")
74
+
75
+ if args.stats:
76
+ print("\nStats:")
77
+ print(f" Files: {stats.files}")
78
+ print(f" Lines: {stats.lines}")
79
+ print(f" Bytes: {stats.bytes}")
80
+ print(f" Skipped files: {stats.skipped_files}")
81
+
82
+ if __name__ == "__main__":
83
+ main()
filemerger/config.py ADDED
@@ -0,0 +1,28 @@
1
+ ALLOWED_EXTENSIONS = {
2
+ ".py", ".js", ".json", ".html", ".css", ".txt", ".md"
3
+ }
4
+
5
+ EXCLUDED_FILES = {
6
+ ".DS_Store",
7
+ }
8
+
9
+ EXCLUDED_DIRECTORIES = {
10
+ "__pycache__",
11
+ ".git",
12
+ ".idea",
13
+ ".vscode",
14
+ "node_modules",
15
+ "migrations",
16
+ "tests",
17
+ ".pytest_cache",
18
+ ".mypy_cache",
19
+ ".ruff_cache",
20
+ "env",
21
+ ".env",
22
+ ".venv",
23
+ }
24
+
25
+ DEFAULT_SEPARATOR_LENGTH = 90
26
+ MAX_FILE_SIZE_MB = 2
27
+
28
+ DEFAULT_OUTPUT_FILE = "filemerger-output.txt"
filemerger/core.py ADDED
@@ -0,0 +1,69 @@
1
+ import os
2
+ from typing import List, Set
3
+ from .filters import is_allowed_file
4
+ from .gitignore import load_gitignore
5
+ from .user_config import load_user_config
6
+ from .config import EXCLUDED_DIRECTORIES, MAX_FILE_SIZE_MB
7
+ from .format_default import DefaultFormatter
8
+ from .format_llm import LLMFormatter
9
+ from .stats import MergeStats
10
+
11
+ def collect_files(
12
+ paths: List[str],
13
+ *,
14
+ output_file: str | None = None,
15
+ ) -> List[str]:
16
+ collected: Set[str] = set()
17
+ root = os.getcwd()
18
+
19
+ gitignore_spec = load_gitignore(root)
20
+ user_config = load_user_config()
21
+
22
+ max_mb = user_config.get("filters", {}).get("max_file_size_mb", MAX_FILE_SIZE_MB)
23
+ excluded_dirs = set(EXCLUDED_DIRECTORIES)
24
+ excluded_dirs.update(
25
+ user_config.get("filters", {}).get("exclude_dirs", [])
26
+ )
27
+
28
+ max_file_size_bytes = int(max_mb * 1024 * 1024)
29
+
30
+ for path in paths:
31
+ if os.path.isfile(path) and is_allowed_file(
32
+ path,
33
+ output_file=output_file,
34
+ gitignore_spec=gitignore_spec,
35
+ root=root,
36
+ max_file_size_bytes=max_file_size_bytes,
37
+ excluded_dirs=excluded_dirs,
38
+ ):
39
+ collected.add(os.path.abspath(path))
40
+
41
+ elif os.path.isdir(path):
42
+ for current_root, _, files in os.walk(path):
43
+ for name in sorted(files):
44
+ full_path = os.path.join(current_root, name)
45
+ if is_allowed_file(
46
+ full_path,
47
+ output_file=output_file,
48
+ gitignore_spec=gitignore_spec,
49
+ root=root,
50
+ max_file_size_bytes=max_file_size_bytes,
51
+ excluded_dirs=excluded_dirs,
52
+ ):
53
+ collected.add(os.path.abspath(full_path))
54
+
55
+ return sorted(collected)
56
+
57
+ def merge_files(
58
+ files: List[str],
59
+ output_file: str,
60
+ *,
61
+ llm_mode: bool = False,
62
+ llm_compact: bool = False,
63
+ ) -> MergeStats:
64
+ if llm_mode or llm_compact:
65
+ formatter = LLMFormatter(compact=llm_compact)
66
+ else:
67
+ formatter = DefaultFormatter()
68
+
69
+ return formatter.write(files, output_file)
filemerger/filters.py ADDED
@@ -0,0 +1,37 @@
1
+ import os
2
+ from .gitignore import is_ignored
3
+
4
+ def is_allowed_file(
5
+ path: str,
6
+ *,
7
+ output_file: str | None = None,
8
+ gitignore_spec=None,
9
+ root: str | None = None,
10
+ max_file_size_bytes: int,
11
+ excluded_dirs: set[str],
12
+ ) -> bool:
13
+ if not os.path.isfile(path):
14
+ return False
15
+
16
+ if output_file and os.path.abspath(path) == os.path.abspath(output_file):
17
+ return False
18
+
19
+ if gitignore_spec and root and is_ignored(path, root=root, spec=gitignore_spec):
20
+ return False
21
+
22
+ if os.path.splitext(path)[1].lower() not in {
23
+ ".py", ".js", ".json", ".html", ".css", ".txt", ".md"
24
+ }:
25
+ return False
26
+
27
+ parts = path.split(os.sep)
28
+ if any(part in excluded_dirs for part in parts):
29
+ return False
30
+
31
+ try:
32
+ if os.path.getsize(path) > max_file_size_bytes:
33
+ return False
34
+ except OSError:
35
+ return False
36
+
37
+ return True
@@ -0,0 +1,53 @@
1
+ from typing import List
2
+ from .stats import MergeStats
3
+ from .user_config import load_user_config
4
+ from .config import DEFAULT_SEPARATOR_LENGTH
5
+
6
+ class DefaultFormatter:
7
+ def write(self, files: List[str], output_file: str) -> MergeStats:
8
+ user_config = load_user_config()
9
+ sep_len = user_config.get("output", {}).get(
10
+ "separator_length", DEFAULT_SEPARATOR_LENGTH
11
+ )
12
+ separator = "-" * int(sep_len)
13
+
14
+ stats = MergeStats(files=len(files))
15
+
16
+ with open(output_file, "w", encoding="utf-8") as out:
17
+ header = "FILES INCLUDED\n" + separator + "\n"
18
+ out.write(header)
19
+ stats.lines += header.count("\n")
20
+ stats.bytes += len(header.encode("utf-8"))
21
+
22
+ for f in files:
23
+ line = f"{f}\n"
24
+ out.write(line)
25
+ stats.lines += 1
26
+ stats.bytes += len(line.encode("utf-8"))
27
+
28
+ out.write("\n")
29
+ stats.lines += 1
30
+ stats.bytes += 1
31
+
32
+ for file_path in files:
33
+ block_header = (
34
+ f"{separator}\nFILE: {file_path}\n{separator}\n"
35
+ )
36
+ out.write(block_header)
37
+ stats.lines += block_header.count("\n")
38
+ stats.bytes += len(block_header.encode("utf-8"))
39
+
40
+ try:
41
+ with open(file_path, "r", encoding="utf-8") as f:
42
+ content = f.read().rstrip() + "\n"
43
+ out.write(content)
44
+ stats.lines += content.count("\n")
45
+ stats.bytes += len(content.encode("utf-8"))
46
+ except UnicodeDecodeError:
47
+ skipped = "[Skipped: binary or non-UTF8 file]\n"
48
+ out.write(skipped)
49
+ stats.lines += 1
50
+ stats.bytes += len(skipped.encode("utf-8"))
51
+ stats.skipped_files += 1
52
+
53
+ return stats
@@ -0,0 +1,36 @@
1
+ from typing import List
2
+ from .stats import MergeStats
3
+
4
+ class LLMFormatter:
5
+ def __init__(self, *, compact: bool = False):
6
+ self.compact = compact
7
+
8
+ def write(self, files: List[str], output_file: str) -> MergeStats:
9
+ stats = MergeStats(files=len(files))
10
+
11
+ with open(output_file, "w", encoding="utf-8") as out:
12
+ for idx, file_path in enumerate(files, start=1):
13
+ header = f"[{idx}] {file_path}\n"
14
+ out.write(header)
15
+ stats.lines += 1
16
+ stats.bytes += len(header.encode("utf-8"))
17
+
18
+ try:
19
+ with open(file_path, "r", encoding="utf-8") as f:
20
+ content = f.read().rstrip() + ("\n" if self.compact else "\n\n")
21
+ out.write(content)
22
+ stats.lines += content.count("\n")
23
+ stats.bytes += len(content.encode("utf-8"))
24
+ except UnicodeDecodeError:
25
+ skipped = "[Skipped: binary or non-UTF8 file]\n"
26
+ out.write(skipped)
27
+ stats.lines += 1
28
+ stats.bytes += len(skipped.encode("utf-8"))
29
+ stats.skipped_files += 1
30
+
31
+ if not self.compact:
32
+ out.write("\n")
33
+ stats.lines += 1
34
+ stats.bytes += 1
35
+
36
+ return stats
@@ -0,0 +1,6 @@
1
+ from typing import List
2
+ from .stats import MergeStats
3
+
4
+ class BaseFormatter:
5
+ def write(self, files: List[str], output_file: str) -> MergeStats:
6
+ raise NotImplementedError
@@ -0,0 +1,29 @@
1
+ import os
2
+ import pathspec
3
+ from typing import Optional
4
+
5
+ def load_gitignore(root: str) -> Optional[pathspec.PathSpec]:
6
+ """
7
+ Load .gitignore from the given root directory.
8
+ Returns None if no .gitignore exists.
9
+ """
10
+ gitignore_path = os.path.join(root, ".gitignore")
11
+ if not os.path.isfile(gitignore_path):
12
+ return None
13
+
14
+ with open(gitignore_path, "r", encoding="utf-8") as f:
15
+ lines = f.read().splitlines()
16
+
17
+ return pathspec.PathSpec.from_lines("gitwildmatch", lines)
18
+
19
+ def is_ignored(
20
+ path: str,
21
+ *,
22
+ root: str,
23
+ spec: Optional[pathspec.PathSpec],
24
+ ) -> bool:
25
+ if not spec:
26
+ return False
27
+
28
+ rel_path = os.path.relpath(path, root)
29
+ return spec.match_file(rel_path)
filemerger/stats.py ADDED
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class MergeStats:
5
+ files: int = 0
6
+ lines: int = 0
7
+ bytes: int = 0
8
+ skipped_files: int = 0
@@ -0,0 +1,15 @@
1
+ import os
2
+ import tomllib
3
+ from typing import Dict, Any
4
+
5
+ def load_user_config() -> Dict[str, Any]:
6
+ """
7
+ Load .filemerger.toml from the current working directory.
8
+ Returns empty dict if not found.
9
+ """
10
+ config_path = os.path.join(os.getcwd(), ".filemerger.toml")
11
+ if not os.path.isfile(config_path):
12
+ return {}
13
+
14
+ with open(config_path, "rb") as f:
15
+ return tomllib.load(f)
filemerger/utils.py ADDED
@@ -0,0 +1,20 @@
1
+ import os
2
+ from .config import DEFAULT_OUTPUT_FILE
3
+
4
+ def normalize_output_filename(output: str | None) -> str:
5
+ """
6
+ Normalize output filename:
7
+ - Default to DEFAULT_OUTPUT_FILE
8
+ - Force .txt extension
9
+ - Strip directory components (always write to CWD)
10
+ """
11
+ if not output:
12
+ return DEFAULT_OUTPUT_FILE
13
+
14
+ base = os.path.basename(output)
15
+ name, ext = os.path.splitext(base)
16
+
17
+ if ext.lower() != ".txt":
18
+ return f"{name}.txt"
19
+
20
+ return base
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: filemerger-cli
3
+ Version: 0.3.0
4
+ Summary: Developer CLI tool to consolidate project files into a single output
5
+ Author: BinaryFleet
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/binaryfleet/filemerger
8
+ Project-URL: Repository, https://github.com/binaryfleet/filemerger
9
+ Project-URL: Issues, https://github.com/binaryfleet/filemerger/issues
10
+ Keywords: cli,developer-tools,file-merger,llm,code-review
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pathspec>=0.11
15
+ Dynamic: license-file
16
+
17
+ # FileMerger
18
+
19
+ **FileMerger** is a developer-focused CLI tool that consolidates project files into a
20
+ single plain-text output.
21
+
22
+ It is designed to help developers:
23
+ - Share complete code context with AI tools (ChatGPT, Gemini, Grok, Claude, etc.)
24
+ - Review large codebases
25
+ - Create audit or snapshot files
26
+ - Prepare structured input for analysis
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install filemerger
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Basic Usage
39
+
40
+ Merge a directory:
41
+
42
+ ```bash
43
+ filemerger src/
44
+ ```
45
+
46
+ Specify output file:
47
+
48
+ ```bash
49
+ filemerger src/ --output context.txt
50
+ ```
51
+
52
+ Dry run (no file written):
53
+
54
+ ```bash
55
+ filemerger . --dry-run
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Output Modes
61
+
62
+ FileMerger supports multiple output modes depending on **who (or what)** will consume the output.
63
+
64
+ ### 1. Default Mode (Human-Readable)
65
+
66
+ ```bash
67
+ filemerger src/
68
+ ```
69
+
70
+ **Use this when:**
71
+
72
+ * You want to read the output yourself
73
+ * You are reviewing or auditing code
74
+ * You want clear visual separation
75
+
76
+ **Characteristics:**
77
+
78
+ * File lists and headers
79
+ * Visual separators
80
+ * Structured, readable layout
81
+
82
+ ---
83
+
84
+ ### 2. LLM Mode (`--llm`)
85
+
86
+ ```bash
87
+ filemerger src/ --llm
88
+ ```
89
+
90
+ **Use this when:**
91
+
92
+ * The output will be pasted into an AI system
93
+ * You want deterministic file references
94
+ * You want to reduce semantic noise
95
+
96
+ **Characteristics:**
97
+
98
+ * Files are numbered (`[1]`, `[2]`, …)
99
+ * No decorative separators
100
+ * Simple, predictable structure
101
+
102
+ Example:
103
+
104
+ ```
105
+ [1] path/to/file.py
106
+ <content>
107
+
108
+ [2] another/file.js
109
+ <content>
110
+ ```
111
+
112
+ ---
113
+
114
+ ### 3. LLM Compact Mode (`--llm-compact`)
115
+
116
+ ```bash
117
+ filemerger src/ --llm-compact
118
+ ```
119
+
120
+ **Use this when:**
121
+
122
+ * Token limits are tight
123
+ * The project is very large
124
+ * Maximum efficiency matters
125
+
126
+ **Characteristics:**
127
+
128
+ * Same structure as `--llm`
129
+ * Fewer blank lines
130
+ * Minimal formatting overhead
131
+
132
+ ---
133
+
134
+ ## Statistics
135
+
136
+ Use `--stats` to print merge statistics:
137
+
138
+ ```bash
139
+ filemerger src/ --stats
140
+ ```
141
+
142
+ Reported values:
143
+
144
+ * Number of files
145
+ * Total lines
146
+ * Total bytes
147
+ * Skipped files (binary / non-UTF8)
148
+
149
+ ---
150
+
151
+ ## Configuration (Optional)
152
+
153
+ FileMerger supports an optional `.filemerger.toml` file in the project root.
154
+
155
+ Example:
156
+
157
+ ```toml
158
+ [filters]
159
+ max_file_size_mb = 1
160
+ exclude_dirs = ["tests"]
161
+
162
+ [output]
163
+ separator_length = 60
164
+ ```
165
+
166
+ If the file is not present, default behavior is used.
167
+
168
+ ---
169
+
170
+ ## License
171
+
172
+ This project is licensed under the MIT License.
173
+ See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,18 @@
1
+ filemerger/__init__.py,sha256=cK_fu67VOvaz2x8vA8GjvSSl3nzPkcO0mYiNAKEN9yM,54
2
+ filemerger/cli.py,sha256=w99HGLjAfhswfi898Vj5GKxSWqVn-H1krh_ttdUf5Go,2070
3
+ filemerger/config.py,sha256=FdvW-aqSIlup13s19vwVlNRlhboHyKqHULZu-rSPYO8,454
4
+ filemerger/core.py,sha256=9IWp4DInHcEGgwOVkzvoxLXulf1eXUkeyef_N7-ge70,2175
5
+ filemerger/filters.py,sha256=8udBqIpz4Ac6n7vRf1gfH67ubXiwl7t5HS-YdchrzWI,905
6
+ filemerger/format_default.py,sha256=O2nXiXxBNHMnLNcJEcQRFJlE-N3FGT-_cQln-3OxMr4,1964
7
+ filemerger/format_llm.py,sha256=il1J1rzuoIni_sY3i81WnswdNkgs4QnsV5O8zh4tCkk,1391
8
+ filemerger/formatter.py,sha256=3NL2jlabzhxUttzDYaAnL-sfQ8XCxcHcv7wuzHfcmkA,181
9
+ filemerger/gitignore.py,sha256=P4j2c5yUmPkd5qHl9Nmj1c-TXvtgoz-j1EmDWY1H0tU,728
10
+ filemerger/stats.py,sha256=Ot1MEDX9atkGImDIXFzPyriNe1-K42nzhYuHkEH51_Q,148
11
+ filemerger/user_config.py,sha256=7YsojU_ArtuV6_zWMHsfgr3nu8fmMXH90fb9cbniuSI,405
12
+ filemerger/utils.py,sha256=_mYbT2rQj9xVlQC2hgaU8p-opgx6wgS-Hz-v8pjYpDA,482
13
+ filemerger_cli-0.3.0.dist-info/licenses/LICENSE,sha256=9SW1OrVckeEZJ2FZERYeZNW-KQlkuEfo2pDXWLnaSnI,1117
14
+ filemerger_cli-0.3.0.dist-info/METADATA,sha256=qSAxZ3mAsVodVLxAHrZGo2UCjJ-nPieEj9d4dFhMsJs,2889
15
+ filemerger_cli-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
16
+ filemerger_cli-0.3.0.dist-info/entry_points.txt,sha256=ZhK_MfyMXjYvYg27m09NMZMJL5hN_nzXa9HLYMRAybg,51
17
+ filemerger_cli-0.3.0.dist-info/top_level.txt,sha256=nSw1KMXvWRdNjRMV0OaH8s78L6V-MeyKhY5SUgnKbk8,11
18
+ filemerger_cli-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ filemerger = filemerger.cli:main
@@ -0,0 +1,23 @@
1
+ This project is licensed under the MIT License.
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2026 binaryfleet
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ filemerger