appledger 0.2.0__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.
- {appledger-0.2.0 → appledger-0.2.2}/PKG-INFO +4 -14
- {appledger-0.2.0 → appledger-0.2.2}/README.md +3 -13
- {appledger-0.2.0 → appledger-0.2.2}/plugin/README.md +3 -3
- {appledger-0.2.0 → appledger-0.2.2}/pyproject.toml +44 -44
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/__init__.py +1 -1
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/client.py +34 -19
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/scanner.py +19 -66
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/server.py +2 -29
- appledger-0.2.0/src/appledger/usage.py +0 -219
- {appledger-0.2.0 → appledger-0.2.2}/.gitignore +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/LICENSE +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/plugin/LICENSE +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/__main__.py +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/consent.py +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/gitfiles.py +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/schedule.py +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/service.py +0 -0
- {appledger-0.2.0 → appledger-0.2.2}/src/appledger/uploadpolicy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: appledger
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -25,7 +25,7 @@ Description-Content-Type: text/markdown
|
|
|
25
25
|
|
|
26
26
|
# AppLedger honest check
|
|
27
27
|
|
|
28
|
-
<!-- mcp-name:
|
|
28
|
+
<!-- mcp-name: net.appledger/appledger -->
|
|
29
29
|
|
|
30
30
|
Honest security feedback, in plain language, for apps people build with AI tools such as Claude Code, Codex, Cursor and Lovable.
|
|
31
31
|
|
|
@@ -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
|
|
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,
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AppLedger honest check
|
|
2
2
|
|
|
3
|
-
<!-- mcp-name:
|
|
3
|
+
<!-- mcp-name: net.appledger/appledger -->
|
|
4
4
|
|
|
5
5
|
Honest security feedback, in plain language, for apps people build with AI tools such as Claude Code, Codex, Cursor and Lovable.
|
|
6
6
|
|
|
@@ -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
|
|
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,
|
|
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
|
-
- "
|
|
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
|
|
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`, `
|
|
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.
|
|
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"]
|
|
@@ -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
|
|
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,
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
239
|
-
"""
|
|
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
|
|
250
|
-
|
|
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
|
|
|
@@ -224,19 +224,30 @@ def safest_fix(fixed_versions):
|
|
|
224
224
|
return str(per_vuln[-1])
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def scanner_failed(name, stderr):
|
|
228
|
+
"""On the hosted service, log the scanner's error and give the user a plain message without server
|
|
229
|
+
details; on the user's own computer, show the error so they can fix their setup."""
|
|
230
|
+
if os.environ.get("APPLEDGER_TRIVY_OFFLINE"):
|
|
231
|
+
print(f"{name} failed: {stderr.strip()[-1500:]}", file=sys.stderr)
|
|
232
|
+
return f"{name} couldn't check this project. Please try again later."
|
|
233
|
+
return f"{name} failed:\n" + stderr.strip()[-1500:]
|
|
234
|
+
|
|
235
|
+
|
|
227
236
|
def run_trivy(app_dir):
|
|
228
237
|
trivy = find_tool("APPLEDGER_TRIVY", ["trivy", "trivy.exe"])
|
|
229
238
|
if not trivy:
|
|
230
239
|
raise ScanError("Trivy is not installed. Install it from https://trivy.dev "
|
|
231
240
|
"(Windows: winget install AquaSecurity.Trivy, Mac: brew install trivy).")
|
|
232
241
|
skip = [arg for d in sorted(SKIP_DIRS) for arg in ("--skip-dirs", d)]
|
|
242
|
+
offline = ["--skip-db-update", "--skip-java-db-update", "--offline-scan"] \
|
|
243
|
+
if os.environ.get("APPLEDGER_TRIVY_OFFLINE") else []
|
|
233
244
|
proc = subprocess.run(
|
|
234
|
-
[trivy, "fs", "--quiet", "--scanners", "vuln,secret,misconfig", *skip,
|
|
245
|
+
[trivy, "fs", "--quiet", "--scanners", "vuln,secret,misconfig", *skip, *offline,
|
|
235
246
|
"--format", "json", str(app_dir)],
|
|
236
247
|
capture_output=True, text=True, encoding="utf-8", stdin=subprocess.DEVNULL,
|
|
237
248
|
)
|
|
238
249
|
if proc.returncode != 0:
|
|
239
|
-
raise ScanError("Trivy
|
|
250
|
+
raise ScanError(scanner_failed("Trivy", proc.stderr))
|
|
240
251
|
return json.loads(proc.stdout)
|
|
241
252
|
|
|
242
253
|
|
|
@@ -443,7 +454,7 @@ def run_opengrep(app_dir):
|
|
|
443
454
|
"--json", "--quiet", str(app_dir)],
|
|
444
455
|
capture_output=True, text=True, encoding="utf-8", stdin=subprocess.DEVNULL)
|
|
445
456
|
if not proc.stdout.strip():
|
|
446
|
-
raise ScanError("Opengrep
|
|
457
|
+
raise ScanError(scanner_failed("Opengrep", proc.stderr))
|
|
447
458
|
data = json.loads(proc.stdout)
|
|
448
459
|
for err in data.get("errors", []):
|
|
449
460
|
print("Opengrep warning:", err.get("message", "")[:200], file=sys.stderr)
|
|
@@ -636,36 +647,7 @@ def write_fix_prompt(findings, app_name, scanners, path):
|
|
|
636
647
|
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
637
648
|
|
|
638
649
|
|
|
639
|
-
def
|
|
640
|
-
"""HTML for the 'AI effort' part of the report, or '' when no agent logs were found."""
|
|
641
|
-
if not usage:
|
|
642
|
-
return ""
|
|
643
|
-
e = html.escape
|
|
644
|
-
rows = []
|
|
645
|
-
for tool in usage["tools"]:
|
|
646
|
-
here = tool["this_folder"]
|
|
647
|
-
if not here:
|
|
648
|
-
continue
|
|
649
|
-
for model, v in here["by_model"].items():
|
|
650
|
-
cost = f"${v['cost_usd']:,.2f}" if v.get("cost_usd") else "not priced"
|
|
651
|
-
rows.append(f"<tr><td>{e(tool['tool'])}</td><td>{e(model)}</td><td>{v['messages']:,}</td>"
|
|
652
|
-
f"<td>{v['input'] + v['cache_write'] + v['cache_read'] + v['output']:,}</td><td>{cost}</td></tr>")
|
|
653
|
-
parent = [t["parent_folders"] for t in usage["tools"] if t.get("parent_folders")]
|
|
654
|
-
parent_note = (f"<p class=\"tech\">Another {sum(p['tokens_total'] for p in parent):,} tokens "
|
|
655
|
-
f"(${sum(p['cost_usd'] for p in parent):,.2f}) were used in sessions started in a parent folder; "
|
|
656
|
-
"they may include other work, so they're not counted above.</p>") if parent else ""
|
|
657
|
-
return f"""
|
|
658
|
-
<section class="usage">
|
|
659
|
-
<h2>AI effort that went into this app</h2>
|
|
660
|
-
<p><strong>{usage['tokens_total']:,} tokens</strong> over {usage['sessions']} session{'s' if usage['sessions'] != 1 else ''},
|
|
661
|
-
worth about <strong>${usage['cost_usd']:,.2f}</strong> at API list prices.</p>
|
|
662
|
-
<table><thead><tr><th>Tool</th><th>Model</th><th>Replies</th><th>Tokens</th><th>Est. cost</th></tr></thead>
|
|
663
|
-
<tbody>{''.join(rows)}</tbody></table>
|
|
664
|
-
<p class="tech">{e(usage['note'])} Prices: {e(usage['prices'])}.</p>{parent_note}
|
|
665
|
-
</section>"""
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
def write_report(findings, app_name, scanners, path, usage=None):
|
|
650
|
+
def write_report(findings, app_name, scanners, path):
|
|
669
651
|
e = html.escape
|
|
670
652
|
score = health_score(findings)
|
|
671
653
|
grade = "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 55 else "D" if score >= 30 else "F"
|
|
@@ -718,10 +700,6 @@ h1,h2,h3 {{ margin:0; line-height:1.2 }} p {{ margin:0 }}
|
|
|
718
700
|
button {{ font:inherit; padding:8px 14px; border-radius:6px; border:1px solid var(--line); background:var(--surface); color:var(--ink); cursor:pointer }}
|
|
719
701
|
button:focus-visible {{ outline:2px solid var(--accent); outline-offset:2px }}
|
|
720
702
|
.fixbox {{ background:var(--surface); border:1px dashed var(--accent); border-radius:10px; padding:18px; display:flex; flex-direction:column; gap:10px }}
|
|
721
|
-
.usage {{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:18px; display:flex; flex-direction:column; gap:10px; overflow-x:auto }}
|
|
722
|
-
.usage table {{ border-collapse:collapse; width:100%; min-width:460px; font-size:14px; font-variant-numeric:tabular-nums }}
|
|
723
|
-
.usage th, .usage td {{ text-align:left; padding:6px 8px; border-bottom:1px solid var(--line) }}
|
|
724
|
-
.usage th {{ font:600 11px/1 ui-monospace,Consolas,monospace; text-transform:uppercase; letter-spacing:.06em; color:var(--muted) }}
|
|
725
703
|
@media (max-width:620px) {{ .summary {{ grid-template-columns:1fr }} .tiers li {{ grid-template-columns:1fr; gap:2px }} }}
|
|
726
704
|
</style></head>
|
|
727
705
|
<body><main>
|
|
@@ -737,7 +715,7 @@ button:focus-visible {{ outline:2px solid var(--accent); outline-offset:2px }}
|
|
|
737
715
|
<span class="pill minor">{counts['minor']} good to know</span>
|
|
738
716
|
</div></div>
|
|
739
717
|
</section>
|
|
740
|
-
<section><h2 style="margin-bottom:12px">Who can use it right now</h2><ul class="tiers">{tiers}</ul></section>
|
|
718
|
+
<section><h2 style="margin-bottom:12px">Who can use it right now</h2><ul class="tiers">{tiers}</ul></section>
|
|
741
719
|
<section class="fixbox">
|
|
742
720
|
<h2>Fix it for me</h2>
|
|
743
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>
|
|
@@ -771,15 +749,6 @@ def doctor():
|
|
|
771
749
|
}
|
|
772
750
|
|
|
773
751
|
|
|
774
|
-
def ai_usage_for(app_dir):
|
|
775
|
-
"""Token use and cost from local agent logs; never lets a log problem break the check."""
|
|
776
|
-
from .usage import estimate_usage
|
|
777
|
-
try:
|
|
778
|
-
return estimate_usage(app_dir)
|
|
779
|
-
except (OSError, ValueError, KeyError, TypeError):
|
|
780
|
-
return None
|
|
781
|
-
|
|
782
|
-
|
|
783
752
|
def grade_for(score):
|
|
784
753
|
return "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 55 else "D" if score >= 30 else "F"
|
|
785
754
|
|
|
@@ -839,11 +808,10 @@ def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True,
|
|
|
839
808
|
|
|
840
809
|
out = Path(out_dir) if out_dir else app_dir / OUTPUT_DIR_NAME
|
|
841
810
|
out.mkdir(parents=True, exist_ok=True)
|
|
842
|
-
usage = ai_usage_for(app_dir)
|
|
843
811
|
ledger = None
|
|
844
812
|
local_findings = findings_for_secret_files(plan["secret_files_not_ignored"])
|
|
845
813
|
if hosted:
|
|
846
|
-
findings, scanners, ledger = hosted_scan(app_dir, app_name,
|
|
814
|
+
findings, scanners, ledger = hosted_scan(app_dir, app_name, plan, save_to_dashboard, local_findings)
|
|
847
815
|
else:
|
|
848
816
|
findings, scanners = scan(app_dir, sonar_dir, code_scan)
|
|
849
817
|
findings = dedupe(findings + local_findings)
|
|
@@ -853,7 +821,7 @@ def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True,
|
|
|
853
821
|
if not hosted and save_to_dashboard and connection_key():
|
|
854
822
|
# Local mode sends no code: only the summary below, so the dashboard still shows the app.
|
|
855
823
|
ledger = record_check(record_payload({"app": app_name, "score": score, "grade": grade_for(score),
|
|
856
|
-
"scanners": scanners, "findings": findings}, counts
|
|
824
|
+
"scanners": scanners, "findings": findings}, counts))
|
|
857
825
|
summary = {
|
|
858
826
|
"app": app_name, "score": score, "grade": grade_for(score), "scanners": scanners,
|
|
859
827
|
"counts": {LEVELS[k]["label"]: v for k, v in counts.items()},
|
|
@@ -862,12 +830,11 @@ def check(app_dir, out_dir=None, app_name=None, sonar_dir=None, code_scan=True,
|
|
|
862
830
|
"findings": findings,
|
|
863
831
|
"report": str(out / "report.html"), "fix_prompt": str(out / "fix-prompt.md"),
|
|
864
832
|
"checked_at": datetime.now().isoformat(timespec="seconds"),
|
|
865
|
-
"ai_usage": usage,
|
|
866
833
|
"ledger": ledger,
|
|
867
834
|
}
|
|
868
835
|
(out / "findings.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
869
836
|
write_fix_prompt(findings, app_name, scanners, out / "fix-prompt.md")
|
|
870
|
-
write_report(findings, app_name, scanners, out / "report.html"
|
|
837
|
+
write_report(findings, app_name, scanners, out / "report.html")
|
|
871
838
|
(out / ".gitignore").write_text("*\n", encoding="utf-8") # keep reports out of version control
|
|
872
839
|
return summary
|
|
873
840
|
|
|
@@ -927,8 +894,6 @@ def main():
|
|
|
927
894
|
ap.add_argument("--sonar-dir", help="folder with sonar-issues.json and sonar-hotspots.json")
|
|
928
895
|
ap.add_argument("--no-code-scan", action="store_true", help="skip the Opengrep code scan")
|
|
929
896
|
ap.add_argument("--doctor", action="store_true", help="show which scanners are available")
|
|
930
|
-
ap.add_argument("--ai-cost", action="store_true",
|
|
931
|
-
help="only show how many AI tokens, and what they'd cost, went into this folder")
|
|
932
897
|
ap.add_argument("--local", action="store_true",
|
|
933
898
|
help="run Trivy and Opengrep on this machine instead of the AppLedger service")
|
|
934
899
|
ap.add_argument("--yes", action="store_true",
|
|
@@ -960,18 +925,6 @@ def main():
|
|
|
960
925
|
print(f"Connected. Key saved in {where}. Every check from this computer now appears in {DASHBOARD_URL}")
|
|
961
926
|
return
|
|
962
927
|
|
|
963
|
-
if args.ai_cost:
|
|
964
|
-
usage = ai_usage_for(Path(args.app_dir).resolve())
|
|
965
|
-
if not usage:
|
|
966
|
-
print("No Claude Code or Codex logs on this computer mention this folder.")
|
|
967
|
-
return
|
|
968
|
-
print(f"{usage['tokens_total']:,} tokens over {usage['sessions']} sessions, "
|
|
969
|
-
f"about ${usage['cost_usd']:,.2f} at API list prices.")
|
|
970
|
-
for tool in usage["tools"]:
|
|
971
|
-
for model, v in (tool["this_folder"] or {}).get("by_model", {}).items():
|
|
972
|
-
print(f" {tool['tool']:12} {model:22} {v['messages']:>6,} replies ${v.get('cost_usd', 0):>9,.2f}")
|
|
973
|
-
print(usage["note"])
|
|
974
|
-
return
|
|
975
928
|
if args.doctor:
|
|
976
929
|
for tool, where in doctor().items():
|
|
977
930
|
print(f"{tool:15} {where or 'not found'}")
|
|
@@ -5,8 +5,8 @@ commit, never secrets files) and sends them to the hosted AppLedger scanning ser
|
|
|
5
5
|
scanners need to be installed. The first check of a folder needs the user's go-ahead: check_app
|
|
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
|
-
code leaves the computer. Tools: check_app, preview_upload, get_fix_prompt,
|
|
9
|
-
|
|
8
|
+
code leaves the computer. Tools: check_app, preview_upload, get_fix_prompt, connect_dashboard
|
|
9
|
+
and scanner_status.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import json
|
|
@@ -108,7 +108,6 @@ async def check_app(path: str, app_name: str | None = None, confirm_upload: bool
|
|
|
108
108
|
"findings": [{k: f[k] for k in keep} for f in result["findings"]],
|
|
109
109
|
"report": result["report"], "fix_prompt": result["fix_prompt"],
|
|
110
110
|
"checked_by": "AppLedger scanning service" if hosted() else "scanners on this computer",
|
|
111
|
-
"ai_effort": short_usage(result.get("ai_usage")),
|
|
112
111
|
"dashboard": dashboard,
|
|
113
112
|
}
|
|
114
113
|
|
|
@@ -140,32 +139,6 @@ def preview_upload(path: str) -> dict:
|
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
|
|
143
|
-
def short_usage(usage):
|
|
144
|
-
if not usage:
|
|
145
|
-
return None
|
|
146
|
-
return {"tokens": usage["tokens_total"], "sessions": usage["sessions"],
|
|
147
|
-
"estimated_cost_usd": usage["cost_usd"], "note": usage["note"]}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
@mcp.tool(annotations=ToolAnnotations(title="Estimate the AI tokens and cost behind an app", readOnlyHint=True, destructiveHint=False,
|
|
151
|
-
openWorldHint=False))
|
|
152
|
-
def ai_cost(path: str) -> dict:
|
|
153
|
-
"""Estimate how many AI tokens went into building an app, and what they would cost at API list prices.
|
|
154
|
-
|
|
155
|
-
Reads Claude Code and Codex session logs on this computer for sessions run in that folder; nothing is
|
|
156
|
-
uploaded. Returns totals plus a breakdown per tool and model. People on flat-rate plans (Claude Pro,
|
|
157
|
-
Max, Team) pay their plan price instead, so present the figure as the work's value at API rates.
|
|
158
|
-
|
|
159
|
-
Args:
|
|
160
|
-
path: absolute path to the app's folder.
|
|
161
|
-
"""
|
|
162
|
-
from .scanner import ai_usage_for
|
|
163
|
-
usage = ai_usage_for(Path(path).resolve())
|
|
164
|
-
if not usage:
|
|
165
|
-
return {"found": False, "message": "No Claude Code or Codex logs on this computer mention this folder."}
|
|
166
|
-
return {"found": True, **usage}
|
|
167
|
-
|
|
168
|
-
|
|
169
142
|
@mcp.tool(annotations=ToolAnnotations(title="Get the fix request for an app", readOnlyHint=True, destructiveHint=False,
|
|
170
143
|
openWorldHint=False))
|
|
171
144
|
def get_fix_prompt(path: str) -> str:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|