sideby 0.0.0-stage → 0.2.1

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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +50 -0
  8. package/dist/src/cli/commands.js +566 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +259 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +276 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +20 -0
  20. package/dist/src/core/accounts.js +131 -0
  21. package/dist/src/core/config.d.ts +61 -0
  22. package/dist/src/core/config.js +184 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +37 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/resume.d.ts +9 -0
  48. package/dist/src/core/resume.js +15 -0
  49. package/dist/src/core/secret-file.d.ts +30 -0
  50. package/dist/src/core/secret-file.js +116 -0
  51. package/dist/src/core/share-meaning.d.ts +6 -0
  52. package/dist/src/core/share-meaning.js +32 -0
  53. package/dist/src/core/share-mode-names.d.ts +1 -0
  54. package/dist/src/core/share-mode-names.js +10 -0
  55. package/dist/src/core/share-modes.d.ts +24 -0
  56. package/dist/src/core/share-modes.js +405 -0
  57. package/dist/src/core/shell-init.d.ts +31 -0
  58. package/dist/src/core/shell-init.js +84 -0
  59. package/dist/src/core/skill-check.d.ts +11 -0
  60. package/dist/src/core/skill-check.js +57 -0
  61. package/dist/src/core/usage-days.d.ts +15 -0
  62. package/dist/src/core/usage-days.js +33 -0
  63. package/dist/src/core/version.d.ts +9 -0
  64. package/dist/src/core/version.js +46 -0
  65. package/dist/src/desktop/app.d.ts +60 -0
  66. package/dist/src/desktop/app.js +316 -0
  67. package/dist/src/desktop/icon.d.ts +16 -0
  68. package/dist/src/desktop/icon.js +174 -0
  69. package/dist/src/families/claude/index.d.ts +10 -0
  70. package/dist/src/families/claude/index.js +127 -0
  71. package/dist/src/families/claude/layout.d.ts +6 -0
  72. package/dist/src/families/claude/layout.js +5 -0
  73. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  74. package/dist/src/families/claude/quota-cache.js +111 -0
  75. package/dist/src/families/claude/quota.d.ts +3 -0
  76. package/dist/src/families/claude/quota.js +53 -0
  77. package/dist/src/families/claude/usage.d.ts +22 -0
  78. package/dist/src/families/claude/usage.js +146 -0
  79. package/dist/src/families/codex/index.d.ts +5 -0
  80. package/dist/src/families/codex/index.js +70 -0
  81. package/dist/src/families/codex/rollout.d.ts +67 -0
  82. package/dist/src/families/codex/rollout.js +298 -0
  83. package/dist/src/families/grok/index.d.ts +8 -0
  84. package/dist/src/families/grok/index.js +82 -0
  85. package/dist/src/families/index.d.ts +3 -0
  86. package/dist/src/families/index.js +40 -0
  87. package/dist/src/families/logos.d.ts +21 -0
  88. package/dist/src/families/logos.js +26 -0
  89. package/dist/src/families/pi/index.d.ts +3 -0
  90. package/dist/src/families/pi/index.js +54 -0
  91. package/dist/src/families/shared/sessions.d.ts +9 -0
  92. package/dist/src/families/shared/sessions.js +70 -0
  93. package/dist/src/families/shared/toml.d.ts +5 -0
  94. package/dist/src/families/shared/toml.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +4 -0
  97. package/dist/src/panel/background.d.ts +62 -0
  98. package/dist/src/panel/background.js +274 -0
  99. package/dist/src/panel/handler.d.ts +91 -0
  100. package/dist/src/panel/handler.js +396 -0
  101. package/dist/src/panel/i18n.d.ts +287 -0
  102. package/dist/src/panel/i18n.js +570 -0
  103. package/dist/src/panel/page-logic.d.ts +154 -0
  104. package/dist/src/panel/page-logic.js +326 -0
  105. package/dist/src/panel/page-script.d.ts +2 -0
  106. package/dist/src/panel/page-script.js +1633 -0
  107. package/dist/src/panel/page-styles.d.ts +7 -0
  108. package/dist/src/panel/page-styles.js +514 -0
  109. package/dist/src/panel/page.d.ts +20 -0
  110. package/dist/src/panel/page.js +96 -0
  111. package/dist/src/panel/server.d.ts +32 -0
  112. package/dist/src/panel/server.js +133 -0
  113. package/dist/src/panel/theme.d.ts +116 -0
  114. package/dist/src/panel/theme.js +132 -0
  115. package/dist/src/plugins/account-script.d.ts +4 -0
  116. package/dist/src/plugins/account-script.js +66 -0
  117. package/dist/src/plugins/bus.d.ts +33 -0
  118. package/dist/src/plugins/bus.js +84 -0
  119. package/dist/src/plugins/loader.d.ts +58 -0
  120. package/dist/src/plugins/loader.js +250 -0
  121. package/dist/src/quota/claude-statusline.d.ts +28 -0
  122. package/dist/src/quota/claude-statusline.js +301 -0
  123. package/dist/src/quota/statusline-tap.d.ts +11 -0
  124. package/dist/src/quota/statusline-tap.js +104 -0
  125. package/dist/src/runtime.d.ts +195 -0
  126. package/dist/src/runtime.js +424 -0
  127. package/dist/src/types.d.ts +285 -0
  128. package/dist/src/types.js +3 -0
  129. package/dist/testing/demo.d.ts +8 -0
  130. package/dist/testing/demo.js +50 -0
  131. package/dist/testing/index.d.ts +36 -0
  132. package/dist/testing/index.js +118 -0
  133. package/package.json +66 -4
  134. package/schemas/alias.json +102 -0
  135. package/schemas/app-install.json +74 -0
  136. package/schemas/app-uninstall.json +54 -0
  137. package/schemas/config.json +170 -0
  138. package/schemas/doctor.json +229 -0
  139. package/schemas/error.json +25 -0
  140. package/schemas/families.json +205 -0
  141. package/schemas/list.json +177 -0
  142. package/schemas/new.json +199 -0
  143. package/schemas/next.json +127 -0
  144. package/schemas/plugins.json +74 -0
  145. package/schemas/quota-setup.json +60 -0
  146. package/schemas/quota-teardown.json +30 -0
  147. package/schemas/quota.json +203 -0
  148. package/skills/sideby/SKILL.md +61 -0
  149. package/skills/sideby/references/accounts.md +31 -0
  150. package/skills/sideby/references/diagnose.md +47 -0
  151. package/skills/sideby/references/handoff.md +41 -0
  152. package/skills/sideby/references/quota.md +14 -0
  153. package/skills/sideby/references/setup.md +31 -0
  154. package/skills/sideby/references/sharing.md +17 -0
@@ -0,0 +1,301 @@
1
+ // `sideby quota setup|teardown claude` (spec §3.7): wraps the Main Account's status line command with
2
+ // `sideby statusline-tap` so Claude Code's own `rate_limits` get cached, and restores the original bytes
3
+ // exactly on teardown, but only while the file is still what setup wrote.
4
+ import { mkdir, readFile, rm, stat } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { lstatOrNull, realpathOrNull, sha256, writeFileAtomic, writeIfUnchanged } from '../core/fs-safe.js';
7
+ import { which } from '../core/launch.js';
8
+ import { tildify } from '../core/paths.js';
9
+ import { CLAUDE_MAIN_DIR } from '../families/claude/layout.js';
10
+ import { ORIG_FLAG, TAP_COMMAND } from './statusline-tap.js';
11
+ export function backupFile(stateDir) {
12
+ return join(stateDir, 'statusline', 'claude-settings.backup.json');
13
+ }
14
+ export function mainSettingsFile(home) {
15
+ return join(home, CLAUDE_MAIN_DIR, 'settings.json');
16
+ }
17
+ export function isWrapped(command) {
18
+ return typeof command === 'string' && (command === TAP_COMMAND || command.startsWith(`${TAP_COMMAND} `));
19
+ }
20
+ export function wrapCommand(original) {
21
+ return original?.trim()
22
+ ? `${TAP_COMMAND} ${ORIG_FLAG} ${Buffer.from(original, 'utf8').toString('base64')}`
23
+ : TAP_COMMAND;
24
+ }
25
+ function isPlainObject(v) {
26
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
27
+ }
28
+ /** Keeps the file's own indentation so the diff shows only the changed lines. */
29
+ function serializeLike(original, value) {
30
+ const body = original.trimEnd();
31
+ const indent = body.includes('\n') ? (/^([ \t]+)\S/m.exec(body)?.[1] ?? 2) : undefined;
32
+ const out = indent === undefined ? JSON.stringify(value) : JSON.stringify(value, null, indent);
33
+ return original.endsWith('\n') ? `${out}\n` : out;
34
+ }
35
+ async function analyze(ctx) {
36
+ const shown = mainSettingsFile(ctx.home);
37
+ const showName = tildify(shown, ctx.home);
38
+ const blocked = (message, file = shown) => ({
39
+ plan: { status: 'blocked', file, diff: '', message },
40
+ });
41
+ const lst = await lstatOrNull(shown);
42
+ if (!lst)
43
+ return blocked(`${showName} does not exist; start Claude Code once so it creates the file, then retry`);
44
+ const target = lst.isSymbolicLink() ? await realpathOrNull(shown) : shown;
45
+ if (!target)
46
+ return blocked(`${showName} is a link to a missing file; fix the link and retry`);
47
+ const targetName = tildify(target, ctx.home);
48
+ const note = target !== shown ? ` (${showName} is a link; its target ${targetName} is changed)` : '';
49
+ let original;
50
+ try {
51
+ original = await readFile(target);
52
+ }
53
+ catch (err) {
54
+ return blocked(`cannot read ${targetName}: ${err.code ?? 'error'}`, target);
55
+ }
56
+ const text = original.toString('utf8');
57
+ let settings;
58
+ try {
59
+ settings = JSON.parse(text);
60
+ }
61
+ catch {
62
+ return blocked(`${targetName} is not valid JSON; sideby will not rewrite it`, target);
63
+ }
64
+ if (!isPlainObject(settings))
65
+ return blocked(`${targetName} is not a JSON object`, target);
66
+ const current = settings.statusLine ?? undefined;
67
+ if (current !== undefined && !isPlainObject(current))
68
+ return blocked(`statusLine in ${targetName} is not an object; sideby will not rewrite it`, target);
69
+ const command = current?.command;
70
+ if (command !== undefined && typeof command !== 'string')
71
+ return blocked(`statusLine.command in ${targetName} is not a string`, target);
72
+ if (isWrapped(command))
73
+ return {
74
+ plan: { status: 'enabled', file: target, diff: '', message: `already enabled in ${targetName}${note}` },
75
+ };
76
+ const bin = await which('sideby', ctx.env);
77
+ if (!bin)
78
+ return blocked(`sideby is not on PATH, and the status line runs \`${TAP_COMMAND}\` on every refresh; install it with \`npm i -g sideby\` and retry`, target);
79
+ const real = (await realpathOrNull(bin)) ?? bin;
80
+ if (bin.includes('/_npx/') || real.includes('/_npx/'))
81
+ return blocked(`sideby on PATH comes from the npx cache (${bin}), which can disappear; install it with \`npm i -g sideby\` and retry`, target);
82
+ const statusLine = current
83
+ ? { type: 'command', ...current, command: wrapCommand(command) }
84
+ : { type: 'command', command: TAP_COMMAND };
85
+ const wrapped = serializeLike(text, { ...settings, statusLine });
86
+ return {
87
+ plan: {
88
+ status: 'ready',
89
+ file: target,
90
+ diff: statusLineDiff(current, statusLine, targetName),
91
+ message: `wraps statusLine.command in ${targetName} so sideby can record Claude's rate limits${note}; undo with \`sideby quota teardown claude\``,
92
+ },
93
+ target,
94
+ original,
95
+ wrapped,
96
+ };
97
+ }
98
+ async function readBackup(file) {
99
+ try {
100
+ const b = JSON.parse(await readFile(file, 'utf8'));
101
+ if (typeof b.file !== 'string' ||
102
+ typeof b.originalBase64 !== 'string' ||
103
+ typeof b.originalHash !== 'string' ||
104
+ typeof b.wrappedHash !== 'string')
105
+ return null;
106
+ return b;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ export const claudeStatusline = {
113
+ summary: "Wrap the status line command in ~/.claude/settings.json with `sideby statusline-tap` so sideby can cache Claude's 5h and 7d rate limits. Your status line keeps working; teardown restores the file byte for byte.",
114
+ async plan(ctx) {
115
+ return (await analyze(ctx)).plan;
116
+ },
117
+ async apply(ctx) {
118
+ const a = await analyze(ctx);
119
+ if (a.plan.status !== 'ready' || !a.target || !a.original || a.wrapped === undefined)
120
+ return a.plan;
121
+ const mode = (await stat(a.target)).mode & 0o777;
122
+ const backup = {
123
+ file: a.target,
124
+ originalBase64: a.original.toString('base64'),
125
+ originalHash: sha256(a.original),
126
+ wrappedHash: sha256(a.wrapped),
127
+ appliedAt: ctx.now.toISOString(),
128
+ };
129
+ const bf = backupFile(ctx.stateDir);
130
+ await mkdir(dirname(bf), { recursive: true, mode: 0o700 });
131
+ await writeFileAtomic(bf, `${JSON.stringify(backup, null, 2)}\n`, 0o600);
132
+ let written = false;
133
+ try {
134
+ written = await writeIfUnchanged(a.target, backup.originalHash, a.wrapped, mode);
135
+ }
136
+ finally {
137
+ if (!written)
138
+ await rm(bf, { force: true });
139
+ }
140
+ if (!written)
141
+ return {
142
+ ...a.plan,
143
+ status: 'blocked',
144
+ message: `${tildify(a.target, ctx.home)} changed while applying; nothing was written, run setup again`,
145
+ };
146
+ return { ...a.plan, status: 'enabled', message: `enabled: ${a.plan.message}` };
147
+ },
148
+ async teardown(ctx) {
149
+ const bf = backupFile(ctx.stateDir);
150
+ const b = await readBackup(bf);
151
+ if (!b)
152
+ return {
153
+ ok: false,
154
+ message: `the Claude status line wrapper is not enabled by sideby (no backup at ${tildify(bf, ctx.home)})`,
155
+ };
156
+ const name = tildify(b.file, ctx.home);
157
+ const original = Buffer.from(b.originalBase64, 'base64');
158
+ if (sha256(original) !== b.originalHash)
159
+ return {
160
+ ok: false,
161
+ message: `the backup at ${tildify(bf, ctx.home)} is damaged; ${name} was not changed`,
162
+ };
163
+ let current;
164
+ try {
165
+ current = await readFile(b.file);
166
+ }
167
+ catch {
168
+ return {
169
+ ok: false,
170
+ message: `${name} is missing; nothing restored, the backup stays at ${tildify(bf, ctx.home)}`,
171
+ };
172
+ }
173
+ if (sha256(current) !== b.wrappedHash)
174
+ return {
175
+ ok: false,
176
+ message: `${name} changed after quota setup, so sideby will not overwrite it. Remove the \`${TAP_COMMAND}\` wrapper by hand: set statusLine.command back to your original command (base64 after ${ORIG_FLAG}), or delete statusLine if you had none. The diff shows the statusLine change to make.`,
177
+ diff: statusLineDiff(statusLineOf(current), statusLineOf(original), name),
178
+ };
179
+ try {
180
+ const mode = (await stat(b.file)).mode & 0o777;
181
+ if (!(await writeIfUnchanged(b.file, b.wrappedHash, original, mode)))
182
+ return { ok: false, message: `${name} changed while restoring; nothing was written` };
183
+ }
184
+ catch (err) {
185
+ return { ok: false, message: `could not restore ${name}: ${err.message}` };
186
+ }
187
+ await rm(bf, { force: true });
188
+ return { ok: true, message: `restored ${name} to its original bytes` };
189
+ },
190
+ };
191
+ function splitLines(a, b) {
192
+ // Drop one shared trailing newline so files ending in "\n" do not diff an empty last line.
193
+ if (a.endsWith('\n') && b.endsWith('\n'))
194
+ return [a.slice(0, -1).split('\n'), b.slice(0, -1).split('\n')];
195
+ return [a.split('\n'), b.split('\n')];
196
+ }
197
+ function middleOps(a, b) {
198
+ const n = a.length;
199
+ const m = b.length;
200
+ if (n * m > 4_000_000)
201
+ return [...a.map((line) => ({ t: '-', line })), ...b.map((line) => ({ t: '+', line }))];
202
+ // lcs[i][j] = longest common subsequence of a[i..] and b[j..]
203
+ const w = m + 1;
204
+ const lcs = new Uint32Array((n + 1) * w);
205
+ for (let i = n - 1; i >= 0; i--)
206
+ for (let j = m - 1; j >= 0; j--)
207
+ lcs[i * w + j] =
208
+ a[i] === b[j] ? lcs[(i + 1) * w + j + 1] + 1 : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
209
+ const ops = [];
210
+ let i = 0;
211
+ let j = 0;
212
+ while (i < n || j < m) {
213
+ if (i < n && j < m && a[i] === b[j]) {
214
+ ops.push({ t: ' ', line: a[i++] });
215
+ j++;
216
+ }
217
+ else if (j >= m || (i < n && lcs[(i + 1) * w + j] >= lcs[i * w + j + 1]))
218
+ ops.push({ t: '-', line: a[i++] });
219
+ else
220
+ ops.push({ t: '+', line: b[j++] });
221
+ }
222
+ return ops;
223
+ }
224
+ const SECRET_KEY = /(key|token|secret|password|passwd|auth|credential|cookie)/i;
225
+ /**
226
+ * Hides values of secret-looking JSON keys in a diff line. settings.json often keeps API keys in `env`,
227
+ * right next to `statusLine`, so context lines could otherwise print them.
228
+ */
229
+ export function redactLine(line) {
230
+ return line.replace(/("([^"\\]*)"\s*:\s*)"(?:[^"\\]|\\.)*"/g, (m, head, key) => SECRET_KEY.test(key) ? `${head}"<redacted>"` : m);
231
+ }
232
+ /**
233
+ * Diff of the statusLine object only. The rest of settings.json never enters a diff: it often holds API keys
234
+ * (for example in `env`), in shapes no line-based redaction can reliably catch.
235
+ */
236
+ export function statusLineDiff(before, after, file) {
237
+ const show = (v) => v === undefined ? '' : `${JSON.stringify({ statusLine: redactStatusLine(v) }, null, 2)}\n`;
238
+ return unifiedDiff(show(before), show(after), `${file} (statusLine only)`);
239
+ }
240
+ const SHOWN_STATUSLINE_KEYS = new Set(['type', 'command', 'padding']);
241
+ /** Shows the statusLine fields sideby touches as they are; any other value becomes "<redacted>". */
242
+ export function redactStatusLine(v) {
243
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
244
+ return '<redacted>';
245
+ return Object.fromEntries(Object.entries(v).map(([k, val]) => [
246
+ k,
247
+ SHOWN_STATUSLINE_KEYS.has(k) && (typeof val !== 'object' || val === null) ? val : '<redacted>',
248
+ ]));
249
+ }
250
+ function statusLineOf(bytes) {
251
+ try {
252
+ const v = JSON.parse(bytes.toString('utf8'));
253
+ return v && typeof v === 'object' ? v.statusLine : undefined;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }
259
+ /** Minimal unified diff with 3 lines of context; empty when the texts are equal. Secret-looking values are redacted. */
260
+ export function unifiedDiff(oldText, newText, file, context = 3) {
261
+ if (oldText === newText)
262
+ return '';
263
+ const [a, b] = splitLines(oldText, newText);
264
+ let pre = 0;
265
+ while (pre < a.length && pre < b.length && a[pre] === b[pre])
266
+ pre++;
267
+ let suf = 0;
268
+ while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf])
269
+ suf++;
270
+ const ops = [
271
+ ...a.slice(0, pre).map((line) => ({ t: ' ', line })),
272
+ ...middleOps(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf)),
273
+ ...a.slice(a.length - suf).map((line) => ({ t: ' ', line })),
274
+ ];
275
+ const changes = ops.flatMap((o, i) => (o.t === ' ' ? [] : [i]));
276
+ if (!changes.length)
277
+ return `--- a/${file}\n+++ b/${file}\n@@ line endings differ @@\n`;
278
+ const groups = [];
279
+ for (const c of changes) {
280
+ const last = groups[groups.length - 1];
281
+ if (last && c - last[1] <= 2 * context)
282
+ last[1] = c;
283
+ else
284
+ groups.push([c, c]);
285
+ }
286
+ let out = `--- a/${file}\n+++ b/${file}\n`;
287
+ for (const [first, lastChange] of groups) {
288
+ const start = Math.max(0, first - context);
289
+ const end = Math.min(ops.length, lastChange + 1 + context);
290
+ const before = ops.slice(0, start);
291
+ const slice = ops.slice(start, end);
292
+ const oldLen = slice.filter((o) => o.t !== '+').length;
293
+ const newLen = slice.filter((o) => o.t !== '-').length;
294
+ const oldStart = before.filter((o) => o.t !== '+').length + (oldLen ? 1 : 0);
295
+ const newStart = before.filter((o) => o.t !== '-').length + (newLen ? 1 : 0);
296
+ out += `@@ -${oldStart},${oldLen} +${newStart},${newLen} @@\n`;
297
+ for (const o of slice)
298
+ out += `${o.t}${redactLine(o.line)}\n`;
299
+ }
300
+ return out;
301
+ }
@@ -0,0 +1,11 @@
1
+ import { type ClaudeQuotaCache } from '../families/claude/quota-cache.ts';
2
+ import type { Env } from '../types.ts';
3
+ /** Command the wrapper starts with; `quota setup claude` detects an existing wrapper by this prefix. */
4
+ export declare const TAP_COMMAND = "sideby statusline-tap";
5
+ export declare const ORIG_FLAG = "--orig-b64";
6
+ export declare function defaultLine(cache: ClaudeQuotaCache | null): string;
7
+ /**
8
+ * Runs the tap. `argv` is everything after `statusline-tap`. Returns the original command's exit code,
9
+ * or 0 when there is no original command and a default line was printed.
10
+ */
11
+ export declare function runStatuslineTap(argv: string[], env: Env, stdin: AsyncIterable<Uint8Array | string>, stdout: NodeJS.WritableStream, stderr?: NodeJS.WritableStream): Promise<number>;
@@ -0,0 +1,104 @@
1
+ // Entry for `sideby statusline-tap` (spec §3.7). Claude Code runs it on every status line refresh, so it
2
+ // imports only Node built-ins and leaf modules (the cache module, core/fs-safe, core/paths), never the
3
+ // Runtime or a Family index. It caches `rate_limits` for the Account, then runs the original command.
4
+ // Nothing about the cache may change what the original command receives, prints or returns.
5
+ import { spawn } from 'node:child_process';
6
+ import { constants } from 'node:os';
7
+ import { accountNameFromEnv, cacheStateDir, parseRateLimits, writeQuotaCache, } from '../families/claude/quota-cache.js';
8
+ /** Command the wrapper starts with; `quota setup claude` detects an existing wrapper by this prefix. */
9
+ export const TAP_COMMAND = 'sideby statusline-tap';
10
+ export const ORIG_FLAG = '--orig-b64';
11
+ export function defaultLine(cache) {
12
+ return (cache?.windows ?? []).map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(' · ');
13
+ }
14
+ function originalCommand(argv) {
15
+ let b64;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (a === ORIG_FLAG)
19
+ b64 = argv[i + 1];
20
+ else if (a.startsWith(`${ORIG_FLAG}=`))
21
+ b64 = a.slice(ORIG_FLAG.length + 1);
22
+ }
23
+ if (!b64)
24
+ return null;
25
+ const cmd = Buffer.from(b64, 'base64').toString('utf8');
26
+ return cmd.trim() ? cmd : null;
27
+ }
28
+ /** Reads the whole stream; on a read error returns what arrived so far instead of nothing. */
29
+ async function readAll(stream) {
30
+ const chunks = [];
31
+ try {
32
+ for await (const c of stream)
33
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : Buffer.from(c));
34
+ }
35
+ catch {
36
+ // keep the partial input
37
+ }
38
+ return Buffer.concat(chunks);
39
+ }
40
+ function runOriginal(cmd, input, env, stdout, stderr) {
41
+ return new Promise((done) => {
42
+ let child;
43
+ try {
44
+ child = spawn('/bin/sh', ['-c', cmd], {
45
+ env: env,
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ });
48
+ }
49
+ catch (err) {
50
+ stderr.write(`sideby statusline-tap: ${err.message}\n`);
51
+ done(127);
52
+ return;
53
+ }
54
+ child.stdout.pipe(stdout, { end: false });
55
+ child.stderr.pipe(stderr, { end: false });
56
+ // The original command may exit without reading stdin; that is not an error.
57
+ child.stdin.on('error', () => { });
58
+ child.stdin.end(input);
59
+ child.on('error', (err) => {
60
+ stderr.write(`sideby statusline-tap: ${err.message}\n`);
61
+ done(127);
62
+ });
63
+ child.on('close', (code, signal) => {
64
+ if (code !== null)
65
+ done(code);
66
+ else
67
+ done(128 + ((signal && constants.signals[signal]) || 0));
68
+ });
69
+ });
70
+ }
71
+ /**
72
+ * Runs the tap. `argv` is everything after `statusline-tap`. Returns the original command's exit code,
73
+ * or 0 when there is no original command and a default line was printed.
74
+ */
75
+ export async function runStatuslineTap(argv, env, stdin, stdout, stderr = process.stderr) {
76
+ const cmd = originalCommand(argv);
77
+ let input = Buffer.alloc(0);
78
+ try {
79
+ input = await readAll(stdin);
80
+ }
81
+ catch {
82
+ // Pass through whatever arrived; a broken stdin must not hide the status line.
83
+ }
84
+ let cache = null;
85
+ const record = (async () => {
86
+ try {
87
+ cache = parseRateLimits(input.toString('utf8'), new Date());
88
+ const name = accountNameFromEnv(env);
89
+ if (!cache || !name)
90
+ return;
91
+ await writeQuotaCache(cacheStateDir(env), name, cache);
92
+ }
93
+ catch {
94
+ // Caching is best effort.
95
+ }
96
+ })();
97
+ if (cmd === null) {
98
+ await record;
99
+ stdout.write(`${defaultLine(cache)}\n`);
100
+ return 0;
101
+ }
102
+ const [code] = await Promise.all([runOriginal(cmd, input, env, stdout, stderr), record]);
103
+ return code;
104
+ }
@@ -0,0 +1,195 @@
1
+ import { type AccountRefError } from './core/accounts.ts';
2
+ import { type Config } from './core/config.ts';
3
+ import { type CreateResult } from './core/create.ts';
4
+ import { type DoctorReport } from './core/doctor.ts';
5
+ import { UserError } from './core/errors.ts';
6
+ import { type HandoffPlan } from './core/handoff.ts';
7
+ import { type DoctorHistory } from './core/last-doctor.ts';
8
+ import { type HostLaunch, type PreparedLaunch } from './core/launch.ts';
9
+ import { type Paths } from './core/paths.ts';
10
+ import { type Shell, type ShellInitWrite } from './core/shell-init.ts';
11
+ import { type BuiltinPlugin, type LoadedPlugin, type PluginLoadError } from './plugins/loader.ts';
12
+ import type { Account, AccountIdentity, Env, FamilyDef, LoginState, QuotaResult, QuotaSetupPlan, ReadContext, ShareMode, UsageResult } from './types.ts';
13
+ export type { AccountIdentity, AccountRefError, DoctorHistory, Shell, ShellInitWrite };
14
+ /** How `sideby resume` starts the Host (spec §3.15). */
15
+ export interface ResumeLaunch {
16
+ launch: HostLaunch;
17
+ /** The session the Host arguments resume by id, when they name one. */
18
+ sessionId?: string;
19
+ /** Set when the session lives in a non-main Account and `launch` starts that Account as `run` would. */
20
+ account?: Account;
21
+ }
22
+ /** What happened to the alias asked for with a new Account. */
23
+ export interface AliasResult {
24
+ name: string;
25
+ /** True when the config now maps the alias to the new Account (also when it already did). */
26
+ added: boolean;
27
+ message?: string;
28
+ }
29
+ /** What `sideby alias add|rm` did; `ok` is false when a shell-init file was not rewritten. */
30
+ export interface AliasEditResult {
31
+ ok: boolean;
32
+ alias: string;
33
+ status: 'added' | 'exists' | 'removed' | 'absent';
34
+ /** The Account ref the alias starts (for `rm`, started before it was removed). */
35
+ account?: string;
36
+ /** Host arguments the alias adds to `sideby run`; absent when there are none. */
37
+ args?: string[];
38
+ /** One entry per file in config `shellInitFile`; absent when none is configured. */
39
+ shellInitFiles?: ShellInitWrite[];
40
+ }
41
+ /** `createAccount` plus the alias and shell-init files it wrote; `ok` is false when either failed. */
42
+ export interface CreateAccountResult extends CreateResult {
43
+ alias?: AliasResult;
44
+ /** One entry per file in config `shellInitFile`; absent when none is configured. */
45
+ shellInitFiles?: ShellInitWrite[];
46
+ }
47
+ export interface FamilyInfo {
48
+ id: string;
49
+ title: string;
50
+ bin: string;
51
+ installUrl: string;
52
+ installed: boolean;
53
+ /** Plugin that provides the Family. */
54
+ plugin: string;
55
+ }
56
+ /** One Shared Item as `sideby families` reports it (spec §3.16). */
57
+ export interface SharedItemInfo {
58
+ path: string;
59
+ mode: ShareMode;
60
+ key?: string;
61
+ mainPath?: string;
62
+ credential?: boolean;
63
+ noSymlink?: boolean;
64
+ /** `family` from the FamilyDef, `config` from `extraSharedItems`. */
65
+ source: 'family' | 'config';
66
+ /** What the item looks like in an Account, in one fixed sentence. */
67
+ meaning: string;
68
+ }
69
+ /** A Family's facts for agents and docs: FamilyInfo plus layout, sign-in, sources and Shared Items (spec §3.16). */
70
+ export interface FamilyDetail extends FamilyInfo {
71
+ /** Relative to HOME: `{ main: '.claude', account: '.claude-<name>' }`. */
72
+ layout: {
73
+ main: string;
74
+ account: string;
75
+ };
76
+ selectVar: string;
77
+ /** `args` is null when sign-in happens inside the Host. */
78
+ login: {
79
+ args: string[] | null;
80
+ hint: string;
81
+ };
82
+ quota: boolean;
83
+ usage: boolean;
84
+ quotaSetup: boolean;
85
+ shared: SharedItemInfo[];
86
+ }
87
+ export interface AccountStatus extends Account {
88
+ familyTitle: string;
89
+ hostInstalled: boolean;
90
+ /** `not-needed` for API Accounts, which authenticate with their Secret File. */
91
+ login: LoginState | 'not-needed';
92
+ model?: string;
93
+ /** Who the Account is signed in as (email, organization), when its Family can tell. */
94
+ identity?: AccountIdentity;
95
+ /** Plugin errors met while reading this Account (for example an unreadable settings file). */
96
+ problems?: string[];
97
+ }
98
+ export interface DoctorOptions {
99
+ /** Family id or Account ref; all Accounts when omitted. */
100
+ target?: string;
101
+ fix?: boolean;
102
+ force?: boolean;
103
+ /** Record this run as the last Doctor run (default true). A read-only Panel passes false. */
104
+ persist?: boolean;
105
+ }
106
+ /** A Family's one-time quota setup, bound to this Runtime's context. */
107
+ export interface BoundQuotaSetup {
108
+ family: string;
109
+ summary: string;
110
+ plan(): Promise<QuotaSetupPlan>;
111
+ apply(): Promise<QuotaSetupPlan>;
112
+ teardown(): Promise<{
113
+ ok: boolean;
114
+ message: string;
115
+ diff?: string;
116
+ }>;
117
+ }
118
+ export interface Runtime {
119
+ paths: Paths;
120
+ config: Config;
121
+ env: Env;
122
+ families: ReadonlyMap<string, FamilyDef>;
123
+ plugins: readonly LoadedPlugin[];
124
+ pluginErrors: readonly PluginLoadError[];
125
+ familyInfo(): Promise<FamilyInfo[]>;
126
+ /** Every loaded Family with its layout, sign-in, quota support and Shared Items (config extras included). */
127
+ familyDetails(): Promise<FamilyDetail[]>;
128
+ accounts(): Promise<Account[]>;
129
+ resolve(ref: string): Promise<Account>;
130
+ status(account: Account): Promise<AccountStatus>;
131
+ doctor(opts?: DoctorOptions): Promise<DoctorReport>;
132
+ /**
133
+ * Doctor plus the merged history it produces (written unless `persist: false`), so a read-only caller
134
+ * still sees the result in context.
135
+ */
136
+ doctorWithHistory(opts?: DoctorOptions): Promise<{
137
+ report: DoctorReport;
138
+ history: DoctorHistory;
139
+ }>;
140
+ /** Latest Doctor result per Account and per Family, if any check was recorded. */
141
+ doctorHistory(): Promise<DoctorHistory | undefined>;
142
+ /** Every Family's quota setup with its current plan; a plan that throws is reported as `blocked`. */
143
+ quotaSetups(): Promise<{
144
+ family: string;
145
+ summary: string;
146
+ plan: QuotaSetupPlan;
147
+ }[]>;
148
+ /** One Family's quota setup; throws UnknownFamilyError or a UserError when the Family has none. */
149
+ quotaSetup(familyId: string): BoundQuotaSetup;
150
+ /**
151
+ * Creates the Account, then (when `alias` is given) adds it to the config `aliases`, then rewrites every
152
+ * `shellInitFile`. An alias that is invalid or taken is refused before anything is created; a failure to write
153
+ * it afterwards leaves the Account in place and is reported in `alias`.
154
+ */
155
+ createAccount(family: string, name: string, opts?: {
156
+ api?: boolean;
157
+ alias?: string;
158
+ }): Promise<CreateAccountResult>;
159
+ /**
160
+ * Adds a short command for an existing Account (`target` is a ref, a unique name or an alias, saved as the ref),
161
+ * with Host arguments for `sideby run`, then rewrites every `shellInitFile`. Refuses a taken name.
162
+ */
163
+ addAlias(alias: string, target: string, args?: readonly string[]): Promise<AliasEditResult>;
164
+ /** Removes a short command from the config, then rewrites every `shellInitFile`. */
165
+ removeAlias(alias: string): Promise<AliasEditResult>;
166
+ /** Why `alias` cannot start `ref` (shell-safe, not reserved, not taken by another Account), or null. */
167
+ aliasProblem(alias: string, ref: string): string | null;
168
+ /** What `sideby shell-init <shell>` prints, from the Accounts on disk and the config aliases. */
169
+ shellInitScript(shell: Shell): Promise<string>;
170
+ /** Rewrites the configured `shellInitFile` of each shell (all configured ones by default). */
171
+ writeShellInitFiles(shells?: readonly Shell[]): Promise<ShellInitWrite[]>;
172
+ prepareLaunch(ref: string, userArgs: readonly string[], command: 'run' | 'login'): Promise<PreparedLaunch>;
173
+ /**
174
+ * Starts the non-main Account that holds the session the Host arguments resume, or else the Host unchanged
175
+ * (spec §3.15). Throws UnknownFamilyError, or a UserError when the Family cannot route sessions.
176
+ */
177
+ resumeLaunch(familyId: string, userArgs: readonly string[]): Promise<ResumeLaunch>;
178
+ quota(account: Account): Promise<QuotaResult>;
179
+ usage(account: Account): Promise<UsageResult>;
180
+ /**
181
+ * Which Account of a Family to move on to (spec §3.13). Throws a UserError with code `no-quota-source`
182
+ * when the Family cannot tell, `no-accounts` when it has none.
183
+ */
184
+ handoff(familyId: string, opts?: {
185
+ includeApi?: boolean;
186
+ }): Promise<HandoffPlan>;
187
+ readContext(): ReadContext;
188
+ }
189
+ export declare class UnknownFamilyError extends UserError {
190
+ }
191
+ export declare function createRuntime(opts?: {
192
+ env?: Env;
193
+ builtins?: BuiltinPlugin[];
194
+ now?: () => Date;
195
+ }): Promise<Runtime>;