azathoth 0.0.1__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.
- azathoth/__init__.py +20 -0
- azathoth/agent/__init__.py +0 -0
- azathoth/agent/tasks/__init__.py +0 -0
- azathoth/cli/__init__.py +6 -0
- azathoth/cli/commands/__init__.py +0 -0
- azathoth/cli/commands/i18n.py +273 -0
- azathoth/cli/commands/ingest.py +316 -0
- azathoth/cli/commands/workflow.py +244 -0
- azathoth/cli/main.py +16 -0
- azathoth/config.py +77 -0
- azathoth/core/__init__.py +0 -0
- azathoth/core/directives.py +79 -0
- azathoth/core/exceptions.py +34 -0
- azathoth/core/i18n.py +360 -0
- azathoth/core/ingest.py +312 -0
- azathoth/core/llm.py +68 -0
- azathoth/core/prompts.py +153 -0
- azathoth/core/scout.py +68 -0
- azathoth/core/utils.py +23 -0
- azathoth/core/workflow.py +115 -0
- azathoth/directives/core.toml +13 -0
- azathoth/mcp/__init__.py +0 -0
- azathoth/mcp/i18n.py +105 -0
- azathoth/mcp/workflow.py +153 -0
- azathoth/providers/__init__.py +0 -0
- azathoth/transforms/__init__.py +0 -0
- azathoth-0.0.1.dist-info/METADATA +101 -0
- azathoth-0.0.1.dist-info/RECORD +31 -0
- azathoth-0.0.1.dist-info/WHEEL +4 -0
- azathoth-0.0.1.dist-info/entry_points.txt +4 -0
- azathoth-0.0.1.dist-info/licenses/LICENSE +21 -0
azathoth/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Azathoth: AI Architect & Development Framework
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
# Load .env from azathoth's own directory, not the cwd
|
|
9
|
+
load_dotenv(Path(__file__).parent.parent.parent / ".env")
|
|
10
|
+
|
|
11
|
+
from azathoth.cli import init_cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""Main entry point for the Azathoth CLI."""
|
|
16
|
+
init_cli()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
File without changes
|
|
File without changes
|
azathoth/cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
|
|
11
|
+
from azathoth.core.i18n import (
|
|
12
|
+
InlangConfig,
|
|
13
|
+
resolve_paths,
|
|
14
|
+
load_all_translations,
|
|
15
|
+
diff_against_base,
|
|
16
|
+
translate_locale,
|
|
17
|
+
merge_translations,
|
|
18
|
+
write_translations,
|
|
19
|
+
prune_orphans,
|
|
20
|
+
build_matrix,
|
|
21
|
+
export_registry,
|
|
22
|
+
import_registry,
|
|
23
|
+
)
|
|
24
|
+
from azathoth.core.exceptions import I18nError
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(help="i18n translation automation commands.")
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def translate(
|
|
32
|
+
settings_path: Path = typer.Argument(
|
|
33
|
+
..., help="Path to project.inlang/settings.json"
|
|
34
|
+
),
|
|
35
|
+
full: bool = typer.Option(
|
|
36
|
+
False, "--full", help="Retranslate all keys, not just missing ones."
|
|
37
|
+
),
|
|
38
|
+
dry_run: bool = typer.Option(
|
|
39
|
+
False, "--dry-run", help="Preview changes without writing to files."
|
|
40
|
+
),
|
|
41
|
+
prune: bool = typer.Option(
|
|
42
|
+
False, "--prune", help="Remove orphan keys from target files."
|
|
43
|
+
),
|
|
44
|
+
):
|
|
45
|
+
"""Translate missing keys using AI."""
|
|
46
|
+
try:
|
|
47
|
+
config = InlangConfig.from_json(settings_path)
|
|
48
|
+
paths = resolve_paths(settings_path, config)
|
|
49
|
+
translations = load_all_translations(paths)
|
|
50
|
+
|
|
51
|
+
base_locale = config.base_locale
|
|
52
|
+
if base_locale not in translations:
|
|
53
|
+
console.print(
|
|
54
|
+
f"[red]Error: Base locale '{base_locale}' not found in translations.[/red]"
|
|
55
|
+
)
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
base_set = translations[base_locale]
|
|
59
|
+
target_locales = [l for l in config.locales if l != base_locale]
|
|
60
|
+
|
|
61
|
+
async def run_translations():
|
|
62
|
+
tasks = []
|
|
63
|
+
results = {}
|
|
64
|
+
|
|
65
|
+
with Progress(
|
|
66
|
+
SpinnerColumn(),
|
|
67
|
+
TextColumn("[progress.description]{task.description}"),
|
|
68
|
+
console=console,
|
|
69
|
+
) as progress:
|
|
70
|
+
for locale in target_locales:
|
|
71
|
+
target_set = translations.get(locale)
|
|
72
|
+
diff = diff_against_base(base_set, target_set)
|
|
73
|
+
|
|
74
|
+
keys_to_translate = diff.missing_keys
|
|
75
|
+
if full:
|
|
76
|
+
keys_to_translate = list(base_set.messages.keys())
|
|
77
|
+
|
|
78
|
+
if not keys_to_translate:
|
|
79
|
+
console.print(
|
|
80
|
+
f"[yellow]Skipping {locale}: No keys to translate.[/yellow]"
|
|
81
|
+
)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
values_to_translate = [
|
|
85
|
+
base_set.messages[k] for k in keys_to_translate
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Style samples (first 5 existing translations)
|
|
89
|
+
samples = []
|
|
90
|
+
existing_keys = [
|
|
91
|
+
k for k in base_set.messages.keys() if k in target_set.messages
|
|
92
|
+
]
|
|
93
|
+
for k in existing_keys[:5]:
|
|
94
|
+
samples.append((base_set.messages[k], target_set.messages[k]))
|
|
95
|
+
|
|
96
|
+
task_id = progress.add_task(
|
|
97
|
+
description=f"Translating {locale} ({len(keys_to_translate)} keys)...",
|
|
98
|
+
total=None,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def do_translate(
|
|
102
|
+
l=locale,
|
|
103
|
+
k=keys_to_translate,
|
|
104
|
+
v=values_to_translate,
|
|
105
|
+
s=samples,
|
|
106
|
+
t_id=task_id,
|
|
107
|
+
):
|
|
108
|
+
try:
|
|
109
|
+
res = await translate_locale(l, k, v, s)
|
|
110
|
+
progress.update(
|
|
111
|
+
t_id,
|
|
112
|
+
completed=True,
|
|
113
|
+
description=f"[green]Finished {l}[/green]",
|
|
114
|
+
)
|
|
115
|
+
return l, k, res
|
|
116
|
+
except Exception as e:
|
|
117
|
+
progress.update(
|
|
118
|
+
t_id,
|
|
119
|
+
completed=True,
|
|
120
|
+
description=f"[red]Failed {l}[/red]",
|
|
121
|
+
)
|
|
122
|
+
return l, k, e
|
|
123
|
+
|
|
124
|
+
tasks.append(do_translate())
|
|
125
|
+
|
|
126
|
+
if not tasks:
|
|
127
|
+
console.print("[green]All translations up to date.[/green]")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
batch_results = await asyncio.gather(*tasks)
|
|
131
|
+
for locale, keys, result in batch_results:
|
|
132
|
+
if isinstance(result, Exception):
|
|
133
|
+
console.print(
|
|
134
|
+
f"[red]Error translating {locale}: {str(result)}[/red]"
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
results[locale] = (keys, result)
|
|
138
|
+
return results
|
|
139
|
+
|
|
140
|
+
results = asyncio.run(run_translations())
|
|
141
|
+
|
|
142
|
+
if not results:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Apply changes
|
|
146
|
+
for locale, (keys, values) in results.items():
|
|
147
|
+
target_set = translations[locale]
|
|
148
|
+
new_set = merge_translations(target_set, keys, values)
|
|
149
|
+
|
|
150
|
+
if prune:
|
|
151
|
+
new_set = prune_orphans(new_set, base_set)
|
|
152
|
+
|
|
153
|
+
if not dry_run:
|
|
154
|
+
write_translations(paths[locale], new_set)
|
|
155
|
+
console.print(f"[green]Updated {paths[locale]}[/green]")
|
|
156
|
+
else:
|
|
157
|
+
console.print(
|
|
158
|
+
f"[yellow][DRY RUN] Would update {paths[locale]}[/yellow]"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
except I18nError as e:
|
|
162
|
+
console.print(f"[red]Error: {str(e)}[/red]")
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command()
|
|
167
|
+
def audit(
|
|
168
|
+
settings_path: Path = typer.Argument(
|
|
169
|
+
..., help="Path to project.inlang/settings.json"
|
|
170
|
+
),
|
|
171
|
+
):
|
|
172
|
+
"""Display a translation coverage matrix."""
|
|
173
|
+
try:
|
|
174
|
+
config = InlangConfig.from_json(settings_path)
|
|
175
|
+
paths = resolve_paths(settings_path, config)
|
|
176
|
+
translations = load_all_translations(paths)
|
|
177
|
+
|
|
178
|
+
locales = config.locales
|
|
179
|
+
matrix = build_matrix(translations, locales)
|
|
180
|
+
|
|
181
|
+
table = Table(title="i18n Translation Audit")
|
|
182
|
+
table.add_column("Key", style="cyan", no_wrap=True)
|
|
183
|
+
for locale in locales:
|
|
184
|
+
table.add_column(locale, justify="center")
|
|
185
|
+
|
|
186
|
+
for key in matrix.keys:
|
|
187
|
+
row = [key]
|
|
188
|
+
for locale in locales:
|
|
189
|
+
val = matrix.matrix[key][locale]
|
|
190
|
+
if val:
|
|
191
|
+
row.append("[green]✓[/green]")
|
|
192
|
+
else:
|
|
193
|
+
row.append("[red]✗[/red]")
|
|
194
|
+
table.add_row(*row)
|
|
195
|
+
|
|
196
|
+
# Totals row
|
|
197
|
+
totals = ["TOTAL"]
|
|
198
|
+
for locale in locales:
|
|
199
|
+
count = sum(1 for k in matrix.keys if matrix.matrix[k][locale])
|
|
200
|
+
percent = (count / len(matrix.keys)) * 100 if matrix.keys else 0
|
|
201
|
+
color = "green" if percent == 100 else "yellow" if percent > 80 else "red"
|
|
202
|
+
totals.append(
|
|
203
|
+
f"[{color}]{count}/{len(matrix.keys)} ({percent:.0f}%)[/{color}]"
|
|
204
|
+
)
|
|
205
|
+
table.add_section()
|
|
206
|
+
table.add_row(*totals)
|
|
207
|
+
|
|
208
|
+
console.print(table)
|
|
209
|
+
|
|
210
|
+
except I18nError as e:
|
|
211
|
+
console.print(f"[red]Error: {str(e)}[/red]")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def export(
|
|
217
|
+
settings_path: Path = typer.Argument(
|
|
218
|
+
..., help="Path to project.inlang/settings.json"
|
|
219
|
+
),
|
|
220
|
+
output: Path = typer.Option(
|
|
221
|
+
"registry.json", "--output", "-o", help="Output file path."
|
|
222
|
+
),
|
|
223
|
+
fmt: str = typer.Option("json", "--format", "-f", help="Export format (json, py)."),
|
|
224
|
+
):
|
|
225
|
+
"""Export all translations to a master registry file."""
|
|
226
|
+
try:
|
|
227
|
+
config = InlangConfig.from_json(settings_path)
|
|
228
|
+
paths = resolve_paths(settings_path, config)
|
|
229
|
+
translations = load_all_translations(paths)
|
|
230
|
+
|
|
231
|
+
matrix = build_matrix(translations, config.locales)
|
|
232
|
+
export_registry(matrix, output, fmt)
|
|
233
|
+
console.print(f"[green]Exported registry to {output}[/green]")
|
|
234
|
+
|
|
235
|
+
except I18nError as e:
|
|
236
|
+
console.print(f"[red]Error: {str(e)}[/red]")
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def sync(
|
|
242
|
+
registry_path: Path = typer.Argument(..., help="Path to registry.json"),
|
|
243
|
+
settings_path: Path = typer.Argument(
|
|
244
|
+
..., help="Path to project.inlang/settings.json"
|
|
245
|
+
),
|
|
246
|
+
):
|
|
247
|
+
"""Sync a registry file back to individual locale files."""
|
|
248
|
+
try:
|
|
249
|
+
matrix = import_registry(registry_path)
|
|
250
|
+
config = InlangConfig.from_json(settings_path)
|
|
251
|
+
paths = resolve_paths(settings_path, config)
|
|
252
|
+
|
|
253
|
+
for locale in matrix.locales:
|
|
254
|
+
if locale not in paths:
|
|
255
|
+
console.print(
|
|
256
|
+
f"[yellow]Warning: Locale '{locale}' in registry not found in config. Skipping.[/yellow]"
|
|
257
|
+
)
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
messages = {}
|
|
261
|
+
for key in matrix.keys:
|
|
262
|
+
val = matrix.matrix[key].get(locale)
|
|
263
|
+
if val:
|
|
264
|
+
messages[key] = val
|
|
265
|
+
|
|
266
|
+
write_translations(
|
|
267
|
+
paths[locale], TranslationSet(locale=locale, messages=messages)
|
|
268
|
+
)
|
|
269
|
+
console.print(f"[green]Synced {paths[locale]}[/green]")
|
|
270
|
+
|
|
271
|
+
except I18nError as e:
|
|
272
|
+
console.print(f"[red]Error: {str(e)}[/red]")
|
|
273
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console, RenderableType
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.progress import (
|
|
12
|
+
Progress,
|
|
13
|
+
SpinnerColumn,
|
|
14
|
+
BarColumn,
|
|
15
|
+
TextColumn,
|
|
16
|
+
TimeElapsedColumn,
|
|
17
|
+
MofNCompleteColumn,
|
|
18
|
+
ProgressColumn,
|
|
19
|
+
Task,
|
|
20
|
+
)
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from azathoth.config import config
|
|
24
|
+
from azathoth.core.ingest import (
|
|
25
|
+
ingest,
|
|
26
|
+
IngestionResult,
|
|
27
|
+
detect_type,
|
|
28
|
+
IngestType,
|
|
29
|
+
fetch_user_repos,
|
|
30
|
+
get_subpath_context,
|
|
31
|
+
)
|
|
32
|
+
from azathoth.core.utils import format_size
|
|
33
|
+
|
|
34
|
+
# --- AGGRESSIVE LOG SILENCING ---
|
|
35
|
+
try:
|
|
36
|
+
from loguru import logger
|
|
37
|
+
|
|
38
|
+
logger.remove()
|
|
39
|
+
logger.disable("gitingest")
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
console = Console()
|
|
44
|
+
app = typer.Typer(help="Ingest codebases into a single file.", no_args_is_help=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StatusSpinnerColumn(ProgressColumn):
|
|
48
|
+
"""Morphs from spinner to ✓/✗ on completion."""
|
|
49
|
+
|
|
50
|
+
def __init__(self):
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.spinner = SpinnerColumn(spinner_name="dots")
|
|
53
|
+
|
|
54
|
+
def render(self, task: "Task") -> RenderableType:
|
|
55
|
+
if task.finished:
|
|
56
|
+
icon = task.fields.get("status_icon", "[bold green]✓[/]")
|
|
57
|
+
return Text.from_markup(icon)
|
|
58
|
+
return self.spinner.render(task)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _display_info_panel(
|
|
62
|
+
target: str, detected_type: IngestType, mode: str, ignore_gitignore: bool = False
|
|
63
|
+
):
|
|
64
|
+
"""The blue info panel at the start."""
|
|
65
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
66
|
+
table.add_column(style="dim")
|
|
67
|
+
table.add_column(style="bold cyan")
|
|
68
|
+
table.add_row("Target:", target)
|
|
69
|
+
table.add_row("Type:", detected_type.name)
|
|
70
|
+
table.add_row("Mode:", mode)
|
|
71
|
+
if ignore_gitignore:
|
|
72
|
+
table.add_row("Ignore Gitignore:", "[bold yellow]Yes[/]")
|
|
73
|
+
|
|
74
|
+
panel = Panel(
|
|
75
|
+
table,
|
|
76
|
+
title="🚀 [bold]Ingestion Started[/bold]",
|
|
77
|
+
border_style="blue",
|
|
78
|
+
expand=False,
|
|
79
|
+
)
|
|
80
|
+
console.print(panel)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _display_metrics_panel(result: IngestionResult, save_path: Optional[Path]):
|
|
84
|
+
"""The green summary panel at the end."""
|
|
85
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
86
|
+
table.add_column(style="dim")
|
|
87
|
+
table.add_column(style="bold cyan")
|
|
88
|
+
|
|
89
|
+
table.add_row("Files", str(result.metrics.file_count))
|
|
90
|
+
table.add_row("Tokens", f"{result.metrics.token_count:,}")
|
|
91
|
+
table.add_row("Size", format_size(result.metrics.size_bytes))
|
|
92
|
+
if save_path:
|
|
93
|
+
table.add_row("Saved to", f"@{save_path}")
|
|
94
|
+
|
|
95
|
+
panel = Panel(
|
|
96
|
+
table,
|
|
97
|
+
title="[bold green]✓ Ingestion Complete![/]",
|
|
98
|
+
expand=False,
|
|
99
|
+
border_style="green",
|
|
100
|
+
)
|
|
101
|
+
console.print(panel)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def list_reports():
|
|
105
|
+
"""List all saved ingestion reports."""
|
|
106
|
+
reports = sorted(
|
|
107
|
+
config.reports_dir.glob("*.*"), key=lambda p: p.stat().st_mtime, reverse=True
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not reports:
|
|
111
|
+
console.print("[yellow]No reports found.[/]")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
table = Table(title="Ingestion Reports", box=box.SIMPLE)
|
|
115
|
+
table.add_column("Filename", style="cyan")
|
|
116
|
+
table.add_column("Date", style="dim")
|
|
117
|
+
table.add_column("Size", style="green")
|
|
118
|
+
|
|
119
|
+
for r in reports:
|
|
120
|
+
size = format_size(r.stat().st_size)
|
|
121
|
+
mtime = datetime.fromtimestamp(r.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
122
|
+
table.add_row(r.name, mtime, size)
|
|
123
|
+
|
|
124
|
+
console.print(table)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _ingest_single(
|
|
128
|
+
target: str,
|
|
129
|
+
list_only: bool,
|
|
130
|
+
save: bool,
|
|
131
|
+
output: Optional[Path],
|
|
132
|
+
fmt: str,
|
|
133
|
+
clipboard: bool,
|
|
134
|
+
ignore_gitignore: bool = False,
|
|
135
|
+
):
|
|
136
|
+
"""Handles ingestion for a single target."""
|
|
137
|
+
target_path = Path(target).resolve() if Path(target).exists() else None
|
|
138
|
+
|
|
139
|
+
# Determine mode string for display
|
|
140
|
+
if target_path and target_path.is_file():
|
|
141
|
+
mode = f"Single file → [bold]{target_path.name}[/]"
|
|
142
|
+
elif list_only:
|
|
143
|
+
mode = "Structure only [dim](--list)[/]"
|
|
144
|
+
else:
|
|
145
|
+
mode = "Full ingest"
|
|
146
|
+
|
|
147
|
+
ctx = await get_subpath_context(target)
|
|
148
|
+
if ctx:
|
|
149
|
+
root_name, rel_path = ctx
|
|
150
|
+
console.print(f"[dim]Context:[/dim] Detected Git Root at [bold]{root_name}[/]")
|
|
151
|
+
console.print(
|
|
152
|
+
f"[dim]Scope:[/dim] Restricting ingestion to [bold]{rel_path}[/]"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
itype = detect_type(target)
|
|
156
|
+
_display_info_panel(target, itype, mode, ignore_gitignore=ignore_gitignore)
|
|
157
|
+
|
|
158
|
+
with console.status(f"⠋ Ingesting [cyan]{target}[/cyan]...", spinner="dots"):
|
|
159
|
+
try:
|
|
160
|
+
result = await ingest(
|
|
161
|
+
target, list_only=list_only, ignore_gitignore=ignore_gitignore
|
|
162
|
+
)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
console.print(f"[bold red]✗ Ingestion failed:[/] {e}")
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
# Determine save path
|
|
168
|
+
save_path = None
|
|
169
|
+
if save or output:
|
|
170
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
171
|
+
list_tag = "-list" if list_only else ""
|
|
172
|
+
filename = f"{result.suggested_filename}{list_tag}-{timestamp}.{fmt}"
|
|
173
|
+
save_path = output or (config.reports_dir / filename)
|
|
174
|
+
|
|
175
|
+
full_report = result.format_report(fmt=fmt)
|
|
176
|
+
save_path.write_text(full_report, encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
if clipboard:
|
|
179
|
+
try:
|
|
180
|
+
import pyperclip
|
|
181
|
+
|
|
182
|
+
pyperclip.copy(result.format_report(fmt=fmt))
|
|
183
|
+
console.print("[dim]→ Copied to clipboard[/]")
|
|
184
|
+
except ImportError:
|
|
185
|
+
console.print("[yellow]→ Clipboard failed: pyperclip not installed[/]")
|
|
186
|
+
|
|
187
|
+
_display_metrics_panel(result, save_path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _ingest_user(
|
|
191
|
+
target: str,
|
|
192
|
+
output_dir: Path,
|
|
193
|
+
fmt: str,
|
|
194
|
+
separate: bool,
|
|
195
|
+
ignore_gitignore: bool = False,
|
|
196
|
+
):
|
|
197
|
+
"""Concurrent multi-repo ingestion for GitHub users."""
|
|
198
|
+
username = target.rstrip("/").split("/")[-1]
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
repos = await fetch_user_repos(username)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
console.print(f"[bold red]✗ Error fetching repos for {username}:[/] {e}")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if not repos:
|
|
207
|
+
console.print(f"[yellow]No public source repositories found for {username}.[/]")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
console.print(f"[bold green]✓[/] Found [bold]{len(repos)}[/] source repositories.")
|
|
211
|
+
|
|
212
|
+
semaphore = asyncio.Semaphore(5)
|
|
213
|
+
full_content = []
|
|
214
|
+
|
|
215
|
+
progress_cols = (
|
|
216
|
+
TextColumn(" "),
|
|
217
|
+
StatusSpinnerColumn(),
|
|
218
|
+
TextColumn("[bold blue]{task.description}[/]"),
|
|
219
|
+
BarColumn(bar_width=40),
|
|
220
|
+
MofNCompleteColumn(),
|
|
221
|
+
TimeElapsedColumn(),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
with Progress(*progress_cols, console=console, expand=False) as progress:
|
|
225
|
+
main_task = progress.add_task(f"Ingesting {username}...", total=len(repos))
|
|
226
|
+
|
|
227
|
+
async def _work(repo: Dict[str, Any]):
|
|
228
|
+
async with semaphore:
|
|
229
|
+
try:
|
|
230
|
+
res = await ingest(
|
|
231
|
+
repo["clone_url"], ignore_gitignore=ignore_gitignore
|
|
232
|
+
)
|
|
233
|
+
if separate:
|
|
234
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
235
|
+
path = (
|
|
236
|
+
output_dir / f"{res.suggested_filename}-{timestamp}.{fmt}"
|
|
237
|
+
)
|
|
238
|
+
path.write_text(res.format_report(fmt=fmt), encoding="utf-8")
|
|
239
|
+
else:
|
|
240
|
+
full_content.append(
|
|
241
|
+
f"\n\n{'=' * 40}\nREPO: {res.suggested_filename}\n{'=' * 40}\n{res.content}"
|
|
242
|
+
)
|
|
243
|
+
progress.update(main_task, advance=1)
|
|
244
|
+
except Exception:
|
|
245
|
+
progress.update(main_task, advance=1, status_icon="[bold red]✗[/]")
|
|
246
|
+
|
|
247
|
+
await asyncio.gather(*[_work(r) for r in repos])
|
|
248
|
+
|
|
249
|
+
if not separate and full_content:
|
|
250
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
251
|
+
save_path = output_dir / f"{username}-profile-{timestamp}.{fmt}"
|
|
252
|
+
save_path.write_text("\n".join(full_content), encoding="utf-8")
|
|
253
|
+
console.print(
|
|
254
|
+
f"\n[bold green]✓[/] Profile digest saved to: [bold]{save_path}[/]"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main(
|
|
259
|
+
ctx: typer.Context,
|
|
260
|
+
target: Optional[str] = typer.Argument(None, help="Path, GitHub URL, or Username"),
|
|
261
|
+
list_only: bool = typer.Option(
|
|
262
|
+
False, "--list", "-l", help="Structure only, no file content"
|
|
263
|
+
),
|
|
264
|
+
ignore_gitignore: bool = typer.Option(
|
|
265
|
+
False,
|
|
266
|
+
"--no-git-ignore",
|
|
267
|
+
help="Ignore .gitignore patterns and ingest everything",
|
|
268
|
+
),
|
|
269
|
+
save: bool = typer.Option(True, "--save/--no-save", help="Save report to file"),
|
|
270
|
+
output: Optional[Path] = typer.Option(
|
|
271
|
+
None, "--output", "-o", help="Custom output path"
|
|
272
|
+
),
|
|
273
|
+
format: str = typer.Option("txt", "--format", "-f", help="txt, md, xml"),
|
|
274
|
+
clipboard: bool = typer.Option(
|
|
275
|
+
False, "--clipboard", "-c", help="Copy to clipboard"
|
|
276
|
+
),
|
|
277
|
+
separate: bool = typer.Option(
|
|
278
|
+
False, "--separate", "-s", help="Split user repos into files"
|
|
279
|
+
),
|
|
280
|
+
list_reports_flag: bool = typer.Option(
|
|
281
|
+
False, "--reports", help="List saved reports"
|
|
282
|
+
),
|
|
283
|
+
):
|
|
284
|
+
"""
|
|
285
|
+
Ingest codebases into a single file.
|
|
286
|
+
"""
|
|
287
|
+
if list_reports_flag:
|
|
288
|
+
list_reports()
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
if not target:
|
|
292
|
+
console.print(ctx.get_help())
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
async def _run():
|
|
296
|
+
itype = detect_type(target)
|
|
297
|
+
if itype == IngestType.GITHUB_USER:
|
|
298
|
+
await _ingest_user(
|
|
299
|
+
target,
|
|
300
|
+
output or config.reports_dir,
|
|
301
|
+
format,
|
|
302
|
+
separate,
|
|
303
|
+
ignore_gitignore=ignore_gitignore,
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
await _ingest_single(
|
|
307
|
+
target,
|
|
308
|
+
list_only,
|
|
309
|
+
save,
|
|
310
|
+
output,
|
|
311
|
+
format,
|
|
312
|
+
clipboard,
|
|
313
|
+
ignore_gitignore=ignore_gitignore,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
asyncio.run(_run())
|