jekyll-theme-zer0 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1143 -13
- data/_data/README.md +2 -0
- data/_data/ai.yml +5 -3
- data/_data/ai_pricing.yml +36 -0
- data/_data/backlog.yml +507 -2
- data/_data/consumers.yml +157 -9
- data/_data/features.yml +303 -20
- data/_data/feedback_types.yml +17 -12
- data/_data/i18n/fr.yml +12 -7
- data/_data/i18n/manifest.yml +39 -12
- data/_data/ingredient_densities.yml +122 -0
- data/_data/landing.yml +5 -2
- data/_data/navigation/main.yml +16 -0
- data/_data/navigation/quickstart.yml +4 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/site_builder.yml +874 -0
- data/_data/theme-manifest.yml +160 -124
- data/_data/ui-text.yml +26 -0
- data/_includes/README.md +26 -2
- data/_includes/analytics/posthog.html +2 -2
- data/_includes/components/admin-links.html +2 -2
- data/_includes/components/admin-tabs.html +2 -2
- data/_includes/components/ai-chat.html +14 -11
- data/_includes/components/analytics-dashboard.html +8 -8
- data/_includes/components/author-bio.html +1 -1
- data/_includes/components/author-card.html +10 -2
- data/_includes/components/author-eeat.html +4 -4
- data/_includes/components/background-customizer.html +10 -10
- data/_includes/components/background-image.html +114 -0
- data/_includes/components/background-settings.html +28 -15
- data/_includes/components/collection-manager.html +5 -5
- data/_includes/components/component-showcase.html +13 -13
- data/_includes/components/config-editor.html +12 -12
- data/_includes/components/config-viewer.html +8 -8
- data/_includes/components/cookie-consent.html +15 -15
- data/_includes/components/cta-button.html +7 -2
- data/_includes/components/dev-shortcuts.html +7 -7
- data/_includes/components/env-dashboard.html +8 -8
- data/_includes/components/env-switcher.html +9 -9
- data/_includes/components/feature-card.html +2 -2
- data/_includes/components/halfmoon.html +2 -2
- data/_includes/components/info-section.html +42 -37
- data/_includes/components/js-cdn.html +15 -15
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/mermaid.html +72 -435
- data/_includes/components/nanobar.html +5 -5
- data/_includes/components/nav-editor.html +2 -2
- data/_includes/components/nav-export.html +2 -2
- data/_includes/components/nav-overview.html +2 -2
- data/_includes/components/page-feedback.html +45 -30
- data/_includes/components/page-views-init.html +55 -0
- data/_includes/components/page-views.html +33 -0
- data/_includes/components/post-card.html +22 -22
- data/_includes/components/post-type-badge.html +2 -2
- data/_includes/components/powered-by.html +2 -2
- data/_includes/components/preview-image.html +6 -0
- data/_includes/components/quick-index.html +2 -2
- data/_includes/components/recipe-card.html +67 -0
- data/_includes/components/recipe-duration.html +50 -0
- data/_includes/components/recipe-grams.html +58 -0
- data/_includes/components/recipe-index.html +96 -0
- data/_includes/components/recipe-ingredients.html +90 -0
- data/_includes/components/recipe-meta.html +96 -0
- data/_includes/components/recipe-nutrition.html +57 -0
- data/_includes/components/recipe-qty.html +73 -0
- data/_includes/components/recipe-ratio.html +151 -0
- data/_includes/components/recipe-scaler.html +73 -0
- data/_includes/components/recipe-steps.html +86 -0
- data/_includes/components/recipe-temp.html +45 -0
- data/_includes/components/search-modal.html +29 -4
- data/_includes/components/searchbar.html +2 -2
- data/_includes/components/shortcuts-modal.html +3 -0
- data/_includes/components/svg-background.html +2 -2
- data/_includes/components/theme-customizer.html +2 -2
- data/_includes/components/theme-info.html +14 -7
- data/_includes/components/theme-preview-gallery.html +22 -22
- data/_includes/content/giscus.html +2 -2
- data/_includes/content/intro.html +8 -8
- data/_includes/content/jsonld-faq.html +2 -2
- data/_includes/content/jsonld-software.html +24 -5
- data/_includes/content/seo.html +4 -4
- data/_includes/content/sitemap.html +27 -27
- data/_includes/content/toc.html +183 -183
- data/_includes/core/branding.html +6 -6
- data/_includes/core/console-capture.html +32 -74
- data/_includes/core/favicon.html +49 -7
- data/_includes/core/footer-fabs.html +17 -3
- data/_includes/core/footer.html +49 -34
- data/_includes/core/head.html +110 -86
- data/_includes/core/header.html +76 -54
- data/_includes/docs/bootstrap-docs.html +8 -8
- data/_includes/landing/landing-install-cards.html +2 -2
- data/_includes/landing/landing-quick-links.html +1 -1
- data/_includes/navigation/admin-nav.html +2 -2
- data/_includes/navigation/nav-tree.html +8 -8
- data/_includes/navigation/navbar.html +12 -12
- data/_includes/navigation/section-sidebar.html +109 -27
- data/_includes/navigation/sidebar-config.html +36 -2
- data/_includes/navigation/sidebar-left.html +17 -16
- data/_includes/navigation/sidebar-right.html +8 -7
- data/_includes/obsidian/full-graph.html +2 -2
- data/_includes/setup/claude-session.html +72 -0
- data/_includes/setup/prereq-checklist.html +90 -0
- data/_includes/setup/wizard.html +924 -222
- data/_includes/stats/stats-categories.html +8 -8
- data/_includes/stats/stats-header.html +14 -14
- data/_includes/stats/stats-metrics.html +14 -14
- data/_includes/stats/stats-no-data.html +12 -12
- data/_includes/stats/stats-overview.html +6 -6
- data/_includes/stats/stats-tags.html +8 -8
- data/_layouts/404.html +38 -24
- data/_layouts/README.md +2 -0
- data/_layouts/admin.html +24 -24
- data/_layouts/article.html +43 -33
- data/_layouts/author.html +20 -20
- data/_layouts/authors.html +2 -2
- data/_layouts/book-abc.html +12 -12
- data/_layouts/book-story.html +15 -15
- data/_layouts/book.html +12 -12
- data/_layouts/collection.html +33 -33
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +31 -28
- data/_layouts/home.html +23 -23
- data/_layouts/index.html +10 -10
- data/_layouts/landing.html +17 -17
- data/_layouts/news.html +44 -44
- data/_layouts/note.html +38 -38
- data/_layouts/notebook.html +34 -34
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +92 -55
- data/_layouts/section.html +62 -33
- data/_layouts/setup.html +3 -3
- data/_layouts/sitemap-collection.html +49 -49
- data/_layouts/stats.html +40 -40
- data/_layouts/tag.html +12 -12
- data/_layouts/welcome.html +21 -21
- data/_sass/components/_callout.scss +1 -1
- data/_sass/components/_footer.scss +37 -1
- data/_sass/components/_mermaid.scss +375 -0
- data/_sass/components/_page-views.scss +36 -0
- data/_sass/components/_recipe.scss +506 -0
- data/_sass/components/_setup-wizard.scss +764 -0
- data/_sass/components/_ui-enhancements.scss +6 -6
- data/_sass/core/_navbar.scss +261 -46
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/layouts/_navbar-extras.scss +14 -4
- data/_sass/tokens/_color.scss +6 -0
- data/_sass/tokens/_index.scss +2 -0
- data/_sass/tokens/_radius.scss +21 -0
- data/_sass/tokens/_typography.scss +4 -0
- data/_sass/utilities/_focus.scss +14 -0
- data/assets/css/main.scss +4 -0
- data/assets/js/ai-chat.js +47 -5
- data/assets/js/fleet-feedback-capture.js +124 -0
- data/assets/js/fleet-feedback.js +853 -0
- data/assets/js/mermaid-diagrams.js +1267 -0
- data/assets/js/modules/navigation/config.js +9 -6
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/modules/navigation/scroll-spy.js +315 -80
- data/assets/js/modules/theme/appearance.js +8 -2
- data/assets/js/obsidian-wiki-links.js +8 -3
- data/assets/js/page-feedback.js +125 -192
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +36 -0
- data/assets/js/setup-wizard.js +2279 -226
- data/assets/js/site-builder.js +1834 -0
- data/assets/js/ui-enhancements.js +11 -3
- data/scripts/README.md +44 -0
- data/scripts/ai/README.md +38 -0
- data/scripts/ai/api_call.rb +124 -0
- data/scripts/ai/usage.rb +314 -0
- data/scripts/ai/usage_report.rb +225 -0
- data/scripts/bin/audit-consumer +39 -7
- data/scripts/bin/giscus-discussions +213 -14
- data/scripts/bin/manifest +35 -12
- data/scripts/ci/agent_review_result.py +164 -0
- data/scripts/ci/test_agent_review_result.py +172 -0
- data/scripts/ci/test_visual_evidence_autogen.py +341 -0
- data/scripts/ci/visual_evidence_autogen.py +1060 -0
- data/scripts/content-review.rb +20 -1
- data/scripts/design-system-check.rb +170 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/test/integration/mermaid +22 -8
- data/scripts/test/lib/run_tests.sh +3 -1
- data/scripts/test/lib/test_agent_review_result.sh +27 -0
- data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
- data/scripts/translate.rb +94 -16
- metadata +48 -2
|
@@ -101,12 +101,20 @@
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Add active state to navigation links on scroll
|
|
104
|
+
* Add active state to in-page navigation links on scroll.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately excludes `#TableOfContents` links (and the section-page
|
|
107
|
+
* sidebar): those are owned by assets/js/modules/navigation/scroll-spy.js.
|
|
108
|
+
* This observer used to claim every `a[href^="#"]` on the page and clear
|
|
109
|
+
* `.active` from all of them, which fought the TOC scroll spy and made the
|
|
110
|
+
* active heading flicker between entries.
|
|
105
111
|
*/
|
|
106
112
|
function initScrollSpy() {
|
|
107
113
|
const sections = document.querySelectorAll('section[id], [id^="get-started"], [id^="features"]');
|
|
108
|
-
const navLinks = document.querySelectorAll('a[href^="#"]')
|
|
109
|
-
|
|
114
|
+
const navLinks = Array.from(document.querySelectorAll('a[href^="#"]')).filter(
|
|
115
|
+
link => !link.closest('#TableOfContents, .bd-toc, .sidebar-nav')
|
|
116
|
+
);
|
|
117
|
+
|
|
110
118
|
if (!sections.length || !navLinks.length) return;
|
|
111
119
|
|
|
112
120
|
const observerOptions = {
|
data/scripts/README.md
CHANGED
|
@@ -25,6 +25,15 @@ scripts/
|
|
|
25
25
|
│ ├── pixelate_images.py # Pure-stdlib pixelate/quantize engine
|
|
26
26
|
│ ├── install-preview-generator # Preview generator installer
|
|
27
27
|
│ └── validate_preview_urls.py # Preview URL validator
|
|
28
|
+
├── ai/ # Consumer companions to the hub's `ai-runner` kit (the runner itself is referenced, not stored)
|
|
29
|
+
│ ├── api_call.rb # Single-shot Claude API fallback (stdlib only)
|
|
30
|
+
│ ├── usage.rb # Optional metering: one JSONL record per call
|
|
31
|
+
│ ├── usage_report.rb # Publishes metering (summary, artifact, PR comment)
|
|
32
|
+
│ └── README.md # What stays here and why
|
|
33
|
+
├── ci/ # Helpers called directly by workflow steps
|
|
34
|
+
│ ├── classify_changes.py # Change classifier (issue-pr-auto-merge)
|
|
35
|
+
│ ├── agent_review_result.py # Did the Claude content review actually run?
|
|
36
|
+
│ └── test_agent_review_result.py # …its tests (CI-run via test/lib bridge)
|
|
28
37
|
├── utils/ # Utility scripts
|
|
29
38
|
│ ├── analyze-commits # Commit analyzer for version bumps
|
|
30
39
|
│ ├── fix-markdown # Markdown formatting fixer
|
|
@@ -171,6 +180,41 @@ Validate preview image URLs in frontmatter.
|
|
|
171
180
|
python3 scripts/features/validate_preview_urls.py [--verbose] [--suggestions]
|
|
172
181
|
```
|
|
173
182
|
|
|
183
|
+
### AI Runner (scripts/ai/)
|
|
184
|
+
|
|
185
|
+
Every model call a workflow makes goes through `uses: bamr87/bamr87/.github/actions/claude-run@main` — the fleet's shared **`ai-runner` kit**, versioned once in the [bamr87/bamr87 hub](https://github.com/bamr87/bamr87) and consumed by reference. The runner (`run.sh`) and the composite action are no longer stored in this repo; the hub's kit README ([`templates/ai-runner/README.md`](https://github.com/bamr87/bamr87/blob/main/templates/ai-runner/README.md)) is the contract. In short:
|
|
186
|
+
|
|
187
|
+
- Model: `--model` input > `AI_MODEL` > `_data/ai.yml` `model:` > the fleet default.
|
|
188
|
+
- Auth from the env, OAuth first: `CLAUDE_CODE_OAUTH_TOKEN` (preferred; strips `ANTHROPIC_API_KEY` from the CLI's env) or `ANTHROPIC_API_KEY` (also the only credential the API fallback can use).
|
|
189
|
+
- Exit codes: `0` when the call ran or nothing was ever attempted (no auth — the documented no-op); `1` when the call was attempted and failed with no usable fallback, with the reason raised as a `::error::` annotation.
|
|
190
|
+
|
|
191
|
+
This directory holds the **consumer-owned companions** the hub runner probes for in the checkout and uses when present: `usage.rb` metering (prices from `_data/ai_pricing.yml`), `usage_report.rb` publishing, `api_call.rb` fallback — plus `tools/unwrap-prose.py` as the post-run prose normalizer. See [`scripts/ai/README.md`](ai/README.md). The exit-code contract test lives in the hub (`templates/ai-runner/tests/contract.sh`) and runs there on every runner change.
|
|
192
|
+
|
|
193
|
+
### CI Helpers (scripts/ci/)
|
|
194
|
+
|
|
195
|
+
#### `agent_review_result.py`
|
|
196
|
+
|
|
197
|
+
Decides whether the Claude tier of `ai-content-review.yml` **actually produced a review**, and fails the job when it did not.
|
|
198
|
+
|
|
199
|
+
Before this existed, the agent step captured the Claude CLI's exit status, echoed it, and then posted whatever landed on stdout as the review. When the OAuth credential was revoked, `Failed to authenticate. API Error: 401 OAuth access token has been revoked.` became the "review" and every job still reported success (issue #418) — for days, across several PRs, with nothing on the checks list to say the editorial review had stopped happening.
|
|
200
|
+
|
|
201
|
+
Note that a non-zero exit code would not have caught it: the CLI reported the auth failure on stdout. Three conditions are classified — a non-zero exit, no output at all, and a short output that leads with a known CLI failure signature. Any of them writes an explicit failure notice for the sticky comment, emits a `::error::` annotation, and exits 1.
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
python3 scripts/ci/agent_review_result.py \
|
|
205
|
+
--status "$status" --stdout /tmp/agent-review.md \
|
|
206
|
+
--stderr /tmp/agent-err.log --out /tmp/agent-review-final.md
|
|
207
|
+
# exit 0 = the review is real · 1 = the tier failed · 2 = bad invocation
|
|
208
|
+
|
|
209
|
+
python3 scripts/ci/test_agent_review_result.py # tests, incl. the verbatim #418 output
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The tests run in CI through `scripts/test/lib/test_agent_review_result.sh`, which `run_tests.sh` sources and `./scripts/bin/test` executes on every PR.
|
|
213
|
+
|
|
214
|
+
#### `classify_changes.py`
|
|
215
|
+
|
|
216
|
+
Classifies a PR's changed files for `issue-pr-auto-merge.yml` (`--content-only`).
|
|
217
|
+
|
|
174
218
|
### Content Validation
|
|
175
219
|
|
|
176
220
|
#### `lint-pages`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# scripts/ai — companions to the fleet's `ai-runner` kit
|
|
2
|
+
|
|
3
|
+
Every model call a workflow here makes goes through `uses: bamr87/bamr87/.github/actions/claude-run@main` — the fleet's one AI step, versioned once in the [bamr87/bamr87 hub](https://github.com/bamr87/bamr87) and consumed **by reference**. This repo no longer carries the runner (`scripts/ai/run.sh`) or the composite action (`.github/actions/claude-run/`); it receives every fix on its next run with nothing to copy forward. The kit's contract, environment, and exit codes are documented in the hub: [`templates/ai-runner/README.md`](https://github.com/bamr87/bamr87/blob/main/templates/ai-runner/README.md).
|
|
4
|
+
|
|
5
|
+
What stays here are the **consumer-owned companions** the hub runner probes for in `$GITHUB_WORKSPACE` and uses when present:
|
|
6
|
+
|
|
7
|
+
| File | Role | Required |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `usage.rb` | Metering: one JSONL record per call (tokens, API-equivalent cost, model, status, CI context) into `$AI_USAGE_DIR/records.jsonl` (default `$RUNNER_TEMP/ai-usage`, outside the checkout). Prices come from `_data/ai_pricing.yml`. | optional |
|
|
10
|
+
| `usage_report.rb` | End-of-job publisher: step summary, `ai-usage-*` artifact, sticky PR comment (marker `<!-- lh-ai-usage -->`). Run by the hub action's `always()` post-step. | optional |
|
|
11
|
+
| `api_call.rb` | The single-shot Messages API fallback the runner uses when Claude Code is missing or fails. Stdlib only, self-contained. | optional |
|
|
12
|
+
| `../../_data/ai.yml` | `model:` (and `max_tokens` for the API fallback). The runner resolves `--model` > `AI_MODEL` > this file > the fleet default. | recommended |
|
|
13
|
+
| `../../tools/unwrap-prose.py` | Post-run one-paragraph-per-line normalizer for markdown the agent changed. `SCHEMA.md`/`CHANGELOG.md` are always skipped; further exclusions go in a repo-root `.prose-excludes`, one extended regex per line. | optional |
|
|
14
|
+
|
|
15
|
+
Without a companion the runner degrades honestly: no `usage.rb` means nothing is recorded (an inline emitter still treats `is_error` as a failure); no `api_call.rb` means a failed primary call exits `1` with "no scripts/ai/api_call.rb|py to fall back to".
|
|
16
|
+
|
|
17
|
+
## Contract, in one line
|
|
18
|
+
|
|
19
|
+
Auth from the job env, OAuth first (`CLAUDE_CODE_OAUTH_TOKEN`, then `ANTHROPIC_API_KEY`); canonical `AI_*` environment with no repo prefix (`AI_MODEL`, `AI_FORCE_API`, `AI_MAX_TURNS`, `AI_USAGE_DIR`, `AI_ROLE`, `AI_REPO_ROOT`); exit `0` when the call ran or nothing was attempted (no auth — the documented no-op), exit `1` when a call was attempted and failed with no usable fallback, with the reason raised as a `::error::` annotation. The exit-code contract is pinned by the hub's `templates/ai-runner/tests/contract.sh` (stubbed `claude`, no network, no credentials) and run there on every change to the runner — not vendored here.
|
|
20
|
+
|
|
21
|
+
## Calling it
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
- uses: bamr87/bamr87/.github/actions/claude-run@main
|
|
25
|
+
env:
|
|
26
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
27
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # optional fallback
|
|
28
|
+
with:
|
|
29
|
+
agent: issue-triager # .claude/agents/<name>.md
|
|
30
|
+
prompt: "..."
|
|
31
|
+
tools: "Read,Grep,Glob,Bash(gh:*)"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Inputs: `prompt`, `agent`, `tools`, `mcp`, `system`, `out`, `model`, `max-turns`. Callers in this repo: `issue-autopilot.yml` (triage, verify, resolve) and `visual-evidence-autogen.yml`. Where a lane is the standard shape (gate on a `*_ENABLED` variable, run one agent, open one PR), the hub also offers the reusable `bamr87/bamr87/.github/workflows/ai-lane.yml@main`; the matrix-fed autopilot is deliberately not that shape.
|
|
35
|
+
|
|
36
|
+
## Metering companions are kit files too
|
|
37
|
+
|
|
38
|
+
`usage.rb`, `usage_report.rb`, and `api_call.rb` are the same files lifehacker.dev runs. Fix them there first and copy forward (`shasum` parity is the check); never fork them here.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# api_call.rb — the Claude API fallback (single-shot Messages call)
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
# Used by scripts/ai/run.sh when the Claude Code CLI is unavailable or fails.
|
|
6
|
+
# Calls POST /v1/messages over Ruby's stdlib (net/http + json) — NO gem — so it
|
|
7
|
+
# runs on a bare runner just like the rest of scripts/. Reads ANTHROPIC_API_KEY
|
|
8
|
+
# and _data/ai.yml. Prints the model's text to stdout.
|
|
9
|
+
#
|
|
10
|
+
# Auth note: this path is ANTHROPIC_API_KEY-only by design. A Claude Code OAuth
|
|
11
|
+
# token (CLAUDE_CODE_OAUTH_TOKEN) authenticates the `claude` CLI, NOT the raw
|
|
12
|
+
# Messages API, so it cannot be used here. run.sh selects the credential and only
|
|
13
|
+
# reaches this fallback when an API key is present; with only an OAuth token the
|
|
14
|
+
# primary Claude Code path runs and this fallback is intentionally unavailable.
|
|
15
|
+
#
|
|
16
|
+
# This is a degraded path: a single message in, the final text out — not the
|
|
17
|
+
# full agent. It covers the text-generation/analysis steps (a review comment, a
|
|
18
|
+
# draft, a classification); steps that need multi-file edits should run under
|
|
19
|
+
# Claude Code (the primary), which run.sh tries first.
|
|
20
|
+
#
|
|
21
|
+
# ruby scripts/ai/api_call.rb --prompt "..." [--system "..."]
|
|
22
|
+
# echo "..." | ruby scripts/ai/api_call.rb
|
|
23
|
+
# =============================================================================
|
|
24
|
+
require 'net/http'
|
|
25
|
+
require 'uri'
|
|
26
|
+
require 'json'
|
|
27
|
+
require 'yaml'
|
|
28
|
+
|
|
29
|
+
# kit: ai-runner — stdlib only and self-contained (no repo-local library), so the
|
|
30
|
+
# same file runs in every repo that carries scripts/ai/run.sh.
|
|
31
|
+
ROOT = File.expand_path('../..', __dir__)
|
|
32
|
+
cfg = begin
|
|
33
|
+
raw = File.read(File.join(ROOT, '_data', 'ai.yml'), encoding: 'UTF-8')
|
|
34
|
+
(YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(raw) : YAML.load(raw)) || {}
|
|
35
|
+
rescue StandardError
|
|
36
|
+
{}
|
|
37
|
+
end
|
|
38
|
+
MODEL = ENV['AI_MODEL'] || cfg['fallback_model'] || cfg['model'] || 'claude-opus-4-8'
|
|
39
|
+
MAXTOK = (ENV['AI_MAX_TOKENS'] || cfg['max_tokens'] || 8000).to_i
|
|
40
|
+
VERSION = cfg['api_version'] || '2023-06-01'
|
|
41
|
+
BASE = cfg['api_base'] || 'https://api.anthropic.com'
|
|
42
|
+
|
|
43
|
+
key = ENV['ANTHROPIC_API_KEY'].to_s
|
|
44
|
+
abort '[api_call] ANTHROPIC_API_KEY not set — cannot use the Claude API fallback' if key.empty?
|
|
45
|
+
|
|
46
|
+
# --- parse args: --prompt/-p, --system, else stdin -------------------------
|
|
47
|
+
args = ARGV.dup
|
|
48
|
+
system_prompt = nil
|
|
49
|
+
if (i = args.index('--system'))
|
|
50
|
+
system_prompt = args[i + 1]
|
|
51
|
+
args.delete_at(i + 1); args.delete_at(i)
|
|
52
|
+
end
|
|
53
|
+
prompt =
|
|
54
|
+
if (i = (args.index('--prompt') || args.index('-p'))) then args[i + 1]
|
|
55
|
+
elsif !args.empty? then args.join(' ')
|
|
56
|
+
else $stdin.read
|
|
57
|
+
end
|
|
58
|
+
abort '[api_call] empty prompt' if prompt.to_s.strip.empty?
|
|
59
|
+
|
|
60
|
+
body = { 'model' => MODEL, 'max_tokens' => MAXTOK,
|
|
61
|
+
'messages' => [{ 'role' => 'user', 'content' => prompt }] }
|
|
62
|
+
body['system'] = system_prompt if system_prompt
|
|
63
|
+
|
|
64
|
+
# Dry run: print the request shape without calling the API (for tests).
|
|
65
|
+
if ENV['AI_DRY_RUN'] == '1'
|
|
66
|
+
puts JSON.pretty_generate('endpoint' => "#{BASE}/v1/messages", 'model' => MODEL,
|
|
67
|
+
'max_tokens' => MAXTOK, 'anthropic_version' => VERSION,
|
|
68
|
+
'has_system' => !system_prompt.nil?)
|
|
69
|
+
exit 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
uri = URI.join(BASE, '/v1/messages')
|
|
73
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
74
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
75
|
+
http.read_timeout = 120
|
|
76
|
+
http.open_timeout = 20
|
|
77
|
+
|
|
78
|
+
attempt = 0
|
|
79
|
+
loop do
|
|
80
|
+
attempt += 1
|
|
81
|
+
req = Net::HTTP::Post.new(uri)
|
|
82
|
+
req['x-api-key'] = key
|
|
83
|
+
req['anthropic-version'] = VERSION
|
|
84
|
+
req['content-type'] = 'application/json'
|
|
85
|
+
req.body = JSON.generate(body)
|
|
86
|
+
|
|
87
|
+
res = (http.request(req) rescue nil)
|
|
88
|
+
code = res ? res.code.to_i : 0
|
|
89
|
+
|
|
90
|
+
if code == 200
|
|
91
|
+
data = JSON.parse(res.body)
|
|
92
|
+
# Meter the call (tokens were billed either way — refusals included). The
|
|
93
|
+
# record is an ESTIMATE from _data/ai_pricing.yml: the raw API reports
|
|
94
|
+
# usage but not dollars. Best-effort — a metering bug must never break
|
|
95
|
+
# the actual AI step.
|
|
96
|
+
begin
|
|
97
|
+
require_relative 'usage'
|
|
98
|
+
AIUsage.append(AIUsage.from_api_response(data, agent: ENV['AI_ROLE'].to_s))
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
warn "[api_call] usage record failed (non-fatal): #{e.class}: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
if data['stop_reason'] == 'refusal'
|
|
103
|
+
warn '[api_call] request refused by safety classifiers'
|
|
104
|
+
exit 2
|
|
105
|
+
end
|
|
106
|
+
text = (data['content'] || []).select { |b| b['type'] == 'text' }.map { |b| b['text'] }.join
|
|
107
|
+
warn "[api_call] Claude API fallback ok (model=#{data['model']}, stop=#{data['stop_reason']})"
|
|
108
|
+
puts text
|
|
109
|
+
exit 0
|
|
110
|
+
elsif code == 401 || code == 403
|
|
111
|
+
# Auth failure is not transient — don't retry, and say so plainly so a
|
|
112
|
+
# misconfigured/expired/insufficient ANTHROPIC_API_KEY is diagnosable instead
|
|
113
|
+
# of hiding behind the generic HTTP error below.
|
|
114
|
+
warn "[api_call] authentication failed (HTTP #{code}) — the ANTHROPIC_API_KEY is missing, invalid, expired, or lacks access. #{res && res.body.to_s[0, 200]}"
|
|
115
|
+
exit 1
|
|
116
|
+
elsif (code.zero? || [429, 500, 502, 503, 529].include?(code)) && attempt < 4
|
|
117
|
+
warn "[api_call] transient (HTTP #{code}), retry #{attempt}/3"
|
|
118
|
+
sleep([2**attempt, 30].min)
|
|
119
|
+
next
|
|
120
|
+
else
|
|
121
|
+
warn "[api_call] HTTP #{code}: #{res && res.body.to_s[0, 300]}"
|
|
122
|
+
exit 1
|
|
123
|
+
end
|
|
124
|
+
end
|
data/scripts/ai/usage.rb
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# usage.rb — capture ONE normalized usage record per AI invocation
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
# The metering half of the universal AI runner. Every model call in the repo
|
|
6
|
+
# already flows through scripts/ai/run.sh (Claude Code) or scripts/ai/api_call.rb
|
|
7
|
+
# (API fallback) — this library turns each call's raw usage payload into one
|
|
8
|
+
# JSONL record so tokens and cost are never invisible. Records accumulate in
|
|
9
|
+
# AI_USAGE_DIR (default: $RUNNER_TEMP/ai-usage — OUTSIDE the checkout, so
|
|
10
|
+
# agents never see a dirty tree), and scripts/ai/usage_report.rb publishes them
|
|
11
|
+
# (step summary + artifact + PR comment) at the end of the job.
|
|
12
|
+
#
|
|
13
|
+
# Cost semantics: Claude Code reports its own total_cost_usd — recorded verbatim
|
|
14
|
+
# (cost_source: "reported"). That figure is what the tokens would bill at API
|
|
15
|
+
# list prices; under subscription OAuth the marginal dollar cost is $0, so every
|
|
16
|
+
# surface labels it "API-equivalent". Paths that report tokens but no dollars
|
|
17
|
+
# (the raw API fallback) get cost_source: "estimated" from _data/ai_pricing.yml.
|
|
18
|
+
#
|
|
19
|
+
# ruby scripts/ai/usage.rb ingest-claude <result.json> [--agent X] [--rc N] [--emit-result]
|
|
20
|
+
# ruby scripts/ai/usage.rb ingest-execution-log <log.json> [--agent X]
|
|
21
|
+
#
|
|
22
|
+
# ingest-claude exits non-zero when the file is not a Claude result JSON, so
|
|
23
|
+
# run.sh can treat "unparseable output" like a failed run (fallback engages).
|
|
24
|
+
# Stdlib only — no gems — so it runs on a bare runner before `bundle install`.
|
|
25
|
+
# =============================================================================
|
|
26
|
+
require 'json'
|
|
27
|
+
require 'yaml'
|
|
28
|
+
require 'time'
|
|
29
|
+
require 'digest'
|
|
30
|
+
require 'fileutils'
|
|
31
|
+
|
|
32
|
+
module AIUsage
|
|
33
|
+
ROOT = File.expand_path('../..', __dir__)
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def dir
|
|
38
|
+
ENV['AI_USAGE_DIR'].to_s.empty? ? File.join(ENV['RUNNER_TEMP'] || ENV['TMPDIR'] || '/tmp', 'ai-usage') : ENV['AI_USAGE_DIR']
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def records_path
|
|
42
|
+
File.join(dir, 'records.jsonl')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# --- pricing (estimates only — reported costs are always preferred) --------
|
|
46
|
+
def pricing
|
|
47
|
+
@pricing ||= begin
|
|
48
|
+
raw = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(File.read(File.join(ROOT, '_data', 'ai_pricing.yml'))) : YAML.load(File.read(File.join(ROOT, '_data', 'ai_pricing.yml')))
|
|
49
|
+
raw.is_a?(Hash) ? raw : {}
|
|
50
|
+
rescue StandardError
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def price_for(model)
|
|
56
|
+
models = pricing['models'] || {}
|
|
57
|
+
key = models.keys.reject { |k| k == 'default' }
|
|
58
|
+
.select { |k| model.to_s.start_with?(k) }
|
|
59
|
+
.max_by(&:length)
|
|
60
|
+
models[key] || models['default'] || { 'input' => 5.0, 'output' => 25.0 }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# tokens: {'input'=>, 'output'=>, 'cache_read'=>, 'cache_5m'=>, 'cache_1h'=>}
|
|
64
|
+
# Reproduces the CLI's own arithmetic (validated to the cent, 2026-07-14).
|
|
65
|
+
def estimate_cost(model, tokens)
|
|
66
|
+
p = price_for(model)
|
|
67
|
+
m = pricing['multipliers'] || {}
|
|
68
|
+
inp = p['input'].to_f / 1_000_000
|
|
69
|
+
out = p['output'].to_f / 1_000_000
|
|
70
|
+
tokens['input'].to_i * inp +
|
|
71
|
+
tokens['output'].to_i * out +
|
|
72
|
+
tokens['cache_read'].to_i * inp * (m['cache_read'] || 0.1).to_f +
|
|
73
|
+
tokens['cache_5m'].to_i * inp * (m['cache_write_5m'] || 1.25).to_f +
|
|
74
|
+
tokens['cache_1h'].to_i * inp * (m['cache_write_1h'] || 2.0).to_f
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- shared context ---------------------------------------------------------
|
|
78
|
+
def auth_mode
|
|
79
|
+
return 'oauth' unless ENV['CLAUDE_CODE_OAUTH_TOKEN'].to_s.empty?
|
|
80
|
+
return 'api_key' unless ENV['ANTHROPIC_API_KEY'].to_s.empty?
|
|
81
|
+
'none'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# One safe, bounded line out of a model-supplied error string: credentials
|
|
85
|
+
# masked, whitespace collapsed, length capped. These records ship as build
|
|
86
|
+
# artifacts and land in the public ledger, so nothing verbatim goes in.
|
|
87
|
+
MAX_ERROR_CHARS = 500
|
|
88
|
+
def scrub(text)
|
|
89
|
+
text.to_s
|
|
90
|
+
.gsub(/sk-ant-[A-Za-z0-9_-]{8,}/, 'sk-ant-***')
|
|
91
|
+
.gsub(/\s+/, ' ')
|
|
92
|
+
.strip[0, MAX_ERROR_CHARS]
|
|
93
|
+
.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ci_context
|
|
97
|
+
{
|
|
98
|
+
'repo' => ENV['GITHUB_REPOSITORY'].to_s,
|
|
99
|
+
'workflow' => ENV['GITHUB_WORKFLOW'].to_s,
|
|
100
|
+
'job' => ENV['GITHUB_JOB'].to_s,
|
|
101
|
+
'run_id' => ENV['GITHUB_RUN_ID'].to_s,
|
|
102
|
+
'run_attempt' => ENV['GITHUB_RUN_ATTEMPT'].to_s,
|
|
103
|
+
'event' => ENV['GITHUB_EVENT_NAME'].to_s,
|
|
104
|
+
'ref' => ENV['GITHUB_REF_NAME'].to_s,
|
|
105
|
+
'sha' => ENV['GITHUB_SHA'].to_s
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def base_record(source:, agent:, ts: nil)
|
|
110
|
+
{
|
|
111
|
+
'id' => nil, # set by the ingester (stable per payload)
|
|
112
|
+
'ts' => (ts || Time.now.utc).iso8601,
|
|
113
|
+
'source' => source, # claude-code | api-fallback | claude-code-action
|
|
114
|
+
'status' => 'success',
|
|
115
|
+
'agent' => agent.to_s,
|
|
116
|
+
'model' => '',
|
|
117
|
+
'auth' => auth_mode,
|
|
118
|
+
'tokens' => { 'input' => 0, 'output' => 0, 'cache_read' => 0, 'cache_creation' => 0 },
|
|
119
|
+
'model_usage' => {},
|
|
120
|
+
'cost_usd' => 0.0,
|
|
121
|
+
'cost_source' => 'reported',
|
|
122
|
+
'duration_ms' => nil,
|
|
123
|
+
'num_turns' => nil,
|
|
124
|
+
'session_id' => nil,
|
|
125
|
+
'pr' => nil, # attributed later by usage_report.rb
|
|
126
|
+
'pr_source' => nil # 'event' (ran on the PR) | 'created' (run opened it)
|
|
127
|
+
}.merge(ci_context)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def stable_id(payload_anchor)
|
|
131
|
+
Digest::SHA256.hexdigest("#{ENV['GITHUB_RUN_ID']}|#{ENV['GITHUB_JOB']}|#{payload_anchor}")[0, 16]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- ingesters ---------------------------------------------------------------
|
|
135
|
+
# A `claude -p --output-format json` result object (the probed 2026 schema:
|
|
136
|
+
# total_cost_usd, usage{...}, modelUsage{<id>=>{...costUSD}}, num_turns, uuid).
|
|
137
|
+
def from_claude_result(res, agent: '', exit_code: 0, source: 'claude-code')
|
|
138
|
+
rec = base_record(source: source, agent: agent)
|
|
139
|
+
rec['id'] = stable_id(res['uuid'] || res['session_id'] || Digest::SHA256.hexdigest(res.to_s))
|
|
140
|
+
rec['status'] = (res['is_error'] || exit_code.to_i != 0) ? 'error' : 'success'
|
|
141
|
+
rec['duration_ms'] = res['duration_ms']
|
|
142
|
+
rec['num_turns'] = res['num_turns']
|
|
143
|
+
rec['session_id'] = res['session_id']
|
|
144
|
+
# WHY it failed, not just that it did. A rejected call reports zero tokens
|
|
145
|
+
# and an empty model, so without this the record is indistinguishable from
|
|
146
|
+
# any other dead run and the caller's evidence is already gone.
|
|
147
|
+
if rec['status'] == 'error'
|
|
148
|
+
rec['error'] = {
|
|
149
|
+
'subtype' => res['subtype'].to_s,
|
|
150
|
+
'exit_code' => exit_code.to_i,
|
|
151
|
+
'message' => scrub(res['result'] || res['error'])
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
usage = res['usage'] || {}
|
|
156
|
+
rec['tokens'] = {
|
|
157
|
+
'input' => usage['input_tokens'].to_i,
|
|
158
|
+
'output' => usage['output_tokens'].to_i,
|
|
159
|
+
'cache_read' => usage['cache_read_input_tokens'].to_i,
|
|
160
|
+
'cache_creation' => usage['cache_creation_input_tokens'].to_i
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
mu = res['modelUsage'] || {}
|
|
164
|
+
mu.each do |model, u|
|
|
165
|
+
rec['model_usage'][model] = {
|
|
166
|
+
'input' => u['inputTokens'].to_i,
|
|
167
|
+
'output' => u['outputTokens'].to_i,
|
|
168
|
+
'cache_read' => u['cacheReadInputTokens'].to_i,
|
|
169
|
+
'cache_creation' => u['cacheCreationInputTokens'].to_i,
|
|
170
|
+
'cost_usd' => u['costUSD'].to_f
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
# Primary model = the one that did the most output work (subagents may add others).
|
|
174
|
+
rec['model'] = mu.max_by { |_, u| u['outputTokens'].to_i }&.first || ''
|
|
175
|
+
|
|
176
|
+
if res['total_cost_usd']
|
|
177
|
+
rec['cost_usd'] = res['total_cost_usd'].to_f
|
|
178
|
+
rec['cost_source'] = 'reported'
|
|
179
|
+
else
|
|
180
|
+
cache = usage['cache_creation'] || {}
|
|
181
|
+
rec['cost_usd'] = estimate_cost(rec['model'], rec['tokens'].merge(
|
|
182
|
+
'cache_5m' => cache['ephemeral_5m_input_tokens'].to_i,
|
|
183
|
+
'cache_1h' => cache['ephemeral_1h_input_tokens'].to_i
|
|
184
|
+
))
|
|
185
|
+
rec['cost_source'] = 'estimated'
|
|
186
|
+
end
|
|
187
|
+
rec
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A raw Messages API response (api_call.rb fallback). No cost field — estimate.
|
|
191
|
+
def from_api_response(data, agent: '')
|
|
192
|
+
rec = base_record(source: 'api-fallback', agent: agent)
|
|
193
|
+
rec['id'] = stable_id(data['id'] || Digest::SHA256.hexdigest(data.to_s))
|
|
194
|
+
rec['model'] = data['model'].to_s
|
|
195
|
+
rec['status'] = data['stop_reason'] == 'refusal' ? 'error' : 'success'
|
|
196
|
+
usage = data['usage'] || {}
|
|
197
|
+
cache = usage['cache_creation'] || {}
|
|
198
|
+
rec['tokens'] = {
|
|
199
|
+
'input' => usage['input_tokens'].to_i,
|
|
200
|
+
'output' => usage['output_tokens'].to_i,
|
|
201
|
+
'cache_read' => usage['cache_read_input_tokens'].to_i,
|
|
202
|
+
'cache_creation' => usage['cache_creation_input_tokens'].to_i
|
|
203
|
+
}
|
|
204
|
+
rec['cost_usd'] = estimate_cost(rec['model'], rec['tokens'].merge(
|
|
205
|
+
'cache_5m' => cache['ephemeral_5m_input_tokens'] ? cache['ephemeral_5m_input_tokens'].to_i : usage['cache_creation_input_tokens'].to_i,
|
|
206
|
+
'cache_1h' => cache['ephemeral_1h_input_tokens'].to_i
|
|
207
|
+
))
|
|
208
|
+
rec['cost_source'] = 'estimated'
|
|
209
|
+
rec['model_usage'][rec['model']] = rec['tokens'].merge('cost_usd' => rec['cost_usd'])
|
|
210
|
+
rec
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# A claude-code-action execution log: a JSON array (or NDJSON) of stream
|
|
214
|
+
# events. Prefer the final `result` event (same shape as ingest-claude);
|
|
215
|
+
# fall back to summing per-message assistant usage when the log has none.
|
|
216
|
+
def from_execution_log(text, agent: '')
|
|
217
|
+
events =
|
|
218
|
+
begin
|
|
219
|
+
parsed = JSON.parse(text)
|
|
220
|
+
parsed.is_a?(Array) ? parsed : [parsed]
|
|
221
|
+
rescue JSON::ParserError
|
|
222
|
+
text.split("\n").map { |l| JSON.parse(l) rescue nil }.compact
|
|
223
|
+
end
|
|
224
|
+
result = events.reverse.find { |e| e.is_a?(Hash) && e['type'] == 'result' }
|
|
225
|
+
return from_claude_result(result, agent: agent, source: 'claude-code-action') if result
|
|
226
|
+
|
|
227
|
+
rec = base_record(source: 'claude-code-action', agent: agent)
|
|
228
|
+
rec['id'] = stable_id(Digest::SHA256.hexdigest(text))
|
|
229
|
+
turns = 0
|
|
230
|
+
events.each do |e|
|
|
231
|
+
next unless e.is_a?(Hash) && e['type'] == 'assistant'
|
|
232
|
+
msg = e['message'] || {}
|
|
233
|
+
usage = msg['usage'] || {}
|
|
234
|
+
next if usage.empty?
|
|
235
|
+
turns += 1
|
|
236
|
+
model = msg['model'].to_s
|
|
237
|
+
mu = rec['model_usage'][model] ||= { 'input' => 0, 'output' => 0, 'cache_read' => 0, 'cache_creation' => 0, 'cost_usd' => 0.0 }
|
|
238
|
+
mu['input'] += usage['input_tokens'].to_i
|
|
239
|
+
mu['output'] += usage['output_tokens'].to_i
|
|
240
|
+
mu['cache_read'] += usage['cache_read_input_tokens'].to_i
|
|
241
|
+
mu['cache_creation'] += usage['cache_creation_input_tokens'].to_i
|
|
242
|
+
%w[input output cache_read cache_creation].each { |k| rec['tokens'][k] += mu_key(usage, k) }
|
|
243
|
+
end
|
|
244
|
+
rec['num_turns'] = turns
|
|
245
|
+
rec['model'] = rec['model_usage'].max_by { |_, u| u['output'].to_i }&.first || ''
|
|
246
|
+
rec['model_usage'].each do |model, u|
|
|
247
|
+
u['cost_usd'] = estimate_cost(model, u.merge('cache_5m' => u['cache_creation'], 'cache_1h' => 0))
|
|
248
|
+
rec['cost_usd'] += u['cost_usd']
|
|
249
|
+
end
|
|
250
|
+
rec['cost_source'] = 'estimated'
|
|
251
|
+
rec
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def mu_key(usage, key)
|
|
255
|
+
{ 'input' => usage['input_tokens'], 'output' => usage['output_tokens'],
|
|
256
|
+
'cache_read' => usage['cache_read_input_tokens'],
|
|
257
|
+
'cache_creation' => usage['cache_creation_input_tokens'] }[key].to_i
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# --- sink ---------------------------------------------------------------------
|
|
261
|
+
def append(rec, to: nil)
|
|
262
|
+
path = to || records_path
|
|
263
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
264
|
+
File.open(path, 'a') { |io| io.puts(JSON.generate(rec)) }
|
|
265
|
+
rec
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# --- CLI -------------------------------------------------------------------------
|
|
270
|
+
if __FILE__ == $PROGRAM_NAME
|
|
271
|
+
cmd = ARGV.shift
|
|
272
|
+
agent = ''
|
|
273
|
+
rc = 0
|
|
274
|
+
emit = false
|
|
275
|
+
file = nil
|
|
276
|
+
args = ARGV.dup
|
|
277
|
+
until args.empty?
|
|
278
|
+
case (a = args.shift)
|
|
279
|
+
when '--agent' then agent = args.shift.to_s
|
|
280
|
+
when '--rc' then rc = args.shift.to_i
|
|
281
|
+
when '--emit-result' then emit = true
|
|
282
|
+
else file ||= a
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
case cmd
|
|
287
|
+
when 'ingest-claude'
|
|
288
|
+
abort '[usage] no input file' unless file && File.exist?(file)
|
|
289
|
+
res = begin
|
|
290
|
+
JSON.parse(File.read(file, encoding: 'UTF-8'))
|
|
291
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
292
|
+
warn '[usage] not a Claude result JSON — nothing recorded.'
|
|
293
|
+
exit 65
|
|
294
|
+
end
|
|
295
|
+
unless res.is_a?(Hash) && (res.key?('usage') || res.key?('total_cost_usd'))
|
|
296
|
+
warn '[usage] JSON has no usage payload — nothing recorded.'
|
|
297
|
+
exit 65
|
|
298
|
+
end
|
|
299
|
+
rec = AIUsage.append(AIUsage.from_claude_result(res, agent: agent, exit_code: rc))
|
|
300
|
+
warn "[usage] recorded #{rec['id']}: #{rec['model']} $#{format('%.4f', rec['cost_usd'])} (#{rec['tokens']['output']} out tok, #{rec['status']})"
|
|
301
|
+
warn "[usage] failure: #{[rec['error']['subtype'], rec['error']['message']].reject { |v| v.to_s.empty? }.join(' — ')}" if rec['error']
|
|
302
|
+
# Emit the result text only on success — on error the caller falls back to
|
|
303
|
+
# the API path, and emitting here would double the output it produces.
|
|
304
|
+
print res['result'].to_s if emit && rec['status'] == 'success'
|
|
305
|
+
exit(rec['status'] == 'success' ? 0 : 1)
|
|
306
|
+
when 'ingest-execution-log'
|
|
307
|
+
abort '[usage] no input file' unless file && File.exist?(file)
|
|
308
|
+
rec = AIUsage.append(AIUsage.from_execution_log(File.read(file, encoding: 'UTF-8'), agent: agent))
|
|
309
|
+
warn "[usage] recorded #{rec['id']}: #{rec['model']} $#{format('%.4f', rec['cost_usd'])} (#{rec['source']})"
|
|
310
|
+
else
|
|
311
|
+
abort "usage: usage.rb ingest-claude <result.json> [--agent X] [--rc N] [--emit-result]\n" \
|
|
312
|
+
" usage.rb ingest-execution-log <log.json> [--agent X]"
|
|
313
|
+
end
|
|
314
|
+
end
|