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.
- {appledger-0.2.1 → appledger-0.2.2}/PKG-INFO +3 -13
- {appledger-0.2.1 → appledger-0.2.2}/README.md +2 -12
- {appledger-0.2.1 → appledger-0.2.2}/plugin/README.md +3 -3
- {appledger-0.2.1 → appledger-0.2.2}/pyproject.toml +44 -44
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/__init__.py +1 -1
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/client.py +34 -19
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/scanner.py +6 -65
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/server.py +2 -3
- appledger-0.2.1/src/appledger/usage.py +0 -219
- {appledger-0.2.1 → appledger-0.2.2}/.gitignore +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/LICENSE +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/plugin/LICENSE +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/__main__.py +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/consent.py +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/gitfiles.py +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/schedule.py +0 -0
- {appledger-0.2.1 → appledger-0.2.2}/src/appledger/service.py +0 -0
- {appledger-0.2.1 → 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
|
|
@@ -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
|
|
|
@@ -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`, `connect_dashboard`, `scanner_status
|
|
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
|
|
|
@@ -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
|
|
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>
|
|
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
|
|
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,
|
|
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
|
|
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"
|
|
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.
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|