bpsai-pair 0.2.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.

Potentially problematic release.


This version of bpsai-pair might be problematic. Click here for more details.

Files changed (42) hide show
  1. bpsai_pair/__init__.py +25 -0
  2. bpsai_pair/__main__.py +4 -0
  3. bpsai_pair/adapters.py +9 -0
  4. bpsai_pair/cli.py +514 -0
  5. bpsai_pair/config.py +310 -0
  6. bpsai_pair/data/cookiecutter-paircoder/cookiecutter.json +12 -0
  7. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.agentpackignore +1 -0
  8. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.editorconfig +17 -0
  9. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.github/PULL_REQUEST_TEMPLATE.md +47 -0
  10. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.github/workflows/ci.yml +90 -0
  11. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.github/workflows/project_tree.yml +33 -0
  12. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.gitignore +5 -0
  13. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.gitleaks.toml +17 -0
  14. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/.pre-commit-config.yaml +38 -0
  15. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/CODEOWNERS +9 -0
  16. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/CONTRIBUTING.md +35 -0
  17. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/SECURITY.md +14 -0
  18. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/context/agents.md +6 -0
  19. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/context/agents.md.bak +196 -0
  20. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/context/development.md +1 -0
  21. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/context/development.md.bak +10 -0
  22. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/context/directory_notes/.gitkeep +1 -0
  23. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/context/project_tree.md +7 -0
  24. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/prompts/deep_research.yml +28 -0
  25. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/prompts/implementation.yml +25 -0
  26. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/prompts/roadmap.yml +14 -0
  27. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/scripts/README.md +11 -0
  28. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/src/.gitkeep +1 -0
  29. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/templates/adr.md +19 -0
  30. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/templates/directory_note.md +17 -0
  31. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/tests/example_contract/README.md +3 -0
  32. bpsai_pair/data/cookiecutter-paircoder/{{cookiecutter.project_slug}}/tests/example_integration/README.md +3 -0
  33. bpsai_pair/init_bundled_cli.py +47 -0
  34. bpsai_pair/jsonio.py +6 -0
  35. bpsai_pair/ops.py +451 -0
  36. bpsai_pair/pyutils.py +26 -0
  37. bpsai_pair/utils.py +11 -0
  38. bpsai_pair-0.2.0.dist-info/METADATA +29 -0
  39. bpsai_pair-0.2.0.dist-info/RECORD +42 -0
  40. bpsai_pair-0.2.0.dist-info/WHEEL +5 -0
  41. bpsai_pair-0.2.0.dist-info/entry_points.txt +3 -0
  42. bpsai_pair-0.2.0.dist-info/top_level.txt +1 -0
bpsai_pair/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ bpsai_pair package
3
+ """
4
+
5
+ __version__ = "0.2.0"
6
+
7
+ # Make modules available at package level
8
+ from . import cli
9
+ from . import ops
10
+ from . import config
11
+ from . import utils
12
+ from . import jsonio
13
+ from . import pyutils
14
+ from . import init_bundled_cli
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "cli",
19
+ "ops",
20
+ "config",
21
+ "utils",
22
+ "jsonio",
23
+ "pyutils",
24
+ "init_bundled_cli"
25
+ ]
bpsai_pair/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
bpsai_pair/adapters.py ADDED
@@ -0,0 +1,9 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ class Shell:
6
+ @staticmethod
7
+ def run(cmd: List[str], cwd: Path | None = None, check: bool = True) -> str:
8
+ res = subprocess.run(cmd, cwd=cwd, check=check, text=True, capture_output=True)
9
+ return (res.stdout or "") + (res.stderr or "")
bpsai_pair/cli.py ADDED
@@ -0,0 +1,514 @@
1
+ """
2
+ Enhanced bpsai-pair CLI with cross-platform support and improved UX.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import json
8
+ import sys
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+ from datetime import datetime
13
+
14
+ import typer
15
+ from rich import print
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from rich.progress import Progress, SpinnerColumn, TextColumn
19
+
20
+ # Try relative imports first, fall back to absolute
21
+ try:
22
+ from . import __version__
23
+ from . import init_bundled_cli
24
+ from . import ops
25
+ from .config import Config
26
+ except ImportError:
27
+ # For development/testing when running as script
28
+ import sys
29
+ sys.path.insert(0, str(Path(__file__).parent.parent))
30
+ from bpsai_pair import __version__
31
+ from bpsai_pair import init_bundled_cli
32
+ from bpsai_pair import ops
33
+ from bpsai_pair.config import Config
34
+
35
+ # Initialize Rich console
36
+ console = Console()
37
+
38
+ # Environment variable support
39
+ MAIN_BRANCH = os.getenv("PAIRCODER_MAIN_BRANCH", "main")
40
+ CONTEXT_DIR = os.getenv("PAIRCODER_CONTEXT_DIR", "context")
41
+
42
+ app = typer.Typer(
43
+ add_completion=False,
44
+ help="bpsai-pair: AI pair-coding workflow CLI",
45
+ context_settings={"help_option_names": ["-h", "--help"]}
46
+ )
47
+
48
+
49
+ def version_callback(value: bool):
50
+ """Show version and exit."""
51
+ if value:
52
+ console.print(f"[bold blue]bpsai-pair[/bold blue] version {__version__}")
53
+ raise typer.Exit()
54
+
55
+
56
+ @app.callback()
57
+ def main(
58
+ version: bool = typer.Option(
59
+ False,
60
+ "--version",
61
+ "-v",
62
+ callback=version_callback,
63
+ help="Show version and exit"
64
+ )
65
+ ):
66
+ """bpsai-pair: AI pair-coding workflow CLI"""
67
+ pass
68
+
69
+
70
+ def repo_root() -> Path:
71
+ """Get repo root with better error message."""
72
+ p = Path.cwd()
73
+ if not ops.GitOps.is_repo(p):
74
+ console.print(
75
+ "[red]✗ Not in a git repository.[/red]\n"
76
+ "Please run from your project root directory (where .git exists).\n"
77
+ "[dim]Hint: cd to your project directory first[/dim]"
78
+ )
79
+ raise typer.Exit(1)
80
+ return p
81
+
82
+
83
+ @app.command()
84
+ def init(
85
+ template: Optional[str] = typer.Argument(
86
+ None, help="Path to template (optional, uses bundled template if not provided)"
87
+ ),
88
+ interactive: bool = typer.Option(
89
+ False, "--interactive", "-i", help="Interactive mode to gather project info"
90
+ )
91
+ ):
92
+ """Initialize repo with governance, context, prompts, scripts, and workflows."""
93
+ root = repo_root()
94
+
95
+ if interactive:
96
+ # Interactive mode to gather project information
97
+ project_name = typer.prompt("Project name", default="My Project")
98
+ primary_goal = typer.prompt("Primary goal", default="Build awesome software")
99
+ coverage = typer.prompt("Coverage target (%)", default="80")
100
+
101
+ # Create a config file
102
+ config = Config(
103
+ project_name=project_name,
104
+ primary_goal=primary_goal,
105
+ coverage_target=int(coverage)
106
+ )
107
+ config.save(root)
108
+
109
+ # Use bundled template if none provided
110
+ if template is None:
111
+ with Progress(
112
+ SpinnerColumn(),
113
+ TextColumn("[progress.description]{task.description}"),
114
+ console=console
115
+ ) as progress:
116
+ task = progress.add_task("Initializing scaffolding...", total=None)
117
+ result = init_bundled_cli.main()
118
+ progress.update(task, completed=True)
119
+
120
+ console.print("[green]✓[/green] Initialized repo with pair-coding scaffolding")
121
+ console.print("[dim]Review diffs and commit changes[/dim]")
122
+ else:
123
+ # Use provided template (simplified for now)
124
+ console.print(f"[yellow]Using template: {template}[/yellow]")
125
+
126
+
127
+ @app.command()
128
+ def feature(
129
+ name: str = typer.Argument(..., help="Feature branch name (without prefix)"),
130
+ primary: str = typer.Option("", "--primary", "-p", help="Primary goal to stamp into context"),
131
+ phase: str = typer.Option("", "--phase", help="Phase goal for Next action"),
132
+ force: bool = typer.Option(False, "--force", "-f", help="Bypass dirty-tree check"),
133
+ type: str = typer.Option(
134
+ "feature",
135
+ "--type",
136
+ "-t",
137
+ help="Branch type: feature|fix|refactor",
138
+ case_sensitive=False,
139
+ ),
140
+ ):
141
+ """Create feature branch and scaffold context (cross-platform)."""
142
+ root = repo_root()
143
+
144
+ # Validate branch type
145
+ branch_type = type.lower()
146
+ if branch_type not in {"feature", "fix", "refactor"}:
147
+ console.print(
148
+ f"[red]✗ Invalid branch type: {type}[/red]\n"
149
+ "Must be one of: feature, fix, refactor"
150
+ )
151
+ raise typer.Exit(1)
152
+
153
+ # Use Python ops instead of shell script
154
+ with Progress(
155
+ SpinnerColumn(),
156
+ TextColumn("[progress.description]{task.description}"),
157
+ console=console
158
+ ) as progress:
159
+ task = progress.add_task(f"Creating {branch_type}/{name}...", total=None)
160
+
161
+ try:
162
+ ops.FeatureOps.create_feature(
163
+ root=root,
164
+ name=name,
165
+ branch_type=branch_type,
166
+ primary_goal=primary,
167
+ phase=phase,
168
+ force=force
169
+ )
170
+ progress.update(task, completed=True)
171
+
172
+ console.print(f"[green]✓[/green] Created branch [bold]{branch_type}/{name}[/bold]")
173
+ console.print(f"[green]✓[/green] Updated context with primary goal and phase")
174
+ console.print("[dim]Next: Connect your agent and share /context files[/dim]")
175
+
176
+ except ValueError as e:
177
+ progress.update(task, completed=True)
178
+ console.print(f"[red]✗ {e}[/red]")
179
+ raise typer.Exit(1)
180
+
181
+
182
+ @app.command()
183
+ def pack(
184
+ output: str = typer.Option("agent_pack.tgz", "--out", "-o", help="Output archive name"),
185
+ extra: Optional[List[str]] = typer.Option(None, "--extra", "-e", help="Additional paths to include"),
186
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview files without creating archive"),
187
+ list_only: bool = typer.Option(False, "--list", "-l", help="List files to be included"),
188
+ json_out: bool = typer.Option(False, "--json", help="Output in JSON format"),
189
+ ):
190
+ """Create agent context package (cross-platform)."""
191
+ root = repo_root()
192
+ output_path = root / output
193
+
194
+ # Use Python ops for packing
195
+ files = ops.ContextPacker.pack(
196
+ root=root,
197
+ output=output_path,
198
+ extra_files=extra,
199
+ dry_run=(dry_run or list_only)
200
+ )
201
+
202
+ if json_out:
203
+ result = {
204
+ "files": [str(f.relative_to(root)) for f in files],
205
+ "count": len(files),
206
+ "dry_run": dry_run,
207
+ "list_only": list_only
208
+ }
209
+ if not (dry_run or list_only):
210
+ result["output"] = str(output)
211
+ result["size"] = output_path.stat().st_size if output_path.exists() else 0
212
+ print(json.dumps(result, indent=2))
213
+ elif list_only:
214
+ for f in files:
215
+ console.print(str(f.relative_to(root)))
216
+ elif dry_run:
217
+ console.print(f"[yellow]Would pack {len(files)} files:[/yellow]")
218
+ for f in files[:10]: # Show first 10
219
+ console.print(f" • {f.relative_to(root)}")
220
+ if len(files) > 10:
221
+ console.print(f" [dim]... and {len(files) - 10} more[/dim]")
222
+ else:
223
+ console.print(f"[green]✓[/green] Created [bold]{output}[/bold]")
224
+ size_kb = output_path.stat().st_size / 1024
225
+ console.print(f" Size: {size_kb:.1f} KB")
226
+ console.print(f" Files: {len(files)}")
227
+ console.print("[dim]Upload this archive to your agent session[/dim]")
228
+
229
+
230
+ @app.command("context-sync")
231
+ def context_sync(
232
+ overall: Optional[str] = typer.Option(None, "--overall", help="Overall goal override"),
233
+ last: str = typer.Option(..., "--last", "-l", help="What changed and why"),
234
+ next: str = typer.Option(..., "--next", "--nxt", "-n", help="Next smallest valuable step"),
235
+ blockers: str = typer.Option("", "--blockers", "-b", help="Blockers/Risks"),
236
+ json_out: bool = typer.Option(False, "--json", help="Output in JSON format"),
237
+ ):
238
+ """Update the Context Loop in /context/development.md."""
239
+ root = repo_root()
240
+ context_dir = root / CONTEXT_DIR
241
+ dev_file = context_dir / "development.md"
242
+
243
+ if not dev_file.exists():
244
+ console.print(
245
+ f"[red]✗ {dev_file} not found[/red]\n"
246
+ "Run 'bpsai-pair init' first to set up the project structure"
247
+ )
248
+ raise typer.Exit(1)
249
+
250
+ # Update context
251
+ content = dev_file.read_text()
252
+ import re
253
+
254
+ if overall:
255
+ content = re.sub(r'Overall goal is:.*', f'Overall goal is: {overall}', content)
256
+ content = re.sub(r'Last action was:.*', f'Last action was: {last}', content)
257
+ content = re.sub(r'Next action will be:.*', f'Next action will be: {next}', content)
258
+ if blockers:
259
+ content = re.sub(r'Blockers(/Risks)?:.*', f'Blockers/Risks: {blockers}', content)
260
+
261
+ dev_file.write_text(content)
262
+
263
+ if json_out:
264
+ result = {
265
+ "updated": True,
266
+ "file": str(dev_file.relative_to(root)),
267
+ "context": {
268
+ "overall": overall,
269
+ "last": last,
270
+ "next": next,
271
+ "blockers": blockers
272
+ }
273
+ }
274
+ print(json.dumps(result, indent=2))
275
+ else:
276
+ console.print("[green]✓[/green] Context Sync updated")
277
+ console.print(f" [dim]Last: {last}[/dim]")
278
+ console.print(f" [dim]Next: {next}[/dim]")
279
+
280
+
281
+ # Alias for context-sync
282
+ app.command("sync", hidden=True)(context_sync)
283
+
284
+
285
+ @app.command()
286
+ def status(
287
+ json_out: bool = typer.Option(False, "--json", help="Output in JSON format"),
288
+ ):
289
+ """Show current context loop status and recent changes."""
290
+ root = repo_root()
291
+ context_dir = root / CONTEXT_DIR
292
+ dev_file = context_dir / "development.md"
293
+
294
+ # Get current branch
295
+ current_branch = ops.GitOps.current_branch(root)
296
+ is_clean = ops.GitOps.is_clean(root)
297
+
298
+ # Parse context sync
299
+ context_data = {}
300
+ if dev_file.exists():
301
+ content = dev_file.read_text()
302
+ import re
303
+
304
+ # Extract context sync fields
305
+ overall_match = re.search(r'Overall goal is:\s*(.*)', content)
306
+ last_match = re.search(r'Last action was:\s*(.*)', content)
307
+ next_match = re.search(r'Next action will be:\s*(.*)', content)
308
+ blockers_match = re.search(r'Blockers(/Risks)?:\s*(.*)', content)
309
+ phase_match = re.search(r'\*\*Phase:\*\*\s*(.*)', content)
310
+
311
+ context_data = {
312
+ "phase": phase_match.group(1) if phase_match else "Not set",
313
+ "overall": overall_match.group(1) if overall_match else "Not set",
314
+ "last": last_match.group(1) if last_match else "Not set",
315
+ "next": next_match.group(1) if next_match else "Not set",
316
+ "blockers": blockers_match.group(2) if blockers_match else "None"
317
+ }
318
+
319
+ # Check for recent pack
320
+ pack_files = list(root.glob("*.tgz"))
321
+ latest_pack = None
322
+ if pack_files:
323
+ latest_pack = max(pack_files, key=lambda p: p.stat().st_mtime)
324
+
325
+ if json_out:
326
+ age_hours = None
327
+ if latest_pack:
328
+ age_hours = (datetime.now() - datetime.fromtimestamp(latest_pack.stat().st_mtime)).total_seconds() / 3600
329
+
330
+ result = {
331
+ "branch": current_branch,
332
+ "clean": is_clean,
333
+ "context": context_data,
334
+ "latest_pack": str(latest_pack.name) if latest_pack else None,
335
+ "pack_age": age_hours
336
+ }
337
+ print(json.dumps(result, indent=2))
338
+ else:
339
+ # Create a nice table
340
+ table = Table(title="PairCoder Status", show_header=False)
341
+ table.add_column("Field", style="cyan", width=20)
342
+ table.add_column("Value", style="white")
343
+
344
+ # Git status
345
+ table.add_row("Branch", f"[bold]{current_branch}[/bold]")
346
+ table.add_row("Working Tree", "[green]Clean[/green]" if is_clean else "[yellow]Modified[/yellow]")
347
+
348
+ # Context status
349
+ if context_data:
350
+ table.add_row("Phase", context_data["phase"])
351
+ table.add_row("Overall Goal", context_data["overall"][:60] + "..." if len(context_data["overall"]) > 60 else context_data["overall"])
352
+ table.add_row("Last Action", context_data["last"][:60] + "..." if len(context_data["last"]) > 60 else context_data["last"])
353
+ table.add_row("Next Action", context_data["next"][:60] + "..." if len(context_data["next"]) > 60 else context_data["next"])
354
+ if context_data["blockers"] and context_data["blockers"] != "None":
355
+ table.add_row("Blockers", f"[red]{context_data['blockers']}[/red]")
356
+
357
+ # Pack status
358
+ if latest_pack:
359
+ age_hours = (datetime.now() - datetime.fromtimestamp(latest_pack.stat().st_mtime)).total_seconds() / 3600
360
+ age_str = f"{age_hours:.1f} hours ago" if age_hours < 24 else f"{age_hours/24:.1f} days ago"
361
+ table.add_row("Latest Pack", f"{latest_pack.name} ({age_str})")
362
+
363
+ console.print(table)
364
+
365
+ # Suggestions
366
+ if not is_clean:
367
+ console.print("\n[yellow]⚠ Working tree has uncommitted changes[/yellow]")
368
+ console.print("[dim]Consider committing or stashing before creating a pack[/dim]")
369
+
370
+ if not latest_pack or (latest_pack and age_hours and age_hours > 24):
371
+ console.print("\n[dim]Tip: Run 'bpsai-pair pack' to create a fresh context pack[/dim]")
372
+
373
+
374
+ @app.command()
375
+ def validate(
376
+ fix: bool = typer.Option(False, "--fix", help="Attempt to fix issues"),
377
+ json_out: bool = typer.Option(False, "--json", help="Output in JSON format"),
378
+ ):
379
+ """Validate repo structure and context consistency."""
380
+ root = repo_root()
381
+ issues = []
382
+ fixes = []
383
+
384
+ # Check required files
385
+ required_files = [
386
+ Path(CONTEXT_DIR) / "development.md",
387
+ Path(CONTEXT_DIR) / "agents.md",
388
+ Path(".agentpackignore"),
389
+ Path(".editorconfig"),
390
+ Path("CONTRIBUTING.md"),
391
+ ]
392
+
393
+ for file_path in required_files:
394
+ full_path = root / file_path
395
+ if not full_path.exists():
396
+ issues.append(f"Missing required file: {file_path}")
397
+ if fix:
398
+ # Create with minimal content
399
+ full_path.parent.mkdir(parents=True, exist_ok=True)
400
+ if file_path.name == "development.md":
401
+ full_path.write_text("# Development Log\n\n## Context Sync (AUTO-UPDATED)\n")
402
+ elif file_path.name == "agents.md":
403
+ full_path.write_text("# Agents Guide\n")
404
+ elif file_path.name == ".agentpackignore":
405
+ full_path.write_text(".git/\n.venv/\n__pycache__/\nnode_modules/\n")
406
+ else:
407
+ full_path.touch()
408
+ fixes.append(f"Created {file_path}")
409
+
410
+ # Check context sync format
411
+ dev_file = root / CONTEXT_DIR / "development.md"
412
+ if dev_file.exists():
413
+ content = dev_file.read_text()
414
+ required_sections = [
415
+ "Overall goal is:",
416
+ "Last action was:",
417
+ "Next action will be:",
418
+ ]
419
+ for section in required_sections:
420
+ if section not in content:
421
+ issues.append(f"Missing context sync section: {section}")
422
+ if fix:
423
+ content += f"\n{section} (to be updated)\n"
424
+ dev_file.write_text(content)
425
+ fixes.append(f"Added section: {section}")
426
+
427
+ # Check for uncommitted context changes
428
+ if not ops.GitOps.is_clean(root):
429
+ context_files = ["context/development.md", "context/agents.md"]
430
+ for cf in context_files:
431
+ result = subprocess.run(
432
+ ["git", "diff", "--name-only", cf],
433
+ cwd=root,
434
+ capture_output=True,
435
+ text=True
436
+ )
437
+ if result.stdout.strip():
438
+ issues.append(f"Uncommitted changes in {cf}")
439
+
440
+ if json_out:
441
+ result = {
442
+ "valid": len(issues) == 0,
443
+ "issues": issues,
444
+ "fixes_applied": fixes if fix else []
445
+ }
446
+ print(json.dumps(result, indent=2))
447
+ else:
448
+ if issues:
449
+ console.print("[red]✗ Validation failed[/red]")
450
+ console.print("\nIssues found:")
451
+ for issue in issues:
452
+ console.print(f" • {issue}")
453
+
454
+ if fixes:
455
+ console.print("\n[green]Fixed:[/green]")
456
+ for fix_msg in fixes:
457
+ console.print(f" ✓ {fix_msg}")
458
+ elif not fix:
459
+ console.print("\n[dim]Run with --fix to attempt automatic fixes[/dim]")
460
+ else:
461
+ console.print("[green]✓ All validation checks passed[/green]")
462
+
463
+
464
+ @app.command()
465
+ def ci(
466
+ json_out: bool = typer.Option(False, "--json", help="Output in JSON format"),
467
+ ):
468
+ """Run local CI checks (cross-platform)."""
469
+ root = repo_root()
470
+
471
+ with Progress(
472
+ SpinnerColumn(),
473
+ TextColumn("[progress.description]{task.description}"),
474
+ console=console
475
+ ) as progress:
476
+ task = progress.add_task("Running CI checks...", total=None)
477
+
478
+ results = ops.LocalCI.run_all(root)
479
+
480
+ progress.update(task, completed=True)
481
+
482
+ if json_out:
483
+ print(json.dumps(results, indent=2))
484
+ else:
485
+ console.print("[bold]Local CI Results[/bold]\n")
486
+
487
+ # Python results
488
+ if results["python"]:
489
+ console.print("[cyan]Python:[/cyan]")
490
+ for check, status in results["python"].items():
491
+ icon = "✓" if "passed" in status else "✗"
492
+ color = "green" if "passed" in status else "yellow"
493
+ console.print(f" [{color}]{icon}[/{color}] {check}: {status}")
494
+
495
+ # Node results
496
+ if results["node"]:
497
+ console.print("\n[cyan]Node.js:[/cyan]")
498
+ for check, status in results["node"].items():
499
+ icon = "✓" if "passed" in status else "✗"
500
+ color = "green" if "passed" in status else "yellow"
501
+ console.print(f" [{color}]{icon}[/{color}] {check}: {status}")
502
+
503
+ if not results["python"] and not results["node"]:
504
+ console.print("[dim]No Python or Node.js project detected[/dim]")
505
+
506
+
507
+ # Export for entry point
508
+ def run():
509
+ """Entry point for the CLI."""
510
+ app()
511
+
512
+
513
+ if __name__ == "__main__":
514
+ run()