ai-pack-cli 0.1.0__tar.gz
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.
- ai_pack_cli-0.1.0/PKG-INFO +5 -0
- ai_pack_cli-0.1.0/README.md +137 -0
- ai_pack_cli-0.1.0/ai_pack.py +661 -0
- ai_pack_cli-0.1.0/ai_pack_cli.egg-info/PKG-INFO +5 -0
- ai_pack_cli-0.1.0/ai_pack_cli.egg-info/SOURCES.txt +9 -0
- ai_pack_cli-0.1.0/ai_pack_cli.egg-info/dependency_links.txt +1 -0
- ai_pack_cli-0.1.0/ai_pack_cli.egg-info/entry_points.txt +4 -0
- ai_pack_cli-0.1.0/ai_pack_cli.egg-info/requires.txt +2 -0
- ai_pack_cli-0.1.0/ai_pack_cli.egg-info/top_level.txt +1 -0
- ai_pack_cli-0.1.0/setup.cfg +4 -0
- ai_pack_cli-0.1.0/setup.py +18 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# š¦ ai-pack
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/)
|
|
6
|
+
[](https://github.com/iamraydoan/ai-pack)
|
|
7
|
+
|
|
8
|
+
> Pack your entire codebase into a single formatted Markdown prompt for LLMs, optimized for minimum tokens.
|
|
9
|
+
|
|
10
|
+
`ai-pack` is a lightweight, high-performance CLI tool designed to help developers package their codebase, specific files, or uncommitted changes into a clean Markdown payload. Easily copy it to the clipboard or save it to a file to feed directly into ChatGPT, Claude, Gemini, or any other LLM.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## š„ Key Features
|
|
15
|
+
|
|
16
|
+
* **ā” Native CLI Command**: Accessible globally as `ai-pack`, `aipack`, or `aip`.
|
|
17
|
+
* **š Code Skeleton Extraction (`--skeleton`)**: Drastically save tokens by stripping method and function bodies, keeping only class structures, imports, signatures, and docstrings.
|
|
18
|
+
* **šÆ Interactive Selection (`-i`)**: Interactively choose which files to pack using Arrow keys and Spacebar before generating the payload.
|
|
19
|
+
* **šæ Git-Aware (`--changed`)**: Automatically detect and pack only modified, staged, or untracked files.
|
|
20
|
+
* **š”ļø Gitignore Respecting**: Native Git integration using `git ls-files` to automatically ignore build artifacts, node modules, and everything in `.gitignore` (with a clean manual fallback for non-git folders).
|
|
21
|
+
* **š¬ Predefined LLM Prompts (`-p`)**: Instantly prepend pre-configured prompts for code review, bug hunting, or architecture explanations.
|
|
22
|
+
* **š Token Estimation**: Heuristic token counting warns you if your payload exceeds your limit (`--max-tokens`).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## š Quick Start
|
|
27
|
+
|
|
28
|
+
### Installation
|
|
29
|
+
|
|
30
|
+
Choose one of the following methods to get started quickly:
|
|
31
|
+
|
|
32
|
+
#### Option 1: Install directly from GitHub (Recommended)
|
|
33
|
+
You can install the tool directly without cloning the repo manually:
|
|
34
|
+
```bash
|
|
35
|
+
pip3 install git+https://github.com/iamraydoan/ai-pack.git --user
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
#### Option 2: Using `pipx` (Best for isolated CLI tools)
|
|
39
|
+
Ensure you don't run into dependency conflicts by running:
|
|
40
|
+
```bash
|
|
41
|
+
pipx install git+https://github.com/iamraydoan/ai-pack.git
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### Option 3: Manual Clone (Editable mode)
|
|
45
|
+
If you want to modify the source code:
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/iamraydoan/ai-pack.git
|
|
48
|
+
cd ai-pack
|
|
49
|
+
pip3 install -e . --user
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### Option 4: Run as a Standalone Script (One-Liner)
|
|
53
|
+
Since `ai-pack` is a self-contained single script, you can download it directly:
|
|
54
|
+
```bash
|
|
55
|
+
curl -o ~/.local/bin/aip https://raw.githubusercontent.com/iamraydoan/ai-pack/main/ai_pack.py && chmod +x ~/.local/bin/aip
|
|
56
|
+
```
|
|
57
|
+
*(Note: If you run it standalone, you will need to manually run `pip3 install pyperclip questionary` to enable all optional interactive and clipboard features).*
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
### Usage Examples
|
|
61
|
+
|
|
62
|
+
#### 1. Pack the entire repository (copied to clipboard)
|
|
63
|
+
```bash
|
|
64
|
+
aip
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
#### 2. Pack specific files and save to a file
|
|
68
|
+
```bash
|
|
69
|
+
aip -f src/main.py tests/ -o output.md
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
#### 3. Pack only uncommitted git changes with a code review prompt
|
|
73
|
+
```bash
|
|
74
|
+
aip --changed -p review
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### 4. Extract code skeleton only (drastically saves context window tokens)
|
|
78
|
+
```bash
|
|
79
|
+
aip --skeleton -p explain
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### 5. Interactively choose files to include
|
|
83
|
+
```bash
|
|
84
|
+
aip -i
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## š Skeleton Mode Demo
|
|
90
|
+
|
|
91
|
+
### Original Code (`math.py`):
|
|
92
|
+
```python
|
|
93
|
+
def fibonacci(n):
|
|
94
|
+
if n <= 0:
|
|
95
|
+
return []
|
|
96
|
+
elif n == 1:
|
|
97
|
+
return [0]
|
|
98
|
+
sequence = [0, 1]
|
|
99
|
+
while len(sequence) < n:
|
|
100
|
+
sequence.append(sequence[-1] + sequence[-2])
|
|
101
|
+
return sequence
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Skeleton Output:
|
|
105
|
+
```python
|
|
106
|
+
def fibonacci(n):
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
*Supports Python and all brace-delimited languages (JS, TS, Go, Rust, C++, Java, Swift, etc.)*
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## šØ Interactive CLI Checklist
|
|
115
|
+
|
|
116
|
+
Running `aip -i` triggers a beautiful checkbox prompt:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
? Select files to pack (Space to toggle, Enter to confirm):
|
|
120
|
+
⯠[x] src/main.py
|
|
121
|
+
[x] src/utils.py
|
|
122
|
+
[ ] tests/test_main.py
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## š¤ Contributing
|
|
128
|
+
|
|
129
|
+
Contributions are welcome! If you have ideas for new features, bug fixes, or enhancements:
|
|
130
|
+
|
|
131
|
+
1. Fork the repo.
|
|
132
|
+
2. Create your feature branch (`git checkout -b feat/amazing-feature`).
|
|
133
|
+
3. Commit your changes (`git commit -m 'feat: add amazing feature'`).
|
|
134
|
+
4. Push to the branch (`git push origin feat/amazing-feature`).
|
|
135
|
+
5. Open a Pull Request.
|
|
136
|
+
|
|
137
|
+
**Don't forget to give the project a ā if you found it useful!**
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import re
|
|
5
|
+
import argparse
|
|
6
|
+
import subprocess
|
|
7
|
+
import fnmatch
|
|
8
|
+
from typing import List, Set
|
|
9
|
+
|
|
10
|
+
# Extension map for markdown syntax highlighting
|
|
11
|
+
EXTENSION_MAP = {
|
|
12
|
+
".py": "python",
|
|
13
|
+
".js": "javascript",
|
|
14
|
+
".ts": "typescript",
|
|
15
|
+
".jsx": "jsx",
|
|
16
|
+
".tsx": "tsx",
|
|
17
|
+
".html": "html",
|
|
18
|
+
".css": "css",
|
|
19
|
+
".json": "json",
|
|
20
|
+
".md": "markdown",
|
|
21
|
+
".sh": "bash",
|
|
22
|
+
".yml": "yaml",
|
|
23
|
+
".yaml": "yaml",
|
|
24
|
+
".rs": "rust",
|
|
25
|
+
".go": "go",
|
|
26
|
+
".c": "c",
|
|
27
|
+
".cpp": "cpp",
|
|
28
|
+
".h": "cpp",
|
|
29
|
+
".hpp": "cpp",
|
|
30
|
+
".java": "java",
|
|
31
|
+
".cs": "csharp",
|
|
32
|
+
".xml": "xml",
|
|
33
|
+
".ini": "ini",
|
|
34
|
+
".toml": "toml",
|
|
35
|
+
".sql": "sql",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Pre-defined system prompts
|
|
39
|
+
PROMPTS = {
|
|
40
|
+
"review": "You are an expert Senior Developer. Please review the following codebase for code quality, architectural improvements, and best practices.",
|
|
41
|
+
"bug": "You are a Security Engineer. Please analyze the following codebase to find potential bugs, edge cases, and security vulnerabilities.",
|
|
42
|
+
"explain": "Please explain the architecture and workflow of the following codebase in a clear, easy-to-understand manner."
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitHelper:
|
|
47
|
+
"""Helper class to interact with Git shell commands."""
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def is_git_repo(path: str) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
res = subprocess.run(
|
|
53
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
54
|
+
cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
55
|
+
)
|
|
56
|
+
return res.returncode == 0 and res.stdout.strip() == "true"
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def get_git_root(path: str) -> str:
|
|
62
|
+
try:
|
|
63
|
+
res = subprocess.run(
|
|
64
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
65
|
+
cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
66
|
+
)
|
|
67
|
+
if res.returncode == 0:
|
|
68
|
+
return os.path.abspath(res.stdout.strip())
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return os.path.abspath(path)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def get_all_non_ignored_files(path: str) -> Set[str]:
|
|
75
|
+
"""Returns all tracked and untracked but non-ignored files in the repository."""
|
|
76
|
+
try:
|
|
77
|
+
res = subprocess.run(
|
|
78
|
+
["git", "ls-files", "--full-name", "--cached", "--others", "--exclude-standard"],
|
|
79
|
+
cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
80
|
+
)
|
|
81
|
+
if res.returncode == 0:
|
|
82
|
+
files = set()
|
|
83
|
+
git_root = GitHelper.get_git_root(path)
|
|
84
|
+
for line in res.stdout.splitlines():
|
|
85
|
+
if line.strip():
|
|
86
|
+
abs_path = os.path.abspath(os.path.join(git_root, line.strip()))
|
|
87
|
+
files.add(abs_path)
|
|
88
|
+
return files
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"ā ļø Warning: Could not list git files: {e}", file=sys.stderr)
|
|
91
|
+
return set()
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def get_changed_files(path: str) -> Set[str]:
|
|
95
|
+
"""Returns all modified, staged, and untracked files."""
|
|
96
|
+
changed = set()
|
|
97
|
+
git_root = GitHelper.get_git_root(path)
|
|
98
|
+
try:
|
|
99
|
+
# 1. Uncommitted and modified tracked files
|
|
100
|
+
res_diff = subprocess.run(
|
|
101
|
+
["git", "diff", "--name-only"],
|
|
102
|
+
cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
103
|
+
)
|
|
104
|
+
if res_diff.returncode == 0:
|
|
105
|
+
for line in res_diff.stdout.splitlines():
|
|
106
|
+
if line.strip():
|
|
107
|
+
changed.add(os.path.abspath(os.path.join(git_root, line.strip())))
|
|
108
|
+
|
|
109
|
+
# 2. Staged files
|
|
110
|
+
res_diff_cached = subprocess.run(
|
|
111
|
+
["git", "diff", "--cached", "--name-only"],
|
|
112
|
+
cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
113
|
+
)
|
|
114
|
+
if res_diff_cached.returncode == 0:
|
|
115
|
+
for line in res_diff_cached.stdout.splitlines():
|
|
116
|
+
if line.strip():
|
|
117
|
+
changed.add(os.path.abspath(os.path.join(git_root, line.strip())))
|
|
118
|
+
|
|
119
|
+
# 3. Untracked files
|
|
120
|
+
res_status = subprocess.run(
|
|
121
|
+
["git", "status", "--porcelain"],
|
|
122
|
+
cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
123
|
+
)
|
|
124
|
+
if res_status.returncode == 0:
|
|
125
|
+
for line in res_status.stdout.splitlines():
|
|
126
|
+
if line.startswith("?? "):
|
|
127
|
+
file_path = line[3:].strip()
|
|
128
|
+
changed.add(os.path.abspath(os.path.join(git_root, file_path)))
|
|
129
|
+
except Exception as e:
|
|
130
|
+
print(f"ā ļø Error querying Git changes: {e}", file=sys.stderr)
|
|
131
|
+
return changed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class GitignoreMatcher:
|
|
135
|
+
"""Manual .gitignore matcher for non-git environments."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, base_dir: str):
|
|
138
|
+
self.base_dir = os.path.abspath(base_dir)
|
|
139
|
+
self.patterns = []
|
|
140
|
+
|
|
141
|
+
gitignore_path = os.path.join(self.base_dir, ".gitignore")
|
|
142
|
+
if os.path.exists(gitignore_path):
|
|
143
|
+
try:
|
|
144
|
+
with open(gitignore_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
145
|
+
for line in f:
|
|
146
|
+
line = line.strip()
|
|
147
|
+
if not line or line.startswith("#"):
|
|
148
|
+
continue
|
|
149
|
+
self.patterns.append(line)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def is_ignored(self, file_path: str) -> bool:
|
|
154
|
+
abs_path = os.path.abspath(file_path)
|
|
155
|
+
rel_path = os.path.relpath(abs_path, self.base_dir)
|
|
156
|
+
|
|
157
|
+
# Check standard default ignores
|
|
158
|
+
parts = rel_path.split(os.sep)
|
|
159
|
+
for part in parts:
|
|
160
|
+
if part.startswith(".") and part != ".":
|
|
161
|
+
return True
|
|
162
|
+
if part in ("node_modules", "__pycache__", "venv", "env", "dist", "build", "target"):
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
# Check patterns from .gitignore
|
|
166
|
+
for pattern in self.patterns:
|
|
167
|
+
pat = pattern
|
|
168
|
+
is_dir_only = pat.endswith("/")
|
|
169
|
+
if is_dir_only:
|
|
170
|
+
pat = pat[:-1]
|
|
171
|
+
|
|
172
|
+
if pat.startswith("/"):
|
|
173
|
+
pat = pat[1:]
|
|
174
|
+
if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(os.path.dirname(rel_path), pat):
|
|
175
|
+
return True
|
|
176
|
+
else:
|
|
177
|
+
if fnmatch.fnmatch(rel_path, f"**/{pat}") or fnmatch.fnmatch(rel_path, pat) or any(fnmatch.fnmatch(part, pat) for part in parts):
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_binary(file_path: str) -> bool:
|
|
184
|
+
"""Checks if a file is binary by looking for null bytes in the first 1024 bytes."""
|
|
185
|
+
try:
|
|
186
|
+
with open(file_path, "rb") as f:
|
|
187
|
+
chunk = f.read(1024)
|
|
188
|
+
return b"\x00" in chunk
|
|
189
|
+
except Exception:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class SkeletonExtractor:
|
|
194
|
+
"""Extracts structural outline (signatures, classes, imports) of code files."""
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def get_skeleton(file_path: str, content: str) -> str:
|
|
198
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
199
|
+
if ext == ".py":
|
|
200
|
+
return SkeletonExtractor.extract_python_skeleton(content)
|
|
201
|
+
elif ext in (".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".cpp", ".c", ".h", ".hpp", ".java", ".cs", ".php", ".swift"):
|
|
202
|
+
return SkeletonExtractor.extract_brace_skeleton(content)
|
|
203
|
+
else:
|
|
204
|
+
# Fallback: keep full content for configuration, text, markdown, or unrecognized files
|
|
205
|
+
return content
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def extract_python_skeleton(content: str) -> str:
|
|
209
|
+
lines = content.splitlines()
|
|
210
|
+
skeleton_lines = []
|
|
211
|
+
i = 0
|
|
212
|
+
n = len(lines)
|
|
213
|
+
decorator_buffer = []
|
|
214
|
+
|
|
215
|
+
while i < n:
|
|
216
|
+
line = lines[i]
|
|
217
|
+
stripped = line.strip()
|
|
218
|
+
|
|
219
|
+
# Keep track of decorators
|
|
220
|
+
if stripped.startswith("@"):
|
|
221
|
+
decorator_buffer.append(line)
|
|
222
|
+
i += 1
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# If function signature
|
|
226
|
+
if stripped.startswith("def ") or stripped.startswith("async def "):
|
|
227
|
+
if decorator_buffer:
|
|
228
|
+
skeleton_lines.extend(decorator_buffer)
|
|
229
|
+
decorator_buffer = []
|
|
230
|
+
|
|
231
|
+
indent = len(line) - len(line.lstrip())
|
|
232
|
+
sig_lines = []
|
|
233
|
+
|
|
234
|
+
# Consume multi-line signatures
|
|
235
|
+
while i < n:
|
|
236
|
+
sig_lines.append(lines[i])
|
|
237
|
+
if lines[i].rstrip().endswith(":"):
|
|
238
|
+
break
|
|
239
|
+
i += 1
|
|
240
|
+
|
|
241
|
+
skeleton_lines.extend(sig_lines)
|
|
242
|
+
|
|
243
|
+
# Skip the function body (all lines indented further than indent)
|
|
244
|
+
i += 1
|
|
245
|
+
body_skipped = False
|
|
246
|
+
while i < n:
|
|
247
|
+
next_line = lines[i]
|
|
248
|
+
next_stripped = next_line.strip()
|
|
249
|
+
if not next_stripped:
|
|
250
|
+
i += 1
|
|
251
|
+
continue
|
|
252
|
+
next_indent = len(next_line) - len(next_line.lstrip())
|
|
253
|
+
if next_indent <= indent:
|
|
254
|
+
break
|
|
255
|
+
body_skipped = True
|
|
256
|
+
i += 1
|
|
257
|
+
|
|
258
|
+
if body_skipped:
|
|
259
|
+
skeleton_lines.append(" " * (indent + 4) + "...")
|
|
260
|
+
decorator_buffer = []
|
|
261
|
+
else:
|
|
262
|
+
if decorator_buffer:
|
|
263
|
+
skeleton_lines.extend(decorator_buffer)
|
|
264
|
+
decorator_buffer = []
|
|
265
|
+
skeleton_lines.append(line)
|
|
266
|
+
i += 1
|
|
267
|
+
|
|
268
|
+
return "\n".join(skeleton_lines)
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def extract_brace_skeleton(content: str) -> str:
|
|
272
|
+
"""Parses brace-delimited languages to extract function definitions and omit bodies."""
|
|
273
|
+
result = []
|
|
274
|
+
i = 0
|
|
275
|
+
n = len(content)
|
|
276
|
+
|
|
277
|
+
in_string = None
|
|
278
|
+
in_comment = None
|
|
279
|
+
brace_depth = 0
|
|
280
|
+
skip_until_brace_depth = None
|
|
281
|
+
prefix_buffer = []
|
|
282
|
+
|
|
283
|
+
while i < n:
|
|
284
|
+
c = content[i]
|
|
285
|
+
|
|
286
|
+
# --- 1. Handle Comments ---
|
|
287
|
+
if in_comment == 'single':
|
|
288
|
+
if c == '\n':
|
|
289
|
+
in_comment = None
|
|
290
|
+
if skip_until_brace_depth is None:
|
|
291
|
+
result.append(c)
|
|
292
|
+
i += 1
|
|
293
|
+
continue
|
|
294
|
+
elif in_comment == 'multi':
|
|
295
|
+
if c == '/' and i > 0 and content[i-1] == '*':
|
|
296
|
+
in_comment = None
|
|
297
|
+
i += 1
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# --- 2. Handle Strings ---
|
|
301
|
+
if in_string:
|
|
302
|
+
if c == '\\' and i + 1 < n:
|
|
303
|
+
escaped_char = content[i:i+2]
|
|
304
|
+
if skip_until_brace_depth is None:
|
|
305
|
+
result.append(escaped_char)
|
|
306
|
+
prefix_buffer.append(escaped_char)
|
|
307
|
+
i += 2
|
|
308
|
+
continue
|
|
309
|
+
if skip_until_brace_depth is None:
|
|
310
|
+
result.append(c)
|
|
311
|
+
prefix_buffer.append(c)
|
|
312
|
+
if c == in_string:
|
|
313
|
+
in_string = None
|
|
314
|
+
i += 1
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# --- 3. Detect Comment Start (when not in string/comment) ---
|
|
318
|
+
if c == '/' and i + 1 < n:
|
|
319
|
+
next_c = content[i+1]
|
|
320
|
+
if next_c == '/':
|
|
321
|
+
in_comment = 'single'
|
|
322
|
+
i += 2
|
|
323
|
+
continue
|
|
324
|
+
elif next_c == '*':
|
|
325
|
+
in_comment = 'multi'
|
|
326
|
+
i += 2
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# --- 4. Detect String Start (when not in string/comment) ---
|
|
330
|
+
if c in ('"', "'", "`"):
|
|
331
|
+
in_string = c
|
|
332
|
+
if skip_until_brace_depth is None:
|
|
333
|
+
result.append(c)
|
|
334
|
+
prefix_buffer.append(c)
|
|
335
|
+
i += 1
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# --- 5. Brace & Skipping Logic ---
|
|
339
|
+
if skip_until_brace_depth is not None:
|
|
340
|
+
if c == '{':
|
|
341
|
+
brace_depth += 1
|
|
342
|
+
elif c == '}':
|
|
343
|
+
brace_depth -= 1
|
|
344
|
+
if brace_depth == skip_until_brace_depth:
|
|
345
|
+
result.append(' { /* ... */ }')
|
|
346
|
+
skip_until_brace_depth = None
|
|
347
|
+
i += 1
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
# Normal code processing
|
|
351
|
+
if c == '{':
|
|
352
|
+
prefix_str = "".join(prefix_buffer).strip()
|
|
353
|
+
is_func = False
|
|
354
|
+
|
|
355
|
+
if '=>' in prefix_str:
|
|
356
|
+
is_func = True
|
|
357
|
+
elif 'function' in prefix_str:
|
|
358
|
+
is_func = True
|
|
359
|
+
elif 'constructor' in prefix_str:
|
|
360
|
+
is_func = True
|
|
361
|
+
elif any(x in prefix_str for x in ('fn ', 'func ')):
|
|
362
|
+
is_func = True
|
|
363
|
+
elif prefix_str.endswith(')'):
|
|
364
|
+
words = [w for w in prefix_str.split() if w]
|
|
365
|
+
clean_words = [w for w in words if w not in (
|
|
366
|
+
'export', 'async', 'default', 'public', 'private', 'protected', 'static', 'readonly'
|
|
367
|
+
)]
|
|
368
|
+
if clean_words:
|
|
369
|
+
first_word = clean_words[0].split('(')[0].strip()
|
|
370
|
+
if first_word not in ('if', 'for', 'while', 'switch', 'catch'):
|
|
371
|
+
is_func = True
|
|
372
|
+
|
|
373
|
+
if is_func:
|
|
374
|
+
skip_until_brace_depth = brace_depth
|
|
375
|
+
brace_depth += 1
|
|
376
|
+
prefix_buffer = []
|
|
377
|
+
else:
|
|
378
|
+
brace_depth += 1
|
|
379
|
+
result.append(c)
|
|
380
|
+
prefix_buffer = []
|
|
381
|
+
elif c == '}':
|
|
382
|
+
brace_depth -= 1
|
|
383
|
+
result.append(c)
|
|
384
|
+
prefix_buffer = []
|
|
385
|
+
else:
|
|
386
|
+
result.append(c)
|
|
387
|
+
if c == '\n':
|
|
388
|
+
prefix_buffer = []
|
|
389
|
+
else:
|
|
390
|
+
prefix_buffer.append(c)
|
|
391
|
+
i += 1
|
|
392
|
+
|
|
393
|
+
return "".join(result)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def walk_dir(directory: str, git_files: Set[str] = None, gitignore: GitignoreMatcher = None) -> List[str]:
|
|
397
|
+
"""Recursively traverses directories, ignoring binaries and configured ignore directories."""
|
|
398
|
+
candidates = []
|
|
399
|
+
for root, dirs, files in os.walk(directory):
|
|
400
|
+
# Exclude common large/temporary directories in-place
|
|
401
|
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in (
|
|
402
|
+
"node_modules", "__pycache__", "venv", "env", "dist", "build", "target"
|
|
403
|
+
)]
|
|
404
|
+
|
|
405
|
+
for file in files:
|
|
406
|
+
full_path = os.path.abspath(os.path.join(root, file))
|
|
407
|
+
|
|
408
|
+
# Check manual gitignore helper if in a non-git repo
|
|
409
|
+
if gitignore and gitignore.is_ignored(full_path):
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
# Check git list if in a git repo
|
|
413
|
+
if git_files is not None and full_path not in git_files:
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
if file.startswith("."):
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
if is_binary(full_path):
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
candidates.append(full_path)
|
|
423
|
+
return candidates
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def get_candidate_files(args) -> List[str]:
|
|
427
|
+
"""Combines CLI arguments to determine final list of files to pack."""
|
|
428
|
+
cwd = os.getcwd()
|
|
429
|
+
|
|
430
|
+
is_git = GitHelper.is_git_repo(cwd)
|
|
431
|
+
git_files = GitHelper.get_all_non_ignored_files(cwd) if is_git else None
|
|
432
|
+
gitignore = GitignoreMatcher(cwd) if not is_git else None
|
|
433
|
+
|
|
434
|
+
if args.changed:
|
|
435
|
+
if not is_git:
|
|
436
|
+
print("ā Error: --changed requires a Git repository.", file=sys.stderr)
|
|
437
|
+
sys.exit(1)
|
|
438
|
+
candidates = list(GitHelper.get_changed_files(cwd))
|
|
439
|
+
else:
|
|
440
|
+
if args.files:
|
|
441
|
+
candidates = []
|
|
442
|
+
for path in args.files:
|
|
443
|
+
abs_path = os.path.abspath(path)
|
|
444
|
+
if os.path.isdir(abs_path):
|
|
445
|
+
candidates.extend(walk_dir(abs_path, git_files, gitignore))
|
|
446
|
+
elif os.path.isfile(abs_path):
|
|
447
|
+
if not is_binary(abs_path):
|
|
448
|
+
candidates.append(abs_path)
|
|
449
|
+
else:
|
|
450
|
+
print(f"ā ļø Skipping binary file: {path}")
|
|
451
|
+
else:
|
|
452
|
+
print(f"ā ļø Path not found: {path}", file=sys.stderr)
|
|
453
|
+
else:
|
|
454
|
+
candidates = walk_dir(cwd, git_files, gitignore)
|
|
455
|
+
|
|
456
|
+
# Apply filtering for combined --changed and --files
|
|
457
|
+
if args.changed and args.files:
|
|
458
|
+
filtered = []
|
|
459
|
+
for path in args.files:
|
|
460
|
+
abs_path = os.path.abspath(path)
|
|
461
|
+
for cand in candidates:
|
|
462
|
+
if os.path.commonpath([abs_path, cand]) == abs_path:
|
|
463
|
+
filtered.append(cand)
|
|
464
|
+
candidates = list(set(filtered))
|
|
465
|
+
|
|
466
|
+
# Remove duplicates, directories, non-existent, and binary files
|
|
467
|
+
final_candidates = []
|
|
468
|
+
for cand in sorted(list(set(candidates))):
|
|
469
|
+
if os.path.exists(cand) and not os.path.isdir(cand) and not is_binary(cand):
|
|
470
|
+
final_candidates.append(cand)
|
|
471
|
+
|
|
472
|
+
return final_candidates
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def interactive_select(files: List[str]) -> List[str]:
|
|
476
|
+
"""Displays an interactive checkbox list using questionary."""
|
|
477
|
+
try:
|
|
478
|
+
import questionary
|
|
479
|
+
except ImportError:
|
|
480
|
+
print("ā Error: 'questionary' package is required for interactive mode.", file=sys.stderr)
|
|
481
|
+
print("š” Install it by running: pip install -e .", file=sys.stderr)
|
|
482
|
+
sys.exit(1)
|
|
483
|
+
|
|
484
|
+
cwd = os.getcwd()
|
|
485
|
+
choices = []
|
|
486
|
+
for f in files:
|
|
487
|
+
rel = os.path.relpath(f, cwd)
|
|
488
|
+
choices.append(questionary.Choice(title=rel, value=f, checked=True))
|
|
489
|
+
|
|
490
|
+
if not choices:
|
|
491
|
+
print("ā ļø No files available to select.")
|
|
492
|
+
return []
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
selected = questionary.checkbox(
|
|
496
|
+
"Select files to pack (Space to toggle, Enter to confirm):",
|
|
497
|
+
choices=choices
|
|
498
|
+
).ask()
|
|
499
|
+
except (KeyboardInterrupt, EOFError):
|
|
500
|
+
print("\nš Cancelled by user.")
|
|
501
|
+
sys.exit(0)
|
|
502
|
+
|
|
503
|
+
if selected is None:
|
|
504
|
+
print("\nš Cancelled by user.")
|
|
505
|
+
sys.exit(0)
|
|
506
|
+
|
|
507
|
+
return selected
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def pack_files(files: List[str], skeleton_mode: bool) -> str:
|
|
511
|
+
"""Reads and formats files into a single Markdown payload."""
|
|
512
|
+
cwd = os.getcwd()
|
|
513
|
+
output_blocks = []
|
|
514
|
+
|
|
515
|
+
for file_path in files:
|
|
516
|
+
rel_path = os.path.relpath(file_path, cwd)
|
|
517
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
518
|
+
lang = EXTENSION_MAP.get(ext, ext.lstrip("."))
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
522
|
+
content = f.read()
|
|
523
|
+
|
|
524
|
+
if skeleton_mode:
|
|
525
|
+
content = SkeletonExtractor.get_skeleton(file_path, content)
|
|
526
|
+
|
|
527
|
+
block = f"### FILE: {rel_path}\n```{lang}\n{content}\n```"
|
|
528
|
+
output_blocks.append(block)
|
|
529
|
+
except Exception as e:
|
|
530
|
+
print(f"ā ļø Error reading file {rel_path}: {e}", file=sys.stderr)
|
|
531
|
+
|
|
532
|
+
return "\n\n".join(output_blocks)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def parse_args():
|
|
536
|
+
parser = argparse.ArgumentParser(
|
|
537
|
+
description="š¦ ai-pack: Pack your codebase into a single formatted Markdown string for LLMs."
|
|
538
|
+
)
|
|
539
|
+
parser.add_argument(
|
|
540
|
+
"-f", "--files",
|
|
541
|
+
nargs="+",
|
|
542
|
+
help="Explicitly specify files or directories to pack, ignoring the rest."
|
|
543
|
+
)
|
|
544
|
+
parser.add_argument(
|
|
545
|
+
"--changed",
|
|
546
|
+
action="store_true",
|
|
547
|
+
help="Only pack uncommitted or modified files (requires Git)."
|
|
548
|
+
)
|
|
549
|
+
parser.add_argument(
|
|
550
|
+
"--skeleton",
|
|
551
|
+
action="store_true",
|
|
552
|
+
help="Extract only class definitions, function signatures, and imports/exports to save tokens."
|
|
553
|
+
)
|
|
554
|
+
parser.add_argument(
|
|
555
|
+
"-i", "--interactive",
|
|
556
|
+
action="store_true",
|
|
557
|
+
help="Display an interactive checkbox list in the terminal to select files to pack."
|
|
558
|
+
)
|
|
559
|
+
parser.add_argument(
|
|
560
|
+
"-p", "--prompt",
|
|
561
|
+
choices=["review", "bug", "explain"],
|
|
562
|
+
help="Prepend a pre-defined system prompt to the beginning of the output."
|
|
563
|
+
)
|
|
564
|
+
parser.add_argument(
|
|
565
|
+
"-o", "--output",
|
|
566
|
+
help="Save the final Markdown output to the specified file path instead of copying to clipboard."
|
|
567
|
+
)
|
|
568
|
+
parser.add_argument(
|
|
569
|
+
"--max-tokens",
|
|
570
|
+
type=int,
|
|
571
|
+
help="Count approximate tokens. Warning if payload exceeds this limit."
|
|
572
|
+
)
|
|
573
|
+
return parser.parse_args()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def main():
|
|
577
|
+
args = parse_args()
|
|
578
|
+
|
|
579
|
+
# Pre-checks for dependencies
|
|
580
|
+
# If no output path is set, we will eventually need pyperclip, warn early but don't crash
|
|
581
|
+
if not args.output:
|
|
582
|
+
try:
|
|
583
|
+
import pyperclip
|
|
584
|
+
except ImportError:
|
|
585
|
+
print("ā ļø Note: 'pyperclip' is not installed. Output copy to clipboard will fail.", file=sys.stderr)
|
|
586
|
+
print("š” Install dependencies via: pip install -e .", file=sys.stderr)
|
|
587
|
+
print("š” Or output directly to a file using the -o flag.", file=sys.stderr)
|
|
588
|
+
print("-" * 50, file=sys.stderr)
|
|
589
|
+
|
|
590
|
+
# Gather candidate files
|
|
591
|
+
candidates = get_candidate_files(args)
|
|
592
|
+
|
|
593
|
+
if not candidates:
|
|
594
|
+
print("š No files found matching your criteria.")
|
|
595
|
+
sys.exit(0)
|
|
596
|
+
|
|
597
|
+
# Interactive selection
|
|
598
|
+
if args.interactive:
|
|
599
|
+
selected_files = interactive_select(candidates)
|
|
600
|
+
else:
|
|
601
|
+
selected_files = candidates
|
|
602
|
+
|
|
603
|
+
if not selected_files:
|
|
604
|
+
print("š No files selected.")
|
|
605
|
+
sys.exit(0)
|
|
606
|
+
|
|
607
|
+
# Pack files
|
|
608
|
+
print(f"š¦ Packing {len(selected_files)} files...")
|
|
609
|
+
payload = pack_files(selected_files, args.skeleton)
|
|
610
|
+
|
|
611
|
+
# Prepend prompt if specified
|
|
612
|
+
if args.prompt:
|
|
613
|
+
prompt_text = PROMPTS[args.prompt]
|
|
614
|
+
payload = f"{prompt_text}\n\n{payload}"
|
|
615
|
+
|
|
616
|
+
# Calculate stats
|
|
617
|
+
char_count = len(payload)
|
|
618
|
+
word_count = len(payload.split())
|
|
619
|
+
approx_tokens = char_count // 4 # Standard ~4 characters per token heuristic
|
|
620
|
+
|
|
621
|
+
print(f"š Stats: {char_count:,} characters | {word_count:,} words | ~{approx_tokens:,} tokens")
|
|
622
|
+
|
|
623
|
+
# Check max-tokens limit
|
|
624
|
+
if args.max_tokens:
|
|
625
|
+
if approx_tokens > args.max_tokens:
|
|
626
|
+
print(f"ā ļø Warning: The payload is approximately {approx_tokens:,} tokens, which exceeds your limit of {args.max_tokens:,} tokens.")
|
|
627
|
+
try:
|
|
628
|
+
confirm = input("Do you want to proceed? (y/n): ").strip().lower()
|
|
629
|
+
if confirm not in ("y", "yes"):
|
|
630
|
+
print("š Aborted.")
|
|
631
|
+
sys.exit(0)
|
|
632
|
+
except (KeyboardInterrupt, EOFError):
|
|
633
|
+
print("\nš Aborted.")
|
|
634
|
+
sys.exit(0)
|
|
635
|
+
|
|
636
|
+
# Output operations
|
|
637
|
+
if args.output:
|
|
638
|
+
try:
|
|
639
|
+
# Create directories if they don't exist
|
|
640
|
+
out_dir = os.path.dirname(args.output)
|
|
641
|
+
if out_dir:
|
|
642
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
643
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
644
|
+
f.write(payload)
|
|
645
|
+
print(f"š Successfully saved to {args.output}!")
|
|
646
|
+
except Exception as e:
|
|
647
|
+
print(f"ā Error saving to file {args.output}: {e}", file=sys.stderr)
|
|
648
|
+
sys.exit(1)
|
|
649
|
+
else:
|
|
650
|
+
try:
|
|
651
|
+
import pyperclip
|
|
652
|
+
pyperclip.copy(payload)
|
|
653
|
+
print("š Successfully copied to clipboard!")
|
|
654
|
+
except Exception as e:
|
|
655
|
+
print(f"ā Error copying to clipboard: {e}", file=sys.stderr)
|
|
656
|
+
print("š” In headless Linux systems, you must install 'xclip' or 'xsel', or direct output to a file using the '-o' flag.", file=sys.stderr)
|
|
657
|
+
sys.exit(1)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
if __name__ == "__main__":
|
|
661
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_pack
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="ai-pack-cli",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
py_modules=["ai_pack"],
|
|
7
|
+
install_requires=[
|
|
8
|
+
"pyperclip>=1.8.2",
|
|
9
|
+
"questionary>=1.10.0",
|
|
10
|
+
],
|
|
11
|
+
entry_points={
|
|
12
|
+
"console_scripts": [
|
|
13
|
+
"ai-pack=ai_pack:main",
|
|
14
|
+
"aipack=ai_pack:main",
|
|
15
|
+
"aip=ai_pack:main",
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
)
|