claude-lean 0.1.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.
- claude_lean/__init__.py +9 -0
- claude_lean/__main__.py +6 -0
- claude_lean/apply/__init__.py +1 -0
- claude_lean/apply/generator.py +180 -0
- claude_lean/apply/memory_cleaner.py +77 -0
- claude_lean/apply/wizard.py +107 -0
- claude_lean/audit/__init__.py +7 -0
- claude_lean/audit/analyzer.py +51 -0
- claude_lean/audit/report.py +163 -0
- claude_lean/audit/rules/__init__.py +22 -0
- claude_lean/audit/rules/_base.py +41 -0
- claude_lean/audit/rules/forcing_rules.py +55 -0
- claude_lean/audit/rules/memory_near_cap.py +56 -0
- claude_lean/audit/rules/stale_memory.py +65 -0
- claude_lean/audit/rules/unused_plugins.py +59 -0
- claude_lean/audit/rules/vague_descriptions.py +67 -0
- claude_lean/audit/scanner.py +273 -0
- claude_lean/cli.py +333 -0
- claude_lean/common/__init__.py +1 -0
- claude_lean/common/backup.py +114 -0
- claude_lean/common/claude_paths.py +90 -0
- claude_lean/common/log.py +35 -0
- claude_lean/common/tokenizer.py +62 -0
- claude_lean/profile/__init__.py +6 -0
- claude_lean/profile/manager.py +80 -0
- claude_lean/profile/schema.py +41 -0
- claude_lean/profile/stock/frontend-web.toml +43 -0
- claude_lean/profile/stock/minimal.toml +40 -0
- claude_lean/profile/stock/python-ml.toml +43 -0
- claude_lean-0.1.0.dist-info/METADATA +182 -0
- claude_lean-0.1.0.dist-info/RECORD +34 -0
- claude_lean-0.1.0.dist-info/WHEEL +4 -0
- claude_lean-0.1.0.dist-info/entry_points.txt +2 -0
- claude_lean-0.1.0.dist-info/licenses/LICENSE +21 -0
claude_lean/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""claude-lean — get 5x more from your Claude Code token budget."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__author__ = "vipin"
|
|
5
|
+
|
|
6
|
+
from claude_lean.common.tokenizer import count_tokens
|
|
7
|
+
from claude_lean.common.claude_paths import ClaudePaths
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__", "count_tokens", "ClaudePaths"]
|
claude_lean/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Apply subsystem: write recommended changes with backup + dry-run safety."""
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Generate optimized settings.json + CLAUDE.md based on wizard input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from claude_lean.apply.wizard import WizardResult
|
|
11
|
+
from claude_lean.audit.rules.unused_plugins import LIKELY_UNUSED_FOR_ENGINEERING
|
|
12
|
+
from claude_lean.common.claude_paths import ClaudePaths
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Plugin packs to always keep enabled for engineering work
|
|
16
|
+
_CORE_ENG_PLUGINS = {
|
|
17
|
+
"engineering-skills",
|
|
18
|
+
"engineering-advanced-skills",
|
|
19
|
+
"superpowers",
|
|
20
|
+
"context7",
|
|
21
|
+
"github",
|
|
22
|
+
"code-review",
|
|
23
|
+
"feature-dev",
|
|
24
|
+
"code-simplifier",
|
|
25
|
+
"self-improving-agent",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Plugin packs that depend on a specific stack
|
|
29
|
+
_STACK_TO_PLUGINS = {
|
|
30
|
+
"frontend": {"frontend-design", "playwright"},
|
|
31
|
+
"javascript/typescript": {"frontend-design", "playwright"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Non-engineering work areas that need their corresponding plugins
|
|
35
|
+
_WORK_TO_PLUGINS = {
|
|
36
|
+
"marketing": {"marketing-skills", "content-creator"},
|
|
37
|
+
"sales": {"business-growth-skills"},
|
|
38
|
+
"finance": {"finance-skills"},
|
|
39
|
+
"product": {"product-skills"},
|
|
40
|
+
"regulatory": {"ra-qm-skills"},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class GeneratorPlan:
|
|
46
|
+
"""The proposed changes; render to diff before applying."""
|
|
47
|
+
|
|
48
|
+
new_settings: dict[str, Any]
|
|
49
|
+
old_settings: dict[str, Any]
|
|
50
|
+
new_claude_md: str
|
|
51
|
+
old_claude_md: str
|
|
52
|
+
plugin_disables: list[str]
|
|
53
|
+
plugin_keeps: list[str]
|
|
54
|
+
settings_path: Path
|
|
55
|
+
claude_md_path: Path
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def has_changes(self) -> bool:
|
|
59
|
+
return self.new_settings != self.old_settings or self.new_claude_md != self.old_claude_md
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_plan(paths: ClaudePaths, wizard: WizardResult) -> GeneratorPlan:
|
|
63
|
+
"""Build a GeneratorPlan from current state + wizard input."""
|
|
64
|
+
old_settings = _load_json(paths.settings_json)
|
|
65
|
+
new_settings = _optimize_settings(old_settings, wizard)
|
|
66
|
+
|
|
67
|
+
old_claude_md = paths.global_claude_md.read_text(encoding="utf-8") if paths.global_claude_md.is_file() else ""
|
|
68
|
+
new_claude_md = _optimize_claude_md(old_claude_md, wizard)
|
|
69
|
+
|
|
70
|
+
# Diff plugin states for the human summary
|
|
71
|
+
old_enabled = set(_enabled_keys(old_settings))
|
|
72
|
+
new_enabled = set(_enabled_keys(new_settings))
|
|
73
|
+
disables = sorted(old_enabled - new_enabled)
|
|
74
|
+
keeps = sorted(new_enabled)
|
|
75
|
+
|
|
76
|
+
return GeneratorPlan(
|
|
77
|
+
new_settings=new_settings,
|
|
78
|
+
old_settings=old_settings,
|
|
79
|
+
new_claude_md=new_claude_md,
|
|
80
|
+
old_claude_md=old_claude_md,
|
|
81
|
+
plugin_disables=disables,
|
|
82
|
+
plugin_keeps=keeps,
|
|
83
|
+
settings_path=paths.settings_json,
|
|
84
|
+
claude_md_path=paths.global_claude_md,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_plan(plan: GeneratorPlan) -> None:
|
|
89
|
+
"""Write the plan to disk. Caller is responsible for creating backups first."""
|
|
90
|
+
plan.settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
plan.settings_path.write_text(json.dumps(plan.new_settings, indent=2) + "\n", encoding="utf-8")
|
|
92
|
+
plan.claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
plan.claude_md_path.write_text(plan.new_claude_md, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---- internals ----
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
100
|
+
if not path.is_file():
|
|
101
|
+
return {}
|
|
102
|
+
try:
|
|
103
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
104
|
+
except (OSError, json.JSONDecodeError):
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _enabled_keys(settings: dict[str, Any]) -> list[str]:
|
|
109
|
+
raw = settings.get("enabledPlugins", {})
|
|
110
|
+
if not isinstance(raw, dict):
|
|
111
|
+
return []
|
|
112
|
+
return [k for k, v in raw.items() if v]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _optimize_settings(old: dict[str, Any], wizard: WizardResult) -> dict[str, Any]:
|
|
116
|
+
"""Produce a new settings.json keeping the structure but trimming plugins."""
|
|
117
|
+
out = dict(old) # shallow copy
|
|
118
|
+
plugins = dict(out.get("enabledPlugins", {}))
|
|
119
|
+
|
|
120
|
+
# Decide which plugin keys to keep
|
|
121
|
+
needed_plugin_names = set(_CORE_ENG_PLUGINS)
|
|
122
|
+
for stack in wizard.primary_stacks:
|
|
123
|
+
needed_plugin_names.update(_STACK_TO_PLUGINS.get(stack, set()))
|
|
124
|
+
for work in wizard.non_eng_work:
|
|
125
|
+
needed_plugin_names.update(_WORK_TO_PLUGINS.get(work, set()))
|
|
126
|
+
|
|
127
|
+
aggressive = wizard.aggressiveness == "aggressive"
|
|
128
|
+
conservative = wizard.aggressiveness == "conservative"
|
|
129
|
+
|
|
130
|
+
for key in list(plugins.keys()):
|
|
131
|
+
plugin_name = key.split("@", 1)[0]
|
|
132
|
+
if plugin_name in needed_plugin_names:
|
|
133
|
+
plugins[key] = True
|
|
134
|
+
continue
|
|
135
|
+
if plugin_name in LIKELY_UNUSED_FOR_ENGINEERING and not conservative:
|
|
136
|
+
plugins[key] = False
|
|
137
|
+
continue
|
|
138
|
+
if aggressive and plugin_name not in _CORE_ENG_PLUGINS:
|
|
139
|
+
plugins[key] = False
|
|
140
|
+
continue
|
|
141
|
+
# Otherwise leave as-is
|
|
142
|
+
|
|
143
|
+
out["enabledPlugins"] = plugins
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _optimize_claude_md(old: str, wizard: WizardResult) -> str:
|
|
148
|
+
"""Rewrite CLAUDE.md to remove forcing rules and add stack hints."""
|
|
149
|
+
lines = old.splitlines()
|
|
150
|
+
cleaned: list[str] = []
|
|
151
|
+
|
|
152
|
+
for line in lines:
|
|
153
|
+
stripped = line.strip().lower()
|
|
154
|
+
# Drop "always use all agents" and similar forcing rules
|
|
155
|
+
if "always use" in stripped and "agent" in stripped:
|
|
156
|
+
cleaned.append(
|
|
157
|
+
"- Use agents when work is specialized or parallelizable, "
|
|
158
|
+
"not for simple edits"
|
|
159
|
+
)
|
|
160
|
+
continue
|
|
161
|
+
if "always use all" in stripped:
|
|
162
|
+
continue # filtered out
|
|
163
|
+
cleaned.append(line)
|
|
164
|
+
|
|
165
|
+
# Add a stack-aware hint if not already present
|
|
166
|
+
stack_hint = _stack_hint(wizard.primary_stacks)
|
|
167
|
+
if stack_hint and stack_hint not in "\n".join(cleaned):
|
|
168
|
+
cleaned.append(stack_hint)
|
|
169
|
+
|
|
170
|
+
# Trailing newline discipline
|
|
171
|
+
text = "\n".join(cleaned).rstrip() + "\n"
|
|
172
|
+
return text
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _stack_hint(stacks: list[str]) -> str | None:
|
|
176
|
+
if "python" in stacks and "ml/ai" in stacks:
|
|
177
|
+
return "- Prefer MLX over PyTorch for local ML on Apple Silicon when possible"
|
|
178
|
+
if "rust" in stacks:
|
|
179
|
+
return "- Default to safe Rust; only reach for unsafe when measurably necessary"
|
|
180
|
+
return None
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Remove stale snapshot sections from memory files, keeping policy/why intact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Heading patterns that mark a snapshot section (case-insensitive)
|
|
10
|
+
_SNAPSHOT_HEADING = re.compile(
|
|
11
|
+
r"^(\*\*)?(setup\s+state|state\s+\(as\s+of|snapshot|installed\s+as\s+of|current\s+state)",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def clean_memory_body(text: str) -> tuple[str, bool]:
|
|
17
|
+
"""Remove snapshot sections from a memory file's body.
|
|
18
|
+
|
|
19
|
+
Returns ``(new_text, changed)``. The frontmatter is preserved untouched.
|
|
20
|
+
"""
|
|
21
|
+
if not text.startswith("---\n"):
|
|
22
|
+
return text, False
|
|
23
|
+
|
|
24
|
+
end = text.find("\n---\n", 4)
|
|
25
|
+
if end == -1:
|
|
26
|
+
return text, False
|
|
27
|
+
|
|
28
|
+
frontmatter = text[: end + 5]
|
|
29
|
+
body = text[end + 5 :]
|
|
30
|
+
|
|
31
|
+
new_body, changed = _strip_snapshot_blocks(body)
|
|
32
|
+
if not changed:
|
|
33
|
+
return text, False
|
|
34
|
+
return frontmatter + new_body, True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clean_memory_file(path: Path) -> bool:
|
|
38
|
+
"""In-place clean a memory file. Returns True if anything changed."""
|
|
39
|
+
try:
|
|
40
|
+
text = path.read_text(encoding="utf-8")
|
|
41
|
+
except OSError:
|
|
42
|
+
return False
|
|
43
|
+
new_text, changed = clean_memory_body(text)
|
|
44
|
+
if not changed:
|
|
45
|
+
return False
|
|
46
|
+
path.write_text(new_text, encoding="utf-8")
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _strip_snapshot_blocks(body: str) -> tuple[str, bool]:
|
|
51
|
+
"""Drop sections that look like dated snapshots."""
|
|
52
|
+
lines = body.splitlines(keepends=True)
|
|
53
|
+
out: list[str] = []
|
|
54
|
+
skipping = False
|
|
55
|
+
changed = False
|
|
56
|
+
|
|
57
|
+
for line in lines:
|
|
58
|
+
if skipping:
|
|
59
|
+
# Stop skipping at the next blank-line-then-heading or end of paragraph
|
|
60
|
+
if line.strip() == "":
|
|
61
|
+
# Lookahead would help, but stay simple: treat blank as end of block
|
|
62
|
+
skipping = False
|
|
63
|
+
# Don't emit the blank line (we already consumed the section)
|
|
64
|
+
continue
|
|
65
|
+
# Still in snapshot block; drop
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Detect start of snapshot section
|
|
69
|
+
stripped = line.strip()
|
|
70
|
+
if _SNAPSHOT_HEADING.search(stripped):
|
|
71
|
+
skipping = True
|
|
72
|
+
changed = True
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
out.append(line)
|
|
76
|
+
|
|
77
|
+
return "".join(out), changed
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Interactive wizard that asks the user about their stack and returns a plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Prompt, Confirm
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_ENGINEERING_STACKS = ["python", "javascript/typescript", "rust", "go", "ml/ai", "devops", "frontend", "mobile", "other"]
|
|
12
|
+
_NON_ENG_WORK = ["marketing", "sales", "finance", "product", "regulatory", "none"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class WizardResult:
|
|
17
|
+
"""User's choices, ready to feed into the generator."""
|
|
18
|
+
|
|
19
|
+
primary_stacks: list[str] = field(default_factory=list)
|
|
20
|
+
non_eng_work: list[str] = field(default_factory=list)
|
|
21
|
+
aggressiveness: str = "balanced" # conservative | balanced | aggressive
|
|
22
|
+
confirm_apply: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_wizard(*, console: Console | None = None) -> WizardResult:
|
|
26
|
+
"""Run the interactive wizard. Returns the user's choices."""
|
|
27
|
+
console = console or Console()
|
|
28
|
+
console.print()
|
|
29
|
+
console.rule("[bold]claude-lean apply[/bold]")
|
|
30
|
+
console.print()
|
|
31
|
+
console.print("A few questions to tailor recommendations to your work.")
|
|
32
|
+
console.print()
|
|
33
|
+
|
|
34
|
+
primary = _multi_select(console, "What's your primary stack?", _ENGINEERING_STACKS)
|
|
35
|
+
non_eng = _multi_select(
|
|
36
|
+
console,
|
|
37
|
+
"Any non-engineering work types? ('none' is fine)",
|
|
38
|
+
_NON_ENG_WORK,
|
|
39
|
+
default_index=len(_NON_ENG_WORK) - 1,
|
|
40
|
+
)
|
|
41
|
+
aggressiveness = _single_select(
|
|
42
|
+
console,
|
|
43
|
+
"How aggressive should optimizations be?",
|
|
44
|
+
["conservative", "balanced", "aggressive"],
|
|
45
|
+
default="balanced",
|
|
46
|
+
)
|
|
47
|
+
confirm = Confirm.ask(
|
|
48
|
+
"Apply changes now? (Will show diff first; nothing is destructive)",
|
|
49
|
+
default=False,
|
|
50
|
+
console=console,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return WizardResult(
|
|
54
|
+
primary_stacks=primary,
|
|
55
|
+
non_eng_work=non_eng,
|
|
56
|
+
aggressiveness=aggressiveness,
|
|
57
|
+
confirm_apply=confirm,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _multi_select(
|
|
62
|
+
console: Console,
|
|
63
|
+
prompt: str,
|
|
64
|
+
choices: list[str],
|
|
65
|
+
*,
|
|
66
|
+
default_index: int | None = None,
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
"""Naive stdlib-friendly multi-select: comma-separated indices."""
|
|
69
|
+
console.print(f"[bold]{prompt}[/bold]")
|
|
70
|
+
for i, c in enumerate(choices, start=1):
|
|
71
|
+
marker = " (default)" if default_index == i - 1 else ""
|
|
72
|
+
console.print(f" [cyan]{i}[/cyan]. {c}{marker}")
|
|
73
|
+
default_str = str(default_index + 1) if default_index is not None else ""
|
|
74
|
+
raw = Prompt.ask(
|
|
75
|
+
"Enter numbers separated by commas",
|
|
76
|
+
default=default_str,
|
|
77
|
+
console=console,
|
|
78
|
+
show_default=bool(default_str),
|
|
79
|
+
)
|
|
80
|
+
selected: list[str] = []
|
|
81
|
+
for chunk in raw.split(","):
|
|
82
|
+
chunk = chunk.strip()
|
|
83
|
+
if not chunk:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
idx = int(chunk)
|
|
87
|
+
if 1 <= idx <= len(choices):
|
|
88
|
+
selected.append(choices[idx - 1])
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
console.print()
|
|
92
|
+
return selected
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _single_select(
|
|
96
|
+
console: Console,
|
|
97
|
+
prompt: str,
|
|
98
|
+
choices: list[str],
|
|
99
|
+
*,
|
|
100
|
+
default: str,
|
|
101
|
+
) -> str:
|
|
102
|
+
return Prompt.ask(
|
|
103
|
+
prompt,
|
|
104
|
+
choices=choices,
|
|
105
|
+
default=default,
|
|
106
|
+
console=console,
|
|
107
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Audit subsystem: read-only inspection of ~/.claude/."""
|
|
2
|
+
|
|
3
|
+
from claude_lean.audit.scanner import scan
|
|
4
|
+
from claude_lean.audit.analyzer import analyze
|
|
5
|
+
from claude_lean.audit.report import render_terminal, render_json
|
|
6
|
+
|
|
7
|
+
__all__ = ["scan", "analyze", "render_terminal", "render_json"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Run all registered rules over an inventory and produce a finding list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from claude_lean.audit.rules import all_rules
|
|
8
|
+
from claude_lean.audit.rules._base import Finding, Severity
|
|
9
|
+
from claude_lean.audit.scanner import Inventory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AuditResult:
|
|
14
|
+
"""The full audit output."""
|
|
15
|
+
|
|
16
|
+
inventory: Inventory
|
|
17
|
+
findings: list[Finding] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def total_estimated_savings(self) -> int:
|
|
21
|
+
return sum(f.estimated_savings_tokens for f in self.findings)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def critical_count(self) -> int:
|
|
25
|
+
return sum(1 for f in self.findings if f.severity == Severity.CRITICAL)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def warn_count(self) -> int:
|
|
29
|
+
return sum(1 for f in self.findings if f.severity == Severity.WARN)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def info_count(self) -> int:
|
|
33
|
+
return sum(1 for f in self.findings if f.severity == Severity.INFO)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def multiplier_gain(self) -> float:
|
|
37
|
+
"""Estimated tokens-per-conversation multiplier after applying recommendations."""
|
|
38
|
+
baseline = self.inventory.estimated_system_prompt_tokens
|
|
39
|
+
if baseline <= 0:
|
|
40
|
+
return 1.0
|
|
41
|
+
saved = self.total_estimated_savings
|
|
42
|
+
remaining = max(baseline - saved, 1)
|
|
43
|
+
return round(baseline / remaining, 2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def analyze(inventory: Inventory) -> AuditResult:
|
|
47
|
+
"""Run every registered rule and collect findings."""
|
|
48
|
+
findings: list[Finding] = []
|
|
49
|
+
for rule in all_rules():
|
|
50
|
+
findings.extend(rule.evaluate(inventory))
|
|
51
|
+
return AuditResult(inventory=inventory, findings=findings)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Render audit results to terminal (rich) or JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich import box
|
|
11
|
+
|
|
12
|
+
from claude_lean.audit.analyzer import AuditResult
|
|
13
|
+
from claude_lean.audit.rules._base import Severity
|
|
14
|
+
from claude_lean.common.tokenizer import accuracy_label
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SEVERITY_GLYPH = {
|
|
18
|
+
Severity.INFO: "[cyan]ℹ[/cyan]",
|
|
19
|
+
Severity.WARN: "[yellow]⚠[/yellow]",
|
|
20
|
+
Severity.CRITICAL: "[red]✖[/red]",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_terminal(result: AuditResult, console: Console | None = None) -> None:
|
|
25
|
+
"""Render the audit to the terminal using rich."""
|
|
26
|
+
console = console or Console()
|
|
27
|
+
inv = result.inventory
|
|
28
|
+
|
|
29
|
+
# Header
|
|
30
|
+
console.print()
|
|
31
|
+
console.rule(f"[bold]claude-lean audit[/bold] · {inv.claude_home}")
|
|
32
|
+
console.print(f"[dim]Token measurement: {accuracy_label()}[/dim]")
|
|
33
|
+
console.print()
|
|
34
|
+
|
|
35
|
+
# Headline stats
|
|
36
|
+
console.print(
|
|
37
|
+
f" Plugins enabled: [bold]{sum(1 for p in inv.plugins if p.enabled)}[/bold] "
|
|
38
|
+
f"· Skills: [bold]{sum(len(p.skills) for p in inv.plugins if p.enabled)}[/bold] "
|
|
39
|
+
f"· Agents: [bold]{sum(len(p.agents) for p in inv.plugins if p.enabled)}[/bold]"
|
|
40
|
+
)
|
|
41
|
+
console.print(
|
|
42
|
+
f" Estimated system-prompt overhead per turn: "
|
|
43
|
+
f"[bold yellow]{inv.estimated_system_prompt_tokens:,}[/bold yellow] tokens"
|
|
44
|
+
)
|
|
45
|
+
console.print()
|
|
46
|
+
|
|
47
|
+
# Per-plugin table (top 10 by cost)
|
|
48
|
+
enabled_plugins = sorted(
|
|
49
|
+
(p for p in inv.plugins if p.enabled),
|
|
50
|
+
key=lambda p: p.tokens_total,
|
|
51
|
+
reverse=True,
|
|
52
|
+
)
|
|
53
|
+
top = enabled_plugins[:10]
|
|
54
|
+
if top:
|
|
55
|
+
table = Table(
|
|
56
|
+
title="Top 10 enabled plugins by token cost",
|
|
57
|
+
box=box.SIMPLE_HEAVY,
|
|
58
|
+
show_lines=False,
|
|
59
|
+
)
|
|
60
|
+
table.add_column("Plugin", style="cyan")
|
|
61
|
+
table.add_column("Skills", justify="right")
|
|
62
|
+
table.add_column("Agents", justify="right")
|
|
63
|
+
table.add_column("Total tokens", justify="right", style="bold")
|
|
64
|
+
for p in top:
|
|
65
|
+
table.add_row(
|
|
66
|
+
p.name,
|
|
67
|
+
f"{p.tokens_skills:,}",
|
|
68
|
+
f"{p.tokens_agents:,}",
|
|
69
|
+
f"{p.tokens_total:,}",
|
|
70
|
+
)
|
|
71
|
+
console.print(table)
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
# Findings
|
|
75
|
+
if result.findings:
|
|
76
|
+
console.print(
|
|
77
|
+
f"[bold]Findings:[/bold] "
|
|
78
|
+
f"[red]{result.critical_count} critical[/red] · "
|
|
79
|
+
f"[yellow]{result.warn_count} warn[/yellow] · "
|
|
80
|
+
f"[cyan]{result.info_count} info[/cyan]"
|
|
81
|
+
)
|
|
82
|
+
console.print()
|
|
83
|
+
# Group findings by rule_id
|
|
84
|
+
by_rule: dict[str, list] = {}
|
|
85
|
+
for f in result.findings:
|
|
86
|
+
by_rule.setdefault(f.rule_id, []).append(f)
|
|
87
|
+
for rule_id, group in by_rule.items():
|
|
88
|
+
first = group[0]
|
|
89
|
+
glyph = _SEVERITY_GLYPH.get(first.severity, "·")
|
|
90
|
+
console.print(f"{glyph} [bold]{rule_id}[/bold] ({len(group)})")
|
|
91
|
+
for f in group:
|
|
92
|
+
console.print(f" {f.title}")
|
|
93
|
+
if f.estimated_savings_tokens:
|
|
94
|
+
console.print(
|
|
95
|
+
f" [dim]→ est. savings: {f.estimated_savings_tokens:,} tokens/turn[/dim]"
|
|
96
|
+
)
|
|
97
|
+
console.print()
|
|
98
|
+
else:
|
|
99
|
+
console.print("[green]✓[/green] No anti-patterns detected. Your setup looks clean.")
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
# Bottom line
|
|
103
|
+
savings = result.total_estimated_savings
|
|
104
|
+
if savings > 0:
|
|
105
|
+
console.print(
|
|
106
|
+
f" [bold]Estimated tokens saved per turn if applied:[/bold] "
|
|
107
|
+
f"[green]{savings:,}[/green] tokens "
|
|
108
|
+
f"(≈ [bold green]{result.multiplier_gain}×[/bold green] more headroom)"
|
|
109
|
+
)
|
|
110
|
+
console.print()
|
|
111
|
+
console.print(
|
|
112
|
+
" Next: run [bold cyan]claude-lean apply[/bold cyan] to act on these recommendations."
|
|
113
|
+
)
|
|
114
|
+
console.print()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render_json(result: AuditResult) -> str:
|
|
118
|
+
"""Render the audit as a stable JSON document."""
|
|
119
|
+
inv = result.inventory
|
|
120
|
+
doc = {
|
|
121
|
+
"schema_version": 1,
|
|
122
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
123
|
+
"claude_home": str(inv.claude_home),
|
|
124
|
+
"tokenizer_accuracy": accuracy_label(),
|
|
125
|
+
"totals": {
|
|
126
|
+
"plugins_enabled": sum(1 for p in inv.plugins if p.enabled),
|
|
127
|
+
"plugins_installed": len(inv.plugins),
|
|
128
|
+
"skills_loaded": sum(len(p.skills) for p in inv.plugins if p.enabled),
|
|
129
|
+
"agents_loaded": sum(len(p.agents) for p in inv.plugins if p.enabled),
|
|
130
|
+
"estimated_system_prompt_tokens": inv.estimated_system_prompt_tokens,
|
|
131
|
+
"claude_md_tokens": inv.claude_md_tokens,
|
|
132
|
+
},
|
|
133
|
+
"by_plugin": [
|
|
134
|
+
{
|
|
135
|
+
"name": p.name,
|
|
136
|
+
"marketplace": p.marketplace,
|
|
137
|
+
"enabled": p.enabled,
|
|
138
|
+
"tokens_skills": p.tokens_skills,
|
|
139
|
+
"tokens_agents": p.tokens_agents,
|
|
140
|
+
"tokens_mcp": p.tokens_mcp,
|
|
141
|
+
"tokens_total": p.tokens_total,
|
|
142
|
+
}
|
|
143
|
+
for p in sorted(inv.plugins, key=lambda x: x.tokens_total, reverse=True)
|
|
144
|
+
],
|
|
145
|
+
"findings": [
|
|
146
|
+
{
|
|
147
|
+
"rule_id": f.rule_id,
|
|
148
|
+
"severity": f.severity.value,
|
|
149
|
+
"title": f.title,
|
|
150
|
+
"evidence": f.evidence,
|
|
151
|
+
"suggested_action": f.suggested_action,
|
|
152
|
+
"estimated_savings_tokens": f.estimated_savings_tokens,
|
|
153
|
+
"target": f.target,
|
|
154
|
+
"metadata": f.metadata,
|
|
155
|
+
}
|
|
156
|
+
for f in result.findings
|
|
157
|
+
],
|
|
158
|
+
"recommendations_summary": {
|
|
159
|
+
"estimated_tokens_saved_per_turn": result.total_estimated_savings,
|
|
160
|
+
"estimated_multiplier_gain": result.multiplier_gain,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
return json.dumps(doc, indent=2, default=str)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Rule modules — one anti-pattern detector per file."""
|
|
2
|
+
|
|
3
|
+
from claude_lean.audit.rules._base import Rule, Finding, Severity
|
|
4
|
+
from claude_lean.audit.rules.unused_plugins import UnusedPluginsRule
|
|
5
|
+
from claude_lean.audit.rules.forcing_rules import ForcingRulesRule
|
|
6
|
+
from claude_lean.audit.rules.stale_memory import StaleMemoryRule
|
|
7
|
+
from claude_lean.audit.rules.vague_descriptions import VagueDescriptionsRule
|
|
8
|
+
from claude_lean.audit.rules.memory_near_cap import MemoryNearCapRule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def all_rules() -> list[Rule]:
|
|
12
|
+
"""Return all built-in rules, in fire order."""
|
|
13
|
+
return [
|
|
14
|
+
UnusedPluginsRule(),
|
|
15
|
+
ForcingRulesRule(),
|
|
16
|
+
StaleMemoryRule(),
|
|
17
|
+
VagueDescriptionsRule(),
|
|
18
|
+
MemoryNearCapRule(),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["Rule", "Finding", "Severity", "all_rules"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Base types for audit rules. A rule looks at the inventory and emits findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Protocol, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from claude_lean.audit.scanner import Inventory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Severity(str, Enum):
|
|
14
|
+
INFO = "info"
|
|
15
|
+
WARN = "warn"
|
|
16
|
+
CRITICAL = "critical"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Finding:
|
|
21
|
+
"""A single anti-pattern instance discovered by a rule."""
|
|
22
|
+
|
|
23
|
+
rule_id: str
|
|
24
|
+
severity: Severity
|
|
25
|
+
title: str
|
|
26
|
+
evidence: str
|
|
27
|
+
suggested_action: str
|
|
28
|
+
estimated_savings_tokens: int = 0
|
|
29
|
+
target: str | None = None # plugin name / file path / etc.
|
|
30
|
+
metadata: dict = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Rule(Protocol):
|
|
34
|
+
"""Audit rule contract."""
|
|
35
|
+
|
|
36
|
+
rule_id: str
|
|
37
|
+
severity: Severity
|
|
38
|
+
title: str
|
|
39
|
+
|
|
40
|
+
def evaluate(self, inventory: "Inventory") -> list[Finding]:
|
|
41
|
+
...
|