viberoom 0.2.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 +661 -0
- package/NOTICE +20 -0
- package/README.md +153 -0
- package/assets/icon-128.png +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/icon-vector.svg +30 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +30 -0
- package/assets/vendors/claude.svg +3 -0
- package/assets/vendors/codex.svg +3 -0
- package/assets/vendors/copilot.svg +5 -0
- package/assets/vendors/cursor.svg +3 -0
- package/assets/vendors/gemini.svg +3 -0
- package/assets/vendors/opencode.svg +3 -0
- package/dist/acp-client.js +137 -0
- package/dist/acp-types.js +2 -0
- package/dist/edit.js +34 -0
- package/dist/hub.js +348 -0
- package/dist/icons.js +235 -0
- package/dist/jsonrpc.js +109 -0
- package/dist/launcher.js +161 -0
- package/dist/log.js +35 -0
- package/dist/main.js +389 -0
- package/dist/mcp-skills-server.js +177 -0
- package/dist/open.js +141 -0
- package/dist/persona.js +217 -0
- package/dist/recipes.js +261 -0
- package/dist/room.js +2124 -0
- package/dist/server.js +433 -0
- package/dist/shortcuts.js +176 -0
- package/dist/skills.js +344 -0
- package/dist/tui.js +109 -0
- package/package.json +61 -0
- package/scripts/install.mjs +34 -0
- package/scripts/render-icon.mjs +84 -0
- package/scripts/update.mjs +29 -0
- package/ui/app.css +346 -0
- package/ui/app.js +2834 -0
- package/ui/avatars.js +113 -0
- package/ui/fonts/OFL.txt +93 -0
- package/ui/fonts/nunito-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/nunito-cyrillic.woff2 +0 -0
- package/ui/fonts/nunito-latin-ext.woff2 +0 -0
- package/ui/fonts/nunito-latin.woff2 +0 -0
- package/ui/fonts/nunito-vietnamese.woff2 +0 -0
- package/ui/fonts/nunito.css +6 -0
- package/ui/icons.js +76 -0
- package/ui/index.html +217 -0
- package/ui/manifest.json +14 -0
- package/ui/theme.css +425 -0
package/dist/skills.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
export const SKILL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
5
|
+
export const SKILL_FILE = "SKILL.md";
|
|
6
|
+
export const BUILTIN_AUTHOR = "viberoom";
|
|
7
|
+
export const HUMAN_AUTHOR = "human";
|
|
8
|
+
const DESCRIPTION_MAX = 300;
|
|
9
|
+
const DESCRIPTION_MIN_USEFUL = 15;
|
|
10
|
+
const BODY_MAX = 20_000;
|
|
11
|
+
const KNOWN_FIELDS = new Set([
|
|
12
|
+
"name",
|
|
13
|
+
"description",
|
|
14
|
+
"argument-hint",
|
|
15
|
+
"user-invocable",
|
|
16
|
+
"disable-agent-invocation",
|
|
17
|
+
"author",
|
|
18
|
+
"created",
|
|
19
|
+
"reviewed",
|
|
20
|
+
"draft",
|
|
21
|
+
"metadata",
|
|
22
|
+
"license",
|
|
23
|
+
"compatibility",
|
|
24
|
+
"when_to_use",
|
|
25
|
+
]);
|
|
26
|
+
export function lintSkill(input) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
const warnings = [];
|
|
29
|
+
const name = input.name.trim();
|
|
30
|
+
const description = input.description.trim();
|
|
31
|
+
const body = input.body.replace(/\r\n/g, "\n").trim();
|
|
32
|
+
const hint = (input.argumentHint ?? "").trim();
|
|
33
|
+
if (!SKILL_NAME_PATTERN.test(name))
|
|
34
|
+
errors.push({ code: "name-invalid", message: `name "${name}" must be 1-32 letters, digits, _ or -` });
|
|
35
|
+
if (input.folder && name.toLowerCase() !== input.folder.toLowerCase()) {
|
|
36
|
+
errors.push({ code: "name-folder-mismatch", message: `name "${name}" differs from the folder "${input.folder}"` });
|
|
37
|
+
}
|
|
38
|
+
if (!description)
|
|
39
|
+
errors.push({ code: "description-missing", message: "description is required: it is what tells an agent when to use the skill" });
|
|
40
|
+
else if (description.length > DESCRIPTION_MAX)
|
|
41
|
+
errors.push({ code: "description-too-long", message: `description must be at most ${DESCRIPTION_MAX} characters` });
|
|
42
|
+
else if (description.length < DESCRIPTION_MIN_USEFUL || description.toLowerCase() === name.toLowerCase()) {
|
|
43
|
+
warnings.push({ code: "description-thin", message: "description should say what the skill does and when to use it, not just its name" });
|
|
44
|
+
}
|
|
45
|
+
if (!body)
|
|
46
|
+
errors.push({ code: "body-empty", message: "the instructions are empty" });
|
|
47
|
+
else if (body.length > BODY_MAX)
|
|
48
|
+
errors.push({ code: "body-too-long", message: `the instructions must be at most ${BODY_MAX} characters` });
|
|
49
|
+
if (/\[skill:/i.test(body) || /<\/?skill[\s>]/i.test(body)) {
|
|
50
|
+
errors.push({ code: "body-contains-delivery-syntax", message: "the instructions must not contain [skill:…] or <skill> tags (they are the hub's delivery syntax)" });
|
|
51
|
+
}
|
|
52
|
+
const usesArguments = /(^|[^\\])\$ARGUMENTS/.test(body);
|
|
53
|
+
if (usesArguments && !hint)
|
|
54
|
+
warnings.push({ code: "arguments-without-hint", message: "the instructions use $ARGUMENTS but there is no argument hint for the / menu" });
|
|
55
|
+
if (!usesArguments && hint)
|
|
56
|
+
warnings.push({ code: "hint-without-arguments", message: "there is an argument hint but the instructions never use $ARGUMENTS" });
|
|
57
|
+
for (const field of input.unknownFields ?? [])
|
|
58
|
+
warnings.push({ code: "unknown-field", message: `unknown frontmatter field "${field}" is ignored` });
|
|
59
|
+
return { errors, warnings };
|
|
60
|
+
}
|
|
61
|
+
export const SKILL_WRITER = {
|
|
62
|
+
name: "skill-writer",
|
|
63
|
+
description: "How to write a good skill for this library. Load it before creating or updating a skill with create_skill / update_skill.",
|
|
64
|
+
argumentHint: "",
|
|
65
|
+
body: [
|
|
66
|
+
"A skill is a reusable set of instructions for one kind of task. Another agent (or you, later, in another room) will get only this text when the skill is invoked, so it must stand on its own.",
|
|
67
|
+
"",
|
|
68
|
+
"Write it like this:",
|
|
69
|
+
"- name: short, lowercase, hyphenated (e.g. pr-review, daily-summary). It becomes the /command.",
|
|
70
|
+
"- description: one or two sentences that say WHAT the skill does and WHEN to use it. This is the only thing agents see before loading it, so it must let them decide (e.g. \"Review a pull request for correctness and post findings as a numbered list. Use when someone asks for a code review.\").",
|
|
71
|
+
"- instructions: imperative, concrete steps or a format. Say what the reply should contain, in what order, how long. Include an example when the format is non-obvious. Write \\$ARGUMENTS where the caller's text (what follows /name) belongs; give an argument hint like [PR number] when you use it. To mention the placeholder without filling it in, put a backslash before it.",
|
|
72
|
+
"- Do not put chat greetings, room rules or secrets in a skill, and do not write the hub's own delivery markers (the bracketed skill marker or skill tags) in it.",
|
|
73
|
+
"- Keep it under ~300 words; put long reference material in separate files in the skill's folder instead.",
|
|
74
|
+
"",
|
|
75
|
+
"Before creating: check that no existing skill already covers the task (your brief lists the skills you have). Prefer updating an agent-made skill over creating a near-duplicate.",
|
|
76
|
+
"After creating: attach it to yourself (attach_skill) if you will use it, and to other agents only when they need it; say in the room what you created and why.",
|
|
77
|
+
].join("\n"),
|
|
78
|
+
userInvocable: true,
|
|
79
|
+
agentInvocable: true,
|
|
80
|
+
author: BUILTIN_AUTHOR,
|
|
81
|
+
reviewed: true,
|
|
82
|
+
draft: false,
|
|
83
|
+
};
|
|
84
|
+
export function parseFrontmatter(text) {
|
|
85
|
+
const normalized = text.replace(/^/, "").replace(/\r\n/g, "\n");
|
|
86
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
87
|
+
if (!match)
|
|
88
|
+
return { meta: {}, body: normalized };
|
|
89
|
+
const meta = {};
|
|
90
|
+
for (const rawLine of match[1].split("\n")) {
|
|
91
|
+
const line = rawLine.trim();
|
|
92
|
+
if (!line || line.startsWith("#"))
|
|
93
|
+
continue;
|
|
94
|
+
const colon = line.indexOf(":");
|
|
95
|
+
if (colon <= 0)
|
|
96
|
+
continue;
|
|
97
|
+
const key = line.slice(0, colon).trim();
|
|
98
|
+
let value = line.slice(colon + 1).trim();
|
|
99
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
100
|
+
value = value.slice(1, -1);
|
|
101
|
+
meta[key] = value;
|
|
102
|
+
}
|
|
103
|
+
return { meta, body: match[2] };
|
|
104
|
+
}
|
|
105
|
+
export function renderFrontmatter(meta) {
|
|
106
|
+
const lines = ["---"];
|
|
107
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
108
|
+
if (value === undefined || value === "")
|
|
109
|
+
continue;
|
|
110
|
+
const text = typeof value === "boolean" ? String(value) : /[:#"'\n]/.test(value) ? JSON.stringify(value) : value;
|
|
111
|
+
lines.push(`${key}: ${text}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push("---");
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
|
116
|
+
function listExtraFiles(dir) {
|
|
117
|
+
try {
|
|
118
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
119
|
+
.filter((d) => d.isFile() && d.name !== SKILL_FILE)
|
|
120
|
+
.map((d) => join(dir, d.name))
|
|
121
|
+
.sort();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parseBool(value, fallback) {
|
|
128
|
+
if (value === undefined)
|
|
129
|
+
return fallback;
|
|
130
|
+
const v = value.trim().toLowerCase();
|
|
131
|
+
if (v === "true" || v === "yes")
|
|
132
|
+
return true;
|
|
133
|
+
if (v === "false" || v === "no")
|
|
134
|
+
return false;
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
export function parseSkillInvocation(text) {
|
|
138
|
+
const match = text.match(/^(?:@[\p{L}\p{N}][\p{L}\p{N}_-]*\s+)*\/([A-Za-z0-9][A-Za-z0-9_-]{0,31})(?:\s+([\s\S]*))?$/u);
|
|
139
|
+
if (!match)
|
|
140
|
+
return null;
|
|
141
|
+
return { name: match[1], args: (match[2] ?? "").trim() };
|
|
142
|
+
}
|
|
143
|
+
export function renderSkillBody(body, args) {
|
|
144
|
+
const trimmedArgs = args.trim();
|
|
145
|
+
return body
|
|
146
|
+
.replace(/\\\$ARGUMENTS|\$ARGUMENTS/g, (m) => (m.startsWith("\\") ? "$ARGUMENTS" : trimmedArgs))
|
|
147
|
+
.replace(/\r\n/g, "\n")
|
|
148
|
+
.trim();
|
|
149
|
+
}
|
|
150
|
+
export function isBuiltinSkill(name) {
|
|
151
|
+
return name.trim().toLowerCase() === SKILL_WRITER.name;
|
|
152
|
+
}
|
|
153
|
+
export class SkillLibrary {
|
|
154
|
+
dir;
|
|
155
|
+
log;
|
|
156
|
+
cache = new Map();
|
|
157
|
+
constructor(dir, log) {
|
|
158
|
+
this.dir = resolve(dir);
|
|
159
|
+
this.log = log;
|
|
160
|
+
mkdirSync(this.dir, { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
list() {
|
|
163
|
+
const out = [];
|
|
164
|
+
let entries = [];
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(this.dir, { withFileTypes: true })
|
|
167
|
+
.filter((d) => d.isDirectory())
|
|
168
|
+
.map((d) => d.name)
|
|
169
|
+
.sort((a, b) => a.localeCompare(b));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
this.log.warn(`skills folder unreadable: ${String(error)}`);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const folder of entries) {
|
|
177
|
+
const skill = this.load(folder);
|
|
178
|
+
if (!skill)
|
|
179
|
+
continue;
|
|
180
|
+
seen.add(folder);
|
|
181
|
+
const { body: _body, ...meta } = skill;
|
|
182
|
+
out.push(meta);
|
|
183
|
+
}
|
|
184
|
+
for (const key of [...this.cache.keys()])
|
|
185
|
+
if (!seen.has(key))
|
|
186
|
+
this.cache.delete(key);
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
get(name) {
|
|
190
|
+
if (!SKILL_NAME_PATTERN.test(name))
|
|
191
|
+
return undefined;
|
|
192
|
+
const folder = this.folderFor(name);
|
|
193
|
+
return folder ? this.load(folder) : undefined;
|
|
194
|
+
}
|
|
195
|
+
lint(draft) {
|
|
196
|
+
return lintSkill({ name: draft.name, description: draft.description, argumentHint: draft.argumentHint, body: draft.body });
|
|
197
|
+
}
|
|
198
|
+
save(draft) {
|
|
199
|
+
const name = draft.name.trim();
|
|
200
|
+
if (isBuiltinSkill(name) && draft.author !== BUILTIN_AUTHOR)
|
|
201
|
+
throw new Error(`${name} is built into viberoom and read-only; create your own skill instead`);
|
|
202
|
+
const description = draft.description.trim();
|
|
203
|
+
const body = draft.body.replace(/\r\n/g, "\n").trim();
|
|
204
|
+
const lint = this.lint({ ...draft, name, description, body });
|
|
205
|
+
if (lint.errors.length)
|
|
206
|
+
throw new Error(lint.errors.map((e) => e.message).join("; "));
|
|
207
|
+
const existingFolder = this.folderFor(name);
|
|
208
|
+
const existing = existingFolder ? this.load(existingFolder) : undefined;
|
|
209
|
+
const folder = existingFolder ?? name;
|
|
210
|
+
const dir = join(this.dir, folder);
|
|
211
|
+
mkdirSync(dir, { recursive: true });
|
|
212
|
+
const author = draft.author ?? existing?.author ?? HUMAN_AUTHOR;
|
|
213
|
+
const created = existing?.created || new Date().toISOString();
|
|
214
|
+
const reviewed = draft.reviewed ?? (author === HUMAN_AUTHOR || author === BUILTIN_AUTHOR ? true : (existing?.reviewed ?? false));
|
|
215
|
+
const isDraft = draft.draft ?? (existing?.draft ?? false);
|
|
216
|
+
const text = `${renderFrontmatter({
|
|
217
|
+
name,
|
|
218
|
+
description,
|
|
219
|
+
"argument-hint": draft.argumentHint?.trim() || undefined,
|
|
220
|
+
"user-invocable": draft.userInvocable === false ? false : undefined,
|
|
221
|
+
"disable-agent-invocation": draft.agentInvocable === false ? true : undefined,
|
|
222
|
+
author: author === HUMAN_AUTHOR ? undefined : author,
|
|
223
|
+
created,
|
|
224
|
+
reviewed: reviewed ? undefined : false,
|
|
225
|
+
draft: isDraft ? true : undefined,
|
|
226
|
+
})}\n\n${body}\n`;
|
|
227
|
+
writeFileSync(join(dir, SKILL_FILE), text);
|
|
228
|
+
this.cache.delete(folder);
|
|
229
|
+
const skill = this.load(folder);
|
|
230
|
+
if (!skill)
|
|
231
|
+
throw new Error("the skill could not be read back");
|
|
232
|
+
this.log.info(`skill "${name}" saved (${skill.file}; author ${author}${isDraft ? "; draft" : ""})`);
|
|
233
|
+
return skill;
|
|
234
|
+
}
|
|
235
|
+
approve(name) {
|
|
236
|
+
const skill = this.get(name);
|
|
237
|
+
if (!skill)
|
|
238
|
+
throw new Error(`no such skill: ${name}`);
|
|
239
|
+
return this.save({
|
|
240
|
+
name: skill.name,
|
|
241
|
+
description: skill.description,
|
|
242
|
+
argumentHint: skill.argumentHint,
|
|
243
|
+
body: skill.body,
|
|
244
|
+
userInvocable: skill.userInvocable,
|
|
245
|
+
agentInvocable: skill.agentInvocable,
|
|
246
|
+
author: skill.author,
|
|
247
|
+
reviewed: true,
|
|
248
|
+
draft: false,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
seedBuiltins() {
|
|
252
|
+
for (const builtin of [SKILL_WRITER]) {
|
|
253
|
+
const folder = this.folderFor(builtin.name);
|
|
254
|
+
const current = folder ? this.load(folder) : undefined;
|
|
255
|
+
if (current && current.description === builtin.description && current.body === builtin.body && (current.argumentHint ?? "") === (builtin.argumentHint ?? ""))
|
|
256
|
+
continue;
|
|
257
|
+
this.save(builtin);
|
|
258
|
+
if (current)
|
|
259
|
+
this.log.info(`built-in skill "${builtin.name}" updated to the shipped text`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
remove(name) {
|
|
263
|
+
if (isBuiltinSkill(name))
|
|
264
|
+
throw new Error(`${name.trim()} is built into viberoom and cannot be deleted; detach it from a vibemate if it should not use it`);
|
|
265
|
+
const folder = this.folderFor(name);
|
|
266
|
+
if (!folder)
|
|
267
|
+
throw new Error(`no such skill: ${name}`);
|
|
268
|
+
rmSync(join(this.dir, folder), { recursive: true, force: true });
|
|
269
|
+
this.cache.delete(folder);
|
|
270
|
+
this.log.info(`skill "${name}" removed`);
|
|
271
|
+
}
|
|
272
|
+
folderFor(name) {
|
|
273
|
+
if (existsSync(join(this.dir, name, SKILL_FILE)))
|
|
274
|
+
return name;
|
|
275
|
+
const lower = name.toLowerCase();
|
|
276
|
+
try {
|
|
277
|
+
for (const d of readdirSync(this.dir, { withFileTypes: true })) {
|
|
278
|
+
if (d.isDirectory() && d.name.toLowerCase() === lower && existsSync(join(this.dir, d.name, SKILL_FILE)))
|
|
279
|
+
return d.name;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
load(folder) {
|
|
287
|
+
const dir = join(this.dir, folder);
|
|
288
|
+
const file = join(dir, SKILL_FILE);
|
|
289
|
+
let mtime;
|
|
290
|
+
try {
|
|
291
|
+
mtime = statSync(file).mtimeMs;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const cached = this.cache.get(folder);
|
|
297
|
+
if (cached && cached.mtime === mtime) {
|
|
298
|
+
cached.skill.extraFiles = listExtraFiles(dir);
|
|
299
|
+
return cached.skill;
|
|
300
|
+
}
|
|
301
|
+
let text;
|
|
302
|
+
try {
|
|
303
|
+
text = readFileSync(file, "utf8");
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
this.log.warn(`skill ${folder}: unreadable (${String(error)})`);
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
const { meta, body } = parseFrontmatter(text);
|
|
310
|
+
const name = (meta.name ?? folder).trim();
|
|
311
|
+
const description = (meta.description ?? "").trim();
|
|
312
|
+
const argumentHint = (meta["argument-hint"] ?? "").trim();
|
|
313
|
+
const lint = lintSkill({
|
|
314
|
+
name,
|
|
315
|
+
folder,
|
|
316
|
+
description,
|
|
317
|
+
argumentHint,
|
|
318
|
+
body,
|
|
319
|
+
unknownFields: Object.keys(meta).filter((k) => !KNOWN_FIELDS.has(k)),
|
|
320
|
+
});
|
|
321
|
+
const extraFiles = listExtraFiles(dir);
|
|
322
|
+
const author = (meta.author ?? "").trim() || HUMAN_AUTHOR;
|
|
323
|
+
const skill = {
|
|
324
|
+
name,
|
|
325
|
+
description: description.slice(0, DESCRIPTION_MAX),
|
|
326
|
+
argumentHint,
|
|
327
|
+
userInvocable: parseBool(meta["user-invocable"], true),
|
|
328
|
+
agentInvocable: !parseBool(meta["disable-agent-invocation"], false),
|
|
329
|
+
author,
|
|
330
|
+
created: (meta.created ?? "").trim(),
|
|
331
|
+
reviewed: parseBool(meta.reviewed, true),
|
|
332
|
+
draft: parseBool(meta.draft, false),
|
|
333
|
+
dir,
|
|
334
|
+
file,
|
|
335
|
+
extraFiles,
|
|
336
|
+
mtime,
|
|
337
|
+
problems: lint.errors.map((e) => e.message),
|
|
338
|
+
warnings: lint.warnings.map((w) => w.message),
|
|
339
|
+
body: body.replace(/\r\n/g, "\n").trim(),
|
|
340
|
+
};
|
|
341
|
+
this.cache.set(folder, { mtime, skill });
|
|
342
|
+
return skill;
|
|
343
|
+
}
|
|
344
|
+
}
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const MENU = [
|
|
2
|
+
{ id: "shortcut", label: "Install the desktop icon", hint: "Start Menu and Desktop entry that opens the app window" },
|
|
3
|
+
{ id: "window", label: "Open in the app window", hint: "the hub in the background, a Chromium window of its own" },
|
|
4
|
+
{ id: "browser", label: "Open in your browser", hint: "the hub in the background, a tab in your default browser" },
|
|
5
|
+
{ id: "terminal", label: "Run here, in this terminal", hint: "the hub in the foreground with its log; Ctrl+C stops it" },
|
|
6
|
+
{ id: "quit", label: "Quit", hint: "" },
|
|
7
|
+
];
|
|
8
|
+
export const QUESTION = "What would you like to do?";
|
|
9
|
+
export function decodeKey(data) {
|
|
10
|
+
const s = data.toString();
|
|
11
|
+
if (s === "\x1b[A" || s === "\x1bOA" || s === "k")
|
|
12
|
+
return { key: "up" };
|
|
13
|
+
if (s === "\x1b[B" || s === "\x1bOB" || s === "j")
|
|
14
|
+
return { key: "down" };
|
|
15
|
+
if (s === "\r" || s === "\n" || s === " ")
|
|
16
|
+
return { key: "enter" };
|
|
17
|
+
if (s === "\x1b" || s === "q" || s === "\x03" || s === "\x04")
|
|
18
|
+
return { key: "escape" };
|
|
19
|
+
if (/^[1-9]$/.test(s))
|
|
20
|
+
return { key: "digit", digit: Number(s) };
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export function reduceMenu(state, key, digit, items = MENU) {
|
|
24
|
+
const n = items.length;
|
|
25
|
+
switch (key) {
|
|
26
|
+
case "up":
|
|
27
|
+
return { index: (state.index - 1 + n) % n };
|
|
28
|
+
case "down":
|
|
29
|
+
return { index: (state.index + 1) % n };
|
|
30
|
+
case "enter":
|
|
31
|
+
return { index: state.index, done: items[state.index].id };
|
|
32
|
+
case "escape":
|
|
33
|
+
return { index: state.index, done: "quit" };
|
|
34
|
+
case "digit":
|
|
35
|
+
if (digit === undefined || digit < 1 || digit > n)
|
|
36
|
+
return state;
|
|
37
|
+
return { index: digit - 1, done: items[digit - 1].id };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function unicodeSupported(env = process.env, platform = process.platform) {
|
|
41
|
+
if (platform !== "win32")
|
|
42
|
+
return env.TERM !== "linux";
|
|
43
|
+
return Boolean(env.WT_SESSION || env.TERMINUS_SUBLIME || env.ConEmuTask === "{cmd::Cmder}" || env.TERM_PROGRAM === "Terminus-Sublime" || env.TERM_PROGRAM === "vscode" || env.TERM === "xterm-256color" || env.TERM === "alacritty" || env.TERMINAL_EMULATOR === "JetBrains-JediTerm");
|
|
44
|
+
}
|
|
45
|
+
const GLYPHS = {
|
|
46
|
+
unicode: { top: "┌", bar: "│", bottom: "└", active: "◆", done: "◇", on: "●", off: "○" },
|
|
47
|
+
ascii: { top: "+", bar: "|", bottom: "+", active: "*", done: "o", on: "(*)", off: "( )" },
|
|
48
|
+
};
|
|
49
|
+
const ESC = "\x1b[";
|
|
50
|
+
const paint = (on, code, text) => (on ? `${ESC}${code}m${text}${ESC}0m` : text);
|
|
51
|
+
export function renderMenu(state, title, opts, items = MENU) {
|
|
52
|
+
const g = opts.unicode ? GLYPHS.unicode : GLYPHS.ascii;
|
|
53
|
+
const dim = (t) => paint(opts.color, "2", t);
|
|
54
|
+
const columns = Math.max(40, opts.columns ?? 80);
|
|
55
|
+
const lines = [`${dim(g.top)} ${paint(opts.color, "1", title)}`, dim(g.bar), `${paint(opts.color, "36", g.active)} ${QUESTION}`];
|
|
56
|
+
items.forEach((item, i) => {
|
|
57
|
+
const current = i === state.index;
|
|
58
|
+
const dot = current ? paint(opts.color, "32", g.on) : dim(g.off);
|
|
59
|
+
const label = current ? paint(opts.color, "1", item.label) : item.label;
|
|
60
|
+
lines.push(`${paint(opts.color, "36", g.bar)} ${dot} ${label}`);
|
|
61
|
+
});
|
|
62
|
+
const hint = items[state.index].hint || "Enter to choose, 1-5 to jump, q to quit";
|
|
63
|
+
lines.push(paint(opts.color, "36", g.bar), `${paint(opts.color, "36", g.bottom)} ${dim(hint.slice(0, columns - 4))}`);
|
|
64
|
+
return lines.join("\n") + "\n";
|
|
65
|
+
}
|
|
66
|
+
export function renderDone(choice, title, opts, items = MENU) {
|
|
67
|
+
const g = opts.unicode ? GLYPHS.unicode : GLYPHS.ascii;
|
|
68
|
+
const dim = (t) => paint(opts.color, "2", t);
|
|
69
|
+
const label = items.find((i) => i.id === choice)?.label ?? choice;
|
|
70
|
+
return [`${dim(g.top)} ${paint(opts.color, "1", title)}`, dim(g.bar), `${paint(opts.color, "32", g.done)} ${QUESTION}`, `${dim(g.bar)} ${dim(label)}`, dim(g.bar), ""].join("\n");
|
|
71
|
+
}
|
|
72
|
+
export function menuLineCount(items = MENU) {
|
|
73
|
+
return items.length + 5;
|
|
74
|
+
}
|
|
75
|
+
export function runMenu(title, stdin = process.stdin, stdout = process.stdout) {
|
|
76
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
77
|
+
return Promise.resolve(null);
|
|
78
|
+
const opts = { color: !process.env.NO_COLOR, unicode: unicodeSupported(), columns: stdout.columns };
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
let state = { index: 0 };
|
|
81
|
+
const clear = () => {
|
|
82
|
+
stdout.write(`${ESC}${menuLineCount()}A${ESC}0J`);
|
|
83
|
+
};
|
|
84
|
+
const finish = (choice) => {
|
|
85
|
+
stdin.off("data", onData);
|
|
86
|
+
stdin.setRawMode(false);
|
|
87
|
+
stdin.pause();
|
|
88
|
+
clear();
|
|
89
|
+
stdout.write(renderDone(choice, title, opts));
|
|
90
|
+
resolve(choice);
|
|
91
|
+
};
|
|
92
|
+
const onData = (data) => {
|
|
93
|
+
const decoded = decodeKey(data);
|
|
94
|
+
if (!decoded)
|
|
95
|
+
return;
|
|
96
|
+
state = reduceMenu(state, decoded.key, decoded.digit);
|
|
97
|
+
if (state.done)
|
|
98
|
+
finish(state.done);
|
|
99
|
+
else {
|
|
100
|
+
clear();
|
|
101
|
+
stdout.write(renderMenu(state, title, { ...opts, columns: stdout.columns }));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
stdin.setRawMode(true);
|
|
105
|
+
stdin.resume();
|
|
106
|
+
stdin.on("data", onData);
|
|
107
|
+
stdout.write(renderMenu(state, title, opts));
|
|
108
|
+
});
|
|
109
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "viberoom",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"viberoom": "dist/main.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json",
|
|
14
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
15
|
+
"start": "node dist/main.js",
|
|
16
|
+
"dev": "npm run build && node dist/main.js --open",
|
|
17
|
+
"install:global": "node scripts/install.mjs",
|
|
18
|
+
"update": "node scripts/update.mjs",
|
|
19
|
+
"start:bg": "node dist/main.js start",
|
|
20
|
+
"icons": "node scripts/render-icon.mjs",
|
|
21
|
+
"prepack": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"ui",
|
|
26
|
+
"assets",
|
|
27
|
+
"scripts",
|
|
28
|
+
"NOTICE"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@agentclientprotocol/claude-agent-acp": "0.73.0",
|
|
32
|
+
"@agentclientprotocol/codex-acp": "^1.8.0",
|
|
33
|
+
"mermaid": "^11.17.2"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.0.0",
|
|
37
|
+
"typescript": "^5.9.0"
|
|
38
|
+
},
|
|
39
|
+
"license": "AGPL-3.0-or-later",
|
|
40
|
+
"author": "Todor Rusev <todor.rosenov.rusev@gmail.com>",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"agents",
|
|
43
|
+
"acp",
|
|
44
|
+
"agent-client-protocol",
|
|
45
|
+
"chat",
|
|
46
|
+
"claude",
|
|
47
|
+
"codex",
|
|
48
|
+
"gemini",
|
|
49
|
+
"cursor",
|
|
50
|
+
"opencode",
|
|
51
|
+
"copilot"
|
|
52
|
+
],
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/todor-rusev/viberoom.git"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/todor-rusev/viberoom#readme",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/todor-rusev/viberoom/issues"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
10
|
+
const args = new Set(process.argv.slice(2));
|
|
11
|
+
const dataDir = process.env.VIBEROOM_DATA_DIR ? resolve(process.env.VIBEROOM_DATA_DIR) : join(homedir(), ".viberoom");
|
|
12
|
+
const isWin = process.platform === "win32";
|
|
13
|
+
|
|
14
|
+
function run(cmd, cmdArgs, opts = {}) {
|
|
15
|
+
console.log(`> ${cmd} ${cmdArgs.join(" ")}`);
|
|
16
|
+
const r = isWin && cmd === "npm" ? spawnSync(`npm ${cmdArgs.join(" ")}`, { cwd: root, stdio: "inherit", shell: true, ...opts }) : spawnSync(cmd, cmdArgs, { cwd: root, stdio: "inherit", ...opts });
|
|
17
|
+
if (r.status !== 0) throw new Error(`${cmd} ${cmdArgs.join(" ")} failed with exit code ${r.status}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
run("npm", [args.has("--clean") && existsSync(join(root, "package-lock.json")) ? "ci" : "install", "--no-audit", "--no-fund"]);
|
|
21
|
+
run("npm", ["run", "build"]);
|
|
22
|
+
|
|
23
|
+
if (args.has("--global")) run("npm", ["install", "-g", ".", "--no-audit", "--no-fund"]);
|
|
24
|
+
else run("npm", ["link", "--no-audit", "--no-fund"]);
|
|
25
|
+
|
|
26
|
+
if (!args.has("--no-shortcuts")) {
|
|
27
|
+
const { installShortcuts } = await import(pathToFileURL(join(root, "dist", "shortcuts.js")).href);
|
|
28
|
+
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
29
|
+
const result = installShortcuts({ root, dataDir, node: process.execPath, version, desktop: args.has("--desktop") });
|
|
30
|
+
for (const file of result.files) console.log(`wrote: ${file}`);
|
|
31
|
+
for (const note of result.notes) console.log(note);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log("\nviberoom is installed. Try: viberoom (a small menu), viberoom start (hidden hub + app window), viberoom status, viberoom stop");
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
9
|
+
const { findChromium } = await import(pathToFileURL(join(root, "dist", "launcher.js")).href);
|
|
10
|
+
const { packIcns, packIco } = await import(pathToFileURL(join(root, "dist", "icons.js")).href);
|
|
11
|
+
|
|
12
|
+
const chrome = process.env.CHROME || findChromium();
|
|
13
|
+
if (!chrome) throw new Error("no Chromium browser found; set CHROME=<path to chrome.exe>");
|
|
14
|
+
const master = join(root, "assets", "icon-master.png");
|
|
15
|
+
const hasMaster = existsSync(master);
|
|
16
|
+
const svg = hasMaster ? "" : readFileSync(join(root, "assets", "icon-vector.svg"), "utf8");
|
|
17
|
+
const sizes = [16, 32, 48, 64, 128, 256, 512];
|
|
18
|
+
const port = 9360 + Math.floor(Math.random() * 30);
|
|
19
|
+
const profile = join(tmpdir(), `viberoom-icon-${process.pid}`);
|
|
20
|
+
mkdirSync(profile, { recursive: true });
|
|
21
|
+
const browser = spawn(chrome, ["--headless=new", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "--window-size=600,600", "about:blank"], { stdio: "ignore" });
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
|
|
24
|
+
async function targets() {
|
|
25
|
+
for (let i = 0; i < 40; i++) {
|
|
26
|
+
try {
|
|
27
|
+
return await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
|
28
|
+
} catch {
|
|
29
|
+
await sleep(250);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error("chrome did not start");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const page = (await targets()).find((t) => t.type === "page");
|
|
37
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
38
|
+
await new Promise((r) => (ws.onopen = r));
|
|
39
|
+
let nextId = 1;
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
ws.onmessage = (ev) => {
|
|
42
|
+
const msg = JSON.parse(ev.data);
|
|
43
|
+
if (msg.id && pending.has(msg.id)) {
|
|
44
|
+
pending.get(msg.id)(msg);
|
|
45
|
+
pending.delete(msg.id);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const send = (method, params = {}) =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
const id = nextId++;
|
|
51
|
+
pending.set(id, resolve);
|
|
52
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
53
|
+
});
|
|
54
|
+
await send("Page.enable");
|
|
55
|
+
await send("Emulation.setDefaultBackgroundColorOverride", { color: { r: 0, g: 0, b: 0, a: 0 } });
|
|
56
|
+
const icons = [];
|
|
57
|
+
await send("Emulation.setDeviceMetricsOverride", { width: 600, height: 600, deviceScaleFactor: 1, mobile: false });
|
|
58
|
+
const pageFile = join(profile, "render.html");
|
|
59
|
+
for (const size of sizes) {
|
|
60
|
+
const body = hasMaster
|
|
61
|
+
? `<img src="${pathToFileURL(master).href}" style="display:block;width:${size}px;height:${size}px;border-radius:${(size * 0.21).toFixed(2)}px">`
|
|
62
|
+
: svg.replace(/width="256" height="256"/, `width="${size}" height="${size}"`);
|
|
63
|
+
const html = `<!doctype html><html><head><style>html,body{margin:0;background:transparent;overflow:hidden}svg,img{display:block}</style></head><body>${body}</body></html>`;
|
|
64
|
+
writeFileSync(pageFile, html);
|
|
65
|
+
await send("Page.navigate", { url: pathToFileURL(pageFile).href });
|
|
66
|
+
await sleep(hasMaster ? 700 : 300);
|
|
67
|
+
const shot = await send("Page.captureScreenshot", { format: "png", clip: { x: 0, y: 0, width: size, height: size, scale: 1 }, fromSurface: true });
|
|
68
|
+
const data = Buffer.from(shot.result.data, "base64");
|
|
69
|
+
writeFileSync(join(root, "assets", `icon-${size}.png`), data);
|
|
70
|
+
icons.push({ size, data });
|
|
71
|
+
console.log(`icon-${size}.png ${data.length} bytes`);
|
|
72
|
+
}
|
|
73
|
+
writeFileSync(join(root, "assets", "icon.ico"), packIco(icons.filter((i) => i.size <= 256)));
|
|
74
|
+
writeFileSync(join(root, "assets", "icon.icns"), packIcns(icons));
|
|
75
|
+
const svgOut = hasMaster
|
|
76
|
+
? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><image href="data:image/png;base64,${icons.find((i) => i.size === 256).data.toString("base64")}" width="256" height="256"/></svg>\n`
|
|
77
|
+
: svg;
|
|
78
|
+
writeFileSync(join(root, "assets", "icon.svg"), svgOut);
|
|
79
|
+
console.log("icon.ico and icon.icns written");
|
|
80
|
+
ws.close();
|
|
81
|
+
} finally {
|
|
82
|
+
if (process.platform === "win32") spawnSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like '*viberoom-icon-${process.pid}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`], { stdio: "ignore" });
|
|
83
|
+
else browser.kill();
|
|
84
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
|
+
const args = new Set(process.argv.slice(2));
|
|
10
|
+
const isWin = process.platform === "win32";
|
|
11
|
+
const main = join(root, "dist", "main.js");
|
|
12
|
+
|
|
13
|
+
function run(cmd, cmdArgs, opts = {}) {
|
|
14
|
+
console.log(`> ${cmd} ${cmdArgs.join(" ")}`);
|
|
15
|
+
const r = isWin && cmd === "npm" ? spawnSync(`npm ${cmdArgs.join(" ")}`, { cwd: root, stdio: "inherit", shell: true, ...opts }) : spawnSync(cmd, cmdArgs, { cwd: root, stdio: "inherit", ...opts });
|
|
16
|
+
if (r.status !== 0) throw new Error(`${cmd} ${cmdArgs.join(" ")} failed with exit code ${r.status}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
run("npm", [args.has("--clean") && existsSync(join(root, "package-lock.json")) ? "ci" : "install", "--no-audit", "--no-fund"]);
|
|
20
|
+
run("npm", ["run", "build"]);
|
|
21
|
+
|
|
22
|
+
if (!args.has("--no-restart")) {
|
|
23
|
+
const status = spawnSync(process.execPath, [main, "status"], { encoding: "utf8" });
|
|
24
|
+
if (/running/.test(status.stdout || "")) {
|
|
25
|
+
console.log("a hub is running: replacing it with the new build (background, no window)");
|
|
26
|
+
run(process.execPath, [main, "start", "--no-open"]);
|
|
27
|
+
} else console.log("no hub running; start one with: viberoom start");
|
|
28
|
+
}
|
|
29
|
+
console.log("\nviberoom is up to date.");
|