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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.1
2
+ Name: ai-pack-cli
3
+ Version: 0.1.0
4
+ Requires-Dist: pyperclip>=1.8.2
5
+ Requires-Dist: questionary>=1.10.0
@@ -0,0 +1,137 @@
1
+ # šŸ“¦ ai-pack
2
+
3
+ [![Python Version](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)
4
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-orange.svg)](https://github.com/)
6
+ [![GitHub Stars](https://img.shields.io/github/stars/iamraydoan/ai-pack.svg?style=social)](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,5 @@
1
+ Metadata-Version: 2.1
2
+ Name: ai-pack-cli
3
+ Version: 0.1.0
4
+ Requires-Dist: pyperclip>=1.8.2
5
+ Requires-Dist: questionary>=1.10.0
@@ -0,0 +1,9 @@
1
+ README.md
2
+ ai_pack.py
3
+ setup.py
4
+ ai_pack_cli.egg-info/PKG-INFO
5
+ ai_pack_cli.egg-info/SOURCES.txt
6
+ ai_pack_cli.egg-info/dependency_links.txt
7
+ ai_pack_cli.egg-info/entry_points.txt
8
+ ai_pack_cli.egg-info/requires.txt
9
+ ai_pack_cli.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ ai-pack = ai_pack:main
3
+ aip = ai_pack:main
4
+ aipack = ai_pack:main
@@ -0,0 +1,2 @@
1
+ pyperclip>=1.8.2
2
+ questionary>=1.10.0
@@ -0,0 +1 @@
1
+ ai_pack
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )