Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion saar/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Saar -- extract the essence of your codebase."""

__version__ = "0.5.13"
__version__ = "0.5.14"
6 changes: 3 additions & 3 deletions saar/commands/explore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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]")
Expand Down
43 changes: 24 additions & 19 deletions saar/commands/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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", []):
Expand Down Expand Up @@ -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]")

Expand Down Expand Up @@ -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".
Expand Down
10 changes: 5 additions & 5 deletions saar/commands/maintain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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))
Expand Down Expand Up @@ -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]")
2 changes: 1 addition & 1 deletion tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading