sideby 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +453 -2
- package/README.zh-CN.md +455 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +37 -0
- package/dist/src/cli/commands.js +487 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +224 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +186 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +18 -0
- package/dist/src/core/accounts.js +116 -0
- package/dist/src/core/config.d.ts +60 -0
- package/dist/src/core/config.js +181 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +35 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +25 -0
- package/dist/src/core/shell-init.js +61 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +124 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +67 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +78 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +93 -0
- package/dist/src/panel/handler.js +411 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +149 -0
- package/dist/src/runtime.js +371 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +117 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +166 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +181 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +52 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Built-in Family Plugin for Claude Code (spec §3.4, checked against Claude Code 2.1.289).
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { identityFromJsonFile, identityOf } from '../../core/identity.js';
|
|
5
|
+
import { readSecretFile } from '../../core/secret-file.js';
|
|
6
|
+
import { claudeStatusline } from '../../quota/claude-statusline.js';
|
|
7
|
+
import { BUILTIN_LOGOS } from '../logos.js';
|
|
8
|
+
import { CLAUDE_LAYOUT } from './layout.js';
|
|
9
|
+
import { readClaudeQuota } from './quota.js';
|
|
10
|
+
import { readClaudeUsage } from './usage.js';
|
|
11
|
+
const LINKED = [
|
|
12
|
+
'settings.json',
|
|
13
|
+
'settings.local.json',
|
|
14
|
+
'skills',
|
|
15
|
+
'commands',
|
|
16
|
+
'plugins',
|
|
17
|
+
'hooks',
|
|
18
|
+
'themes',
|
|
19
|
+
'CLAUDE.md',
|
|
20
|
+
];
|
|
21
|
+
/** The Main Account keeps `.claude.json` in HOME; other Accounts keep it inside their directory. */
|
|
22
|
+
export function claudeJsonOf(account) {
|
|
23
|
+
return account.isMain ? join(dirname(account.dir), '.claude.json') : join(account.dir, '.claude.json');
|
|
24
|
+
}
|
|
25
|
+
/** Only checks whether `oauthAccount` is present; the parsed content is dropped immediately. */
|
|
26
|
+
async function loginState(account) {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(await readFile(claudeJsonOf(account), 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 'unknown';
|
|
33
|
+
}
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
35
|
+
return 'unknown';
|
|
36
|
+
return parsed.oauthAccount != null ? 'logged-in' : 'logged-out';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `oauthAccount.emailAddress` and `organizationName` from `.claude.json`; nothing else is kept. The organization a
|
|
40
|
+
* personal sign-in gets by default ("<name>'s Organization") says nothing new, so it is left out.
|
|
41
|
+
*/
|
|
42
|
+
export function claudeIdentity(account) {
|
|
43
|
+
return identityFromJsonFile(claudeJsonOf(account), (data) => {
|
|
44
|
+
const o = data
|
|
45
|
+
?.oauthAccount;
|
|
46
|
+
if (typeof o !== 'object' || o === null)
|
|
47
|
+
return undefined;
|
|
48
|
+
const org = typeof o.organizationName === 'string' && !/'s Organization$/.test(o.organizationName)
|
|
49
|
+
? o.organizationName
|
|
50
|
+
: undefined;
|
|
51
|
+
return identityOf(o.emailAddress, org);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function model(account) {
|
|
55
|
+
if (account.kind === 'api') {
|
|
56
|
+
if (!account.secretFile)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return (await readSecretFile(account.secretFile)).ANTHROPIC_MODEL || undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const s = JSON.parse(await readFile(join(account.dir, 'settings.json'), 'utf8'));
|
|
67
|
+
return typeof s?.model === 'string' && s.model ? s.model : undefined;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const claudeFamily = {
|
|
74
|
+
id: 'claude',
|
|
75
|
+
title: 'Claude Code',
|
|
76
|
+
logo: BUILTIN_LOGOS.claude,
|
|
77
|
+
bin: 'claude',
|
|
78
|
+
installUrl: 'https://code.claude.com/docs/en/setup',
|
|
79
|
+
selectVar: 'CLAUDE_CONFIG_DIR',
|
|
80
|
+
layout: CLAUDE_LAYOUT,
|
|
81
|
+
// `plugins` alone is too generic: claude-code-router keeps `~/.claude-code-router/plugins`.
|
|
82
|
+
markers: [
|
|
83
|
+
'.claude.json',
|
|
84
|
+
'settings.json',
|
|
85
|
+
'settings.local.json',
|
|
86
|
+
'skills',
|
|
87
|
+
'commands',
|
|
88
|
+
'hooks',
|
|
89
|
+
'themes',
|
|
90
|
+
'CLAUDE.md',
|
|
91
|
+
'projects',
|
|
92
|
+
],
|
|
93
|
+
hijackVars: [
|
|
94
|
+
'ANTHROPIC_API_KEY',
|
|
95
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
96
|
+
'ANTHROPIC_BASE_URL',
|
|
97
|
+
'ANTHROPIC_MODEL',
|
|
98
|
+
'ANTHROPIC_SMALL_FAST_MODEL',
|
|
99
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
100
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
101
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
102
|
+
],
|
|
103
|
+
apiVars: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL'],
|
|
104
|
+
sharedItems: [
|
|
105
|
+
...LINKED.map((path) => ({ path, mode: 'link' })),
|
|
106
|
+
{ path: '.claude.json', mode: 'json-key', key: 'mcpServers', mainPath: '.claude.json', credential: true },
|
|
107
|
+
],
|
|
108
|
+
login: {
|
|
109
|
+
args: ['auth', 'login'],
|
|
110
|
+
hint: 'Opens the browser to sign this account in to Claude (`claude auth login`).',
|
|
111
|
+
},
|
|
112
|
+
loginState,
|
|
113
|
+
model,
|
|
114
|
+
identity: claudeIdentity,
|
|
115
|
+
readQuota: readClaudeQuota,
|
|
116
|
+
readUsage: readClaudeUsage,
|
|
117
|
+
quotaSetup: claudeStatusline,
|
|
118
|
+
};
|
|
119
|
+
export const claudePlugin = {
|
|
120
|
+
name: 'claude',
|
|
121
|
+
register(api) {
|
|
122
|
+
api.family(claudeFamily);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Where Claude Code keeps an Account. A leaf module (no imports) shared by the Family, the quota setup and
|
|
2
|
+
// the status line tap, which must not import the Family's index on every refresh.
|
|
3
|
+
export const CLAUDE_MAIN_DIR = '.claude';
|
|
4
|
+
export const CLAUDE_ACCOUNT_PREFIX = '.claude-';
|
|
5
|
+
export const CLAUDE_LAYOUT = { main: CLAUDE_MAIN_DIR, account: `${CLAUDE_ACCOUNT_PREFIX}<name>` };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Env, QuotaWindow } from '../../types.ts';
|
|
2
|
+
export interface ClaudeQuotaCache {
|
|
3
|
+
version: 1;
|
|
4
|
+
/** When the status line last reported these windows. */
|
|
5
|
+
observedAt: string;
|
|
6
|
+
windows: QuotaWindow[];
|
|
7
|
+
}
|
|
8
|
+
export declare function claudeQuotaCacheFile(stateDir: string, name: string): string;
|
|
9
|
+
/** sideby's state directory for this environment (same rule as the Runtime). */
|
|
10
|
+
export declare function cacheStateDir(env: Env): string;
|
|
11
|
+
/** Account name from CLAUDE_CONFIG_DIR: unset or `~/.claude` is `main`, `.claude-<name>` is `<name>`, else null. */
|
|
12
|
+
export declare function accountNameFromEnv(env: Env): string | null;
|
|
13
|
+
/** Extracts the 5h and 7d windows from status line JSON; null when there is nothing usable. */
|
|
14
|
+
export declare function parseRateLimits(input: string, observedAt: Date): ClaudeQuotaCache | null;
|
|
15
|
+
/** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
|
|
16
|
+
export declare function writeQuotaCache(stateDir: string, name: string, cache: ClaudeQuotaCache): Promise<void>;
|
|
17
|
+
export type CacheRead = {
|
|
18
|
+
status: 'ok';
|
|
19
|
+
observedAt: string;
|
|
20
|
+
windows: QuotaWindow[];
|
|
21
|
+
} | {
|
|
22
|
+
status: 'missing';
|
|
23
|
+
} | {
|
|
24
|
+
status: 'unrecognized';
|
|
25
|
+
detail: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Reads the cache. Accepts any file with a parseable `observedAt` and at least one valid window; `version`
|
|
29
|
+
* is not enforced so caches written by earlier releases keep working.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readQuotaCache(stateDir: string, name: string): Promise<CacheRead>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The Claude Quota cache: one module owns where it lives, how the Account is named, and how it is written,
|
|
2
|
+
// read and validated. The status line tap writes it on every refresh, so this module imports only leaf
|
|
3
|
+
// modules and Node built-ins (never the Family index, the Runtime or core/accounts.ts).
|
|
4
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { ACCOUNT_NAME } from '../../core/account-name.js';
|
|
8
|
+
import { writeFileAtomic } from '../../core/fs-safe.js';
|
|
9
|
+
import { resolvePaths } from '../../core/paths.js';
|
|
10
|
+
import { CLAUDE_ACCOUNT_PREFIX, CLAUDE_MAIN_DIR } from './layout.js';
|
|
11
|
+
const WINDOWS = [
|
|
12
|
+
{ key: 'five_hour', label: '5h', minutes: 300 },
|
|
13
|
+
{ key: 'seven_day', label: '7d', minutes: 10_080 },
|
|
14
|
+
];
|
|
15
|
+
export function claudeQuotaCacheFile(stateDir, name) {
|
|
16
|
+
return join(stateDir, 'quota', 'claude', `${name}.json`);
|
|
17
|
+
}
|
|
18
|
+
/** sideby's state directory for this environment (same rule as the Runtime). */
|
|
19
|
+
export function cacheStateDir(env) {
|
|
20
|
+
return resolvePaths(env).stateDir;
|
|
21
|
+
}
|
|
22
|
+
/** Account name from CLAUDE_CONFIG_DIR: unset or `~/.claude` is `main`, `.claude-<name>` is `<name>`, else null. */
|
|
23
|
+
export function accountNameFromEnv(env) {
|
|
24
|
+
const dir = env.CLAUDE_CONFIG_DIR;
|
|
25
|
+
if (!dir)
|
|
26
|
+
return 'main';
|
|
27
|
+
if (resolve(dir) === join(env.HOME || homedir(), CLAUDE_MAIN_DIR))
|
|
28
|
+
return 'main';
|
|
29
|
+
const base = basename(dir);
|
|
30
|
+
if (!base.startsWith(CLAUDE_ACCOUNT_PREFIX))
|
|
31
|
+
return null;
|
|
32
|
+
const name = base.slice(CLAUDE_ACCOUNT_PREFIX.length);
|
|
33
|
+
return name !== 'main' && ACCOUNT_NAME.test(name) ? name : null;
|
|
34
|
+
}
|
|
35
|
+
/** Extracts the 5h and 7d windows from status line JSON; null when there is nothing usable. */
|
|
36
|
+
export function parseRateLimits(input, observedAt) {
|
|
37
|
+
let data;
|
|
38
|
+
try {
|
|
39
|
+
data = JSON.parse(input);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const rl = data?.rate_limits;
|
|
45
|
+
if (typeof rl !== 'object' || rl === null)
|
|
46
|
+
return null;
|
|
47
|
+
const windows = [];
|
|
48
|
+
for (const w of WINDOWS) {
|
|
49
|
+
const v = rl[w.key];
|
|
50
|
+
if (typeof v !== 'object' || v === null)
|
|
51
|
+
continue;
|
|
52
|
+
const used = v.used_percentage;
|
|
53
|
+
const resets = v.resets_at;
|
|
54
|
+
if (typeof used !== 'number' || !Number.isFinite(used))
|
|
55
|
+
continue;
|
|
56
|
+
if (typeof resets !== 'number' || !Number.isFinite(resets) || resets <= 0)
|
|
57
|
+
continue;
|
|
58
|
+
windows.push({
|
|
59
|
+
label: w.label,
|
|
60
|
+
windowMinutes: w.minutes,
|
|
61
|
+
usedPercent: used,
|
|
62
|
+
resetsAt: new Date(resets * 1000).toISOString(),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return windows.length ? { version: 1, observedAt: observedAt.toISOString(), windows } : null;
|
|
66
|
+
}
|
|
67
|
+
/** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
|
|
68
|
+
export async function writeQuotaCache(stateDir, name, cache) {
|
|
69
|
+
const file = claudeQuotaCacheFile(stateDir, name);
|
|
70
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
71
|
+
await writeFileAtomic(file, `${JSON.stringify(cache)}\n`, 0o600);
|
|
72
|
+
}
|
|
73
|
+
function validWindow(w) {
|
|
74
|
+
if (typeof w !== 'object' || w === null)
|
|
75
|
+
return false;
|
|
76
|
+
const v = w;
|
|
77
|
+
return (typeof v.label === 'string' &&
|
|
78
|
+
typeof v.windowMinutes === 'number' &&
|
|
79
|
+
typeof v.usedPercent === 'number' &&
|
|
80
|
+
typeof v.resetsAt === 'string' &&
|
|
81
|
+
Number.isFinite(Date.parse(v.resetsAt)));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads the cache. Accepts any file with a parseable `observedAt` and at least one valid window; `version`
|
|
85
|
+
* is not enforced so caches written by earlier releases keep working.
|
|
86
|
+
*/
|
|
87
|
+
export async function readQuotaCache(stateDir, name) {
|
|
88
|
+
const file = claudeQuotaCacheFile(stateDir, name);
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
raw = await readFile(file, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code === 'ENOENT')
|
|
95
|
+
return { status: 'missing' };
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
let cache;
|
|
99
|
+
try {
|
|
100
|
+
cache = JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { status: 'unrecognized', detail: `${file} is not valid JSON` };
|
|
104
|
+
}
|
|
105
|
+
const windows = Array.isArray(cache?.windows) ? cache.windows.filter(validWindow) : [];
|
|
106
|
+
if (typeof cache?.observedAt !== 'string' ||
|
|
107
|
+
!Number.isFinite(Date.parse(cache.observedAt)) ||
|
|
108
|
+
!windows.length)
|
|
109
|
+
return { status: 'unrecognized', detail: `${file} has no usable quota windows` };
|
|
110
|
+
return { status: 'ok', observedAt: cache.observedAt, windows };
|
|
111
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Quota for a Claude subscription Account: the windows `sideby statusline-tap` last cached for it.
|
|
2
|
+
// Windows past `resetsAt` are returned unchanged; callers compare with the current time and show "reset".
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { realpathOrNull } from '../../core/fs-safe.js';
|
|
6
|
+
import { claudeStatusline, isWrapped, mainSettingsFile } from '../../quota/claude-statusline.js';
|
|
7
|
+
import { readQuotaCache } from './quota-cache.js';
|
|
8
|
+
export const QUOTA_SOURCE = 'claude statusline';
|
|
9
|
+
/** How the Account reaches the wrapper: the shared main settings.json, its own wrapped file, or neither. */
|
|
10
|
+
async function accountWrapped(account, ctx) {
|
|
11
|
+
if (account.isMain)
|
|
12
|
+
return 'shared';
|
|
13
|
+
const own = join(account.dir, 'settings.json');
|
|
14
|
+
const [a, b] = await Promise.all([realpathOrNull(own), realpathOrNull(mainSettingsFile(ctx.home))]);
|
|
15
|
+
if (a !== null && a === b)
|
|
16
|
+
return 'shared';
|
|
17
|
+
try {
|
|
18
|
+
const s = JSON.parse(await readFile(own, 'utf8'));
|
|
19
|
+
return isWrapped(s?.statusLine?.command) ? 'own' : 'own-unwrapped';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return 'own-unwrapped';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function withoutCache(account, ctx) {
|
|
26
|
+
const wiring = await accountWrapped(account, ctx);
|
|
27
|
+
if (wiring === 'own-unwrapped')
|
|
28
|
+
return {
|
|
29
|
+
status: 'unavailable',
|
|
30
|
+
reason: 'not-enabled',
|
|
31
|
+
detail: "this account's settings.json is not shared with the main account, so its quota needs its own status line setup",
|
|
32
|
+
};
|
|
33
|
+
const planStatus = await claudeStatusline.plan(ctx).then((p) => p.status, () => 'blocked');
|
|
34
|
+
if (wiring === 'shared' && planStatus !== 'enabled')
|
|
35
|
+
return {
|
|
36
|
+
status: 'unavailable',
|
|
37
|
+
reason: 'not-enabled',
|
|
38
|
+
detail: 'run `sideby quota setup claude` to record Claude quota from the status line',
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
status: 'unavailable',
|
|
42
|
+
reason: 'no-session',
|
|
43
|
+
detail: 'start a Claude Code session with this account; quota is recorded after its first reply',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function readClaudeQuota(account, ctx) {
|
|
47
|
+
const read = await readQuotaCache(ctx.stateDir, account.name);
|
|
48
|
+
if (read.status === 'missing')
|
|
49
|
+
return withoutCache(account, ctx);
|
|
50
|
+
if (read.status === 'unrecognized')
|
|
51
|
+
return { status: 'unavailable', reason: 'unrecognized', detail: read.detail };
|
|
52
|
+
return { status: 'ok', windows: read.windows, observedAt: read.observedAt, source: QUOTA_SOURCE };
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { FileMemo } from '../../core/file-memo.ts';
|
|
2
|
+
import type { Account, ReadContext, UsageResult } from '../../types.ts';
|
|
3
|
+
interface Entry {
|
|
4
|
+
timestamp: number;
|
|
5
|
+
session: string;
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
cacheWrite: number;
|
|
10
|
+
}
|
|
11
|
+
/** What one session log holds: its usage rows in file order, keyed for the "last line wins" dedupe. */
|
|
12
|
+
interface Parsed {
|
|
13
|
+
lines: number;
|
|
14
|
+
parsed: number;
|
|
15
|
+
rows: [string, Entry][];
|
|
16
|
+
/** The read stopped early; the rows so far still count, but the result is not remembered. */
|
|
17
|
+
failed: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Parsed session logs per Account directory; a file is parsed again only after it changed. */
|
|
20
|
+
export declare const claudeUsageMemo: FileMemo<Parsed>;
|
|
21
|
+
export declare function readClaudeUsage(account: Account, ctx: ReadContext): Promise<UsageResult>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Usage for a Claude Code Account, counted from its own session logs (`<dir>/projects/**/*.jsonl`).
|
|
2
|
+
// One assistant message can be logged on several lines; the last line per `message.id` wins. Each file's parse is
|
|
3
|
+
// remembered until the file changes (core/file-memo.ts), so a refresh reads only the logs that grew.
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { FileMemo } from '../../core/file-memo.js';
|
|
9
|
+
import { USAGE_DAYS } from '../../core/quota-levels.js';
|
|
10
|
+
import { dailyBuckets } from '../../core/usage-days.js';
|
|
11
|
+
const DAY_MS = 86_400_000;
|
|
12
|
+
async function listJsonl(dir, out = []) {
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
const p = join(dir, e.name);
|
|
22
|
+
// Dirent types do not follow symlinks, so a linked directory can never loop the walk.
|
|
23
|
+
if (e.isDirectory())
|
|
24
|
+
await listJsonl(p, out);
|
|
25
|
+
else if (e.isFile() && e.name.endsWith('.jsonl'))
|
|
26
|
+
out.push(p);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
31
|
+
async function parseFile(file) {
|
|
32
|
+
const out = { lines: 0, parsed: 0, rows: [], failed: false };
|
|
33
|
+
try {
|
|
34
|
+
const rl = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
|
|
35
|
+
for await (const line of rl) {
|
|
36
|
+
if (!line.trim())
|
|
37
|
+
continue;
|
|
38
|
+
out.lines++;
|
|
39
|
+
let row;
|
|
40
|
+
try {
|
|
41
|
+
row = JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
out.parsed++;
|
|
47
|
+
if (row?.type !== 'assistant')
|
|
48
|
+
continue;
|
|
49
|
+
const usage = row.message?.usage;
|
|
50
|
+
if (typeof usage !== 'object' || usage === null)
|
|
51
|
+
continue;
|
|
52
|
+
const id = row.message?.id;
|
|
53
|
+
const key = typeof id === 'string'
|
|
54
|
+
? id
|
|
55
|
+
: typeof row.requestId === 'string'
|
|
56
|
+
? `request:${row.requestId}`
|
|
57
|
+
: `${file}:${out.lines}`;
|
|
58
|
+
out.rows.push([
|
|
59
|
+
key,
|
|
60
|
+
{
|
|
61
|
+
timestamp: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : Number.NaN,
|
|
62
|
+
session: typeof row.sessionId === 'string' ? row.sessionId : file,
|
|
63
|
+
input: count(usage.input_tokens),
|
|
64
|
+
output: count(usage.output_tokens),
|
|
65
|
+
cacheRead: count(usage.cache_read_input_tokens),
|
|
66
|
+
cacheWrite: count(usage.cache_creation_input_tokens),
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// An unreadable file is skipped; the others still count.
|
|
73
|
+
out.failed = true;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Parsed session logs per Account directory; a file is parsed again only after it changed. */
|
|
78
|
+
export const claudeUsageMemo = new FileMemo();
|
|
79
|
+
export async function readClaudeUsage(account, ctx) {
|
|
80
|
+
const files = await listJsonl(join(account.dir, 'projects'));
|
|
81
|
+
if (!files.length)
|
|
82
|
+
return {
|
|
83
|
+
status: 'unavailable',
|
|
84
|
+
reason: 'no-session',
|
|
85
|
+
detail: 'no Claude Code sessions recorded for this account yet',
|
|
86
|
+
};
|
|
87
|
+
const now = ctx.now.getTime();
|
|
88
|
+
const since = now - USAGE_DAYS * DAY_MS;
|
|
89
|
+
const entries = new Map();
|
|
90
|
+
let lines = 0;
|
|
91
|
+
let parsed = 0;
|
|
92
|
+
const memo = claudeUsageMemo.pass(account.dir);
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const st = await stat(file).catch(() => null);
|
|
95
|
+
if (!st || st.mtimeMs < since)
|
|
96
|
+
continue;
|
|
97
|
+
const p = await memo.get(file, st, () => parseFile(file), (v) => !v.failed);
|
|
98
|
+
lines += p.lines;
|
|
99
|
+
parsed += p.parsed;
|
|
100
|
+
for (const [key, entry] of p.rows) {
|
|
101
|
+
// Re-insert so the last occurrence wins.
|
|
102
|
+
entries.delete(key);
|
|
103
|
+
entries.set(key, entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
memo.done();
|
|
107
|
+
if (lines > 0 && parsed === 0)
|
|
108
|
+
return {
|
|
109
|
+
status: 'unavailable',
|
|
110
|
+
reason: 'unrecognized',
|
|
111
|
+
detail: `session logs in the last ${USAGE_DAYS} days could not be parsed as JSON lines`,
|
|
112
|
+
};
|
|
113
|
+
const sessions = new Set();
|
|
114
|
+
const days = dailyBuckets(ctx.now);
|
|
115
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
116
|
+
let inputTokens = 0;
|
|
117
|
+
let outputTokens = 0;
|
|
118
|
+
let cacheReadTokens = 0;
|
|
119
|
+
let cacheWriteTokens = 0;
|
|
120
|
+
for (const e of entries.values()) {
|
|
121
|
+
if (!Number.isFinite(e.timestamp) || e.timestamp > now)
|
|
122
|
+
continue;
|
|
123
|
+
if (e.timestamp > last)
|
|
124
|
+
last = e.timestamp;
|
|
125
|
+
if (e.timestamp < since)
|
|
126
|
+
continue;
|
|
127
|
+
sessions.add(e.session);
|
|
128
|
+
inputTokens += e.input;
|
|
129
|
+
outputTokens += e.output;
|
|
130
|
+
cacheReadTokens += e.cacheRead;
|
|
131
|
+
cacheWriteTokens += e.cacheWrite;
|
|
132
|
+
days.add(e.timestamp, e.input + e.output + e.cacheRead + e.cacheWrite);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
status: 'ok',
|
|
136
|
+
days: USAGE_DAYS,
|
|
137
|
+
sessions: sessions.size,
|
|
138
|
+
inputTokens,
|
|
139
|
+
outputTokens,
|
|
140
|
+
cacheReadTokens,
|
|
141
|
+
cacheWriteTokens,
|
|
142
|
+
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
143
|
+
daily: days.daily,
|
|
144
|
+
...(Number.isFinite(last) ? { lastActivityAt: new Date(last).toISOString() } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Account, FamilyDef, Plugin } from '../../types.ts';
|
|
2
|
+
/** The email claim of `tokens.id_token` in auth.json; the token and every other claim are dropped. */
|
|
3
|
+
export declare function codexIdentity(account: Account): Promise<import("../../types.ts").AccountIdentity | undefined>;
|
|
4
|
+
export declare const codexFamily: FamilyDef;
|
|
5
|
+
export declare const codexPlugin: Plugin;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { identityFromJsonFile, identityOf, jwtEmail } from '../../core/identity.js';
|
|
4
|
+
import { BUILTIN_LOGOS } from '../logos.js';
|
|
5
|
+
import { topLevelTomlString } from '../shared/toml.js';
|
|
6
|
+
import { readCodexQuota, readCodexUsage } from './rollout.js';
|
|
7
|
+
const FILE_STORE_KEY = 'cli_auth_credentials_store';
|
|
8
|
+
async function readModel(account) {
|
|
9
|
+
try {
|
|
10
|
+
return topLevelTomlString(await readFile(join(account.dir, 'config.toml'), 'utf8'), 'model');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The email claim of `tokens.id_token` in auth.json; the token and every other claim are dropped. */
|
|
17
|
+
export function codexIdentity(account) {
|
|
18
|
+
return identityFromJsonFile(join(account.dir, 'auth.json'), (data) => identityOf(jwtEmail(data?.tokens?.id_token)));
|
|
19
|
+
}
|
|
20
|
+
export const codexFamily = {
|
|
21
|
+
id: 'codex',
|
|
22
|
+
title: 'Codex',
|
|
23
|
+
logo: BUILTIN_LOGOS.codex,
|
|
24
|
+
bin: 'codex',
|
|
25
|
+
installUrl: 'https://github.com/openai/codex',
|
|
26
|
+
selectVar: 'CODEX_HOME',
|
|
27
|
+
layout: { main: '.codex', account: '.codex-<name>' },
|
|
28
|
+
hijackVars: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_API_BASE_URL'],
|
|
29
|
+
apiVars: ['OPENAI_BASE_URL', 'OPENAI_API_KEY'],
|
|
30
|
+
sharedItems: [
|
|
31
|
+
{ path: 'AGENTS.md', mode: 'link' },
|
|
32
|
+
{ path: 'hooks.json', mode: 'link' },
|
|
33
|
+
{ path: 'plugins', mode: 'link' },
|
|
34
|
+
{ path: 'rules', mode: 'link' },
|
|
35
|
+
{ path: 'skills', mode: 'link' },
|
|
36
|
+
{ path: 'config.toml', mode: 'local-if-api' },
|
|
37
|
+
{ path: 'auth.json', mode: 'info', credential: true },
|
|
38
|
+
],
|
|
39
|
+
login: {
|
|
40
|
+
args: ['login'],
|
|
41
|
+
hint: "Sign in with ChatGPT in the browser; the login is saved to this account's auth.json",
|
|
42
|
+
},
|
|
43
|
+
// A non-main Account keeps its login in its own auth.json instead of the shared OS keyring.
|
|
44
|
+
defaultArgs(account, userArgs) {
|
|
45
|
+
if (account.isMain || userArgs.some((a) => a.includes(FILE_STORE_KEY)))
|
|
46
|
+
return [];
|
|
47
|
+
return ['-c', `${FILE_STORE_KEY}="file"`];
|
|
48
|
+
},
|
|
49
|
+
async loginState(account) {
|
|
50
|
+
try {
|
|
51
|
+
return (await stat(join(account.dir, 'auth.json'))).isFile() ? 'logged-in' : 'unknown';
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return err.code === 'ENOENT' ? 'logged-out' : 'unknown';
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
model: readModel,
|
|
58
|
+
identity: codexIdentity,
|
|
59
|
+
readQuota: (account) => readCodexQuota(account.dir),
|
|
60
|
+
readUsage: (account, ctx) => readCodexUsage(account.dir, ctx.now),
|
|
61
|
+
};
|
|
62
|
+
export const codexPlugin = {
|
|
63
|
+
name: 'codex',
|
|
64
|
+
register(api) {
|
|
65
|
+
api.family(codexFamily);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { FileMemo, type FileStats } from '../../core/file-memo.ts';
|
|
2
|
+
import type { QuotaResult, QuotaWindow, UsageResult } from '../../types.ts';
|
|
3
|
+
export declare const MAX_QUOTA_FILES = 20;
|
|
4
|
+
export interface RolloutFile {
|
|
5
|
+
path: string;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
stats: FileStats;
|
|
8
|
+
}
|
|
9
|
+
/** Every rollout file under `<dir>/sessions`, newest mtime first. */
|
|
10
|
+
export declare function listRollouts(dir: string): Promise<RolloutFile[]>;
|
|
11
|
+
export declare function windowLabel(minutes: number): string;
|
|
12
|
+
interface FileLimits {
|
|
13
|
+
/** The file's newest well-formed `rate_limits` (the later line on a tie), or null. */
|
|
14
|
+
best: {
|
|
15
|
+
at: number;
|
|
16
|
+
windows: QuotaWindow[];
|
|
17
|
+
plan?: string;
|
|
18
|
+
} | null;
|
|
19
|
+
failed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Parsed `rate_limits` per Account directory; a rollout is read again only after it changed. */
|
|
22
|
+
export declare const codexQuotaMemo: FileMemo<FileLimits>;
|
|
23
|
+
/** Newest well-formed `rate_limits` across the newest rollout files, by event timestamp. */
|
|
24
|
+
export declare function readCodexQuota(dir: string): Promise<QuotaResult>;
|
|
25
|
+
interface Totals {
|
|
26
|
+
input: number;
|
|
27
|
+
cached: number;
|
|
28
|
+
output: number;
|
|
29
|
+
cacheWrite: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What each event adds, by index. `total_token_usage` is cumulative per segment: every field, cached input
|
|
33
|
+
* included, only grows until the Host starts a new segment, where they all start again from zero. A subagent's
|
|
34
|
+
* file starts from its parent's running total. So the first event adds its total minus the starting baseline
|
|
35
|
+
* (that total minus the first turn's own usage, zero for an ordinary session), an event where any field went
|
|
36
|
+
* down starts a new segment and adds its whole total, and any other event adds its growth. Every field of every
|
|
37
|
+
* increment is therefore zero or more, and any run of events sums to exactly what it added.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fileDeltas(events: {
|
|
40
|
+
total: Totals;
|
|
41
|
+
turn: Totals | null;
|
|
42
|
+
}[]): Totals[];
|
|
43
|
+
/** Tokens one whole rollout file adds: the sum of its fileDeltas. */
|
|
44
|
+
export declare function fileTokens(events: {
|
|
45
|
+
total: Totals;
|
|
46
|
+
turn: Totals | null;
|
|
47
|
+
}[]): Totals | null;
|
|
48
|
+
interface FileUsage {
|
|
49
|
+
/** Time of every `token_count` event, with or without totals. */
|
|
50
|
+
times: number[];
|
|
51
|
+
events: {
|
|
52
|
+
at: number;
|
|
53
|
+
total: Totals;
|
|
54
|
+
turn: Totals | null;
|
|
55
|
+
}[];
|
|
56
|
+
deltas: Totals[];
|
|
57
|
+
failed: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** Parsed token totals per Account directory; a rollout is read again only after it changed. */
|
|
60
|
+
export declare const codexUsageMemo: FileMemo<FileUsage>;
|
|
61
|
+
/**
|
|
62
|
+
* Token totals for events in the last `days` x 24 h, from rollout files modified in that window (mtime only
|
|
63
|
+
* picks which files to read). Each event counts its fileDeltas increment, so a long session resumed today
|
|
64
|
+
* adds only its recent turns. `daily` spreads the same increments over local days.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readCodexUsage(dir: string, now: Date, days?: number): Promise<UsageResult>;
|
|
67
|
+
export {};
|