diff --git a/pyproject.toml b/pyproject.toml index e538fb9..ea8ae37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "saar" -version = "0.5.13" +version = "0.5.14" description = "Extract the essence of your codebase. Auto-generate AGENTS.md, CLAUDE.md, .cursorrules and more." readme = "README.md" license = "MIT" diff --git a/saar/__init__.py b/saar/__init__.py index 1b19224..308b6f1 100644 --- a/saar/__init__.py +++ b/saar/__init__.py @@ -1,3 +1,3 @@ """Saar -- extract the essence of your codebase.""" -__version__ = "0.5.13" +__version__ = "0.5.14" diff --git a/saar/commands/explore.py b/saar/commands/explore.py index 9c58529..7dded64 100644 --- a/saar/commands/explore.py +++ b/saar/commands/explore.py @@ -53,7 +53,7 @@ def cmd_init( target.write_text(content, encoding="utf-8") line_count = len(content.splitlines()) console.print() - console.print(f" [green]wrote[/green] {target} ({line_count} lines)") + console.print(f" [green]Wrote[/green] {target} [dim]({line_count} lines)[/dim]") console.print() console.print(" [bold]Next steps:[/bold]") console.print(" [dim]1.[/dim] Drop this in your repo root — Claude Code + Cursor pick it up automatically") @@ -183,10 +183,10 @@ def cmd_capture( append_to_cache(repo_path, field_name, rule) if is_duplicate: - console.print(f" [yellow]captured again[/yellow] [{label}] {rule} [dim](×{entry.count} total)[/dim]") + console.print(f" [yellow]Captured again[/yellow] [{label}] {rule} [dim](×{entry.count} total)[/dim]") console.print(f" [dim]Claude has made this mistake {entry.count} times. Rule already in AGENTS.md.[/dim]") else: - console.print(f" [green]captured[/green] [{label}] {rule}") + console.print(f" [green]Captured[/green] [{label}] {rule}") if no_regen: console.print(" [dim]Skipped regeneration (--no-regen). Run [bold]saar extract . --no-interview[/bold] to update.[/dim]") diff --git a/saar/commands/extract.py b/saar/commands/extract.py index 0eb9f46..c8a25ae 100644 --- a/saar/commands/extract.py +++ b/saar/commands/extract.py @@ -130,16 +130,16 @@ def write_with_markers(target: Path, generated: str, *, force: bool, console=Non if not target.exists(): target.write_text(wrapped, encoding="utf-8") - _tok = f" ~{len(wrapped)//4} tokens" if "AGENTS" in target.name else "" - _con.print(f" [green]wrote[/green] {_display_path(target)} [dim]({_line_count(wrapped)} lines{_tok})[/dim]") + _tok = f" [dim]({len(wrapped)//4} tokens)[/dim]" if "AGENTS" in target.name else "" + _con.print(f" [green]Wrote[/green] [bold]{_display_path(target)}[/bold] [dim]{_line_count(wrapped)} lines[/dim]{_tok}") return existing = target.read_text(encoding="utf-8") if force: target.write_text(wrapped, encoding="utf-8") - _tok = f" ~{len(wrapped)//4} tokens" if "AGENTS" in target.name else "" - _con.print(f" [green]wrote[/green] {_display_path(target)} [dim]({_line_count(wrapped)} lines{_tok})[/dim]") + _tok = f" [dim]({len(wrapped)//4} tokens)[/dim]" if "AGENTS" in target.name else "" + _con.print(f" [green]Wrote[/green] [bold]{_display_path(target)}[/bold] [dim]{_line_count(wrapped)} lines[/dim]{_tok}") return start_idx = existing.find(_MARKER_START) @@ -155,8 +155,8 @@ def write_with_markers(target: Path, generated: str, *, force: bool, console=Non after = after.replace(_MARKER_START, "").replace(_MARKER_END, "").lstrip("\n") final = before + wrapped + ("\n" + after if after.strip() else "") target.write_text(final, encoding="utf-8") - _tok = f" ~{len(final)//4} tokens" if "AGENTS" in target.name else "" - _con.print(f" [green]updated[/green] {_display_path(target)} [dim]({_line_count(final)} lines, manual edits preserved{_tok})[/dim]") + _tok = f" [dim]({len(final)//4} tokens)[/dim]" if "AGENTS" in target.name else "" + _con.print(f" [green]Updated[/green] [bold]{_display_path(target)}[/bold] [dim]{_line_count(final)} lines[/dim]{_tok}") def _handle_unmarked_file(target: Path, existing: str, wrapped: str, force: bool, console=None) -> None: @@ -221,6 +221,10 @@ def show_detection_summary(dna, no_interview: bool) -> bool: Returns True to proceed, False if user says detections are wrong. Always returns True in CI / --no-interview mode. + + Design: the Exceptions row is the money shot -- it shows what AI gets wrong + without saar (generic ValueError) vs what it now knows (OCIAPIError). + Make it visually distinct from the rest of the table. """ from rich.table import Table @@ -231,10 +235,14 @@ def show_detection_summary(dna, no_interview: bool) -> bool: rows = _build_summary_rows(dna) table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column(style="dim", width=18) + table.add_column(style="dim", width=20) table.add_column() for label, value in rows: - table.add_row(label, f"[cyan]{value}[/cyan]") + # Exceptions is the demo moment -- make it stand out + if label == "Exceptions": + table.add_row(label, f"[bold white]{value}[/bold white]") + else: + table.add_row(label, f"[cyan]{value}[/cyan]") console.print(table) for warning in getattr(dna, "analysis_warnings", []): @@ -387,8 +395,7 @@ def cmd_extract( import saar as _saar_pkg _ver = getattr(_saar_pkg, "__version__", "") console.print() - console.print(f" [bold]saar[/bold] [dim]v{_ver}[/dim] · analyzing [bold cyan]{repo_path.name}[/bold cyan]") - console.print(f" [dim]{'─' * 44}[/dim]") + console.print(f" [dim]Analyzing[/dim] [bold]{repo_path.name}[/bold][dim]...[/dim]") if include: console.print(f" [dim]subset: {' '.join(include)}[/dim]") @@ -453,22 +460,20 @@ def cmd_extract( if index: run_oci_indexing(repo_path) - # -- upgraded closing summary with token count -- - # Count tokens in AGENTS.md for the summary + # -- clean closing: what was written, token count as secondary info -- _agents_path = (output or repo_path) / "AGENTS.md" - _token_note = "" + _tokens = 0 if _agents_path.exists(): try: - _chars = len(_agents_path.read_text(encoding="utf-8")) - _tokens = _chars // 4 # ~4 chars/token approximation - _token_note = f" [dim]{_tokens} tokens[/dim]" + _tokens = len(_agents_path.read_text(encoding="utf-8")) // 4 except Exception: pass console.print() - console.print(f" [dim]{'─' * 44}[/dim]") - console.print(f" [bold green]Your AI knows your codebase.[/bold green]{_token_note}") - console.print(" [dim]Load AGENTS.md before your next Claude session.[/dim]") + if _tokens: + console.print(f" [dim]AGENTS.md ready. ~{_tokens} tokens.[/dim]") + else: + console.print(" [dim]AGENTS.md ready.[/dim]") # -- post-extract dogfood check: warn on contradictions in tribal knowledge -- # SA006 catches stale facts like "cli.py is 1514 lines" contradicting "cli.py is 68 lines". diff --git a/saar/commands/maintain.py b/saar/commands/maintain.py index 9245f4d..732a108 100644 --- a/saar/commands/maintain.py +++ b/saar/commands/maintain.py @@ -52,8 +52,8 @@ def cmd_add( field, label = "never_do", "Never do" append_to_cache(repo_path, field, correction) - console.print(f" [green]added[/green] [{label}] {correction}") - console.print(" [dim]Saved to .saar/config.json. Re-run [bold]saar .[/bold] to regenerate context files.[/dim]") + console.print(f" [green]Added[/green] [{label}] {correction}") + console.print(" [dim]Saved to .saar/config.json. Re-run [bold]saar .[/bold] to regenerate.[/dim]") def cmd_diff( @@ -75,7 +75,7 @@ def cmd_diff( console.print("[yellow]No snapshot found.[/yellow] Run [bold]saar extract[/bold] first to create a baseline.") raise typer.Exit(code=0) - console.print(f"[bold]saar[/bold] checking [cyan]{repo_path.name}[/cyan] for changes...") + console.print(f" [bold]saar[/bold] checking [bold]{repo_path.name}[/bold] for changes...") extractor = DNAExtractor() dna = extractor.extract(str(repo_path)) @@ -129,5 +129,5 @@ def cmd_enrich( return save_cache(repo_path, enriched) - console.print("[bold green]done[/bold green] -- tribal knowledge enriched and saved.") - console.print("[dim]Re-run [bold]saar extract .[/bold] --no-interview to regenerate context files.[/dim]") + console.print(" [bold green]Done.[/bold green] Tribal knowledge enriched and saved.") + console.print(" [dim]Re-run [bold]saar extract . --no-interview[/bold] to regenerate context files.[/dim]") diff --git a/tests/test_capture.py b/tests/test_capture.py index b0bb9c0..fad9cfb 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -187,7 +187,7 @@ def test_capture_no_regen(self, tmp_path: Path): "--no-regen", ]) assert result.exit_code == 0 - assert "captured" in result.output + assert "Captured" in result.output or "captured" in result.output # check capture was saved entries = load_captures(tmp_path) assert len(entries) == 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 489eb0f..d71172e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -98,7 +98,7 @@ def test_skips_existing_without_force(self, tmp_repo: Path, tmp_path: Path): runner.invoke(app, ["extract", str(tmp_repo), "--format", "claude", "-o", str(output_dir)]) result = runner.invoke(app, ["extract", str(tmp_repo), "--format", "claude", "-o", str(output_dir)]) assert result.exit_code == 0 - assert "updated" in result.stdout or "wrote" in result.stdout + assert "Updated" in result.stdout or "Wrote" in result.stdout def test_force_overwrites(self, tmp_repo: Path, tmp_path: Path): """--force does a clean overwrite.""" @@ -107,7 +107,7 @@ def test_force_overwrites(self, tmp_repo: Path, tmp_path: Path): runner.invoke(app, ["extract", str(tmp_repo), "--format", "claude", "-o", str(output_dir)]) result = runner.invoke(app, ["extract", str(tmp_repo), "--format", "claude", "--force", "-o", str(output_dir)]) assert result.exit_code == 0 - assert "overwrote" in result.stdout or "wrote" in result.stdout + assert "Wrote" in result.stdout or "Updated" in result.stdout class TestPreservationMarkers: diff --git a/tests/test_interview.py b/tests/test_interview.py index 87d4f64..d2416d5 100644 --- a/tests/test_interview.py +++ b/tests/test_interview.py @@ -321,7 +321,7 @@ def test_add_default_goes_to_never_do(self, tmp_path: Path): runner = CliRunner() result = runner.invoke(app, ["add", "Never modify billing/", "--repo", str(tmp_path)]) assert result.exit_code == 0 - assert "added" in result.stdout + assert "Added" in result.stdout or "added" in result.stdout cached = load_cached(tmp_path) assert cached is not None assert "Never modify billing/" in cached.never_do