getbotons 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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,363 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { CLI_NAMES } from '../../shared/legacy-names.mjs';
3
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from 'node:fs';
4
+ import type { DiffLine } from '../../shared/types.ts';
5
+
6
+ /*
7
+ * Skill packages: folders the owner puts in BOTONS_HOME/skill-packages, each holding a SKILL.md (frontmatter with
8
+ * name and description, then the procedure). Only SKILL.md's text is read, and only as text: nothing in a folder is
9
+ * run, unpacked or followed. A Workspace Boton can't read or write BOTONS_HOME, but what is in a folder is still
10
+ * treated as untrusted: it installs only after the owner reviews exactly that text (skills.ts).
11
+ *
12
+ * No link is followed, even one swapped in while a folder is read: the packages folder and each package folder are
13
+ * opened once (a package folder without following a link), and everything in them is then looked up through that
14
+ * open folder, by /proc/self/fd/<fd>/<name>, never by its path again. Renaming or replacing a folder meanwhile
15
+ * changes nothing: what is read is in the folder that was opened. Where there is no /proc/self/fd to look up
16
+ * through, packages are listed but none can be read.
17
+ */
18
+
19
+ export const SKILL_MD = 'SKILL.md';
20
+ /** Bytes of a SKILL.md: room for the longest description and procedure a skill takes, and its frontmatter. */
21
+ export const SKILL_MD_MAX = 64 * 1024;
22
+ const MAX_PACKAGES = 200;
23
+ const MAX_LISTED_FILES = 50;
24
+ const MAX_WALKED = 500;
25
+ const MAX_DEPTH = 6;
26
+ const FIELD_MAX = 300;
27
+ const ARCHIVE = /\.(zip|skill|tar|tgz|gz|bz2|xz|zst|7z|rar|jar)$/i;
28
+ /** Fields that describe a package without asking for anything: shown, not counted as asks. */
29
+ const DESCRIPTIVE = new Set(['license', 'author', 'authors', 'homepage', 'repository', 'tags', 'keywords', 'metadata', 'compatibility']);
30
+ const DIR = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
31
+
32
+ /** What a package folder holds, read and checked. `problems` empty: it can be installed. */
33
+ export interface PackageRead {
34
+ folder: string;
35
+ problems: string[];
36
+ name: string | null;
37
+ description: string | null;
38
+ body: string | null;
39
+ label: string | null;
40
+ fields: Record<string, string>;
41
+ otherFiles: string[];
42
+ /** Full parsed metadata, hashed before any display formatting. */
43
+ metadataHash: string | null;
44
+ }
45
+
46
+ /** Called once a folder is open and before anything in it is read: null for the packages folder, else the package's name. Tests swap folders here. */
47
+ export type Pinned = (folder: string | null) => void;
48
+
49
+ const empty = (folder: string, problems: string[]): PackageRead => ({ folder, problems, name: null, description: null, body: null, label: null, fields: {}, otherFiles: [], metadataHash: null });
50
+
51
+ const code = (e: unknown) => (e as NodeJS.ErrnoException).code ?? (e as Error).message;
52
+
53
+ /** `name` in the folder open as `fd`, wherever that folder is now. `name` is one entry: never a path. */
54
+ const at = (fd: number, name?: string) => (name === undefined ? `/proc/self/fd/${fd}` : `/proc/self/fd/${fd}/${name}`);
55
+
56
+ let lookups: boolean | undefined;
57
+ /** Whether names can be looked up through an open folder here (Linux's /proc/self/fd). */
58
+ function canPin(): boolean {
59
+ if (lookups !== undefined) return lookups;
60
+ let fd: number | undefined;
61
+ try {
62
+ fd = openSync('/', DIR);
63
+ const a = fstatSync(fd);
64
+ const b = lstatSync(`${at(fd)}/.`);
65
+ lookups = a.dev === b.dev && a.ino === b.ino;
66
+ } catch {
67
+ lookups = false;
68
+ } finally {
69
+ if (fd !== undefined) closeSync(fd);
70
+ }
71
+ return lookups;
72
+ }
73
+
74
+ const UNPINNED = "Botons can't read packages safely on this system: it reads them only through folders it holds open (/proc/self/fd, on Linux).";
75
+
76
+ /** The packages folder, opened; null if there is no such folder. */
77
+ function openRoot(dir: string): number | null {
78
+ try {
79
+ return openSync(realpathSync(dir), DIR);
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /** The packages in `dir`, sorted by folder; null if there is no such folder. Plain files and links there are listed with why they aren't packages. */
86
+ export function listPackages(dir: string, nameOk: (name: string) => boolean, pinned?: Pinned): PackageRead[] | null {
87
+ const root = openRoot(dir);
88
+ if (root === null) return null;
89
+ try {
90
+ if (!canPin()) {
91
+ return readdirSync(realpathSync(dir))
92
+ .filter((n) => !n.startsWith('.'))
93
+ .sort()
94
+ .slice(0, MAX_PACKAGES)
95
+ .map((n) => empty(n, [UNPINNED]));
96
+ }
97
+ pinned?.(null);
98
+ const names = readdirSync(at(root))
99
+ .filter((n) => !n.startsWith('.'))
100
+ .sort();
101
+ return names.slice(0, MAX_PACKAGES).map((n) => readEntry(root, n, nameOk, pinned));
102
+ } finally {
103
+ closeSync(root);
104
+ }
105
+ }
106
+
107
+ /** One package by its folder's name; null if there is no entry with that name. */
108
+ export function readPackage(dir: string, folder: string, nameOk: (name: string) => boolean, pinned?: Pinned): PackageRead | null {
109
+ if (!nameOk(folder)) return null;
110
+ const root = openRoot(dir);
111
+ if (root === null) return null;
112
+ try {
113
+ if (!canPin()) return empty(folder, [UNPINNED]);
114
+ pinned?.(null);
115
+ try {
116
+ lstatSync(at(root, folder));
117
+ } catch {
118
+ return null;
119
+ }
120
+ return readEntry(root, folder, nameOk, pinned);
121
+ } finally {
122
+ closeSync(root);
123
+ }
124
+ }
125
+
126
+ const LINK = 'It is a link. Botons reads only real folders here: copy the package in instead.';
127
+ const NOT_FOLDER = 'It is not a folder. Each package is a folder holding a SKILL.md.';
128
+
129
+ function readEntry(root: number, folder: string, nameOk: (name: string) => boolean, pinned?: Pinned): PackageRead {
130
+ let st;
131
+ try {
132
+ st = lstatSync(at(root, folder));
133
+ } catch (e) {
134
+ return empty(folder, [`Can't read it: ${code(e)}`]);
135
+ }
136
+ if (st.isSymbolicLink()) return empty(folder, [LINK]);
137
+ if (st.isFile() && ARCHIVE.test(folder)) return empty(folder, ['It is an archive. Botons doesn\'t unpack archives: check it and unpack it into a folder here yourself.']);
138
+ if (!st.isDirectory()) return empty(folder, [NOT_FOLDER]);
139
+ if (!nameOk(folder)) return empty(folder, ['Its name is not a skill name: lowercase letters and digits in words joined by single hyphens (like weekly-report).']);
140
+
141
+ // Open without following a link: one swapped in since the lstat fails here, and what is read from now on is in this folder.
142
+ let fd: number;
143
+ try {
144
+ fd = openSync(at(root, folder), DIR);
145
+ } catch (e) {
146
+ const c = code(e);
147
+ return empty(folder, [c === 'ELOOP' ? LINK : c === 'ENOTDIR' ? NOT_FOLDER : `Can't read it: ${c}`]);
148
+ }
149
+ try {
150
+ pinned?.(folder);
151
+ const otherFiles = walk(fd);
152
+ let text: string;
153
+ try {
154
+ text = readSkillMd(fd);
155
+ } catch (e) {
156
+ return { ...empty(folder, [(e as Error).message]), otherFiles };
157
+ }
158
+ const parsed = parseSkillMd(text);
159
+ if ('problem' in parsed) return { ...empty(folder, [parsed.problem]), otherFiles };
160
+ const problems: string[] = [];
161
+ const { name, description, label, fields, body, metadataHash } = parsed;
162
+ if (!name) problems.push('SKILL.md has no name.');
163
+ else if (name !== folder) problems.push(`SKILL.md is named ${name}, but its folder is ${folder}: they must be the same.`);
164
+ if (!description) problems.push('SKILL.md has no description.');
165
+ if (!body) problems.push('SKILL.md has no procedure after its frontmatter.');
166
+ return { folder, problems, name, description, body, label, fields, otherFiles, metadataHash };
167
+ } finally {
168
+ closeSync(fd);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * SKILL.md's text from the open package folder: a regular file there, opened without following a link, with no
174
+ * other hard links, at most SKILL_MD_MAX bytes, valid UTF-8. What was checked is what is read: the same open file.
175
+ */
176
+ function readSkillMd(folder: number): string {
177
+ let fd: number;
178
+ try {
179
+ fd = openSync(at(folder, SKILL_MD), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
180
+ } catch (e) {
181
+ const c = code(e);
182
+ if (c === 'ENOENT') throw new Error('It has no SKILL.md.');
183
+ if (c === 'ELOOP') throw new Error('Its SKILL.md is a link. Botons reads only a real file: copy it in instead.');
184
+ throw new Error(`Can't read its SKILL.md: ${c}`);
185
+ }
186
+ try {
187
+ const st = fstatSync(fd);
188
+ if (!st.isFile()) throw new Error('Its SKILL.md is not a regular file.');
189
+ if (st.nlink > 1) throw new Error('Its SKILL.md has other hard links. Copy it to a new file instead.');
190
+ const tooLarge = (n: number) => new Error(`Its SKILL.md is too large (${n} bytes; at most ${SKILL_MD_MAX}).`);
191
+ if (st.size > SKILL_MD_MAX) throw tooLarge(st.size);
192
+ const buf = Buffer.alloc(SKILL_MD_MAX + 1);
193
+ let n = 0;
194
+ for (let got = 1; got > 0 && n < buf.length; n += got) got = readSync(fd, buf, n, buf.length - n, null);
195
+ if (n > SKILL_MD_MAX) throw tooLarge(n);
196
+ let text: string;
197
+ try {
198
+ text = new TextDecoder('utf-8', { fatal: true }).decode(buf.subarray(0, n));
199
+ } catch {
200
+ throw new Error('Its SKILL.md is not UTF-8 text.');
201
+ }
202
+ if (text.includes('\0')) throw new Error('Its SKILL.md is not text (it holds a NUL byte).');
203
+ return text;
204
+ } finally {
205
+ closeSync(fd);
206
+ }
207
+ }
208
+
209
+ /** Everything in the open package folder besides SKILL.md, as relative paths, without following links or reading any file. */
210
+ function walk(folder: number): string[] {
211
+ const out: string[] = [];
212
+ let seen = 0;
213
+ const visit = (dir: number, rel: string, depth: number) => {
214
+ let names: string[];
215
+ try {
216
+ names = readdirSync(at(dir)).sort();
217
+ } catch {
218
+ return;
219
+ }
220
+ for (const n of names) {
221
+ if (++seen > MAX_WALKED) return;
222
+ const r = rel ? `${rel}/${n}` : n;
223
+ if (!rel && n === SKILL_MD) continue;
224
+ let st;
225
+ try {
226
+ st = lstatSync(at(dir, n));
227
+ } catch {
228
+ continue;
229
+ }
230
+ if (st.isSymbolicLink()) out.push(`${r} (a link, not followed)`);
231
+ else if (st.isDirectory()) {
232
+ if (n === '.git' || depth >= MAX_DEPTH) {
233
+ out.push(`${r}/ (not looked into)`);
234
+ continue;
235
+ }
236
+ let sub: number;
237
+ try {
238
+ sub = openSync(at(dir, n), DIR);
239
+ } catch (e) {
240
+ out.push(code(e) === 'ELOOP' ? `${r} (a link, not followed)` : `${r}/ (not looked into)`);
241
+ continue;
242
+ }
243
+ try {
244
+ visit(sub, r, depth + 1);
245
+ } finally {
246
+ closeSync(sub);
247
+ }
248
+ } else out.push(r);
249
+ }
250
+ };
251
+ visit(folder, '', 0);
252
+ if (seen > MAX_WALKED) out.push('… and more');
253
+ return out.length > MAX_LISTED_FILES ? [...out.slice(0, MAX_LISTED_FILES), `… and ${out.length - MAX_LISTED_FILES} more`] : out;
254
+ }
255
+
256
+ type Parsed = { name: string | null; description: string | null; label: string | null; fields: Record<string, string>; body: string; metadataHash: string } | { problem: string };
257
+
258
+ const unquote = (v: string) => {
259
+ const m = /^"((?:[^"\\]|\\.)*)"$/.exec(v);
260
+ if (m) return m[1].replace(/\\(.)/g, (_, c: string) => (c === 'n' ? '\n' : c));
261
+ const s = /^'((?:[^']|'')*)'$/.exec(v);
262
+ return s ? s[1].replace(/''/g, "'") : v;
263
+ };
264
+
265
+ /**
266
+ * SKILL.md: `---`, frontmatter lines (`key: value`, a quoted value, or `>`/`|` with indented lines after it), `---`,
267
+ * then the procedure. Only the subset skills use is read; a key with nested lines is kept as its text.
268
+ */
269
+ export function parseSkillMd(raw: string): Parsed {
270
+ const text = raw.replace(/^/, '').replace(/\r\n?/g, '\n');
271
+ const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)([\s\S]*)$/.exec(text);
272
+ if (!m) return { problem: 'SKILL.md has no frontmatter: it must start with a --- line, then name: and description:, then another --- line.' };
273
+ const entries: [string, string[]][] = [];
274
+ for (const line of m[1].split('\n')) {
275
+ const k = /^([A-Za-z0-9_.-]+):[ \t]*(.*)$/.exec(line);
276
+ if (k) entries.push([k[1], [k[2]]]);
277
+ else if (!line.trim() || line.trim().startsWith('#')) entries.at(-1)?.[1].push('');
278
+ else if (/^[ \t]/.test(line) && entries.length) entries.at(-1)![1].push(line);
279
+ else return { problem: `SKILL.md's frontmatter has a line Botons can't read: ${line.slice(0, 80)}` };
280
+ }
281
+ const values: Record<string, string> = Object.create(null);
282
+ for (const [key, [first, ...rest]] of entries) {
283
+ if (Object.hasOwn(values, key)) return { problem: `SKILL.md's frontmatter has ${key} twice.` };
284
+ const lines = rest.map((l) => l.trim());
285
+ while (lines.at(-1) === '') lines.pop();
286
+ let value: string;
287
+ if (/^[>|][+-]?$/.test(first.trim())) value = first.trim().startsWith('|') ? lines.join('\n') : lines.join(' ').replace(/ {2,}/g, ' ');
288
+ else value = unquote([first.trim(), ...lines].filter(Boolean).join(' '));
289
+ values[key] = value.trim();
290
+ }
291
+ const metadataHash = createHash('sha256').update(JSON.stringify(Object.keys(values).sort().map((key) => [key, values[key]]))).digest('hex');
292
+ const { name = null, description = null, version = null, ...fields } = values;
293
+ for (const k of Object.keys(fields)) fields[k] = clip(fields[k], FIELD_MAX);
294
+ return { name: name || null, description: description || null, label: version ? clip(version, 64) : null, fields, body: m[2].trim(), metadataHash };
295
+ }
296
+
297
+ /** A value cut to `max`, ending in a hash of all of it: a change past the cut still shows. Authorization separately binds the complete parsed metadata. */
298
+ const clip = (v: string, max: number) => (v.length > max ? `${v.slice(0, max)}… (${createHash('sha256').update(v).digest('hex')})` : v);
299
+
300
+ /**
301
+ * Everything a package says besides its description and procedure, one line each, in a fixed order: its version
302
+ * label, every other SKILL.md field, and the other files in its folder. Shown against the installed skill's, and
303
+ * part of what the owner reviews: a change here alone is an update.
304
+ */
305
+ export function packageDetails(p: { label: string | null; fields: Record<string, string>; otherFiles: string[] }): string {
306
+ const one = (v: string) => v.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
307
+ const lines = [
308
+ ...(p.label !== null ? [`version: ${one(p.label)}`] : []),
309
+ ...Object.keys(p.fields)
310
+ .sort()
311
+ .map((k) => `${k}: ${one(p.fields[k])}`),
312
+ ...p.otherFiles.map((f) => `other file: ${one(f)}`),
313
+ ];
314
+ return lines.join('\n');
315
+ }
316
+
317
+ /** What a skill asks for or points at, one line each, sorted: fields Botons doesn't grant, sites it links to, Boton commands it runs. */
318
+ export function asksOf(text: { description: string; body: string; fields: Record<string, string> }): string[] {
319
+ const out = new Set<string>();
320
+ for (const [k, v] of Object.entries(text.fields)) if (!DESCRIPTIVE.has(k.toLowerCase())) out.add(`asks for ${k}: ${v}`);
321
+ const all = `${text.description}\n${text.body}`;
322
+ for (const u of all.matchAll(/\b(?:https?|wss?|ftp):\/\/([^\s/?#"'<>)\]`]+)/gi)) {
323
+ const host = u[1].replace(/^[^@]*@/, '').toLowerCase();
324
+ if (host) out.add(`links to ${host}`);
325
+ }
326
+ // The command, or one of its deprecated names (shared/legacy-names.mjs).
327
+ for (const c of all.matchAll(new RegExp(`\\b(?:${['botons', ...CLI_NAMES].join('|')})\\s+([a-z][a-z-]*)`, 'g'))) out.add(`runs botons ${c[1]}`);
328
+ return [...out].sort();
329
+ }
330
+
331
+ /** Line by line, what turns `a` into `b`: the longest common run kept, the rest removed then added. */
332
+ export function lineDiff(a: string, b: string): DiffLine[] {
333
+ const x = a ? a.split('\n') : [];
334
+ const y = b ? b.split('\n') : [];
335
+ let start = 0;
336
+ while (start < x.length && start < y.length && x[start] === y[start]) start++;
337
+ let end = 0;
338
+ while (end < x.length - start && end < y.length - start && x[x.length - 1 - end] === y[y.length - 1 - end]) end++;
339
+ const xs = x.slice(start, x.length - end);
340
+ const ys = y.slice(start, y.length - end);
341
+ const head: DiffLine[] = x.slice(0, start).map((text) => ({ op: 'same', text }));
342
+ const tail: DiffLine[] = x.slice(x.length - end).map((text) => ({ op: 'same', text }));
343
+ // Too many lines to compare cheaply: all of one out, all of the other in.
344
+ if (xs.length * ys.length > 4_000_000) return [...head, ...xs.map((text): DiffLine => ({ op: 'remove', text })), ...ys.map((text): DiffLine => ({ op: 'add', text })), ...tail];
345
+ const n = xs.length;
346
+ const m = ys.length;
347
+ const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
348
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = xs[i] === ys[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
349
+ const mid: DiffLine[] = [];
350
+ let i = 0;
351
+ let j = 0;
352
+ while (i < n && j < m) {
353
+ if (xs[i] === ys[j]) {
354
+ mid.push({ op: 'same', text: xs[i] });
355
+ i++;
356
+ j++;
357
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) mid.push({ op: 'remove', text: xs[i++] });
358
+ else mid.push({ op: 'add', text: ys[j++] });
359
+ }
360
+ while (i < n) mid.push({ op: 'remove', text: xs[i++] });
361
+ while (j < m) mid.push({ op: 'add', text: ys[j++] });
362
+ return [...head, ...mid, ...tail];
363
+ }