sideby 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +453 -2
- package/README.zh-CN.md +455 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +37 -0
- package/dist/src/cli/commands.js +487 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +224 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +186 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +18 -0
- package/dist/src/core/accounts.js +116 -0
- package/dist/src/core/config.d.ts +60 -0
- package/dist/src/core/config.js +181 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +35 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +25 -0
- package/dist/src/core/shell-init.js +61 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +124 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +67 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +78 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +93 -0
- package/dist/src/panel/handler.js +411 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +149 -0
- package/dist/src/runtime.js +371 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +117 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +166 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +181 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +52 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// The one service layer the CLI and the Panel share. Frozen contract for v0.1 (plan: 审核修订).
|
|
2
|
+
import { discoverAccounts, resolveRef } from './core/accounts.js';
|
|
3
|
+
import { addConfigAlias, aliasAccount, aliasArgs, aliasProblem, aliasRefs, loadConfig, removeConfigAlias, } from './core/config.js';
|
|
4
|
+
import { createAccount } from './core/create.js';
|
|
5
|
+
import { applyFixes, runDoctor } from './core/doctor.js';
|
|
6
|
+
import { UserError } from './core/errors.js';
|
|
7
|
+
import { planHandoff } from './core/handoff.js';
|
|
8
|
+
import { identityOf } from './core/identity.js';
|
|
9
|
+
import { mergeDoctorHistory, readDoctorHistory, writeDoctorHistory, } from './core/last-doctor.js';
|
|
10
|
+
import { prepareLaunch, which } from './core/launch.js';
|
|
11
|
+
import { resolvePaths } from './core/paths.js';
|
|
12
|
+
import { SHELLS, shellInitScript, writeShellInitFile, } from './core/shell-init.js';
|
|
13
|
+
import { packageVersion } from './core/version.js';
|
|
14
|
+
import { BUILTIN_PLUGINS } from './families/index.js';
|
|
15
|
+
import { HookBus } from './plugins/bus.js';
|
|
16
|
+
import { loadPlugins } from './plugins/loader.js';
|
|
17
|
+
export class UnknownFamilyError extends UserError {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A Family's identity reader, kept to the identity fields with the expected types. A reader that throws, or returns
|
|
21
|
+
* anything else, gives undefined: identity is a display nicety and never a problem of the Account.
|
|
22
|
+
*/
|
|
23
|
+
async function readIdentity(f, account) {
|
|
24
|
+
if (!f.identity || account.kind === 'api')
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const r = await f.identity(account);
|
|
28
|
+
return identityOf(r?.email, r?.org);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function createRuntime(opts = {}) {
|
|
35
|
+
const env = opts.env ?? process.env;
|
|
36
|
+
const paths = resolvePaths(env);
|
|
37
|
+
const config = await loadConfig(paths.configFile);
|
|
38
|
+
const bus = new HookBus();
|
|
39
|
+
const loaded = await loadPlugins({
|
|
40
|
+
builtins: opts.builtins ?? BUILTIN_PLUGINS,
|
|
41
|
+
config,
|
|
42
|
+
bus,
|
|
43
|
+
userPluginsDir: paths.userPluginsDir,
|
|
44
|
+
home: paths.home,
|
|
45
|
+
});
|
|
46
|
+
const families = loaded.families;
|
|
47
|
+
const ignore = new Set(config.ignore ?? []);
|
|
48
|
+
const now = opts.now ?? (() => new Date());
|
|
49
|
+
const familyOf = (id) => {
|
|
50
|
+
const f = families.get(id);
|
|
51
|
+
if (!f)
|
|
52
|
+
throw new UnknownFamilyError(`unknown family "${id}"; available: ${[...families.keys()].join(', ') || 'none (check `sideby plugins`)'}`);
|
|
53
|
+
return f;
|
|
54
|
+
};
|
|
55
|
+
// Merge `extraSharedItems` from the config into each Family's list.
|
|
56
|
+
for (const [id, extra] of Object.entries(config.extraSharedItems ?? {})) {
|
|
57
|
+
const f = families.get(id);
|
|
58
|
+
if (f)
|
|
59
|
+
families.set(id, { ...f, sharedItems: [...f.sharedItems, ...extra] });
|
|
60
|
+
}
|
|
61
|
+
const accounts = async () => {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const f of families.values())
|
|
64
|
+
out.push(...(await discoverAccounts(f, paths.home, ignore)));
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
const readContext = () => ({
|
|
68
|
+
home: paths.home,
|
|
69
|
+
now: now(),
|
|
70
|
+
stateDir: paths.stateDir,
|
|
71
|
+
env,
|
|
72
|
+
});
|
|
73
|
+
/** The config value of the alias `name`, from own keys only (`constructor` is not an alias unless configured). */
|
|
74
|
+
const ownAlias = (name) => config.aliases && Object.hasOwn(config.aliases, name) ? config.aliases[name] : undefined;
|
|
75
|
+
/** The config value of `ref` when it names an alias (no colon), as `resolveRef` reads it. */
|
|
76
|
+
const aliasValue = (ref) => (ref.includes(':') ? undefined : ownAlias(ref));
|
|
77
|
+
const aliasEdit = async (alias, status, value) => {
|
|
78
|
+
const args = aliasArgs(value);
|
|
79
|
+
const files = await rt.writeShellInitFiles();
|
|
80
|
+
return {
|
|
81
|
+
ok: files.every((f) => f.ok),
|
|
82
|
+
alias,
|
|
83
|
+
status,
|
|
84
|
+
...(value === undefined ? {} : { account: aliasAccount(value) }),
|
|
85
|
+
...(args.length ? { args } : {}),
|
|
86
|
+
...(files.length ? { shellInitFiles: files } : {}),
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const rt = {
|
|
90
|
+
paths,
|
|
91
|
+
config,
|
|
92
|
+
env,
|
|
93
|
+
families,
|
|
94
|
+
plugins: loaded.plugins,
|
|
95
|
+
pluginErrors: loaded.errors,
|
|
96
|
+
async familyInfo() {
|
|
97
|
+
return Promise.all([...families.values()].map(async (f) => ({
|
|
98
|
+
id: f.id,
|
|
99
|
+
title: f.title,
|
|
100
|
+
bin: f.bin,
|
|
101
|
+
installUrl: f.installUrl,
|
|
102
|
+
installed: (await which(f.bin, env)) !== null,
|
|
103
|
+
plugin: loaded.familyOwner.get(f.id) ?? 'unknown',
|
|
104
|
+
})));
|
|
105
|
+
},
|
|
106
|
+
accounts,
|
|
107
|
+
async resolve(ref) {
|
|
108
|
+
return resolveRef(ref, await accounts(), aliasRefs(config.aliases));
|
|
109
|
+
},
|
|
110
|
+
async status(account) {
|
|
111
|
+
const f = familyOf(account.family);
|
|
112
|
+
const problems = [];
|
|
113
|
+
// Plugin readers may throw synchronously or reject; either way the Account still gets a status.
|
|
114
|
+
const safe = async (what, fn) => {
|
|
115
|
+
if (!fn)
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
return await fn();
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
problems.push(`${what}: ${err?.message ?? String(err)}`);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const [installed, login, model, identity] = await Promise.all([
|
|
126
|
+
which(f.bin, env),
|
|
127
|
+
account.kind === 'api'
|
|
128
|
+
? undefined
|
|
129
|
+
: safe('login state', f.loginState && (() => f.loginState(account))),
|
|
130
|
+
safe('model', f.model && (() => f.model(account))),
|
|
131
|
+
readIdentity(f, account),
|
|
132
|
+
]);
|
|
133
|
+
return {
|
|
134
|
+
...account,
|
|
135
|
+
familyTitle: f.title,
|
|
136
|
+
hostInstalled: installed !== null,
|
|
137
|
+
login: account.kind === 'api' ? 'not-needed' : (login ?? 'unknown'),
|
|
138
|
+
...(model ? { model } : {}),
|
|
139
|
+
...(identity ? { identity } : {}),
|
|
140
|
+
...(problems.length ? { problems } : {}),
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
async doctor(o = {}) {
|
|
144
|
+
return (await rt.doctorWithHistory(o)).report;
|
|
145
|
+
},
|
|
146
|
+
async doctorWithHistory(o = {}) {
|
|
147
|
+
let list = await accounts();
|
|
148
|
+
let fams = families;
|
|
149
|
+
let scope = { kind: 'all' };
|
|
150
|
+
if (o.target) {
|
|
151
|
+
if (families.has(o.target)) {
|
|
152
|
+
list = list.filter((a) => a.family === o.target);
|
|
153
|
+
fams = new Map([[o.target, families.get(o.target)]]);
|
|
154
|
+
scope = { kind: 'family', family: o.target };
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const one = resolveRef(o.target, list, aliasRefs(config.aliases));
|
|
158
|
+
list = [one];
|
|
159
|
+
fams = new Map([[one.family, familyOf(one.family)]]);
|
|
160
|
+
scope = { kind: 'account', ref: one.ref };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const input = {
|
|
164
|
+
home: paths.home,
|
|
165
|
+
families: fams,
|
|
166
|
+
accounts: list,
|
|
167
|
+
bus,
|
|
168
|
+
force: Boolean(o.force),
|
|
169
|
+
// Checked for every Family in the run, also when Doctor is aimed at one Account, so the saved result
|
|
170
|
+
// of that Family keeps its skill warning.
|
|
171
|
+
skillVersion: packageVersion(),
|
|
172
|
+
};
|
|
173
|
+
let report = await runDoctor(input);
|
|
174
|
+
if (o.fix) {
|
|
175
|
+
const fixes = await applyFixes(report);
|
|
176
|
+
report = { ...(await runDoctor(input)), fixes };
|
|
177
|
+
}
|
|
178
|
+
const history = mergeDoctorHistory(await readDoctorHistory(paths.stateDir), report, scope, now());
|
|
179
|
+
if (o.persist !== false)
|
|
180
|
+
await writeDoctorHistory(paths.stateDir, history);
|
|
181
|
+
return { report, history };
|
|
182
|
+
},
|
|
183
|
+
doctorHistory: () => readDoctorHistory(paths.stateDir),
|
|
184
|
+
async quotaSetups() {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const f of families.values()) {
|
|
187
|
+
if (!f.quotaSetup)
|
|
188
|
+
continue;
|
|
189
|
+
const setup = rt.quotaSetup(f.id);
|
|
190
|
+
out.push({ family: f.id, summary: setup.summary, plan: await setup.plan() });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
},
|
|
194
|
+
quotaSetup(familyId) {
|
|
195
|
+
const f = familyOf(familyId);
|
|
196
|
+
const setup = f.quotaSetup;
|
|
197
|
+
if (!setup)
|
|
198
|
+
throw new UserError(`${f.title} needs no quota setup`);
|
|
199
|
+
// One rule for every adapter: a setup that throws is reported, never crashes the caller. The message
|
|
200
|
+
// is the error's own text; setup implementations must not put file contents into it.
|
|
201
|
+
const blocked = (err) => ({
|
|
202
|
+
status: 'blocked',
|
|
203
|
+
file: '',
|
|
204
|
+
diff: '',
|
|
205
|
+
message: err?.message ?? String(err),
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
family: f.id,
|
|
209
|
+
summary: setup.summary,
|
|
210
|
+
// Promise.resolve().then also catches a setup that throws synchronously.
|
|
211
|
+
plan: () => Promise.resolve()
|
|
212
|
+
.then(() => setup.plan(readContext()))
|
|
213
|
+
.catch(blocked),
|
|
214
|
+
apply: () => Promise.resolve()
|
|
215
|
+
.then(() => setup.apply(readContext()))
|
|
216
|
+
.catch(blocked),
|
|
217
|
+
teardown: () => Promise.resolve()
|
|
218
|
+
.then(() => setup.teardown(readContext()))
|
|
219
|
+
.catch((err) => ({
|
|
220
|
+
ok: false,
|
|
221
|
+
message: err?.message ?? String(err),
|
|
222
|
+
})),
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
async createAccount(familyId, name, o = {}) {
|
|
226
|
+
const family = familyOf(familyId);
|
|
227
|
+
const alias = o.alias?.trim() || undefined;
|
|
228
|
+
const ref = `${family.id}:${name}`;
|
|
229
|
+
if (alias) {
|
|
230
|
+
const problem = rt.aliasProblem(alias, ref);
|
|
231
|
+
if (problem)
|
|
232
|
+
throw new UserError(`${problem}; nothing was created`, { code: 'alias-invalid' });
|
|
233
|
+
}
|
|
234
|
+
const created = await createAccount({
|
|
235
|
+
family,
|
|
236
|
+
name,
|
|
237
|
+
api: Boolean(o.api),
|
|
238
|
+
home: paths.home,
|
|
239
|
+
bus,
|
|
240
|
+
accounts: await discoverAccounts(family, paths.home, ignore),
|
|
241
|
+
});
|
|
242
|
+
if (alias) {
|
|
243
|
+
try {
|
|
244
|
+
// An alias that already starts this Account keeps its arguments; creating the Account does not change them.
|
|
245
|
+
const r = await addConfigAlias(paths.configFile, alias, created.account.ref, aliasArgs(ownAlias(alias)));
|
|
246
|
+
config.aliases = r.aliases;
|
|
247
|
+
created.alias = {
|
|
248
|
+
name: alias,
|
|
249
|
+
added: true,
|
|
250
|
+
...(r.status === 'exists' ? { message: 'already in the config' } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
created.alias = {
|
|
255
|
+
name: alias,
|
|
256
|
+
added: false,
|
|
257
|
+
message: `${created.account.ref} was created, but the alias was not added: ${err.message}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const files = await rt.writeShellInitFiles();
|
|
262
|
+
if (files.length)
|
|
263
|
+
created.shellInitFiles = files;
|
|
264
|
+
created.ok = created.ok && created.alias?.added !== false && files.every((f) => f.ok);
|
|
265
|
+
return created;
|
|
266
|
+
},
|
|
267
|
+
async addAlias(alias, target, args = []) {
|
|
268
|
+
const bad = aliasProblem(alias);
|
|
269
|
+
if (bad)
|
|
270
|
+
throw new UserError(`alias "${alias}" ${bad} (it becomes a shell function name)`);
|
|
271
|
+
// Resolve first so the config only ever names an Account that exists, by its ref.
|
|
272
|
+
const account = resolveRef(target, await accounts(), aliasRefs(config.aliases));
|
|
273
|
+
const r = await addConfigAlias(paths.configFile, alias, account.ref, args);
|
|
274
|
+
config.aliases = r.aliases;
|
|
275
|
+
return aliasEdit(alias, r.status, r.aliases[alias]);
|
|
276
|
+
},
|
|
277
|
+
async removeAlias(alias) {
|
|
278
|
+
const before = ownAlias(alias);
|
|
279
|
+
const r = await removeConfigAlias(paths.configFile, alias);
|
|
280
|
+
config.aliases = r.aliases;
|
|
281
|
+
return aliasEdit(alias, r.status, r.status === 'removed' ? before : undefined);
|
|
282
|
+
},
|
|
283
|
+
aliasProblem(alias, ref) {
|
|
284
|
+
const bad = aliasProblem(alias);
|
|
285
|
+
if (bad)
|
|
286
|
+
return `alias "${alias}" ${bad} (it becomes a shell function name)`;
|
|
287
|
+
const value = ownAlias(alias);
|
|
288
|
+
const taken = value === undefined ? undefined : aliasAccount(value);
|
|
289
|
+
if (taken !== undefined && taken !== ref)
|
|
290
|
+
return `alias "${alias}" already starts ${taken}; pick another name`;
|
|
291
|
+
return null;
|
|
292
|
+
},
|
|
293
|
+
async shellInitScript() {
|
|
294
|
+
return shellInitScript(await accounts(), config.aliases);
|
|
295
|
+
},
|
|
296
|
+
async writeShellInitFiles(shells = SHELLS) {
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const shell of shells) {
|
|
299
|
+
const file = config.shellInitFile?.[shell];
|
|
300
|
+
if (file)
|
|
301
|
+
out.push(await writeShellInitFile(shell, file, paths.home, await rt.shellInitScript(shell)));
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
},
|
|
305
|
+
async prepareLaunch(ref, userArgs, command) {
|
|
306
|
+
const account = resolveRef(ref, await accounts(), aliasRefs(config.aliases));
|
|
307
|
+
return prepareLaunch({
|
|
308
|
+
account,
|
|
309
|
+
family: familyOf(account.family),
|
|
310
|
+
baseEnv: env,
|
|
311
|
+
accountArgs: config.accounts?.[account.ref]?.args ?? [],
|
|
312
|
+
// An alias's arguments come before the user's; the Family sees both as the user's choice (ADR-0005).
|
|
313
|
+
userArgs: command === 'run' ? [...aliasArgs(aliasValue(ref)), ...userArgs] : userArgs,
|
|
314
|
+
command,
|
|
315
|
+
bus,
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
async quota(account) {
|
|
319
|
+
if (account.kind === 'api')
|
|
320
|
+
return { status: 'unavailable', reason: 'api-account' };
|
|
321
|
+
const f = familyOf(account.family);
|
|
322
|
+
if (!f.readQuota)
|
|
323
|
+
return { status: 'unavailable', reason: 'no-source' };
|
|
324
|
+
try {
|
|
325
|
+
return await f.readQuota(account, readContext());
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
async usage(account) {
|
|
332
|
+
const f = familyOf(account.family);
|
|
333
|
+
if (!f.readUsage)
|
|
334
|
+
return { status: 'unavailable', reason: 'no-source' };
|
|
335
|
+
try {
|
|
336
|
+
return await f.readUsage(account, readContext());
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
async handoff(familyId, o = {}) {
|
|
343
|
+
const f = familyOf(familyId);
|
|
344
|
+
const list = (await accounts()).filter((a) => a.family === f.id);
|
|
345
|
+
if (!f.readQuota)
|
|
346
|
+
throw new UserError(`${f.title} has no public quota source, so sideby cannot tell which account has room; pick one yourself: ${list.map((a) => `sideby run ${a.ref}`).join(', ') || `sideby new ${f.id} <name>`}`, { code: 'no-quota-source' });
|
|
347
|
+
if (list.length === 0)
|
|
348
|
+
throw new UserError(`no ${f.title} accounts yet; create one with \`sideby new ${f.id} <name>\``, {
|
|
349
|
+
code: 'no-accounts',
|
|
350
|
+
});
|
|
351
|
+
// Only the login state and Quota: no identity, model or Host lookups are needed to rank.
|
|
352
|
+
const loginOf = async (a) => {
|
|
353
|
+
if (a.kind === 'api')
|
|
354
|
+
return 'not-needed';
|
|
355
|
+
try {
|
|
356
|
+
return (await f.loginState?.(a)) ?? 'unknown';
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return 'unknown';
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const candidates = await Promise.all(list.map(async (a) => {
|
|
363
|
+
const [login, quota] = await Promise.all([loginOf(a), rt.quota(a)]);
|
|
364
|
+
return { ref: a.ref, name: a.name, isMain: a.isMain, kind: a.kind, login, quota };
|
|
365
|
+
}));
|
|
366
|
+
return planHandoff(f.id, candidates, readContext().now, o);
|
|
367
|
+
},
|
|
368
|
+
readContext,
|
|
369
|
+
};
|
|
370
|
+
return rt;
|
|
371
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
export type ShareMode = 'link' | 'copy' | 'link-or-copy' | 'link-or-local' | 'local' | 'local-if-api' | 'info' | 'json-key';
|
|
2
|
+
export interface SharedItemDef {
|
|
3
|
+
/** Path inside the Account directory, for example `skills` or `.claude.json`. */
|
|
4
|
+
path: string;
|
|
5
|
+
mode: ShareMode;
|
|
6
|
+
/** Main-side source relative to HOME, when it does not live in the Main Account directory (`.claude.json`). */
|
|
7
|
+
mainPath?: string;
|
|
8
|
+
/** For `json-key`: the top-level key mirrored from the main file. */
|
|
9
|
+
key?: string;
|
|
10
|
+
/** The Host refuses to start when this path is a symlink (Grok sandbox). */
|
|
11
|
+
noSymlink?: boolean;
|
|
12
|
+
/** Holds credentials: must be a regular file with mode 600. Contents are never read. */
|
|
13
|
+
credential?: boolean;
|
|
14
|
+
/** Host message quoted when the item breaks a Host rule. */
|
|
15
|
+
hostMessage?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface FamilyLayout {
|
|
18
|
+
/** Main Account directory relative to HOME, for example `.claude` or `.pi/agent`. */
|
|
19
|
+
main: string;
|
|
20
|
+
/** Other Accounts relative to HOME, with `<name>` in the first path segment: `.claude-<name>`, `.pi-<name>/agent`. */
|
|
21
|
+
account: string;
|
|
22
|
+
}
|
|
23
|
+
export type LoginState = 'logged-in' | 'logged-out' | 'unknown';
|
|
24
|
+
/**
|
|
25
|
+
* A Family's mark for the Panel: one SVG path in a 24x24 view box, drawn inline. sideby checks it when the
|
|
26
|
+
* Plugin registers; an invalid logo is dropped with a plugin error and the Panel shows the Family's initials.
|
|
27
|
+
*/
|
|
28
|
+
export interface FamilyLogo {
|
|
29
|
+
/** SVG path data: only commands, numbers, spaces, commas, dots, `+` and `-`. At most 20000 characters. */
|
|
30
|
+
path: string;
|
|
31
|
+
/** Hex brand color such as `#D97757`. Without one the mark uses the text color, so it works in light and dark mode. */
|
|
32
|
+
color?: string;
|
|
33
|
+
/** Name of the mark for tooltips; defaults to the Family title. */
|
|
34
|
+
title?: string;
|
|
35
|
+
fillRule?: 'nonzero' | 'evenodd';
|
|
36
|
+
}
|
|
37
|
+
export interface FamilyDef {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
/** Mark shown on the Panel's cards, lists and the Tool select; without one the Panel shows the id's initials. */
|
|
41
|
+
logo?: FamilyLogo;
|
|
42
|
+
/** Host executable looked up on PATH. */
|
|
43
|
+
bin: string;
|
|
44
|
+
installUrl: string;
|
|
45
|
+
/** Environment variable that selects the Account directory. Not set for the Main Account. */
|
|
46
|
+
selectVar: string;
|
|
47
|
+
layout: FamilyLayout;
|
|
48
|
+
/**
|
|
49
|
+
* Entries that prove a directory is this Family's Account (besides a Secret File or an empty directory).
|
|
50
|
+
* Defaults to the Shared Item paths; set it when a shared name is generic, like Claude's `plugins`.
|
|
51
|
+
*/
|
|
52
|
+
markers?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Whether a Secret File makes an Account an API Account (default true). pi keeps `proxy.env` in every
|
|
55
|
+
* Account for provider keys, so for pi it is loaded at Launch but says nothing about the login.
|
|
56
|
+
*/
|
|
57
|
+
secretFileMeansApi?: boolean;
|
|
58
|
+
/** Cleared before every Launch, for every Account. */
|
|
59
|
+
hijackVars: string[];
|
|
60
|
+
/** Variables written, commented out, into a new API Account's Secret File. */
|
|
61
|
+
apiVars: string[];
|
|
62
|
+
sharedItems: SharedItemDef[];
|
|
63
|
+
/** Arguments for the Host's sign-in command, or null when sign-in happens inside the Host. */
|
|
64
|
+
login: {
|
|
65
|
+
args: string[] | null;
|
|
66
|
+
hint: string;
|
|
67
|
+
};
|
|
68
|
+
/** Arguments sideby adds before the Account's and the user's arguments. */
|
|
69
|
+
defaultArgs?(account: Account, userArgs: readonly string[]): string[];
|
|
70
|
+
loginState?(account: Account): Promise<LoginState>;
|
|
71
|
+
model?(account: Account): Promise<string | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Who the Account is signed in as, from the Host's own login file: identity fields such as the email and the
|
|
74
|
+
* organization only, never a token or any other credential value (ADR-0003). Return undefined when unknown; an
|
|
75
|
+
* error is treated the same way and never shown as a problem.
|
|
76
|
+
*/
|
|
77
|
+
identity?(account: Account): Promise<AccountIdentity | undefined>;
|
|
78
|
+
readQuota?(account: Account, ctx: ReadContext): Promise<QuotaResult>;
|
|
79
|
+
readUsage?(account: Account, ctx: ReadContext): Promise<UsageResult>;
|
|
80
|
+
/** One-time, reversible change that lets the Host report Quota (Claude: status line wrapper). */
|
|
81
|
+
quotaSetup?: QuotaSetup;
|
|
82
|
+
}
|
|
83
|
+
/** Identity fields of a signed-in Account. */
|
|
84
|
+
export interface AccountIdentity {
|
|
85
|
+
email?: string;
|
|
86
|
+
/** Organization or team, when it is not just the personal default. */
|
|
87
|
+
org?: string;
|
|
88
|
+
}
|
|
89
|
+
export interface QuotaSetupPlan {
|
|
90
|
+
/** `enabled`: already on; `ready`: apply would make `diff`; `blocked`: cannot apply, see `message`. */
|
|
91
|
+
status: 'enabled' | 'ready' | 'blocked';
|
|
92
|
+
/** File the change touches. */
|
|
93
|
+
file: string;
|
|
94
|
+
/** Unified diff of the change, empty when nothing would change. */
|
|
95
|
+
diff: string;
|
|
96
|
+
message: string;
|
|
97
|
+
}
|
|
98
|
+
export interface QuotaSetup {
|
|
99
|
+
/** One sentence shown before the user confirms. */
|
|
100
|
+
summary: string;
|
|
101
|
+
plan(ctx: ReadContext): Promise<QuotaSetupPlan>;
|
|
102
|
+
/** Re-plans and applies only when the plan is `ready`. */
|
|
103
|
+
apply(ctx: ReadContext): Promise<QuotaSetupPlan>;
|
|
104
|
+
/** Restores the original bytes only if the file is still exactly what `apply` wrote. */
|
|
105
|
+
teardown(ctx: ReadContext): Promise<{
|
|
106
|
+
ok: boolean;
|
|
107
|
+
message: string;
|
|
108
|
+
diff?: string;
|
|
109
|
+
}>;
|
|
110
|
+
}
|
|
111
|
+
export interface Account {
|
|
112
|
+
family: string;
|
|
113
|
+
/** `main` for the Main Account. */
|
|
114
|
+
name: string;
|
|
115
|
+
/** `<family>:<name>` */
|
|
116
|
+
ref: string;
|
|
117
|
+
dir: string;
|
|
118
|
+
isMain: boolean;
|
|
119
|
+
kind: 'subscription' | 'api';
|
|
120
|
+
/** Path of the Secret File for API Accounts. */
|
|
121
|
+
secretFile?: string;
|
|
122
|
+
}
|
|
123
|
+
export interface ReadContext {
|
|
124
|
+
home: string;
|
|
125
|
+
now: Date;
|
|
126
|
+
/** sideby state directory (quota caches live here). */
|
|
127
|
+
stateDir: string;
|
|
128
|
+
/** sideby's own environment (PATH lookups); never the Host's. */
|
|
129
|
+
env: Env;
|
|
130
|
+
}
|
|
131
|
+
export interface QuotaWindow {
|
|
132
|
+
/** `5h`, `7d`, or `<n>m` for unknown window lengths. */
|
|
133
|
+
label: string;
|
|
134
|
+
windowMinutes: number;
|
|
135
|
+
usedPercent: number;
|
|
136
|
+
/** ISO timestamp. */
|
|
137
|
+
resetsAt: string;
|
|
138
|
+
}
|
|
139
|
+
export type QuotaUnavailableReason = 'not-enabled' | 'no-session' | 'unrecognized' | 'no-source' | 'api-account';
|
|
140
|
+
export type QuotaResult = {
|
|
141
|
+
status: 'ok';
|
|
142
|
+
windows: QuotaWindow[];
|
|
143
|
+
observedAt: string;
|
|
144
|
+
source: string;
|
|
145
|
+
plan?: string;
|
|
146
|
+
} | {
|
|
147
|
+
status: 'unavailable';
|
|
148
|
+
reason: QuotaUnavailableReason;
|
|
149
|
+
detail?: string;
|
|
150
|
+
};
|
|
151
|
+
export interface TokenTotals {
|
|
152
|
+
inputTokens: number;
|
|
153
|
+
outputTokens: number;
|
|
154
|
+
cacheReadTokens: number;
|
|
155
|
+
cacheWriteTokens: number;
|
|
156
|
+
totalTokens: number;
|
|
157
|
+
}
|
|
158
|
+
export type UsageResult = ({
|
|
159
|
+
status: 'ok';
|
|
160
|
+
days: number;
|
|
161
|
+
sessions: number;
|
|
162
|
+
/**
|
|
163
|
+
* Tokens per local calendar day (`YYYY-MM-DD`), `days` entries ending today, oldest first, days without
|
|
164
|
+
* records as 0. It can sum to less than `totalTokens`, which also covers the part of the oldest day
|
|
165
|
+
* inside the rolling window.
|
|
166
|
+
*/
|
|
167
|
+
daily?: {
|
|
168
|
+
date: string;
|
|
169
|
+
totalTokens: number;
|
|
170
|
+
}[];
|
|
171
|
+
/** ISO time of the newest record the reader saw, not later than now. */
|
|
172
|
+
lastActivityAt?: string;
|
|
173
|
+
} & TokenTotals) | {
|
|
174
|
+
status: 'unavailable';
|
|
175
|
+
reason: 'no-source' | 'no-session' | 'unrecognized';
|
|
176
|
+
detail?: string;
|
|
177
|
+
};
|
|
178
|
+
export type FindingLevel = 'ok' | 'warn' | 'fail';
|
|
179
|
+
export interface FixResult {
|
|
180
|
+
ok: boolean;
|
|
181
|
+
message: string;
|
|
182
|
+
}
|
|
183
|
+
export interface Finding {
|
|
184
|
+
level: FindingLevel;
|
|
185
|
+
/** Account ref the Finding is about. */
|
|
186
|
+
account: string;
|
|
187
|
+
/** Shared Item path or check name. */
|
|
188
|
+
item: string;
|
|
189
|
+
/** Stable machine code such as `link.real-file`. */
|
|
190
|
+
code: string;
|
|
191
|
+
message: string;
|
|
192
|
+
/** What the user should do; never contains credential values. */
|
|
193
|
+
hint?: string;
|
|
194
|
+
/** `core` or the Plugin name. */
|
|
195
|
+
source: string;
|
|
196
|
+
fixable: boolean;
|
|
197
|
+
/** Present when `fixable`; not serialized. */
|
|
198
|
+
fix?: () => Promise<FixResult>;
|
|
199
|
+
}
|
|
200
|
+
/** What a `doctor.check` hook returns; sideby fills `account` and `source`. */
|
|
201
|
+
export type CheckResult = Omit<Finding, 'account' | 'source' | 'fixable'> & {
|
|
202
|
+
fixable?: boolean;
|
|
203
|
+
};
|
|
204
|
+
export type Env = Record<string, string | undefined>;
|
|
205
|
+
export interface LaunchContext {
|
|
206
|
+
account: Account;
|
|
207
|
+
family: FamilyDef;
|
|
208
|
+
/** Environment the Host will receive; mutate to change it. */
|
|
209
|
+
env: Env;
|
|
210
|
+
/** Arguments the Host will receive; mutate to change them. */
|
|
211
|
+
args: string[];
|
|
212
|
+
config: Record<string, unknown>;
|
|
213
|
+
/** `run` or `login`. */
|
|
214
|
+
command: 'run' | 'login';
|
|
215
|
+
}
|
|
216
|
+
export interface CreateBeforeContext {
|
|
217
|
+
family: FamilyDef;
|
|
218
|
+
/** Name of the Account about to be created, already checked against the name rules. */
|
|
219
|
+
name: string;
|
|
220
|
+
/** Whether it will be an API Account (`sideby new --api`, or "API key account" in the Panel). */
|
|
221
|
+
api: boolean;
|
|
222
|
+
/** The Family's existing Accounts, the Main Account included. */
|
|
223
|
+
accounts: Account[];
|
|
224
|
+
config: Record<string, unknown>;
|
|
225
|
+
}
|
|
226
|
+
export interface CreatedContext {
|
|
227
|
+
account: Account;
|
|
228
|
+
family: FamilyDef;
|
|
229
|
+
config: Record<string, unknown>;
|
|
230
|
+
log(message: string): void;
|
|
231
|
+
}
|
|
232
|
+
export interface DoctorContext {
|
|
233
|
+
account: Account;
|
|
234
|
+
family: FamilyDef;
|
|
235
|
+
config: Record<string, unknown>;
|
|
236
|
+
}
|
|
237
|
+
export interface HookFilter {
|
|
238
|
+
family?: string;
|
|
239
|
+
}
|
|
240
|
+
export interface HookEvents {
|
|
241
|
+
'launch.before': {
|
|
242
|
+
ctx: LaunchContext;
|
|
243
|
+
result: void;
|
|
244
|
+
};
|
|
245
|
+
'account.create.before': {
|
|
246
|
+
ctx: CreateBeforeContext;
|
|
247
|
+
result: void;
|
|
248
|
+
};
|
|
249
|
+
'account.created': {
|
|
250
|
+
ctx: CreatedContext;
|
|
251
|
+
result: void;
|
|
252
|
+
};
|
|
253
|
+
'doctor.check': {
|
|
254
|
+
ctx: DoctorContext;
|
|
255
|
+
result: CheckResult[] | undefined;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export type HookEvent = keyof HookEvents;
|
|
259
|
+
export type HookHandler<E extends HookEvent> = (ctx: HookEvents[E]['ctx']) => HookEvents[E]['result'] | Promise<HookEvents[E]['result']>;
|
|
260
|
+
export interface PluginFs {
|
|
261
|
+
/** Atomic write via a temp file in the same directory; `mode` defaults to 0o600. */
|
|
262
|
+
writeFileAtomic(path: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
263
|
+
}
|
|
264
|
+
export interface PluginApi {
|
|
265
|
+
family(def: FamilyDef): void;
|
|
266
|
+
on<E extends HookEvent>(event: E, handler: HookHandler<E>): void;
|
|
267
|
+
on<E extends HookEvent>(event: E, filter: HookFilter, handler: HookHandler<E>): void;
|
|
268
|
+
/**
|
|
269
|
+
* Returns an error that, when thrown from `launch.before` or `account.create.before`, stops the Launch or the
|
|
270
|
+
* new Account with this message. Say what to do instead: the CLI and the Panel show it as is.
|
|
271
|
+
*/
|
|
272
|
+
abort(message: string): Error;
|
|
273
|
+
fs: PluginFs;
|
|
274
|
+
}
|
|
275
|
+
export interface Plugin {
|
|
276
|
+
name: string;
|
|
277
|
+
register(api: PluginApi): void | Promise<void>;
|
|
278
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BuiltinPlugin } from '../src/plugins/loader.ts';
|
|
2
|
+
import type { FamilyDef, Plugin, SharedItemDef } from '../src/types.ts';
|
|
3
|
+
export declare const DEMO_ITEMS: SharedItemDef[];
|
|
4
|
+
export declare function demoFamily(overrides?: Partial<FamilyDef>): FamilyDef;
|
|
5
|
+
export declare function demoPlugin(def?: FamilyDef, extra?: Plugin['register']): Plugin;
|
|
6
|
+
export declare function asBuiltin(plugin: Plugin, defaultEnabled?: boolean): BuiltinPlugin;
|
|
7
|
+
/** Populates a Main Account with one source for every demo Shared Item. */
|
|
8
|
+
export declare function seedDemoMain(write: (rel: string, data: string, mode?: number) => Promise<string>): Promise<void>;
|