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.
- package/README.md +5 -3
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +16 -0
- package/dist/src/cli/commands.js +460 -5
- package/dist/src/cli/json-schemas.js +115 -0
- package/dist/src/cli/main.js +93 -3
- package/dist/src/core/ask.d.ts +150 -0
- package/dist/src/core/ask.js +365 -0
- package/dist/src/core/attachments.d.ts +50 -0
- package/dist/src/core/attachments.js +442 -0
- package/dist/src/core/autostart.d.ts +88 -0
- package/dist/src/core/autostart.js +256 -0
- package/dist/src/core/config.d.ts +97 -0
- package/dist/src/core/config.js +278 -10
- package/dist/src/core/launch.d.ts +15 -4
- package/dist/src/core/launch.js +27 -23
- package/dist/src/core/local-build.d.ts +17 -0
- package/dist/src/core/local-build.js +39 -3
- package/dist/src/core/probe.d.ts +78 -0
- package/dist/src/core/probe.js +127 -0
- package/dist/src/core/same-login.d.ts +9 -4
- package/dist/src/core/same-login.js +9 -4
- package/dist/src/core/secret-file.d.ts +26 -0
- package/dist/src/core/secret-file.js +126 -7
- package/dist/src/core/terminal.d.ts +93 -0
- package/dist/src/core/terminal.js +371 -0
- package/dist/src/core/update.d.ts +17 -0
- package/dist/src/core/update.js +63 -1
- package/dist/src/families/claude/index.js +3 -0
- package/dist/src/families/claude/quota-cache.d.ts +24 -7
- package/dist/src/families/claude/quota-cache.js +72 -21
- package/dist/src/families/codex/index.js +7 -0
- package/dist/src/families/codex/login-key.d.ts +8 -0
- package/dist/src/families/codex/login-key.js +33 -0
- package/dist/src/families/grok/index.js +3 -0
- package/dist/src/families/login-keys.d.ts +6 -0
- package/dist/src/families/login-keys.js +6 -0
- package/dist/src/families/pi/index.js +4 -0
- package/dist/src/panel/background.js +3 -2
- package/dist/src/panel/handler.d.ts +29 -2
- package/dist/src/panel/handler.js +396 -9
- package/dist/src/panel/i18n.d.ts +175 -2
- package/dist/src/panel/i18n.js +381 -35
- package/dist/src/panel/page-logic.d.ts +214 -0
- package/dist/src/panel/page-logic.js +394 -0
- package/dist/src/panel/page-script.js +984 -134
- package/dist/src/panel/page-styles.js +69 -19
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +2 -0
- package/dist/src/panel/server.d.ts +16 -2
- package/dist/src/panel/server.js +41 -13
- package/dist/src/plugins/check.d.ts +47 -0
- package/dist/src/plugins/check.js +544 -0
- package/dist/src/plugins/scaffold.d.ts +21 -0
- package/dist/src/plugins/scaffold.js +123 -0
- package/dist/src/quota/statusline-tap.js +1 -1
- package/dist/src/runtime.d.ts +10 -0
- package/dist/src/runtime.js +47 -16
- package/dist/src/types.d.ts +19 -0
- package/package.json +2 -2
- package/schemas/ask.json +180 -0
- package/schemas/autostart.json +165 -0
- package/schemas/config-get.json +132 -0
- package/schemas/config-set.json +107 -0
- package/schemas/config.json +73 -6
- package/schemas/key.json +93 -0
- package/schemas/local.json +4 -0
- package/schemas/plugins-check.json +113 -0
- package/schemas/plugins-new.json +87 -0
- package/schemas/terminal.json +152 -0
- package/schemas/try.json +109 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +12 -6
- package/skills/sideby/references/accounts.md +1 -1
- package/skills/sideby/references/ai-tasks.md +116 -0
- package/skills/sideby/references/panel.md +14 -0
- 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
|
|
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/
|
|
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":"
|
|
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>;
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|