oni-cli 1.0.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.
oni/oni.py ADDED
@@ -0,0 +1,384 @@
1
+ """
2
+ ONI (鬼)
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Annotated, List, Optional, Set
12
+
13
+ import pyfiglet
14
+ import questionary
15
+ import typer
16
+ from questionary import Style as QStyle
17
+ from rich import box
18
+ from rich.align import Align
19
+ from rich.console import Console, Group
20
+ from rich.panel import Panel
21
+ from rich.progress import (
22
+ BarColumn,
23
+ Progress,
24
+ SpinnerColumn,
25
+ TaskProgressColumn,
26
+ TextColumn,
27
+ )
28
+ from rich.syntax import Syntax
29
+ from rich.table import Table
30
+ from rich.text import Text
31
+
32
+ # ---------------------------------------------------------
33
+ # Palette
34
+ # ---------------------------------------------------------
35
+ COLOR_CRIMSON = "#e60033"
36
+ COLOR_EMBER = "#ff6a00"
37
+ COLOR_GOLD = "#e0a96d"
38
+ COLOR_BONE = "#f4f1de"
39
+ COLOR_IRON = "#3d3a45"
40
+ COLOR_ASH = "#6c757d"
41
+
42
+ console = Console()
43
+
44
+ CLI_DESCRIPTION = "Oni is a fast CLI tool that packages your files into a single context window."
45
+
46
+ app = typer.Typer(
47
+ name="oni",
48
+ help=f"[bold {COLOR_CRIMSON}]ONI (鬼)[/] :: {CLI_DESCRIPTION}",
49
+ rich_markup_mode="rich",
50
+ no_args_is_help=False,
51
+ )
52
+
53
+ # Standard ignore sets spared from the maw
54
+ DEFAULT_IGNORES = {
55
+ ".git",
56
+ "node_modules",
57
+ "__pycache__",
58
+ ".venv",
59
+ "venv",
60
+ ".pytest_cache",
61
+ ".mypy_cache",
62
+ "dist",
63
+ "build",
64
+ ".DS_Store",
65
+ ".idea",
66
+ ".vscode",
67
+ }
68
+
69
+ # Extension-to-syntax mapping for code fences
70
+ EXT_MAP = {
71
+ ".py": "python",
72
+ ".js": "javascript",
73
+ ".ts": "typescript",
74
+ ".tsx": "tsx",
75
+ ".jsx": "jsx",
76
+ ".json": "json",
77
+ ".yaml": "yaml",
78
+ ".yml": "yaml",
79
+ ".toml": "toml",
80
+ ".md": "markdown",
81
+ ".rs": "rust",
82
+ ".go": "go",
83
+ ".html": "html",
84
+ ".css": "css",
85
+ ".sh": "bash",
86
+ ".zsh": "bash",
87
+ ".sql": "sql",
88
+ ".c": "c",
89
+ ".cpp": "cpp",
90
+ ".h": "c",
91
+ ".hpp": "cpp",
92
+ ".swift": "swift",
93
+ ".kt": "kotlin",
94
+ }
95
+
96
+ # Questionary style tuned to iron and crimson
97
+ ONI_PROMPT_STYLE = QStyle(
98
+ [
99
+ ("qmark", f"fg:{COLOR_CRIMSON} bold"),
100
+ ("question", f"fg:{COLOR_BONE} bold"),
101
+ ("answer", f"fg:{COLOR_EMBER} bold"),
102
+ ("pointer", f"fg:{COLOR_CRIMSON} bold"),
103
+ ("text", f"fg:{COLOR_BONE}"),
104
+ ]
105
+ )
106
+
107
+
108
+ def render_banner() -> None:
109
+ ascii_art = pyfiglet.figlet_format("ONI", font="alligator2")
110
+ header_text = Text(ascii_art, style=f"bold {COLOR_CRIMSON}")
111
+ sub_title = Text(
112
+ "INSTANT CONTEXT WINDOWS FROM YOUR FILES",
113
+ style=f"bold {COLOR_EMBER}",
114
+ )
115
+ console.print(
116
+ Panel(
117
+ Align.center(Group(header_text, sub_title)),
118
+ border_style=COLOR_CRIMSON,
119
+ box=box.DOUBLE,
120
+ padding=(0, 2),
121
+ )
122
+ )
123
+
124
+
125
+ def is_binary(path: Path) -> bool:
126
+ """Detect binary files that would choke the prompt buffer."""
127
+ try:
128
+ with open(path, "rb") as f:
129
+ chunk = f.read(1024)
130
+ return b"\x00" in chunk
131
+ except Exception:
132
+ return True
133
+
134
+
135
+ def copy_to_macos_clipboard(payload: str) -> None:
136
+ """Feeds the amalgamated payload directly to the macOS pbcopy buffer."""
137
+ process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, close_fds=True)
138
+ process.communicate(payload.encode("utf-8"))
139
+ if process.returncode != 0:
140
+ raise RuntimeError("pbcopy buffer rejected the payload.")
141
+
142
+
143
+ # ---------------------------------------------------------
144
+ # Core Execution
145
+ # ---------------------------------------------------------
146
+ @app.callback(invoke_without_command=True)
147
+ def devour(
148
+ ctx: typer.Context,
149
+ patterns: Annotated[
150
+ Optional[List[str]],
151
+ typer.Argument(
152
+ help="File paths or glob patterns (e.g. 'src/**/*.py', 'main.py', '*.json'). Prompts if omitted."
153
+ ),
154
+ ] = None,
155
+ output_meta: Annotated[
156
+ bool,
157
+ typer.Option(
158
+ "--meta/--raw",
159
+ help="Include repository overview and file breakdown in LLM context header.",
160
+ ),
161
+ ] = True,
162
+ max_file_size_kb: Annotated[
163
+ int,
164
+ typer.Option(
165
+ "--max-kb", "-m", help="Skip files larger than this threshold in KB."
166
+ ),
167
+ ] = 512,
168
+ preview: Annotated[
169
+ bool,
170
+ typer.Option(
171
+ "--preview", "-p", help="Echo sample of amalgamated context to terminal."
172
+ ),
173
+ ] = False,
174
+ ):
175
+ """
176
+ Read codebase files and amalgamate them directly into your macOS clipboard.
177
+ """
178
+ render_banner()
179
+ root_dir = Path.cwd()
180
+
181
+ # Interactive prompt if no patterns provided
182
+ if not patterns:
183
+ console.print(
184
+ f"[bold {COLOR_EMBER}]Specify file patterns to search for...[/]\n"
185
+ )
186
+ user_input = questionary.text(
187
+ "Enter target paths or globs to fetch (space-separated):",
188
+ default="**/*",
189
+ style=ONI_PROMPT_STYLE,
190
+ ).ask()
191
+
192
+ if not user_input or not user_input.strip():
193
+ console.print(f"[{COLOR_ASH}]Aborted.[/]")
194
+ raise typer.Exit()
195
+ patterns = user_input.strip().split()
196
+
197
+ console.print(
198
+ f"[bold {COLOR_ASH}]Root Directory:[/] [bold {COLOR_BONE}]{root_dir}[/]"
199
+ )
200
+ console.print(
201
+ f"[bold {COLOR_ASH}]Glob Patterns:[/] [bold {COLOR_GOLD}]{', '.join(patterns)}[/]\n"
202
+ )
203
+
204
+ # Step 1: Scan & resolve files
205
+ matching_files: Set[Path] = set()
206
+
207
+ with console.status(
208
+ f"[bold {COLOR_CRIMSON}]Fetching matching files across directories...",
209
+ spinner="aesthetic",
210
+ ):
211
+ for pattern in patterns:
212
+ target = root_dir / pattern
213
+ if target.is_file():
214
+ matching_files.add(target.resolve())
215
+ elif target.is_dir():
216
+ for p in target.rglob("*"):
217
+ if p.is_file():
218
+ matching_files.add(p.resolve())
219
+ else:
220
+ for p in root_dir.glob(pattern):
221
+ if p.is_file():
222
+ matching_files.add(p.resolve())
223
+
224
+ # Filter out ignored directories
225
+ filtered_files: List[Path] = []
226
+ for f in matching_files:
227
+ rel = f.relative_to(root_dir)
228
+ if any(ignored in rel.parts for ignored in DEFAULT_IGNORES):
229
+ continue
230
+ filtered_files.append(f)
231
+
232
+ filtered_files.sort()
233
+
234
+ if not filtered_files:
235
+ console.print(
236
+ Panel(
237
+ f"[bold {COLOR_CRIMSON}]Nothing found.[/]\n"
238
+ f"[{COLOR_ASH}]No files matched the specified patterns or all were filtered by ignore rules (.git, node_modules, etc).[/]",
239
+ border_style=COLOR_CRIMSON,
240
+ box=box.ROUNDED,
241
+ )
242
+ )
243
+ raise typer.Exit()
244
+
245
+ # Step 2: Ingest, format, and display progress
246
+ payload_parts: List[str] = []
247
+ devour_table = Table(
248
+ box=box.HORIZONTALS,
249
+ border_style=COLOR_IRON,
250
+ title=f"[bold {COLOR_CRIMSON}]LOG[/]",
251
+ expand=True,
252
+ )
253
+ devour_table.add_column("#", style=f"bold {COLOR_ASH}", width=5)
254
+ devour_table.add_column("File", style=f"bold {COLOR_BONE}")
255
+ devour_table.add_column("Lines", style=COLOR_GOLD, justify="right")
256
+ devour_table.add_column("Weight", style=COLOR_EMBER, justify="right")
257
+ devour_table.add_column("Fate", style=COLOR_CRIMSON, justify="center")
258
+
259
+ if output_meta:
260
+ payload_parts.append(
261
+ f"# FILE CONTEXT\n"
262
+ f"- Project Root: `{root_dir.name}`\n"
263
+ f"- Total Files: {len(filtered_files)}\n\n"
264
+ f"The following files have been added into the context window "
265
+ f"for full comprehension and context.\n\n"
266
+ f"---\n\n"
267
+ )
268
+
269
+ total_bytes = 0
270
+ total_lines = 0
271
+
272
+ with Progress(
273
+ SpinnerColumn("bouncingBar", style=f"bold {COLOR_CRIMSON}"),
274
+ TextColumn(f"[bold {COLOR_BONE}]{{task.description}}"),
275
+ BarColumn(
276
+ bar_width=35,
277
+ style=COLOR_IRON,
278
+ complete_style=COLOR_CRIMSON,
279
+ finished_style=COLOR_EMBER,
280
+ ),
281
+ TaskProgressColumn(style=f"bold {COLOR_GOLD}"),
282
+ console=console,
283
+ ) as progress:
284
+ task = progress.add_task("Reading files...", total=len(filtered_files))
285
+
286
+ for idx, file_path in enumerate(filtered_files, start=1):
287
+ rel_path = file_path.relative_to(root_dir)
288
+ size_kb = file_path.stat().st_size / 1024
289
+
290
+ if is_binary(file_path):
291
+ devour_table.add_row(
292
+ f"{idx:02d}",
293
+ str(rel_path),
294
+ "—",
295
+ f"{size_kb:.1f} KB",
296
+ f"[{COLOR_ASH}](BINARY)[/]",
297
+ )
298
+ progress.update(task, advance=1)
299
+ continue
300
+
301
+ if size_kb > max_file_size_kb:
302
+ devour_table.add_row(
303
+ f"{idx:02d}",
304
+ str(rel_path),
305
+ "—",
306
+ f"{size_kb:.1f} KB",
307
+ f"[{COLOR_EMBER}]SPAT OUT (> {max_file_size_kb}KB)[/]",
308
+ )
309
+ progress.update(task, advance=1)
310
+ continue
311
+
312
+ try:
313
+ content = file_path.read_text(encoding="utf-8", errors="replace")
314
+ lines_count = len(content.splitlines())
315
+ total_lines += lines_count
316
+ total_bytes += file_path.stat().st_size
317
+ lang = EXT_MAP.get(file_path.suffix.lower(), "")
318
+
319
+ # LLM code fence formatting
320
+ shard_block = f"## File: `{rel_path}`\n```{lang}\n{content}\n```\n\n"
321
+ payload_parts.append(shard_block)
322
+
323
+ devour_table.add_row(
324
+ f"{idx:02d}",
325
+ str(rel_path),
326
+ f"{lines_count:,}",
327
+ f"{size_kb:.1f} KB",
328
+ f"[bold {COLOR_CRIMSON}]DEVOUR[/]",
329
+ )
330
+ except Exception as e:
331
+ devour_table.add_row(
332
+ f"{idx:02d}", str(rel_path), "ERR", "0 KB", f"[red]{str(e)[:15]}[/]"
333
+ )
334
+
335
+ progress.update(task, advance=1)
336
+
337
+ console.print()
338
+ console.print(devour_table)
339
+
340
+ final_payload = "".join(payload_parts)
341
+
342
+ if not final_payload.strip():
343
+ console.print(f"[{COLOR_CRIMSON}]No source content was found.[/]")
344
+ raise typer.Exit()
345
+
346
+ # Step 3: Direct Ingestion into macOS Clipboard
347
+ with console.status(
348
+ f"[bold {COLOR_CRIMSON}]Binding context into macOS clipboard...", spinner="line"
349
+ ):
350
+ try:
351
+ copy_to_macos_clipboard(final_payload)
352
+ except Exception as err:
353
+ console.print(f"[bold red]Failed to deliver payload to clipboard:[/] {err}")
354
+ raise typer.Exit(code=1)
355
+
356
+ # Culmination / Victory Panel
357
+ stats_msg = (
358
+ f"[bold {COLOR_CRIMSON}]COMPLETE // PROMPT CREATED[/]\n\n"
359
+ f"• [bold {COLOR_BONE}]Clipboard State:[/] [bold {COLOR_EMBER}]Copied[/]\n"
360
+ f"• [bold {COLOR_BONE}]Files Combined:[/] [bold {COLOR_GOLD}]{len(filtered_files)}[/]\n"
361
+ f"• [bold {COLOR_BONE}]Total Lines:[/] [bold {COLOR_GOLD}]{total_lines:,}[/]\n"
362
+ f"• [bold {COLOR_BONE}]Context Size:[/] [bold {COLOR_GOLD}]{total_bytes / 1024:.2f} KB[/]\n\n"
363
+ f"[{COLOR_ASH}]Press [bold {COLOR_BONE}]⌘ + V[/] to paste the content.[/]"
364
+ )
365
+ console.print()
366
+ console.print(Panel(stats_msg, border_style=COLOR_CRIMSON, box=box.HEAVY))
367
+
368
+ # Step 4: Optional terminal preview
369
+ if preview:
370
+ console.print(f"\n[bold {COLOR_EMBER}]// PROMPT PREVIEW //[/]\n")
371
+ syntax = Syntax(
372
+ final_payload[:2500], "markdown", theme="ansi_dark", line_numbers=True
373
+ )
374
+ console.print(
375
+ Panel(
376
+ syntax,
377
+ title=f"[{COLOR_ASH}]Excerpt (First 2500 chars)[/]",
378
+ border_style=COLOR_IRON,
379
+ )
380
+ )
381
+
382
+
383
+ if __name__ == "__main__":
384
+ app()
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: oni-cli
3
+ Version: 1.0.0
4
+ Summary: Single-command context aggregation for LLMs, straight to your macOS clipboard.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pyfiglet>=1.0.4
8
+ Requires-Dist: questionary>=2.1.1
9
+ Requires-Dist: rich>=15.0.0
10
+ Requires-Dist: typer>=0.27.2
11
+
12
+
13
+ <div align="center">
14
+
15
+ <img src="https://rowlinsonmike.com/oni.png" alt="ONI Logo" width="180" />
16
+
17
+ # ONI
18
+
19
+ **Single-command context aggregation for LLMs, straight to your macOS clipboard.**
20
+
21
+ <br />
22
+
23
+ <img src="https://rowlinsonmike.com/oni-demo.gif" alt="ONI Demo" width="750" />
24
+
25
+ </div>
26
+
27
+ ---
28
+
29
+ CLI that makes a single context widow from your files and sends it directly to your macOS clipboard.
30
+
31
+ ---
32
+
33
+ ## Key Features
34
+
35
+ - **Direct to Clipboard**: Uses macOS `pbcopy` under the hood to send your aggregated code context straight to your clipboard.
36
+ - **Smart Filtering**: Ignores binary files and junk directories (`.git`, `node_modules`, `.venv`, build caches) automatically.
37
+ - **Syntax Awareness**: Maps file extensions to Markdown code fences (`py`, `ts`, `rs`, `go`, `sql`, etc.) for LLM legibility.
38
+ - **Interactive Prompting**: If run with no arguments, Oni prompts for target glob patterns.
39
+ - **Size Safeguards**: Skips oversized source files with customizable thresholds.
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ Ensure you have Python 3.9+ installed:
46
+
47
+
48
+ ---
49
+
50
+ ## Quick Usage
51
+
52
+ ### Parse everything in the current directory
53
+
54
+ ```bash
55
+ oni
56
+
57
+ ```
58
+
59
+ *If called without patterns, Oni triggers an interactive prompt defaulting to `**/*`.*
60
+
61
+ ### Parse specific directories or file globs
62
+
63
+ ```bash
64
+ # Ingest entire src directory
65
+ oni src/**/*
66
+
67
+ # Combine specific file types
68
+ oni "src/**/*.py" "config/*.json"
69
+
70
+ # Ingest explicit targets
71
+ oni main.py pyproject.toml docs/README.md
72
+
73
+ ```
74
+
75
+ ---
76
+
77
+ ## CLI Options Reference
78
+
79
+ ```text
80
+ Usage: oni [OPTIONS] [PATTERNS]...
81
+
82
+ ```
83
+
84
+ | Flag / Option | Short | Default | Description |
85
+ | --- | --- | --- | --- |
86
+ | `[PATTERNS]...` | — | `None` (Prompts) | File paths or glob patterns (e.g. `src/**/*.py`, `main.py`). |
87
+ | `--meta / --raw` | — | `--meta` | Includes repository file context header and aggregate statistics. |
88
+ | `--max-kb` | `-m` | `512` | Upper size limit in KB per file. Larger files are spat out. |
89
+ | `--preview` | `-p` | `False` | Prints the first 2,500 characters of the prompt to terminal. |
90
+ | `--help` | — | — | Displays the command-line usage and exits. |
91
+
92
+ ### Examples with Flags
93
+
94
+ #### Omit Metadata Header
95
+
96
+ Generate pure code fence blocks with no introductory repository summaries:
97
+
98
+ ```bash
99
+ oni "src/**/*.rs" --raw
100
+
101
+ ```
102
+
103
+ #### Adjust Max File Size Threshold
104
+
105
+ Lower file size limit to 200 KB and inspect the payload before pasting:
106
+
107
+ ```bash
108
+ oni "**/*.ts" --max-kb 200 --preview
109
+
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Automatic Exclusions
115
+
116
+ Oni ignores the following paths during scanning:
117
+
118
+ ```text
119
+ .git node_modules __pycache__
120
+ .venv venv .pytest_cache
121
+ .mypy_cache dist build
122
+ .DS_Store .idea .vscode
123
+
124
+ ```
125
+
126
+ Binary files are detected via null-byte inspection and excluded from the output buffer.
@@ -0,0 +1,6 @@
1
+ oni/oni.py,sha256=SsGf_olqYCMWrillRqZlXCXN5Okfo0Wy7gNUo8QABbs,11854
2
+ oni_cli-1.0.0.dist-info/METADATA,sha256=T2JQVcVbi4IaOwbyFiAxaaI6LXWCWtj7yiESw_UbcLI,3085
3
+ oni_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ oni_cli-1.0.0.dist-info/entry_points.txt,sha256=yE9SVtyFFlSYMijdNBm5DUtD_vBYg3QWuOgTzQ6mtZo,36
5
+ oni_cli-1.0.0.dist-info/top_level.txt,sha256=y46kSeGeBZ2tP2IRm8OwnYdiwUJbsD4EcvLLbt7D5JU,4
6
+ oni_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oni = oni.oni:app
@@ -0,0 +1 @@
1
+ oni