appledger 0.2.1__tar.gz → 0.2.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: appledger
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: The ledger for the AI app explosion: honest security checks, in plain language, for apps your people build with AI.
5
5
  Project-URL: Homepage, https://appledger.net/
6
6
  Project-URL: Repository, https://github.com/contentasoftware/appledger
@@ -45,16 +45,6 @@ Each run produces, in `<your-app>/.appledger/`:
45
45
  - `fix-prompt.md`: a ready-made fix request for your coding agent
46
46
  - `findings.json`: the same findings for tools and dashboards
47
47
 
48
- ## AI effort behind the app
49
-
50
- Every check also reports how many AI tokens went into the app and what they would cost at API list prices. It reads the coding agents' own logs on your computer (Claude Code in `~/.claude/projects`, Codex in `~/.codex/sessions`), counts sessions that ran in the app's folder, and prices each reply by model, including cache reads and writes. These logs never leave your computer. On a flat-rate plan (Claude Pro, Max or Team) you pay your plan price instead, so the figure shows the work's value at API rates.
51
-
52
- ```
53
- python -m appledger path/to/your-app --ai-cost
54
- ```
55
-
56
- Codex support follows Codex's documented log format and hasn't been tested against real Codex logs yet.
57
-
58
48
  ## Two ways to run
59
49
 
60
50
  | Mode | What you install | Where your code goes |
@@ -108,7 +98,7 @@ Version 0.2.0 (upload preview, consent, secret-file exclusion, scheduling) is be
108
98
 
109
99
  ## Company dashboard
110
100
 
111
- Create a free account at [appledger.net](https://appledger.net/app/signup.php) to keep every app your people check in one ledger: owner, grade, score trend, who may use it and the AI cost behind it. Create a connection key under "Connect a computer", then save it on each computer:
101
+ Create a free account at [appledger.net](https://appledger.net/app/signup.php) to keep every app your people check in one ledger: owner, grade, score trend, who may use it and which AI tool built it. Create a connection key under "Connect a computer", then save it on each computer:
112
102
 
113
103
  ```
114
104
  python -m appledger --connect al_your_key
@@ -116,7 +106,7 @@ python -m appledger --connect al_your_key
116
106
 
117
107
  Or tell Claude: "Connect AppLedger with this key: al_…". The key is saved in `~/.appledger/key`; `APPLEDGER_KEY` overrides it and `--disconnect` removes it.
118
108
 
119
- From then on each check also saves a summary to your dashboard: the app's name, score, grade, problem counts, up to 3 titles of the most serious problems, the scanners used and the AI cost totals. Never the code.
109
+ From then on each check also saves a summary to your dashboard: the app's name, score, grade, problem counts, the list of problems found (level and short title, up to 60; never code or file paths), and the scanners used. Never the code.
120
110
 
121
111
  You can also add an app in the web app with **Add app**: register it without code, or upload a zip to check it in the browser (the zip is scanned and deleted the same way).
122
112
 
@@ -20,16 +20,6 @@ Each run produces, in `<your-app>/.appledger/`:
20
20
  - `fix-prompt.md`: a ready-made fix request for your coding agent
21
21
  - `findings.json`: the same findings for tools and dashboards
22
22
 
23
- ## AI effort behind the app
24
-
25
- Every check also reports how many AI tokens went into the app and what they would cost at API list prices. It reads the coding agents' own logs on your computer (Claude Code in `~/.claude/projects`, Codex in `~/.codex/sessions`), counts sessions that ran in the app's folder, and prices each reply by model, including cache reads and writes. These logs never leave your computer. On a flat-rate plan (Claude Pro, Max or Team) you pay your plan price instead, so the figure shows the work's value at API rates.
26
-
27
- ```
28
- python -m appledger path/to/your-app --ai-cost
29
- ```
30
-
31
- Codex support follows Codex's documented log format and hasn't been tested against real Codex logs yet.
32
-
33
23
  ## Two ways to run
34
24
 
35
25
  | Mode | What you install | Where your code goes |
@@ -83,7 +73,7 @@ Version 0.2.0 (upload preview, consent, secret-file exclusion, scheduling) is be
83
73
 
84
74
  ## Company dashboard
85
75
 
86
- Create a free account at [appledger.net](https://appledger.net/app/signup.php) to keep every app your people check in one ledger: owner, grade, score trend, who may use it and the AI cost behind it. Create a connection key under "Connect a computer", then save it on each computer:
76
+ Create a free account at [appledger.net](https://appledger.net/app/signup.php) to keep every app your people check in one ledger: owner, grade, score trend, who may use it and which AI tool built it. Create a connection key under "Connect a computer", then save it on each computer:
87
77
 
88
78
  ```
89
79
  python -m appledger --connect al_your_key
@@ -91,7 +81,7 @@ python -m appledger --connect al_your_key
91
81
 
92
82
  Or tell Claude: "Connect AppLedger with this key: al_…". The key is saved in `~/.appledger/key`; `APPLEDGER_KEY` overrides it and `--disconnect` removes it.
93
83
 
94
- From then on each check also saves a summary to your dashboard: the app's name, score, grade, problem counts, up to 3 titles of the most serious problems, the scanners used and the AI cost totals. Never the code.
84
+ From then on each check also saves a summary to your dashboard: the app's name, score, grade, problem counts, the list of problems found (level and short title, up to 60; never code or file paths), and the scanners used. Never the code.
95
85
 
96
86
  You can also add an app in the web app with **Add app**: register it without code, or upload a zip to check it in the browser (the zip is scanned and deleted the same way).
97
87
 
@@ -11,7 +11,7 @@ Full guide: https://appledger.net/docs/
11
11
 
12
12
  - "Is my app safe to share?"
13
13
  - "Add this app to AppLedger."
14
- - "How much did it cost to build this app with AI?"
14
+ - "Check my app every week."
15
15
 
16
16
  ## Install
17
17
 
@@ -37,7 +37,7 @@ By default (hosted mode) **your project's source files are uploaded to the AppLe
37
37
 
38
38
  **You confirm first.** Before the first upload of a folder, your agent shows what would be sent (`preview_upload`) and asks you. If the code belongs to your employer or a client, make sure you're allowed to upload it, or use local mode.
39
39
 
40
- **Local mode** keeps the code on your computer: set `APPLEDGER_MODE=local` and install [Trivy](https://trivy.dev) and [Opengrep](https://github.com/opengrep/opengrep/releases). If the computer is connected to a dashboard, only a summary is saved there (app name, score, grade, problem counts, up to 3 top problem titles, scanners used, AI cost totals). Ask for "don't save to the dashboard" to skip even that.
40
+ **Local mode** keeps the code on your computer: set `APPLEDGER_MODE=local` and install [Trivy](https://trivy.dev) and [Opengrep](https://github.com/opengrep/opengrep/releases). If the computer is connected to a dashboard, only a summary is saved there (app name, score, grade, problem counts, the list of problems found (level and short title, up to 60; never code or file paths), scanners used). Ask for "don't save to the dashboard" to skip even that.
41
41
 
42
42
  Results are written to a `.appledger/` folder inside your project, which is excluded from version control.
43
43
 
@@ -48,6 +48,6 @@ Results are written to a `.appledger/` folder inside your project, which is excl
48
48
  ## What's included
49
49
 
50
50
  - `check-my-app` skill: asks before uploading, runs the check, explains it, fixes problems with your permission and checks again
51
- - `appledger` MCP server with these tools: `preview_upload` (what would be sent, without sending), `check_app`, `get_fix_prompt`, `connect_dashboard`, `scanner_status`. The plugin never reads your Claude Code or Codex logs; the AI cost estimate (`python -m appledger PATH --ai-cost`) is in the command line tool only
51
+ - `appledger` MCP server with these tools: `preview_upload` (what would be sent, without sending), `check_app`, `get_fix_prompt`, `connect_dashboard`, `scanner_status`
52
52
 
53
53
  More: https://appledger.net/
@@ -1,44 +1,44 @@
1
- [build-system]
2
- requires = ["hatchling>=1.27"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "appledger"
7
- version = "0.2.1"
8
- description = "The ledger for the AI app explosion: honest security checks, in plain language, for apps your people build with AI."
9
- readme = "README.md"
10
- license = "MIT"
11
- license-files = ["LICENSE"]
12
- requires-python = ">=3.10"
13
- authors = [{ name = "Contenta Software" }]
14
- keywords = ["security", "mcp", "claude", "codex", "trivy", "opengrep", "vibe-coding", "governance"]
15
- classifiers = [
16
- "Development Status :: 3 - Alpha",
17
- "Intended Audience :: Developers",
18
- "Intended Audience :: Information Technology",
19
- "Operating System :: OS Independent",
20
- "Programming Language :: Python :: 3",
21
- "Topic :: Security",
22
- "Topic :: Software Development :: Quality Assurance",
23
- ]
24
- dependencies = [
25
- "mcp>=1.20,<2",
26
- "pyyaml>=6",
27
- "packaging>=23",
28
- "anyio>=4",
29
- ]
30
-
31
- [project.urls]
32
- Homepage = "https://appledger.net/"
33
- Repository = "https://github.com/contentasoftware/appledger"
34
- Privacy = "https://appledger.net/privacy.html"
35
-
36
- [project.scripts]
37
- appledger = "appledger.scanner:main"
38
- appledger-mcp = "appledger.server:main"
39
-
40
- [tool.hatch.build.targets.wheel]
41
- packages = ["src/appledger"]
42
-
43
- [tool.hatch.build.targets.sdist]
44
- include = ["src/appledger", "README.md", "LICENSE", "pyproject.toml"]
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "appledger"
7
+ version = "0.2.2"
8
+ description = "The ledger for the AI app explosion: honest security checks, in plain language, for apps your people build with AI."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Contenta Software" }]
14
+ keywords = ["security", "mcp", "claude", "codex", "trivy", "opengrep", "vibe-coding", "governance"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Information Technology",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Security",
22
+ "Topic :: Software Development :: Quality Assurance",
23
+ ]
24
+ dependencies = [
25
+ "mcp>=1.20,<2",
26
+ "pyyaml>=6",
27
+ "packaging>=23",
28
+ "anyio>=4",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://appledger.net/"
33
+ Repository = "https://github.com/contentasoftware/appledger"
34
+ Privacy = "https://appledger.net/privacy.html"
35
+
36
+ [project.scripts]
37
+ appledger = "appledger.scanner:main"
38
+ appledger-mcp = "appledger.server:main"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/appledger"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src/appledger", "README.md", "LICENSE", "pyproject.toml"]
@@ -2,5 +2,5 @@
2
2
 
3
3
  from .scanner import ScanError, check, doctor
4
4
 
5
- __version__ = "0.2.1"
5
+ __version__ = "0.2.2"
6
6
  __all__ = ["ScanError", "check", "doctor", "__version__"]
@@ -8,7 +8,8 @@ rest isn't. The upload goes over HTTPS, is scanned with Trivy and Opengrep, and
8
8
  the server right after.
9
9
 
10
10
  In local mode nothing is uploaded; with a connection key, record_check sends only a summary
11
- (name, score, problem counts and titles, AI cost) to the dashboard. Never the code.
11
+ (name, score, problem counts, the problems' levels and titles) to the dashboard. Never the code
12
+ or file paths.
12
13
  """
13
14
 
14
15
  import io
@@ -46,6 +47,8 @@ DATA_SUFFIXES = {
46
47
  VAULT_SUFFIXES = {".yml", ".yaml", ".txt", ".cfg", ".ini", ".conf", ".json", ""}
47
48
  DATA_FOLDER_WORDS = ("data", "dump", "export", "fixture", "sample", "traffic")
48
49
  MAX_DATA_JSON_BYTES = 100_000
50
+ MAX_RECORDED_ISSUES = 60
51
+ LEVEL_RANK = {"blocker": 0, "important": 1, "minor": 2}
49
52
 
50
53
 
51
54
  def looks_like_data(rel, size):
@@ -169,15 +172,6 @@ def forget_connection_key():
169
172
  KEY_FILE.unlink(missing_ok=True)
170
173
 
171
174
 
172
- def ai_fields(usage):
173
- """The AI effort totals that go to the dashboard (never the logs themselves)."""
174
- tools = [t["tool"] for t in (usage or {}).get("tools", []) if t.get("this_folder")]
175
- spent = bool(usage and usage.get("tokens_total"))
176
- return {"ai_tool": ", ".join(tools) or None,
177
- "ai_tokens": usage["tokens_total"] if spent else None,
178
- "ai_cost_usd": usage["cost_usd"] if spent else None}
179
-
180
-
181
175
  def clean_name(app_name, app_dir):
182
176
  return " ".join((app_name or Path(app_dir).name).split())
183
177
 
@@ -198,11 +192,11 @@ def error_message(e):
198
192
  return ""
199
193
 
200
194
 
201
- def hosted_scan(app_dir, app_name=None, usage=None, plan=None, save_to_dashboard=True, local_findings=None):
195
+ def hosted_scan(app_dir, app_name=None, plan=None, save_to_dashboard=True, local_findings=None):
202
196
  """Returns (findings, scanner names, ledger) from the AppLedger service.
203
197
 
204
198
  Sends only the files in upload_plan (pass plan to reuse one already shown to the user). With
205
- a connection key and save_to_dashboard, the app's name, score and AI cost totals are also
199
+ a connection key and save_to_dashboard, the app's name and score are also
206
200
  saved to the organisation's dashboard, and ledger says where. Otherwise ledger is None and
207
201
  the key isn't sent. local_findings (found on this computer, such as committed secrets files)
208
202
  are sent as level and title only, so the dashboard's score matches the local report.
@@ -210,9 +204,9 @@ def hosted_scan(app_dir, app_name=None, usage=None, plan=None, save_to_dashboard
210
204
  data, count, skipped = build_zip(app_dir, plan)
211
205
  boundary = uuid.uuid4().hex
212
206
  key = connection_key() if save_to_dashboard else None
213
- fields = {"app_name": clean_name(app_name, app_dir), **ai_fields(usage)} if key else {}
207
+ fields = {"app_name": clean_name(app_name, app_dir)} if key else {}
214
208
  if key and local_findings:
215
- fields["local_findings"] = json.dumps([{"level": f["level"], "title": f["title"]} for f in local_findings][:20])
209
+ fields["local_findings"] = json.dumps([{"level": f["level"], "title": summary_title(f)} for f in local_findings][:20])
216
210
  headers = {
217
211
  "Content-Type": f"multipart/form-data; boundary={boundary}",
218
212
  "Accept": "application/json",
@@ -235,10 +229,31 @@ def hosted_scan(app_dir, app_name=None, usage=None, plan=None, save_to_dashboard
235
229
  return result["findings"], result["scanners"], result.get("ledger")
236
230
 
237
231
 
238
- def record_payload(summary, counts, usage=None):
239
- """The summary a local check sends to the dashboard: no code, no file contents, no logs.
232
+ def summary_title(finding, limit=200):
233
+ """A problem's title for the dashboard: one line, without the file path some titles name in brackets."""
234
+ title = finding.get("title") or ""
235
+ where = (finding.get("where") or "").split(":")[0]
236
+ if where:
237
+ title = title.replace(f" ({where})", "")
238
+ return " ".join(title.split())[:limit]
239
+
240
+
241
+ def summary_issues(findings, limit=MAX_RECORDED_ISSUES):
242
+ """The problems a dashboard summary lists, most serious first: level, title and topic only."""
243
+ issues = []
244
+ for f in sorted(findings, key=lambda f: LEVEL_RANK.get(f.get("level"), 9)):
245
+ title = summary_title(f)
246
+ if f.get("level") in LEVEL_RANK and title:
247
+ topic = " ".join(str(f.get("topic") or "").split())[:120] or None
248
+ issues.append({"level": f["level"], "title": title, "topic": topic})
249
+ return issues[:limit]
250
+
251
+
252
+ def record_payload(summary, counts):
253
+ """The summary a local check sends to the dashboard: no code, no file contents or paths.
240
254
 
241
- counts uses the plain level keys (blocker, important, minor).
255
+ counts uses the plain level keys (blocker, important, minor). issues lists up to 60 problems
256
+ (level, title, topic), most serious first.
242
257
  """
243
258
  return {
244
259
  "app_name": clean_name(summary["app"], summary["app"]),
@@ -246,8 +261,8 @@ def record_payload(summary, counts, usage=None):
246
261
  "grade": summary["grade"],
247
262
  "scanners": list(summary["scanners"]),
248
263
  "counts": {lvl: int(counts.get(lvl, 0)) for lvl in ("blocker", "important", "minor")},
249
- "top_issues": [f["title"] for f in summary["findings"] if f["level"] == "blocker"][:3],
250
- **ai_fields(usage),
264
+ "top_issues": [summary_title(f, 160) for f in summary["findings"] if f["level"] == "blocker"][:3],
265
+ "issues": summary_issues(summary["findings"]),
251
266
  "client_version": __version__,
252
267
  }
253
268
 
@@ -647,36 +647,7 @@ def write_fix_prompt(findings, app_name, scanners, path):
647
647
  Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
648
648
 
649
649
 
650
- def usage_section(usage):
651
- """HTML for the 'AI effort' part of the report, or '' when no agent logs were found."""
652
- if not usage:
653
- return ""
654
- e = html.escape
655
- rows = []
656
- for tool in usage["tools"]:
657
- here = tool["this_folder"]
658
- if not here:
659
- continue
660
- for model, v in here["by_model"].items():
661
- cost = f"${v['cost_usd']:,.2f}" if v.get("cost_usd") else "not priced"
662
- rows.append(f"<tr><td>{e(tool['tool'])}</td><td>{e(model)}</td><td>{v['messages']:,}</td>"
663
- f"<td>{v['input'] + v['cache_write'] + v['cache_read'] + v['output']:,}</td><td>{cost}</td></tr>")
664
- parent = [t["parent_folders"] for t in usage["tools"] if t.get("parent_folders")]
665
- parent_note = (f"<p class=\"tech\">Another {sum(p['tokens_total'] for p in parent):,} tokens "
666
- f"(${sum(p['cost_usd'] for p in parent):,.2f}) were used in sessions started in a parent folder; "
667
- "they may include other work, so they're not counted above.</p>") if parent else ""
668
- return f"""
669
- <section class="usage">
670
- <h2>AI effort that went into this app</h2>
671
- <p><strong>{usage['tokens_total']:,} tokens</strong> over {usage['sessions']} session{'s' if usage['sessions'] != 1 else ''},
672
- worth about <strong>${usage['cost_usd']:,.2f}</strong> at API list prices.</p>
673
- <table><thead><tr><th>Tool</th><th>Model</th><th>Replies</th><th>Tokens</th><th>Est. cost</th></tr></thead>
674
- <tbody>{''.join(rows)}</tbody></table>
675
- <p class="tech">{e(usage['note'])} Prices: {e(usage['prices'])}.</p>{parent_note}
676
- </section>"""
677
-
678
-
679
- def write_report(findings, app_name, scanners, path, usage=None):
650
+ def write_report(findings, app_name, scanners, path):
680
651
  e = html.escape
681
652
  score = health_score(findings)
682
653
  grade = "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 55 else "D" if score >= 30 else "F"
@@ -729,10 +700,6 @@ h1,h2,h3 {{ margin:0; line-height:1.2 }} p {{ margin:0 }}
729
700
  button {{ font:inherit; padding:8px 14px; border-radius:6px; border:1px solid var(--line); background:var(--surface); color:var(--ink); cursor:pointer }}
730
701
  button:focus-visible {{ outline:2px solid var(--accent); outline-offset:2px }}
731
702
  .fixbox {{ background:var(--surface); border:1px dashed var(--accent); border-radius:10px; padding:18px; display:flex; flex-direction:column; gap:10px }}
732
- .usage {{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:18px; display:flex; flex-direction:column; gap:10px; overflow-x:auto }}
733
- .usage table {{ border-collapse:collapse; width:100%; min-width:460px; font-size:14px; font-variant-numeric:tabular-nums }}
734
- .usage th, .usage td {{ text-align:left; padding:6px 8px; border-bottom:1px solid var(--line) }}
735
- .usage th {{ font:600 11px/1 ui-monospace,Consolas,monospace; text-transform:uppercase; letter-spacing:.06em; color:var(--muted) }}
736
703
  @media (max-width:620px) {{ .summary {{ grid-template-columns:1fr }} .tiers li {{ grid-template-columns:1fr; gap:2px }} }}
737
704
  </style></head>
738
705
  <body><main>
@@ -748,7 +715,7 @@ button:focus-visible {{ outline:2px solid var(--accent); outline-offset:2px }}
748
715
  <span class="pill minor">{counts['minor']} good to know</span>
749
716
  </div></div>
750
717
  </section>
751
- <section><h2 style="margin-bottom:12px">Who can use it right now</h2><ul class="tiers">{tiers}</ul></section>{usage_section(usage)}
718
+ <section><h2 style="margin-bottom:12px">Who can use it right now</h2><ul class="tiers">{tiers}</ul></section>
752
719
  <section class="fixbox">
753
720
  <h2>Fix it for me</h2>
754
721
  <p>AppLedger wrote a fix request for the coding agent that built this app (Claude Code, Cursor, Codex…). Open <code>fix-prompt.md</code>, paste it into the agent, then run the check again.</p>
@@ -782,15 +749,6 @@ def doctor():
782
749
  }
783
750
 
784
751
 
785
- def ai_usage_for(app_dir):
786
- """Token use and cost from local agent logs; never lets a log problem break the check."""
787
- from .usage import estimate_usage
788
- try:
789
- return estimate_usage(app_dir)
790
- except (OSError, ValueError, KeyError, TypeError):
791
- return None
792
-
793
-
794
752
  def grade_for(score):
795
753
  return "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 55 else "D" if score >= 30 else "F"
796
754
 
@@ -817,7 +775,7 @@ def scan(app_dir, sonar_dir=None, code_scan=True):
817
775
 
818
776
 
819
777
  def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True, mode=None,
820
- confirm_upload=False, save_to_dashboard=True, read_ai_logs=True):
778
+ confirm_upload=False, save_to_dashboard=True):
821
779
  """Check an app folder and write report.html, fix-prompt.md and findings.json.
822
780
 
823
781
  mode "hosted" (the default) sends the project's files to the AppLedger scanning service, so
@@ -850,12 +808,10 @@ def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True,
850
808
 
851
809
  out = Path(out_dir) if out_dir else app_dir / OUTPUT_DIR_NAME
852
810
  out.mkdir(parents=True, exist_ok=True)
853
- # The AI cost comes from coding agents' logs on this computer; the MCP server turns this off.
854
- usage = ai_usage_for(app_dir) if read_ai_logs else None
855
811
  ledger = None
856
812
  local_findings = findings_for_secret_files(plan["secret_files_not_ignored"])
857
813
  if hosted:
858
- findings, scanners, ledger = hosted_scan(app_dir, app_name, usage, plan, save_to_dashboard, local_findings)
814
+ findings, scanners, ledger = hosted_scan(app_dir, app_name, plan, save_to_dashboard, local_findings)
859
815
  else:
860
816
  findings, scanners = scan(app_dir, sonar_dir, code_scan)
861
817
  findings = dedupe(findings + local_findings)
@@ -865,7 +821,7 @@ def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True,
865
821
  if not hosted and save_to_dashboard and connection_key():
866
822
  # Local mode sends no code: only the summary below, so the dashboard still shows the app.
867
823
  ledger = record_check(record_payload({"app": app_name, "score": score, "grade": grade_for(score),
868
- "scanners": scanners, "findings": findings}, counts, usage))
824
+ "scanners": scanners, "findings": findings}, counts))
869
825
  summary = {
870
826
  "app": app_name, "score": score, "grade": grade_for(score), "scanners": scanners,
871
827
  "counts": {LEVELS[k]["label"]: v for k, v in counts.items()},
@@ -874,12 +830,11 @@ def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True,
874
830
  "findings": findings,
875
831
  "report": str(out / "report.html"), "fix_prompt": str(out / "fix-prompt.md"),
876
832
  "checked_at": datetime.now().isoformat(timespec="seconds"),
877
- "ai_usage": usage,
878
833
  "ledger": ledger,
879
834
  }
880
835
  (out / "findings.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
881
836
  write_fix_prompt(findings, app_name, scanners, out / "fix-prompt.md")
882
- write_report(findings, app_name, scanners, out / "report.html", summary["ai_usage"])
837
+ write_report(findings, app_name, scanners, out / "report.html")
883
838
  (out / ".gitignore").write_text("*\n", encoding="utf-8") # keep reports out of version control
884
839
  return summary
885
840
 
@@ -939,8 +894,6 @@ def main():
939
894
  ap.add_argument("--sonar-dir", help="folder with sonar-issues.json and sonar-hotspots.json")
940
895
  ap.add_argument("--no-code-scan", action="store_true", help="skip the Opengrep code scan")
941
896
  ap.add_argument("--doctor", action="store_true", help="show which scanners are available")
942
- ap.add_argument("--ai-cost", action="store_true",
943
- help="only show how many AI tokens, and what they'd cost, went into this folder")
944
897
  ap.add_argument("--local", action="store_true",
945
898
  help="run Trivy and Opengrep on this machine instead of the AppLedger service")
946
899
  ap.add_argument("--yes", action="store_true",
@@ -972,18 +925,6 @@ def main():
972
925
  print(f"Connected. Key saved in {where}. Every check from this computer now appears in {DASHBOARD_URL}")
973
926
  return
974
927
 
975
- if args.ai_cost:
976
- usage = ai_usage_for(Path(args.app_dir).resolve())
977
- if not usage:
978
- print("No Claude Code or Codex logs on this computer mention this folder.")
979
- return
980
- print(f"{usage['tokens_total']:,} tokens over {usage['sessions']} sessions, "
981
- f"about ${usage['cost_usd']:,.2f} at API list prices.")
982
- for tool in usage["tools"]:
983
- for model, v in (tool["this_folder"] or {}).get("by_model", {}).items():
984
- print(f" {tool['tool']:12} {model:22} {v['messages']:>6,} replies ${v.get('cost_usd', 0):>9,.2f}")
985
- print(usage["note"])
986
- return
987
928
  if args.doctor:
988
929
  for tool, where in doctor().items():
989
930
  print(f"{tool:15} {where or 'not found'}")
@@ -6,8 +6,7 @@ scanners need to be installed. The first check of a folder needs the user's go-a
6
6
  returns needs_confirmation with a preview until it's called with confirm_upload=True.
7
7
  Set APPLEDGER_MODE=local to run Trivy and Opengrep on this machine instead, in which case no
8
8
  code leaves the computer. Tools: check_app, preview_upload, get_fix_prompt, connect_dashboard
9
- and scanner_status. It never reads Claude Code or Codex logs: the AI cost estimate is only in the
10
- command line tool (python -m appledger PATH --ai-cost).
9
+ and scanner_status.
11
10
  """
12
11
 
13
12
  import json
@@ -73,7 +72,7 @@ async def check_app(path: str, app_name: str | None = None, confirm_upload: bool
73
72
  save_to_dashboard: false to keep this check out of the user's AppLedger dashboard.
74
73
  """
75
74
  try:
76
- result = await anyio.to_thread.run_sync(lambda: check(path, app_name=app_name, confirm_upload=confirm_upload, read_ai_logs=False,
75
+ result = await anyio.to_thread.run_sync(lambda: check(path, app_name=app_name, confirm_upload=confirm_upload,
77
76
  save_to_dashboard=save_to_dashboard))
78
77
  except ConsentRequired as e:
79
78
  preview = upload_preview(e.plan)
@@ -1,219 +0,0 @@
1
- """Estimate how many AI tokens, and what they would cost, went into building a project.
2
-
3
- Reads the coding agents' own logs on this computer, so the numbers are measured, not guessed:
4
-
5
- Claude Code ~/.claude/projects/<folder>/*.jsonl (CLAUDE_CONFIG_DIR overrides ~/.claude)
6
- Codex ~/.codex/sessions/**/*.jsonl (CODEX_HOME overrides ~/.codex)
7
-
8
- Costs use Anthropic's API list prices. People on a flat-rate plan (Claude Pro/Max/Team) pay
9
- their plan price instead, so the figure shows the work's value at API rates, not their bill.
10
- Nothing here is sent anywhere.
11
- """
12
-
13
- import json
14
- import os
15
- import re
16
- from collections import defaultdict
17
- from pathlib import Path
18
-
19
- PRICES_SOURCE = "https://platform.claude.com/docs/en/about-claude/pricing (checked 2026-10-12)"
20
-
21
- # USD per million tokens: input, 5-minute cache write, 1-hour cache write, cache read, output.
22
- CLAUDE_PRICES = {
23
- "claude-fable-5-1": (10, 12.5, 20, 0.25, 50),
24
- "claude-mythos-5-1": (10, 12.5, 20, 0.25, 50),
25
- "claude-fable-5": (10, 12.5, 20, 1, 50),
26
- "claude-mythos-5": (10, 12.5, 20, 1, 50),
27
- "claude-opus-5-5": (4, 5, 8, 0.20, 20),
28
- "claude-opus-5": (5, 6.25, 10, 0.50, 25),
29
- "claude-opus-4-8": (5, 6.25, 10, 0.50, 25),
30
- "claude-opus-4-7": (5, 6.25, 10, 0.50, 25),
31
- "claude-opus-4-6": (5, 6.25, 10, 0.50, 25),
32
- "claude-opus-4-5": (5, 6.25, 10, 0.50, 25),
33
- "claude-opus-4-1": (15, 18.75, 30, 1.50, 75),
34
- "claude-opus-4": (15, 18.75, 30, 1.50, 75),
35
- "claude-sonnet-5-5": (2, 2.5, 4, 0.10, 10),
36
- "claude-sonnet-5": (2, 2.5, 4, 0.20, 10),
37
- "claude-sonnet-4-6": (3, 3.75, 6, 0.30, 15),
38
- "claude-sonnet-4-5": (3, 3.75, 6, 0.30, 15),
39
- "claude-sonnet-4": (3, 3.75, 6, 0.30, 15),
40
- "claude-haiku-5-5": (0.10, 0.125, 0.20, 0.01, 0.50),
41
- "claude-haiku-4-5": (1, 1.25, 2, 0.10, 5),
42
- "claude-3-5-haiku": (0.80, 1, 1.60, 0.08, 4),
43
- }
44
- HAIKU_5_5_LONG = (0.50, 0.625, 1, 0.05, 2.50) # prompts over 100,000 tokens
45
- FAST_MODE_FACTOR = 2.0 # fast mode on Opus 5.5 / 5 / 4.8 is billed at 2x the standard rates
46
- US_ONLY_FACTOR = 1.1 # inference_geo "us" on Claude 4.6 and later
47
-
48
-
49
- def claude_home():
50
- return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
51
-
52
-
53
- def codex_home():
54
- return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
55
-
56
-
57
- def price_key(model):
58
- """Map a logged model name (dated, prefixed or regional) to a price table key."""
59
- name = (model or "").lower().split("/")[-1]
60
- name = re.sub(r"^(?:[a-z]{2,4}\.)?anthropic\.", "", name) # Bedrock prefixes
61
- name = re.sub(r"-v\d+(?::\d+)?$", "", name) # Bedrock "-v1:0"
62
- name = re.sub(r"[-@]\d{8}$", "", name) # snapshot dates
63
- for key in sorted(CLAUDE_PRICES, key=len, reverse=True):
64
- if name == key or name.startswith(key + "-") or name.startswith(key + "@"):
65
- return key
66
- return None
67
-
68
-
69
- def message_cost(model, usage):
70
- key = price_key(model)
71
- if not key:
72
- return None
73
- cache_5m, cache_1h = cache_writes(usage)
74
- inp, cread, out = usage.get("input_tokens", 0), usage.get("cache_read_input_tokens", 0), usage.get("output_tokens", 0)
75
- rates = CLAUDE_PRICES[key]
76
- if key == "claude-haiku-5-5" and inp + cread + cache_5m + cache_1h > 100_000:
77
- rates = HAIKU_5_5_LONG
78
- cost = (inp * rates[0] + cache_5m * rates[1] + cache_1h * rates[2] + cread * rates[3] + out * rates[4]) / 1e6
79
- if usage.get("speed") == "fast":
80
- cost *= FAST_MODE_FACTOR
81
- if usage.get("inference_geo") == "us":
82
- cost *= US_ONLY_FACTOR
83
- return cost
84
-
85
-
86
- def cache_writes(usage):
87
- detail = usage.get("cache_creation") or {}
88
- if detail:
89
- return detail.get("ephemeral_5m_input_tokens", 0), detail.get("ephemeral_1h_input_tokens", 0)
90
- return usage.get("cache_creation_input_tokens", 0), 0
91
-
92
-
93
- def same_or_inside(child, parent):
94
- child, parent = os.path.normcase(os.path.abspath(child)), os.path.normcase(os.path.abspath(parent))
95
- return child == parent or child.startswith(parent.rstrip("\\/") + os.sep)
96
-
97
-
98
- def claude_code_usage(app_dir):
99
- """Token use from Claude Code sessions run inside app_dir (and, separately, in its parent folders)."""
100
- root = claude_home() / "projects"
101
- if not root.is_dir():
102
- return None
103
- encoded = re.sub(r"[^A-Za-z0-9]", "-", os.path.abspath(app_dir))
104
- candidates = [d for d in root.iterdir() if d.is_dir() and (
105
- d.name.lower().startswith(encoded.lower()) or encoded.lower().startswith(d.name.lower() + "-"))]
106
- messages = {} # message id -> (model, usage, cwd, session, timestamp); streaming repeats each message
107
- for folder in candidates:
108
- for log in folder.rglob("*.jsonl"):
109
- with open(log, encoding="utf-8", errors="replace") as fh:
110
- for line in fh:
111
- if '"usage"' not in line:
112
- continue
113
- try:
114
- entry = json.loads(line)
115
- except ValueError:
116
- continue
117
- msg = entry.get("message")
118
- if entry.get("type") != "assistant" or not isinstance(msg, dict) or not msg.get("usage"):
119
- continue
120
- key = msg.get("id") or f"{log.name}:{entry.get('uuid')}"
121
- previous = messages.get(key)
122
- if previous and previous[1].get("output_tokens", 0) >= msg["usage"].get("output_tokens", 0):
123
- continue
124
- messages[key] = (msg.get("model"), msg["usage"], entry.get("cwd") or "",
125
- entry.get("sessionId") or log.stem, entry.get("timestamp") or "")
126
- inside, parent = new_totals(), new_totals()
127
- for model, usage, cwd, session, ts in messages.values():
128
- if model == "<synthetic>":
129
- continue
130
- bucket = inside if cwd and same_or_inside(cwd, app_dir) else parent if cwd and same_or_inside(app_dir, cwd) else None
131
- if bucket is not None:
132
- add(bucket, model, usage, session, ts)
133
- return {"tool": "Claude Code", "this_folder": finish(inside), "parent_folders": finish(parent)}
134
-
135
-
136
- def codex_usage(app_dir):
137
- """Token use from Codex sessions run inside app_dir. Codex logs running totals per session."""
138
- root = codex_home() / "sessions"
139
- if not root.is_dir():
140
- return None
141
- totals = new_totals()
142
- for log in root.rglob("*.jsonl"):
143
- cwd, model, last, ts = "", "", None, ""
144
- with open(log, encoding="utf-8", errors="replace") as fh:
145
- for line in fh:
146
- try:
147
- entry = json.loads(line)
148
- except ValueError:
149
- continue
150
- payload = entry.get("payload") or {}
151
- if entry.get("type") in ("session_meta", "turn_context"):
152
- cwd = payload.get("cwd") or cwd
153
- model = payload.get("model") or model
154
- elif entry.get("type") == "event_msg" and payload.get("type") == "token_count":
155
- last = (payload.get("info") or {}).get("total_token_usage") or last
156
- ts = entry.get("timestamp") or ts
157
- if cwd and last and same_or_inside(cwd, app_dir):
158
- usage = {"input_tokens": last.get("input_tokens", 0) - last.get("cached_input_tokens", 0),
159
- "cache_read_input_tokens": last.get("cached_input_tokens", 0),
160
- "output_tokens": last.get("output_tokens", 0)}
161
- add(totals, model or "unknown", usage, log.stem, ts)
162
- return {"tool": "Codex", "this_folder": finish(totals), "parent_folders": None}
163
-
164
-
165
- def new_totals():
166
- return {"by_model": defaultdict(lambda: defaultdict(float)), "sessions": set(), "first": "", "last": ""}
167
-
168
-
169
- def add(totals, model, usage, session, ts):
170
- m = totals["by_model"][model]
171
- cache_5m, cache_1h = cache_writes(usage)
172
- m["input"] += usage.get("input_tokens", 0)
173
- m["cache_write"] += cache_5m + cache_1h
174
- m["cache_read"] += usage.get("cache_read_input_tokens", 0)
175
- m["output"] += usage.get("output_tokens", 0)
176
- m["messages"] += 1
177
- cost = message_cost(model, usage)
178
- if cost is None:
179
- m["unpriced"] += 1
180
- else:
181
- m["cost_usd"] += cost
182
- totals["sessions"].add(session)
183
- if ts:
184
- totals["first"] = min(totals["first"] or ts, ts)
185
- totals["last"] = max(totals["last"], ts)
186
-
187
-
188
- def finish(totals):
189
- if not totals["by_model"]:
190
- return None
191
- by_model = {model: {k: round(v, 4) if k == "cost_usd" else int(v) for k, v in vals.items()}
192
- for model, vals in totals["by_model"].items()}
193
- tokens = sum(v["input"] + v["cache_write"] + v["cache_read"] + v["output"] for v in by_model.values())
194
- return {
195
- "sessions": len(totals["sessions"]), "first": totals["first"][:10], "last": totals["last"][:10],
196
- "tokens_total": tokens,
197
- "output_tokens": sum(v["output"] for v in by_model.values()),
198
- "cost_usd": round(sum(v.get("cost_usd", 0) for v in by_model.values()), 2),
199
- "unpriced_messages": sum(v.get("unpriced", 0) for v in by_model.values()),
200
- "by_model": by_model,
201
- }
202
-
203
-
204
- def estimate_usage(app_dir):
205
- """Combine every agent log found for app_dir. Returns None when no logs mention this folder."""
206
- found = [r for r in (claude_code_usage(app_dir), codex_usage(app_dir))
207
- if r and (r["this_folder"] or r.get("parent_folders"))]
208
- if not found:
209
- return None
210
- here = [r["this_folder"] for r in found if r["this_folder"]]
211
- return {
212
- "tools": found,
213
- "tokens_total": sum(h["tokens_total"] for h in here),
214
- "cost_usd": round(sum(h["cost_usd"] for h in here), 2),
215
- "sessions": sum(h["sessions"] for h in here),
216
- "prices": PRICES_SOURCE,
217
- "note": ("Measured from the coding agents' logs on this computer and priced at API list prices. "
218
- "On a flat-rate plan (Claude Pro, Max or Team) you pay your plan price instead."),
219
- }
File without changes
File without changes
File without changes