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,566 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { aliasesByAccount } from '../core/accounts.js';
3
+ import { aliasRefs } from '../core/config.js';
4
+ import { UserError } from '../core/errors.js';
5
+ import { runHost } from '../core/launch.js';
6
+ import { resolvePaths, tildify } from '../core/paths.js';
7
+ import { USAGE_DAYS } from '../core/quota-levels.js';
8
+ import { SHELLS } from '../core/shell-init.js';
9
+ import { suggestAlias, suggestName } from '../panel/page-logic.js';
10
+ import { createRuntime, UnknownFamilyError } from '../runtime.js';
11
+ import { UsageError } from './args.js';
12
+ import { c, clock, quotaText, table, usageText } from './format.js';
13
+ const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data }, null, 2));
14
+ function stripFix(f) {
15
+ const { fix: _fix, ...rest } = f;
16
+ return rest;
17
+ }
18
+ export function serializeDoctor(r) {
19
+ return {
20
+ status: r.status,
21
+ accounts: r.accounts.map((a) => ({ ...a, findings: a.findings.map(stripFix) })),
22
+ general: r.general.map(stripFix),
23
+ fixes: r.fixes,
24
+ };
25
+ }
26
+ /** Shows paths under HOME as `~/…` so output is short and safe to share. */
27
+ function tilde(text, home) {
28
+ return text.split(`${home}/`).join('~/');
29
+ }
30
+ export async function cmdList(a, io) {
31
+ const rt = await createRuntime();
32
+ const accounts = await rt.accounts();
33
+ const statuses = await Promise.all(accounts.map((x) => rt.status(x)));
34
+ const families = await rt.familyInfo();
35
+ if (a.flags.has('json')) {
36
+ json(io, { families, accounts: statuses, pluginErrors: [...rt.pluginErrors] });
37
+ return 0;
38
+ }
39
+ const home = rt.paths.home;
40
+ if (statuses.length === 0) {
41
+ io.out('No accounts found yet.');
42
+ const installed = families.filter((f) => f.installed);
43
+ if (installed.length === 0)
44
+ io.out(`Install a host first: ${families.map((f) => `${f.title} (${f.installUrl})`).join(', ')}`);
45
+ else
46
+ io.out(`Run ${installed.map((f) => f.bin).join(' or ')} once, then \`sideby new <family> <name>\`.`);
47
+ }
48
+ else {
49
+ const rows = statuses.map((s) => [
50
+ c.bold(s.ref),
51
+ s.isMain ? 'main' : s.kind === 'api' ? c.cyan('api') : 'sub',
52
+ s.login === 'logged-in'
53
+ ? c.green('✓')
54
+ : s.login === 'not-needed'
55
+ ? c.dim('key')
56
+ : s.login === 'logged-out'
57
+ ? c.yellow('login needed')
58
+ : c.dim('?'),
59
+ s.identity?.email ?? c.dim('—'),
60
+ s.model ?? c.dim('—'),
61
+ s.hostInstalled ? tildify(s.dir, home) : `${tildify(s.dir, home)} ${c.yellow('(host not installed)')}`,
62
+ ]);
63
+ io.out(table(rows, ['ACCOUNT', 'TYPE', 'LOGIN', 'EMAIL', 'MODEL', 'DIR']));
64
+ io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
65
+ }
66
+ for (const s of statuses)
67
+ for (const p of s.problems ?? [])
68
+ io.err(c.yellow(`${s.ref}: ${p}`));
69
+ for (const e of rt.pluginErrors)
70
+ io.err(c.yellow(`plugin ${e.where}: ${e.message}`));
71
+ return 0;
72
+ }
73
+ /**
74
+ * `new --next`: the next name of a Family, and a short command following the pattern of its existing aliases,
75
+ * from the same helpers the Panel uses (spec §3.16).
76
+ */
77
+ async function suggestNew(rt, family, alias) {
78
+ if (!(await rt.familyInfo()).some((f) => f.id === family))
79
+ throw new UnknownFamilyError(`unknown family "${family}"; run \`sideby families\` to see them`);
80
+ const all = await rt.accounts();
81
+ const mine = all.filter((x) => x.family === family);
82
+ const name = suggestName(mine.map((x) => x.name));
83
+ if (alias !== undefined)
84
+ return { name };
85
+ const byRef = aliasesByAccount(aliasRefs(rt.config.aliases), all);
86
+ const offer = suggestAlias(mine.map((x) => ({ family: x.family, name: x.name, ref: x.ref, aliases: byRef.get(x.ref) ?? [] })), family, name);
87
+ if (!offer)
88
+ return { name };
89
+ const problem = rt.aliasProblem(offer, `${family}:${name}`);
90
+ return problem ? { name, aliasProblem: `${offer}: ${problem}` } : { name, alias: offer };
91
+ }
92
+ export async function cmdNew(a, io) {
93
+ const [family, given] = a.positionals;
94
+ const next = a.flags.has('next');
95
+ if (!family || (next ? given !== undefined : !given) || a.positionals.length > 2)
96
+ throw new UsageError(next
97
+ ? 'usage: sideby new <family> --next [--api] [--alias <short-command>]; --next picks the name, so leave it out'
98
+ : 'usage: sideby new <family> <name> [--api] [--alias <short-command>], or `--next` instead of <name>');
99
+ const givenAlias = a.flags.get('alias');
100
+ const rt = await createRuntime();
101
+ const suggestion = next
102
+ ? await suggestNew(rt, family, typeof givenAlias === 'string' ? givenAlias : undefined)
103
+ : undefined;
104
+ const name = suggestion?.name ?? given;
105
+ const alias = typeof givenAlias === 'string' ? givenAlias : suggestion?.alias;
106
+ const res = await rt.createAccount(family, name, {
107
+ api: a.flags.has('api'),
108
+ ...(alias !== undefined ? { alias } : {}),
109
+ });
110
+ if (a.flags.has('json')) {
111
+ json(io, suggestion ? { ...res, suggestion } : res);
112
+ return res.ok ? 0 : 1;
113
+ }
114
+ if (suggestion?.aliasProblem)
115
+ io.out(`${c.yellow('!')} no short command: the pattern suggests ${suggestion.aliasProblem}`);
116
+ const home = rt.paths.home;
117
+ const where = tildify(res.account.dir, home);
118
+ if (res.ok)
119
+ io.out(`${c.green('✓')} created ${c.bold(res.account.ref)} at ${where}`);
120
+ else
121
+ io.out(`${c.yellow('!')} created ${c.bold(res.account.ref)} at ${where} with problems; see below`);
122
+ for (const s of res.steps.filter((x) => !x.ok || x.action === 'skipped'))
123
+ io.out(` ${s.ok ? c.dim('·') : c.red('✗')} ${s.item}: ${s.message ?? s.action}`);
124
+ for (const e of res.hookErrors)
125
+ io.err(c.red(` ${e.message}`));
126
+ if (res.alias?.added)
127
+ io.out(`${c.green('✓')} short command ${c.bold(res.alias.name)} (in a new shell)`);
128
+ else if (res.alias)
129
+ io.out(` ${c.red('✗')} ${res.alias.message}`);
130
+ printShellInitWrites(io, res.shellInitFiles ?? [], home);
131
+ io.out('\nNext:');
132
+ for (const n of res.nextSteps)
133
+ io.out(` ${n}`);
134
+ return res.ok ? 0 : 1;
135
+ }
136
+ function printShellInitWrites(io, files, home) {
137
+ for (const f of files) {
138
+ const path = f.path.startsWith('/') ? tildify(f.path, home) : f.path;
139
+ if (f.ok)
140
+ io.out(`${c.green('✓')} ${f.action === 'unchanged' ? 'up to date' : 'updated'} ${path}`);
141
+ else
142
+ io.out(` ${c.red('✗')} ${f.message}`);
143
+ }
144
+ }
145
+ export async function cmdRun(a, io, command) {
146
+ const [ref, ...extra] = a.positionals;
147
+ if (!ref)
148
+ throw new UsageError(`usage: sideby ${command} <account>${command === 'run' ? ' [-- host args]' : ''}`);
149
+ if (command === 'login' && (extra.length || a.rest.length))
150
+ throw new UsageError('login takes no extra arguments');
151
+ const rt = await createRuntime();
152
+ const prepared = await rt.prepareLaunch(ref, [...extra, ...a.rest], command);
153
+ if (prepared.notice)
154
+ io.err(c.cyan(prepared.notice));
155
+ return runHost(prepared);
156
+ }
157
+ /**
158
+ * `sideby resume <family>`: what the Host-named shell function calls (spec §3.15). Starts the Account that holds the
159
+ * session the arguments resume by id, or runs the Host unchanged.
160
+ */
161
+ export async function cmdResume(a, io) {
162
+ const [family, ...extra] = a.positionals;
163
+ if (!family)
164
+ throw new UsageError('usage: sideby resume <family> [-- host args]');
165
+ const rt = await createRuntime();
166
+ const route = await rt.resumeLaunch(family, [...extra, ...a.rest]);
167
+ if (route.account)
168
+ io.err(c.dim(`sideby: session ${route.sessionId} is in ${route.account.ref}; starting it there`));
169
+ return runHost(route.launch);
170
+ }
171
+ /** Quotes a Host argument for a command line the user may paste; plain words stay as they are. */
172
+ function shellQuote(arg) {
173
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
174
+ }
175
+ /** `sideby next <family>`: recommend the Account with the most room, then start it (spec §3.13). */
176
+ export async function cmdNext(a, io) {
177
+ const [family, ...extra] = a.positionals;
178
+ if (!family || extra.length)
179
+ throw new UsageError('usage: sideby next <family> [--dry-run] [--include-api] [-- host args]');
180
+ const includeApi = a.flags.has('include-api');
181
+ const rt = await createRuntime();
182
+ const plan = await rt.handoff(family, { includeApi });
183
+ if (a.flags.has('json')) {
184
+ json(io, plan);
185
+ return plan.pick && plan.hasQuota ? 0 : 1;
186
+ }
187
+ const now = rt.readContext().now;
188
+ const note = (e) => {
189
+ if (e.state === 'ready')
190
+ return `fullest window ${Math.round(e.pressure ?? 0)}%`;
191
+ if (e.state === 'unknown')
192
+ return 'no quota data yet';
193
+ if (e.state === 'api')
194
+ return includeApi ? 'API account, pay per use' : 'left out; add --include-api to use it';
195
+ if (e.state === 'full')
196
+ return e.resetsAt ? `back at ${clock(e.resetsAt, now)}` : 'at its limit';
197
+ return `signed out; sign in with \`sideby login ${e.ref}\``;
198
+ };
199
+ const mark = { ready: c.green, unknown: c.yellow, api: c.cyan, full: c.red, 'logged-out': c.dim };
200
+ io.out(table(plan.accounts.map((e) => [
201
+ e.ref === plan.pick ? c.green('→') : ' ',
202
+ c.bold(e.ref),
203
+ mark[e.state](e.state === 'logged-out' ? 'signed out' : e.state),
204
+ c.dim(note(e)),
205
+ ]), ['', 'ACCOUNT', 'STATE', 'NOTE']));
206
+ if (!plan.pick) {
207
+ const out = plan.accounts.filter((e) => e.state === 'logged-out').map((e) => `sideby login ${e.ref}`);
208
+ const apiLeft = !includeApi && plan.accounts.some((e) => e.state === 'api');
209
+ const why = plan.earliestReset
210
+ ? `every ${family} account is at its limit; ${plan.earliestReset.ref} is back at ${clock(plan.earliestReset.at, now)}`
211
+ : `no ${family} account can be used now`;
212
+ const next = [
213
+ ...(out.length ? [`sign in with \`${out.join('` or `')}\``] : []),
214
+ ...(apiLeft ? ['add --include-api to use an API account'] : []),
215
+ ];
216
+ io.err(c.yellow(`\n${why}${next.length ? `; ${next.join(', or ')}` : ''}.`));
217
+ return 1;
218
+ }
219
+ const picked = plan.accounts.find((e) => e.ref === plan.pick);
220
+ const runCmd = ['sideby run', plan.pick, ...(a.rest.length ? ['--', ...a.rest.map(shellQuote)] : [])].join(' ');
221
+ if (!plan.hasQuota) {
222
+ // Without any Quota the pick is a guess, often the Account that just ran out: recommend nothing, start nothing.
223
+ const setup = (await rt.quotaSetups()).some((s) => s.family === family && s.plan.status === 'ready');
224
+ io.err(c.yellow(`\nno ${family} account has quota data yet, so sideby cannot tell which one has room; ${setup ? `turn it on with \`sideby quota setup ${family}\`, or ` : ''}start one yourself, for example \`${runCmd}\`.`));
225
+ return 1;
226
+ }
227
+ const reason = picked.state === 'ready'
228
+ ? `lowest quota pressure, ${Math.round(picked.pressure ?? 0)}%`
229
+ : picked.state === 'unknown'
230
+ ? 'no account with known room; this one has no quota data yet'
231
+ : 'API account, pay per use';
232
+ io.out(`\nNext: ${c.bold(plan.pick)} (${reason}).`);
233
+ if (a.flags.has('dry-run')) {
234
+ io.out(`Start it with \`${runCmd}\`.`);
235
+ return 0;
236
+ }
237
+ // The old session stays in the old Account (spec §3.13); a short note carries the context over.
238
+ io.out(c.dim('Tip: ask your old session for a short handoff note and paste it into the new one.'));
239
+ const prepared = await rt.prepareLaunch(plan.pick, a.rest, 'run');
240
+ if (prepared.notice)
241
+ io.err(c.cyan(prepared.notice));
242
+ return runHost(prepared);
243
+ }
244
+ export async function cmdDoctor(a, io) {
245
+ const rt = await createRuntime();
246
+ const report = await rt.doctor({
247
+ ...(a.positionals[0] ? { target: a.positionals[0] } : {}),
248
+ fix: a.flags.has('fix'),
249
+ force: a.flags.has('force'),
250
+ });
251
+ if (a.flags.has('json')) {
252
+ json(io, serializeDoctor(report));
253
+ return report.status === 'ok' ? 0 : 1;
254
+ }
255
+ const home = rt.paths.home;
256
+ for (const f of report.fixes)
257
+ io.out(`${f.ok ? c.green('fixed') : c.red('not fixed')} ${f.account} ${f.item}: ${f.message}`);
258
+ for (const f of report.general)
259
+ io.out(`${f.level === 'fail' ? c.red('✗') : c.yellow('!')} ${tilde(f.message, home)}${f.hint ? c.dim(` — ${f.hint}`) : ''}`);
260
+ for (const acc of report.accounts) {
261
+ const bad = acc.findings.filter((f) => f.level !== 'ok');
262
+ const mark = bad.some((f) => f.level === 'fail') ? c.red('✗') : bad.length ? c.yellow('!') : c.green('✓');
263
+ const shared = acc.shared.total ? `shared ${acc.shared.ok}/${acc.shared.total}` : '';
264
+ const backups = acc.backups ? c.dim(` ${acc.backups} backup leftovers`) : '';
265
+ io.out(`${mark} ${c.bold(acc.ref)} ${c.dim(acc.kind)} ${shared}${backups} ${c.dim(tildify(acc.dir, home))}`);
266
+ for (const f of bad) {
267
+ const lvl = f.level === 'fail' ? c.red('fail') : c.yellow('warn');
268
+ const src = f.source === 'core' ? '' : c.dim(` [${f.source}]`);
269
+ io.out(` ${lvl} ${tilde(f.message, home)}${src}`);
270
+ if (f.hint)
271
+ io.out(` ${c.dim(tilde(f.hint, home))}`);
272
+ }
273
+ }
274
+ if (report.accounts.length === 0 && report.general.length === 0)
275
+ io.out('No accounts to check.');
276
+ const fixable = report.accounts.flatMap((x) => x.findings).filter((f) => f.fixable).length;
277
+ if (!a.flags.has('fix') && fixable)
278
+ io.out(c.cyan(`\n${fixable} issue(s) can be fixed with \`sideby doctor --fix\`.`));
279
+ return report.status === 'ok' ? 0 : 1;
280
+ }
281
+ export async function cmdQuota(a, io) {
282
+ const sub = a.positionals[0];
283
+ if (sub === 'setup' || sub === 'teardown')
284
+ return cmdQuotaSetup(a, io, sub);
285
+ const rt = await createRuntime();
286
+ const accounts = sub ? [await rt.resolve(sub)] : await rt.accounts();
287
+ const rows = await Promise.all(accounts.map(async (x) => ({
288
+ ref: x.ref,
289
+ kind: x.kind,
290
+ quota: await rt.quota(x),
291
+ usage: await rt.usage(x),
292
+ })));
293
+ if (a.flags.has('json')) {
294
+ json(io, { accounts: rows });
295
+ return 0;
296
+ }
297
+ if (rows.length === 0) {
298
+ io.out('No accounts found yet. Run `sideby` for how to start.');
299
+ return 0;
300
+ }
301
+ const now = new Date();
302
+ io.out(table(rows.map((r) => [c.bold(r.ref), quotaText(r.quota, now), usageText(r.usage)]), ['ACCOUNT', 'QUOTA', `USAGE (${USAGE_DAYS}d)`]));
303
+ for (const r of rows)
304
+ if (r.quota.status === 'unavailable' && r.quota.detail)
305
+ io.out(c.dim(`${r.ref}: ${r.quota.detail}`));
306
+ // Suggest a setup only where it would help: the Family has one and its plan can be applied.
307
+ const off = new Set(rows
308
+ .filter((r) => r.quota.status === 'unavailable' && r.quota.reason === 'not-enabled')
309
+ .map((r) => r.ref.slice(0, r.ref.indexOf(':'))));
310
+ if (off.size)
311
+ for (const s of await rt.quotaSetups())
312
+ if (off.has(s.family) && s.plan.status === 'ready')
313
+ io.out(c.cyan(`\nTurn on ${s.family} quota with \`sideby quota setup ${s.family}\` (shows the change first).`));
314
+ return 0;
315
+ }
316
+ async function cmdQuotaSetup(a, io, sub) {
317
+ const familyId = a.positionals[1];
318
+ if (!familyId)
319
+ throw new UsageError(`usage: sideby quota ${sub} <family>${sub === 'setup' ? ' [--yes]' : ''}`);
320
+ const rt = await createRuntime();
321
+ const setup = rt.quotaSetup(familyId);
322
+ if (sub === 'teardown') {
323
+ const r = await setup.teardown();
324
+ if (a.flags.has('json'))
325
+ json(io, { family: familyId, ...r });
326
+ else {
327
+ io.out(`${r.ok ? c.green('✓') : c.red('✗')} ${r.message}`);
328
+ if (r.diff)
329
+ io.out(r.diff);
330
+ }
331
+ return r.ok ? 0 : 1;
332
+ }
333
+ const plan = await setup.plan();
334
+ const apply = a.flags.has('yes');
335
+ if (plan.status !== 'ready' || !apply) {
336
+ if (a.flags.has('json'))
337
+ json(io, { family: familyId, applied: false, plan });
338
+ else {
339
+ io.out(plan.message);
340
+ if (plan.status === 'ready') {
341
+ io.out(`\n${setup.summary}\nChange to ${tildify(plan.file, rt.paths.home)}:\n`);
342
+ io.out(plan.diff);
343
+ io.out(c.cyan(`\nApply it with \`sideby quota setup ${familyId} --yes\`; undo any time with \`sideby quota teardown ${familyId}\`.`));
344
+ }
345
+ }
346
+ // 10: the change is shown and waits for the user's yes (spec §3.9); agents ask before adding --yes.
347
+ return plan.status === 'blocked' ? 1 : plan.status === 'ready' ? 10 : 0;
348
+ }
349
+ const after = await setup.apply();
350
+ if (a.flags.has('json'))
351
+ json(io, { family: familyId, applied: after.status === 'enabled', plan: after });
352
+ else
353
+ io.out(`${after.status === 'enabled' ? c.green('✓') : c.red('✗')} ${after.message}`);
354
+ return after.status === 'enabled' ? 0 : 1;
355
+ }
356
+ export async function cmdPlugins(a, io) {
357
+ const rt = await createRuntime();
358
+ if (a.flags.has('json'))
359
+ // Plugin settings can hold tokens: report which keys are set, never their values.
360
+ json(io, {
361
+ plugins: rt.plugins.map(({ config, ...p }) => ({ ...p, configKeys: Object.keys(config).sort() })),
362
+ errors: [...rt.pluginErrors],
363
+ });
364
+ else {
365
+ io.out(table(rt.plugins.map((p) => [
366
+ c.bold(p.name),
367
+ p.version,
368
+ p.source === 'builtin' ? 'built-in' : tildify(p.source, rt.paths.home),
369
+ p.families.join(', ') || c.dim('hooks'),
370
+ ]), ['PLUGIN', 'VERSION', 'SOURCE', 'PROVIDES']));
371
+ for (const e of rt.pluginErrors)
372
+ io.err(c.red(`✗ ${e.where}: ${e.message}`));
373
+ io.out(c.dim('\nPlugins run as you, with your permissions. Only load plugins you trust.'));
374
+ }
375
+ return rt.pluginErrors.length ? 1 : 0;
376
+ }
377
+ export async function cmdShellInit(a, io) {
378
+ const shell = a.positionals[0];
379
+ const write = a.flags.has('write');
380
+ const usage = 'usage: sideby shell-init zsh|bash | sideby shell-init [zsh|bash] --write';
381
+ if (a.positionals.length > 1 || (shell === undefined && !write))
382
+ throw new UsageError(usage);
383
+ if (shell !== undefined && !SHELLS.includes(shell))
384
+ throw new UsageError(usage);
385
+ const rt = await createRuntime();
386
+ if (!write) {
387
+ io.out((await rt.shellInitScript(shell)).replace(/\n$/, ''));
388
+ return 0;
389
+ }
390
+ const configured = SHELLS.filter((s) => rt.config.shellInitFile?.[s]);
391
+ const wanted = shell ? [shell] : configured;
392
+ const missing = shell && !configured.includes(shell) ? shell : wanted.length ? null : 'zsh';
393
+ if (missing)
394
+ throw new UserError(`no shellInitFile.${missing} in ${tildify(rt.paths.configFile, rt.paths.home)}; add for example "shellInitFile": { "${missing}": "~/.config/sideby/shell-init.${missing}" }`);
395
+ const files = await rt.writeShellInitFiles(wanted);
396
+ printShellInitWrites(io, files, rt.paths.home);
397
+ return files.every((f) => f.ok) ? 0 : 1;
398
+ }
399
+ /** Absolute path of this CLI's entry (`main.ts` from source, `main.js` from dist), for detached processes. */
400
+ function cliEntry() {
401
+ const ext = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
402
+ return fileURLToPath(new URL(`./main.${ext}`, import.meta.url));
403
+ }
404
+ function parsePort(a) {
405
+ const portFlag = a.flags.get('port');
406
+ const port = typeof portFlag === 'string' ? Number(portFlag) : undefined;
407
+ if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
408
+ throw new UsageError('--port must be a number between 1 and 65535');
409
+ return port;
410
+ }
411
+ export async function cmdUi(a, io) {
412
+ const port = parsePort(a);
413
+ const bg = await import('../panel/background.js');
414
+ const paths = resolvePaths(process.env);
415
+ if (a.flags.has('stop')) {
416
+ if (a.flags.size > 1)
417
+ throw new UsageError('--stop takes no other options');
418
+ const r = await bg.stopBackgroundPanel(paths);
419
+ if (r.status === 'stopped')
420
+ io.out(`${c.green('✓')} stopped the background panel (pid ${r.pid}, ${r.url})`);
421
+ else if (r.status === 'not-running')
422
+ io.out(`No background panel is running.${r.detail ? ` ${r.detail}.` : ''}`);
423
+ else
424
+ io.err(c.red(`✗ could not stop the background panel: ${r.detail}`));
425
+ return r.status === 'failed' ? 1 : 0;
426
+ }
427
+ if (a.flags.has(bg.DETACHED_FLAG))
428
+ return bg.serveDetached({
429
+ paths,
430
+ env: process.env,
431
+ ...(port ? { port } : {}),
432
+ runtimeFactory: (env) => createRuntime({ env }),
433
+ log: (s) => io.out(s),
434
+ });
435
+ const { openBrowser, startPanelServer } = await import('../panel/server.js');
436
+ if (a.flags.has('background')) {
437
+ const r = await bg.startBackgroundPanel({
438
+ paths,
439
+ env: process.env,
440
+ entry: cliEntry(),
441
+ ...(port ? { port } : {}),
442
+ });
443
+ io.out(r.reused
444
+ ? `sideby panel is already running at ${r.url}`
445
+ : `sideby panel: ${r.url} (in the background${r.replaced ? ', replacing the one from an older sideby' : ''}; stop it with \`sideby ui --stop\`)`);
446
+ if (!a.flags.has('no-open') && !(await openBrowser(r.url)))
447
+ io.out(`open ${r.url} in your browser`);
448
+ return 0;
449
+ }
450
+ const srv = await startPanelServer({
451
+ runtimeFactory: () => createRuntime(),
452
+ ...(port ? { port } : {}),
453
+ open: !a.flags.has('no-open'),
454
+ log: (s) => io.out(s),
455
+ });
456
+ if (srv.reused)
457
+ return 0;
458
+ io.out(c.dim('Ctrl+C to stop'));
459
+ await new Promise((resolve) => {
460
+ const stop = () => {
461
+ void srv.close().then(resolve);
462
+ };
463
+ process.once('SIGINT', stop);
464
+ process.once('SIGTERM', stop);
465
+ });
466
+ return 0;
467
+ }
468
+ export async function cmdApp(a, io) {
469
+ const sub = a.positionals[0];
470
+ const usage = 'usage: sideby app install [--url <url>] | sideby app uninstall';
471
+ if ((sub !== 'install' && sub !== 'uninstall') || a.positionals.length > 1)
472
+ throw new UsageError(usage);
473
+ if (sub === 'uninstall' && a.flags.has('url'))
474
+ throw new UsageError('--url belongs to `sideby app install`');
475
+ const { installApp, uninstallApp } = await import('../desktop/app.js');
476
+ const home = resolvePaths(process.env).home;
477
+ const ctx = { home, env: process.env, platform: process.platform };
478
+ if (sub === 'install') {
479
+ const url = a.flags.get('url');
480
+ const { packageVersion } = await import('../core/version.js');
481
+ const r = await installApp(ctx, {
482
+ node: process.execPath,
483
+ entry: cliEntry(),
484
+ version: packageVersion(),
485
+ ...(typeof url === 'string' ? { url } : {}),
486
+ });
487
+ if (a.flags.has('json'))
488
+ json(io, r);
489
+ else {
490
+ io.out(`${c.green('✓')} ${r.updated ? 'updated' : 'installed'} ${c.bold(tildify(r.path, home))}`);
491
+ io.out(c.dim(` starts ${tildify(r.node, home)} ${tildify(r.entry, home)}`));
492
+ for (const h of r.hints)
493
+ io.out(` ${h}`);
494
+ }
495
+ return 0;
496
+ }
497
+ const r = await uninstallApp(ctx);
498
+ if (a.flags.has('json'))
499
+ json(io, r);
500
+ else {
501
+ for (const p of r.removed)
502
+ io.out(`${c.green('✓')} removed ${tildify(p, home)}`);
503
+ for (const s of r.skipped)
504
+ io.err(c.yellow(`! ${tildify(s.path, home)}: ${s.reason}`));
505
+ if (!r.removed.length && !r.skipped.length)
506
+ io.out('No sideby app is installed.');
507
+ }
508
+ return r.skipped.length ? 1 : 0;
509
+ }
510
+ export async function cmdAlias(a, io) {
511
+ const [sub, alias, target, ...more] = a.positionals;
512
+ const usage = 'usage: sideby alias add <short-command> <account> [-- host args] | sideby alias rm <short-command>';
513
+ const add = sub === 'add' && alias !== undefined && target !== undefined && more.length === 0;
514
+ const rm = sub === 'rm' && alias !== undefined && target === undefined && a.rest.length === 0;
515
+ if (!add && !rm)
516
+ throw new UsageError(usage);
517
+ const rt = await createRuntime();
518
+ const res = add ? await rt.addAlias(alias, target, a.rest) : await rt.removeAlias(alias);
519
+ if (a.flags.has('json')) {
520
+ json(io, res);
521
+ return res.ok ? 0 : 1;
522
+ }
523
+ const starts = res.account ? ` → ${res.account}${res.args ? ` ${res.args.join(' ')}` : ''}` : '';
524
+ const said = {
525
+ added: `${c.green('✓')} short command ${c.bold(res.alias)}${starts} (in a new shell)`,
526
+ exists: `${c.green('✓')} short command ${c.bold(res.alias)}${starts} was already in the config`,
527
+ removed: `${c.green('✓')} removed short command ${c.bold(res.alias)}${starts}`,
528
+ absent: `${c.dim('·')} no short command ${res.alias} in the config; nothing to remove`,
529
+ };
530
+ io.out(said[res.status]);
531
+ printShellInitWrites(io, res.shellInitFiles ?? [], rt.paths.home);
532
+ return res.ok ? 0 : 1;
533
+ }
534
+ /** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
535
+ export async function cmdFamilies(a, io) {
536
+ const [only, ...extra] = a.positionals;
537
+ if (extra.length)
538
+ throw new UsageError('usage: sideby families [family] [--json]');
539
+ const rt = await createRuntime();
540
+ const all = await rt.familyDetails();
541
+ const families = only === undefined ? all : all.filter((f) => f.id === only);
542
+ if (only !== undefined && families.length === 0)
543
+ throw new UnknownFamilyError(`unknown family "${only}"; available: ${all.map((f) => f.id).join(', ') || 'none (check `sideby plugins`)'}`);
544
+ if (a.flags.has('json')) {
545
+ json(io, { families });
546
+ return 0;
547
+ }
548
+ families.forEach((f, i) => {
549
+ if (i)
550
+ io.out('');
551
+ io.out(`${c.bold(f.title)} (${f.id}) ${f.installed ? c.green('installed') : c.yellow(`not installed: ${f.installUrl}`)}`);
552
+ io.out(` Accounts ~/${f.layout.main} (main), ~/${f.layout.account}; selected with ${f.selectVar}`);
553
+ io.out(` Sign in sideby login ${f.id}:<name> ${c.dim(f.login.args ? `(runs ${[f.bin, ...f.login.args].join(' ')})` : `(${f.login.hint})`)}`);
554
+ const yes = (b) => (b ? c.green('yes') : c.dim('no public source'));
555
+ io.out(` Quota ${yes(f.quota)}${f.quotaSetup ? c.dim(`, after \`sideby quota setup ${f.id}\``) : ''} Usage ${yes(f.usage)}`);
556
+ io.out(' Shared items, and how each account holds them:');
557
+ const rows = f.shared.map((s) => [
558
+ ` ${s.path}${s.key ? `#${s.key}` : ''}`,
559
+ s.source === 'config' ? `${s.mode} ${c.dim('(config)')}` : s.mode,
560
+ s.meaning,
561
+ ]);
562
+ io.out(table(rows, [' ITEM', 'MODE', 'IN EACH ACCOUNT']));
563
+ io.out(c.dim(' Everything else in an account directory is its own: sign-in, sessions and history.'));
564
+ });
565
+ return 0;
566
+ }
@@ -0,0 +1,16 @@
1
+ import type { QuotaResult, UsageResult } from '../types.ts';
2
+ export declare const c: {
3
+ dim: (s: string) => string;
4
+ bold: (s: string) => string;
5
+ red: (s: string) => string;
6
+ green: (s: string) => string;
7
+ yellow: (s: string) => string;
8
+ cyan: (s: string) => string;
9
+ };
10
+ export declare function table(rows: string[][], header: string[]): string;
11
+ export declare function ago(iso: string, now: Date): string;
12
+ export declare function clock(iso: string, now: Date): string;
13
+ export declare const QUOTA_REASON: Record<string, string>;
14
+ export declare function quotaText(q: QuotaResult, now: Date): string;
15
+ export declare function compactNumber(n: number): string;
16
+ export declare function usageText(u: UsageResult): string;
@@ -0,0 +1,80 @@
1
+ import { quotaLevel } from '../core/quota-levels.js';
2
+ const useColor = () => !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
3
+ const paint = (code) => (s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
4
+ export const c = {
5
+ dim: paint(2),
6
+ bold: paint(1),
7
+ red: paint(31),
8
+ green: paint(32),
9
+ yellow: paint(33),
10
+ cyan: paint(36),
11
+ };
12
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
13
+ export function table(rows, header) {
14
+ const strip = (s) => s.replace(ANSI, '');
15
+ const all = [header, ...rows];
16
+ const widths = header.map((_, i) => Math.max(...all.map((r) => strip(r[i] ?? '').length)));
17
+ return all
18
+ .map((r, ri) => r
19
+ .map((cell, i) => {
20
+ const pad = ' '.repeat(widths[i] - strip(cell).length);
21
+ return (ri === 0 ? c.dim(cell) : cell) + (i < r.length - 1 ? pad : '');
22
+ })
23
+ .join(' ')
24
+ .trimEnd())
25
+ .join('\n');
26
+ }
27
+ export function ago(iso, now) {
28
+ const s = Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / 1000));
29
+ if (s < 90)
30
+ return 'just now';
31
+ const m = Math.round(s / 60);
32
+ if (m < 90)
33
+ return `${m}m ago`;
34
+ const h = Math.round(m / 60);
35
+ if (h < 36)
36
+ return `${h}h ago`;
37
+ return `${Math.round(h / 24)}d ago`;
38
+ }
39
+ export function clock(iso, now) {
40
+ const d = new Date(iso);
41
+ const sameDay = d.toDateString() === now.toDateString();
42
+ const hm = d.toTimeString().slice(0, 5);
43
+ return sameDay ? hm : `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${hm}`;
44
+ }
45
+ function windowText(w, now) {
46
+ if (Date.parse(w.resetsAt) <= now.getTime())
47
+ return `${w.label} reset`;
48
+ const pct = Math.round(w.usedPercent);
49
+ const level = quotaLevel(pct);
50
+ const color = level === 'fail' ? c.red : level === 'warn' ? c.yellow : c.green;
51
+ return `${w.label} ${color(`${pct}%`)} ${c.dim(`→ ${clock(w.resetsAt, now)}`)}`;
52
+ }
53
+ export const QUOTA_REASON = {
54
+ 'not-enabled': 'off',
55
+ 'no-session': 'no data yet — start a session',
56
+ unrecognized: 'unrecognized format',
57
+ 'no-source': 'no public source',
58
+ 'api-account': 'API account',
59
+ };
60
+ export function quotaText(q, now) {
61
+ if (q.status === 'unavailable')
62
+ return c.dim(QUOTA_REASON[q.reason] ?? q.reason);
63
+ return `${q.windows.map((w) => windowText(w, now)).join(' ')} ${c.dim(ago(q.observedAt, now))}`;
64
+ }
65
+ export function compactNumber(n) {
66
+ if (n < 1000)
67
+ return String(n);
68
+ if (n < 1_000_000)
69
+ return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`;
70
+ if (n < 1_000_000_000)
71
+ return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)}M`;
72
+ return `${(n / 1_000_000_000).toFixed(1)}B`;
73
+ }
74
+ export function usageText(u) {
75
+ if (u.status === 'unavailable')
76
+ return c.dim(u.reason === 'no-source' ? '—' : u.reason === 'no-session' ? 'none' : u.reason);
77
+ if (u.totalTokens === 0)
78
+ return c.dim('none');
79
+ return `${compactNumber(u.totalTokens)} tok / ${u.days}d`;
80
+ }
@@ -0,0 +1,2 @@
1
+ import { type TSchema } from 'typebox';
2
+ export declare const OUTPUT_SCHEMAS: Record<string, TSchema>;