sideby 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +50 -0
  8. package/dist/src/cli/commands.js +566 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +259 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +276 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +20 -0
  20. package/dist/src/core/accounts.js +131 -0
  21. package/dist/src/core/config.d.ts +61 -0
  22. package/dist/src/core/config.js +184 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +37 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/resume.d.ts +9 -0
  48. package/dist/src/core/resume.js +15 -0
  49. package/dist/src/core/secret-file.d.ts +30 -0
  50. package/dist/src/core/secret-file.js +116 -0
  51. package/dist/src/core/share-meaning.d.ts +6 -0
  52. package/dist/src/core/share-meaning.js +32 -0
  53. package/dist/src/core/share-mode-names.d.ts +1 -0
  54. package/dist/src/core/share-mode-names.js +10 -0
  55. package/dist/src/core/share-modes.d.ts +24 -0
  56. package/dist/src/core/share-modes.js +405 -0
  57. package/dist/src/core/shell-init.d.ts +31 -0
  58. package/dist/src/core/shell-init.js +84 -0
  59. package/dist/src/core/skill-check.d.ts +11 -0
  60. package/dist/src/core/skill-check.js +57 -0
  61. package/dist/src/core/usage-days.d.ts +15 -0
  62. package/dist/src/core/usage-days.js +33 -0
  63. package/dist/src/core/version.d.ts +9 -0
  64. package/dist/src/core/version.js +46 -0
  65. package/dist/src/desktop/app.d.ts +60 -0
  66. package/dist/src/desktop/app.js +316 -0
  67. package/dist/src/desktop/icon.d.ts +16 -0
  68. package/dist/src/desktop/icon.js +174 -0
  69. package/dist/src/families/claude/index.d.ts +10 -0
  70. package/dist/src/families/claude/index.js +127 -0
  71. package/dist/src/families/claude/layout.d.ts +6 -0
  72. package/dist/src/families/claude/layout.js +5 -0
  73. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  74. package/dist/src/families/claude/quota-cache.js +111 -0
  75. package/dist/src/families/claude/quota.d.ts +3 -0
  76. package/dist/src/families/claude/quota.js +53 -0
  77. package/dist/src/families/claude/usage.d.ts +22 -0
  78. package/dist/src/families/claude/usage.js +146 -0
  79. package/dist/src/families/codex/index.d.ts +5 -0
  80. package/dist/src/families/codex/index.js +70 -0
  81. package/dist/src/families/codex/rollout.d.ts +67 -0
  82. package/dist/src/families/codex/rollout.js +298 -0
  83. package/dist/src/families/grok/index.d.ts +8 -0
  84. package/dist/src/families/grok/index.js +82 -0
  85. package/dist/src/families/index.d.ts +3 -0
  86. package/dist/src/families/index.js +40 -0
  87. package/dist/src/families/logos.d.ts +21 -0
  88. package/dist/src/families/logos.js +26 -0
  89. package/dist/src/families/pi/index.d.ts +3 -0
  90. package/dist/src/families/pi/index.js +54 -0
  91. package/dist/src/families/shared/sessions.d.ts +9 -0
  92. package/dist/src/families/shared/sessions.js +70 -0
  93. package/dist/src/families/shared/toml.d.ts +5 -0
  94. package/dist/src/families/shared/toml.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +4 -0
  97. package/dist/src/panel/background.d.ts +62 -0
  98. package/dist/src/panel/background.js +274 -0
  99. package/dist/src/panel/handler.d.ts +91 -0
  100. package/dist/src/panel/handler.js +396 -0
  101. package/dist/src/panel/i18n.d.ts +287 -0
  102. package/dist/src/panel/i18n.js +570 -0
  103. package/dist/src/panel/page-logic.d.ts +154 -0
  104. package/dist/src/panel/page-logic.js +326 -0
  105. package/dist/src/panel/page-script.d.ts +2 -0
  106. package/dist/src/panel/page-script.js +1633 -0
  107. package/dist/src/panel/page-styles.d.ts +7 -0
  108. package/dist/src/panel/page-styles.js +514 -0
  109. package/dist/src/panel/page.d.ts +20 -0
  110. package/dist/src/panel/page.js +96 -0
  111. package/dist/src/panel/server.d.ts +32 -0
  112. package/dist/src/panel/server.js +133 -0
  113. package/dist/src/panel/theme.d.ts +116 -0
  114. package/dist/src/panel/theme.js +132 -0
  115. package/dist/src/plugins/account-script.d.ts +4 -0
  116. package/dist/src/plugins/account-script.js +66 -0
  117. package/dist/src/plugins/bus.d.ts +33 -0
  118. package/dist/src/plugins/bus.js +84 -0
  119. package/dist/src/plugins/loader.d.ts +58 -0
  120. package/dist/src/plugins/loader.js +250 -0
  121. package/dist/src/quota/claude-statusline.d.ts +28 -0
  122. package/dist/src/quota/claude-statusline.js +301 -0
  123. package/dist/src/quota/statusline-tap.d.ts +11 -0
  124. package/dist/src/quota/statusline-tap.js +104 -0
  125. package/dist/src/runtime.d.ts +195 -0
  126. package/dist/src/runtime.js +424 -0
  127. package/dist/src/types.d.ts +285 -0
  128. package/dist/src/types.js +3 -0
  129. package/dist/testing/demo.d.ts +8 -0
  130. package/dist/testing/demo.js +50 -0
  131. package/dist/testing/index.d.ts +36 -0
  132. package/dist/testing/index.js +118 -0
  133. package/package.json +66 -4
  134. package/schemas/alias.json +102 -0
  135. package/schemas/app-install.json +74 -0
  136. package/schemas/app-uninstall.json +54 -0
  137. package/schemas/config.json +170 -0
  138. package/schemas/doctor.json +229 -0
  139. package/schemas/error.json +25 -0
  140. package/schemas/families.json +205 -0
  141. package/schemas/list.json +177 -0
  142. package/schemas/new.json +199 -0
  143. package/schemas/next.json +127 -0
  144. package/schemas/plugins.json +74 -0
  145. package/schemas/quota-setup.json +60 -0
  146. package/schemas/quota-teardown.json +30 -0
  147. package/schemas/quota.json +203 -0
  148. package/skills/sideby/SKILL.md +61 -0
  149. package/skills/sideby/references/accounts.md +31 -0
  150. package/skills/sideby/references/diagnose.md +47 -0
  151. package/skills/sideby/references/handoff.md +41 -0
  152. package/skills/sideby/references/quota.md +14 -0
  153. package/skills/sideby/references/setup.md +31 -0
  154. package/skills/sideby/references/sharing.md +17 -0
@@ -0,0 +1,84 @@
1
+ export const HOOK_TIMEOUT_MS = {
2
+ 'launch.before': 30_000,
3
+ 'account.create.before': 10_000,
4
+ 'account.created': 30_000,
5
+ 'doctor.check': 10_000,
6
+ };
7
+ /** Error thrown by a hook, labelled with the Plugin that raised it. */
8
+ export class HookError extends Error {
9
+ plugin;
10
+ aborted;
11
+ constructor(plugin, message, aborted) {
12
+ super(`[${plugin}] ${message}`);
13
+ this.plugin = plugin;
14
+ this.aborted = aborted;
15
+ }
16
+ }
17
+ /** Thrown by `api.abort()` to stop a Launch or a new Account on purpose. */
18
+ export class HookAbort extends Error {
19
+ }
20
+ export class HookBus {
21
+ entries = new Map();
22
+ timeouts;
23
+ constructor(timeouts = {}) {
24
+ this.timeouts = { ...HOOK_TIMEOUT_MS, ...timeouts };
25
+ }
26
+ add(plugin, event, filter, handler) {
27
+ const list = this.entries.get(event) ?? [];
28
+ list.push({ plugin, filter, handler: handler });
29
+ this.entries.set(event, list);
30
+ }
31
+ count(event) {
32
+ return this.entries.get(event)?.length ?? 0;
33
+ }
34
+ /**
35
+ * Runs matching handlers in registration order. Each gets the event's timeout.
36
+ * Returns per-plugin results; the first failure stops the chain and is thrown as HookError.
37
+ */
38
+ async run(event, family, ctx) {
39
+ const out = [];
40
+ for (const e of this.entries.get(event) ?? []) {
41
+ if (e.filter.family && e.filter.family !== family)
42
+ continue;
43
+ const result = await this.invoke(e, event, ctx);
44
+ out.push({ plugin: e.plugin, result });
45
+ }
46
+ return out;
47
+ }
48
+ /** Like `run`, but a failing handler is reported and the others still run (used by Doctor). */
49
+ async runIsolated(event, family, ctx) {
50
+ const out = [];
51
+ for (const e of this.entries.get(event) ?? []) {
52
+ if (e.filter.family && e.filter.family !== family)
53
+ continue;
54
+ try {
55
+ out.push({ plugin: e.plugin, result: await this.invoke(e, event, ctx) });
56
+ }
57
+ catch (err) {
58
+ out.push({ plugin: e.plugin, error: err });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ async invoke(e, event, ctx) {
64
+ const ms = this.timeouts[event];
65
+ let timer;
66
+ const timeout = new Promise((_, reject) => {
67
+ timer = setTimeout(() => reject(new HookError(e.plugin, `${event} timed out after ${ms / 1000}s`, false)), ms);
68
+ // Kept referenced: a hook stuck on a bare Promise holds no handle, and an unref'd timer would let
69
+ // the process exit silently mid-operation. `finally` clears it, so it never delays a normal exit.
70
+ });
71
+ try {
72
+ return await Promise.race([Promise.resolve().then(() => e.handler(ctx)), timeout]);
73
+ }
74
+ catch (err) {
75
+ if (err instanceof HookError)
76
+ throw err;
77
+ const aborted = err instanceof HookAbort;
78
+ throw new HookError(e.plugin, err?.message ?? String(err), aborted);
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,58 @@
1
+ import type { Config } from '../core/config.ts';
2
+ import type { FamilyDef, Plugin } from '../types.ts';
3
+ import { type HookBus } from './bus.ts';
4
+ export interface PluginManifest {
5
+ name: string;
6
+ version: string;
7
+ description?: string;
8
+ main?: string;
9
+ userConfig?: Record<string, {
10
+ type?: string;
11
+ default?: unknown;
12
+ title?: string;
13
+ description?: string;
14
+ }>;
15
+ }
16
+ export interface LoadedPlugin {
17
+ name: string;
18
+ version: string;
19
+ source: 'builtin' | string;
20
+ description?: string;
21
+ families: string[];
22
+ config: Record<string, unknown>;
23
+ }
24
+ export interface PluginLoadError {
25
+ /** Plugin directory, or the plugin name when known. */
26
+ where: string;
27
+ message: string;
28
+ }
29
+ export interface BuiltinPlugin {
30
+ plugin: Plugin;
31
+ version: string;
32
+ description: string;
33
+ /** Built-in Plugins that stay off unless the config enables them. */
34
+ defaultEnabled: boolean;
35
+ }
36
+ export interface LoadResult {
37
+ plugins: LoadedPlugin[];
38
+ errors: PluginLoadError[];
39
+ families: Map<string, FamilyDef>;
40
+ familyOwner: Map<string, string>;
41
+ }
42
+ /** Rejects plugin files other users could have written: plugins run with the user's full permissions. */
43
+ export declare function checkTrusted(path: string): Promise<string | null>;
44
+ /**
45
+ * checkTrusted for a directory and everything under it, at any depth; symlinks are judged by their
46
+ * targets and each real directory is visited once, so a link cycle cannot loop forever.
47
+ */
48
+ export declare function checkTrustedTree(root: string, seen?: Set<string>): Promise<string | null>;
49
+ export declare const MAX_LOGO_PATH = 20000;
50
+ /** Why a Family logo is unusable, or null when it is fine. The Panel draws it with DOM APIs, never as markup. */
51
+ export declare function logoProblem(logo: unknown): string | null;
52
+ export declare function loadPlugins(opts: {
53
+ builtins: BuiltinPlugin[];
54
+ config: Config;
55
+ bus: HookBus;
56
+ userPluginsDir: string;
57
+ home: string;
58
+ }): Promise<LoadResult>;
@@ -0,0 +1,250 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { readdir, readFile, realpath, stat } from 'node:fs/promises';
10
+ import { join, sep } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { writeFileAtomic } from '../core/fs-safe.js';
13
+ import { expandUserPath } from '../core/paths.js';
14
+ import { HOOK_TIMEOUT_MS, HookAbort } from './bus.js';
15
+ /** Rejects plugin files other users could have written: plugins run with the user's full permissions. */
16
+ export async function checkTrusted(path) {
17
+ const st = await stat(path);
18
+ const uid = process.getuid?.();
19
+ if (uid !== undefined && st.uid !== uid)
20
+ return `${path} is owned by another user`;
21
+ if ((st.mode & 0o022) !== 0)
22
+ return `${path} is writable by group or others; run \`chmod go-w "${path}"\``;
23
+ return null;
24
+ }
25
+ /**
26
+ * checkTrusted for a directory and everything under it, at any depth; symlinks are judged by their
27
+ * targets and each real directory is visited once, so a link cycle cannot loop forever.
28
+ */
29
+ export async function checkTrustedTree(root, seen = new Set()) {
30
+ let st;
31
+ try {
32
+ st = await stat(root);
33
+ }
34
+ catch {
35
+ return `${root} is missing or a dangling link`;
36
+ }
37
+ const own = await checkTrusted(root);
38
+ if (own)
39
+ return own;
40
+ if (!st.isDirectory())
41
+ return null;
42
+ const real = await realpath(root);
43
+ if (seen.has(real))
44
+ return null;
45
+ seen.add(real);
46
+ for (const e of await readdir(root)) {
47
+ const problem = await checkTrustedTree(join(root, e), seen);
48
+ if (problem)
49
+ return problem;
50
+ }
51
+ return null;
52
+ }
53
+ /** SVG path data only: commands, numbers and separators, so a logo cannot carry markup or script. */
54
+ const LOGO_PATH_RE = /^[MmLlHhVvCcSsQqTtAaZz0-9eE.,+\-\s]+$/;
55
+ const LOGO_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
56
+ export const MAX_LOGO_PATH = 20_000;
57
+ /** Why a Family logo is unusable, or null when it is fine. The Panel draws it with DOM APIs, never as markup. */
58
+ export function logoProblem(logo) {
59
+ if (typeof logo !== 'object' || logo === null || Array.isArray(logo))
60
+ return 'logo must be an object';
61
+ const { path, color, title, fillRule } = logo;
62
+ if (typeof path !== 'string' || !path.trim())
63
+ return 'logo.path must be a non-empty string';
64
+ if (path.length > MAX_LOGO_PATH)
65
+ return `logo.path is over ${MAX_LOGO_PATH} characters`;
66
+ if (!LOGO_PATH_RE.test(path))
67
+ return 'logo.path may hold only SVG path commands, numbers, spaces, commas, dots, "+" and "-"';
68
+ if (color !== undefined && (typeof color !== 'string' || !LOGO_COLOR_RE.test(color)))
69
+ return 'logo.color must be a hex color such as #D97757';
70
+ if (title !== undefined && (typeof title !== 'string' || title.length > 80))
71
+ return 'logo.title must be a string of at most 80 characters';
72
+ if (fillRule !== undefined && fillRule !== 'nonzero' && fillRule !== 'evenodd')
73
+ return 'logo.fillRule must be "nonzero" or "evenodd"';
74
+ return null;
75
+ }
76
+ /** Only the known logo fields, so nothing else a Plugin put on the object reaches the Panel. */
77
+ function cleanLogo(logo) {
78
+ return {
79
+ path: logo.path,
80
+ ...(logo.color ? { color: logo.color } : {}),
81
+ ...(logo.title ? { title: logo.title } : {}),
82
+ ...(logo.fillRule ? { fillRule: logo.fillRule } : {}),
83
+ };
84
+ }
85
+ function settingsFor(config, name, manifest) {
86
+ const defaults = {};
87
+ for (const [k, v] of Object.entries(manifest?.userConfig ?? {}))
88
+ if ('default' in v)
89
+ defaults[k] = v.default;
90
+ return { ...defaults, ...(config.plugins?.[name] ?? {}) };
91
+ }
92
+ async function register(plugin, bus, result, source, settings) {
93
+ if (!plugin || typeof plugin.name !== 'string' || typeof plugin.register !== 'function')
94
+ throw new Error(`${source}: default export must be { name, register(api) }`);
95
+ // Stage everything the Plugin registers; commit only after register() succeeds, so a Plugin that fails
96
+ // halfway leaves no Family and no hook behind.
97
+ const stagedFamilies = [];
98
+ const stagedHooks = [];
99
+ const stagedErrors = [];
100
+ const api = {
101
+ family(def) {
102
+ // The id becomes part of account refs and shell function names.
103
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(def.id))
104
+ throw new Error(`family id "${def.id}" must be lowercase letters, digits or "-", starting with a letter`);
105
+ const owner = result.familyOwner.get(def.id);
106
+ if (owner)
107
+ throw new Error(`family ${def.id} is already provided by ${owner}`);
108
+ if (stagedFamilies.some((f) => f.id === def.id))
109
+ throw new Error(`family ${def.id} is registered twice`);
110
+ // A bad logo costs only the logo: the Family still loads and the Panel shows its initials.
111
+ if (def.logo !== undefined) {
112
+ const problem = logoProblem(def.logo);
113
+ if (problem) {
114
+ stagedErrors.push(`family ${def.id}: ${problem}; showing its initials instead`);
115
+ const { logo: _dropped, ...rest } = def;
116
+ stagedFamilies.push(rest);
117
+ return;
118
+ }
119
+ stagedFamilies.push({ ...def, logo: cleanLogo(def.logo) });
120
+ return;
121
+ }
122
+ stagedFamilies.push(def);
123
+ },
124
+ on(event, a, b) {
125
+ const [filter, handler] = typeof a === 'function' ? [{}, a] : [a, b];
126
+ if (!Object.hasOwn(HOOK_TIMEOUT_MS, event))
127
+ throw new Error(`unknown hook event ${event}`);
128
+ // Each hook sees its own Plugin's settings; env and args stay shared so mutations reach the Launch.
129
+ const withConfig = ((ctx) => handler({ ...ctx, config: settings }));
130
+ stagedHooks.push(() => bus.add(plugin.name, event, filter, withConfig));
131
+ },
132
+ abort: (message) => new HookAbort(message),
133
+ fs: {
134
+ async writeFileAtomic(path, data, mode) {
135
+ await writeFileAtomic(path, data, mode);
136
+ },
137
+ },
138
+ };
139
+ await plugin.register(api);
140
+ for (const def of stagedFamilies) {
141
+ result.families.set(def.id, def);
142
+ result.familyOwner.set(def.id, plugin.name);
143
+ }
144
+ for (const add of stagedHooks)
145
+ add();
146
+ for (const message of stagedErrors)
147
+ result.errors.push({ where: plugin.name, message });
148
+ return stagedFamilies.map((f) => f.id);
149
+ }
150
+ async function candidateDirs(config, userPluginsDir, home, errors) {
151
+ const dirs = [];
152
+ let entries = [];
153
+ try {
154
+ entries = (await readdir(userPluginsDir)).sort();
155
+ }
156
+ catch {
157
+ // no user plugins directory
158
+ }
159
+ for (const e of entries) {
160
+ if (e.startsWith('.'))
161
+ continue;
162
+ const p = join(userPluginsDir, e);
163
+ try {
164
+ if ((await stat(p)).isDirectory())
165
+ dirs.push(p);
166
+ }
167
+ catch (err) {
168
+ errors.push({
169
+ where: p,
170
+ message: `cannot read plugin directory: ${err.code ?? 'error'}`,
171
+ });
172
+ }
173
+ }
174
+ for (const raw of config.pluginDirs ?? []) {
175
+ const p = expandUserPath(raw, home);
176
+ if (!p)
177
+ errors.push({ where: raw, message: 'pluginDirs entries must be absolute or start with ~/' });
178
+ else
179
+ dirs.push(p);
180
+ }
181
+ return dirs;
182
+ }
183
+ export async function loadPlugins(opts) {
184
+ const result = { plugins: [], errors: [], families: new Map(), familyOwner: new Map() };
185
+ const seen = new Set();
186
+ for (const b of opts.builtins) {
187
+ const settings = settingsFor(opts.config, b.plugin.name);
188
+ const enabled = settings.enabled === undefined ? b.defaultEnabled : settings.enabled === true;
189
+ if (!enabled)
190
+ continue;
191
+ try {
192
+ const families = await register(b.plugin, opts.bus, result, 'builtin', settings);
193
+ seen.add(b.plugin.name);
194
+ result.plugins.push({
195
+ name: b.plugin.name,
196
+ version: b.version,
197
+ source: 'builtin',
198
+ description: b.description,
199
+ families,
200
+ config: settings,
201
+ });
202
+ }
203
+ catch (err) {
204
+ result.errors.push({ where: b.plugin.name, message: err.message });
205
+ }
206
+ }
207
+ for (const dir of await candidateDirs(opts.config, opts.userPluginsDir, opts.home, result.errors)) {
208
+ try {
209
+ const manifestPath = join(dir, 'plugin.json');
210
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
211
+ if (typeof manifest.name !== 'string' || !manifest.name)
212
+ throw new Error('plugin.json needs a "name"');
213
+ const settings = settingsFor(opts.config, manifest.name, manifest);
214
+ if (settings.enabled === false)
215
+ continue;
216
+ if (seen.has(manifest.name))
217
+ throw new Error(`a plugin named ${manifest.name} is already loaded`);
218
+ const entry = join(dir, manifest.main ?? 'index.ts');
219
+ // The entry can import any file next to it, so every file in the plugin directory must be trusted,
220
+ // and the entry itself must be one of those files (`main: "../x.ts"` must not escape the check).
221
+ const problem = await checkTrustedTree(dir);
222
+ if (problem)
223
+ throw new Error(`not loaded: ${problem}`);
224
+ const [dirReal, entryReal] = await Promise.all([realpath(dir), realpath(entry).catch(() => null)]);
225
+ if (!entryReal)
226
+ throw new Error(`not loaded: entry ${entry} is missing`);
227
+ if (!entryReal.startsWith(`${dirReal}${sep}`))
228
+ throw new Error(`not loaded: entry ${entry} is outside the plugin directory`);
229
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href)));
230
+ if (!mod.default)
231
+ throw new Error(`${entry} has no default export`);
232
+ if (mod.default.name !== manifest.name)
233
+ throw new Error(`plugin.json name ${manifest.name} does not match the exported name ${mod.default.name}`);
234
+ const families = await register(mod.default, opts.bus, result, dir, settings);
235
+ seen.add(manifest.name);
236
+ result.plugins.push({
237
+ name: manifest.name,
238
+ version: manifest.version ?? '0.0.0',
239
+ source: dir,
240
+ ...(manifest.description ? { description: manifest.description } : {}),
241
+ families,
242
+ config: settings,
243
+ });
244
+ }
245
+ catch (err) {
246
+ result.errors.push({ where: dir, message: err.message });
247
+ }
248
+ }
249
+ return result;
250
+ }
@@ -0,0 +1,28 @@
1
+ import type { QuotaSetup } from '../types.ts';
2
+ export interface StatuslineBackup {
3
+ /** Real path that was changed (the symlink target when settings.json is a link). */
4
+ file: string;
5
+ originalBase64: string;
6
+ originalHash: string;
7
+ wrappedHash: string;
8
+ appliedAt: string;
9
+ }
10
+ export declare function backupFile(stateDir: string): string;
11
+ export declare function mainSettingsFile(home: string): string;
12
+ export declare function isWrapped(command: unknown): boolean;
13
+ export declare function wrapCommand(original: string | undefined): string;
14
+ export declare const claudeStatusline: QuotaSetup;
15
+ /**
16
+ * Hides values of secret-looking JSON keys in a diff line. settings.json often keeps API keys in `env`,
17
+ * right next to `statusLine`, so context lines could otherwise print them.
18
+ */
19
+ export declare function redactLine(line: string): string;
20
+ /**
21
+ * Diff of the statusLine object only. The rest of settings.json never enters a diff: it often holds API keys
22
+ * (for example in `env`), in shapes no line-based redaction can reliably catch.
23
+ */
24
+ export declare function statusLineDiff(before: unknown, after: unknown, file: string): string;
25
+ /** Shows the statusLine fields sideby touches as they are; any other value becomes "<redacted>". */
26
+ export declare function redactStatusLine(v: unknown): unknown;
27
+ /** Minimal unified diff with 3 lines of context; empty when the texts are equal. Secret-looking values are redacted. */
28
+ export declare function unifiedDiff(oldText: string, newText: string, file: string, context?: number): string;