sideby 0.16.0 → 0.17.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.
Files changed (77) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +17 -0
  18. package/dist/src/core/local-build.js +39 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +17 -0
  28. package/dist/src/core/update.js +63 -1
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
package/README.md CHANGED
@@ -26,10 +26,10 @@ sideby new claude work # create ~/.claude-work, sharing ~/.claude's sk
26
26
  sideby login claude:work # sign in once
27
27
  sideby run work # Claude Code on the work account
28
28
  sideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota teardown claude
29
- sideby ui # every account's quota at http://127.0.0.1:17420
29
+ sideby ui --background # every account's quota at http://127.0.0.1:17420, still there after you close the terminal
30
30
  ```
31
31
 
32
- `sideby update` installs a newer sideby when there is one; sideby says so after a command and in the panel. `sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.
32
+ `sideby app install` adds a Dock icon that opens the panel, and `sideby autostart on` has it ready each time you log in. `sideby update` installs a newer sideby when there is one; sideby says so after a command and in the panel. `sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.
33
33
 
34
34
  ## Set up with your AI agent
35
35
 
@@ -46,7 +46,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
46
46
 
47
47
  The 25-second story: one account buried in tasks, then accounts side by side sharing one setup, and a task moving on when one runs out.
48
48
 
49
- https://github.com/user-attachments/assets/3a470c03-9009-4f1d-b7f6-8b4625d74958
49
+ https://github.com/user-attachments/assets/fd6138ae-499d-4055-b94f-25ef9ce74035
50
50
 
51
51
  <img alt="Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif" width="1280">
52
52
 
@@ -72,6 +72,8 @@ cc-switch's docs describe that switch: it writes the live settings file ([README
72
72
 
73
73
  | You want to | Run | Guide |
74
74
  |---|---|---|
75
+ | Hand a task to your own AI from the panel: set up an account, fix a problem, support a new tool | **Let AI do it** in the panel, or `sideby ask` | [Let AI do it](docs/guide/panel.md#let-ai-do-it) |
76
+ | Have the panel ready in the background whenever you log in | **Settings › Startup** in the panel, or `sideby autostart on` | [Start at login](docs/guide/usage.md#start-at-login) |
75
77
  | Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |
76
78
  | Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |
77
79
  | See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261017T131419-98017e"}
1
+ {"id":"20261018T035316-29e715"}
@@ -1,5 +1,6 @@
1
1
  import type { DoctorReport } from '../core/doctor.ts';
2
2
  import { type HandoffIo } from '../core/handoff-run.ts';
3
+ import { type Runtime } from '../runtime.ts';
3
4
  import type { Account, Finding } from '../types.ts';
4
5
  import { type ParsedArgs } from './args.ts';
5
6
  export interface Io {
@@ -87,3 +88,18 @@ export declare function cmdBrowser(a: ParsedArgs, io: Io): Promise<number>;
87
88
  export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
88
89
  /** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
89
90
  export declare function cmdFamilies(a: ParsedArgs, io: Io): Promise<number>;
91
+ /** `sideby ask` arguments: `--attach` may repeat, so main.ts collects every value here. */
92
+ export type AskArgs = ParsedArgs & {
93
+ attach?: string[];
94
+ };
95
+ /** AI Task CLI; the terminal receives only an opaque task id. */
96
+ export declare function cmdAsk(a: AskArgs, io: Io, runtime?: Runtime): Promise<number>;
97
+ export declare function cmdTerminal(a: ParsedArgs, io: Io): Promise<number>;
98
+ /** Reads a key only from an interactive terminal, restoring its original mode on every exit. */
99
+ export declare function readKeyLine(input?: NodeJS.ReadStream): Promise<string>;
100
+ export declare function cmdKey(a: ParsedArgs, io: Io, runtime?: Runtime): Promise<number>;
101
+ export declare function cmdTry(a: ParsedArgs, io: Io): Promise<number>;
102
+ /** `sideby autostart [on|off] [--yes]` (spec §3.29, ADR-0029): start the Panel in the background at login. */
103
+ export declare function cmdAutostart(a: ParsedArgs, io: Io): Promise<number>;
104
+ /** `sideby config get|set|unset` (spec §3.29): every setting has a command. */
105
+ export declare function cmdConfig(a: ParsedArgs, io: Io): Promise<number>;
@@ -1,3 +1,4 @@
1
+ import { StringDecoder } from 'node:string_decoder';
1
2
  import { fileURLToPath } from 'node:url';
2
3
  import { aliasesByAccount } from '../core/accounts.js';
3
4
  import { listAppProfiles, openAppProfile, parseProfileRef, removeAppProfile } from '../core/app-profiles.js';
@@ -15,7 +16,8 @@ import { resolvePaths, tildify } from '../core/paths.js';
15
16
  import { USAGE_DAYS } from '../core/quota-levels.js';
16
17
  import { groupsToTurnOff, groupsToTurnOn, groupsToUndo, } from '../core/remote-control.js';
17
18
  import { SHELLS } from '../core/shell-init.js';
18
- import { inDisplayOrder, pinnedIn, suggestAlias, suggestName, withPinned } from '../panel/page-logic.js';
19
+ import { MODEL_NAME } from '../families/shared/model-args.js';
20
+ import { AI_TASK_KINDS, inDisplayOrder, pinnedIn, suggestAlias, suggestName, withPinned, } from '../panel/page-logic.js';
19
21
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
20
22
  import { UsageError } from './args.js';
21
23
  import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
@@ -235,7 +237,7 @@ export async function cmdList(a, io) {
235
237
  io.out('No accounts found yet.');
236
238
  const installed = families.filter((f) => f.installed);
237
239
  if (installed.length === 0)
238
- io.out(`Install a host first: ${families.map((f) => `${f.title} (${installHint(f)})`).join(', ')}`);
240
+ io.out(`Install a host first:\n${families.map((f) => ` ${f.title}: ${installHint(f)}`).join('\n')}`);
239
241
  else
240
242
  io.out(`Run ${installed.map((f) => f.bin).join(' or ')} once, then \`sideby new <family> <name>\`.`);
241
243
  }
@@ -270,8 +272,9 @@ export async function cmdList(a, io) {
270
272
  io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
271
273
  io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
272
274
  }
273
- // App Profiles (ADR-0019): windows of an app that signs in apart from the Accounts above.
274
- for (const app of new Set(appProfiles.map((p) => p.app))) {
275
+ // App Profiles (ADR-0019): windows of an app that signs in apart from the Accounts above. Not on the first screen
276
+ // of someone without Accounts yet, where they would only distract from installing a Host; --json keeps them.
277
+ for (const app of statuses.length ? new Set(appProfiles.map((p) => p.app)) : []) {
275
278
  const list = appProfiles.filter((p) => p.app === app);
276
279
  io.out(`\n${c.bold(app)} ${c.dim('(signs in on its own, not with the accounts above)')}`);
277
280
  io.out(table(list.map((p) => [
@@ -345,8 +348,13 @@ export async function cmdNew(a, io) {
345
348
  io.out(`${c.green('✓')} created ${c.bold(res.account.ref)} at ${where}`);
346
349
  else
347
350
  io.out(`${c.yellow('!')} created ${c.bold(res.account.ref)} at ${where} with problems; see below`);
348
- for (const s of res.steps.filter((x) => !x.ok || x.action === 'skipped'))
351
+ // Items the main account does not have are normal for a new setup: one line, not one per item, so a beginner does
352
+ // not read a list of problems.
353
+ const notInMain = res.steps.filter((x) => x.ok && x.action === 'skipped' && x.message === 'not in the main account');
354
+ for (const s of res.steps.filter((x) => (!x.ok || x.action === 'skipped') && !notInMain.includes(x)))
349
355
  io.out(` ${s.ok ? c.dim('·') : c.red('✗')} ${s.item}: ${s.message ?? s.action}`);
356
+ if (notInMain.length)
357
+ io.out(` ${c.dim('·')} nothing to share yet: ${notInMain.map((x) => x.item).join(', ')} ${notInMain.length === 1 ? 'is' : 'are'} not in the main account (add ${notInMain.length === 1 ? 'it' : 'them'} there; \`sideby doctor\` then says what to do)`);
350
358
  for (const e of res.hookErrors)
351
359
  io.err(c.red(` ${e.message}`));
352
360
  if (res.alias?.added)
@@ -1103,6 +1111,42 @@ export async function cmdHandoff(a, io) {
1103
1111
  }
1104
1112
  export async function cmdPlugins(a, io) {
1105
1113
  const rt = await createRuntime();
1114
+ if (a.positionals.length) {
1115
+ const [action, target, ...extra] = a.positionals;
1116
+ if (!target || extra.length || a.rest.length || !['new', 'check'].includes(action))
1117
+ throw new UsageError('Use sideby plugins new <id> or sideby plugins check <id|path>.');
1118
+ const { join, isAbsolute } = await import('node:path');
1119
+ const { expandUserPath } = await import('../core/paths.js');
1120
+ const dirs = (rt.config.pluginDirs ?? [rt.paths.userPluginsDir]).map((p) => expandUserPath(p, rt.paths.home) ?? p);
1121
+ if (action === 'new') {
1122
+ const { scaffoldPlugin } = await import('../plugins/scaffold.js');
1123
+ const result = await scaffoldPlugin({ id: target, dir: dirs[0] ?? rt.paths.userPluginsDir });
1124
+ if (a.flags.has('json'))
1125
+ json(io, result);
1126
+ else
1127
+ io.out(`Created ${result.dir}. Fill in the TODOs, then run sideby plugins check ${target}.`);
1128
+ return 0;
1129
+ }
1130
+ const { checkPlugin } = await import('../plugins/check.js');
1131
+ const { access } = await import('node:fs/promises');
1132
+ let dir = target;
1133
+ if (!isAbsolute(target) && !target.includes('/') && !target.startsWith('.')) {
1134
+ dir = join(dirs[0] ?? rt.paths.userPluginsDir, target);
1135
+ for (const root of dirs)
1136
+ if (await access(join(root, target)).then(() => true, () => false)) {
1137
+ dir = join(root, target);
1138
+ break;
1139
+ }
1140
+ }
1141
+ const result = await checkPlugin({ dir });
1142
+ if (a.flags.has('json'))
1143
+ json(io, result);
1144
+ else {
1145
+ io.out(result.ok ? 'OK' : result.problems.map((p) => `${p.message}: ${p.fix}`).join('\n'));
1146
+ io.out('Plugins run as you, with your permissions. Only load plugins you trust.');
1147
+ }
1148
+ return result.ok ? 0 : 1;
1149
+ }
1106
1150
  if (a.flags.has('json'))
1107
1151
  // Plugin settings can hold tokens: report which keys are set, never their values.
1108
1152
  json(io, {
@@ -1310,6 +1354,7 @@ export async function cmdUpdate(a, io) {
1310
1354
  message: r.message,
1311
1355
  restartedPanel: r.restartedPanel,
1312
1356
  refreshedApp: r.refreshedApp,
1357
+ ...(r.refreshedAutostart === undefined ? {} : { refreshedAutostart: r.refreshedAutostart }),
1313
1358
  skill: SKILL_INSTALL,
1314
1359
  ...(r.skillUpdated === undefined ? {} : { skillUpdated: r.skillUpdated }),
1315
1360
  ...(r.ok ? {} : { output: r.output }),
@@ -1325,6 +1370,10 @@ export async function cmdUpdate(a, io) {
1325
1370
  io.out(' The background panel is restarting with the new version; open pages reload by themselves.');
1326
1371
  if (r.refreshedApp)
1327
1372
  io.out(' The desktop app now starts the new version and shows its icon.');
1373
+ if (r.refreshedAutostart)
1374
+ io.out(' The login item now starts the new version.');
1375
+ else if (r.refreshedAutostart === false)
1376
+ io.out(' The login item could not be pointed at the new version; run `sideby autostart` to see why.');
1328
1377
  io.out(' A `sideby ui` running in a terminal and sessions already running keep the old version until you restart them.');
1329
1378
  if (r.skillUpdated)
1330
1379
  io.out(' The sideby skill was updated too; agents load it in their next session.');
@@ -1419,9 +1468,12 @@ export async function cmdLocal(a, io) {
1419
1468
  switched: r.ok,
1420
1469
  message: r.message,
1421
1470
  restartedPanel: r.restartedPanel,
1471
+ ...(r.skillSynced === undefined ? {} : { skillSynced: r.skillSynced }),
1422
1472
  });
1423
1473
  else if (r.ok) {
1424
1474
  io.out(`${c.green('✓')} ${r.message}.`);
1475
+ if (r.skillSynced === false)
1476
+ io.err(c.yellow('The sideby skill was not brought along; run `sideby update` to see why.'));
1425
1477
  if (r.restartedPanel)
1426
1478
  io.out(c.dim('The background panel restarted with it; open panels reload by themselves.'));
1427
1479
  }
@@ -1648,3 +1700,406 @@ export async function cmdFamilies(a, io) {
1648
1700
  });
1649
1701
  return 0;
1650
1702
  }
1703
+ /** AI Task CLI; the terminal receives only an opaque task id. */
1704
+ export async function cmdAsk(a, io, runtime) {
1705
+ const { previewAttachments, promptForTask, readTask, startAiTask, taskEnv, taskHostArgs, taskStateDir } = await import('../core/ask.js');
1706
+ const run = a.flags.get('run');
1707
+ if (typeof run === 'string') {
1708
+ const stateDir = a.flags.get('state-dir');
1709
+ if (a.positionals.length ||
1710
+ a.rest.length ||
1711
+ [...a.flags.keys()].some((f) => f !== 'run' && f !== 'state-dir') ||
1712
+ stateDir === true)
1713
+ throw new UsageError('Use `sideby ask --run <id> [--state-dir <dir>]` on its own.');
1714
+ // The task names the state folder the Panel wrote it to; without it, this environment's folder is used.
1715
+ const where = typeof stateDir === 'string'
1716
+ ? { stateDir: await taskStateDir(stateDir) }
1717
+ : (runtime?.paths ?? resolvePaths());
1718
+ const task = await readTask(run, where);
1719
+ // The Host starts with the HOME, config and state the Panel used, whatever this terminal's environment says.
1720
+ const rt = task.home && (!runtime || runtime.paths.home !== task.home || runtime.paths.stateDir !== task.stateDir)
1721
+ ? await createRuntime({ env: taskEnv(task, runtime?.env ?? process.env) })
1722
+ : (runtime ?? (await createRuntime()));
1723
+ const cwd = process.cwd();
1724
+ try {
1725
+ process.chdir(task.cwd);
1726
+ if (task.kind === 'prompt')
1727
+ return await runAccount(rt, task.account, await taskHostArgs(rt, task), io);
1728
+ const prepared = await rt.prepareLaunch(task.account, [], 'login');
1729
+ if (prepared.notice)
1730
+ io.err(prepared.notice);
1731
+ return await whileRunning(rt, () => runHost(prepared));
1732
+ }
1733
+ finally {
1734
+ process.chdir(cwd);
1735
+ }
1736
+ }
1737
+ const rt = runtime ?? (await createRuntime());
1738
+ const task = String(a.flags.get('task') ?? 'free');
1739
+ if (!AI_TASK_KINDS.includes(task))
1740
+ throw new UsageError(`Choose --task ${AI_TASK_KINDS.join('|')}.`);
1741
+ if (a.positionals.length > 1 || a.rest.length)
1742
+ throw new UsageError('Pass the text as one quoted argument: sideby ask "help me".');
1743
+ const input = {
1744
+ task,
1745
+ text: a.positionals[0],
1746
+ account: a.flags.get('account'),
1747
+ terminal: a.flags.get('terminal'),
1748
+ files: a.attach ?? [],
1749
+ };
1750
+ // The files are checked before anything else; `--print` lists them where they are and copies nothing.
1751
+ const preview = promptForTask(rt, input, await previewAttachments(rt, input));
1752
+ if (preview.removedSecrets)
1753
+ io.err('Key-shaped text was removed. Enter keys with `sideby key` or the Panel key box.');
1754
+ if (a.flags.has('print')) {
1755
+ io.out(preview.prompt);
1756
+ return 0;
1757
+ }
1758
+ // A chosen account becomes the next default, as choosing it in the Panel does.
1759
+ const result = await startAiTask(rt, { ...input, remember: Boolean(input.account || input.terminal) });
1760
+ if (a.flags.has('json'))
1761
+ json(io, result);
1762
+ else if (result.command)
1763
+ io.out(result.opened ? `Opened ${result.terminal} for ${result.account}.` : result.command);
1764
+ else
1765
+ io.out(result.prompt ?? preview.prompt);
1766
+ if (!result.opened)
1767
+ io.err(result.detail);
1768
+ return result.opened ? 0 : 1;
1769
+ }
1770
+ export async function cmdTerminal(a, io) {
1771
+ const { terminals, setCustomTerminal, setPreferredTerminal } = await import('../core/ask.js');
1772
+ const rt = await createRuntime();
1773
+ const changes = ['set', 'unset', 'custom'].filter((f) => a.flags.has(f));
1774
+ if (a.positionals.length || a.rest.length || changes.length > 1)
1775
+ throw new UsageError("Use sideby terminal [--set <id>|--custom '<program> … {cwd} … {cmd}'|--unset] [--json].");
1776
+ const custom = a.flags.get('custom');
1777
+ if (a.flags.has('custom')) {
1778
+ if (typeof custom !== 'string')
1779
+ throw new UsageError("Use sideby terminal --custom '<program> … {cwd} … {cmd}'.");
1780
+ await setCustomTerminal(rt, custom);
1781
+ }
1782
+ else if (a.flags.has('set') || a.flags.has('unset'))
1783
+ await setPreferredTerminal(rt, a.flags.get('set'));
1784
+ const result = await terminals(rt);
1785
+ if (a.flags.has('json'))
1786
+ json(io, result);
1787
+ else {
1788
+ io.out(table(result.terminals.map((t) => [t.id, t.installed ? 'yes' : 'no']), ['Terminal', 'Installed']));
1789
+ io.out(`Preferred: ${result.preferred ?? 'automatic'}; selected: ${result.guess.id} (${result.guess.source}).`);
1790
+ }
1791
+ return 0;
1792
+ }
1793
+ /** Reads a key only from an interactive terminal, restoring its original mode on every exit. */
1794
+ export function readKeyLine(input = process.stdin) {
1795
+ if (!input.isTTY || !input.setRawMode)
1796
+ throw new UserError('Run `sideby key` in a terminal or use the Panel key box.', { code: 'tty-required' });
1797
+ return new Promise((resolve, reject) => {
1798
+ const raw = input.isRaw;
1799
+ const paused = input.isPaused();
1800
+ let value = '';
1801
+ const finish = (error) => {
1802
+ input.off('data', onData);
1803
+ input.off('end', onEnd);
1804
+ input.off('error', onError);
1805
+ input.setRawMode(raw);
1806
+ if (paused)
1807
+ input.pause();
1808
+ if (error)
1809
+ reject(error);
1810
+ else
1811
+ resolve(value);
1812
+ };
1813
+ const onEnd = () => finish(new UserError('Key input ended; rerun `sideby key` in a terminal.'));
1814
+ const onError = () => finish(new UserError('Cannot read the key; use the Panel key box.'));
1815
+ // Bytes decode across chunks, so a character split by a paste arrives whole.
1816
+ const decoder = new StringDecoder('utf8');
1817
+ // An escape sequence (an arrow key: ESC [ D) is dropped whole: 0 none, 1 after ESC, 2 inside CSI, 3 inside SS3.
1818
+ let sequence = 0;
1819
+ const onData = (data) => {
1820
+ for (const ch of typeof data === 'string' ? data : decoder.write(data)) {
1821
+ // A control character (Enter, Ctrl-C) still counts inside a sequence; it also ends the sequence.
1822
+ if (sequence && ch >= ' ' && ch !== '\u007f') {
1823
+ if (sequence === 1)
1824
+ sequence = ch === '[' ? 2 : ch === 'O' ? 3 : 0;
1825
+ else if (sequence === 3 || (ch >= '@' && ch <= '~'))
1826
+ sequence = 0;
1827
+ continue;
1828
+ }
1829
+ sequence = 0;
1830
+ if (ch === '\u001b') {
1831
+ sequence = 1;
1832
+ continue;
1833
+ }
1834
+ if (ch === '\u0003') {
1835
+ finish(new UserError('Cancelled; rerun `sideby key` when ready.'));
1836
+ return;
1837
+ }
1838
+ if (ch === '\r' || ch === '\n') {
1839
+ finish();
1840
+ return;
1841
+ }
1842
+ if (ch === '\u007f' || ch === '\b')
1843
+ value = [...value].slice(0, -1).join('');
1844
+ else if (ch >= ' ')
1845
+ value += ch;
1846
+ }
1847
+ };
1848
+ input.setRawMode(true);
1849
+ input.on('data', onData);
1850
+ input.once('end', onEnd);
1851
+ input.once('error', onError);
1852
+ input.resume();
1853
+ });
1854
+ }
1855
+ export async function cmdKey(a, io, runtime) {
1856
+ const rt = runtime ?? (await createRuntime());
1857
+ const { join } = await import('node:path');
1858
+ const { setSecret, secretVarFor, SECRET_VAR_NAME } = await import('../core/secret-file.js');
1859
+ if (a.positionals.length !== 1 || a.rest.length)
1860
+ throw new UsageError('Use sideby key <account> [--var <NAME>] [--json].');
1861
+ const account = await rt.resolve(a.positionals[0]);
1862
+ const family = rt.families.get(account.family);
1863
+ if (account.kind !== 'api' && family.secretFileMeansApi !== false)
1864
+ throw new UserError('This is a subscription account; use `sideby login <account>` or create an API account with `sideby new <family> <name> --api`.');
1865
+ const name = a.flags.get('var') ?? secretVarFor(family);
1866
+ if (!SECRET_VAR_NAME.test(name))
1867
+ throw new UsageError('Use an uppercase variable name such as API_KEY.');
1868
+ const input = (io.stdin ?? process.stdin);
1869
+ if (!input.isTTY)
1870
+ throw new UserError('Run `sideby key` in a terminal or use the Panel key box.', { code: 'tty-required' });
1871
+ io.err(`Enter ${name} (hidden), then press Enter:`);
1872
+ const value = await readKeyLine(input);
1873
+ if (!value.trim())
1874
+ throw new UserError(`No key entered; nothing was written. Run \`sideby key ${account.ref}\` again.`, {
1875
+ code: 'key-empty',
1876
+ });
1877
+ const result = await setSecret(account.secretFile ?? join(account.dir, 'proxy.env'), name, value);
1878
+ if (a.flags.has('json'))
1879
+ json(io, { account: account.ref, var: name, written: true, path: result.path });
1880
+ else
1881
+ io.out(`Wrote ${name} to ${result.path}.`);
1882
+ return 0;
1883
+ }
1884
+ export async function cmdTry(a, io) {
1885
+ const { tryAccount, tryPreview } = await import('../core/ask.js');
1886
+ if (a.positionals.length !== 1 || a.rest.length)
1887
+ throw new UsageError('Use sideby try <account> [--yes] [--json].');
1888
+ const rt = await createRuntime();
1889
+ const { account, command } = await tryPreview(rt, a.positionals[0]);
1890
+ if (!a.flags.has('yes')) {
1891
+ if (a.flags.has('json'))
1892
+ json(io, { account: account.ref, command, confirmed: false });
1893
+ else
1894
+ io.out(`This spends a few tokens: ${command.map((v) => JSON.stringify(v)).join(' ')}. Run again with --yes to continue.`);
1895
+ return 10;
1896
+ }
1897
+ const { account: ref, command: _command, ...result } = await tryAccount(rt, account.ref);
1898
+ if (a.flags.has('json'))
1899
+ json(io, { account: ref, confirmed: true, ...result });
1900
+ else
1901
+ io.out(result.ok ? 'OK' : result.output || 'The Host did not reply; check the account and try again.');
1902
+ return result.ok ? 0 : 1;
1903
+ }
1904
+ /** `sideby autostart [on|off] [--yes]` (spec §3.29, ADR-0029): start the Panel in the background at login. */
1905
+ export async function cmdAutostart(a, io) {
1906
+ const { applyAutostart, autostartStatus, planAutostart, refreshAutostart } = await import('../core/autostart.js');
1907
+ const [action, extra] = a.positionals;
1908
+ if (extra !== undefined || (action !== undefined && action !== 'on' && action !== 'off'))
1909
+ throw new UsageError('usage: sideby autostart [on|off] [--yes] [--json]');
1910
+ if (a.flags.has('yes') && !action)
1911
+ throw new UsageError('--yes needs a change: sideby autostart on|off --yes');
1912
+ const paths = resolvePaths();
1913
+ const home = paths.home;
1914
+ const ctx = { home };
1915
+ const wantJson = a.flags.has('json');
1916
+ if (a.flags.has('refresh')) {
1917
+ // After an update or a local build switch: point an existing agent at this sideby; never create one.
1918
+ const r = await refreshAutostart(ctx);
1919
+ const status = await autostartStatus(ctx);
1920
+ if (wantJson)
1921
+ json(io, {
1922
+ ...status,
1923
+ path: tilde(status.path, home),
1924
+ changed: r.rewritten,
1925
+ ...(r.note ? { note: r.note } : {}),
1926
+ });
1927
+ else
1928
+ io.out(r.rewritten ? 'The login item now starts this sideby.' : (r.note ?? 'Nothing to change.'));
1929
+ return 0;
1930
+ }
1931
+ if (!action) {
1932
+ const s = await autostartStatus(ctx);
1933
+ if (wantJson)
1934
+ json(io, { ...s, path: tilde(s.path, home) });
1935
+ else if (!s.supported)
1936
+ io.out(`${s.reason}.`);
1937
+ else if (s.state === 'off')
1938
+ io.out('Off: sideby does not start at login. Turn it on: sideby autostart on');
1939
+ else if (s.state === 'foreign')
1940
+ io.out(`${tilde(s.path, home)} is not sideby's, so sideby leaves it alone; remove or rename it yourself if it is not needed.`);
1941
+ else if (s.state === 'stale')
1942
+ io.out(`Out of date: the login item starts ${s.missing.join(' and ')}, which no longer exists. Run \`sideby autostart on\` to point it at this sideby.`);
1943
+ else {
1944
+ io.out('On: the panel starts in the background at login, without opening a window.');
1945
+ if (!s.current)
1946
+ io.out(c.dim('It starts another sideby than this one; `sideby autostart on` points it here.'));
1947
+ io.out(c.dim('Turn it off: sideby autostart off'));
1948
+ }
1949
+ return 0;
1950
+ }
1951
+ const plan = await planAutostart(ctx, action);
1952
+ if (plan.blocked)
1953
+ throw new UserError(plan.blocked.message, { code: plan.blocked.code });
1954
+ const yes = a.flags.has('yes');
1955
+ const shown = {
1956
+ ...plan.status,
1957
+ path: tilde(plan.path, home),
1958
+ action,
1959
+ changed: plan.changed,
1960
+ applied: false,
1961
+ ...(plan.content ? { content: plan.content } : {}),
1962
+ ...(plan.programArguments ? { programArguments: plan.programArguments } : {}),
1963
+ commands: plan.commands,
1964
+ };
1965
+ if (!plan.changed) {
1966
+ if (wantJson)
1967
+ json(io, shown);
1968
+ else
1969
+ io.out(action === 'on' ? 'Already on.' : 'Already off.');
1970
+ return 0;
1971
+ }
1972
+ if (!yes) {
1973
+ if (wantJson)
1974
+ json(io, shown);
1975
+ else {
1976
+ if (action === 'on') {
1977
+ io.out(`Writes ${tilde(plan.path, home)}:`);
1978
+ io.out(c.dim(plan.content.trimEnd()));
1979
+ }
1980
+ else
1981
+ io.out(`Removes ${tilde(plan.path, home)}.`);
1982
+ io.out(`Then runs: ${plan.commands.map((cmd) => cmd.join(' ')).join('; ')}`);
1983
+ io.out(action === 'on'
1984
+ ? 'At login the panel starts in the background, without opening a window. Run again with --yes to turn it on.'
1985
+ : 'Run again with --yes to turn it off.');
1986
+ }
1987
+ return 10;
1988
+ }
1989
+ const r = await applyAutostart(ctx, plan);
1990
+ if (wantJson)
1991
+ json(io, {
1992
+ ...shown,
1993
+ state: r.state,
1994
+ applied: true,
1995
+ loaded: r.loaded,
1996
+ ...(r.note ? { note: r.note } : {}),
1997
+ });
1998
+ else {
1999
+ io.out(action === 'on'
2000
+ ? 'On: the panel starts in the background at login. Undo: sideby autostart off'
2001
+ : 'Off: sideby no longer starts at login.');
2002
+ if (r.note)
2003
+ io.out(c.dim(r.note));
2004
+ }
2005
+ return 0;
2006
+ }
2007
+ /** The `accounts.<ref>.args` check of `sideby config set`: only the model and effort arguments, as `sideby model`. */
2008
+ function modelArgsProblem(rt) {
2009
+ return (ref, args) => {
2010
+ const family = ref.split(':')[0] ?? '';
2011
+ const f = rt.families.get(family);
2012
+ const mc = f?.modelChoice;
2013
+ if (!mc)
2014
+ return `${f?.title ?? family} has no model choice in sideby`;
2015
+ const { choice, rest } = mc.fromArgs(args);
2016
+ if (rest.length)
2017
+ return `${rest.join(' ')} is not a model or effort argument`;
2018
+ if (choice.effort && !mc.efforts.includes(choice.effort))
2019
+ return `"${choice.effort}" is not an effort level for ${f.title}; use one of: ${mc.efforts.join(', ')}`;
2020
+ if (choice.model && !MODEL_NAME.test(choice.model))
2021
+ return `"${choice.model}" is not a model name sideby passes on`;
2022
+ return null;
2023
+ };
2024
+ }
2025
+ const shownValue = (v) => (v === undefined ? '(not set)' : JSON.stringify(v));
2026
+ /** `sideby config get|set|unset` (spec §3.29): every setting has a command. */
2027
+ export async function cmdConfig(a, io) {
2028
+ const cfg = await import('../core/config.js');
2029
+ const [sub = 'get', key, value, ...more] = a.positionals;
2030
+ const wantJson = a.flags.has('json');
2031
+ const yes = a.flags.has('yes');
2032
+ const paths = resolvePaths();
2033
+ const file = paths.configFile;
2034
+ const shownFile = tilde(file, paths.home);
2035
+ if (sub === 'get') {
2036
+ if (value !== undefined || yes)
2037
+ throw new UsageError('usage: sideby config get [<key>] [--json]');
2038
+ if (key === undefined) {
2039
+ const keys = cfg.settableKeys(await cfg.loadConfig(file));
2040
+ if (wantJson)
2041
+ json(io, { file: shownFile, keys });
2042
+ else {
2043
+ for (const k of keys) {
2044
+ const now = k.value !== undefined
2045
+ ? shownValue(k.value)
2046
+ : k.entries?.length
2047
+ ? k.entries.map((e) => `${e.key}=${JSON.stringify(e.value)}`).join(', ')
2048
+ : c.dim('(not set)');
2049
+ io.out(`${k.key} = ${now}`);
2050
+ io.out(c.dim(` ${k.meaning} Takes ${k.takes}.`));
2051
+ }
2052
+ io.out(c.dim(`Change one: sideby config set <key> <value>. File: ${shownFile}`));
2053
+ }
2054
+ return 0;
2055
+ }
2056
+ const d = cfg.describeConfigKey(key);
2057
+ const r = await cfg.configGet(file, key);
2058
+ if (wantJson)
2059
+ json(io, {
2060
+ key,
2061
+ set: r.value !== undefined,
2062
+ ...(r.value !== undefined ? { value: r.value } : {}),
2063
+ meaning: d.meaning,
2064
+ takes: d.takes,
2065
+ });
2066
+ else {
2067
+ io.out(r.value === undefined ? '(not set)' : JSON.stringify(r.value, null, 2));
2068
+ io.out(c.dim(`${d.meaning} Takes ${d.takes}.`));
2069
+ }
2070
+ return 0;
2071
+ }
2072
+ if (sub !== 'set' && sub !== 'unset')
2073
+ throw new UsageError('usage: sideby config get [<key>] | set <key> <value> | unset <key> [--yes] [--json]');
2074
+ if (!key ||
2075
+ (sub === 'set' && value === undefined) ||
2076
+ (sub === 'unset' && value !== undefined) ||
2077
+ more.length)
2078
+ throw new UsageError(sub === 'set'
2079
+ ? 'usage: sideby config set <key> <value> [--yes] [--json] (quote a value with spaces)'
2080
+ : 'usage: sideby config unset <key> [--yes] [--json]');
2081
+ const next = sub === 'set' ? cfg.parseConfigValue(value) : undefined;
2082
+ const argsProblem = key.startsWith('accounts') ? modelArgsProblem(await createRuntime()) : undefined;
2083
+ const r = await cfg.editConfigPath(file, key, next, {
2084
+ dryRun: !yes,
2085
+ ...(argsProblem ? { argsProblem } : {}),
2086
+ });
2087
+ const out = {
2088
+ action: sub,
2089
+ key,
2090
+ file: shownFile,
2091
+ changed: r.changed,
2092
+ applied: r.changed && yes,
2093
+ ...(r.before !== undefined ? { before: r.before } : {}),
2094
+ ...(r.after !== undefined ? { after: r.after } : {}),
2095
+ };
2096
+ if (wantJson)
2097
+ json(io, out);
2098
+ else if (!r.changed)
2099
+ io.out('Nothing to change.');
2100
+ else {
2101
+ io.out(`${key}: ${shownValue(r.before)} → ${shownValue(r.after)}`);
2102
+ io.out(yes ? `Saved to ${shownFile}.` : 'Run again with --yes to save it.');
2103
+ }
2104
+ return r.changed && !yes ? 10 : 0;
2105
+ }