Skip to content

Commit 265feff

Browse files
committed
feat(cli): plan video creation around remaining harness usage
1 parent fd2f90c commit 265feff

10 files changed

Lines changed: 409 additions & 4 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Usage budget contract</title><style>
2+
:root{color-scheme:light dark;
3+
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
4+
--go:#178a5a;--go-bg:#e8f6ee;--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
5+
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
6+
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
7+
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
8+
*{box-sizing:border-box}
9+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
10+
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
11+
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
12+
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
13+
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
14+
.strip .sep{opacity:.5}
15+
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
16+
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
17+
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
18+
.pill b{font-size:13px}
19+
section{margin-top:56px}
20+
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
21+
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
22+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
23+
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
24+
.breath b{color:var(--go)}
25+
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
26+
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
27+
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
28+
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
29+
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
30+
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
31+
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
32+
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
33+
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
34+
tr:last-child td{border-bottom:0}
35+
td.k{font:13px var(--mono);white-space:nowrap}
36+
.decision{border-left:4px solid var(--info)}
37+
.decision h3{margin:0 0 8px;font-size:17px}
38+
.decision .ask{color:var(--info);font-weight:600}
39+
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
40+
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
41+
ul,ol{padding-left:22px}li{margin:6px 0}
42+
.mermaid{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
43+
.mermaid svg{max-width:100%}
44+
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
45+
</style><main class="wrap"><header><h1>Get a video ready to watch before usage runs out</h1><p class="what">Verify the usage read before adding one CLI command and a short rule to the skills.</p><div class="strip">OpenUsage @ ae49de04a2b7fc1b3cc334fcc1009276c46d82ad · 2026-10-02 · source audited, live subscription read not exercised</div></header>
46+
<section><div class="card breath">Subscription limits are a fuel gauge, not a trip estimate. They tell us which window is almost full, but cannot prove how many agent turns a video needs.</div></section>
47+
<section><h2>Provisional policy</h2><div class="card decision"><h3>1. What counts as low?</h3><p>Default: 20% or less remaining in either the shared session or weekly window triggers first-cut-first.</p><table><tr><th>Option</th><th>Consequence</th></tr><tr><td>10% remaining</td><td>Preserves optional work longer, leaves less reserve for render repair.</td></tr><tr><td>20% remaining</td><td>Earlier first cut, fewer optional drafts.</td></tr><tr><td>30% remaining</td><td>More reserve, more runs lose optional work.</td></tr></table><p>No measured run-cost distribution supports an exact threshold yet.</p></div><div class="card decision"><h3>2. Tell the user before changing the flow?</h3><p>Default: one sentence naming the measured remaining percentage and first-cut-first plan. Silent adaptation is shorter but hides why the storyboard and fan-out were reduced.</p></div></section>
48+
<section><h2>Where the value travels</h2><div class="card" style="display:flex;gap:12px;flex-wrap:wrap"><span>Existing Claude login</span><b>→</b><span>OAuth usage GET</span><b>→</b><span>Window percentages</span><b>→</b><span>CLI JSON</span><b>→</b><span>Skill plan</span></div><p>The first three links are source-audited. The CLI reader and skill consumers are implemented; live subscribed proof is pending.</p></section>
49+
<section><h2>The verified boundaries</h2><div class="card"><h3>Login read</h3><p class="plain">Use the existing subscription login. Do not ask for a new token.</p><pre>claudeAiOauth: { accessToken?, refreshToken?, expiresAt?, subscriptionType?, rateLimitTier?, scopes? }</pre><p>Keychain candidates precede the credential file. Custom CLAUDE_CONFIG_DIR adds a SHA-256-derived service suffix. Credentials are parsed once. Explicit inference-only tokens cannot read limits.</p><a href="https://github.com/robinebers/openusage/blob/ae49de04a2b7fc1b3cc334fcc1009276c46d82ad/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift#L340-L430">Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift:340-430</a><br><a href="https://github.com/robinebers/openusage/blob/ae49de04a2b7fc1b3cc334fcc1009276c46d82ad/Sources/OpenUsage/Providers/Claude/ClaudeCredentials.swift#L3-L26">Sources/OpenUsage/Providers/Claude/ClaudeCredentials.swift:3-26</a><br><a href="https://github.com/robinebers/openusage/blob/ae49de04a2b7fc1b3cc334fcc1009276c46d82ad/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift#L249-L270">Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift:249-270</a><p>Exists: yes. Reachable: source verified. Read: OpenUsage reads it; this machine has no readable subscription login. The reader does not rotate or write tokens.</p></div>
50+
<div class="card"><h3>Usage request</h3><p class="plain">A subscription token asks Anthropic for the current capacity windows.</p><pre>GET https://api.anthropic.com/api/oauth/usage
51+
Authorization: Bearer &lt;existing access token&gt;
52+
anthropic-beta: oauth-2025-04-20
53+
Accept: application/json</pre><p>OpenUsage adds cedar_ember=1 for reset grants and a Claude CLI User-Agent. Reset grants are unnecessary for this task. Its request timeout is 10 seconds.</p><a href="https://github.com/robinebers/openusage/blob/ae49de04a2b7fc1b3cc334fcc1009276c46d82ad/Sources/OpenUsage/Providers/Claude/ClaudeUsageClient.swift#L94-L120">Sources/OpenUsage/Providers/Claude/ClaudeUsageClient.swift:94-120</a><p>Exists: OpenUsage client verified. Reachable: endpoint was not called without credentials. Written/read: upstream server implementation unavailable; runtime response shape is inferred from OpenUsage's actual consumer.</p></div>
54+
<div class="card"><h3>Window consumer</h3><p class="plain">Each shared window gives percent used and a reset time. Remaining is 100 minus used.</p><pre>five_hour: { utilization, resets_at }
55+
seven_day: { utilization, resets_at }
56+
seven_day_sonnet: { utilization, resets_at }
57+
limits: [{ kind: "weekly_scoped", scope: { model: { display_name } }, percent, resets_at }]</pre><a href="https://github.com/robinebers/openusage/blob/ae49de04a2b7fc1b3cc334fcc1009276c46d82ad/Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift#L26-L31">Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift:26-31</a><br><a href="https://github.com/robinebers/openusage/blob/ae49de04a2b7fc1b3cc334fcc1009276c46d82ad/Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift#L139-L175">Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift:139-175</a><p>These are accepting lines, not a published server schema. Model limits are separate from shared limits; choosing an unrelated model's limit would misstate the active harness budget.</p></div></section>
58+
<section><h2>How it says no</h2><table><tr><th>Signal</th><th>Meaning</th><th>Behavior</th></tr><tr><td>No credentials, API key, inference-only</td><td>No readable subscription limit</td><td>unknown; existing skill flow</td></tr><tr><td>401 / 403</td><td>Expired or insufficient scope</td><td>unknown; no token refresh or writes</td></tr><tr><td>429</td><td>Usage read throttled</td><td>unknown; no immediate retries</td></tr><tr><td>Timeout / malformed response</td><td>Capacity unverified</td><td>unknown; continue</td></tr></table><p>The brief says “Never send usage data anywhere.” A remote authenticated GET is necessary for live windows. Interpret this as no telemetry or forwarding of usage results. Authentication goes only to the provider.</p></section>
59+
<section><h2>Coverage, honestly</h2><p>Audited: OpenUsage auth order, request, consumer, MIT license. HyperFrames main searched for remaining-usage planning; no matching implementation found at fd2f90c7d81d4c2a57848bb21e225cbccc66ec4f. commandUsageResolution.ts resolves help subcommands, not subscription usage.</p><p>Trusting: Anthropic response fields inferred from OpenUsage consumer. Not exercised at the time of this audit: live subscription response, skill adaptation, render. Fixture verification is reported with the implementation.</p><pre>Actual local probe:
60+
{ "status": "unknown", "reason": "no_readable_subscription_login" }</pre><p>MIT permits reuse with the license notice for substantial copied portions. Recommendation: implement the small read independently, cite the method, do not vendor the app.</p></section>
61+
<section><h2>What would prove this wrong?</h2><p>A valid subscribed Claude login whose live response does not match these fields. A low-budget blank-agent run that spends optional work before its first MP4. A credential fixture where another profile's login supplies the budget. These need explicit verification before merge.</p></section></main></html>

‎packages/cli/src/cli.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ const commandLoaders = {
168168
browser: () => import("./commands/browser.js").then((m) => m.default),
169169
"remove-background": () => import("./commands/remove-background.js").then((m) => m.default),
170170
transcribe: () => import("./commands/transcribe.js").then((m) => m.default),
171+
usage: () => import("./commands/usage.js").then((m) => m.default),
171172
models: () => import("./commands/models.js").then((m) => m.default),
172173
tts: () => import("./commands/tts.js").then((m) => m.default),
173174
docs: () => import("./commands/docs.js").then((m) => m.default),
@@ -246,7 +247,13 @@ let telemetryReady: Promise<void> = Promise.resolve();
246247
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
247248
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
248249
// printed into a skill's captured output).
249-
if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") {
250+
if (
251+
!isHelp &&
252+
command !== "telemetry" &&
253+
command !== "events" &&
254+
command !== "usage" &&
255+
command !== "unknown"
256+
) {
250257
telemetryReady = import("./telemetry/index.js").then((mod) => {
251258
_flushSync = mod.flushSync;
252259
_trackCliError = mod.trackCliError;
@@ -273,7 +280,8 @@ if (
273280
command !== "upgrade" &&
274281
command !== "events" &&
275282
command !== "telemetry" &&
276-
command !== "skills"
283+
command !== "skills" &&
284+
command !== "usage"
277285
) {
278286
// Report any completed auto-install from the previous run first, before
279287
// kicking off the next check — so the user sees "updated to vX" once and
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { execFileSync } from "node:child_process";
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join, resolve } from "node:path";
5+
import { expect, it } from "vitest";
6+
7+
it("prints the real CLI plan while keeping usage out of telemetry and other network requests", () => {
8+
const profile = mkdtempSync(join(tmpdir(), "hf-usage-"));
9+
try {
10+
writeFileSync(
11+
join(profile, ".credentials.json"),
12+
JSON.stringify({
13+
claudeAiOauth: { accessToken: "fixture-token", scopes: ["user:profile"] },
14+
}),
15+
);
16+
const requests = join(profile, "requests.jsonl");
17+
const preload = join(profile, "transport.mjs");
18+
writeFileSync(
19+
preload,
20+
`
21+
import { appendFileSync } from "node:fs";
22+
globalThis.fetch = async (url, options) => {
23+
appendFileSync(${JSON.stringify(requests)}, JSON.stringify(String(url)) + "\\n");
24+
if (String(url) !== "https://api.anthropic.com/api/oauth/usage") throw new Error("unexpected network request");
25+
if (options.headers.Authorization !== "Bearer fixture-token") throw new Error("wrong credential");
26+
return new Response(JSON.stringify({five_hour:{utilization:90},seven_day:{utilization:20}}));
27+
};
28+
`,
29+
);
30+
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDE_CONFIG_DIR: profile };
31+
for (const key of [
32+
"ANTHROPIC_API_KEY",
33+
"ANTHROPIC_AUTH_TOKEN",
34+
"ANTHROPIC_BASE_URL",
35+
"CLAUDE_CODE_CUSTOM_OAUTH_URL",
36+
"USE_LOCAL_OAUTH",
37+
"USE_STAGING_OAUTH",
38+
])
39+
delete env[key];
40+
const stdout = execFileSync(
41+
process.execPath,
42+
[
43+
"--import",
44+
"tsx",
45+
"--import",
46+
preload,
47+
resolve("src/cli.ts"),
48+
"usage",
49+
"--harness",
50+
"claude-code",
51+
"--json",
52+
],
53+
{
54+
cwd: resolve("."),
55+
env,
56+
encoding: "utf8",
57+
timeout: 15000,
58+
},
59+
);
60+
expect(JSON.parse(stdout)).toMatchObject({
61+
status: "known",
62+
remainingPercent: 10,
63+
plan: "first-cut-first",
64+
});
65+
expect(stdout).not.toContain("fixture-token");
66+
expect(readFileSync(requests, "utf8").trim().split("\n")).toEqual([
67+
'"https://api.anthropic.com/api/oauth/usage"',
68+
]);
69+
} finally {
70+
rmSync(profile, { recursive: true, force: true });
71+
}
72+
});

‎packages/cli/src/commands/usage.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { defineCommand } from "citty";
2+
import { readHarnessUsage } from "../utils/harnessUsage.js";
3+
4+
export default defineCommand({
5+
meta: {
6+
name: "usage",
7+
description: "Read remaining harness subscription usage without telemetry",
8+
},
9+
args: {
10+
harness: {
11+
type: "string",
12+
description: "Harness to read (claude-code); otherwise detect the current harness",
13+
},
14+
json: { type: "boolean", description: "Print the usage and run plan as JSON", default: false },
15+
},
16+
async run({ args }) {
17+
const harness =
18+
args.harness ??
19+
(process.env.CLAUDECODE && !process.env.CODEX_THREAD_ID ? "claude-code" : "unknown");
20+
const usage = await readHarnessUsage(harness);
21+
if (args.json) console.log(JSON.stringify(usage));
22+
else if (usage.status === "unknown")
23+
console.log(`Usage unknown (${usage.reason}); continue with the standard plan.`);
24+
else
25+
console.log(
26+
usage.message ??
27+
`${usage.remainingPercent}% usage remaining; continue with the standard plan.`,
28+
);
29+
},
30+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { execFile } from "node:child_process";
2+
import { createHash } from "node:crypto";
3+
import { readFile } from "node:fs/promises";
4+
import { homedir } from "node:os";
5+
import { join } from "node:path";
6+
import { promisify } from "node:util";
7+
import Ajv from "ajv/dist/jtd.js";
8+
import type { JTDDataType } from "ajv/dist/jtd.js";
9+
import { parseHarnessUsage, unknownUsage, type HarnessUsage } from "./usageBudget.js";
10+
11+
const credentialSchema = {
12+
properties: {
13+
claudeAiOauth: {
14+
properties: { accessToken: { type: "string" } },
15+
optionalProperties: {
16+
expiresAt: { type: "float64" },
17+
scopes: { elements: { type: "string" } },
18+
},
19+
additionalProperties: true,
20+
},
21+
},
22+
additionalProperties: true,
23+
} as const;
24+
const parseCredentials = new Ajv().compileParser<JTDDataType<typeof credentialSchema>>(
25+
credentialSchema,
26+
);
27+
const exec = promisify(execFile);
28+
29+
export async function readHarnessUsage(harness: string): Promise<HarnessUsage> {
30+
if (harness !== "claude-code") return unknownUsage("unsupported_harness");
31+
if (
32+
process.env.ANTHROPIC_API_KEY ||
33+
process.env.ANTHROPIC_AUTH_TOKEN ||
34+
process.env.ANTHROPIC_BASE_URL ||
35+
process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ||
36+
process.env.USE_LOCAL_OAUTH ||
37+
process.env.USE_STAGING_OAUTH
38+
) {
39+
return unknownUsage("unsupported_auth");
40+
}
41+
const configDir = process.env.CLAUDE_CONFIG_DIR;
42+
const candidates: string[] = [];
43+
if (process.platform === "darwin") {
44+
let service = "Claude Code-credentials";
45+
if (configDir)
46+
service += `-${createHash("sha256").update(configDir.normalize("NFC")).digest("hex").slice(0, 8)}`;
47+
try {
48+
const { stdout } = await exec("security", ["find-generic-password", "-s", service, "-w"], {
49+
timeout: 2000,
50+
maxBuffer: 1024 * 1024,
51+
});
52+
candidates.push(stdout);
53+
} catch {
54+
// An unavailable keychain leaves the credential file as the read-only fallback.
55+
}
56+
}
57+
try {
58+
candidates.push(
59+
await readFile(join(configDir ?? join(homedir(), ".claude"), ".credentials.json"), "utf8"),
60+
);
61+
} catch {
62+
// No login file is normal for API-key and unsupported harness sessions.
63+
}
64+
let reason = "no_subscription_login";
65+
for (const text of candidates) {
66+
const credentials = parseCredentials(text)?.claudeAiOauth;
67+
if (!credentials || !credentials.accessToken.trim()) continue;
68+
if (credentials.expiresAt !== undefined && credentials.expiresAt <= Date.now()) {
69+
reason = "expired_login";
70+
continue;
71+
}
72+
if (credentials.scopes !== undefined && !credentials.scopes.includes("user:profile")) {
73+
reason = "missing_profile_scope";
74+
continue;
75+
}
76+
let response: Response;
77+
let body: string;
78+
try {
79+
response = await fetch("https://api.anthropic.com/api/oauth/usage", {
80+
headers: {
81+
Authorization: `Bearer ${credentials.accessToken.trim()}`,
82+
Accept: "application/json",
83+
"anthropic-beta": "oauth-2025-04-20",
84+
},
85+
signal: AbortSignal.timeout(5000),
86+
redirect: "error",
87+
});
88+
if (response.status === 401 || response.status === 403) {
89+
reason = `http_${response.status}`;
90+
continue;
91+
}
92+
if (!response.ok) return unknownUsage(`http_${response.status}`);
93+
body = await response.text();
94+
} catch {
95+
return unknownUsage("usage_request_failed");
96+
}
97+
return parseHarnessUsage(body);
98+
}
99+
return unknownUsage(reason);
100+
}

0 commit comments

Comments
 (0)