Sign In

codex-usage-analyzer

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

codex-usage-analyzer - npm Package Compare versions

Comparing version
0.3.0
to
0.4.0
+241
docs/experimental-full-profile.md
# Experimental Full Profile Contract
The Full Profile Envelope is an opt-in, CLI-only contract for identity and
activity fields shown by Codex. It combines one stable, identity-free Account
Usage Contract document with allowlisted fields from an unsupported private
profile endpoint.
This contract is experimental. It is not part of the public JavaScript SDK and
does not change the default command or the stable
[`account/usage/read`](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md)
path. The machine-readable source of truth is
[`experimental-full-profile.schema.json`](experimental-full-profile.schema.json).
## CLI
```bash
codex-usage-analyzer profile
codex-usage-analyzer profile --json
```
`profile` prints a human-readable view. `profile --json` prints exactly one Full
Profile Envelope to stdout. Both commands first write this warning to stderr:
```text
codex-usage-analyzer: Warning: profile uses an unsupported experimental endpoint and may expose account identity fields.
```
`profile --help` and `profile -h` do not start app-server or make a network
request. There is no environment variable, configuration file, implicit mode,
or fallback that enables the experiment without the `profile` command token.
## Envelope
```json
{
"fullProfileContractVersion": 1,
"kind": "codex-usage-analyzer.fullProfile",
"stability": "experimental",
"status": "ok",
"usage": {
"contractVersion": 1,
"capturedAt": "2026-01-01T00:00:00.000Z",
"summary": {
"lifetimeTokens": 1000,
"peakDailyTokens": 200,
"longestRunningTurnSec": 300,
"currentStreakDays": 4,
"longestStreakDays": 5
},
"dailyUsageBuckets": [
{
"startDate": "2025-12-31",
"tokens": 100
}
]
},
"profile": {
"displayName": "Example Name",
"username": "example-user",
"avatarUrl": "https://example.invalid/avatar.png",
"planType": "example-plan"
},
"activityInsights": {
"fastModePercent": 25,
"reasoningEffort": "example-effort",
"reasoningEffortPercent": 50,
"skillsExplored": 6,
"totalSkillsUsed": 7,
"totalThreads": 8,
"topInvocations": [
{
"type": "skill",
"name": "example-skill",
"usageCount": 9
}
]
}
}
```
All values are synthetic. They do not identify or describe a real account.
Every object rejects unknown fields under the JSON Schema.
## Root fields
| Field | Type | Meaning |
|---|---|---|
| `fullProfileContractVersion` | integer, constant `1` | Version of this experimental envelope |
| `kind` | string, constant `codex-usage-analyzer.fullProfile` | Contract discriminator |
| `stability` | string, constant `experimental` | Explicit stability classification |
| `status` | `ok`, `partial`, or `unavailable` | Completeness of private profile categories |
| `usage` | Account Usage Contract v1 | Canonical usage from official app-server data |
| `profile` | object or `null` | Allowlisted identity and plan projection |
| `activityInsights` | object or `null` | Allowlisted private activity projection |
The envelope has no separate observation timestamp. `usage.capturedAt` is the
canonical time at which the official usage result was received.
## Canonical usage
`usage` is reconstructed only from the documented app-server
`account/usage/read` result and follows the stable
[Account Usage Contract](account-usage-contract.md). Private profile stats are
never copied into `usage`, even when they contain similarly named totals,
streaks, durations, or daily buckets.
Downstreams must use nested `usage` as the only canonical source for tokens,
streaks, turn duration, and token activity. They must not replace it with values
from identity or activity categories.
## Profile fields
When `profile` is an object, all fields are present. Each value is a bounded
string or `null`.
| Field | Meaning |
|---|---|
| `displayName` | Cosmetic display name reported by the private profile source |
| `username` | Cosmetic username reported by the private profile source |
| `avatarUrl` | Validated absolute HTTPS source URL; never fetched by this CLI |
| `planType` | Plan category from app-server account metadata when available |
These values are identity data, but they are not account ownership proof. The
human renderer never prints the avatar URL; it prints only `Available` or
`Unavailable`. JSON consumers receive the validated URL and must still treat it
as untrusted remote input.
## Activity insight fields
When `activityInsights` is an object, all fields are present.
| Field | Type | Meaning |
|---|---|---|
| `fastModePercent` | integer 0-100 or `null` | Share using fast mode, rounded to the whole-percent UI value |
| `reasoningEffort` | string or `null` | Most-used reasoning effort label |
| `reasoningEffortPercent` | integer 0-100 or `null` | Share for that effort, rounded to the whole-percent UI value |
| `skillsExplored` | non-negative safe integer or `null` | Distinct skills reported by the private source |
| `totalSkillsUsed` | non-negative safe integer or `null` | Total skill uses reported by the private source |
| `totalThreads` | non-negative safe integer or `null` | Total threads reported by the private source |
| `topInvocations` | array or `null` | Allowlisted plugin or skill invocation rows |
Each invocation contains only `type`, `name`, and `usageCount`. `type` is
`plugin` or `skill`; ids and unknown metadata are discarded. Invocation names
can reveal workflow details and must be treated as private unless a user opts in
to storage or publication.
## Status and exit code
| Condition | `status` | Profile/activity | Exit code |
|---|---|---|---:|
| Private root and required categories are structurally complete | `ok` | Allowlisted objects | 0 |
| Private root exists but a category or field is missing, invalid, or reports a stats error | `partial` | Fixed objects with unavailable values as `null` | 0 |
| Auth context, HTTP, body, or private root validation fails | `unavailable` | Both categories are `null` | 1 |
| Official `account/usage/read` fails | No envelope | Safe error on stderr | 1 |
An unavailable private profile does not turn private stats into a fallback usage
source. When the official read succeeded, its canonical `usage` remains in the
`unavailable` envelope.
## Null and empty values
- `null` means a field or category is unavailable. It never means zero.
- `profile: null` and `activityInsights: null` identify an unavailable private
profile root or request.
- A present profile or activity object keeps its complete field set; unavailable
members are `null`.
- `topInvocations: null` means the source category was unavailable.
- `topInvocations: []` means the category was available but had no accepted rows.
- `usage.dailyUsageBuckets` retains the stable contract's separate `null`, `[]`,
and populated-array meanings.
## Human token activity
The human renderer builds its token activity map only from
`usage.dailyUsageBuckets`:
- UTC date-only values with no timezone rebucketing
- Sunday-start weeks ending with the week containing `usage.capturedAt`
- at most 52 weeks, with future dates in the current week left blank
- relative ASCII intensity levels `0` through `4`
- duplicate dates summed with safe-integer saturation
- `Unavailable` for `null` and `No activity recorded` for an empty array
The map is presentation only and is not an additional contract field.
## Security boundary
The profile flow is isolated from the default usage client:
1. Resolve the installed Codex executable and start one dedicated app-server
child without a shell.
2. Read official usage, official account metadata, and the minimum internal auth
context in that child session.
3. Accept only a bounded compact bearer token and one bounded ChatGPT account
claim needed for the fixed request.
4. Send one GET request to the fixed ChatGPT HTTPS profile endpoint with redirects
disabled, ambient credentials omitted, no retry, a timeout, a 1 MiB response
limit, strict UTF-8, JSON content-type, and object-root validation.
5. Reconstruct the output from an allowlist and discard raw response categories.
Bearer and account context references exist only in process memory for the
request. They are never returned, logged, written to a file, or passed to the
normalizer. JavaScript memory zeroization is not guaranteed, so the implementation
minimizes reference and child-process lifetime instead of claiming erasure.
The CLI does not directly read authentication files, cookies, keychains, prompts,
responses, or local sessions. It discards app-server stderr and raw RPC/HTTP error
details. It uses an honest package originator and User-Agent; it does not imitate
a Desktop client if the endpoint rejects the request.
## Unsupported endpoint and drift
The private `/wham/profiles/me` endpoint and the internal auth method have no
documented stability contract. Authentication requirements, response fields, or
endpoint availability can change independently of this package. The safe response
to drift is `partial`, `unavailable`, or a safe error, not a hidden alternate
endpoint or private-stat fallback.
Consumers must check `fullProfileContractVersion`, `kind`, `stability`, and
`status` on every envelope. A future incompatible envelope change requires a new
full-profile contract version and consumer-impact review; it does not imply a
change to Account Usage Contract v1.
## Downstream use
The default downstream design remains an identity-free usage submission combined
with a separately authenticated GitHub identity. A service that also accepts this
experimental envelope must:
- require a separate explicit opt-in and keep the feature non-public by default
- validate the Full Profile Schema and reject unknown versions or fields
- use GitHub identity, not remote profile strings, for ownership and authorization
- trust only nested official `usage` for canonical metrics
- apply a separate privacy policy before retaining activity insights or invocation
names
- validate, decode, safely re-encode, and re-host an allowed avatar instead of
exposing the source URL
- never accept Codex/OpenAI credentials and never log raw profile payloads
See the [Downstream Integration Guide](downstream-integration.md) for the complete
submission, identity, rendering, cache, revocation, and deletion boundary.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Codex Usage Analyzer Experimental Full Profile Contract",
"description": "Experimental opt-in profile output. This is separate from the stable Account Usage Contract.",
"type": "object",
"additionalProperties": false,
"required": [
"fullProfileContractVersion",
"kind",
"stability",
"status",
"usage",
"profile",
"activityInsights"
],
"properties": {
"fullProfileContractVersion": {
"const": 1
},
"kind": {
"const": "codex-usage-analyzer.fullProfile"
},
"stability": {
"const": "experimental"
},
"status": {
"enum": [
"ok",
"partial",
"unavailable"
]
},
"usage": {
"$ref": "#/definitions/accountUsage"
},
"profile": {
"oneOf": [
{
"$ref": "#/definitions/profile"
},
{
"type": "null"
}
]
},
"activityInsights": {
"oneOf": [
{
"$ref": "#/definitions/activityInsights"
},
{
"type": "null"
}
]
}
},
"definitions": {
"nullableNonNegativeInteger": {
"oneOf": [
{
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
{
"type": "null"
}
]
},
"nullablePercentage": {
"oneOf": [
{
"type": "integer",
"minimum": 0,
"maximum": 100
},
{
"type": "null"
}
]
},
"nullableString64": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
},
{
"type": "null"
}
]
},
"accountUsage": {
"type": "object",
"additionalProperties": false,
"required": [
"contractVersion",
"capturedAt",
"summary",
"dailyUsageBuckets"
],
"properties": {
"contractVersion": {
"const": 1
},
"capturedAt": {
"type": "string",
"format": "date-time"
},
"summary": {
"$ref": "#/definitions/accountUsageSummary"
},
"dailyUsageBuckets": {
"oneOf": [
{
"type": "array",
"items": {
"$ref": "#/definitions/accountUsageDailyBucket"
}
},
{
"type": "null"
}
]
}
}
},
"accountUsageSummary": {
"type": "object",
"additionalProperties": false,
"required": [
"lifetimeTokens",
"peakDailyTokens",
"longestRunningTurnSec",
"currentStreakDays",
"longestStreakDays"
],
"properties": {
"lifetimeTokens": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"peakDailyTokens": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"longestRunningTurnSec": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"currentStreakDays": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"longestStreakDays": {
"$ref": "#/definitions/nullableNonNegativeInteger"
}
}
},
"accountUsageDailyBucket": {
"type": "object",
"additionalProperties": false,
"required": [
"startDate",
"tokens"
],
"properties": {
"startDate": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"tokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": [
"displayName",
"username",
"avatarUrl",
"planType"
],
"properties": {
"displayName": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
},
{
"type": "null"
}
]
},
"username": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 100,
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
},
{
"type": "null"
}
]
},
"avatarUrl": {
"oneOf": [
{
"type": "string",
"format": "uri",
"pattern": "^https://",
"maxLength": 2048
},
{
"type": "null"
}
]
},
"planType": {
"$ref": "#/definitions/nullableString64"
}
}
},
"activityInsights": {
"type": "object",
"additionalProperties": false,
"required": [
"fastModePercent",
"reasoningEffort",
"reasoningEffortPercent",
"skillsExplored",
"totalSkillsUsed",
"totalThreads",
"topInvocations"
],
"properties": {
"fastModePercent": {
"$ref": "#/definitions/nullablePercentage"
},
"reasoningEffort": {
"$ref": "#/definitions/nullableString64"
},
"reasoningEffortPercent": {
"$ref": "#/definitions/nullablePercentage"
},
"skillsExplored": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"totalSkillsUsed": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"totalThreads": {
"$ref": "#/definitions/nullableNonNegativeInteger"
},
"topInvocations": {
"oneOf": [
{
"type": "array",
"maxItems": 100,
"items": {
"$ref": "#/definitions/topInvocation"
}
},
{
"type": "null"
}
]
}
}
},
"topInvocation": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"name",
"usageCount"
],
"properties": {
"type": {
"enum": [
"plugin",
"skill"
]
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
},
"usageCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
}
}
}
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { normalizeAccountUsageResult } from "./account-usage.js";
import { resolveCodexExecutable } from "./codex-executable.js";
import { CODEX_USAGE_ERROR_CODES, CodexUsageError } from "./errors.js";
import {
createUnavailableFullProfile,
normalizeFullProfileResult
} from "./experimental-profile.js";
export const EXPERIMENTAL_PROFILE_URL =
"https://chatgpt.com/backend-api/wham/profiles/me";
export const DEFAULT_EXPERIMENTAL_PROFILE_TIMEOUT_MS = 15_000;
export const MAX_EXPERIMENTAL_PROFILE_RESPONSE_BYTES = 1_048_576;
const MAX_TIMEOUT_MS = 120_000;
const MAX_AUTH_TOKEN_LENGTH = 65_536;
const MAX_ACCOUNT_ID_LENGTH = 256;
const INITIALIZE_REQUEST_ID = 0;
const ACCOUNT_USAGE_REQUEST_ID = 1;
const ACCOUNT_READ_REQUEST_ID = 2;
const AUTH_STATUS_REQUEST_ID = 3;
const CLIENT_VERSION = "0.4.0";
const ACCOUNT_CLAIM = "https://api.openai.com/auth";
export async function readExperimentalProfile(options = {}) {
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
const session = await requestExperimentalSession({
timeoutMs,
spawnProcess: options.spawnProcess,
resolveExecutable: options.resolveExecutable
});
const usage = normalizeAccountUsageResult(session.usageResult, {
capturedAt: options.capturedAt
});
const planType = extractPlanType(session.accountResult);
let authToken = extractAuthToken(session.authResult);
let accountId = authToken === null ? null : extractAccountId(authToken);
session.accountResult = null;
session.authResult = null;
if (authToken === null || accountId === null) {
authToken = null;
accountId = null;
return createUnavailableFullProfile(usage);
}
try {
const remoteResult = await requestRemoteProfile({
accountId,
authToken,
fetchImpl: options.fetchImpl,
timeoutMs
});
if (remoteResult === null) {
return createUnavailableFullProfile(usage);
}
return normalizeFullProfileResult(usage, remoteResult, { planType });
} finally {
authToken = null;
accountId = null;
}
}
async function requestExperimentalSession(options) {
const spawnProcess = options.spawnProcess ?? spawn;
const resolveExecutable = options.resolveExecutable ?? resolveCodexExecutable;
let command;
let child;
try {
command = await resolveExecutable();
} catch {
throw new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_START_FAILED
);
}
if (command === null) {
throw new CodexUsageError(CODEX_USAGE_ERROR_CODES.CODEX_NOT_FOUND);
}
try {
child = spawnProcess(command, ["app-server"], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true
});
} catch (error) {
throw createSpawnError(error);
}
if (!child?.stdin || !child?.stdout || !child?.stderr) {
stopChild(child);
throw new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_START_FAILED
);
}
child.stderr.resume();
return new Promise((resolve, reject) => {
const lines = createInterface({
input: child.stdout,
crlfDelay: Infinity
});
const responses = {
usageResult: undefined,
accountResult: undefined,
authResult: undefined
};
let phase = "initializing";
let settled = false;
const timer = setTimeout(() => {
if (responses.usageResult !== undefined) {
succeedWithOptionalFailures();
} else {
fail(new CodexUsageError(CODEX_USAGE_ERROR_CODES.APP_SERVER_TIMEOUT));
}
}, options.timeoutMs);
function cleanup() {
clearTimeout(timer);
lines.removeAllListeners();
lines.close();
if (!child.stdin.destroyed) {
child.stdin.end();
}
stopChild(child);
}
function succeed(result) {
if (settled) return;
settled = true;
cleanup();
resolve(result);
}
function succeedWithOptionalFailures() {
succeed({
usageResult: responses.usageResult,
accountResult: responses.accountResult ?? null,
authResult: responses.authResult ?? null
});
}
function fail(error) {
if (settled) return;
settled = true;
cleanup();
reject(error);
}
function send(message) {
if (settled) return;
try {
child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
if (error) {
failOrReturnUnavailable(
new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
)
);
}
});
} catch {
failOrReturnUnavailable(
new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
)
);
}
}
function failOrReturnUnavailable(error) {
if (responses.usageResult !== undefined) {
succeedWithOptionalFailures();
} else {
fail(error);
}
}
function onError(error) {
failOrReturnUnavailable(createSpawnError(error));
}
function onExit() {
failOrReturnUnavailable(
new CodexUsageError(CODEX_USAGE_ERROR_CODES.APP_SERVER_EXITED)
);
}
function onLine(line) {
let message;
try {
message = JSON.parse(line);
} catch {
failOrReturnUnavailable(
new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
)
);
return;
}
if (!isRecord(message)) {
failOrReturnUnavailable(
new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
)
);
return;
}
if (message.id === INITIALIZE_REQUEST_ID) {
handleInitialize(message);
return;
}
if (message.id === ACCOUNT_USAGE_REQUEST_ID) {
handleRequiredUsage(message);
return;
}
if (message.id === ACCOUNT_READ_REQUEST_ID) {
handleOptionalResponse("accountResult", message);
return;
}
if (message.id === AUTH_STATUS_REQUEST_ID) {
handleOptionalResponse("authResult", message);
}
}
function handleInitialize(message) {
if (phase !== "initializing") {
fail(new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
));
return;
}
if (hasRpcError(message)) {
fail(createRpcError(message.error));
return;
}
if (!Object.hasOwn(message, "result")) {
fail(new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
));
return;
}
phase = "reading_profile_context";
send({ method: "initialized", params: {} });
send({ method: "account/usage/read", id: ACCOUNT_USAGE_REQUEST_ID });
send({
method: "account/read",
id: ACCOUNT_READ_REQUEST_ID,
params: { refreshToken: false }
});
send({
method: "getAuthStatus",
id: AUTH_STATUS_REQUEST_ID,
params: { includeToken: true, refreshToken: false }
});
}
function handleRequiredUsage(message) {
if (phase !== "reading_profile_context"
|| responses.usageResult !== undefined) {
fail(new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
));
return;
}
if (hasRpcError(message)) {
fail(createRpcError(message.error));
return;
}
if (!Object.hasOwn(message, "result")) {
fail(new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
));
return;
}
responses.usageResult = message.result;
finishIfComplete();
}
function handleOptionalResponse(key, message) {
if (phase !== "reading_profile_context" || responses[key] !== undefined) {
failOrReturnUnavailable(
new CodexUsageError(
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
)
);
return;
}
responses[key] = hasRpcError(message)
|| !Object.hasOwn(message, "result")
? null
: message.result;
finishIfComplete();
}
function finishIfComplete() {
if (responses.usageResult !== undefined
&& responses.accountResult !== undefined
&& responses.authResult !== undefined) {
succeedWithOptionalFailures();
}
}
child.once("error", onError);
child.once("exit", onExit);
lines.on("line", onLine);
send({
method: "initialize",
id: INITIALIZE_REQUEST_ID,
params: {
clientInfo: {
name: "codex_usage_analyzer",
title: "Codex Usage Analyzer",
version: CLIENT_VERSION
}
}
});
});
}
async function requestRemoteProfile(options) {
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
if (typeof fetchImpl !== "function") return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await fetchImpl(EXPERIMENTAL_PROFILE_URL, {
method: "GET",
credentials: "omit",
redirect: "manual",
signal: controller.signal,
headers: {
Accept: "application/json",
Authorization: `Bearer ${options.authToken}`,
"ChatGPT-Account-Id": options.accountId,
originator: "codex-usage-analyzer",
"User-Agent": `codex-usage-analyzer/${CLIENT_VERSION}`
}
});
if (!isRecord(response)
|| !Number.isInteger(response.status)
|| response.status < 200
|| response.status >= 300
|| !hasJsonContentType(response.headers)) {
return null;
}
const body = await readLimitedResponseBody(response);
if (body === null) return null;
const parsed = JSON.parse(body);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
} finally {
clearTimeout(timer);
controller.abort();
}
}
async function readLimitedResponseBody(response) {
const contentLength = response.headers?.get?.("content-length");
if (contentLength !== null && contentLength !== undefined) {
if (!/^\d+$/u.test(contentLength)) return null;
const declaredLength = Number(contentLength);
if (!Number.isSafeInteger(declaredLength)
|| declaredLength > MAX_EXPERIMENTAL_PROFILE_RESPONSE_BYTES) {
return null;
}
}
const reader = response.body?.getReader?.();
if (reader === undefined) return null;
const chunks = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!(value instanceof Uint8Array)) return null;
totalBytes += value.byteLength;
if (totalBytes > MAX_EXPERIMENTAL_PROFILE_RESPONSE_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
function hasJsonContentType(headers) {
const value = headers?.get?.("content-type");
return typeof value === "string"
&& /^application\/json(?:\s*;|$)/iu.test(value.trim());
}
function extractAuthToken(value) {
if (!isRecord(value)
|| (value.authMethod !== "chatgpt"
&& value.authMethod !== "chatgptAuthTokens")
|| typeof value.authToken !== "string"
|| value.authToken.length < 1
|| value.authToken.length > MAX_AUTH_TOKEN_LENGTH
|| !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(
value.authToken
)) {
return null;
}
return value.authToken;
}
function extractAccountId(token) {
const segments = token.split(".");
if (segments.length !== 3 || segments[1].length === 0) return null;
try {
const payload = JSON.parse(
Buffer.from(segments[1], "base64url").toString("utf8")
);
const accountId = payload?.[ACCOUNT_CLAIM]?.chatgpt_account_id;
if (typeof accountId !== "string"
|| accountId.length < 1
|| accountId.length > MAX_ACCOUNT_ID_LENGTH
|| accountId.trim() !== accountId
|| hasControlCharacter(accountId)) {
return null;
}
return accountId;
} catch {
return null;
}
}
function extractPlanType(value) {
const account = isRecord(value) ? value.account : null;
if (!isRecord(account)
|| account.type !== "chatgpt"
|| typeof account.planType !== "string"
|| account.planType.length < 1
|| account.planType.length > 64
|| hasControlCharacter(account.planType)) {
return null;
}
return account.planType;
}
function normalizeTimeoutMs(value) {
if (value === undefined) {
return DEFAULT_EXPERIMENTAL_PROFILE_TIMEOUT_MS;
}
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) {
throw new CodexUsageError(CODEX_USAGE_ERROR_CODES.INVALID_TIMEOUT);
}
return value;
}
function createSpawnError(error) {
const code = error && typeof error === "object" ? error.code : undefined;
return new CodexUsageError(
code === "ENOENT"
? CODEX_USAGE_ERROR_CODES.CODEX_NOT_FOUND
: CODEX_USAGE_ERROR_CODES.APP_SERVER_START_FAILED
);
}
function createRpcError(error) {
return new CodexUsageError(CODEX_USAGE_ERROR_CODES.APP_SERVER_RPC_ERROR, {
rpcCode: Number.isInteger(error?.code) ? error.code : undefined
});
}
function hasRpcError(message) {
return Object.hasOwn(message, "error");
}
function hasControlCharacter(value) {
return /[\u0000-\u001f\u007f]/u.test(value);
}
function stopChild(child) {
if (!child || child.exitCode !== null || child.killed) return;
try {
child.kill();
} catch {
// Cleanup failure must not expose or replace profile state.
}
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
import { ACCOUNT_USAGE_SUMMARY_FIELDS } from "./account-usage.js";
export const FULL_PROFILE_CONTRACT_VERSION = 1;
export const FULL_PROFILE_KIND = "codex-usage-analyzer.fullProfile";
export const FULL_PROFILE_STABILITY = "experimental";
export const FULL_PROFILE_STATUSES = Object.freeze([
"ok",
"partial",
"unavailable"
]);
export const FULL_PROFILE_FIELDS = Object.freeze([
"displayName",
"username",
"avatarUrl",
"planType"
]);
export const FULL_PROFILE_ACTIVITY_FIELDS = Object.freeze([
"fastModePercent",
"reasoningEffort",
"reasoningEffortPercent",
"skillsExplored",
"totalSkillsUsed",
"totalThreads",
"topInvocations"
]);
export const FULL_PROFILE_INVOCATION_FIELDS = Object.freeze([
"type",
"name",
"usageCount"
]);
const PROFILE_STRING_LIMITS = Object.freeze({
displayName: 256,
username: 100,
avatarUrl: 2_048,
planType: 64
});
const REASONING_EFFORT_MAX_LENGTH = 64;
const INVOCATION_NAME_MAX_LENGTH = 256;
const MAX_TOP_INVOCATIONS = 100;
const PRIVATE_SUMMARY_FIELDS = Object.freeze([
"lifetime_tokens",
"peak_daily_tokens",
"longest_running_turn_sec",
"current_streak_days",
"longest_streak_days"
]);
export function normalizeFullProfileResult(
usageDocument,
remoteResult,
options = {}
) {
const usage = normalizeUsageDocument(usageDocument);
if (!isRecord(remoteResult)) {
return createEnvelope("unavailable", usage, null, null);
}
const profileResult = normalizeProfile(
remoteResult.profile,
isRecord(options) ? options.planType : undefined
);
const activityResult = normalizeStats(remoteResult.stats);
const metadataComplete = hasCompleteMetadata(remoteResult.metadata);
const status = profileResult.complete
&& activityResult.complete
&& metadataComplete
? "ok"
: "partial";
return createEnvelope(
status,
usage,
profileResult.value,
activityResult.value
);
}
export function createUnavailableFullProfile(usageDocument) {
return createEnvelope(
"unavailable",
normalizeUsageDocument(usageDocument),
null,
null
);
}
function createEnvelope(status, usage, profile, activityInsights) {
return {
fullProfileContractVersion: FULL_PROFILE_CONTRACT_VERSION,
kind: FULL_PROFILE_KIND,
stability: FULL_PROFILE_STABILITY,
status,
usage,
profile,
activityInsights
};
}
function normalizeProfile(value, planType) {
const normalizedPlanType = normalizeOptionalString(
planType,
PROFILE_STRING_LIMITS.planType
);
if (!isRecord(value)) {
return {
complete: false,
value: {
displayName: null,
username: null,
avatarUrl: null,
planType: normalizedPlanType.value
}
};
}
const displayName = normalizeOptionalString(
value.display_name,
PROFILE_STRING_LIMITS.displayName
);
const username = normalizeOptionalString(
value.username,
PROFILE_STRING_LIMITS.username
);
const avatarUrl = normalizeAvatarUrl(value.profile_picture_url);
return {
complete: displayName.valid && username.valid && avatarUrl.valid,
value: {
displayName: displayName.value,
username: username.value,
avatarUrl: avatarUrl.value,
planType: normalizedPlanType.value
}
};
}
function normalizeStats(value) {
if (!isRecord(value)) {
return {
complete: false,
value: emptyActivityInsights()
};
}
const privateSummaryComplete = PRIVATE_SUMMARY_FIELDS.every((field) => (
normalizeRequiredInteger(value[field]).valid
));
const privateDailyComplete = hasValidPrivateDailyBuckets(
value.daily_usage_buckets
);
const fastModePercent = normalizePercentage(
value.fast_mode_usage_percentage
);
const reasoningEffort = normalizeRequiredString(
value.most_used_reasoning_effort,
REASONING_EFFORT_MAX_LENGTH
);
const reasoningEffortPercent = normalizePercentage(
value.most_used_reasoning_effort_percentage
);
const skillsExplored = normalizeRequiredInteger(value.unique_skills_used);
const totalSkillsUsed = normalizeRequiredInteger(value.total_skills_used);
const totalThreads = normalizeRequiredInteger(value.total_threads);
const topInvocations = normalizeTopInvocations(value.top_invocations);
return {
complete: privateSummaryComplete
&& privateDailyComplete
&& fastModePercent.valid
&& reasoningEffort.valid
&& reasoningEffortPercent.valid
&& skillsExplored.valid
&& totalSkillsUsed.valid
&& totalThreads.valid
&& topInvocations.valid,
value: {
fastModePercent: fastModePercent.value,
reasoningEffort: reasoningEffort.value,
reasoningEffortPercent: reasoningEffortPercent.value,
skillsExplored: skillsExplored.value,
totalSkillsUsed: totalSkillsUsed.value,
totalThreads: totalThreads.value,
topInvocations: topInvocations.value
}
};
}
function emptyActivityInsights() {
return {
fastModePercent: null,
reasoningEffort: null,
reasoningEffortPercent: null,
skillsExplored: null,
totalSkillsUsed: null,
totalThreads: null,
topInvocations: null
};
}
function normalizeTopInvocations(value) {
if (value === undefined || value === null) {
return { valid: false, value: null };
}
if (!Array.isArray(value)) {
return { valid: false, value: null };
}
let valid = value.length <= MAX_TOP_INVOCATIONS;
const normalized = [];
for (const item of value.slice(0, MAX_TOP_INVOCATIONS)) {
const invocation = normalizeInvocation(item);
if (invocation === null) {
valid = false;
} else {
normalized.push(invocation);
}
}
return { valid, value: normalized };
}
function normalizeInvocation(value) {
if (!isRecord(value) || (value.type !== "plugin" && value.type !== "skill")) {
return null;
}
const name = normalizeRequiredString(
value.type === "plugin" ? value.plugin_name : value.skill_name,
INVOCATION_NAME_MAX_LENGTH
);
const usageCount = normalizeRequiredInteger(value.usage_count);
if (!name.valid || !usageCount.valid) {
return null;
}
return {
type: value.type,
name: name.value,
usageCount: usageCount.value
};
}
function hasCompleteMetadata(value) {
if (!isRecord(value)) return false;
if (value.stats_error === undefined || value.stats_error === null) {
return true;
}
if (typeof value.stats_error !== "string") {
return false;
}
return value.stats_error.trim().length === 0;
}
function hasValidPrivateDailyBuckets(value) {
if (!Array.isArray(value)) return false;
return value.every((bucket) => (
isRecord(bucket)
&& isDateOnly(bucket.start_date)
&& normalizeRequiredInteger(bucket.tokens).valid
));
}
function normalizeUsageDocument(value) {
if (!isRecord(value)
|| value.contractVersion !== 1
|| !isTimestamp(value.capturedAt)
|| !isRecord(value.summary)) {
throw invalidUsageDocument();
}
const summary = {};
for (const field of ACCOUNT_USAGE_SUMMARY_FIELDS) {
const metric = normalizeNullableInteger(value.summary[field]);
if (!metric.valid) throw invalidUsageDocument();
summary[field] = metric.value;
}
return {
contractVersion: 1,
capturedAt: value.capturedAt,
summary,
dailyUsageBuckets: normalizeUsageBuckets(value.dailyUsageBuckets)
};
}
function normalizeUsageBuckets(value) {
if (value === null) return null;
if (!Array.isArray(value)) throw invalidUsageDocument();
return value.map((bucket) => {
if (!isRecord(bucket) || !isDateOnly(bucket.startDate)) {
throw invalidUsageDocument();
}
const tokens = normalizeRequiredInteger(bucket.tokens);
if (!tokens.valid) throw invalidUsageDocument();
return {
startDate: bucket.startDate,
tokens: tokens.value
};
});
}
function normalizeAvatarUrl(value) {
const normalized = normalizeOptionalString(
value,
PROFILE_STRING_LIMITS.avatarUrl
);
if (!normalized.valid || normalized.value === null) {
return normalized;
}
try {
const url = new URL(normalized.value);
if (url.protocol !== "https:" || url.username !== "" || url.password !== "") {
return { valid: false, value: null };
}
} catch {
return { valid: false, value: null };
}
return normalized;
}
function normalizeOptionalString(value, maxLength) {
if (value === undefined || value === null) {
return { valid: true, value: null };
}
if (typeof value !== "string") {
return { valid: false, value: null };
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return { valid: true, value: null };
}
if (trimmed.length > maxLength || hasControlCharacter(trimmed)) {
return { valid: false, value: null };
}
return { valid: true, value: trimmed };
}
function normalizeRequiredString(value, maxLength) {
const normalized = normalizeOptionalString(value, maxLength);
if (normalized.value === null) {
return { valid: false, value: null };
}
return normalized;
}
function normalizePercentage(value) {
if (!Number.isFinite(value) || value < 0 || value > 100) {
return { valid: false, value: null };
}
return { valid: true, value: Math.round(value) };
}
function normalizeNullableInteger(value) {
if (value === null) return { valid: true, value: null };
return normalizeRequiredInteger(value);
}
function normalizeRequiredInteger(value) {
if (!Number.isSafeInteger(value) || value < 0) {
return { valid: false, value: null };
}
return { valid: true, value };
}
function hasControlCharacter(value) {
return /[\u0000-\u001f\u007f]/u.test(value);
}
function isTimestamp(value) {
if (typeof value !== "string") return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.toISOString() === value;
}
function isDateOnly(value) {
if (typeof value !== "string") return false;
const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value);
if (match === null) return false;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day;
}
function invalidUsageDocument() {
return new TypeError("Invalid Account Usage Contract document");
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
import { formatAccountUsage } from "./format-account-usage.js";
const UNAVAILABLE = "Unavailable";
const EMPTY_ACTIVITY = "No activity recorded";
const WEEKDAY_LABELS = Object.freeze([
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
]);
const HEATMAP_SYMBOLS = Object.freeze([".", ":", "*", "O", "#"]);
const HEATMAP_WEEKS = 52;
const DAYS_PER_WEEK = 7;
const DAY_MS = 86_400_000;
export function formatExperimentalProfile(envelope) {
return [
"Codex profile (experimental)",
`Status ${envelope.status}`,
"",
...formatProfile(envelope.profile),
"",
"Usage",
formatAccountUsage(envelope.usage),
"",
...formatTokenActivity(envelope.usage),
"",
...formatActivityInsights(envelope.activityInsights),
"",
...formatTopInvocations(envelope.activityInsights?.topInvocations ?? null)
].join("\n");
}
function formatProfile(profile) {
if (profile === null) return ["Profile", UNAVAILABLE];
return [
"Profile",
...formatRows([
["Display name", formatValue(profile.displayName)],
["Username", formatUsername(profile.username)],
["Avatar", profile.avatarUrl === null ? UNAVAILABLE : "Available"],
["Plan", formatValue(profile.planType)]
])
];
}
function formatTokenActivity(usage) {
const buckets = usage.dailyUsageBuckets;
if (buckets === null) return ["Token activity", UNAVAILABLE];
if (buckets.length === 0) return ["Token activity", EMPTY_ACTIVITY];
const anchorMs = Date.parse(`${usage.capturedAt.slice(0, 10)}T00:00:00.000Z`);
const anchor = new Date(anchorMs);
const currentWeekStartMs = anchorMs - anchor.getUTCDay() * DAY_MS;
const firstWeekStartMs = currentWeekStartMs
- (HEATMAP_WEEKS - 1) * DAYS_PER_WEEK * DAY_MS;
const totals = aggregateBuckets(buckets);
const cells = [];
let maximum = 0;
for (let day = 0; day < DAYS_PER_WEEK; day += 1) {
const row = [];
for (let week = 0; week < HEATMAP_WEEKS; week += 1) {
const dateMs = firstWeekStartMs + (week * DAYS_PER_WEEK + day) * DAY_MS;
if (dateMs > anchorMs) {
row.push(null);
continue;
}
const tokens = totals.get(toDateOnly(dateMs)) ?? 0;
maximum = Math.max(maximum, tokens);
row.push(tokens);
}
cells.push(row);
}
return [
"Token activity",
...cells.map((row, index) => (
`${WEEKDAY_LABELS[index]} |${row.map((tokens) => (
tokens === null ? " " : HEATMAP_SYMBOLS[toIntensity(tokens, maximum)]
)).join("")}|`
)),
"Legend . 0 : 1 * 2 O 3 # 4"
];
}
function formatActivityInsights(activity) {
if (activity === null) return ["Activity insights", UNAVAILABLE];
return [
"Activity insights",
...formatRows([
["Fast mode", formatPercentage(activity.fastModePercent)],
["Reasoning effort", formatReasoningEffort(activity)],
["Skills explored", formatInteger(activity.skillsExplored)],
["Total skill uses", formatInteger(activity.totalSkillsUsed)],
["Total threads", formatInteger(activity.totalThreads)]
])
];
}
function formatTopInvocations(value) {
if (value === null) return ["Top invocations", UNAVAILABLE];
if (value.length === 0) return ["Top invocations", "None"];
return [
"Top invocations",
...formatRows(value.map((invocation) => [
formatInvocationName(invocation),
formatInteger(invocation.usageCount)
]))
];
}
function aggregateBuckets(buckets) {
const totals = new Map();
for (const bucket of buckets) {
const current = totals.get(bucket.startDate) ?? 0;
const next = current + bucket.tokens;
totals.set(
bucket.startDate,
Number.isSafeInteger(next) ? next : Number.MAX_SAFE_INTEGER
);
}
return totals;
}
function toIntensity(tokens, maximum) {
if (tokens === 0 || maximum === 0) return 0;
return Math.ceil((tokens / maximum) * 4);
}
function toDateOnly(timestamp) {
return new Date(timestamp).toISOString().slice(0, 10);
}
function formatRows(rows) {
const labelWidth = Math.max(...rows.map(([label]) => label.length));
return rows.map(([label, value]) => `${label.padEnd(labelWidth)} ${value}`);
}
function formatUsername(value) {
if (value === null) return UNAVAILABLE;
return value.startsWith("@") ? value : `@${value}`;
}
function formatReasoningEffort(activity) {
const effort = formatValue(activity.reasoningEffort);
const percentage = formatPercentage(activity.reasoningEffortPercent);
if (effort === UNAVAILABLE && percentage === UNAVAILABLE) return UNAVAILABLE;
if (effort === UNAVAILABLE) return `${UNAVAILABLE} (${percentage})`;
if (percentage === UNAVAILABLE) return `${effort} (share unavailable)`;
return `${effort} (${percentage})`;
}
function formatInvocationName(invocation) {
const prefix = invocation.type === "plugin" ? "@" : "$";
return invocation.name.startsWith(prefix)
? invocation.name
: `${prefix}${invocation.name}`;
}
function formatPercentage(value) {
return value === null ? UNAVAILABLE : `${value}%`;
}
function formatInteger(value) {
if (value === null) return UNAVAILABLE;
return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ",");
}
function formatValue(value) {
return value === null ? UNAVAILABLE : value;
}
+5
-0

@@ -9,2 +9,7 @@ # Account Usage Contract

The opt-in `profile --json` command does not extend this contract. It returns a
separate [Experimental Full Profile Envelope](experimental-full-profile.md) that
nests one unchanged Account Usage Contract document under `usage`. The stable SDK
continues to expose only this identity-free contract.
## Shape

@@ -11,0 +16,0 @@

+18
-18

@@ -5,3 +5,3 @@ # Downstream Integration Guide

The CLI owns one artifact: the identity-free [Account Usage Contract](account-usage-contract.md). A downstream service owns identity, authorization, submission, persistence, rendering, cache behavior, privacy controls, and deletion.
The stable CLI and SDK own one artifact: the identity-free [Account Usage Contract](account-usage-contract.md). An explicit CLI-only experiment can emit a separate [Full Profile Envelope](experimental-full-profile.md). A downstream service owns identity, authorization, submission, persistence, rendering, cache behavior, privacy controls, and deletion.

@@ -161,5 +161,5 @@ ## Recommended architecture

A future adapter for private or undocumented profile endpoints such as `/wham/profiles/me` must remain separate from the default CLI and its contract. It is not implemented by `codex-usage-analyzer`.
`codex-usage-analyzer profile --json` explicitly calls the unsupported `/wham/profiles/me` endpoint and returns a separate Full Profile Envelope. It does not change the default command, the Account Usage Contract, or the public SDK. A downstream must not silently substitute this envelope for the normal submit body.
If a downstream chooses to experiment with such an adapter:
If a downstream chooses to accept this experimental envelope:

@@ -170,3 +170,5 @@ - require explicit opt-in for every invocation

- never use its identity as authentication, authorization, or account ownership proof
- allowlist only cosmetic fields and discard usage metrics from that response
- validate `fullProfileContractVersion`, `kind`, `stability`, the complete field set, and `status`
- trust only nested `usage` for canonical token and streak metrics
- never reconstruct canonical usage from private profile stats or activity fields
- never persist or log raw responses, credentials, account identifiers, or private URLs

@@ -176,20 +178,18 @@ - sanitize and re-host an avatar instead of exposing a raw remote URL to viewers

A separate downstream-owned envelope may use these field names:
GitHub identity should remain the default display source and the only account binding. Treat `profile.displayName`, `profile.username`, `profile.planType`, invocation names, and every other experimental string as untrusted cosmetic input. Do not let remote identity select a record, overwrite GitHub ownership, create a public URL, or grant access.
```json
{
"experimentalProfile": {
"source": "codex-private-profile",
"displayName": "Example Name",
"username": "example",
"avatarUrl": "https://example.invalid/avatar-source",
"observedAt": "2026-07-11T00:00:00.000Z"
}
}
```
Activity insight storage requires a separate downstream opt-in and privacy policy. Explain whether fast-mode percentage, reasoning effort, skill counts, thread counts, and top invocation names will be retained or made public. If that opt-in is absent, discard `activityInsights` after rendering or reject the experimental submission according to a documented policy.
This envelope is not part of the Account Usage Contract. GitHub identity should remain the default display source and the only account binding unless a later, separately reviewed design establishes another verifiable identity source.
Treat `profile.avatarUrl` as an untrusted remote source. Apply an HTTPS host allowlist, redirect and response-size limits, content-type verification, full image decoding, safe re-encoding, and downstream re-hosting before storage or rendering. Never expose the submitted source URL directly to viewers.
Keep the experimental envelope authenticated and non-public. Treat `avatarUrl` as untrusted adapter input and replace it with a sanitized downstream asset before rendering or storage.
Handle envelope status explicitly:
| Status | Downstream behavior |
|---|---|
| `ok` | Validate every category, then apply the experimental identity/activity policy. |
| `partial` | Preserve nested official usage; render or store only non-null fields allowed by policy. |
| `unavailable` | Accept nested official usage only if the endpoint and product policy allow it; profile and activity are `null`. |
Keep the experimental envelope authenticated and non-public by default. Validate it against [`experimental-full-profile.schema.json`](experimental-full-profile.schema.json), preserve null/empty semantics, and isolate stored records by `fullProfileContractVersion` rather than treating it as `contractVersion` 1.
## Versioning

@@ -196,0 +196,0 @@

{
"name": "codex-usage-analyzer",
"version": "0.3.0",
"version": "0.4.0",
"description": "Read Codex account usage through the official app-server protocol.",

@@ -40,3 +40,6 @@ "keywords": [

"src/errors.js",
"src/experimental-profile-client.js",
"src/experimental-profile.js",
"src/format-account-usage.js",
"src/format-experimental-profile.js",
"src/index.d.ts",

@@ -43,0 +46,0 @@ "src/index.js",

@@ -68,2 +68,32 @@ # codex-usage-analyzer

## Experimental profile
The default command and `--json` continue to use the documented
[`account/usage/read`](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md)
method and remain identity-free. A separate explicit command can also request the
profile and activity fields shown by Codex:
```bash
npx codex-usage-analyzer@latest profile
npx codex-usage-analyzer@latest profile --json
```
> **Experimental and unsupported:** `profile` uses the private
> `/wham/profiles/me` endpoint. It can change or stop working without notice.
> The command prints a warning to stderr and may emit a display name, username,
> avatar URL, plan type, activity insights, and top invocation names.
The command starts a dedicated app-server session, reads canonical usage through
`account/usage/read`, obtains the minimum ChatGPT auth context from that process,
and performs one fixed HTTPS request. The bearer token and account context stay in
process memory for that request; the CLI does not directly read authentication
files, cookies, or keychains. JavaScript cannot guarantee memory zeroization.
There is no retry, alternate endpoint, Desktop-client impersonation, or silent
fallback from the default command. `profile --json` returns a separate Full
Profile Envelope rather than extending the stable Account Usage Contract or SDK.
See the [Experimental Full Profile Contract](docs/experimental-full-profile.md)
and [JSON Schema](docs/experimental-full-profile.schema.json) before integrating
it or making identity and activity public.
## Why this CLI

@@ -116,3 +146,4 @@

codex-usage-analyzer [usage] [--json]
codex-usage-analyzer [usage] --help
codex-usage-analyzer profile [--json] (experimental)
codex-usage-analyzer [usage|profile] --help
codex-usage-analyzer --version

@@ -127,6 +158,8 @@ ```

| `codex-usage-analyzer usage --json` | Same JSON output |
| `codex-usage-analyzer profile` | Experimental human-readable profile and token activity |
| `codex-usage-analyzer profile --json` | Experimental Full Profile Envelope JSON |
| `codex-usage-analyzer --help` | Help without starting app-server |
| `codex-usage-analyzer --version` | Package version without starting app-server |
Successful output is written to stdout. Failures are written to stderr as a stable error code and a safe message, without raw RPC data or app-server stderr.
Command output is written to stdout. Failures are written to stderr as a stable error code and a safe message, without raw RPC data or app-server stderr. The experimental profile warning is always written to stderr, including successful profile calls. An unavailable private profile still emits an envelope and exits with status `1`; canonical usage remains nested when the official read succeeded.

@@ -164,3 +197,3 @@ ## SDK

The package has no direct credential reader and no private profile endpoint fallback. Authentication and service communication remain inside the selected Codex process.
The default path has no direct credential reader and no private profile endpoint fallback. Authentication and service communication remain inside the selected Codex process. The explicit experimental `profile` command uses the separate, bounded flow described above and never activates automatically.

@@ -173,2 +206,4 @@ ## Downstream integrations

Downstreams that explicitly accept the Full Profile Envelope must keep GitHub identity as the ownership proof, treat remote profile identity as cosmetic untrusted input, and opt in separately before storing activity insights.
## Privacy and Security

@@ -185,2 +220,4 @@

The experimental `profile` command has a different privacy boundary: it can emit identity, an avatar source URL, plan information, activity insights, and invocation names. Inspect its JSON and the downstream privacy policy before storing, publishing, or submitting it.
For vulnerability reporting and supported versions, see [SECURITY.md](SECURITY.md).

@@ -187,0 +224,0 @@

@@ -196,3 +196,3 @@ import { spawn } from "node:child_process";

title: "Codex Usage Analyzer",
version: "0.3.0"
version: "0.4.0"
}

@@ -199,0 +199,0 @@ }

@@ -6,3 +6,5 @@ import { readAccountUsage } from "./account-usage.js";

export const PACKAGE_NAME = "codex-usage-analyzer";
export const PACKAGE_VERSION = "0.3.0";
export const PACKAGE_VERSION = "0.4.0";
export const EXPERIMENTAL_PROFILE_WARNING =
"codex-usage-analyzer: Warning: profile uses an unsupported experimental endpoint and may expose account identity fields.";

@@ -14,3 +16,4 @@ const USAGE = [

" codex-usage-analyzer [usage] [--json]",
" codex-usage-analyzer [usage] --help",
" codex-usage-analyzer profile [--json] (experimental)",
" codex-usage-analyzer [usage|profile] --help",
" codex-usage-analyzer --version"

@@ -39,11 +42,30 @@ ].join("\n");

const readUsage = dependencies.readAccountUsage ?? readAccountUsage;
const formatUsage = dependencies.formatAccountUsage ?? formatAccountUsage;
try {
if (parsed.action === "profile") {
stderr.write(`${EXPERIMENTAL_PROFILE_WARNING}\n`);
const readProfile = dependencies.readExperimentalProfile
?? (await import("./experimental-profile-client.js"))
.readExperimentalProfile;
const profile = await readProfile();
let output;
try {
if (parsed.json) {
output = JSON.stringify(profile, null, 2);
} else {
const formatProfile = dependencies.formatExperimentalProfile
?? (await import("./format-experimental-profile.js"))
.formatExperimentalProfile;
output = formatProfile(profile);
}
stdout.write(`${output}\n`);
return profile.status === "unavailable" ? 1 : 0;
}
const readUsage = dependencies.readAccountUsage ?? readAccountUsage;
const formatUsage = dependencies.formatAccountUsage ?? formatAccountUsage;
const usage = await readUsage();
const output = parsed.json
stdout.write(`${parsed.json
? JSON.stringify(usage, null, 2)
: formatUsage(usage);
stdout.write(`${output}\n`);
: formatUsage(usage)}\n`);
return 0;

@@ -63,4 +85,6 @@ } catch (error) {

const args = [...argv];
let action = "usage";
if (args[0] === "usage") {
if (args[0] === "usage" || args[0] === "profile") {
action = args[0];
args.shift();

@@ -72,3 +96,3 @@ } else if (args[0] !== undefined && !args[0].startsWith("-")) {

if (args.length === 0) {
return { action: "usage", json: false };
return { action, json: false };
}

@@ -81,3 +105,3 @@

if (args[0] === "--json") {
return { action: "usage", json: true };
return { action, json: true };
}

@@ -84,0 +108,0 @@

export declare const PACKAGE_NAME: "codex-usage-analyzer";
export declare const PACKAGE_VERSION: "0.3.0";
export declare const PACKAGE_VERSION: "0.4.0";
export declare const ACCOUNT_USAGE_CONTRACT_VERSION: 1;

@@ -4,0 +4,0 @@ export declare const ACCOUNT_USAGE_SUMMARY_FIELDS: readonly [