claude-finops 0.1.0

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,229 @@
1
+ """Copy-paste fixes: for each finding, numbered steps plus a prompt to give Claude.
2
+
3
+ `where` says where to run it (which repo, or "any session"). Prompts are written so
4
+ Claude does the edit itself and keeps it small.
5
+ """
6
+ import os
7
+
8
+
9
+ def _rel(p, root):
10
+ return os.path.relpath(p, root) if root and p and p.startswith(root) else p
11
+
12
+
13
+ def _fix(steps, prompt=None, where=None):
14
+ return {"steps": steps, "prompt": prompt, "where": where}
15
+
16
+
17
+ # ---------------------------------------------------------------- recommendations
18
+ def for_recommendation(r):
19
+ t = r["title"]
20
+ if t.startswith("Clear or compact"):
21
+ return _fix([
22
+ "Before switching to an unrelated task, type /clear.",
23
+ "Once a session passes ~100K context (check with /context), type /compact.",
24
+ "For long work, ask Claude to save progress to a file first (prompt below), then /clear "
25
+ "and start the next session with: \"Read NOTES.md and continue.\"",
26
+ ], "Write a short handoff to NOTES.md: goal, what's done, what's left, key files and "
27
+ "decisions. Max 30 lines. I'm going to /clear after this.", "any long session")
28
+ if t.startswith("Break up marathon"):
29
+ return _fix([
30
+ "One session per ticket/feature: start with `claude` in the repo, finish, then exit.",
31
+ "Before ending, use the handoff prompt below so the next session starts small.",
32
+ "Resume later with /resume only if you need that exact context; otherwise start fresh.",
33
+ ], "Summarise this session into NOTES.md for the next session: goal, done, pending, "
34
+ "gotchas, files touched. Keep it under 30 lines.", "the long session")
35
+ if t.startswith("Default to Sonnet"):
36
+ return _fix([
37
+ "Add \"model\": \"sonnet\" to ~/.claude/settings.json (or run /model sonnet).",
38
+ "Switch up with /model opus only for design, tricky debugging or large refactors.",
39
+ "For custom agents, add `model: sonnet` (or haiku for search) in their frontmatter.",
40
+ ], "Set my default Claude Code model to sonnet in ~/.claude/settings.json. Don't change "
41
+ "anything else in that file.", "any session")
42
+ if t.startswith("Cap noisy shell"):
43
+ return _fix([
44
+ "Add the output rules below to ~/.claude/CLAUDE.md (applies to every project).",
45
+ "Run test suites with failure-only reporters (e.g. `jest --silent`, `pytest -q`).",
46
+ ], "Add a short 'Shell output' section to ~/.claude/CLAUDE.md: pipe long output through "
47
+ "`| tail -50` or `| head -50`, use quiet flags (-q, --silent), grep logs for errors "
48
+ "instead of printing them, and never cat files over 300 lines — use Read with a range. "
49
+ "Max 5 bullet points.", "any session")
50
+ if t.startswith("Browser/MCP"):
51
+ return _fix([
52
+ "Prefer text reads (get_page_text, find, read_page) over screenshots.",
53
+ "Run browser checks in a subagent so screenshots don't stay in your main context.",
54
+ "Add the rule below to ~/.claude/CLAUDE.md.",
55
+ ], "Add to ~/.claude/CLAUDE.md under 'Browser tools': use get_page_text/find before "
56
+ "screenshots; take a screenshot only to verify visuals; do multi-step browser checks "
57
+ "inside a subagent and return a short summary.", "any session")
58
+ if t.startswith("Subagents are a large"):
59
+ return _fix([
60
+ "Use subagents for wide searches only; read known files directly.",
61
+ "Give custom agents a cheaper model: `model: haiku` for search, `sonnet` for coding.",
62
+ ], "List my custom agents in ~/.claude/agents and .claude/agents. For each one without a "
63
+ "`model:` line, add `model: sonnet` (or `haiku` if it only searches/reads). Show me "
64
+ "the diff.", "any session")
65
+ if "need Claude config changes" in t:
66
+ return _fix(["Open each project's Fix card below and run its prompt in that repo."])
67
+ return None
68
+
69
+
70
+ # ---------------------------------------------------------------- project issues
71
+ def for_project_issue(i, project):
72
+ t, path = i["title"], project.get("path")
73
+ where = f"cd \"{path}\" && claude" if path else None
74
+ if t == "No CLAUDE.md":
75
+ return _fix([
76
+ f"Open Claude in the repo: {where}",
77
+ "Run /init to generate a first CLAUDE.md.",
78
+ "Then paste the prompt below to tighten it.",
79
+ ], "Review CLAUDE.md and keep it under 150 lines with: 1) exact install/run/test/lint "
80
+ "commands, 2) a folder map (one line per top-level folder), 3) where key things live "
81
+ "(routes, API layer, state, config), 4) coding conventions that differ from defaults. "
82
+ "Remove anything Claude can infer from the code.", where)
83
+ if t.startswith("CLAUDE.md isn't saving"):
84
+ return _fix([f"Open Claude in the repo: {where}", "Paste the prompt below."],
85
+ "Look at which folders you usually have to search to find things in this repo. "
86
+ "Add a 'Where things live' section to CLAUDE.md: one line per area (routes, "
87
+ "API calls, state, shared components, config, tests) with its path. Max 15 "
88
+ "lines. Don't touch other sections.", where)
89
+ if t.startswith("CLAUDE.md is ~"):
90
+ return _fix([
91
+ f"Open Claude in the repo: {where}",
92
+ "Paste the prompt below; review the diff before accepting.",
93
+ "Aim for under ~2,500 tokens (~10,000 characters).",
94
+ ], "CLAUDE.md is loaded on every request and is too long. Shrink it to under 10,000 "
95
+ "characters: keep commands, folder map, conventions and hard rules. Move long "
96
+ "reference sections into docs/ files and leave a one-line pointer to each in CLAUDE.md "
97
+ "(\"For X, read docs/x.md\"). Don't lose any rule.", where)
98
+ if t.startswith("MEMORY.md is ~") or "long MEMORY.md" in t:
99
+ return _fix(["In any session in this project, paste the prompt below."],
100
+ "Clean up my auto-memory for this project: keep MEMORY.md to one short line "
101
+ "per memory (under 150 chars), merge duplicates, delete memories that are "
102
+ "stale or already in CLAUDE.md. Show what you removed.", where)
103
+ if t == "Same files re-read across sessions":
104
+ files = ", ".join(_rel(x["path"], path) for x in (i.get("evidence") or [])[:5])
105
+ return _fix([f"Open Claude in the repo: {where}", "Paste the prompt below."],
106
+ f"These files get re-read in almost every session: {files}. Add a 'Key files' "
107
+ f"section to CLAUDE.md with 2–3 lines each: what it does, main exports/"
108
+ f"functions, and when to open it. Don't paste their code.", where)
109
+ if t == "Generated/large files were read":
110
+ return _fix([f"Open Claude in the repo: {where}", "Paste the prompt below."],
111
+ "Create or update .claude/settings.json in this repo with permissions.deny "
112
+ "rules so Claude can't read generated or huge files: node_modules, dist, "
113
+ "build, coverage, lockfiles, *.min.js, *.map. Keep existing settings.", where)
114
+ if t == "Unused MCP servers enabled":
115
+ return _fix([f"In a terminal: cd \"{path}\"",
116
+ "Run `claude mcp list`, then `claude mcp remove <name>` for each unused one."])
117
+ if t == "CLAUDE.md has no build/test commands":
118
+ return _fix([f"Open Claude in the repo: {where}", "Paste the prompt below."],
119
+ "Add a 'Commands' section at the top of CLAUDE.md with the exact commands "
120
+ "to install, run dev, run tests (all and a single file), lint and build — "
121
+ "check package.json/Makefile to get them right.", where)
122
+ if "stale path" in t:
123
+ return _fix([f"Open Claude in the repo: {where}", "Paste the prompt below."],
124
+ f"CLAUDE.md references paths that no longer exist ({i['fix'].split(': ', 1)[-1]}). "
125
+ f"Find where each moved and update the reference, or delete the line if the "
126
+ f"thing is gone.", where)
127
+ if "duplicated line" in t:
128
+ return _fix(["Paste the prompt below in the repo."],
129
+ "Remove duplicated lines and repeated rules from CLAUDE.md. Show the diff.", where)
130
+ if t == "No auto-memory yet":
131
+ return _fix(["When you catch yourself repeating an instruction, say: \"remember: …\"",
132
+ "See 'Add to CLAUDE.md / memory' above for candidates."])
133
+ if "links to missing" in t or "not indexed" in t:
134
+ return _fix(["Paste the prompt below in a session in this project."],
135
+ "Fix my memory index: every memory file must have one line in MEMORY.md and "
136
+ "every MEMORY.md link must point to an existing file. Remove dead links.", where)
137
+ return None
138
+
139
+
140
+ def for_global_issue(i):
141
+ if i["title"] == "No default model set":
142
+ return for_recommendation({"title": "Default to Sonnet"})
143
+ if i["title"] == "Global CLAUDE.md is large":
144
+ return _fix(["Paste the prompt below in any session."],
145
+ "~/.claude/CLAUDE.md loads in every project. Keep only rules that apply "
146
+ "everywhere (under 60 lines); move project-specific parts to that project's "
147
+ "CLAUDE.md. Show the diff.", "any session")
148
+ return None
149
+
150
+
151
+ # ---------------------------------------------------------------- sessions
152
+ def for_live(s):
153
+ if s["severity"] == "ok":
154
+ return None
155
+ return _fix([
156
+ "In that session, paste the handoff prompt below.",
157
+ "Then type /clear (or /compact if you need the details that are still in context).",
158
+ "Continue with: \"Read NOTES.md and continue.\"",
159
+ ], "Write a handoff to NOTES.md: goal, what's done, what's left, key files, decisions. "
160
+ "Max 30 lines.", "that running session")
161
+
162
+
163
+ def for_past_session(s):
164
+ steps = ["Next time for similar work, start a fresh session per task."]
165
+ if any("Never compacted" in f for f in s["fixes"]):
166
+ steps.append("Type /compact when /context shows more than ~100K.")
167
+ if any("Same files" in f for f in s["fixes"]):
168
+ steps.append("Run the prompt below in that repo so those files are summarised once.")
169
+ if any("Large tool outputs" in f for f in s["fixes"]):
170
+ steps.append("Ask for trimmed output (`| tail -50`) or run the command in a subagent.")
171
+ if any("No subagents" in f for f in s["fixes"]):
172
+ steps.append("Start research with: \"Use an Explore subagent to find … and report back "
173
+ "in 10 lines.\"")
174
+ return _fix(steps,
175
+ "Summarise the files you re-read most in this session into CLAUDE.md under "
176
+ "'Key files' (2–3 lines each: purpose, main functions, when to open). Keep it "
177
+ "short.", f"the {s.get('project')} repo")
178
+
179
+
180
+ # ---------------------------------------------------------------- memory suggestions
181
+ def for_memory(m):
182
+ ex = (m.get("examples") or [m["text"]])
183
+ if m["kind"] == "security":
184
+ return _fix([
185
+ "Rotate the exposed credential now; it is stored in plain text in ~/.claude/projects.",
186
+ "Put credentials in an untracked .env file (add it to .gitignore).",
187
+ "Tell Claude the variable name, never the value: \"use $DB_URL from .env\".",
188
+ ], "Create a .env.example listing the variable names this project needs (no values), "
189
+ "make sure .env is in .gitignore, and update the code/docs to read from env vars.",
190
+ "the affected repo")
191
+ if m["kind"] == "template":
192
+ return _fix([
193
+ "Create .claude/skills/<name>/SKILL.md in the repo (or ~/.claude/skills for global).",
194
+ "Paste the prompt below; afterwards run it as /<name>.",
195
+ ], "Turn this repeated prompt into a skill at .claude/skills/<pick-a-name>/SKILL.md with "
196
+ "a one-line description and the instructions as the body. Replace the parts that "
197
+ "change each time with $ARGUMENTS. The prompt:\n\n" + m["text"], "that repo")
198
+ if m["kind"] == "reference":
199
+ return _fix(["Paste the prompt below in the project where you use it."],
200
+ f"Add this to CLAUDE.md under 'Related locations' with a one-line description "
201
+ f"of what it is, so I can refer to it by name: {m['text']}", "that repo")
202
+ if m.get("already_saved"):
203
+ return _fix(["Paste the prompt below in any session."],
204
+ "I keep repeating this kind of instruction even though it's saved. Examples: "
205
+ + " | ".join(f'"{e[:120]}"' for e in ex[:3])
206
+ + ". Find the matching rule in ~/.claude/CLAUDE.md or my memory and rewrite "
207
+ "it to be explicit and unambiguous (or update it if these show my "
208
+ "preference changed). One or two lines.", "any session")
209
+ return _fix(["Paste the prompt below in any session."],
210
+ "remember: " + ("; ".join(e[:120] for e in ex[:2]))
211
+ + " — save this as a standing preference.", "any session")
212
+
213
+
214
+ def attach(result):
215
+ """Add a `playbook` to every actionable item in a diagnose() result."""
216
+ for r in result.get("recommendations", []):
217
+ r["playbook"] = for_recommendation(r)
218
+ for p in result.get("projects", []):
219
+ for i in p["issues"]:
220
+ i["playbook"] = for_project_issue(i, p)
221
+ for i in (result.get("global") or {}).get("issues", []):
222
+ i["playbook"] = for_global_issue(i)
223
+ for s in result.get("live_sessions", []):
224
+ s["playbook"] = for_live(s)
225
+ for s in result.get("session_health", []):
226
+ s["playbook"] = for_past_session(s)
227
+ for m in result.get("memory_suggestions", []):
228
+ m["playbook"] = for_memory(m)
229
+ return result
@@ -0,0 +1,74 @@
1
+ """Cost engine. Pricing lives in config/pricing.json and is never mixed into usage data.
2
+
3
+ Claude Code transcripts contain token counts but no billed amount, so every figure
4
+ produced here is an ESTIMATE. Callers must label it as such.
5
+ """
6
+ import json
7
+ import os
8
+
9
+ from .paths import ROOT, PRICING_PATH
10
+
11
+ M = 1_000_000.0
12
+
13
+
14
+ FREE = {"display_name": None, "tier": "free", "input": 0.0, "output": 0.0, "cache_read": 0.0,
15
+ "cache_write_5m": 0.0, "cache_write_1h": 0.0}
16
+
17
+
18
+ class Pricing:
19
+ def __init__(self, path=PRICING_PATH):
20
+ with open(path) as fh:
21
+ self.raw = json.load(fh)
22
+ self.models = self.raw.get("models", {})
23
+ self.default = self.raw.get("default_model_pricing", {})
24
+ self.updated = self.raw.get("updated")
25
+ self.source = self.raw.get("source")
26
+
27
+ def rates(self, model):
28
+ if model in self.models:
29
+ return self.models[model]
30
+ # An unlisted non-Claude model reached through Claude Code (e.g. a local Ollama or
31
+ # free OpenRouter model via a claude-qwen launcher) has no Anthropic price: $0.
32
+ if model and not model.startswith("claude") and model not in ("unknown",):
33
+ return FREE
34
+ return self.default
35
+
36
+ def is_known(self, model):
37
+ return model in self.models
38
+
39
+ def display_name(self, model):
40
+ return self.rates(model).get("display_name") or model
41
+
42
+ def tier(self, model):
43
+ return self.rates(model).get("tier", "unknown")
44
+
45
+ def context_window(self, model):
46
+ return self.rates(model).get("context_window")
47
+
48
+ def estimate(self, model, input_tokens=0, output_tokens=0, cache_read=0,
49
+ cache_write_5m=0, cache_write_1h=0):
50
+ """Return estimated USD for one request."""
51
+ r = self.rates(model)
52
+ g = lambda k, d=0.0: float(r.get(k, self.default.get(k, d)))
53
+ return (
54
+ input_tokens * g("input")
55
+ + output_tokens * g("output")
56
+ + cache_read * g("cache_read")
57
+ + cache_write_5m * g("cache_write_5m")
58
+ + cache_write_1h * g("cache_write_1h")
59
+ ) / M
60
+
61
+ def uncached_baseline(self, model, cache_read, cache_write_5m, cache_write_1h):
62
+ """What the cached tokens would have cost as plain input tokens.
63
+
64
+ Used to estimate caching savings: cache reads are billed at a discount and
65
+ writes at a premium, so savings = (reads+writes at input rate) - (actual).
66
+ """
67
+ r = self.rates(model)
68
+ g = lambda k, d=0.0: float(r.get(k, self.default.get(k, d)))
69
+ total = cache_read + cache_write_5m + cache_write_1h
70
+ no_cache = total * g("input")
71
+ with_cache = (cache_read * g("cache_read")
72
+ + cache_write_5m * g("cache_write_5m")
73
+ + cache_write_1h * g("cache_write_1h"))
74
+ return no_cache / M, with_cache / M