sideby 0.1.0 → 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.
- package/README.md +71 -413
- package/README.zh-CN.md +71 -414
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +13 -0
- package/dist/src/cli/commands.js +86 -7
- package/dist/src/cli/json-schemas.js +35 -0
- package/dist/src/cli/main.js +99 -9
- package/dist/src/core/accounts.d.ts +2 -0
- package/dist/src/core/accounts.js +15 -0
- package/dist/src/core/config.d.ts +1 -0
- package/dist/src/core/config.js +3 -0
- package/dist/src/core/launch.d.ts +3 -1
- package/dist/src/core/resume.d.ts +9 -0
- package/dist/src/core/resume.js +15 -0
- package/dist/src/core/share-meaning.d.ts +6 -0
- package/dist/src/core/share-meaning.js +32 -0
- package/dist/src/core/shell-init.d.ts +7 -1
- package/dist/src/core/shell-init.js +25 -2
- package/dist/src/families/claude/index.js +3 -0
- package/dist/src/families/codex/index.js +3 -0
- package/dist/src/families/grok/index.js +4 -0
- package/dist/src/families/shared/sessions.d.ts +9 -0
- package/dist/src/families/shared/sessions.js +70 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/panel/handler.d.ts +0 -2
- package/dist/src/panel/handler.js +1 -16
- package/dist/src/runtime.d.ts +48 -2
- package/dist/src/runtime.js +54 -1
- package/dist/src/types.d.ts +7 -0
- package/dist/testing/index.js +2 -1
- package/package.json +1 -1
- package/schemas/config.json +4 -0
- package/schemas/families.json +205 -0
- package/schemas/new.json +18 -0
- package/skills/sideby/SKILL.md +15 -6
- package/skills/sideby/references/accounts.md +1 -1
- package/skills/sideby/references/setup.md +31 -0
- package/skills/sideby/references/sharing.md +17 -0
package/dist/src/cli/commands.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { aliasesByAccount } from '../core/accounts.js';
|
|
3
|
+
import { aliasRefs } from '../core/config.js';
|
|
2
4
|
import { UserError } from '../core/errors.js';
|
|
3
5
|
import { runHost } from '../core/launch.js';
|
|
4
6
|
import { resolvePaths, tildify } from '../core/paths.js';
|
|
5
7
|
import { USAGE_DAYS } from '../core/quota-levels.js';
|
|
6
8
|
import { SHELLS } from '../core/shell-init.js';
|
|
7
|
-
import {
|
|
9
|
+
import { suggestAlias, suggestName } from '../panel/page-logic.js';
|
|
10
|
+
import { createRuntime, UnknownFamilyError } from '../runtime.js';
|
|
8
11
|
import { UsageError } from './args.js';
|
|
9
12
|
import { c, clock, quotaText, table, usageText } from './format.js';
|
|
10
13
|
const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data }, null, 2));
|
|
@@ -67,20 +70,49 @@ export async function cmdList(a, io) {
|
|
|
67
70
|
io.err(c.yellow(`plugin ${e.where}: ${e.message}`));
|
|
68
71
|
return 0;
|
|
69
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
|
+
}
|
|
70
92
|
export async function cmdNew(a, io) {
|
|
71
|
-
const [family,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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');
|
|
75
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;
|
|
76
106
|
const res = await rt.createAccount(family, name, {
|
|
77
107
|
api: a.flags.has('api'),
|
|
78
|
-
...(
|
|
108
|
+
...(alias !== undefined ? { alias } : {}),
|
|
79
109
|
});
|
|
80
110
|
if (a.flags.has('json')) {
|
|
81
|
-
json(io, res);
|
|
111
|
+
json(io, suggestion ? { ...res, suggestion } : res);
|
|
82
112
|
return res.ok ? 0 : 1;
|
|
83
113
|
}
|
|
114
|
+
if (suggestion?.aliasProblem)
|
|
115
|
+
io.out(`${c.yellow('!')} no short command: the pattern suggests ${suggestion.aliasProblem}`);
|
|
84
116
|
const home = rt.paths.home;
|
|
85
117
|
const where = tildify(res.account.dir, home);
|
|
86
118
|
if (res.ok)
|
|
@@ -122,6 +154,20 @@ export async function cmdRun(a, io, command) {
|
|
|
122
154
|
io.err(c.cyan(prepared.notice));
|
|
123
155
|
return runHost(prepared);
|
|
124
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
|
+
}
|
|
125
171
|
/** Quotes a Host argument for a command line the user may paste; plain words stay as they are. */
|
|
126
172
|
function shellQuote(arg) {
|
|
127
173
|
return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
@@ -485,3 +531,36 @@ export async function cmdAlias(a, io) {
|
|
|
485
531
|
printShellInitWrites(io, res.shellInitFiles ?? [], rt.paths.home);
|
|
486
532
|
return res.ok ? 0 : 1;
|
|
487
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
|
+
}
|
|
@@ -42,6 +42,37 @@ const FamilyInfo = Type.Object({
|
|
|
42
42
|
installed: Type.Boolean(),
|
|
43
43
|
plugin: Str,
|
|
44
44
|
});
|
|
45
|
+
const ShareModeSchema = Type.Union(['link', 'copy', 'link-or-copy', 'link-or-local', 'local', 'local-if-api', 'info', 'json-key'].map((m) => Type.Literal(m)));
|
|
46
|
+
const FamilyDetail = Type.Intersect([
|
|
47
|
+
FamilyInfo,
|
|
48
|
+
Type.Object({
|
|
49
|
+
layout: Type.Object({ main: Str, account: Str }, { description: 'Relative to HOME, for example `.claude` and `.claude-<name>`.' }),
|
|
50
|
+
selectVar: Type.String({
|
|
51
|
+
description: 'Environment variable that selects a non-main Account directory.',
|
|
52
|
+
}),
|
|
53
|
+
login: Type.Object({
|
|
54
|
+
args: Type.Union([Type.Array(Str), Type.Null()], {
|
|
55
|
+
description: "Arguments of the Host's sign-in command; null when sign-in happens inside the Host.",
|
|
56
|
+
}),
|
|
57
|
+
hint: Str,
|
|
58
|
+
}),
|
|
59
|
+
quota: Type.Boolean(),
|
|
60
|
+
usage: Type.Boolean(),
|
|
61
|
+
quotaSetup: Type.Boolean({ description: 'Quota needs `sideby quota setup <family>` once.' }),
|
|
62
|
+
shared: Type.Array(Type.Object({
|
|
63
|
+
path: Str,
|
|
64
|
+
mode: ShareModeSchema,
|
|
65
|
+
key: Type.Optional(Str),
|
|
66
|
+
mainPath: Type.Optional(Str),
|
|
67
|
+
credential: Type.Optional(Type.Boolean()),
|
|
68
|
+
noSymlink: Type.Optional(Type.Boolean()),
|
|
69
|
+
source: Type.Union([Type.Literal('family'), Type.Literal('config')]),
|
|
70
|
+
meaning: Type.String({
|
|
71
|
+
description: 'What the item looks like in each Account, in one fixed sentence.',
|
|
72
|
+
}),
|
|
73
|
+
})),
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
45
76
|
const PluginError = Type.Object({ where: Str, message: Str });
|
|
46
77
|
const Finding = Type.Object({
|
|
47
78
|
level: Level,
|
|
@@ -168,6 +199,9 @@ export const OUTPUT_SCHEMAS = {
|
|
|
168
199
|
nextSteps: Type.Array(Str),
|
|
169
200
|
alias: Type.Optional(Type.Object({ name: Str, added: Type.Boolean(), message: Type.Optional(Str) }, { description: 'The `--alias` short command; `added: false` means the Account exists without it.' })),
|
|
170
201
|
shellInitFiles: Type.Optional(ShellInitFiles),
|
|
202
|
+
suggestion: Type.Optional(Type.Object({ name: Str, alias: Type.Optional(Str), aliasProblem: Type.Optional(Str) }, {
|
|
203
|
+
description: 'Only with `--next`: the picked name, the short command that follows the existing pattern, or why that short command was left out.',
|
|
204
|
+
})),
|
|
171
205
|
}, { title: 'sideby new --json' }),
|
|
172
206
|
alias: Type.Object({
|
|
173
207
|
schemaVersion: Version,
|
|
@@ -195,6 +229,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
195
229
|
})),
|
|
196
230
|
errors: Type.Array(PluginError),
|
|
197
231
|
}, { title: 'sideby plugins --json' }),
|
|
232
|
+
families: Type.Object({ schemaVersion: Version, families: Type.Array(FamilyDetail) }, { title: 'sideby families --json' }),
|
|
198
233
|
'app-install': Type.Object({
|
|
199
234
|
schemaVersion: Version,
|
|
200
235
|
platform: AppPlatform,
|
package/dist/src/cli/main.js
CHANGED
|
@@ -8,14 +8,19 @@ const HELP = `sideby — run every AI coding account side by side
|
|
|
8
8
|
Usage:
|
|
9
9
|
sideby list accounts (same as \`sideby list\`)
|
|
10
10
|
sideby run <account> [-- args] start the host CLI for one account
|
|
11
|
-
sideby new <family> <name
|
|
11
|
+
sideby new <family> <name>|--next [--api] [--alias <short>]
|
|
12
12
|
create an account (families: claude, codex, grok, pi);
|
|
13
|
+
--next picks the next name and short command (cc008);
|
|
13
14
|
--alias also adds a short command such as cc008
|
|
15
|
+
sideby families [family] what each host's accounts share with the main account,
|
|
16
|
+
where they live and how they sign in
|
|
14
17
|
sideby alias add <short> <account> [-- args]
|
|
15
18
|
add a short command, optionally with host arguments
|
|
16
19
|
(sideby alias add pi-kimi pi:main -- --model kimi-coding/k3)
|
|
17
20
|
sideby alias rm <short> remove a short command
|
|
18
21
|
sideby login <account> sign in to an account
|
|
22
|
+
sideby resume <family> [-- args] run the host; a session resumed by id (claude --resume <id>,
|
|
23
|
+
codex resume <id>) starts in the account that holds it
|
|
19
24
|
sideby next <family> [--dry-run] [--include-api] [-- args]
|
|
20
25
|
recommend the account with the most quota left (claude, codex)
|
|
21
26
|
and start it; --dry-run only recommends
|
|
@@ -35,11 +40,90 @@ Usage:
|
|
|
35
40
|
sideby plugins list loaded plugins and load errors
|
|
36
41
|
|
|
37
42
|
Options:
|
|
38
|
-
--json machine-readable output (list, new, next, alias, doctor, quota, plugins, app)
|
|
39
|
-
-h, --help show this help
|
|
43
|
+
--json machine-readable output (list, new, next, alias, doctor, quota, families, plugins, app)
|
|
44
|
+
-h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families
|
|
40
45
|
-v, --version show the version
|
|
41
46
|
|
|
42
|
-
An <account> is <family>:<name> (claude:work) or just <name> when it is unique
|
|
47
|
+
An <account> is <family>:<name> (claude:work) or just <name> when it is unique.
|
|
48
|
+
|
|
49
|
+
For AI agents:
|
|
50
|
+
Add --json and read the result; JSON Schemas ship in the package under schemas/.
|
|
51
|
+
Install the skill: npx skills add Adonis0123/sideby -g
|
|
52
|
+
Setup steps and the full contract: https://github.com/Adonis0123/sideby/blob/main/llms.txt
|
|
53
|
+
Run everything else yourself, but hand the user these as one line each: \`sideby login <account>\`
|
|
54
|
+
(browser sign-in), filling in proxy.env, and \`sideby run\` or \`sideby next\` (interactive sessions).
|
|
55
|
+
Exit 10 means a change was shown: ask the user, then run it again with --yes.`;
|
|
56
|
+
/** `sideby <command> --help` for the commands agents use to set up and repair accounts (spec §3.16). */
|
|
57
|
+
const COMMAND_HELP = {
|
|
58
|
+
new: `sideby new <family> <name> [--api] [--alias <short>] [--json]
|
|
59
|
+
sideby new <family> --next [--api] [--alias <short>] [--json]
|
|
60
|
+
|
|
61
|
+
Create an account directory (~/.claude-<name>, ~/.codex-<name>, ~/.grok-<name>, ~/.pi-<name>/agent) and link or
|
|
62
|
+
copy the main account's shared items into it. \`sideby families <family>\` lists them.
|
|
63
|
+
|
|
64
|
+
--next pick the name: the next number after the highest numbered account (001…007 → 008), else work;
|
|
65
|
+
without --alias, also add a short command that follows your existing ones (cc007 → cc008)
|
|
66
|
+
--api API account: also writes an empty proxy.env template (mode 600) for the user to fill in
|
|
67
|
+
--alias <short> add a short command for it to the config (it becomes a shell function)
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
sideby new claude --next --json # ~/.claude-008 plus cc008 when cc001…cc007 exist
|
|
71
|
+
sideby new claude work # ~/.claude-work
|
|
72
|
+
sideby new claude deepseek --api # then the user fills in ~/.claude-deepseek/proxy.env
|
|
73
|
+
|
|
74
|
+
Writes: the account directory; the config \`aliases\` and every \`shellInitFile\` when a short command is added.
|
|
75
|
+
Next: the user signs in with \`sideby login <account>\` (subscription) or fills in proxy.env (API).
|
|
76
|
+
Exit: 0 done; 1 invalid name or alias, directory exists, or partly failed; 2 malformed command line.`,
|
|
77
|
+
alias: `sideby alias add <short> <account> [-- host args] [--json]
|
|
78
|
+
sideby alias rm <short> [--json]
|
|
79
|
+
|
|
80
|
+
A short command starts one account: \`sideby run <short>\`, or just \`<short>\` once your shell loads
|
|
81
|
+
\`sideby shell-init\`. Host arguments after -- are added only when it starts through the short command; it is
|
|
82
|
+
the same account (same sign-in, same sessions), so do not create an account per model.
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
sideby alias add cc008 claude:008
|
|
86
|
+
sideby alias add pi-kimi pi:main -- --model kimi-coding/k3
|
|
87
|
+
sideby alias rm cc008
|
|
88
|
+
|
|
89
|
+
Writes: the config \`aliases\` and every \`shellInitFile\`. New shell functions work in a new shell, or after
|
|
90
|
+
eval "$(sideby shell-init zsh)" (put that line in ~/.zshrc once).
|
|
91
|
+
Exit: 0 done (also when it already exists or is absent); 1 invalid, reserved or taken; 2 malformed.`,
|
|
92
|
+
login: `sideby login <account>
|
|
93
|
+
|
|
94
|
+
Run the host's own sign-in for one account: claude auth login, codex login, grok login. For pi it starts pi;
|
|
95
|
+
then type /login. It opens a browser and needs the person who owns the account, so an agent hands this
|
|
96
|
+
line to the user instead of running it (in Claude Code: ! sideby login claude:008).
|
|
97
|
+
|
|
98
|
+
sideby never reads, copies or stores the credentials the host writes.
|
|
99
|
+
Exit: the host's exit code.`,
|
|
100
|
+
doctor: `sideby doctor [account|family] [--fix] [--force] [--json]
|
|
101
|
+
|
|
102
|
+
Check every account's shared items against the main account, credential file modes (600), backup leftovers,
|
|
103
|
+
and whether the installed sideby skill matches this version.
|
|
104
|
+
|
|
105
|
+
--fix repair what is safe: add missing links, refresh copies, chmod 600, sync MCP servers
|
|
106
|
+
--force with --fix, also let a synced MCP list (json-key) drop entries the account has
|
|
107
|
+
|
|
108
|
+
--fix never replaces a real file with a link and never changes where an existing link points; those are
|
|
109
|
+
reported with the command to run.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
sideby doctor --json
|
|
113
|
+
sideby doctor claude:008 --fix
|
|
114
|
+
Exit: 0 when nothing fails (warnings allowed); 1 when a check fails.`,
|
|
115
|
+
families: `sideby families [family] [--json]
|
|
116
|
+
|
|
117
|
+
For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
|
|
118
|
+
in, whether quota and usage can be read, and every shared item with what it looks like in each account
|
|
119
|
+
(a link to the main account's, a copy, or the account's own file). Everything else in an account directory
|
|
120
|
+
(sign-in, sessions, history) belongs to that account.
|
|
121
|
+
|
|
122
|
+
Examples:
|
|
123
|
+
sideby families
|
|
124
|
+
sideby families claude --json
|
|
125
|
+
Exit: 0; 1 unknown family; 2 malformed command line.`,
|
|
126
|
+
};
|
|
43
127
|
function nodeTooOld() {
|
|
44
128
|
const [maj = 0, min = 0] = process.versions.node.split('.').map(Number);
|
|
45
129
|
return maj < MIN_NODE[0] || (maj === MIN_NODE[0] && min < MIN_NODE[1]);
|
|
@@ -47,9 +131,10 @@ function nodeTooOld() {
|
|
|
47
131
|
const COMMANDS = {
|
|
48
132
|
list: { flags: ['json'] },
|
|
49
133
|
run: { flags: [] },
|
|
134
|
+
resume: { flags: [] },
|
|
50
135
|
login: { flags: [] },
|
|
51
136
|
next: { flags: ['dry-run', 'include-api', 'json'] },
|
|
52
|
-
new: { flags: ['api', 'json'], valued: ['alias'] },
|
|
137
|
+
new: { flags: ['api', 'json', 'next'], valued: ['alias'] },
|
|
53
138
|
alias: { flags: ['json'] },
|
|
54
139
|
doctor: { flags: ['fix', 'force', 'json'] },
|
|
55
140
|
quota: { flags: ['json', 'yes'] },
|
|
@@ -57,6 +142,7 @@ const COMMANDS = {
|
|
|
57
142
|
app: { flags: ['json'], valued: ['url'] },
|
|
58
143
|
'shell-init': { flags: ['write'] },
|
|
59
144
|
plugins: { flags: ['json'] },
|
|
145
|
+
families: { flags: ['json'] },
|
|
60
146
|
};
|
|
61
147
|
export async function main(argv) {
|
|
62
148
|
const [first, ...rest] = argv;
|
|
@@ -70,7 +156,7 @@ export async function main(argv) {
|
|
|
70
156
|
return 0;
|
|
71
157
|
}
|
|
72
158
|
if (first === '-h' || first === '--help' || first === 'help') {
|
|
73
|
-
console.log(HELP);
|
|
159
|
+
console.log((rest[0] !== undefined && first === 'help' && COMMAND_HELP[rest[0]]) || HELP);
|
|
74
160
|
return 0;
|
|
75
161
|
}
|
|
76
162
|
if (nodeTooOld()) {
|
|
@@ -88,9 +174,9 @@ export async function main(argv) {
|
|
|
88
174
|
try {
|
|
89
175
|
if (!spec)
|
|
90
176
|
throw new UsageError(`unknown command "${cmd}"; run \`sideby --help\``);
|
|
91
|
-
// `run`
|
|
177
|
+
// `run` and `resume` pass everything after the account or family through to the host, with or without `--`.
|
|
92
178
|
let parsed;
|
|
93
|
-
if (cmd === 'run') {
|
|
179
|
+
if (cmd === 'run' || cmd === 'resume') {
|
|
94
180
|
const sep = args.indexOf('--');
|
|
95
181
|
const head = sep === -1 ? args : args.slice(0, sep);
|
|
96
182
|
const [ref, ...more] = head;
|
|
@@ -116,7 +202,7 @@ export async function main(argv) {
|
|
|
116
202
|
}
|
|
117
203
|
}
|
|
118
204
|
if (parsed.flags.has('help')) {
|
|
119
|
-
console.log(HELP);
|
|
205
|
+
console.log(COMMAND_HELP[cmd] ?? HELP);
|
|
120
206
|
return 0;
|
|
121
207
|
}
|
|
122
208
|
const cmds = await import('./commands.js');
|
|
@@ -127,6 +213,8 @@ export async function main(argv) {
|
|
|
127
213
|
return await cmds.cmdRun(parsed, io, 'run');
|
|
128
214
|
case 'login':
|
|
129
215
|
return await cmds.cmdRun(parsed, io, 'login');
|
|
216
|
+
case 'resume':
|
|
217
|
+
return await cmds.cmdResume(parsed, io);
|
|
130
218
|
case 'next':
|
|
131
219
|
return await cmds.cmdNext(parsed, io);
|
|
132
220
|
case 'new':
|
|
@@ -145,6 +233,8 @@ export async function main(argv) {
|
|
|
145
233
|
return await cmds.cmdShellInit(parsed, io);
|
|
146
234
|
case 'plugins':
|
|
147
235
|
return await cmds.cmdPlugins(parsed, io);
|
|
236
|
+
case 'families':
|
|
237
|
+
return await cmds.cmdFamilies(parsed, io);
|
|
148
238
|
}
|
|
149
239
|
return 2;
|
|
150
240
|
}
|
|
@@ -16,3 +16,5 @@ export declare class AccountRefError extends UserError {
|
|
|
16
16
|
}
|
|
17
17
|
/** Resolves `<family>:<name>`, an alias from the config, or a name unique across Families. */
|
|
18
18
|
export declare function resolveRef(ref: string, accounts: readonly Account[], aliases?: Readonly<Record<string, string>>): Account;
|
|
19
|
+
/** Alias names per Account ref, sorted. An alias whose target matches no Account is left out. */
|
|
20
|
+
export declare function aliasesByAccount(aliases: Readonly<Record<string, string>> | undefined, accounts: readonly Account[]): Map<string, string[]>;
|
|
@@ -114,3 +114,18 @@ export function resolveRef(ref, accounts, aliases = {}) {
|
|
|
114
114
|
throw new AccountRefError(`no account named ${ref}; run \`sideby list\` to see accounts`);
|
|
115
115
|
throw new AccountRefError(`${ref} is ambiguous; use one of: ${hits.map((a) => a.ref).join(', ')}`);
|
|
116
116
|
}
|
|
117
|
+
/** Alias names per Account ref, sorted. An alias whose target matches no Account is left out. */
|
|
118
|
+
export function aliasesByAccount(aliases, accounts) {
|
|
119
|
+
const out = new Map();
|
|
120
|
+
for (const [alias, target] of Object.entries(aliases ?? {})) {
|
|
121
|
+
let ref;
|
|
122
|
+
try {
|
|
123
|
+
ref = resolveRef(target, accounts).ref;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
out.set(ref, [...(out.get(ref) ?? []), alias].sort());
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
@@ -34,6 +34,7 @@ export declare const ConfigSchema: Type.TObject<{
|
|
|
34
34
|
noSymlink: Type.TOptional<Type.TBoolean>;
|
|
35
35
|
credential: Type.TOptional<Type.TBoolean>;
|
|
36
36
|
}>>>>;
|
|
37
|
+
resumeRouting: Type.TOptional<Type.TBoolean>;
|
|
37
38
|
shellInitFile: Type.TOptional<Type.TObject<{
|
|
38
39
|
zsh: Type.TOptional<Type.TString>;
|
|
39
40
|
bash: Type.TOptional<Type.TString>;
|
package/dist/src/core/config.js
CHANGED
|
@@ -57,6 +57,9 @@ export const ConfigSchema = Type.Object({
|
|
|
57
57
|
description: 'Per-Plugin settings; `enabled: false` turns a Plugin off.',
|
|
58
58
|
})),
|
|
59
59
|
extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema))),
|
|
60
|
+
resumeRouting: Type.Optional(Type.Boolean({
|
|
61
|
+
description: 'Make `sideby shell-init` also define Host-named functions (`claude`, `codex`, `grok`) that resume a session by id in the Account that holds it. Off by default.',
|
|
62
|
+
})),
|
|
60
63
|
shellInitFile: Type.Optional(Type.Object({
|
|
61
64
|
zsh: Type.Optional(Type.String({ minLength: 1 })),
|
|
62
65
|
bash: Type.Optional(Type.String({ minLength: 1 })),
|
|
@@ -9,6 +9,8 @@ export interface PreparedLaunch {
|
|
|
9
9
|
/** Printed before launching when sign-in happens inside the Host. */
|
|
10
10
|
notice?: string;
|
|
11
11
|
}
|
|
12
|
+
/** What `runHost` needs: a prepared Account launch, or the Host run unchanged (`sideby resume`). */
|
|
13
|
+
export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env'>;
|
|
12
14
|
/** Finds an executable on PATH; returns null when the Host is not installed. */
|
|
13
15
|
export declare function which(bin: string, env: Env): Promise<string | null>;
|
|
14
16
|
/**
|
|
@@ -32,4 +34,4 @@ export declare function prepareLaunch(opts: {
|
|
|
32
34
|
* process group, so the Host receives them directly) and forwards SIGTERM/SIGHUP once. SIGTSTP keeps its
|
|
33
35
|
* default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
|
|
34
36
|
*/
|
|
35
|
-
export declare function runHost(p:
|
|
37
|
+
export declare function runHost(p: HostLaunch): Promise<number>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Account, FamilyDef } from '../types.ts';
|
|
2
|
+
export interface ResumeRoute {
|
|
3
|
+
/** The session the Host arguments resume by id; absent when they resume none. */
|
|
4
|
+
sessionId?: string;
|
|
5
|
+
/** The Account that last wrote that session; absent when no Account holds it. */
|
|
6
|
+
account?: Account;
|
|
7
|
+
}
|
|
8
|
+
/** Asks every Account of the Family for the session and picks the one that wrote it last. */
|
|
9
|
+
export declare function findResumeRoute(family: FamilyDef, accounts: readonly Account[], args: readonly string[]): Promise<ResumeRoute>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Asks every Account of the Family for the session and picks the one that wrote it last. */
|
|
2
|
+
export async function findResumeRoute(family, accounts, args) {
|
|
3
|
+
const sessionId = family.resumedSession?.(args);
|
|
4
|
+
if (!sessionId || !family.sessionWrittenAt)
|
|
5
|
+
return {};
|
|
6
|
+
const own = accounts.filter((a) => a.family === family.id);
|
|
7
|
+
const written = await Promise.all(own.map((a) => family.sessionWrittenAt(a, sessionId).catch(() => undefined)));
|
|
8
|
+
let best;
|
|
9
|
+
own.forEach((account, i) => {
|
|
10
|
+
const at = written[i];
|
|
11
|
+
if (at !== undefined && (!best || at > best.at))
|
|
12
|
+
best = { account, at };
|
|
13
|
+
});
|
|
14
|
+
return best ? { sessionId, account: best.account } : { sessionId };
|
|
15
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SharedItemDef } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* What one Shared Item looks like in an Account, in one fixed English sentence for `sideby families` (spec §3.16).
|
|
4
|
+
* Agents quote it to users, so it follows the Share Mode rules of spec §3.3 and never promises more.
|
|
5
|
+
*/
|
|
6
|
+
export declare function shareMeaning(item: SharedItemDef): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one Shared Item looks like in an Account, in one fixed English sentence for `sideby families` (spec §3.16).
|
|
3
|
+
* Agents quote it to users, so it follows the Share Mode rules of spec §3.3 and never promises more.
|
|
4
|
+
*/
|
|
5
|
+
export function shareMeaning(item) {
|
|
6
|
+
const main = item.mainPath ? `~/${item.mainPath}` : "the Main Account's";
|
|
7
|
+
const base = (() => {
|
|
8
|
+
switch (item.mode) {
|
|
9
|
+
case 'link':
|
|
10
|
+
return "A link to the Main Account's: one copy for every Account.";
|
|
11
|
+
case 'copy':
|
|
12
|
+
return "A copy of the Main Account's, kept equal by `doctor --fix`; the Host does not accept a link here.";
|
|
13
|
+
case 'link-or-copy':
|
|
14
|
+
return "A link to the Main Account's, or a copy kept equal by `doctor --fix`.";
|
|
15
|
+
case 'link-or-local':
|
|
16
|
+
return "A link to the Main Account's, unless the Account keeps its own file.";
|
|
17
|
+
case 'local':
|
|
18
|
+
return "The Account's own file, first copied from the Main Account and never synced.";
|
|
19
|
+
case 'local-if-api':
|
|
20
|
+
return item.noSymlink
|
|
21
|
+
? "Subscription Accounts: a copy of the Main Account's, kept equal by `doctor --fix`. API Accounts: their own file."
|
|
22
|
+
: "Subscription Accounts: a link to the Main Account's, or a copy kept equal. API Accounts: their own file.";
|
|
23
|
+
case 'info':
|
|
24
|
+
return item.credential
|
|
25
|
+
? "The Account's own sign-in: never shared and never read; sideby checks only that it is mode 600."
|
|
26
|
+
: "The Account's own; sideby only reports it.";
|
|
27
|
+
case 'json-key':
|
|
28
|
+
return `The Account's own file; only its \`${item.key}\` entry is kept equal to ${main}${item.credential ? ', and sideby reads no other part of it' : ''}.`;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
return item.noSymlink && item.mode !== 'copy' ? `${base} Never a link: the Host refuses one.` : base;
|
|
32
|
+
}
|
|
@@ -3,11 +3,17 @@ export declare const SHELLS: readonly ['zsh', 'bash'];
|
|
|
3
3
|
export type Shell = (typeof SHELLS)[number];
|
|
4
4
|
/** First line of every generated script; an existing file without it is never overwritten. */
|
|
5
5
|
export declare const SHELL_INIT_HEADER = "# sideby shell-init: one function per account, plus aliases from the sideby config";
|
|
6
|
+
/** A Family whose bare Host command `shell-init` routes through `sideby resume` (config `resumeRouting`). */
|
|
7
|
+
export interface ResumeRouteFunction {
|
|
8
|
+
family: string;
|
|
9
|
+
bin: string;
|
|
10
|
+
selectVar: string;
|
|
11
|
+
}
|
|
6
12
|
/**
|
|
7
13
|
* The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
|
|
8
14
|
* read the same function syntax, so the shell only names the file it goes to.
|
|
9
15
|
*/
|
|
10
|
-
export declare function shellInitScript(accounts: readonly Account[], aliases: Readonly<Record<string, unknown>> | undefined): string;
|
|
16
|
+
export declare function shellInitScript(accounts: readonly Account[], aliases: Readonly<Record<string, unknown>> | undefined, routes?: readonly ResumeRouteFunction[]): string;
|
|
11
17
|
export interface ShellInitWrite {
|
|
12
18
|
shell: Shell;
|
|
13
19
|
/** The configured path, as written in the config (`~/…` kept). */
|
|
@@ -2,18 +2,35 @@
|
|
|
2
2
|
// in sync with them whenever it adds an Account or an alias.
|
|
3
3
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
|
-
import { aliasProblem } from './config.js';
|
|
5
|
+
import { ALIAS_NAME, aliasProblem, RESERVED_ALIASES } from './config.js';
|
|
6
6
|
import { realpathOrNull, statOrNull, writeFileAtomic } from './fs-safe.js';
|
|
7
7
|
import { expandUserPath } from './paths.js';
|
|
8
8
|
export const SHELLS = ['zsh', 'bash'];
|
|
9
9
|
/** First line of every generated script; an existing file without it is never overwritten. */
|
|
10
10
|
export const SHELL_INIT_HEADER = '# sideby shell-init: one function per account, plus aliases from the sideby config';
|
|
11
11
|
const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
const SHELL_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
/** A shell `case` pattern for a session id: 36 characters with dashes where a UUID has them. */
|
|
14
|
+
const SESSION_ID_GLOB = '????????-????-????-????-????????????';
|
|
15
|
+
/**
|
|
16
|
+
* The Host-named function for one routed Family, or null when its names are not safe in a shell (spec §3.15). It
|
|
17
|
+
* starts sideby only when an argument looks like a session id, so a plain Host command costs nothing extra.
|
|
18
|
+
* `function name {` because zsh expands an alias of the same name in `name() {`.
|
|
19
|
+
*/
|
|
20
|
+
function resumeRouteFunction(r) {
|
|
21
|
+
if (!ALIAS_NAME.test(r.bin) || RESERVED_ALIASES.has(r.bin) || !SHELL_VAR.test(r.selectVar))
|
|
22
|
+
return null;
|
|
23
|
+
const id = SESSION_ID_GLOB;
|
|
24
|
+
return [
|
|
25
|
+
`function ${r.bin} { local a; if [ -z "\${${r.selectVar}-}" ]; then for a in "$@"; do case $a in ${id}|--resume=${id})`,
|
|
26
|
+
` command sideby resume ${quote(r.family)} -- "$@"; return;; esac; done; fi; command ${r.bin} "$@"; }`,
|
|
27
|
+
].join('');
|
|
28
|
+
}
|
|
12
29
|
/**
|
|
13
30
|
* The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
|
|
14
31
|
* read the same function syntax, so the shell only names the file it goes to.
|
|
15
32
|
*/
|
|
16
|
-
export function shellInitScript(accounts, aliases) {
|
|
33
|
+
export function shellInitScript(accounts, aliases, routes = []) {
|
|
17
34
|
const lines = [SHELL_INIT_HEADER];
|
|
18
35
|
for (const acc of accounts)
|
|
19
36
|
lines.push(`sideby-${acc.family}-${acc.name}() { command sideby run ${quote(acc.ref)} -- "$@"; }`);
|
|
@@ -21,6 +38,12 @@ export function shellInitScript(accounts, aliases) {
|
|
|
21
38
|
for (const alias of Object.keys(aliases ?? {}))
|
|
22
39
|
if (!aliasProblem(alias))
|
|
23
40
|
lines.push(`${alias}() { command sideby run ${quote(alias)} -- "$@"; }`);
|
|
41
|
+
// An Alias with the Host's name wins: it already starts a chosen Account.
|
|
42
|
+
for (const r of routes) {
|
|
43
|
+
const fn = aliases && Object.hasOwn(aliases, r.bin) ? null : resumeRouteFunction(r);
|
|
44
|
+
if (fn)
|
|
45
|
+
lines.push(fn);
|
|
46
|
+
}
|
|
24
47
|
return `${lines.join('\n')}\n`;
|
|
25
48
|
}
|
|
26
49
|
/**
|
|
@@ -5,6 +5,7 @@ import { identityFromJsonFile, identityOf } from '../../core/identity.js';
|
|
|
5
5
|
import { readSecretFile } from '../../core/secret-file.js';
|
|
6
6
|
import { claudeStatusline } from '../../quota/claude-statusline.js';
|
|
7
7
|
import { BUILTIN_LOGOS } from '../logos.js';
|
|
8
|
+
import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
|
|
8
9
|
import { CLAUDE_LAYOUT } from './layout.js';
|
|
9
10
|
import { readClaudeQuota } from './quota.js';
|
|
10
11
|
import { readClaudeUsage } from './usage.js';
|
|
@@ -115,6 +116,8 @@ export const claudeFamily = {
|
|
|
115
116
|
readQuota: readClaudeQuota,
|
|
116
117
|
readUsage: readClaudeUsage,
|
|
117
118
|
quotaSetup: claudeStatusline,
|
|
119
|
+
resumedSession: resumeFlagSession,
|
|
120
|
+
sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'projects'), `${id}.jsonl`, 'file'),
|
|
118
121
|
};
|
|
119
122
|
export const claudePlugin = {
|
|
120
123
|
name: 'claude',
|
|
@@ -2,6 +2,7 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { identityFromJsonFile, identityOf, jwtEmail } from '../../core/identity.js';
|
|
4
4
|
import { BUILTIN_LOGOS } from '../logos.js';
|
|
5
|
+
import { nestedFileWrittenAt, resumeSubcommandSession } from '../shared/sessions.js';
|
|
5
6
|
import { topLevelTomlString } from '../shared/toml.js';
|
|
6
7
|
import { readCodexQuota, readCodexUsage } from './rollout.js';
|
|
7
8
|
const FILE_STORE_KEY = 'cli_auth_credentials_store';
|
|
@@ -58,6 +59,8 @@ export const codexFamily = {
|
|
|
58
59
|
identity: codexIdentity,
|
|
59
60
|
readQuota: (account) => readCodexQuota(account.dir),
|
|
60
61
|
readUsage: (account, ctx) => readCodexUsage(account.dir, ctx.now),
|
|
62
|
+
resumedSession: resumeSubcommandSession,
|
|
63
|
+
sessionWrittenAt: (account, id) => nestedFileWrittenAt(join(account.dir, 'sessions'), `-${id}.jsonl`),
|
|
61
64
|
};
|
|
62
65
|
export const codexPlugin = {
|
|
63
66
|
name: 'codex',
|
|
@@ -2,6 +2,7 @@ import { lstat, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { identityFromJsonFile, identityOf } from '../../core/identity.js';
|
|
4
4
|
import { BUILTIN_LOGOS } from '../logos.js';
|
|
5
|
+
import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
|
|
5
6
|
import { topLevelTomlString } from '../shared/toml.js';
|
|
6
7
|
// Grok's sandbox rejects these paths when they are symlinks; doctor quotes the Host's own words.
|
|
7
8
|
const HOOKS_DIR_MESSAGE = 'Grok hooks directory has wrong type (expected real directory)';
|
|
@@ -69,6 +70,9 @@ export const grokFamily = {
|
|
|
69
70
|
},
|
|
70
71
|
model: readModel,
|
|
71
72
|
identity: grokIdentity,
|
|
73
|
+
resumedSession: resumeFlagSession,
|
|
74
|
+
// One directory per session, grouped by working directory: sessions/<encoded cwd>/<id>/.
|
|
75
|
+
sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'sessions'), id, 'dir'),
|
|
72
76
|
};
|
|
73
77
|
export const grokPlugin = {
|
|
74
78
|
name: 'grok',
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const isSessionId: (s: string | undefined) => s is string;
|
|
2
|
+
/** The id after `--resume <id>`, `--resume=<id>` or `-r <id>`; Claude Code and Grok Build share this form. */
|
|
3
|
+
export declare function resumeFlagSession(args: readonly string[]): string | undefined;
|
|
4
|
+
/** The first UUID after the `resume` subcommand (`codex resume [options] <id>`). */
|
|
5
|
+
export declare function resumeSubcommandSession(args: readonly string[]): string | undefined;
|
|
6
|
+
/** `<dir>/*` + `/<entry>`: one level of grouping directories (Claude projects, Grok working directories). */
|
|
7
|
+
export declare function groupedEntryWrittenAt(dir: string, entry: string, type: 'file' | 'dir'): Promise<number | undefined>;
|
|
8
|
+
/** A file anywhere under `dir` whose name ends with `suffix` (Codex `sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`). */
|
|
9
|
+
export declare function nestedFileWrittenAt(dir: string, suffix: string): Promise<number | undefined>;
|