feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27. #19 — a saved auth profile now reports how much of its account's quota is already spent, which is the only number that decides which one is worth switching to. Nothing under CLAUDE_HOME records it, so it comes from the same endpoint the CLI's own /usage reads, answered for whichever account's OAuth token is presented. It is a POST and a button, never a poll: every profile that is not the active one is normally expired, so answering means refreshing it first, and a refresh ROTATES the stored refresh token. The rotated pair is written straight back — dropping it would leave the profile holding a credential Anthropic has already retired, i.e. an account needing a fresh login. The token is read, put in an Authorization header, and never returned, logged or echoed; what leaves the server is two percentages and two reset times. #20 — a settings patch that changes nothing wrote nothing but still took a timestamped backup, so opening the config panel quietly grew a backup directory inside every lane. patchSettings now compares the rendered output with what is already on disk and returns early, fixed once where all callers route through. A lane's git exclude also picks up .claude/.cc-config-backups/ beside settings.local.json. #21 — the router apply route was the only mutating lane route that never broadcast its lane. The write reached disk and the column, but the client's cached row kept the old router_profile forever: the picker looked right until the user switched lanes and back, at which point the effect re-read the stale row and flipped the selection to the previous profile. One broadcastLane, plus a lane-switch guard so an apply that resolves after the user has moved on no longer paints its result onto whichever lane is now on screen. #27 — Add-lane in worktree mode left the dialog up after a clean setup with a dead confirm button and no obvious way out. It closes itself when nothing failed, and when something did, the summary stays and the confirm becomes a real Close.
This commit is contained in:
@@ -606,6 +606,24 @@ describe("patchSettings — backup and atomicity", () => {
|
||||
assert.deepEqual(backup, original);
|
||||
});
|
||||
|
||||
it("writes and backs up nothing when the patch changes nothing", () => {
|
||||
// Re-attaching the same AI Router profile sends the same eight env keys
|
||||
// every time. With an unconditional backup that left a `.bak` per apply
|
||||
// piling up inside the user's own project — a dozen untracked files in a
|
||||
// minute of switching lanes.
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
|
||||
const before = fs.readFileSync(file, "utf8");
|
||||
|
||||
const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
|
||||
|
||||
assert.equal(result.backupPath, null);
|
||||
assert.deepEqual(result.changed, ["model"], "the request was still satisfied");
|
||||
assert.equal(fs.readFileSync(file, "utf8"), before);
|
||||
const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings");
|
||||
assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []);
|
||||
});
|
||||
|
||||
it("returns null backupPath for a fresh file", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file cc-usage.test.js
|
||||
* @description Tests for per-profile quota lookup.
|
||||
*
|
||||
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
|
||||
* must be written back — the exchange retires the refresh token it was given,
|
||||
* so dropping the result leaves an account the user has to log into again. And
|
||||
* a still-valid token must NOT be refreshed, because every refresh rewrites
|
||||
* credentials on disk and the point of a read is to read.
|
||||
*
|
||||
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
|
||||
process.env.CLAUDE_HOME = TMP;
|
||||
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const usage = require("../lib/cc-usage");
|
||||
|
||||
const realFetch = global.fetch;
|
||||
let calls = [];
|
||||
|
||||
after(() => {
|
||||
global.fetch = realFetch;
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
|
||||
} catch {
|
||||
/* best-effort temp cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
function writeProfileFile(name, credentials) {
|
||||
const dir = profiles.profilesDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${name}.json`),
|
||||
JSON.stringify({
|
||||
name,
|
||||
label: "Label kept",
|
||||
savedAt: 1,
|
||||
account: { emailAddress: "a@b.c" },
|
||||
credentials,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Answer the token endpoint and the usage endpoint, recording both. */
|
||||
function stubFetch({ usageStatus = 200, usageBody } = {}) {
|
||||
global.fetch = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes("/v1/oauth/token")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
access_token: "fresh-access",
|
||||
refresh_token: "rotated-refresh",
|
||||
expires_in: 28800,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: usageStatus === 200,
|
||||
status: usageStatus,
|
||||
json: async () =>
|
||||
usageBody || {
|
||||
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
|
||||
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
|
||||
seven_day_opus: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("profileUsage", () => {
|
||||
it("reports the two windows, and only those", async () => {
|
||||
writeProfileFile("live", {
|
||||
accessToken: "still-good",
|
||||
refreshToken: "r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("live");
|
||||
|
||||
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
|
||||
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
|
||||
// Nothing token-shaped may be in what a route is about to serialise.
|
||||
assert.equal(JSON.stringify(result).includes("still-good"), false);
|
||||
});
|
||||
|
||||
it("does not refresh a token that is still valid", async () => {
|
||||
writeProfileFile("live", {
|
||||
accessToken: "still-good",
|
||||
refreshToken: "r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("live");
|
||||
|
||||
assert.equal(result.refreshed, false);
|
||||
assert.equal(
|
||||
calls.some((c) => c.url.includes("/v1/oauth/token")),
|
||||
false,
|
||||
"a read must not rewrite credentials on disk"
|
||||
);
|
||||
assert.match(calls[0].init.headers.Authorization, /still-good/);
|
||||
});
|
||||
|
||||
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
|
||||
// The exchange retires the refresh token it was handed. A refresh whose
|
||||
// result is not persisted leaves the profile holding a dead credential —
|
||||
// the account could never be activated again without a fresh login.
|
||||
writeProfileFile("stale", {
|
||||
accessToken: "expired",
|
||||
refreshToken: "original-refresh",
|
||||
expiresAt: Date.now() - 1000,
|
||||
subscriptionType: "team",
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("stale");
|
||||
|
||||
assert.equal(result.refreshed, true);
|
||||
const stored = profiles.readProfile("stale");
|
||||
assert.equal(stored.credentials.accessToken, "fresh-access");
|
||||
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
|
||||
assert.ok(stored.credentials.expiresAt > Date.now());
|
||||
// Everything else about the profile survives the rewrite.
|
||||
assert.equal(stored.label, "Label kept");
|
||||
assert.equal(stored.credentials.subscriptionType, "team");
|
||||
assert.equal(stored.account.emailAddress, "a@b.c");
|
||||
});
|
||||
|
||||
it("retries once through a refresh when a token that looked valid is refused", async () => {
|
||||
writeProfileFile("revoked", {
|
||||
accessToken: "revoked-but-unexpired",
|
||||
refreshToken: "original-refresh",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
let first = true;
|
||||
global.fetch = async (url) => {
|
||||
calls.push({ url: String(url) });
|
||||
if (String(url).includes("/v1/oauth/token")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
|
||||
};
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
return { ok: false, status: 401, json: async () => ({}) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
|
||||
};
|
||||
};
|
||||
|
||||
const result = await usage.profileUsage("revoked");
|
||||
|
||||
assert.equal(result.refreshed, true);
|
||||
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
|
||||
});
|
||||
|
||||
it("refuses a profile with no refresh token instead of half-answering", async () => {
|
||||
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
|
||||
stubFetch();
|
||||
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("tokenless"),
|
||||
(e) => e.code === "ENOREFRESH"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unknown profile as ENOTFOUND", async () => {
|
||||
stubFetch();
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("nope"),
|
||||
(e) => e.code === "ENOTFOUND"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -277,6 +277,50 @@ describe("/api/lanes", () => {
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on router apply", async () => {
|
||||
// Without this the client's cached row keeps the OLD router_profile: the
|
||||
// picker looks right until the user switches lanes and back, at which
|
||||
// point the effect re-reads the stale row and flips the selection.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
|
||||
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
|
||||
const id = created.body.lane.id;
|
||||
// Start somewhere other than native, so the broadcast has to carry a
|
||||
// CHANGED value rather than the one the row already held.
|
||||
require("../lib/lanes").setRouterProfile(id, "some-router");
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
|
||||
const messages = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
ws.on("message", (msg) => {
|
||||
try {
|
||||
messages.push(JSON.parse(msg));
|
||||
} catch {
|
||||
/* parse error */
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(applied.status, 200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
|
||||
assert.ok(msg, "router apply should broadcast lane_update");
|
||||
// `native` is stored as null — what matters is that the pushed row is the
|
||||
// post-apply one, not the "some-router" the client still has cached.
|
||||
assert.equal(msg.data.lane.router_profile, null);
|
||||
} finally {
|
||||
// A socket left open by a failed assertion keeps the whole run alive.
|
||||
ws.close();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
|
||||
@@ -571,6 +571,17 @@ function patchSettings(args) {
|
||||
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
|
||||
}
|
||||
|
||||
// A patch that changes nothing writes nothing — and therefore backs up
|
||||
// nothing. Re-applying the same AI Router profile to a lane is the common
|
||||
// case (every re-attach sends the same eight env keys), and an unconditional
|
||||
// backup turned that into a `.bak` per apply piling up inside the user's
|
||||
// project. `changed` still lists the keys the caller asked for: they ARE the
|
||||
// file's content now, the request was satisfied, there was just nothing to
|
||||
// move to get there.
|
||||
if (existed && output === raw) {
|
||||
return { path: file, backupPath: null, created: false, changed };
|
||||
}
|
||||
|
||||
let backupPath = null;
|
||||
if (existed) {
|
||||
const root = settingsBackupRoot(scope, opts);
|
||||
|
||||
@@ -368,12 +368,26 @@ function deleteProfile(name) {
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which
|
||||
* has to write a rotated OAuth token pair straight back: the exchange retires
|
||||
* the refresh token it was given, so a profile whose refreshed credentials are
|
||||
* not persisted is a profile that can no longer be activated.
|
||||
*/
|
||||
function writeProfile(name, value) {
|
||||
writeJson(profileFile(name), value);
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listProfiles,
|
||||
saveProfile,
|
||||
renameProfile,
|
||||
activateProfile,
|
||||
deleteProfile,
|
||||
// Token-bearing reads/writes: for cc-usage.js, never for a route.
|
||||
readProfile,
|
||||
writeProfile,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file cc-usage.js
|
||||
* @description How much of an Anthropic account's quota is already spent —
|
||||
* the number that decides which saved auth profile (`cc-profiles.js`) is worth
|
||||
* switching to.
|
||||
*
|
||||
* There is no local source for it. Nothing under CLAUDE_HOME records a
|
||||
* utilization or a reset time; the credentials file carries only
|
||||
* `subscriptionType` and `rateLimitTier`. The figure comes from the same
|
||||
* endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for
|
||||
* whichever account's OAuth token is presented.
|
||||
*
|
||||
* Two rules this module exists to hold:
|
||||
*
|
||||
* - **A profile's token is sent to exactly one place: Anthropic.** It is read
|
||||
* from the profile file, put in an `Authorization` header, and never
|
||||
* returned, logged or echoed. What leaves this module is two percentages
|
||||
* and two reset timestamps.
|
||||
*
|
||||
* - **Nothing here runs on a poll.** A saved profile's access token is short
|
||||
* lived — every profile that is not the active one is normally expired — so
|
||||
* reading its usage means refreshing it first, and a refresh ROTATES the
|
||||
* stored refresh token. Doing that on a page load would rewrite the user's
|
||||
* credentials for every account they have, repeatedly, for a number on a
|
||||
* screen. It is therefore driven by an explicit per-profile request, and the
|
||||
* rotated tokens are written straight back (`cc-profiles.writeProfile`) —
|
||||
* a refresh whose result is dropped leaves the profile holding a refresh
|
||||
* token Anthropic has already retired, i.e. an account the user must log in
|
||||
* to again.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const profiles = require("./cc-profiles");
|
||||
|
||||
/** Claude Code's own public OAuth client. A refresh is only accepted for the
|
||||
* client the token was issued to, so this is not a configurable value. */
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
|
||||
/** Treat a token expiring within this window as already expired: a refresh is
|
||||
* cheaper than a 401 that costs the whole call. */
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message);
|
||||
e.code = code;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw err("EUPSTREAM", `${url} did not answer JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a profile's refresh token for a fresh access token AND persist what
|
||||
* comes back.
|
||||
*
|
||||
* The persist is not an optimisation. Anthropic returns a new refresh token and
|
||||
* retires the one just used, so a refresh that is not written back leaves the
|
||||
* profile file holding a dead credential — the account would have to be logged
|
||||
* into again before it could ever be activated.
|
||||
*
|
||||
* @param {string} name a saved profile's identifier
|
||||
* @returns {Promise<string>} the fresh access token (never returned to a route)
|
||||
*/
|
||||
async function refreshProfile(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
if (!creds.refreshToken) {
|
||||
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
|
||||
}
|
||||
|
||||
const data = await postJson(TOKEN_URL, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: creds.refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
|
||||
|
||||
profiles.writeProfile(name, {
|
||||
...stored,
|
||||
credentials: {
|
||||
...creds,
|
||||
accessToken: data.access_token,
|
||||
// Both are rotated by the exchange; keeping the old refresh token here
|
||||
// would pin the profile to a credential that no longer works.
|
||||
refreshToken: data.refresh_token || creds.refreshToken,
|
||||
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
|
||||
},
|
||||
});
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** The two windows the UI reports, flattened out of a response that also
|
||||
* carries a dozen experiment-named buckets this has no use for. */
|
||||
function windowOf(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const utilization = Number(value.utilization);
|
||||
return {
|
||||
utilization: Number.isFinite(utilization) ? utilization : null,
|
||||
resetsAt: typeof value.resets_at === "string" ? value.resets_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask Anthropic what this token's account has spent. */
|
||||
async function fetchUsage(accessToken) {
|
||||
const res = await fetch(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token");
|
||||
if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage for one saved profile, refreshing its token first when that token has
|
||||
* expired (which, for any profile that is not the active one, it usually has).
|
||||
*
|
||||
* @param {string} name a saved profile's identifier
|
||||
* @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null,
|
||||
* sevenDay:object|null, checkedAt:string}>} percentages and reset times only
|
||||
*/
|
||||
async function profileUsage(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
|
||||
let token = fresh ? creds.accessToken : await refreshProfile(name);
|
||||
let refreshed = !fresh;
|
||||
if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`);
|
||||
|
||||
try {
|
||||
const usage = await fetchUsage(token);
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
} catch (e) {
|
||||
// A token that looked fresh but was revoked server-side is worth one
|
||||
// retry through the refresh path; a second 401 is a real answer.
|
||||
if (e.code !== "EUNAUTHORIZED" || refreshed) throw e;
|
||||
token = await refreshProfile(name);
|
||||
refreshed = true;
|
||||
const usage = await fetchUsage(token);
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { profileUsage, fetchUsage, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL };
|
||||
@@ -105,18 +105,21 @@ function gitDirOf(laneDir) {
|
||||
* must not fail an otherwise-successful attach.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
const line = ".claude/settings.local.json";
|
||||
// The backup directory beside it, for the same reason plus one more: those
|
||||
// copies hold the API key too, and they are what a user sees as a dozen
|
||||
// untracked files appearing in their project while they switch routers.
|
||||
const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"];
|
||||
try {
|
||||
const gitDir = gitDirOf(laneDir);
|
||||
if (!gitDir) return;
|
||||
const excludePath = path.join(gitDir, "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
if (existing.split("\n").includes(line)) return;
|
||||
fs.writeFileSync(
|
||||
excludePath,
|
||||
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n`
|
||||
);
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
} catch {
|
||||
/* best effort — see above */
|
||||
}
|
||||
|
||||
@@ -1297,6 +1297,25 @@ const schemas = {
|
||||
},
|
||||
},
|
||||
},
|
||||
CcConfigUsageWindow: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description: "One quota window as the account reports it.",
|
||||
properties: {
|
||||
utilization: {
|
||||
type: "number",
|
||||
nullable: true,
|
||||
description: "Percent of the window's allowance already spent, 0-100.",
|
||||
example: 48,
|
||||
},
|
||||
resetsAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
nullable: true,
|
||||
description: "When the window rolls over, when the account reports one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reusable parameters (inlined per-operation to match base style) ────
|
||||
@@ -2649,6 +2668,74 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/profiles/usage": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "How much of a saved profile's account quota is already spent",
|
||||
description:
|
||||
"Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.\n\nA POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.",
|
||||
operationId: "ccConfigProfileUsage",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string", example: "work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Percentages and reset times for the two quota windows.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
refreshed: {
|
||||
type: "boolean",
|
||||
description: "True when the stored token was expired and had to be rotated.",
|
||||
},
|
||||
fiveHour: { $ref: "#/components/schemas/CcConfigUsageWindow" },
|
||||
sevenDay: { $ref: "#/components/schemas/CcConfigUsageWindow" },
|
||||
checkedAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "`EUNAUTHORIZED` - the account refused the token even after a refresh.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "`ENOTFOUND` - no profile by that name.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
502: {
|
||||
description: "`EUPSTREAM` - Anthropic's token or usage endpoint did not cooperate.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
@@ -30,6 +30,7 @@ const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
const ccMcpCli = require("../lib/cc-mcp-cli");
|
||||
const ccProfiles = require("../lib/cc-profiles");
|
||||
const ccUsage = require("../lib/cc-usage");
|
||||
const projectMemory = require("../lib/project-memory");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { broadcast } = require("../websocket");
|
||||
@@ -577,6 +578,31 @@ router.post("/profiles/activate", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /profiles/usage — how much of this profile's account quota is spent.
|
||||
*
|
||||
* A POST, and behind the same-origin guard, because it is not a read: a saved
|
||||
* profile's access token has almost always expired, so answering means
|
||||
* refreshing it, and the refresh rotates the stored credentials on disk (see
|
||||
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
|
||||
* per profile, when the user asks.
|
||||
*
|
||||
* The response is two percentages and two reset timestamps. No token material
|
||||
* crosses this boundary, in either direction: the name is all the caller sends.
|
||||
*/
|
||||
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
||||
const name = (req.body || {}).name;
|
||||
try {
|
||||
res.json(await ccUsage.profileUsage(name));
|
||||
} catch (e) {
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADNAME: 400, ENOREFRESH: 409, ENOAUTH: 409, EUNAUTHORIZED: 401 }[
|
||||
e.code
|
||||
] || 502;
|
||||
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
||||
router.delete("/profiles", (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -649,6 +649,10 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const result = applyRouterToLane(lane, profile);
|
||||
lanesLib.setRouterProfile(lane.id, profile);
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
broadcastLane(lane.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
||||
|
||||
Reference in New Issue
Block a user