project-brain-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.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
project_brain/cli.py ADDED
@@ -0,0 +1,644 @@
1
+ import importlib.metadata
2
+ import json
3
+ import sys
4
+ import webbrowser
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ import os
8
+
9
+ import typer
10
+ import typer.rich_utils
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+
14
+ from project_brain.core.analyzer import analyze_project
15
+ from project_brain.core.config_loader import (DEFAULT_CONFIG, dump_config,
16
+ load_config)
17
+ from project_brain.core.differ import compute_diff, is_git_repo, run_git_command
18
+ from project_brain.core.doctor import run_doctor
19
+ from project_brain.core.explainer import explain_diff
20
+ from project_brain.core.explainer_file import explain_file, explain_function
21
+ from project_brain.core.exporter import (add_code_dir, add_code_file,
22
+ export_code_changes, export_full_code)
23
+ from project_brain.core.results import generate_html
24
+ from project_brain.core.summarizer import format_summary, load_data
25
+ from project_brain.llm.provider import call_llm
26
+
27
+ from project_brain.cli_help import (
28
+ ROOT_HELP,
29
+ PROJECT_HELP,
30
+ DIFF_HELP,
31
+ EXPORT_HELP,
32
+ LLM_HELP,
33
+ )
34
+
35
+ from project_brain.cli_ui import (
36
+ console,
37
+ section,
38
+ success,
39
+ error,
40
+ info,
41
+ key_value_table,
42
+ doctor_panel,
43
+ )
44
+
45
+ typer.rich_utils.STYLE_HELPTEXT = "bold"
46
+ typer.rich_utils.STYLE_OPTIONS_PANEL_BORDER = "cyan"
47
+ typer.rich_utils.STYLE_COMMANDS_PANEL_BORDER = "cyan"
48
+
49
+ def configure_output_encoding():
50
+ for stream in (sys.stdout, sys.stderr):
51
+ if hasattr(stream, "reconfigure"):
52
+ stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
53
+
54
+
55
+ configure_output_encoding()
56
+
57
+ app = typer.Typer(
58
+ help=ROOT_HELP,
59
+ rich_markup_mode="rich",
60
+ no_args_is_help=True,
61
+ )
62
+
63
+ project_app = typer.Typer(
64
+ help=PROJECT_HELP,
65
+ no_args_is_help=True,
66
+ )
67
+
68
+ diff_app = typer.Typer(
69
+ help=DIFF_HELP,
70
+ no_args_is_help=True,
71
+ )
72
+
73
+ export_app = typer.Typer(
74
+ help=EXPORT_HELP,
75
+ no_args_is_help=True,
76
+ )
77
+
78
+ llm_app = typer.Typer(
79
+ help=LLM_HELP,
80
+ no_args_is_help=True,
81
+ )
82
+
83
+ app.add_typer(project_app, name="project")
84
+ app.add_typer(diff_app, name="diff")
85
+ app.add_typer(export_app, name="export")
86
+ app.add_typer(llm_app, name="testllm")
87
+
88
+
89
+ def version_callback(value: bool):
90
+ if value:
91
+ try:
92
+ version = importlib.metadata.version("project-brain")
93
+ except Exception:
94
+ version = "unknown"
95
+ typer.echo(f"project-brain version: {version}")
96
+ raise typer.Exit()
97
+
98
+
99
+ @app.callback()
100
+ def main_callback(
101
+ version: bool = typer.Option(
102
+ False,
103
+ "--version",
104
+ "-v",
105
+ callback=version_callback,
106
+ is_eager=True,
107
+ help="Show version and exit",
108
+ )
109
+ ):
110
+ """ project-brain CLI entrypoint """
111
+
112
+
113
+ def require_git_repo(root: Path):
114
+ if not is_git_repo(root):
115
+ error(
116
+ "Current directory is not a git repository",
117
+ suggestion="""
118
+ Initialize git:
119
+
120
+ git init
121
+
122
+ Then create first commit:
123
+
124
+ git add .
125
+ git commit -m "initial commit"
126
+ """,
127
+ )
128
+ raise typer.Exit(code=1)
129
+
130
+
131
+ def create_file(path: Path, content: str):
132
+ if path.exists():
133
+ typer.echo(f"⚠️ Skipped (already exists): {path}")
134
+ return False
135
+ path.write_text(content)
136
+ typer.echo(f"✅ Created: {path}")
137
+ return True
138
+
139
+
140
+ @project_app.command()
141
+ def init():
142
+ """Initialize project-brain in the current directory"""
143
+ cwd = Path.cwd()
144
+
145
+ brain_yaml = cwd / "brain.yaml"
146
+ brain_dir = cwd / ".brain"
147
+ data_json = brain_dir / "data.json"
148
+ index_json = brain_dir / "index.json"
149
+ cache_dir = brain_dir / "cache"
150
+
151
+ created_anything = False
152
+
153
+ if not brain_dir.exists():
154
+ brain_dir.mkdir()
155
+ created_anything = True
156
+ typer.echo(f"✅ Created: {brain_dir}")
157
+ else:
158
+ typer.echo(f"⚠️ Exists: {brain_dir}")
159
+
160
+ if not cache_dir.exists():
161
+ cache_dir.mkdir()
162
+ created_anything = True
163
+ typer.echo(f"✅ Created: {cache_dir}")
164
+ else:
165
+ typer.echo(f"⚠️ Exists: {cache_dir}")
166
+
167
+ created_brain_yaml = create_file(
168
+ brain_yaml, dump_config(DEFAULT_CONFIG)
169
+ )
170
+ created_data_json = create_file(data_json, json.dumps({}, indent=2))
171
+ created_index_json = create_file(index_json, json.dumps({}, indent=2))
172
+ created_anything = (
173
+ created_anything
174
+ or created_brain_yaml
175
+ or created_data_json
176
+ or created_index_json
177
+ )
178
+
179
+ if created_anything:
180
+ success(
181
+ "project-brain initialized successfully",
182
+ next_step="brain project analyze .",
183
+ )
184
+ else:
185
+ info("Project already initialized")
186
+
187
+
188
+ @project_app.command()
189
+ def analyze(
190
+ path: str = typer.Argument(
191
+ ".",
192
+ help="Repository path to analyze",
193
+ )
194
+ ):
195
+ """
196
+ Analyze repository structure using AST parsing.
197
+
198
+ Extracts:
199
+ - files
200
+ - functions
201
+ - classes
202
+ - metadata
203
+
204
+ Stores results inside:
205
+ .brain/data.json
206
+
207
+ Example:
208
+ brain project analyze .
209
+ """
210
+ root = Path(path)
211
+
212
+ section("Project Analysis")
213
+ info(f"Analyzing: {root}")
214
+
215
+ config = load_config(root)
216
+
217
+ analysis_cfg = config.get("analysis", {})
218
+
219
+ ignore = analysis_cfg.get("ignore", [])
220
+ include_tests = analysis_cfg.get("include_tests", False)
221
+
222
+ data, files_path = analyze_project(
223
+ root, ignore_patterns=ignore, include_tests=include_tests
224
+ )
225
+
226
+ brain_dir = root / ".brain"
227
+ brain_dir.mkdir(exist_ok=True)
228
+
229
+ data_path = brain_dir / "data.json"
230
+ data_path.write_text(json.dumps(data, indent=2))
231
+
232
+ formatted_paths = "\n\t\t".join(str(p) for p in files_path)
233
+ typer.echo(f"📋 File Paths: {formatted_paths}")
234
+ success(
235
+ "Analysis complete",
236
+ next_step="brain project summary",
237
+ )
238
+
239
+
240
+ @project_app.command()
241
+ def summary():
242
+ """Summarize the analyzed data"""
243
+ root = Path.cwd()
244
+ data = load_data(root)
245
+
246
+ if not data:
247
+ error(
248
+ "Project has not been analyzed yet",
249
+ suggestion="""
250
+ Run:
251
+
252
+ brain project analyze .
253
+ """,
254
+ )
255
+ raise typer.Exit(code=1)
256
+
257
+ config = load_config(root)
258
+ fmt = config.get("output", {}).get("format", "text")
259
+
260
+ if fmt == "json":
261
+
262
+ typer.echo(json.dumps(data, indent=2))
263
+ typer.echo(
264
+ "✅ Summary complete (JSON format), its already saved in .brain/data.json"
265
+ )
266
+ return
267
+
268
+ output = format_summary(root, data)
269
+ typer.echo(output)
270
+
271
+
272
+ @diff_app.callback(invoke_without_command=True)
273
+ def diff(ctx: typer.Context):
274
+ """
275
+ Diff command group
276
+ """
277
+ if ctx.invoked_subcommand is None:
278
+ typer.echo("❌ Missing subcommand")
279
+ typer.echo("👉 Use: brain diff show or brain diff review")
280
+ raise typer.Exit(code=1)
281
+
282
+
283
+ @diff_app.command()
284
+ def show(
285
+ from_ref: str = typer.Argument(None),
286
+ to_ref: str = typer.Argument(None),
287
+ ):
288
+
289
+ # Defaults
290
+ if not from_ref and not to_ref:
291
+ from_ref, to_ref = "HEAD~1", "HEAD"
292
+
293
+ elif from_ref and not to_ref:
294
+ to_ref = "HEAD"
295
+
296
+ root = Path.cwd()
297
+
298
+ require_git_repo(root)
299
+
300
+ config = load_config(root)
301
+ mode = config.get("diff", {}).get("mode", "function")
302
+
303
+ def validate_ref(ref: str):
304
+ return run_git_command(["rev-parse", "--verify", ref], root) is not None
305
+
306
+ if not validate_ref(from_ref) or not validate_ref(to_ref):
307
+ error(
308
+ f"Invalid git reference: {from_ref} {to_ref}",
309
+ suggestion="""
310
+ brain diff show HEAD~1 HEAD
311
+ brain diff show main dev
312
+
313
+ Check refs using:
314
+ git log --oneline
315
+ """,
316
+ )
317
+ raise typer.Exit(code=1)
318
+
319
+ try:
320
+ result = compute_diff(from_ref, to_ref, root)
321
+ except Exception as e:
322
+ typer.echo(f"❌ Diff failed: {str(e)}")
323
+ raise typer.Exit(code=1)
324
+
325
+ if result is None:
326
+ typer.echo("❌ Failed to compute diff")
327
+ raise typer.Exit(code=1)
328
+
329
+ added = result["added"]
330
+ modified = result["modified"]
331
+ deleted = result["deleted"]
332
+
333
+ typer.echo(f"Files Changed: {len(added) + len(modified) + len(deleted)}\n")
334
+
335
+ section("Modified Files")
336
+
337
+ if modified:
338
+ for f in modified:
339
+ console.print(f"[yellow]•[/yellow] {f}")
340
+ else:
341
+ info("No modified files")
342
+
343
+ section("Added Files")
344
+ if added:
345
+ for f in added:
346
+ console.print(f"[green]•[/green] {f}")
347
+ else:
348
+ info("No added files")
349
+
350
+ section("Deleted Files")
351
+ if deleted:
352
+ for f in deleted:
353
+ console.print(f"[red]•[/red] {f}")
354
+ else:
355
+ info("No deleted files")
356
+
357
+ typer.echo("* None")
358
+
359
+ if mode == "file":
360
+ return
361
+
362
+ # Function-level diff
363
+ for fd in result["function_diffs"]:
364
+ typer.echo(f"\nFile: {fd['file']}\n")
365
+
366
+ typer.echo("Functions Added:\n")
367
+ for fn in fd["added"]:
368
+ typer.echo(f"* {fn}")
369
+ if not fd["added"]:
370
+ typer.echo("* None")
371
+
372
+ typer.echo("\nFunctions Removed:\n")
373
+ for fn in fd["removed"]:
374
+ typer.echo(f"* {fn}")
375
+ if not fd["removed"]:
376
+ typer.echo("* None")
377
+
378
+ typer.echo("\nFunctions Modified:\n")
379
+ for fn in fd["modified"]:
380
+ typer.echo(f"* {fn}")
381
+ if not fd["modified"]:
382
+ typer.echo("* None")
383
+
384
+ typer.echo("")
385
+
386
+
387
+ @diff_app.command()
388
+ def review(
389
+ from_ref: str = typer.Argument(None),
390
+ to_ref: str = typer.Argument(None),
391
+ ):
392
+ """
393
+ Explain code changes using LLM
394
+ """
395
+ if not from_ref and not to_ref:
396
+ from_ref, to_ref = "HEAD~1", "HEAD"
397
+
398
+ elif from_ref and not to_ref:
399
+ to_ref = "HEAD"
400
+
401
+ root = Path.cwd()
402
+
403
+ require_git_repo(root)
404
+ def validate_ref(ref: str):
405
+ return run_git_command(["rev-parse", "--verify", ref], root) is not None
406
+
407
+ if not validate_ref(from_ref) or not validate_ref(to_ref):
408
+ error(
409
+ f"Invalid git reference: {from_ref} {to_ref}",
410
+ suggestion="""
411
+ brain diff show HEAD~1 HEAD
412
+ brain diff show main dev
413
+
414
+ Check refs using:
415
+ git log --oneline
416
+ """,
417
+ )
418
+ typer.echo(f" From: {from_ref}")
419
+ typer.echo(f" To: {to_ref}")
420
+ raise typer.Exit(code=1)
421
+ results = explain_diff(from_ref, to_ref, root)
422
+
423
+
424
+ if not results:
425
+ typer.echo("❌ Failed to compute explain-diff")
426
+ raise typer.Exit(code=1)
427
+
428
+ reports_dir = root / ".brain" / "reports"
429
+ reports_dir.mkdir(parents=True, exist_ok=True)
430
+
431
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
432
+
433
+ json_path = reports_dir / f"diff_{timestamp}.json"
434
+ html_path = reports_dir / f"diff_{timestamp}.html"
435
+ json_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
436
+ html_path.write_text(generate_html(results), encoding="utf-8")
437
+
438
+ typer.echo("\n✅ Analysis complete\n")
439
+ typer.echo(f"📄 JSON: {json_path}")
440
+ typer.echo(f"🌐 HTML: {html_path}")
441
+
442
+ webbrowser.open(str(html_path))
443
+
444
+
445
+
446
+ @project_app.command()
447
+ def doctor():
448
+ """
449
+ Repository diagnostics and environment health checks.
450
+ """
451
+
452
+ root = Path.cwd()
453
+
454
+ checks, final_status = run_doctor(root)
455
+
456
+ console.print(
457
+ Panel.fit(
458
+ "[bold cyan]🩺 Project Brain Diagnostics[/bold cyan]",
459
+ border_style="cyan",
460
+ )
461
+ )
462
+
463
+ grouped = {}
464
+
465
+ for check in checks:
466
+ grouped.setdefault(check.category, []).append(check)
467
+
468
+ for category, items in grouped.items():
469
+ rows = []
470
+
471
+ for item in items:
472
+
473
+ if item.status == "pass":
474
+ status = "[green]PASS[/green]"
475
+ elif item.status == "warn":
476
+ status = "[yellow]WARN[/yellow]"
477
+ elif item.status == "fail":
478
+ status = "[red]FAIL[/red]"
479
+ else:
480
+ status = "[cyan]INFO[/cyan]"
481
+
482
+ detail = item.message
483
+
484
+ if item.fix:
485
+ detail += f"\n[dim]{item.fix}[/dim]"
486
+
487
+ rows.append(
488
+ (
489
+ item.name,
490
+ status,
491
+ detail,
492
+ )
493
+ )
494
+
495
+ doctor_panel(category, rows)
496
+
497
+ if final_status == "READY":
498
+ success(
499
+ "Repository is fully operational",
500
+ next_step="brain diff review",
501
+ )
502
+
503
+ elif final_status == "PARTIAL":
504
+ info("Repository partially configured")
505
+
506
+ else:
507
+ error(
508
+ "Repository is not ready",
509
+ suggestion="""
510
+ brain project init
511
+ brain project analyze .
512
+ """,
513
+ )
514
+
515
+ @export_app.command()
516
+ def full_code():
517
+ """
518
+ Export entire codebase into structured file
519
+ """
520
+ root = Path.cwd()
521
+
522
+ count, output_path, files_path = export_full_code(root)
523
+
524
+ success(
525
+ f"Exported {count} files",
526
+ next_step="brain export code_changes HEAD~1 HEAD",
527
+ )
528
+
529
+ info(f"Output: {output_path}")
530
+ formatted_paths = "\n\t\t".join(files_path)
531
+ typer.echo(f"📋 File Paths: {formatted_paths}")
532
+
533
+
534
+ @export_app.command("file")
535
+ def add_code_file_cmd(path: str):
536
+ """
537
+ Manually add a single file to export
538
+ """
539
+ root = Path.cwd()
540
+ target = Path(path)
541
+
542
+ count, output_path, msg = add_code_file(root, target)
543
+
544
+ if msg:
545
+ typer.echo(msg)
546
+
547
+ typer.echo(f"📦 Files added: {count}")
548
+ typer.echo(f"📄 Output: {output_path}")
549
+
550
+
551
+ @export_app.command("dir")
552
+ def add_code_dir_cmd(path: str):
553
+ """
554
+ Manually add a directory to export
555
+ """
556
+ root = Path.cwd()
557
+ target = Path(path)
558
+
559
+ count, output_path, msg = add_code_dir(root, target)
560
+
561
+ if msg:
562
+ typer.echo(msg)
563
+
564
+ typer.echo(f"📦 Files added: {count}")
565
+ typer.echo(f"📄 Output: {output_path}")
566
+
567
+
568
+ @export_app.command()
569
+ def code_changes(from_ref: str, to_ref: str):
570
+ """
571
+ Export changed code between two git references
572
+ """
573
+ root = Path.cwd()
574
+
575
+ count, output_path = export_code_changes(root, from_ref, to_ref)
576
+
577
+ typer.echo(f"📦 Files processed: {count}")
578
+ typer.echo(f"📄 Output: {output_path}")
579
+
580
+
581
+ @diff_app.command()
582
+ def explain(target: str):
583
+ """
584
+ Explain a file or function
585
+ """
586
+ root = Path.cwd()
587
+
588
+ if ":" in target:
589
+ file_path, func_name = target.split(":", 1)
590
+ output = explain_function(root, file_path, func_name)
591
+ else:
592
+ output = explain_file(root, target)
593
+
594
+ typer.echo(output)
595
+
596
+
597
+ @llm_app.command()
598
+ def test():
599
+ root = Path.cwd()
600
+ config = load_config(root)
601
+
602
+ llm = config.get("llm", {})
603
+ provider = llm.get("provider", "none")
604
+ model = llm.get("model", "")
605
+ timeout = llm.get("timeout_sec", 60)
606
+
607
+ if provider == "none":
608
+ info("LLM disabled (provider=none)")
609
+ return
610
+
611
+ result = call_llm(
612
+ provider,
613
+ model,
614
+ "What is 2 + 2?",
615
+ api_key="",
616
+ include_models=True,
617
+ timeout=timeout
618
+ )
619
+ print(f"LLM Call - Provider: {provider}, Model: {model}, Timeout: {timeout}s")
620
+
621
+
622
+ if result["error"]:
623
+ error(
624
+ f"LLM test failed: {result['error']}",
625
+ suggestion="""
626
+ Check:
627
+ - provider name
628
+ - model name
629
+ - API keys
630
+ - internet connectivity
631
+ """,
632
+ )
633
+ return
634
+
635
+ typer.echo(f"✅ Output: {result['output']}")
636
+ typer.echo(f"📦 Models: {result['models'][:5]}")
637
+
638
+
639
+ def main():
640
+ app()
641
+
642
+
643
+ if __name__ == "__main__":
644
+ main()
@@ -0,0 +1,52 @@
1
+ ROOT_HELP = """
2
+ 🧠 project-brain
3
+
4
+ Developer intelligence CLI for understanding repositories,
5
+ Git changes, and AI-assisted code analysis.
6
+
7
+ COMMON WORKFLOW
8
+
9
+ 1. Initialize project
10
+ brain project init
11
+
12
+ 2. Analyze repository
13
+ brain project analyze .
14
+
15
+ 3. View project summary
16
+ brain project summary
17
+
18
+ 4. Inspect code changes
19
+ brain diff show
20
+
21
+ 5. Generate semantic review
22
+ brain diff review
23
+
24
+ POPULAR COMMANDS
25
+
26
+ project Repository analysis and diagnostics
27
+ diff Git-aware change intelligence
28
+ export AI-friendly code exports
29
+ testllm Validate LLM connectivity
30
+
31
+ EXAMPLES
32
+
33
+ brain diff show HEAD~3 HEAD
34
+ brain export full_code
35
+ brain diff explain src/api.py:create_user
36
+ """
37
+
38
+ PROJECT_HELP = """
39
+ Project analysis and repository management commands.
40
+ """
41
+
42
+ DIFF_HELP = """
43
+ Git-aware repository change analysis.
44
+ """
45
+
46
+ EXPORT_HELP = """
47
+ Export repository content into AI-friendly formats.
48
+ """
49
+
50
+ LLM_HELP = """
51
+ LLM provider testing and diagnostics.
52
+ """