turnbell 1.0.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 +301 -0
- package/dist/index.js +4673 -0
- package/package.json +67 -0
- package/scripts/helper/play-then-say.mjs +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4673 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { spawn as spawn2 } from "child_process";
|
|
5
|
+
import { realpathSync } from "fs";
|
|
6
|
+
import { basename as basename4 } from "path";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
8
|
+
|
|
9
|
+
// src/adapters/amp.ts
|
|
10
|
+
import { statSync as statSync2 } from "fs";
|
|
11
|
+
import { join as join3 } from "path";
|
|
12
|
+
|
|
13
|
+
// src/core/events.ts
|
|
14
|
+
import { basename } from "path";
|
|
15
|
+
var TRAILING_SEPARATORS = /[/\\]+$/;
|
|
16
|
+
var WORD_SEPARATORS = /[-_]/g;
|
|
17
|
+
var CODE_BLOCK = /```[\s\S]*?```/g;
|
|
18
|
+
var BACKTICK = /`/g;
|
|
19
|
+
var WHITESPACE_RUN = /\s+/g;
|
|
20
|
+
var CODE_BLOCK_PLACEHOLDER = " code block omitted. ";
|
|
21
|
+
function projectLabel(cwd) {
|
|
22
|
+
const trimmed = cwd.replace(TRAILING_SEPARATORS, "");
|
|
23
|
+
const base = basename(trimmed) || cwd;
|
|
24
|
+
return base.replace(WORD_SEPARATORS, " ");
|
|
25
|
+
}
|
|
26
|
+
function prepareSpeechText(text2, maxChars, stripCode) {
|
|
27
|
+
let out = text2;
|
|
28
|
+
if (stripCode) {
|
|
29
|
+
out = out.replace(CODE_BLOCK, CODE_BLOCK_PLACEHOLDER).replace(BACKTICK, "");
|
|
30
|
+
}
|
|
31
|
+
return out.replace(WHITESPACE_RUN, " ").trim().slice(0, maxChars);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/adapters/diffwire.ts
|
|
35
|
+
import {
|
|
36
|
+
mkdirSync as mkdirSync2,
|
|
37
|
+
readFileSync as readFileSync2,
|
|
38
|
+
renameSync as renameSync2,
|
|
39
|
+
rmSync,
|
|
40
|
+
writeFileSync as writeFileSync2
|
|
41
|
+
} from "fs";
|
|
42
|
+
import { basename as basename2, dirname, join as join2 } from "path";
|
|
43
|
+
|
|
44
|
+
// src/core/config.ts
|
|
45
|
+
import {
|
|
46
|
+
existsSync,
|
|
47
|
+
mkdirSync,
|
|
48
|
+
readFileSync,
|
|
49
|
+
readdirSync,
|
|
50
|
+
renameSync,
|
|
51
|
+
statSync,
|
|
52
|
+
writeFileSync
|
|
53
|
+
} from "fs";
|
|
54
|
+
import { homedir } from "os";
|
|
55
|
+
import { join } from "path";
|
|
56
|
+
var SCHEMA_VERSION = 2;
|
|
57
|
+
var DEFAULT_RATE_WPM = 190;
|
|
58
|
+
var DEFAULT_MAX_CHARS = 1200;
|
|
59
|
+
var DEFAULTS = {
|
|
60
|
+
version: SCHEMA_VERSION,
|
|
61
|
+
activation: "all",
|
|
62
|
+
events: {
|
|
63
|
+
done: { mode: "announce" },
|
|
64
|
+
blocked: { mode: "announce" },
|
|
65
|
+
error: { mode: "notify" }
|
|
66
|
+
},
|
|
67
|
+
voice: { name: null, rateWpm: DEFAULT_RATE_WPM },
|
|
68
|
+
notify: { desktop: true, sound: null },
|
|
69
|
+
readaloud: { maxChars: DEFAULT_MAX_CHARS, stripCode: true, openCommand: "" },
|
|
70
|
+
quietHours: null,
|
|
71
|
+
quietHoursWebhooks: "fire",
|
|
72
|
+
webhooks: [],
|
|
73
|
+
allowHttp: false
|
|
74
|
+
};
|
|
75
|
+
var NESTED_KEYS = /* @__PURE__ */ new Set([
|
|
76
|
+
"events",
|
|
77
|
+
"voice",
|
|
78
|
+
"notify",
|
|
79
|
+
"readaloud"
|
|
80
|
+
]);
|
|
81
|
+
var NON_ALNUM = /[^A-Za-z0-9]/g;
|
|
82
|
+
var HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
83
|
+
var MINUTES_PER_HOUR = 60;
|
|
84
|
+
var ENABLED_SUFFIX = ".enabled";
|
|
85
|
+
var QUIET_UNTIL_FILE = "quiet-until";
|
|
86
|
+
var QUIET_INDEFINITE = "indefinite";
|
|
87
|
+
var INTEGER_RE = /^-?\d+$/;
|
|
88
|
+
function turnbellHome() {
|
|
89
|
+
const override = process.env.TURNBELL_HOME ?? process.env.HOLLR_HOME;
|
|
90
|
+
if (override !== void 0 && override.length > 0) {
|
|
91
|
+
return override;
|
|
92
|
+
}
|
|
93
|
+
return join(homedir(), ".config", "turnbell");
|
|
94
|
+
}
|
|
95
|
+
function migrateLegacyHome() {
|
|
96
|
+
const override = process.env.TURNBELL_HOME ?? process.env.HOLLR_HOME;
|
|
97
|
+
if (override !== void 0 && override.length > 0) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const legacy = join(homedir(), ".config", "hollr");
|
|
101
|
+
const current = join(homedir(), ".config", "turnbell");
|
|
102
|
+
if (!existsSync(legacy) || existsSync(current)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
renameSync(legacy, current);
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function encodeCwd(cwd) {
|
|
111
|
+
return cwd.replace(NON_ALNUM, "-");
|
|
112
|
+
}
|
|
113
|
+
function defaultOpenCommand(platformId = process.platform) {
|
|
114
|
+
if (platformId === "darwin") {
|
|
115
|
+
return "open";
|
|
116
|
+
}
|
|
117
|
+
if (platformId === "win32") {
|
|
118
|
+
return "start";
|
|
119
|
+
}
|
|
120
|
+
return "xdg-open";
|
|
121
|
+
}
|
|
122
|
+
function globalConfigPath() {
|
|
123
|
+
return join(turnbellHome(), "config.json");
|
|
124
|
+
}
|
|
125
|
+
function projectConfigPath(cwd) {
|
|
126
|
+
return join(turnbellHome(), "projects", `${encodeCwd(cwd)}.json`);
|
|
127
|
+
}
|
|
128
|
+
function muteFlagPath(cwd) {
|
|
129
|
+
return join(turnbellHome(), "projects", `${encodeCwd(cwd)}.muted`);
|
|
130
|
+
}
|
|
131
|
+
function isPlainObject(value) {
|
|
132
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
function isFile(path) {
|
|
135
|
+
try {
|
|
136
|
+
return statSync(path).isFile();
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function readJsonObject(path) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
144
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
145
|
+
} catch {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function mergeConfig(base, override) {
|
|
150
|
+
const merged = { ...base };
|
|
151
|
+
for (const [key, value] of Object.entries(override)) {
|
|
152
|
+
const current = merged[key];
|
|
153
|
+
if (NESTED_KEYS.has(key) && isPlainObject(value) && isPlainObject(current)) {
|
|
154
|
+
merged[key] = { ...current, ...value };
|
|
155
|
+
} else {
|
|
156
|
+
merged[key] = value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return merged;
|
|
160
|
+
}
|
|
161
|
+
function loadConfig(cwd) {
|
|
162
|
+
const base = structuredClone(DEFAULTS);
|
|
163
|
+
const withGlobal = mergeConfig(base, readJsonObject(globalConfigPath()));
|
|
164
|
+
const merged = mergeConfig(withGlobal, readJsonObject(projectConfigPath(cwd)));
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
|
167
|
+
function isConfigured(cwd) {
|
|
168
|
+
return isFile(globalConfigPath()) || isFile(projectConfigPath(cwd));
|
|
169
|
+
}
|
|
170
|
+
function isMuted(cwd) {
|
|
171
|
+
return isFile(muteFlagPath(cwd));
|
|
172
|
+
}
|
|
173
|
+
function projectEnabledFlagPath(cwd) {
|
|
174
|
+
return join(turnbellHome(), "projects", `${encodeCwd(cwd)}${ENABLED_SUFFIX}`);
|
|
175
|
+
}
|
|
176
|
+
function isProjectEnabled(cwd) {
|
|
177
|
+
return isFile(projectEnabledFlagPath(cwd));
|
|
178
|
+
}
|
|
179
|
+
function quietUntilPath() {
|
|
180
|
+
return join(turnbellHome(), QUIET_UNTIL_FILE);
|
|
181
|
+
}
|
|
182
|
+
function quietActive(now) {
|
|
183
|
+
let raw;
|
|
184
|
+
try {
|
|
185
|
+
raw = readFileSync(quietUntilPath(), "utf8").trim();
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (raw === QUIET_INDEFINITE) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (!INTEGER_RE.test(raw)) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return now.getTime() < Number.parseInt(raw, 10);
|
|
196
|
+
}
|
|
197
|
+
function toMinutes(part) {
|
|
198
|
+
const match = HHMM.exec(part);
|
|
199
|
+
if (match === null) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const hours = match[1];
|
|
203
|
+
const minutes = match[2];
|
|
204
|
+
if (hours === void 0 || minutes === void 0) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return Number(hours) * MINUTES_PER_HOUR + Number(minutes);
|
|
208
|
+
}
|
|
209
|
+
function inQuietHours(spec, now) {
|
|
210
|
+
if (spec === null || !spec.includes("-")) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const separator = spec.indexOf("-");
|
|
214
|
+
const start = toMinutes(spec.slice(0, separator));
|
|
215
|
+
const end = toMinutes(spec.slice(separator + 1));
|
|
216
|
+
if (start === null || end === null) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
const current = now.getHours() * MINUTES_PER_HOUR + now.getMinutes();
|
|
220
|
+
if (start <= end) {
|
|
221
|
+
return start <= current && current < end;
|
|
222
|
+
}
|
|
223
|
+
return current >= start || current < end;
|
|
224
|
+
}
|
|
225
|
+
var V1_CONFIG_RELATIVE = [".claude", "hollr", "config.json"];
|
|
226
|
+
function v2ConfigExists() {
|
|
227
|
+
if (isFile(globalConfigPath())) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
return readdirSync(join(turnbellHome(), "projects")).some(
|
|
232
|
+
(entry) => entry.endsWith(".json")
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function isMode(value) {
|
|
239
|
+
return value === "announce" || value === "readaloud" || value === "notify" || value === "silent";
|
|
240
|
+
}
|
|
241
|
+
function applyEventMode(config, target, source) {
|
|
242
|
+
if (isPlainObject(source) && isMode(source.mode)) {
|
|
243
|
+
config.events[target] = { mode: source.mode };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function buildV2FromV1(v1) {
|
|
247
|
+
const config = structuredClone(DEFAULTS);
|
|
248
|
+
const voice = v1.voice;
|
|
249
|
+
if (isPlainObject(voice)) {
|
|
250
|
+
if (typeof voice.name === "string" || voice.name === null) {
|
|
251
|
+
config.voice.name = voice.name;
|
|
252
|
+
}
|
|
253
|
+
if (typeof voice.rate_wpm === "number") {
|
|
254
|
+
config.voice.rateWpm = voice.rate_wpm;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const readaloud = v1.readaloud;
|
|
258
|
+
if (isPlainObject(readaloud)) {
|
|
259
|
+
if (typeof readaloud.max_chars === "number") {
|
|
260
|
+
config.readaloud.maxChars = readaloud.max_chars;
|
|
261
|
+
}
|
|
262
|
+
if (typeof readaloud.strip_code === "boolean") {
|
|
263
|
+
config.readaloud.stripCode = readaloud.strip_code;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (typeof v1.quiet_hours === "string" || v1.quiet_hours === null) {
|
|
267
|
+
config.quietHours = v1.quiet_hours;
|
|
268
|
+
}
|
|
269
|
+
const events = v1.events;
|
|
270
|
+
if (isPlainObject(events)) {
|
|
271
|
+
applyEventMode(config, "done", events.done);
|
|
272
|
+
applyEventMode(config, "blocked", events.needs_input);
|
|
273
|
+
}
|
|
274
|
+
return config;
|
|
275
|
+
}
|
|
276
|
+
function migrateV1() {
|
|
277
|
+
if (v2ConfigExists()) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const v1 = readJsonObject(join(homedir(), ...V1_CONFIG_RELATIVE));
|
|
281
|
+
if (Object.keys(v1).length === 0) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const home = turnbellHome();
|
|
285
|
+
try {
|
|
286
|
+
mkdirSync(home, { recursive: true });
|
|
287
|
+
writeFileSync(
|
|
288
|
+
join(home, "config.json"),
|
|
289
|
+
`${JSON.stringify(buildV2FromV1(v1), null, 2)}
|
|
290
|
+
`,
|
|
291
|
+
"utf8"
|
|
292
|
+
);
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
var HTTP_SCHEME = "http:";
|
|
299
|
+
function urlScheme(url) {
|
|
300
|
+
try {
|
|
301
|
+
return new URL(url).protocol;
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function migrateHttpOptIn(config) {
|
|
307
|
+
if (config.allowHttp !== true) {
|
|
308
|
+
return { config, changed: false };
|
|
309
|
+
}
|
|
310
|
+
const next = structuredClone(config);
|
|
311
|
+
next.allowHttp = false;
|
|
312
|
+
if (Array.isArray(next.webhooks)) {
|
|
313
|
+
for (const target of next.webhooks) {
|
|
314
|
+
if (target.allowHttp === void 0 && urlScheme(target.url) === HTTP_SCHEME) {
|
|
315
|
+
target.allowHttp = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { config: next, changed: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/adapters/diffwire.ts
|
|
323
|
+
var JSON_INDENT = 2;
|
|
324
|
+
var TRAILING_NEWLINE = "\n";
|
|
325
|
+
var LEDGER_FILE = "wired.json";
|
|
326
|
+
var LEDGER_MODE = 384;
|
|
327
|
+
var DIFF_CONTEXT_LINES = 3;
|
|
328
|
+
var REMOVED_MARK = "-";
|
|
329
|
+
var ADDED_MARK = "+";
|
|
330
|
+
var CONTEXT_MARK = " ";
|
|
331
|
+
function isPlainObject2(value) {
|
|
332
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
333
|
+
}
|
|
334
|
+
function parseJsonObject(raw) {
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(raw);
|
|
337
|
+
return isPlainObject2(parsed) ? parsed : {};
|
|
338
|
+
} catch {
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function serializeJson(value) {
|
|
343
|
+
return `${JSON.stringify(value, null, JSON_INDENT)}${TRAILING_NEWLINE}`;
|
|
344
|
+
}
|
|
345
|
+
function readFileOrNull(path) {
|
|
346
|
+
try {
|
|
347
|
+
return readFileSync2(path, "utf8");
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function writeFileAtomic(path, content, mode) {
|
|
353
|
+
const dir = dirname(path);
|
|
354
|
+
mkdirSync2(dir, { recursive: true });
|
|
355
|
+
const temp = join2(dir, `.${basename2(path)}.${process.pid}.tmp`);
|
|
356
|
+
writeFileSync2(temp, content, mode === void 0 ? "utf8" : { encoding: "utf8", mode });
|
|
357
|
+
renameSync2(temp, path);
|
|
358
|
+
}
|
|
359
|
+
function ledgerPath() {
|
|
360
|
+
return join2(turnbellHome(), LEDGER_FILE);
|
|
361
|
+
}
|
|
362
|
+
function isLedgerEntry(value) {
|
|
363
|
+
if (!isPlainObject2(value)) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const kindOk = value.kind === void 0 || value.kind === "file" || value.kind === "marked";
|
|
367
|
+
const markerOk = value.markerId === void 0 || typeof value.markerId === "string";
|
|
368
|
+
return typeof value.ledgerKey === "string" && typeof value.path === "string" && (value.before === null || typeof value.before === "string") && typeof value.at === "string" && kindOk && markerOk;
|
|
369
|
+
}
|
|
370
|
+
function readLedger() {
|
|
371
|
+
const raw = readFileOrNull(ledgerPath());
|
|
372
|
+
if (raw === null) {
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const parsed = JSON.parse(raw);
|
|
377
|
+
return Array.isArray(parsed) ? parsed.filter(isLedgerEntry) : [];
|
|
378
|
+
} catch {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function listWiredKeys() {
|
|
383
|
+
return readLedger().map((entry) => entry.ledgerKey);
|
|
384
|
+
}
|
|
385
|
+
function writeLedger(entries) {
|
|
386
|
+
mkdirSync2(turnbellHome(), { recursive: true });
|
|
387
|
+
writeFileAtomic(
|
|
388
|
+
ledgerPath(),
|
|
389
|
+
`${JSON.stringify(entries, null, JSON_INDENT)}${TRAILING_NEWLINE}`,
|
|
390
|
+
LEDGER_MODE
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
function appendLedgerEntry(entry) {
|
|
394
|
+
const entries = readLedger();
|
|
395
|
+
const existing = entries.find((item) => item.ledgerKey === entry.ledgerKey);
|
|
396
|
+
if (existing !== void 0) {
|
|
397
|
+
if (existing.path === entry.path && existing.markerId === entry.markerId) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
restoreEntry(existing);
|
|
401
|
+
writeLedger([
|
|
402
|
+
...entries.filter((item) => item.ledgerKey !== entry.ledgerKey),
|
|
403
|
+
entry
|
|
404
|
+
]);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
entries.push(entry);
|
|
408
|
+
writeLedger(entries);
|
|
409
|
+
}
|
|
410
|
+
function splitLines(text2) {
|
|
411
|
+
return text2.length === 0 ? [] : text2.split("\n");
|
|
412
|
+
}
|
|
413
|
+
function commonPrefixLength(a, b) {
|
|
414
|
+
let index = 0;
|
|
415
|
+
while (index < a.length && index < b.length && a[index] === b[index]) {
|
|
416
|
+
index += 1;
|
|
417
|
+
}
|
|
418
|
+
return index;
|
|
419
|
+
}
|
|
420
|
+
function commonSuffixLength(a, b, prefix) {
|
|
421
|
+
let count = 0;
|
|
422
|
+
while (count < a.length - prefix && count < b.length - prefix && a[a.length - 1 - count] === b[b.length - 1 - count]) {
|
|
423
|
+
count += 1;
|
|
424
|
+
}
|
|
425
|
+
return count;
|
|
426
|
+
}
|
|
427
|
+
function renderLineDiff(oldText, newText) {
|
|
428
|
+
const before = splitLines(oldText);
|
|
429
|
+
const after = splitLines(newText);
|
|
430
|
+
const prefix = commonPrefixLength(before, after);
|
|
431
|
+
const suffix = commonSuffixLength(before, after, prefix);
|
|
432
|
+
const removed = before.slice(prefix, before.length - suffix);
|
|
433
|
+
const added = after.slice(prefix, after.length - suffix);
|
|
434
|
+
if (removed.length === 0 && added.length === 0) {
|
|
435
|
+
return "";
|
|
436
|
+
}
|
|
437
|
+
const leading = before.slice(Math.max(0, prefix - DIFF_CONTEXT_LINES), prefix);
|
|
438
|
+
const tailStart = before.length - suffix;
|
|
439
|
+
const trailing = before.slice(tailStart, tailStart + DIFF_CONTEXT_LINES);
|
|
440
|
+
return [
|
|
441
|
+
...leading.map((line) => `${CONTEXT_MARK}${line}`),
|
|
442
|
+
...removed.map((line) => `${REMOVED_MARK}${line}`),
|
|
443
|
+
...added.map((line) => `${ADDED_MARK}${line}`),
|
|
444
|
+
...trailing.map((line) => `${CONTEXT_MARK}${line}`)
|
|
445
|
+
].join("\n");
|
|
446
|
+
}
|
|
447
|
+
function buildWireOp(path, original, nextContent, ledgerKey) {
|
|
448
|
+
const oldContent = original ?? "";
|
|
449
|
+
const changed = oldContent !== nextContent;
|
|
450
|
+
return {
|
|
451
|
+
diff: renderLineDiff(oldContent, nextContent),
|
|
452
|
+
changed,
|
|
453
|
+
apply() {
|
|
454
|
+
if (!changed) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
writeFileAtomic(path, nextContent);
|
|
458
|
+
appendLedgerEntry({
|
|
459
|
+
ledgerKey,
|
|
460
|
+
path,
|
|
461
|
+
before: original,
|
|
462
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function wireJsonFile(path, mutate, ledgerKey) {
|
|
468
|
+
const original = readFileOrNull(path);
|
|
469
|
+
const nextContent = serializeJson(mutate(parseJsonObject(original ?? "")));
|
|
470
|
+
return buildWireOp(path, original, nextContent, ledgerKey);
|
|
471
|
+
}
|
|
472
|
+
function wireTextFile(path, newContent, ledgerKey) {
|
|
473
|
+
return buildWireOp(path, readFileOrNull(path), newContent, ledgerKey);
|
|
474
|
+
}
|
|
475
|
+
function startMarker(markerId) {
|
|
476
|
+
return `<!-- ${markerId}:start (managed by turnbell \u2014 \`turnbell uninstall\`, or re-run \`turnbell init\` with read-aloud off, removes this) -->`;
|
|
477
|
+
}
|
|
478
|
+
function endMarker(markerId) {
|
|
479
|
+
return `<!-- ${markerId}:end -->`;
|
|
480
|
+
}
|
|
481
|
+
function markedBlock(markerId, body) {
|
|
482
|
+
return `${startMarker(markerId)}
|
|
483
|
+
${body}
|
|
484
|
+
${endMarker(markerId)}`;
|
|
485
|
+
}
|
|
486
|
+
function startMarkerPrefix(markerId) {
|
|
487
|
+
return `<!-- ${markerId}:start`;
|
|
488
|
+
}
|
|
489
|
+
function stripMarkedSection(content, markerId) {
|
|
490
|
+
const start = content.indexOf(startMarkerPrefix(markerId));
|
|
491
|
+
if (start === -1) {
|
|
492
|
+
return content;
|
|
493
|
+
}
|
|
494
|
+
const endToken = endMarker(markerId);
|
|
495
|
+
const endIdx = content.indexOf(endToken, start);
|
|
496
|
+
if (endIdx === -1) {
|
|
497
|
+
return content;
|
|
498
|
+
}
|
|
499
|
+
const after = endIdx + endToken.length;
|
|
500
|
+
const head = content.slice(0, start).replace(/\n?\n$/, "\n");
|
|
501
|
+
const tail = content.slice(after).replace(/^\n/, "");
|
|
502
|
+
return `${head}${tail}`;
|
|
503
|
+
}
|
|
504
|
+
function upsertMarkedSection(content, markerId, body) {
|
|
505
|
+
const block = markedBlock(markerId, body);
|
|
506
|
+
const start = content.indexOf(startMarkerPrefix(markerId));
|
|
507
|
+
if (start !== -1) {
|
|
508
|
+
const stripped = stripMarkedSection(content, markerId);
|
|
509
|
+
const base2 = stripped.length === 0 || stripped.endsWith("\n") ? stripped : `${stripped}
|
|
510
|
+
`;
|
|
511
|
+
return `${base2}${base2.length === 0 ? "" : "\n"}${block}
|
|
512
|
+
`;
|
|
513
|
+
}
|
|
514
|
+
if (content.length === 0) {
|
|
515
|
+
return `${block}
|
|
516
|
+
`;
|
|
517
|
+
}
|
|
518
|
+
const base = content.endsWith("\n") ? content : `${content}
|
|
519
|
+
`;
|
|
520
|
+
return `${base}
|
|
521
|
+
${block}
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
function wireMarkedSection(path, markerId, blockBody, ledgerKey, legacyMarkerIds = []) {
|
|
525
|
+
const original = readFileOrNull(path);
|
|
526
|
+
const base = legacyMarkerIds.reduce(
|
|
527
|
+
(text2, legacyId) => stripMarkedSection(text2, legacyId),
|
|
528
|
+
original ?? ""
|
|
529
|
+
);
|
|
530
|
+
const nextContent = upsertMarkedSection(base, markerId, blockBody);
|
|
531
|
+
const oldContent = original ?? "";
|
|
532
|
+
const changed = oldContent !== nextContent;
|
|
533
|
+
return {
|
|
534
|
+
diff: renderLineDiff(oldContent, nextContent),
|
|
535
|
+
changed,
|
|
536
|
+
apply() {
|
|
537
|
+
if (!changed) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
writeFileAtomic(path, nextContent);
|
|
541
|
+
appendLedgerEntry({
|
|
542
|
+
ledgerKey,
|
|
543
|
+
path,
|
|
544
|
+
before: original,
|
|
545
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
546
|
+
kind: "marked",
|
|
547
|
+
markerId
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function restoreEntry(entry) {
|
|
553
|
+
if (entry.kind === "marked" && typeof entry.markerId === "string") {
|
|
554
|
+
const current = readFileOrNull(entry.path);
|
|
555
|
+
if (current === null) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const stripped = stripMarkedSection(current, entry.markerId);
|
|
559
|
+
if (stripped !== current) {
|
|
560
|
+
writeFileAtomic(entry.path, stripped);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (entry.before === null) {
|
|
565
|
+
try {
|
|
566
|
+
rmSync(entry.path, { force: true });
|
|
567
|
+
} catch {
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
writeFileAtomic(entry.path, entry.before);
|
|
572
|
+
}
|
|
573
|
+
function unwireFromLedger(ledgerKey) {
|
|
574
|
+
const entries = readLedger();
|
|
575
|
+
const entry = entries.find((item) => item.ledgerKey === ledgerKey);
|
|
576
|
+
if (entry === void 0) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
restoreEntry(entry);
|
|
580
|
+
writeLedger(entries.filter((item) => item.ledgerKey !== ledgerKey));
|
|
581
|
+
}
|
|
582
|
+
function dropLedgerKey(ledgerKey) {
|
|
583
|
+
const entries = readLedger();
|
|
584
|
+
const kept = entries.filter((entry) => entry.ledgerKey !== ledgerKey);
|
|
585
|
+
if (kept.length !== entries.length) {
|
|
586
|
+
writeLedger(kept);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function unwireJsonFile(path, removeMutate, ledgerKey) {
|
|
590
|
+
const original = readFileOrNull(path);
|
|
591
|
+
if (original !== null) {
|
|
592
|
+
const next = serializeJson(removeMutate(parseJsonObject(original)));
|
|
593
|
+
if (next !== original) {
|
|
594
|
+
writeFileAtomic(path, next);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
dropLedgerKey(ledgerKey);
|
|
598
|
+
}
|
|
599
|
+
function unwireCreatedFile(path, ledgerKey) {
|
|
600
|
+
try {
|
|
601
|
+
rmSync(path, { force: true });
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
dropLedgerKey(ledgerKey);
|
|
605
|
+
}
|
|
606
|
+
function unwireTextFile(path, transform, ledgerKey) {
|
|
607
|
+
const original = readFileOrNull(path);
|
|
608
|
+
const next = transform(original);
|
|
609
|
+
if (next !== null && next !== original) {
|
|
610
|
+
writeFileAtomic(path, next);
|
|
611
|
+
}
|
|
612
|
+
dropLedgerKey(ledgerKey);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/adapters/amp.ts
|
|
616
|
+
var ID = "amp";
|
|
617
|
+
var TITLE = "Amp";
|
|
618
|
+
var LEDGER_KEY = "amp";
|
|
619
|
+
var BINARY = "amp";
|
|
620
|
+
var CONFIG_SEGMENTS = [".config", "amp"];
|
|
621
|
+
var FALLBACK_WARNINGS = [
|
|
622
|
+
"Amp has no declarative hook catalog turnbell can write, so nothing was changed.",
|
|
623
|
+
`Enable Amp's built-in turn alert: set "amp.notifications.enabled": true in ~/.config/amp/settings.json.`,
|
|
624
|
+
"For turnbell voice announcements, launch Amp through the wrapper: `turnbell run --agent amp -- amp`."
|
|
625
|
+
];
|
|
626
|
+
function isRecord(value) {
|
|
627
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
628
|
+
}
|
|
629
|
+
function configDir(deps) {
|
|
630
|
+
return join3(deps.home, ...CONFIG_SEGMENTS);
|
|
631
|
+
}
|
|
632
|
+
function isDir(path) {
|
|
633
|
+
try {
|
|
634
|
+
return statSync2(path).isDirectory();
|
|
635
|
+
} catch {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function payloadCwd(raw) {
|
|
640
|
+
return typeof raw.cwd === "string" ? raw.cwd : "";
|
|
641
|
+
}
|
|
642
|
+
var amp = {
|
|
643
|
+
id: ID,
|
|
644
|
+
title: TITLE,
|
|
645
|
+
tagline: "Sourcegraph Amp \u2014 announce-only via amp.notifications + the turnbell run wrapper",
|
|
646
|
+
capabilities: {
|
|
647
|
+
done: true,
|
|
648
|
+
blocked: false,
|
|
649
|
+
readAloud: false,
|
|
650
|
+
slashCommand: false,
|
|
651
|
+
instructionInjection: false
|
|
652
|
+
},
|
|
653
|
+
detect(deps) {
|
|
654
|
+
const installed = deps.which(BINARY) !== null || isDir(configDir(deps));
|
|
655
|
+
return Promise.resolve({ installed });
|
|
656
|
+
},
|
|
657
|
+
wire(_deps) {
|
|
658
|
+
return Promise.resolve({
|
|
659
|
+
changed: false,
|
|
660
|
+
diff: "",
|
|
661
|
+
warnings: [...FALLBACK_WARNINGS]
|
|
662
|
+
});
|
|
663
|
+
},
|
|
664
|
+
unwire(_deps) {
|
|
665
|
+
unwireFromLedger(LEDGER_KEY);
|
|
666
|
+
return Promise.resolve();
|
|
667
|
+
},
|
|
668
|
+
normalize(raw, eventHint) {
|
|
669
|
+
if (!isRecord(raw)) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const cwd = payloadCwd(raw);
|
|
673
|
+
return {
|
|
674
|
+
v: 1,
|
|
675
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
676
|
+
agent: ID,
|
|
677
|
+
agentTitle: TITLE,
|
|
678
|
+
event: eventHint,
|
|
679
|
+
cwd,
|
|
680
|
+
project: projectLabel(cwd),
|
|
681
|
+
summary: "",
|
|
682
|
+
lastResponse: null
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
readLastResponse(_raw) {
|
|
686
|
+
return Promise.resolve(null);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// src/adapters/antigravity.ts
|
|
691
|
+
import { statSync as statSync3 } from "fs";
|
|
692
|
+
import { join as join4 } from "path";
|
|
693
|
+
|
|
694
|
+
// src/adapters/hooks.ts
|
|
695
|
+
function legacyCommandVariant(command) {
|
|
696
|
+
return command.replace(/^turnbell(?= )/, "hollr");
|
|
697
|
+
}
|
|
698
|
+
function isRecord2(value) {
|
|
699
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
700
|
+
}
|
|
701
|
+
function removeTurnbellHooks(json, eventKeys, isTurnbellEntry6) {
|
|
702
|
+
if (!isRecord2(json.hooks)) {
|
|
703
|
+
return json;
|
|
704
|
+
}
|
|
705
|
+
const hooks = { ...json.hooks };
|
|
706
|
+
for (const event of eventKeys) {
|
|
707
|
+
const arr = hooks[event];
|
|
708
|
+
if (!Array.isArray(arr)) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const kept = arr.filter((entry) => !isTurnbellEntry6(entry));
|
|
712
|
+
if (kept.length > 0) {
|
|
713
|
+
hooks[event] = kept;
|
|
714
|
+
} else {
|
|
715
|
+
delete hooks[event];
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (Object.keys(hooks).length === 0) {
|
|
719
|
+
const { hooks: _drop, ...rest } = json;
|
|
720
|
+
return rest;
|
|
721
|
+
}
|
|
722
|
+
return { ...json, hooks };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/adapters/antigravity.ts
|
|
726
|
+
var ID2 = "antigravity";
|
|
727
|
+
var TITLE2 = "Antigravity";
|
|
728
|
+
var LEDGER_KEY2 = "antigravity";
|
|
729
|
+
var BINARY2 = "agy";
|
|
730
|
+
var CONFIG_DIR = ".gemini";
|
|
731
|
+
var HOOKS_FILE = "hooks.json";
|
|
732
|
+
var HOOK_NAME = "turnbell";
|
|
733
|
+
var LEGACY_HOOK_NAME = "hollr";
|
|
734
|
+
var HOOK_STOP = "Stop";
|
|
735
|
+
var HOOK_TYPE_COMMAND = "command";
|
|
736
|
+
var TURNBELL_STOP_COMMAND = "turnbell emit --agent antigravity --event done --payload-stdin; printf '{}'";
|
|
737
|
+
function isRecord3(value) {
|
|
738
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
739
|
+
}
|
|
740
|
+
function hooksPath(deps) {
|
|
741
|
+
return join4(deps.home, CONFIG_DIR, HOOKS_FILE);
|
|
742
|
+
}
|
|
743
|
+
function isDir2(path) {
|
|
744
|
+
try {
|
|
745
|
+
return statSync3(path).isDirectory();
|
|
746
|
+
} catch {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function workspaceCwd(raw) {
|
|
751
|
+
const paths = raw.workspacePaths;
|
|
752
|
+
if (!Array.isArray(paths)) {
|
|
753
|
+
return "";
|
|
754
|
+
}
|
|
755
|
+
const first = paths[0];
|
|
756
|
+
return typeof first === "string" ? first : "";
|
|
757
|
+
}
|
|
758
|
+
function stopHasTurnbell(stop) {
|
|
759
|
+
if (!Array.isArray(stop)) {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
return stop.some(
|
|
763
|
+
(handler) => isRecord3(handler) && handler.command === TURNBELL_STOP_COMMAND
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
function addStopHook(json) {
|
|
767
|
+
const existing = isRecord3(json[HOOK_NAME]) ? json[HOOK_NAME] : {};
|
|
768
|
+
const currentStop = Array.isArray(existing[HOOK_STOP]) ? existing[HOOK_STOP] : [];
|
|
769
|
+
const nextStop = stopHasTurnbell(currentStop) ? currentStop : [...currentStop, { type: HOOK_TYPE_COMMAND, command: TURNBELL_STOP_COMMAND }];
|
|
770
|
+
return {
|
|
771
|
+
...json,
|
|
772
|
+
[HOOK_NAME]: {
|
|
773
|
+
...existing,
|
|
774
|
+
[HOOK_STOP]: nextStop
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
var TURNBELL_STOP_COMMANDS = /* @__PURE__ */ new Set([
|
|
779
|
+
TURNBELL_STOP_COMMAND,
|
|
780
|
+
legacyCommandVariant(TURNBELL_STOP_COMMAND)
|
|
781
|
+
]);
|
|
782
|
+
function isTurnbellEntry(entry) {
|
|
783
|
+
return isRecord3(entry) && typeof entry.command === "string" && TURNBELL_STOP_COMMANDS.has(entry.command);
|
|
784
|
+
}
|
|
785
|
+
function removeHooksFromGroup(json, groupName) {
|
|
786
|
+
const existing = json[groupName];
|
|
787
|
+
if (!isRecord3(existing)) {
|
|
788
|
+
return json;
|
|
789
|
+
}
|
|
790
|
+
const stop = Array.isArray(existing[HOOK_STOP]) ? existing[HOOK_STOP] : [];
|
|
791
|
+
const kept = stop.filter((entry) => !isTurnbellEntry(entry));
|
|
792
|
+
const nextEntry = { ...existing };
|
|
793
|
+
if (kept.length > 0) {
|
|
794
|
+
nextEntry[HOOK_STOP] = kept;
|
|
795
|
+
} else {
|
|
796
|
+
delete nextEntry[HOOK_STOP];
|
|
797
|
+
}
|
|
798
|
+
if (Object.keys(nextEntry).length === 0) {
|
|
799
|
+
const { [groupName]: _drop, ...rest } = json;
|
|
800
|
+
return rest;
|
|
801
|
+
}
|
|
802
|
+
return { ...json, [groupName]: nextEntry };
|
|
803
|
+
}
|
|
804
|
+
function removeHooks(json) {
|
|
805
|
+
return removeHooksFromGroup(
|
|
806
|
+
removeHooksFromGroup(json, LEGACY_HOOK_NAME),
|
|
807
|
+
HOOK_NAME
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
var antigravity = {
|
|
811
|
+
id: ID2,
|
|
812
|
+
title: TITLE2,
|
|
813
|
+
tagline: "Google's agentic CLI (agy)",
|
|
814
|
+
capabilities: {
|
|
815
|
+
done: true,
|
|
816
|
+
blocked: false,
|
|
817
|
+
readAloud: false,
|
|
818
|
+
slashCommand: false,
|
|
819
|
+
instructionInjection: false
|
|
820
|
+
},
|
|
821
|
+
detect(deps) {
|
|
822
|
+
const installed = deps.which(BINARY2) !== null || isDir2(join4(deps.home, CONFIG_DIR));
|
|
823
|
+
const detection = { installed };
|
|
824
|
+
if (installed) {
|
|
825
|
+
detection.configPath = hooksPath(deps);
|
|
826
|
+
}
|
|
827
|
+
return Promise.resolve(detection);
|
|
828
|
+
},
|
|
829
|
+
wire(deps) {
|
|
830
|
+
const op = wireJsonFile(hooksPath(deps), (json) => addStopHook(removeHooks(json)), LEDGER_KEY2);
|
|
831
|
+
const result = {
|
|
832
|
+
changed: op.changed,
|
|
833
|
+
diff: op.diff,
|
|
834
|
+
warnings: []
|
|
835
|
+
};
|
|
836
|
+
op.apply();
|
|
837
|
+
return Promise.resolve(result);
|
|
838
|
+
},
|
|
839
|
+
unwire(deps) {
|
|
840
|
+
unwireJsonFile(hooksPath(deps), removeHooks, LEDGER_KEY2);
|
|
841
|
+
return Promise.resolve();
|
|
842
|
+
},
|
|
843
|
+
normalize(raw, eventHint) {
|
|
844
|
+
if (!isRecord3(raw)) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
const cwd = workspaceCwd(raw);
|
|
848
|
+
return {
|
|
849
|
+
v: 1,
|
|
850
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
851
|
+
agent: ID2,
|
|
852
|
+
agentTitle: TITLE2,
|
|
853
|
+
event: eventHint,
|
|
854
|
+
cwd,
|
|
855
|
+
project: projectLabel(cwd),
|
|
856
|
+
summary: "",
|
|
857
|
+
lastResponse: null
|
|
858
|
+
};
|
|
859
|
+
},
|
|
860
|
+
readLastResponse(_raw) {
|
|
861
|
+
return Promise.resolve(null);
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// src/adapters/claude-code.ts
|
|
866
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync3, readSync, statSync as statSync4 } from "fs";
|
|
867
|
+
import { join as join5 } from "path";
|
|
868
|
+
var ID3 = "claude-code";
|
|
869
|
+
var TITLE3 = "Claude Code";
|
|
870
|
+
var LEDGER_KEY3 = "claude-code:settings";
|
|
871
|
+
var COMMAND_LEDGER_KEY = "claude-code:command";
|
|
872
|
+
var COMMANDS_DIR = "commands";
|
|
873
|
+
var COMMAND_FILE = "turnbell.md";
|
|
874
|
+
var LEGACY_COMMAND_FILE = "hollr.md";
|
|
875
|
+
var HOOKS_KEY = "hooks";
|
|
876
|
+
var ENABLED_PLUGINS_KEY = "enabledPlugins";
|
|
877
|
+
var COMMAND_TEMPLATE = `---
|
|
878
|
+
description: Control turnbell (pause/resume/stop/status/mute/doctor)
|
|
879
|
+
---
|
|
880
|
+
|
|
881
|
+
Managed by turnbell \u2014 reversible via \`turnbell uninstall\`. Do not edit by hand.
|
|
882
|
+
|
|
883
|
+
Run this shell command and relay its output to the user verbatim:
|
|
884
|
+
|
|
885
|
+
\`\`\`bash
|
|
886
|
+
turnbell $ARGUMENTS
|
|
887
|
+
\`\`\`
|
|
888
|
+
|
|
889
|
+
Supported actions: pause, resume, stop, status, mute, doctor.
|
|
890
|
+
|
|
891
|
+
Note: \`turnbell init\` is terminal-only (an interactive wizard) and is not
|
|
892
|
+
available as a slash command \u2014 run it directly in your terminal instead.
|
|
893
|
+
`;
|
|
894
|
+
var MAX_TRANSCRIPT_BYTES = 2e6;
|
|
895
|
+
var HOOK_STOP2 = "Stop";
|
|
896
|
+
var HOOK_NOTIFICATION = "Notification";
|
|
897
|
+
var HOOK_TYPE_COMMAND2 = "command";
|
|
898
|
+
var ASSISTANT_TYPE = "assistant";
|
|
899
|
+
var TEXT_TYPE = "text";
|
|
900
|
+
var BLOCKED_EVENT = "blocked";
|
|
901
|
+
var DONE_EVENT = "done";
|
|
902
|
+
var NOTIFICATION_TYPE_KEY = "notification_type";
|
|
903
|
+
var BACKGROUND_TASKS_KEY = "background_tasks";
|
|
904
|
+
var TASK_TYPE_KEY = "type";
|
|
905
|
+
var BLOCKING_BACKGROUND_TYPES = /* @__PURE__ */ new Set([
|
|
906
|
+
"subagent",
|
|
907
|
+
"workflow",
|
|
908
|
+
"teammate",
|
|
909
|
+
"cloud session"
|
|
910
|
+
]);
|
|
911
|
+
var SUPPRESSED_NOTIFICATION_TYPES = /* @__PURE__ */ new Set([
|
|
912
|
+
"idle_prompt",
|
|
913
|
+
"auth_success",
|
|
914
|
+
"agent_completed",
|
|
915
|
+
"elicitation_complete",
|
|
916
|
+
"elicitation_response"
|
|
917
|
+
]);
|
|
918
|
+
var STOP_COMMAND = "turnbell emit --agent claude-code --event done --payload-stdin";
|
|
919
|
+
var NOTIFICATION_COMMAND = "turnbell emit --agent claude-code --event blocked --payload-stdin";
|
|
920
|
+
var LEGACY_SCRIPT_MARKERS = ["hollr_hook.py", "announce-done.py"];
|
|
921
|
+
var LEGACY_PLUGIN_ID = "hollr@hollr-marketplace";
|
|
922
|
+
var LEGACY_MARKERS = [
|
|
923
|
+
...LEGACY_SCRIPT_MARKERS,
|
|
924
|
+
LEGACY_PLUGIN_ID
|
|
925
|
+
];
|
|
926
|
+
function isRecord4(value) {
|
|
927
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
928
|
+
}
|
|
929
|
+
function settingsPath(deps) {
|
|
930
|
+
return join5(deps.home, ".claude", "settings.json");
|
|
931
|
+
}
|
|
932
|
+
function commandPath(deps) {
|
|
933
|
+
return join5(deps.home, ".claude", COMMANDS_DIR, COMMAND_FILE);
|
|
934
|
+
}
|
|
935
|
+
function legacyCommandPath(deps) {
|
|
936
|
+
return join5(deps.home, ".claude", COMMANDS_DIR, LEGACY_COMMAND_FILE);
|
|
937
|
+
}
|
|
938
|
+
function omitKey(obj, key) {
|
|
939
|
+
const rest = {};
|
|
940
|
+
for (const [name, value] of Object.entries(obj)) {
|
|
941
|
+
if (name !== key) {
|
|
942
|
+
rest[name] = value;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return rest;
|
|
946
|
+
}
|
|
947
|
+
function isDir3(path) {
|
|
948
|
+
try {
|
|
949
|
+
return statSync4(path).isDirectory();
|
|
950
|
+
} catch {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function readFileOrNull2(path) {
|
|
955
|
+
try {
|
|
956
|
+
return readFileSync3(path, "utf8");
|
|
957
|
+
} catch {
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function readTail(path) {
|
|
962
|
+
try {
|
|
963
|
+
const fd = openSync(path, "r");
|
|
964
|
+
try {
|
|
965
|
+
const size = fstatSync(fd).size;
|
|
966
|
+
const start = Math.max(0, size - MAX_TRANSCRIPT_BYTES);
|
|
967
|
+
const length = size - start;
|
|
968
|
+
const buffer = Buffer.alloc(length);
|
|
969
|
+
let offset = 0;
|
|
970
|
+
while (offset < length) {
|
|
971
|
+
const read = readSync(fd, buffer, offset, length - offset, start + offset);
|
|
972
|
+
if (read === 0) {
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
offset += read;
|
|
976
|
+
}
|
|
977
|
+
return buffer.subarray(0, offset).toString("utf8");
|
|
978
|
+
} finally {
|
|
979
|
+
closeSync(fd);
|
|
980
|
+
}
|
|
981
|
+
} catch {
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function assistantText(line) {
|
|
986
|
+
let obj;
|
|
987
|
+
try {
|
|
988
|
+
obj = JSON.parse(line);
|
|
989
|
+
} catch {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
if (!isRecord4(obj) || obj.type !== ASSISTANT_TYPE) {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
const message = obj.message;
|
|
996
|
+
const content = isRecord4(message) ? message.content : null;
|
|
997
|
+
if (!Array.isArray(content)) {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
const joined = content.filter((block) => isRecord4(block) && block.type === TEXT_TYPE).map((block) => typeof block.text === "string" ? block.text : "").filter((text2) => text2.length > 0).join(" ").trim();
|
|
1001
|
+
return joined.length > 0 ? joined : null;
|
|
1002
|
+
}
|
|
1003
|
+
function entryHasCommand(entry, command) {
|
|
1004
|
+
if (!isRecord4(entry) || !Array.isArray(entry.hooks)) {
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
return entry.hooks.some((hook) => isRecord4(hook) && hook.command === command);
|
|
1008
|
+
}
|
|
1009
|
+
function appendTurnbellHook(existing, command) {
|
|
1010
|
+
const list = Array.isArray(existing) ? existing : [];
|
|
1011
|
+
if (list.some((entry) => entryHasCommand(entry, command))) {
|
|
1012
|
+
return list;
|
|
1013
|
+
}
|
|
1014
|
+
return [...list, { hooks: [{ type: HOOK_TYPE_COMMAND2, command }] }];
|
|
1015
|
+
}
|
|
1016
|
+
function addHooks(json) {
|
|
1017
|
+
const hooks = isRecord4(json.hooks) ? json.hooks : {};
|
|
1018
|
+
return {
|
|
1019
|
+
...json,
|
|
1020
|
+
hooks: {
|
|
1021
|
+
...hooks,
|
|
1022
|
+
[HOOK_STOP2]: appendTurnbellHook(hooks[HOOK_STOP2], STOP_COMMAND),
|
|
1023
|
+
[HOOK_NOTIFICATION]: appendTurnbellHook(hooks[HOOK_NOTIFICATION], NOTIFICATION_COMMAND)
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
var TURNBELL_COMMANDS = /* @__PURE__ */ new Set([
|
|
1028
|
+
STOP_COMMAND,
|
|
1029
|
+
NOTIFICATION_COMMAND,
|
|
1030
|
+
legacyCommandVariant(STOP_COMMAND),
|
|
1031
|
+
legacyCommandVariant(NOTIFICATION_COMMAND)
|
|
1032
|
+
]);
|
|
1033
|
+
function isTurnbellEntry2(entry) {
|
|
1034
|
+
return isRecord4(entry) && Array.isArray(entry.hooks) && entry.hooks.some(
|
|
1035
|
+
(hook) => isRecord4(hook) && typeof hook.command === "string" && TURNBELL_COMMANDS.has(hook.command)
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
function removeHooks2(json) {
|
|
1039
|
+
return removeTurnbellHooks(json, [HOOK_STOP2, HOOK_NOTIFICATION], isTurnbellEntry2);
|
|
1040
|
+
}
|
|
1041
|
+
function commandIsLegacy(command) {
|
|
1042
|
+
return typeof command === "string" && LEGACY_SCRIPT_MARKERS.some((marker) => command.includes(marker));
|
|
1043
|
+
}
|
|
1044
|
+
function isLegacyHookEntry(entry) {
|
|
1045
|
+
if (!isRecord4(entry) || !Array.isArray(entry.hooks)) {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
return entry.hooks.some(
|
|
1049
|
+
(hook) => isRecord4(hook) && commandIsLegacy(hook.command)
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
function stripLegacyHooks(hooks) {
|
|
1053
|
+
const cleaned = {};
|
|
1054
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
1055
|
+
if (!Array.isArray(entries)) {
|
|
1056
|
+
cleaned[event] = entries;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const kept = entries.filter((entry) => !isLegacyHookEntry(entry));
|
|
1060
|
+
if (kept.length > 0) {
|
|
1061
|
+
cleaned[event] = kept;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return cleaned;
|
|
1065
|
+
}
|
|
1066
|
+
function stripLegacyPlugin(json) {
|
|
1067
|
+
const plugins = json[ENABLED_PLUGINS_KEY];
|
|
1068
|
+
if (isRecord4(plugins) && LEGACY_PLUGIN_ID in plugins) {
|
|
1069
|
+
const rest = omitKey(plugins, LEGACY_PLUGIN_ID);
|
|
1070
|
+
return Object.keys(rest).length > 0 ? { ...json, [ENABLED_PLUGINS_KEY]: rest } : omitKey(json, ENABLED_PLUGINS_KEY);
|
|
1071
|
+
}
|
|
1072
|
+
if (Array.isArray(plugins) && plugins.includes(LEGACY_PLUGIN_ID)) {
|
|
1073
|
+
const kept = plugins.filter((item) => item !== LEGACY_PLUGIN_ID);
|
|
1074
|
+
return kept.length > 0 ? { ...json, [ENABLED_PLUGINS_KEY]: kept } : omitKey(json, ENABLED_PLUGINS_KEY);
|
|
1075
|
+
}
|
|
1076
|
+
return json;
|
|
1077
|
+
}
|
|
1078
|
+
function stripLegacy(json) {
|
|
1079
|
+
const withoutPlugin = stripLegacyPlugin(json);
|
|
1080
|
+
const hooks = withoutPlugin[HOOKS_KEY];
|
|
1081
|
+
if (!isRecord4(hooks)) {
|
|
1082
|
+
return withoutPlugin;
|
|
1083
|
+
}
|
|
1084
|
+
const cleaned = stripLegacyHooks(hooks);
|
|
1085
|
+
return Object.keys(cleaned).length > 0 ? { ...withoutPlugin, [HOOKS_KEY]: cleaned } : omitKey(withoutPlugin, HOOKS_KEY);
|
|
1086
|
+
}
|
|
1087
|
+
function wireSettings(json) {
|
|
1088
|
+
return addHooks(removeHooks2(stripLegacy(json)));
|
|
1089
|
+
}
|
|
1090
|
+
function joinDiffs(settingsDiff, commandDiff) {
|
|
1091
|
+
return [settingsDiff, commandDiff].filter((diff) => diff.length > 0).join("\n");
|
|
1092
|
+
}
|
|
1093
|
+
function legacyMarker(rawText) {
|
|
1094
|
+
if (rawText === null) {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
return LEGACY_MARKERS.find((marker) => rawText.includes(marker)) ?? null;
|
|
1098
|
+
}
|
|
1099
|
+
function legacyMessage(marker) {
|
|
1100
|
+
return `legacy turnbell v1 integration detected in settings (${marker}); \`turnbell init\` removes it as part of wiring (reversible via \`turnbell uninstall\`)`;
|
|
1101
|
+
}
|
|
1102
|
+
function isSuppressedNotification(raw, eventHint) {
|
|
1103
|
+
if (eventHint !== BLOCKED_EVENT) {
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
const type = raw[NOTIFICATION_TYPE_KEY];
|
|
1107
|
+
return typeof type === "string" && SUPPRESSED_NOTIFICATION_TYPES.has(type);
|
|
1108
|
+
}
|
|
1109
|
+
function hasPendingDelegatedWork(raw, eventHint) {
|
|
1110
|
+
if (eventHint !== DONE_EVENT) {
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
const tasks = raw[BACKGROUND_TASKS_KEY];
|
|
1114
|
+
if (!Array.isArray(tasks)) {
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
return tasks.some(
|
|
1118
|
+
(task) => isRecord4(task) && typeof task[TASK_TYPE_KEY] === "string" && BLOCKING_BACKGROUND_TYPES.has(task[TASK_TYPE_KEY])
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
var claudeCode = {
|
|
1122
|
+
id: ID3,
|
|
1123
|
+
title: TITLE3,
|
|
1124
|
+
tagline: "Claude Code \u2014 done/blocked hooks and transcript read-aloud",
|
|
1125
|
+
capabilities: {
|
|
1126
|
+
done: true,
|
|
1127
|
+
blocked: true,
|
|
1128
|
+
readAloud: true,
|
|
1129
|
+
slashCommand: true,
|
|
1130
|
+
instructionInjection: true
|
|
1131
|
+
},
|
|
1132
|
+
memoryPath(deps) {
|
|
1133
|
+
return join5(deps.home, ".claude", "CLAUDE.md");
|
|
1134
|
+
},
|
|
1135
|
+
detect(deps) {
|
|
1136
|
+
const path = settingsPath(deps);
|
|
1137
|
+
const installed = isDir3(join5(deps.home, ".claude")) || deps.which("claude") !== null;
|
|
1138
|
+
const marker = legacyMarker(readFileOrNull2(path));
|
|
1139
|
+
const detection = { installed };
|
|
1140
|
+
if (installed) {
|
|
1141
|
+
detection.configPath = path;
|
|
1142
|
+
}
|
|
1143
|
+
if (marker !== null) {
|
|
1144
|
+
detection.degraded = legacyMessage(marker);
|
|
1145
|
+
}
|
|
1146
|
+
return Promise.resolve(detection);
|
|
1147
|
+
},
|
|
1148
|
+
wire(deps) {
|
|
1149
|
+
const path = settingsPath(deps);
|
|
1150
|
+
const marker = legacyMarker(readFileOrNull2(path));
|
|
1151
|
+
const settingsOp = wireJsonFile(path, wireSettings, LEDGER_KEY3);
|
|
1152
|
+
const commandOp = wireTextFile(
|
|
1153
|
+
commandPath(deps),
|
|
1154
|
+
COMMAND_TEMPLATE,
|
|
1155
|
+
COMMAND_LEDGER_KEY
|
|
1156
|
+
);
|
|
1157
|
+
const result = {
|
|
1158
|
+
changed: settingsOp.changed || commandOp.changed,
|
|
1159
|
+
diff: joinDiffs(settingsOp.diff, commandOp.diff),
|
|
1160
|
+
warnings: marker === null ? [] : [legacyMessage(marker)]
|
|
1161
|
+
};
|
|
1162
|
+
settingsOp.apply();
|
|
1163
|
+
commandOp.apply();
|
|
1164
|
+
return Promise.resolve(result);
|
|
1165
|
+
},
|
|
1166
|
+
unwire(deps) {
|
|
1167
|
+
unwireJsonFile(settingsPath(deps), removeHooks2, LEDGER_KEY3);
|
|
1168
|
+
unwireCreatedFile(legacyCommandPath(deps), COMMAND_LEDGER_KEY);
|
|
1169
|
+
unwireCreatedFile(commandPath(deps), COMMAND_LEDGER_KEY);
|
|
1170
|
+
return Promise.resolve();
|
|
1171
|
+
},
|
|
1172
|
+
normalize(raw, eventHint) {
|
|
1173
|
+
if (!isRecord4(raw)) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
if (isSuppressedNotification(raw, eventHint)) {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
if (hasPendingDelegatedWork(raw, eventHint)) {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
1183
|
+
return {
|
|
1184
|
+
v: 1,
|
|
1185
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1186
|
+
agent: ID3,
|
|
1187
|
+
agentTitle: TITLE3,
|
|
1188
|
+
event: eventHint,
|
|
1189
|
+
cwd,
|
|
1190
|
+
project: projectLabel(cwd),
|
|
1191
|
+
summary: typeof raw.message === "string" ? raw.message : ""
|
|
1192
|
+
};
|
|
1193
|
+
},
|
|
1194
|
+
readLastResponse(raw) {
|
|
1195
|
+
if (!isRecord4(raw) || typeof raw.transcript_path !== "string") {
|
|
1196
|
+
return Promise.resolve(null);
|
|
1197
|
+
}
|
|
1198
|
+
const data = readTail(raw.transcript_path);
|
|
1199
|
+
if (data === null) {
|
|
1200
|
+
return Promise.resolve(null);
|
|
1201
|
+
}
|
|
1202
|
+
const lines = data.split("\n");
|
|
1203
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1204
|
+
const text2 = assistantText(lines[index] ?? "");
|
|
1205
|
+
if (text2 !== null) {
|
|
1206
|
+
return Promise.resolve(text2);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return Promise.resolve(null);
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
// src/adapters/codex.ts
|
|
1214
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync2, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1215
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1216
|
+
var ID4 = "codex";
|
|
1217
|
+
var TITLE4 = "Codex";
|
|
1218
|
+
var BINARY3 = "codex";
|
|
1219
|
+
var CONFIG_DIR2 = ".codex";
|
|
1220
|
+
var CONFIG_FILE = "config.toml";
|
|
1221
|
+
var HOOKS_FILE2 = "hooks.json";
|
|
1222
|
+
var CONFIG_LEDGER_KEY = "codex:config";
|
|
1223
|
+
var HOOKS_LEDGER_KEY = "codex:hooks";
|
|
1224
|
+
var LAST_MESSAGE_FIELD = "last-assistant-message";
|
|
1225
|
+
var NOTIFY_ARGV = [
|
|
1226
|
+
"turnbell",
|
|
1227
|
+
"emit",
|
|
1228
|
+
"--agent",
|
|
1229
|
+
"codex",
|
|
1230
|
+
"--event",
|
|
1231
|
+
"done",
|
|
1232
|
+
"--payload-argv"
|
|
1233
|
+
];
|
|
1234
|
+
var BLOCKED_COMMAND = "turnbell emit --agent codex --event blocked --payload-stdin";
|
|
1235
|
+
var HOOK_EVENT = "PermissionRequest";
|
|
1236
|
+
var HOOK_TYPE_COMMAND3 = "command";
|
|
1237
|
+
var HOOK_MATCHER = ".*";
|
|
1238
|
+
var HOOK_TRUST_WARNING = "codex requires you to review and trust the blocked hook once (run `codex` and approve it) \u2014 it stays inert until then";
|
|
1239
|
+
var NOTIFY_KEY_PATTERN = /^\s*notify\s*=/;
|
|
1240
|
+
var TABLE_HEADER_PATTERN = /^\s*\[/;
|
|
1241
|
+
var NEWLINE = "\n";
|
|
1242
|
+
function isRecord5(value) {
|
|
1243
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1244
|
+
}
|
|
1245
|
+
function configPath(deps) {
|
|
1246
|
+
return join6(deps.home, CONFIG_DIR2, CONFIG_FILE);
|
|
1247
|
+
}
|
|
1248
|
+
function hooksPath2(deps) {
|
|
1249
|
+
return join6(deps.home, CONFIG_DIR2, HOOKS_FILE2);
|
|
1250
|
+
}
|
|
1251
|
+
function isDir4(path) {
|
|
1252
|
+
try {
|
|
1253
|
+
return statSync5(path).isDirectory();
|
|
1254
|
+
} catch {
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
function readFileOrNull3(path) {
|
|
1259
|
+
try {
|
|
1260
|
+
return readFileSync4(path, "utf8");
|
|
1261
|
+
} catch {
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
function notifyLine() {
|
|
1266
|
+
const items = NOTIFY_ARGV.map((item) => `"${item}"`).join(", ");
|
|
1267
|
+
return `notify = [${items}]`;
|
|
1268
|
+
}
|
|
1269
|
+
function legacyNotifyLine() {
|
|
1270
|
+
const items = NOTIFY_ARGV.map((item, index) => `"${index === 0 ? "hollr" : item}"`).join(", ");
|
|
1271
|
+
return `notify = [${items}]`;
|
|
1272
|
+
}
|
|
1273
|
+
function bracketDelta(line) {
|
|
1274
|
+
const opens = (line.match(/\[/g) ?? []).length;
|
|
1275
|
+
const closes = (line.match(/\]/g) ?? []).length;
|
|
1276
|
+
return opens - closes;
|
|
1277
|
+
}
|
|
1278
|
+
function notifyEndIndex(lines, start, limit) {
|
|
1279
|
+
let depth = 0;
|
|
1280
|
+
for (let index = start; index < limit; index += 1) {
|
|
1281
|
+
depth += bracketDelta(lines[index] ?? "");
|
|
1282
|
+
if (depth <= 0) {
|
|
1283
|
+
return index;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return limit - 1;
|
|
1287
|
+
}
|
|
1288
|
+
function replaceNotifyRange(lines, start, end, target) {
|
|
1289
|
+
const next = [...lines];
|
|
1290
|
+
next.splice(start, end - start + 1, target);
|
|
1291
|
+
return next.join(NEWLINE);
|
|
1292
|
+
}
|
|
1293
|
+
function firstTableHeaderIndex(lines) {
|
|
1294
|
+
const index = lines.findIndex((line) => TABLE_HEADER_PATTERN.test(line));
|
|
1295
|
+
return index === -1 ? lines.length : index;
|
|
1296
|
+
}
|
|
1297
|
+
function topLevelNotifyIndex(lines, limit) {
|
|
1298
|
+
for (let index = 0; index < limit; index += 1) {
|
|
1299
|
+
if (NOTIFY_KEY_PATTERN.test(lines[index] ?? "")) {
|
|
1300
|
+
return index;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return -1;
|
|
1304
|
+
}
|
|
1305
|
+
function insertNotifyLine(original, lines, target) {
|
|
1306
|
+
const headerIndex = lines.findIndex((line) => TABLE_HEADER_PATTERN.test(line));
|
|
1307
|
+
if (headerIndex === -1) {
|
|
1308
|
+
const base = original.endsWith(NEWLINE) ? original : `${original}${NEWLINE}`;
|
|
1309
|
+
return `${base}${target}${NEWLINE}`;
|
|
1310
|
+
}
|
|
1311
|
+
const next = [...lines];
|
|
1312
|
+
next.splice(headerIndex, 0, target);
|
|
1313
|
+
return next.join(NEWLINE);
|
|
1314
|
+
}
|
|
1315
|
+
function patchNotify(original) {
|
|
1316
|
+
const target = notifyLine();
|
|
1317
|
+
if (original === null || original.trim().length === 0) {
|
|
1318
|
+
return `${target}${NEWLINE}`;
|
|
1319
|
+
}
|
|
1320
|
+
const lines = original.split(NEWLINE);
|
|
1321
|
+
const limit = firstTableHeaderIndex(lines);
|
|
1322
|
+
const start = topLevelNotifyIndex(lines, limit);
|
|
1323
|
+
if (start !== -1) {
|
|
1324
|
+
return replaceNotifyRange(lines, start, notifyEndIndex(lines, start, limit), target);
|
|
1325
|
+
}
|
|
1326
|
+
return insertNotifyLine(original, lines, target);
|
|
1327
|
+
}
|
|
1328
|
+
function removeNotify(original) {
|
|
1329
|
+
if (original === null) {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
const lines = original.split(NEWLINE);
|
|
1333
|
+
const limit = firstTableHeaderIndex(lines);
|
|
1334
|
+
const start = topLevelNotifyIndex(lines, limit);
|
|
1335
|
+
if (start === -1) {
|
|
1336
|
+
return original;
|
|
1337
|
+
}
|
|
1338
|
+
const end = notifyEndIndex(lines, start, limit);
|
|
1339
|
+
const next = [...lines];
|
|
1340
|
+
next.splice(start, end - start + 1);
|
|
1341
|
+
return next.join(NEWLINE);
|
|
1342
|
+
}
|
|
1343
|
+
var NOTIFY_BACKUP_FILE = "codex-notify.bak";
|
|
1344
|
+
function codexNotifyBackupPath() {
|
|
1345
|
+
return join6(turnbellHome(), NOTIFY_BACKUP_FILE);
|
|
1346
|
+
}
|
|
1347
|
+
function readNotifyBackup() {
|
|
1348
|
+
return readFileOrNull3(codexNotifyBackupPath());
|
|
1349
|
+
}
|
|
1350
|
+
function writeNotifyBackup(content) {
|
|
1351
|
+
try {
|
|
1352
|
+
const path = codexNotifyBackupPath();
|
|
1353
|
+
mkdirSync3(dirname2(path), { recursive: true });
|
|
1354
|
+
writeFileSync3(path, content, "utf8");
|
|
1355
|
+
} catch {
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
function deleteNotifyBackup() {
|
|
1359
|
+
try {
|
|
1360
|
+
rmSync2(codexNotifyBackupPath(), { force: true });
|
|
1361
|
+
} catch {
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function extractNotify(original) {
|
|
1365
|
+
if (original === null) {
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
const lines = original.split(NEWLINE);
|
|
1369
|
+
const limit = firstTableHeaderIndex(lines);
|
|
1370
|
+
const start = topLevelNotifyIndex(lines, limit);
|
|
1371
|
+
if (start === -1) {
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
const end = notifyEndIndex(lines, start, limit);
|
|
1375
|
+
return lines.slice(start, end + 1).join(NEWLINE);
|
|
1376
|
+
}
|
|
1377
|
+
function archiveExistingNotify(original) {
|
|
1378
|
+
const existing = extractNotify(original);
|
|
1379
|
+
if (existing !== null && existing !== notifyLine() && existing !== legacyNotifyLine()) {
|
|
1380
|
+
writeNotifyBackup(existing);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
deleteNotifyBackup();
|
|
1384
|
+
}
|
|
1385
|
+
function restoreNotifyRange(current, backup) {
|
|
1386
|
+
const lines = current.split(NEWLINE);
|
|
1387
|
+
const limit = firstTableHeaderIndex(lines);
|
|
1388
|
+
const start = topLevelNotifyIndex(lines, limit);
|
|
1389
|
+
if (start === -1) {
|
|
1390
|
+
return current;
|
|
1391
|
+
}
|
|
1392
|
+
const end = notifyEndIndex(lines, start, limit);
|
|
1393
|
+
return replaceNotifyRange(lines, start, end, backup);
|
|
1394
|
+
}
|
|
1395
|
+
function restoreOrRemoveNotify(current) {
|
|
1396
|
+
const backup = readNotifyBackup();
|
|
1397
|
+
if (backup === null) {
|
|
1398
|
+
return removeNotify(current);
|
|
1399
|
+
}
|
|
1400
|
+
const restored = current === null ? null : restoreNotifyRange(current, backup);
|
|
1401
|
+
deleteNotifyBackup();
|
|
1402
|
+
return restored;
|
|
1403
|
+
}
|
|
1404
|
+
var BLOCKED_COMMANDS = /* @__PURE__ */ new Set([
|
|
1405
|
+
BLOCKED_COMMAND,
|
|
1406
|
+
legacyCommandVariant(BLOCKED_COMMAND)
|
|
1407
|
+
]);
|
|
1408
|
+
function entryHasCommand2(entry) {
|
|
1409
|
+
if (!isRecord5(entry) || !Array.isArray(entry.hooks)) {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
return entry.hooks.some(
|
|
1413
|
+
(hook) => isRecord5(hook) && typeof hook.command === "string" && BLOCKED_COMMANDS.has(hook.command)
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
function appendPermissionEntry(existing) {
|
|
1417
|
+
const list = Array.isArray(existing) ? existing : [];
|
|
1418
|
+
if (list.some(entryHasCommand2)) {
|
|
1419
|
+
return list;
|
|
1420
|
+
}
|
|
1421
|
+
return [
|
|
1422
|
+
...list,
|
|
1423
|
+
{
|
|
1424
|
+
matcher: HOOK_MATCHER,
|
|
1425
|
+
hooks: [{ type: HOOK_TYPE_COMMAND3, command: BLOCKED_COMMAND }]
|
|
1426
|
+
}
|
|
1427
|
+
];
|
|
1428
|
+
}
|
|
1429
|
+
function addPermissionHook(json) {
|
|
1430
|
+
const hooks = isRecord5(json.hooks) ? json.hooks : {};
|
|
1431
|
+
return {
|
|
1432
|
+
...json,
|
|
1433
|
+
hooks: {
|
|
1434
|
+
...hooks,
|
|
1435
|
+
[HOOK_EVENT]: appendPermissionEntry(hooks[HOOK_EVENT])
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
function removeHooks3(json) {
|
|
1440
|
+
return removeTurnbellHooks(json, [HOOK_EVENT], entryHasCommand2);
|
|
1441
|
+
}
|
|
1442
|
+
function joinDiffs2(configDiff, hooksDiff) {
|
|
1443
|
+
return [configDiff, hooksDiff].filter((diff) => diff.length > 0).join(NEWLINE);
|
|
1444
|
+
}
|
|
1445
|
+
var codex = {
|
|
1446
|
+
id: ID4,
|
|
1447
|
+
title: TITLE4,
|
|
1448
|
+
tagline: "OpenAI Codex \u2014 notify done/read-aloud and PermissionRequest blocked",
|
|
1449
|
+
capabilities: {
|
|
1450
|
+
done: true,
|
|
1451
|
+
blocked: true,
|
|
1452
|
+
readAloud: true,
|
|
1453
|
+
slashCommand: false,
|
|
1454
|
+
instructionInjection: true
|
|
1455
|
+
},
|
|
1456
|
+
memoryPath(deps) {
|
|
1457
|
+
return join6(deps.home, CONFIG_DIR2, "AGENTS.md");
|
|
1458
|
+
},
|
|
1459
|
+
detect(deps) {
|
|
1460
|
+
const installed = deps.which(BINARY3) !== null || isDir4(join6(deps.home, CONFIG_DIR2));
|
|
1461
|
+
const detection = { installed };
|
|
1462
|
+
if (installed) {
|
|
1463
|
+
detection.configPath = configPath(deps);
|
|
1464
|
+
}
|
|
1465
|
+
return Promise.resolve(detection);
|
|
1466
|
+
},
|
|
1467
|
+
wire(deps) {
|
|
1468
|
+
const cfgPath = configPath(deps);
|
|
1469
|
+
const originalConfig = readFileOrNull3(cfgPath);
|
|
1470
|
+
archiveExistingNotify(originalConfig);
|
|
1471
|
+
const configOp = wireTextFile(cfgPath, patchNotify(originalConfig), CONFIG_LEDGER_KEY);
|
|
1472
|
+
const hooksOp = wireJsonFile(
|
|
1473
|
+
hooksPath2(deps),
|
|
1474
|
+
(json) => addPermissionHook(removeHooks3(json)),
|
|
1475
|
+
HOOKS_LEDGER_KEY
|
|
1476
|
+
);
|
|
1477
|
+
const result = {
|
|
1478
|
+
changed: configOp.changed || hooksOp.changed,
|
|
1479
|
+
diff: joinDiffs2(configOp.diff, hooksOp.diff),
|
|
1480
|
+
warnings: [HOOK_TRUST_WARNING]
|
|
1481
|
+
};
|
|
1482
|
+
configOp.apply();
|
|
1483
|
+
hooksOp.apply();
|
|
1484
|
+
return Promise.resolve(result);
|
|
1485
|
+
},
|
|
1486
|
+
unwire(deps) {
|
|
1487
|
+
unwireTextFile(configPath(deps), restoreOrRemoveNotify, CONFIG_LEDGER_KEY);
|
|
1488
|
+
unwireJsonFile(hooksPath2(deps), removeHooks3, HOOKS_LEDGER_KEY);
|
|
1489
|
+
return Promise.resolve();
|
|
1490
|
+
},
|
|
1491
|
+
normalize(raw, eventHint) {
|
|
1492
|
+
if (!isRecord5(raw)) {
|
|
1493
|
+
return null;
|
|
1494
|
+
}
|
|
1495
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
1496
|
+
return {
|
|
1497
|
+
v: 1,
|
|
1498
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1499
|
+
agent: ID4,
|
|
1500
|
+
agentTitle: TITLE4,
|
|
1501
|
+
event: eventHint,
|
|
1502
|
+
cwd,
|
|
1503
|
+
project: projectLabel(cwd),
|
|
1504
|
+
summary: "",
|
|
1505
|
+
lastResponse: null
|
|
1506
|
+
};
|
|
1507
|
+
},
|
|
1508
|
+
readLastResponse(raw) {
|
|
1509
|
+
if (!isRecord5(raw)) {
|
|
1510
|
+
return Promise.resolve(null);
|
|
1511
|
+
}
|
|
1512
|
+
const message = raw[LAST_MESSAGE_FIELD];
|
|
1513
|
+
return Promise.resolve(typeof message === "string" ? message : null);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// src/adapters/copilot.ts
|
|
1518
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readSync as readSync2, statSync as statSync6 } from "fs";
|
|
1519
|
+
import { join as join7 } from "path";
|
|
1520
|
+
var ID5 = "copilot";
|
|
1521
|
+
var TITLE5 = "GitHub Copilot";
|
|
1522
|
+
var BINARY4 = "copilot";
|
|
1523
|
+
var CONFIG_DIR3 = ".copilot";
|
|
1524
|
+
var HOOKS_DIR = "hooks";
|
|
1525
|
+
var HOOKS_FILE3 = "turnbell.json";
|
|
1526
|
+
var LEGACY_HOOKS_FILE = "hollr.json";
|
|
1527
|
+
var LEDGER_KEY4 = "copilot:hooks";
|
|
1528
|
+
var MAX_TRANSCRIPT_BYTES2 = 2e6;
|
|
1529
|
+
var HOOKS_VERSION = 1;
|
|
1530
|
+
var HOOK_AGENT_STOP = "agentStop";
|
|
1531
|
+
var HOOK_NOTIFICATION2 = "notification";
|
|
1532
|
+
var HOOK_TYPE_COMMAND4 = "command";
|
|
1533
|
+
var ASSISTANT_EVENT = "assistant.message";
|
|
1534
|
+
var BLOCKED_MATCHER = "agent_idle|permission_prompt|elicitation_dialog";
|
|
1535
|
+
var DONE_COMMAND = "turnbell emit --agent copilot --event done --payload-stdin";
|
|
1536
|
+
var BLOCKED_COMMAND2 = "turnbell emit --agent copilot --event blocked --payload-stdin";
|
|
1537
|
+
function isRecord6(value) {
|
|
1538
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1539
|
+
}
|
|
1540
|
+
function hooksPath3(deps) {
|
|
1541
|
+
return join7(deps.home, CONFIG_DIR3, HOOKS_DIR, HOOKS_FILE3);
|
|
1542
|
+
}
|
|
1543
|
+
function isDir5(path) {
|
|
1544
|
+
try {
|
|
1545
|
+
return statSync6(path).isDirectory();
|
|
1546
|
+
} catch {
|
|
1547
|
+
return false;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
function readTail2(path) {
|
|
1551
|
+
try {
|
|
1552
|
+
const fd = openSync2(path, "r");
|
|
1553
|
+
try {
|
|
1554
|
+
const size = fstatSync2(fd).size;
|
|
1555
|
+
const start = Math.max(0, size - MAX_TRANSCRIPT_BYTES2);
|
|
1556
|
+
const length = size - start;
|
|
1557
|
+
const buffer = Buffer.alloc(length);
|
|
1558
|
+
readSync2(fd, buffer, 0, length, start);
|
|
1559
|
+
return buffer.toString("utf8");
|
|
1560
|
+
} finally {
|
|
1561
|
+
closeSync2(fd);
|
|
1562
|
+
}
|
|
1563
|
+
} catch {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
function joinTextBlocks(content) {
|
|
1568
|
+
if (!Array.isArray(content)) {
|
|
1569
|
+
return "";
|
|
1570
|
+
}
|
|
1571
|
+
return content.filter((block) => isRecord6(block)).map((block) => typeof block.text === "string" ? block.text : "").filter((text2) => text2.length > 0).join(" ");
|
|
1572
|
+
}
|
|
1573
|
+
function textFromData(data) {
|
|
1574
|
+
if (typeof data.text === "string") {
|
|
1575
|
+
return data.text;
|
|
1576
|
+
}
|
|
1577
|
+
if (typeof data.content === "string") {
|
|
1578
|
+
return data.content;
|
|
1579
|
+
}
|
|
1580
|
+
if (typeof data.message === "string") {
|
|
1581
|
+
return data.message;
|
|
1582
|
+
}
|
|
1583
|
+
return joinTextBlocks(data.content);
|
|
1584
|
+
}
|
|
1585
|
+
function assistantText2(line) {
|
|
1586
|
+
let obj;
|
|
1587
|
+
try {
|
|
1588
|
+
obj = JSON.parse(line);
|
|
1589
|
+
} catch {
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
if (!isRecord6(obj) || obj.type !== ASSISTANT_EVENT || !isRecord6(obj.data)) {
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
const text2 = textFromData(obj.data).trim();
|
|
1596
|
+
return text2.length > 0 ? text2 : null;
|
|
1597
|
+
}
|
|
1598
|
+
function commandHandler(command, matcher) {
|
|
1599
|
+
const handler = { type: HOOK_TYPE_COMMAND4, command };
|
|
1600
|
+
if (matcher !== void 0) {
|
|
1601
|
+
handler.matcher = matcher;
|
|
1602
|
+
}
|
|
1603
|
+
return handler;
|
|
1604
|
+
}
|
|
1605
|
+
function appendHandler(existing, command, matcher) {
|
|
1606
|
+
const list = Array.isArray(existing) ? existing : [];
|
|
1607
|
+
if (list.some((entry) => isRecord6(entry) && entry.command === command)) {
|
|
1608
|
+
return list;
|
|
1609
|
+
}
|
|
1610
|
+
return [...list, commandHandler(command, matcher)];
|
|
1611
|
+
}
|
|
1612
|
+
function addHooks2(json) {
|
|
1613
|
+
const hooks = isRecord6(json.hooks) ? json.hooks : {};
|
|
1614
|
+
return {
|
|
1615
|
+
...json,
|
|
1616
|
+
version: typeof json.version === "number" ? json.version : HOOKS_VERSION,
|
|
1617
|
+
hooks: {
|
|
1618
|
+
...hooks,
|
|
1619
|
+
[HOOK_AGENT_STOP]: appendHandler(hooks[HOOK_AGENT_STOP], DONE_COMMAND),
|
|
1620
|
+
[HOOK_NOTIFICATION2]: appendHandler(
|
|
1621
|
+
hooks[HOOK_NOTIFICATION2],
|
|
1622
|
+
BLOCKED_COMMAND2,
|
|
1623
|
+
BLOCKED_MATCHER
|
|
1624
|
+
)
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
var TURNBELL_COMMANDS2 = /* @__PURE__ */ new Set([
|
|
1629
|
+
DONE_COMMAND,
|
|
1630
|
+
BLOCKED_COMMAND2,
|
|
1631
|
+
legacyCommandVariant(DONE_COMMAND),
|
|
1632
|
+
legacyCommandVariant(BLOCKED_COMMAND2)
|
|
1633
|
+
]);
|
|
1634
|
+
function isTurnbellEntry3(entry) {
|
|
1635
|
+
return isRecord6(entry) && typeof entry.command === "string" && TURNBELL_COMMANDS2.has(entry.command);
|
|
1636
|
+
}
|
|
1637
|
+
function removeHooks4(json) {
|
|
1638
|
+
return removeTurnbellHooks(json, [HOOK_AGENT_STOP, HOOK_NOTIFICATION2], isTurnbellEntry3);
|
|
1639
|
+
}
|
|
1640
|
+
var copilot = {
|
|
1641
|
+
id: ID5,
|
|
1642
|
+
title: TITLE5,
|
|
1643
|
+
tagline: "GitHub Copilot CLI \u2014 agentStop/notification hooks and read-aloud",
|
|
1644
|
+
capabilities: {
|
|
1645
|
+
done: true,
|
|
1646
|
+
blocked: true,
|
|
1647
|
+
readAloud: true,
|
|
1648
|
+
slashCommand: false,
|
|
1649
|
+
instructionInjection: false
|
|
1650
|
+
},
|
|
1651
|
+
detect(deps) {
|
|
1652
|
+
const installed = deps.which(BINARY4) !== null || isDir5(join7(deps.home, CONFIG_DIR3));
|
|
1653
|
+
const detection = { installed };
|
|
1654
|
+
if (installed) {
|
|
1655
|
+
detection.configPath = hooksPath3(deps);
|
|
1656
|
+
}
|
|
1657
|
+
return Promise.resolve(detection);
|
|
1658
|
+
},
|
|
1659
|
+
wire(deps) {
|
|
1660
|
+
const op = wireJsonFile(hooksPath3(deps), addHooks2, LEDGER_KEY4);
|
|
1661
|
+
const result = {
|
|
1662
|
+
changed: op.changed,
|
|
1663
|
+
diff: op.diff,
|
|
1664
|
+
warnings: []
|
|
1665
|
+
};
|
|
1666
|
+
op.apply();
|
|
1667
|
+
return Promise.resolve(result);
|
|
1668
|
+
},
|
|
1669
|
+
unwire(deps) {
|
|
1670
|
+
unwireJsonFile(hooksPath3(deps), removeHooks4, LEDGER_KEY4);
|
|
1671
|
+
unwireCreatedFile(join7(deps.home, CONFIG_DIR3, HOOKS_DIR, LEGACY_HOOKS_FILE), LEDGER_KEY4);
|
|
1672
|
+
return Promise.resolve();
|
|
1673
|
+
},
|
|
1674
|
+
normalize(raw, eventHint) {
|
|
1675
|
+
if (!isRecord6(raw)) {
|
|
1676
|
+
return null;
|
|
1677
|
+
}
|
|
1678
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
1679
|
+
return {
|
|
1680
|
+
v: 1,
|
|
1681
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1682
|
+
agent: ID5,
|
|
1683
|
+
agentTitle: TITLE5,
|
|
1684
|
+
event: eventHint,
|
|
1685
|
+
cwd,
|
|
1686
|
+
project: projectLabel(cwd),
|
|
1687
|
+
summary: typeof raw.message === "string" ? raw.message : ""
|
|
1688
|
+
};
|
|
1689
|
+
},
|
|
1690
|
+
readLastResponse(raw) {
|
|
1691
|
+
if (!isRecord6(raw) || typeof raw.transcriptPath !== "string") {
|
|
1692
|
+
return Promise.resolve(null);
|
|
1693
|
+
}
|
|
1694
|
+
const data = readTail2(raw.transcriptPath);
|
|
1695
|
+
if (data === null) {
|
|
1696
|
+
return Promise.resolve(null);
|
|
1697
|
+
}
|
|
1698
|
+
const lines = data.split("\n");
|
|
1699
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1700
|
+
const text2 = assistantText2(lines[index] ?? "");
|
|
1701
|
+
if (text2 !== null) {
|
|
1702
|
+
return Promise.resolve(text2);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return Promise.resolve(null);
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
// src/adapters/cursor.ts
|
|
1710
|
+
import { statSync as statSync7 } from "fs";
|
|
1711
|
+
import { join as join8 } from "path";
|
|
1712
|
+
var ID6 = "cursor";
|
|
1713
|
+
var TITLE6 = "Cursor";
|
|
1714
|
+
var LEDGER_KEY5 = "cursor";
|
|
1715
|
+
var BINARY5 = "cursor-agent";
|
|
1716
|
+
var CONFIG_DIR4 = ".cursor";
|
|
1717
|
+
var HOOKS_FILE4 = "hooks.json";
|
|
1718
|
+
var HOOKS_VERSION2 = 1;
|
|
1719
|
+
var HOOK_STOP3 = "stop";
|
|
1720
|
+
var HOOK_TYPE_COMMAND5 = "command";
|
|
1721
|
+
var STOP_COMMAND2 = "turnbell emit --agent cursor --event done --payload-stdin";
|
|
1722
|
+
function isRecord7(value) {
|
|
1723
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1724
|
+
}
|
|
1725
|
+
function hooksPath4(deps) {
|
|
1726
|
+
return join8(deps.home, CONFIG_DIR4, HOOKS_FILE4);
|
|
1727
|
+
}
|
|
1728
|
+
function isDir6(path) {
|
|
1729
|
+
try {
|
|
1730
|
+
return statSync7(path).isDirectory();
|
|
1731
|
+
} catch {
|
|
1732
|
+
return false;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
function firstWorkspaceRoot(raw) {
|
|
1736
|
+
const roots = raw.workspace_roots;
|
|
1737
|
+
if (!Array.isArray(roots)) {
|
|
1738
|
+
return "";
|
|
1739
|
+
}
|
|
1740
|
+
const first = roots.find((root) => typeof root === "string" && root.length > 0);
|
|
1741
|
+
return typeof first === "string" ? first : "";
|
|
1742
|
+
}
|
|
1743
|
+
function resolveCwd(raw) {
|
|
1744
|
+
if (typeof raw.cwd === "string" && raw.cwd.length > 0) {
|
|
1745
|
+
return raw.cwd;
|
|
1746
|
+
}
|
|
1747
|
+
return firstWorkspaceRoot(raw);
|
|
1748
|
+
}
|
|
1749
|
+
function appendHook(existing, command) {
|
|
1750
|
+
const list = Array.isArray(existing) ? existing : [];
|
|
1751
|
+
if (list.some((entry) => isRecord7(entry) && entry.command === command)) {
|
|
1752
|
+
return list;
|
|
1753
|
+
}
|
|
1754
|
+
return [...list, { type: HOOK_TYPE_COMMAND5, command }];
|
|
1755
|
+
}
|
|
1756
|
+
function addHooks3(json) {
|
|
1757
|
+
const hooks = isRecord7(json.hooks) ? json.hooks : {};
|
|
1758
|
+
const version = typeof json.version === "number" ? json.version : HOOKS_VERSION2;
|
|
1759
|
+
return {
|
|
1760
|
+
...json,
|
|
1761
|
+
version,
|
|
1762
|
+
hooks: {
|
|
1763
|
+
...hooks,
|
|
1764
|
+
[HOOK_STOP3]: appendHook(hooks[HOOK_STOP3], STOP_COMMAND2)
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
var TURNBELL_COMMANDS3 = /* @__PURE__ */ new Set([
|
|
1769
|
+
STOP_COMMAND2,
|
|
1770
|
+
legacyCommandVariant(STOP_COMMAND2)
|
|
1771
|
+
]);
|
|
1772
|
+
function isTurnbellEntry4(entry) {
|
|
1773
|
+
return isRecord7(entry) && typeof entry.command === "string" && TURNBELL_COMMANDS3.has(entry.command);
|
|
1774
|
+
}
|
|
1775
|
+
function removeHooks5(json) {
|
|
1776
|
+
return removeTurnbellHooks(json, [HOOK_STOP3], isTurnbellEntry4);
|
|
1777
|
+
}
|
|
1778
|
+
var cursor = {
|
|
1779
|
+
id: ID6,
|
|
1780
|
+
title: TITLE6,
|
|
1781
|
+
tagline: "Cursor Agent (cursor-agent) \u2014 done via stop hook (read-aloud/blocked come via the wrapper)",
|
|
1782
|
+
capabilities: {
|
|
1783
|
+
done: true,
|
|
1784
|
+
blocked: false,
|
|
1785
|
+
readAloud: false,
|
|
1786
|
+
slashCommand: false,
|
|
1787
|
+
instructionInjection: false
|
|
1788
|
+
},
|
|
1789
|
+
detect(deps) {
|
|
1790
|
+
const installed = deps.which(BINARY5) !== null || isDir6(join8(deps.home, CONFIG_DIR4));
|
|
1791
|
+
const detection = { installed };
|
|
1792
|
+
if (installed) {
|
|
1793
|
+
detection.configPath = hooksPath4(deps);
|
|
1794
|
+
}
|
|
1795
|
+
return Promise.resolve(detection);
|
|
1796
|
+
},
|
|
1797
|
+
wire(deps) {
|
|
1798
|
+
const op = wireJsonFile(hooksPath4(deps), (json) => addHooks3(removeHooks5(json)), LEDGER_KEY5);
|
|
1799
|
+
const result = {
|
|
1800
|
+
changed: op.changed,
|
|
1801
|
+
diff: op.diff,
|
|
1802
|
+
warnings: []
|
|
1803
|
+
};
|
|
1804
|
+
op.apply();
|
|
1805
|
+
return Promise.resolve(result);
|
|
1806
|
+
},
|
|
1807
|
+
unwire(deps) {
|
|
1808
|
+
unwireJsonFile(hooksPath4(deps), removeHooks5, LEDGER_KEY5);
|
|
1809
|
+
return Promise.resolve();
|
|
1810
|
+
},
|
|
1811
|
+
normalize(raw, eventHint) {
|
|
1812
|
+
if (!isRecord7(raw)) {
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
const cwd = resolveCwd(raw);
|
|
1816
|
+
return {
|
|
1817
|
+
v: 1,
|
|
1818
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1819
|
+
agent: ID6,
|
|
1820
|
+
agentTitle: TITLE6,
|
|
1821
|
+
event: eventHint,
|
|
1822
|
+
cwd,
|
|
1823
|
+
project: projectLabel(cwd),
|
|
1824
|
+
summary: "",
|
|
1825
|
+
lastResponse: null
|
|
1826
|
+
};
|
|
1827
|
+
},
|
|
1828
|
+
readLastResponse(_raw) {
|
|
1829
|
+
return Promise.resolve(null);
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// src/adapters/gemini.ts
|
|
1834
|
+
import { join as join9 } from "path";
|
|
1835
|
+
var ID7 = "gemini";
|
|
1836
|
+
var TITLE7 = "Gemini";
|
|
1837
|
+
var BINARY6 = "gemini";
|
|
1838
|
+
var CONFIG_DIR5 = ".gemini";
|
|
1839
|
+
var SETTINGS_FILE = "settings.json";
|
|
1840
|
+
var COMMANDS_DIR2 = "commands";
|
|
1841
|
+
var COMMAND_FILE2 = "turnbell.toml";
|
|
1842
|
+
var LEGACY_COMMAND_FILE2 = "hollr.toml";
|
|
1843
|
+
var SETTINGS_LEDGER_KEY = "gemini:settings";
|
|
1844
|
+
var COMMAND_LEDGER_KEY2 = "gemini:command";
|
|
1845
|
+
var HOOK_AFTER_AGENT = "AfterAgent";
|
|
1846
|
+
var HOOK_NOTIFICATION3 = "Notification";
|
|
1847
|
+
var HOOK_TYPE_COMMAND6 = "command";
|
|
1848
|
+
var DONE_COMMAND2 = "turnbell emit --agent gemini --event done --payload-stdin";
|
|
1849
|
+
var BLOCKED_COMMAND3 = "turnbell emit --agent gemini --event blocked --payload-stdin";
|
|
1850
|
+
var LAST_RESPONSE_FIELD = "prompt_response";
|
|
1851
|
+
var COMMAND_TEMPLATE2 = `# Managed by turnbell \u2014 reversible via \`turnbell uninstall\`. Do not edit by hand.
|
|
1852
|
+
description = "Run the turnbell CLI (voice + desktop notifications) from inside Gemini"
|
|
1853
|
+
prompt = """
|
|
1854
|
+
Below is the raw output of the turnbell command-line tool. Relay it to the user as-is.
|
|
1855
|
+
|
|
1856
|
+
!{turnbell {{args}}}
|
|
1857
|
+
"""
|
|
1858
|
+
`;
|
|
1859
|
+
function isRecord8(value) {
|
|
1860
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1861
|
+
}
|
|
1862
|
+
function settingsPath2(deps) {
|
|
1863
|
+
return join9(deps.home, CONFIG_DIR5, SETTINGS_FILE);
|
|
1864
|
+
}
|
|
1865
|
+
function commandPath2(deps) {
|
|
1866
|
+
return join9(deps.home, CONFIG_DIR5, COMMANDS_DIR2, COMMAND_FILE2);
|
|
1867
|
+
}
|
|
1868
|
+
function entryHasCommand3(entry, command) {
|
|
1869
|
+
if (!isRecord8(entry) || !Array.isArray(entry.hooks)) {
|
|
1870
|
+
return false;
|
|
1871
|
+
}
|
|
1872
|
+
return entry.hooks.some((hook) => isRecord8(hook) && hook.command === command);
|
|
1873
|
+
}
|
|
1874
|
+
function appendTurnbellHook2(existing, command) {
|
|
1875
|
+
const list = Array.isArray(existing) ? existing : [];
|
|
1876
|
+
if (list.some((entry) => entryHasCommand3(entry, command))) {
|
|
1877
|
+
return list;
|
|
1878
|
+
}
|
|
1879
|
+
return [...list, { hooks: [{ type: HOOK_TYPE_COMMAND6, command }] }];
|
|
1880
|
+
}
|
|
1881
|
+
function addHooks4(json) {
|
|
1882
|
+
const hooks = isRecord8(json.hooks) ? json.hooks : {};
|
|
1883
|
+
return {
|
|
1884
|
+
...json,
|
|
1885
|
+
hooks: {
|
|
1886
|
+
...hooks,
|
|
1887
|
+
[HOOK_AFTER_AGENT]: appendTurnbellHook2(hooks[HOOK_AFTER_AGENT], DONE_COMMAND2),
|
|
1888
|
+
[HOOK_NOTIFICATION3]: appendTurnbellHook2(
|
|
1889
|
+
hooks[HOOK_NOTIFICATION3],
|
|
1890
|
+
BLOCKED_COMMAND3
|
|
1891
|
+
)
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
function joinDiffs3(settingsDiff, commandDiff) {
|
|
1896
|
+
return [settingsDiff, commandDiff].filter((diff) => diff.length > 0).join("\n");
|
|
1897
|
+
}
|
|
1898
|
+
var TURNBELL_COMMANDS4 = /* @__PURE__ */ new Set([
|
|
1899
|
+
DONE_COMMAND2,
|
|
1900
|
+
BLOCKED_COMMAND3,
|
|
1901
|
+
legacyCommandVariant(DONE_COMMAND2),
|
|
1902
|
+
legacyCommandVariant(BLOCKED_COMMAND3)
|
|
1903
|
+
]);
|
|
1904
|
+
function isTurnbellEntry5(entry) {
|
|
1905
|
+
return isRecord8(entry) && Array.isArray(entry.hooks) && entry.hooks.some(
|
|
1906
|
+
(hook) => isRecord8(hook) && typeof hook.command === "string" && TURNBELL_COMMANDS4.has(hook.command)
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
function removeHooks6(json) {
|
|
1910
|
+
return removeTurnbellHooks(json, [HOOK_AFTER_AGENT, HOOK_NOTIFICATION3], isTurnbellEntry5);
|
|
1911
|
+
}
|
|
1912
|
+
var gemini = {
|
|
1913
|
+
id: ID7,
|
|
1914
|
+
title: TITLE7,
|
|
1915
|
+
tagline: "Google Gemini CLI \u2014 AfterAgent done, Notification blocked, /turnbell command",
|
|
1916
|
+
capabilities: {
|
|
1917
|
+
done: true,
|
|
1918
|
+
blocked: true,
|
|
1919
|
+
readAloud: true,
|
|
1920
|
+
slashCommand: true,
|
|
1921
|
+
instructionInjection: true
|
|
1922
|
+
},
|
|
1923
|
+
memoryPath(deps) {
|
|
1924
|
+
return join9(deps.home, CONFIG_DIR5, "GEMINI.md");
|
|
1925
|
+
},
|
|
1926
|
+
detect(deps) {
|
|
1927
|
+
const installed = deps.which(BINARY6) !== null;
|
|
1928
|
+
const detection = { installed };
|
|
1929
|
+
if (installed) {
|
|
1930
|
+
detection.configPath = settingsPath2(deps);
|
|
1931
|
+
}
|
|
1932
|
+
return Promise.resolve(detection);
|
|
1933
|
+
},
|
|
1934
|
+
wire(deps) {
|
|
1935
|
+
const settingsOp = wireJsonFile(
|
|
1936
|
+
settingsPath2(deps),
|
|
1937
|
+
(json) => addHooks4(removeHooks6(json)),
|
|
1938
|
+
SETTINGS_LEDGER_KEY
|
|
1939
|
+
);
|
|
1940
|
+
const commandOp = wireTextFile(
|
|
1941
|
+
commandPath2(deps),
|
|
1942
|
+
COMMAND_TEMPLATE2,
|
|
1943
|
+
COMMAND_LEDGER_KEY2
|
|
1944
|
+
);
|
|
1945
|
+
const result = {
|
|
1946
|
+
changed: settingsOp.changed || commandOp.changed,
|
|
1947
|
+
diff: joinDiffs3(settingsOp.diff, commandOp.diff),
|
|
1948
|
+
warnings: []
|
|
1949
|
+
};
|
|
1950
|
+
settingsOp.apply();
|
|
1951
|
+
commandOp.apply();
|
|
1952
|
+
return Promise.resolve(result);
|
|
1953
|
+
},
|
|
1954
|
+
unwire(deps) {
|
|
1955
|
+
unwireJsonFile(settingsPath2(deps), removeHooks6, SETTINGS_LEDGER_KEY);
|
|
1956
|
+
unwireCreatedFile(
|
|
1957
|
+
join9(deps.home, CONFIG_DIR5, COMMANDS_DIR2, LEGACY_COMMAND_FILE2),
|
|
1958
|
+
COMMAND_LEDGER_KEY2
|
|
1959
|
+
);
|
|
1960
|
+
unwireCreatedFile(commandPath2(deps), COMMAND_LEDGER_KEY2);
|
|
1961
|
+
return Promise.resolve();
|
|
1962
|
+
},
|
|
1963
|
+
normalize(raw, eventHint) {
|
|
1964
|
+
if (!isRecord8(raw)) {
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
1968
|
+
return {
|
|
1969
|
+
v: 1,
|
|
1970
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1971
|
+
agent: ID7,
|
|
1972
|
+
agentTitle: TITLE7,
|
|
1973
|
+
event: eventHint,
|
|
1974
|
+
cwd,
|
|
1975
|
+
project: projectLabel(cwd),
|
|
1976
|
+
summary: typeof raw.message === "string" ? raw.message : "",
|
|
1977
|
+
lastResponse: null
|
|
1978
|
+
};
|
|
1979
|
+
},
|
|
1980
|
+
readLastResponse(raw) {
|
|
1981
|
+
if (!isRecord8(raw)) {
|
|
1982
|
+
return Promise.resolve(null);
|
|
1983
|
+
}
|
|
1984
|
+
const response = raw[LAST_RESPONSE_FIELD];
|
|
1985
|
+
return Promise.resolve(typeof response === "string" ? response : null);
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
// src/adapters/opencode.ts
|
|
1990
|
+
import { statSync as statSync8 } from "fs";
|
|
1991
|
+
import { join as join10 } from "path";
|
|
1992
|
+
var ID8 = "opencode";
|
|
1993
|
+
var TITLE8 = "opencode";
|
|
1994
|
+
var BINARY7 = "opencode";
|
|
1995
|
+
var LEDGER_KEY6 = "opencode";
|
|
1996
|
+
var CONFIG_SEGMENTS2 = [".config", "opencode"];
|
|
1997
|
+
var PLUGIN_DIR = "plugin";
|
|
1998
|
+
var PLUGIN_FILE = "turnbell.js";
|
|
1999
|
+
var LEGACY_PLUGIN_FILE = "hollr.js";
|
|
2000
|
+
var EVENT_SESSION_IDLE = "session.idle";
|
|
2001
|
+
var EVENT_PERMISSION_ASKED = "permission.asked";
|
|
2002
|
+
var EMIT_DONE = "done";
|
|
2003
|
+
var EMIT_BLOCKED = "blocked";
|
|
2004
|
+
var EMIT_COMMAND_PREFIX = `turnbell emit --agent ${ID8}`;
|
|
2005
|
+
var PLUGIN_TEMPLATE = `// Managed by turnbell \u2014 reversible via \`turnbell uninstall\`. Do not edit by hand.
|
|
2006
|
+
// Bridges opencode lifecycle events to the turnbell CLI (voice + desktop notifications).
|
|
2007
|
+
// opencode auto-loads .js/.ts files under ~/.config/opencode/plugin/ (and .opencode/plugin/).
|
|
2008
|
+
export const turnbell = async ({ $, directory }) => {
|
|
2009
|
+
const emit = async (event, properties) => {
|
|
2010
|
+
const payload = JSON.stringify({
|
|
2011
|
+
cwd: directory,
|
|
2012
|
+
sessionID: properties?.sessionID ?? null,
|
|
2013
|
+
});
|
|
2014
|
+
try {
|
|
2015
|
+
await $\`${EMIT_COMMAND_PREFIX} --event \${event} --payload-argv \${payload}\`
|
|
2016
|
+
.quiet()
|
|
2017
|
+
.nothrow();
|
|
2018
|
+
} catch {
|
|
2019
|
+
// A notification failure must never disrupt the coding session.
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
return {
|
|
2023
|
+
event: async ({ event }) => {
|
|
2024
|
+
if (event.type === "${EVENT_SESSION_IDLE}") {
|
|
2025
|
+
await emit("${EMIT_DONE}", event.properties);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
if (event.type === "${EVENT_PERMISSION_ASKED}") {
|
|
2029
|
+
await emit("${EMIT_BLOCKED}", event.properties);
|
|
2030
|
+
}
|
|
2031
|
+
},
|
|
2032
|
+
};
|
|
2033
|
+
};
|
|
2034
|
+
`;
|
|
2035
|
+
function isRecord9(value) {
|
|
2036
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2037
|
+
}
|
|
2038
|
+
function configDir2(deps) {
|
|
2039
|
+
return join10(deps.home, ...CONFIG_SEGMENTS2);
|
|
2040
|
+
}
|
|
2041
|
+
function pluginPath(deps) {
|
|
2042
|
+
return join10(configDir2(deps), PLUGIN_DIR, PLUGIN_FILE);
|
|
2043
|
+
}
|
|
2044
|
+
function isDir7(path) {
|
|
2045
|
+
try {
|
|
2046
|
+
return statSync8(path).isDirectory();
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
var opencode = {
|
|
2052
|
+
id: ID8,
|
|
2053
|
+
title: TITLE8,
|
|
2054
|
+
tagline: "sst opencode \u2014 plugin bridges session.idle done and permission.asked blocked",
|
|
2055
|
+
capabilities: {
|
|
2056
|
+
done: true,
|
|
2057
|
+
blocked: true,
|
|
2058
|
+
readAloud: false,
|
|
2059
|
+
slashCommand: false,
|
|
2060
|
+
instructionInjection: false
|
|
2061
|
+
},
|
|
2062
|
+
detect(deps) {
|
|
2063
|
+
const installed = deps.which(BINARY7) !== null || isDir7(configDir2(deps));
|
|
2064
|
+
const detection = { installed };
|
|
2065
|
+
if (installed) {
|
|
2066
|
+
detection.configPath = pluginPath(deps);
|
|
2067
|
+
}
|
|
2068
|
+
return Promise.resolve(detection);
|
|
2069
|
+
},
|
|
2070
|
+
wire(deps) {
|
|
2071
|
+
const op = wireTextFile(pluginPath(deps), PLUGIN_TEMPLATE, LEDGER_KEY6);
|
|
2072
|
+
const result = {
|
|
2073
|
+
changed: op.changed,
|
|
2074
|
+
diff: op.diff,
|
|
2075
|
+
warnings: []
|
|
2076
|
+
};
|
|
2077
|
+
op.apply();
|
|
2078
|
+
return Promise.resolve(result);
|
|
2079
|
+
},
|
|
2080
|
+
unwire(deps) {
|
|
2081
|
+
unwireCreatedFile(join10(configDir2(deps), PLUGIN_DIR, LEGACY_PLUGIN_FILE), LEDGER_KEY6);
|
|
2082
|
+
unwireCreatedFile(pluginPath(deps), LEDGER_KEY6);
|
|
2083
|
+
return Promise.resolve();
|
|
2084
|
+
},
|
|
2085
|
+
normalize(raw, eventHint) {
|
|
2086
|
+
if (!isRecord9(raw)) {
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2089
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
2090
|
+
return {
|
|
2091
|
+
v: 1,
|
|
2092
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2093
|
+
agent: ID8,
|
|
2094
|
+
agentTitle: TITLE8,
|
|
2095
|
+
event: eventHint,
|
|
2096
|
+
cwd,
|
|
2097
|
+
project: projectLabel(cwd),
|
|
2098
|
+
summary: "",
|
|
2099
|
+
lastResponse: null
|
|
2100
|
+
};
|
|
2101
|
+
},
|
|
2102
|
+
readLastResponse(_raw) {
|
|
2103
|
+
return Promise.resolve(null);
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
|
|
2107
|
+
// src/adapters/wrapper.ts
|
|
2108
|
+
var ID9 = "wrapper";
|
|
2109
|
+
var TITLE9 = "Wrapper";
|
|
2110
|
+
var USAGE_HINT = "wrap any agent with `turnbell run -- <cmd> [args...]`; add `--announce-stream cursor` before `--` for read-aloud of cursor output";
|
|
2111
|
+
var wrapper = {
|
|
2112
|
+
id: ID9,
|
|
2113
|
+
title: TITLE9,
|
|
2114
|
+
tagline: "Universal wrapper \u2014 announce any agent via `turnbell run -- <cmd>`",
|
|
2115
|
+
capabilities: {
|
|
2116
|
+
done: true,
|
|
2117
|
+
blocked: false,
|
|
2118
|
+
readAloud: true,
|
|
2119
|
+
slashCommand: false,
|
|
2120
|
+
instructionInjection: false
|
|
2121
|
+
},
|
|
2122
|
+
detect(_deps) {
|
|
2123
|
+
return Promise.resolve({ installed: true });
|
|
2124
|
+
},
|
|
2125
|
+
wire(_deps) {
|
|
2126
|
+
return Promise.resolve({ changed: false, diff: "", warnings: [USAGE_HINT] });
|
|
2127
|
+
},
|
|
2128
|
+
unwire(_deps) {
|
|
2129
|
+
return Promise.resolve();
|
|
2130
|
+
},
|
|
2131
|
+
normalize(_raw, _eventHint) {
|
|
2132
|
+
return null;
|
|
2133
|
+
},
|
|
2134
|
+
readLastResponse(_raw) {
|
|
2135
|
+
return Promise.resolve(null);
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
// src/adapters/registry.ts
|
|
2140
|
+
var adapters = [
|
|
2141
|
+
claudeCode,
|
|
2142
|
+
amp,
|
|
2143
|
+
antigravity,
|
|
2144
|
+
codex,
|
|
2145
|
+
copilot,
|
|
2146
|
+
cursor,
|
|
2147
|
+
gemini,
|
|
2148
|
+
opencode,
|
|
2149
|
+
wrapper
|
|
2150
|
+
];
|
|
2151
|
+
function byId(id) {
|
|
2152
|
+
return adapters.find((adapter) => adapter.id === id);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// src/core/control.ts
|
|
2156
|
+
import { readFileSync as readFileSync5, unlinkSync } from "fs";
|
|
2157
|
+
import { join as join13 } from "path";
|
|
2158
|
+
|
|
2159
|
+
// src/platform/index.ts
|
|
2160
|
+
import { spawn } from "child_process";
|
|
2161
|
+
import { accessSync, constants } from "fs";
|
|
2162
|
+
import { delimiter, join as join12 } from "path";
|
|
2163
|
+
|
|
2164
|
+
// src/platform/darwin.ts
|
|
2165
|
+
import { existsSync as existsSync2 } from "fs";
|
|
2166
|
+
|
|
2167
|
+
// src/platform/rate.ts
|
|
2168
|
+
var DEFAULT_RATE_WPM2 = 190;
|
|
2169
|
+
var SPD_BASELINE_WPM = DEFAULT_RATE_WPM2;
|
|
2170
|
+
var SPD_RATE_MIN = -100;
|
|
2171
|
+
var SPD_RATE_MAX = 100;
|
|
2172
|
+
function clamp(value, min, max) {
|
|
2173
|
+
return Math.max(min, Math.min(max, value));
|
|
2174
|
+
}
|
|
2175
|
+
function coerceRate(rateWpm) {
|
|
2176
|
+
return Number.isFinite(rateWpm) ? Math.trunc(rateWpm) : DEFAULT_RATE_WPM2;
|
|
2177
|
+
}
|
|
2178
|
+
function rateToSpd(rateWpm) {
|
|
2179
|
+
if (!Number.isFinite(rateWpm)) {
|
|
2180
|
+
return "0";
|
|
2181
|
+
}
|
|
2182
|
+
const points = Math.round(rateWpm - SPD_BASELINE_WPM);
|
|
2183
|
+
return String(clamp(points, SPD_RATE_MIN, SPD_RATE_MAX));
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// src/platform/darwin.ts
|
|
2187
|
+
var MAX_SPEECH_CHARS = 2e3;
|
|
2188
|
+
var MAX_NOTIFY_BODY = 200;
|
|
2189
|
+
var MAX_NOTIFY_TITLE = 60;
|
|
2190
|
+
var SOUND_DIR = "/System/Library/Sounds";
|
|
2191
|
+
var SOUND_NAME_RE = /^[A-Za-z]+$/;
|
|
2192
|
+
var SYSTEM_VOICE_SENTINELS = /* @__PURE__ */ new Set(["", "system", "default"]);
|
|
2193
|
+
var LOCALE_TOKEN_RE = /\s+[a-z]{2}_[A-Z]{2}/;
|
|
2194
|
+
var COLUMN_GAP_RE = /\s{2,}/;
|
|
2195
|
+
function isNamedVoice(voice) {
|
|
2196
|
+
if (voice === null) {
|
|
2197
|
+
return false;
|
|
2198
|
+
}
|
|
2199
|
+
return !SYSTEM_VOICE_SENTINELS.has(voice.trim().toLowerCase());
|
|
2200
|
+
}
|
|
2201
|
+
function applescriptSafe(text2) {
|
|
2202
|
+
return text2.replace(/\\/g, "").replace(/"/g, "'");
|
|
2203
|
+
}
|
|
2204
|
+
function parseVoiceLine(line) {
|
|
2205
|
+
if (line.trim().length === 0) {
|
|
2206
|
+
return null;
|
|
2207
|
+
}
|
|
2208
|
+
const boundary = LOCALE_TOKEN_RE.exec(line) ?? COLUMN_GAP_RE.exec(line);
|
|
2209
|
+
if (boundary === null || boundary.index === 0) {
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
const name = line.slice(0, boundary.index).trim();
|
|
2213
|
+
return name.length > 0 ? name : null;
|
|
2214
|
+
}
|
|
2215
|
+
var DarwinPlatform = class {
|
|
2216
|
+
id = "darwin";
|
|
2217
|
+
canPauseResume = true;
|
|
2218
|
+
requiredBinaries = [
|
|
2219
|
+
{ name: "say", fix: null },
|
|
2220
|
+
{ name: "osascript", fix: null },
|
|
2221
|
+
{ name: "afplay", fix: null, optional: true }
|
|
2222
|
+
];
|
|
2223
|
+
#fileExists;
|
|
2224
|
+
constructor(fileExists = existsSync2) {
|
|
2225
|
+
this.#fileExists = fileExists;
|
|
2226
|
+
}
|
|
2227
|
+
voiceArgv(text2, voice, rateWpm) {
|
|
2228
|
+
if (text2.trim().length === 0) {
|
|
2229
|
+
return null;
|
|
2230
|
+
}
|
|
2231
|
+
const voiceArgs = isNamedVoice(voice) ? ["-v", voice] : [];
|
|
2232
|
+
return [
|
|
2233
|
+
"say",
|
|
2234
|
+
...voiceArgs,
|
|
2235
|
+
"-r",
|
|
2236
|
+
String(coerceRate(rateWpm)),
|
|
2237
|
+
"--",
|
|
2238
|
+
text2.slice(0, MAX_SPEECH_CHARS)
|
|
2239
|
+
];
|
|
2240
|
+
}
|
|
2241
|
+
notifyArgv(title, body) {
|
|
2242
|
+
const safeBody = applescriptSafe(body).slice(0, MAX_NOTIFY_BODY);
|
|
2243
|
+
const safeTitle = applescriptSafe(title).slice(0, MAX_NOTIFY_TITLE);
|
|
2244
|
+
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
|
|
2245
|
+
return ["osascript", "-e", script];
|
|
2246
|
+
}
|
|
2247
|
+
soundArgv(soundName) {
|
|
2248
|
+
if (!SOUND_NAME_RE.test(soundName)) {
|
|
2249
|
+
return null;
|
|
2250
|
+
}
|
|
2251
|
+
const path = `${SOUND_DIR}/${soundName}.aiff`;
|
|
2252
|
+
return this.#fileExists(path) ? ["afplay", path] : null;
|
|
2253
|
+
}
|
|
2254
|
+
enumerateVoicesArgv() {
|
|
2255
|
+
return ["say", "-v", "?"];
|
|
2256
|
+
}
|
|
2257
|
+
parseVoicesOutput(raw) {
|
|
2258
|
+
const names = [];
|
|
2259
|
+
for (const line of raw.split("\n")) {
|
|
2260
|
+
const name = parseVoiceLine(line);
|
|
2261
|
+
if (name !== null) {
|
|
2262
|
+
names.push(name);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return names;
|
|
2266
|
+
}
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
// src/platform/linux.ts
|
|
2270
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2271
|
+
var MAX_SPEECH_CHARS2 = 2e3;
|
|
2272
|
+
var MAX_NOTIFY_TITLE2 = 60;
|
|
2273
|
+
var MAX_NOTIFY_BODY2 = 200;
|
|
2274
|
+
var SPD_SAY = "spd-say";
|
|
2275
|
+
var ESPEAK_NG = "espeak-ng";
|
|
2276
|
+
var ESPEAK = "espeak";
|
|
2277
|
+
var NOTIFY_SEND = "notify-send";
|
|
2278
|
+
var PAPLAY = "paplay";
|
|
2279
|
+
var APLAY = "aplay";
|
|
2280
|
+
var SOUND_DIR2 = "/usr/share/sounds/freedesktop/stereo";
|
|
2281
|
+
var SOUND_NAME_RE2 = /^[A-Za-z][A-Za-z0-9 _-]*$/;
|
|
2282
|
+
var ESPEAK_HEADER_PREFIX = "Pty";
|
|
2283
|
+
var ESPEAK_NAME_COLUMN = 3;
|
|
2284
|
+
var SPD_NAME_COLUMN = 0;
|
|
2285
|
+
function hasVoice(voice) {
|
|
2286
|
+
return voice !== null && voice.length > 0;
|
|
2287
|
+
}
|
|
2288
|
+
var LinuxPlatform = class {
|
|
2289
|
+
id = "linux";
|
|
2290
|
+
canPauseResume = true;
|
|
2291
|
+
requiredBinaries = [
|
|
2292
|
+
{ name: SPD_SAY, fix: "sudo apt install speech-dispatcher" },
|
|
2293
|
+
{ name: NOTIFY_SEND, fix: "sudo apt install libnotify-bin" },
|
|
2294
|
+
{ name: PAPLAY, fix: "sudo apt install pulseaudio-utils", optional: true }
|
|
2295
|
+
];
|
|
2296
|
+
#which;
|
|
2297
|
+
#fileExists;
|
|
2298
|
+
constructor(which, fileExists = existsSync3) {
|
|
2299
|
+
this.#which = which;
|
|
2300
|
+
this.#fileExists = fileExists;
|
|
2301
|
+
}
|
|
2302
|
+
voiceArgv(text2, voice, rateWpm) {
|
|
2303
|
+
if (text2.trim().length === 0) {
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
const body = text2.slice(0, MAX_SPEECH_CHARS2);
|
|
2307
|
+
if (this.#which(SPD_SAY) !== null) {
|
|
2308
|
+
const voiceArgs2 = hasVoice(voice) ? ["-y", voice] : [];
|
|
2309
|
+
return [SPD_SAY, ...voiceArgs2, "-r", rateToSpd(rateWpm), "--wait", body];
|
|
2310
|
+
}
|
|
2311
|
+
const espeak = this.#selectEspeak();
|
|
2312
|
+
if (espeak === null) {
|
|
2313
|
+
return null;
|
|
2314
|
+
}
|
|
2315
|
+
const voiceArgs = hasVoice(voice) ? ["-v", voice] : [];
|
|
2316
|
+
return [espeak, ...voiceArgs, "-s", String(coerceRate(rateWpm)), "--", body];
|
|
2317
|
+
}
|
|
2318
|
+
notifyArgv(title, body) {
|
|
2319
|
+
return [
|
|
2320
|
+
NOTIFY_SEND,
|
|
2321
|
+
"--app-name=turnbell",
|
|
2322
|
+
title.slice(0, MAX_NOTIFY_TITLE2),
|
|
2323
|
+
body.slice(0, MAX_NOTIFY_BODY2)
|
|
2324
|
+
];
|
|
2325
|
+
}
|
|
2326
|
+
soundArgv(soundName) {
|
|
2327
|
+
if (!SOUND_NAME_RE2.test(soundName)) {
|
|
2328
|
+
return null;
|
|
2329
|
+
}
|
|
2330
|
+
const path = `${SOUND_DIR2}/${soundName}.oga`;
|
|
2331
|
+
if (!this.#fileExists(path)) {
|
|
2332
|
+
return null;
|
|
2333
|
+
}
|
|
2334
|
+
if (this.#which(PAPLAY) !== null) {
|
|
2335
|
+
return [PAPLAY, path];
|
|
2336
|
+
}
|
|
2337
|
+
if (this.#which(APLAY) !== null) {
|
|
2338
|
+
return [APLAY, path];
|
|
2339
|
+
}
|
|
2340
|
+
return null;
|
|
2341
|
+
}
|
|
2342
|
+
enumerateVoicesArgv() {
|
|
2343
|
+
if (this.#which(SPD_SAY) !== null) {
|
|
2344
|
+
return [SPD_SAY, "-L"];
|
|
2345
|
+
}
|
|
2346
|
+
if (this.#which(ESPEAK_NG) !== null) {
|
|
2347
|
+
return [ESPEAK_NG, "--voices"];
|
|
2348
|
+
}
|
|
2349
|
+
return null;
|
|
2350
|
+
}
|
|
2351
|
+
parseVoicesOutput(raw) {
|
|
2352
|
+
const lines = raw.split("\n");
|
|
2353
|
+
const header = lines[0] ?? "";
|
|
2354
|
+
const column = header.trimStart().startsWith(ESPEAK_HEADER_PREFIX) ? ESPEAK_NAME_COLUMN : SPD_NAME_COLUMN;
|
|
2355
|
+
const names = [];
|
|
2356
|
+
for (const line of lines.slice(1)) {
|
|
2357
|
+
const name = line.trim().split(/\s+/)[column];
|
|
2358
|
+
if (name !== void 0 && name.length > 0) {
|
|
2359
|
+
names.push(name);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
return names;
|
|
2363
|
+
}
|
|
2364
|
+
/** Pick the available espeak binary (`espeak-ng` preferred), or `null`. */
|
|
2365
|
+
#selectEspeak() {
|
|
2366
|
+
if (this.#which(ESPEAK_NG) !== null) {
|
|
2367
|
+
return ESPEAK_NG;
|
|
2368
|
+
}
|
|
2369
|
+
if (this.#which(ESPEAK) !== null) {
|
|
2370
|
+
return ESPEAK;
|
|
2371
|
+
}
|
|
2372
|
+
return null;
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
|
|
2376
|
+
// src/platform/win32.ts
|
|
2377
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
2378
|
+
import { join as join11 } from "path";
|
|
2379
|
+
var POWERSHELL = ["powershell", "-NoProfile", "-Command"];
|
|
2380
|
+
var TMP_SUBDIR = "tmp";
|
|
2381
|
+
var SPEECH_TMP_PREFIX = "speech";
|
|
2382
|
+
var NOTIFY_TITLE_TMP_PREFIX = "notify-title";
|
|
2383
|
+
var NOTIFY_BODY_TMP_PREFIX = "notify-body";
|
|
2384
|
+
var RATE_MIN = -10;
|
|
2385
|
+
var RATE_MAX = 10;
|
|
2386
|
+
var RATE_BASE_WPM = 190;
|
|
2387
|
+
var RATE_SLOW_ANCHOR_WPM = 150;
|
|
2388
|
+
var RATE_SLOW_ANCHOR = -3;
|
|
2389
|
+
var RATE_FAST_ANCHOR_WPM = 220;
|
|
2390
|
+
var RATE_FAST_ANCHOR = 3;
|
|
2391
|
+
var RATE_SLOW_SLOPE = RATE_SLOW_ANCHOR / (RATE_SLOW_ANCHOR_WPM - RATE_BASE_WPM);
|
|
2392
|
+
var RATE_FAST_SLOPE = RATE_FAST_ANCHOR / (RATE_FAST_ANCHOR_WPM - RATE_BASE_WPM);
|
|
2393
|
+
var VOICE_NAME_RE = /^[A-Za-z0-9 ]+$/;
|
|
2394
|
+
var SOUND_DIR3 = "C:\\Windows\\Media";
|
|
2395
|
+
var SOUND_NAME_RE3 = /^[A-Za-z][A-Za-z0-9 _-]*$/;
|
|
2396
|
+
function toSynthRate(rateWpm) {
|
|
2397
|
+
if (!Number.isFinite(rateWpm)) {
|
|
2398
|
+
return 0;
|
|
2399
|
+
}
|
|
2400
|
+
const delta = rateWpm - RATE_BASE_WPM;
|
|
2401
|
+
const slope = delta < 0 ? RATE_SLOW_SLOPE : RATE_FAST_SLOPE;
|
|
2402
|
+
const rate = Math.round(delta * slope);
|
|
2403
|
+
return Math.max(RATE_MIN, Math.min(RATE_MAX, rate));
|
|
2404
|
+
}
|
|
2405
|
+
var Win32Platform = class {
|
|
2406
|
+
id = "win32";
|
|
2407
|
+
canPauseResume = false;
|
|
2408
|
+
requiredBinaries = [{ name: "powershell", fix: null }];
|
|
2409
|
+
#fileExists;
|
|
2410
|
+
// `which` is injected for construction symmetry with the other engines, but
|
|
2411
|
+
// win32 depends only on the built-in `powershell`, so it is not retained.
|
|
2412
|
+
constructor(_which, fileExists = existsSync4) {
|
|
2413
|
+
this.#fileExists = fileExists;
|
|
2414
|
+
}
|
|
2415
|
+
voiceArgv(text2, voice, rateWpm) {
|
|
2416
|
+
if (text2.trim().length === 0) {
|
|
2417
|
+
return null;
|
|
2418
|
+
}
|
|
2419
|
+
const tmp = this.#writeTmp(SPEECH_TMP_PREFIX, text2);
|
|
2420
|
+
const voiceLine = voice !== null && VOICE_NAME_RE.test(voice) ? `$s.SelectVoice('${voice}')` : null;
|
|
2421
|
+
const script = [
|
|
2422
|
+
"Add-Type -AssemblyName System.Speech",
|
|
2423
|
+
"$s=New-Object System.Speech.Synthesis.SpeechSynthesizer",
|
|
2424
|
+
`$s.Rate=${toSynthRate(rateWpm)}`,
|
|
2425
|
+
...voiceLine === null ? [] : [voiceLine],
|
|
2426
|
+
`$s.Speak([IO.File]::ReadAllText('${tmp}'))`,
|
|
2427
|
+
removeItem(tmp)
|
|
2428
|
+
].join("; ");
|
|
2429
|
+
return [...POWERSHELL, script];
|
|
2430
|
+
}
|
|
2431
|
+
notifyArgv(title, body) {
|
|
2432
|
+
const titleTmp = this.#writeTmp(NOTIFY_TITLE_TMP_PREFIX, title);
|
|
2433
|
+
const bodyTmp = this.#writeTmp(NOTIFY_BODY_TMP_PREFIX, body);
|
|
2434
|
+
const script = [
|
|
2435
|
+
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null",
|
|
2436
|
+
"$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
2437
|
+
"$n=$t.GetElementsByTagName('text')",
|
|
2438
|
+
`$n.Item(0).AppendChild($t.CreateTextNode([IO.File]::ReadAllText('${titleTmp}'))) > $null`,
|
|
2439
|
+
`$n.Item(1).AppendChild($t.CreateTextNode([IO.File]::ReadAllText('${bodyTmp}'))) > $null`,
|
|
2440
|
+
"$toast=[Windows.UI.Notifications.ToastNotification]::new($t)",
|
|
2441
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('turnbell').Show($toast)",
|
|
2442
|
+
removeItem(titleTmp),
|
|
2443
|
+
removeItem(bodyTmp)
|
|
2444
|
+
].join("; ");
|
|
2445
|
+
return [...POWERSHELL, script];
|
|
2446
|
+
}
|
|
2447
|
+
soundArgv(soundName) {
|
|
2448
|
+
if (!SOUND_NAME_RE3.test(soundName)) {
|
|
2449
|
+
return null;
|
|
2450
|
+
}
|
|
2451
|
+
const path = `${SOUND_DIR3}\\${soundName}.wav`;
|
|
2452
|
+
if (!this.#fileExists(path)) {
|
|
2453
|
+
return null;
|
|
2454
|
+
}
|
|
2455
|
+
return [...POWERSHELL, `(New-Object Media.SoundPlayer '${path}').PlaySync()`];
|
|
2456
|
+
}
|
|
2457
|
+
enumerateVoicesArgv() {
|
|
2458
|
+
const script = "Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }";
|
|
2459
|
+
return [...POWERSHELL, script];
|
|
2460
|
+
}
|
|
2461
|
+
parseVoicesOutput(raw) {
|
|
2462
|
+
const names = [];
|
|
2463
|
+
for (const line of raw.split("\n")) {
|
|
2464
|
+
const name = line.trim();
|
|
2465
|
+
if (name.length > 0) {
|
|
2466
|
+
names.push(name);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
return names;
|
|
2470
|
+
}
|
|
2471
|
+
/** Write `text` to `<TURNBELL_HOME>/tmp/<prefix>-<pid>.txt` and return its path. */
|
|
2472
|
+
#writeTmp(prefix, text2) {
|
|
2473
|
+
const dir = join11(turnbellHome(), TMP_SUBDIR);
|
|
2474
|
+
mkdirSync4(dir, { recursive: true });
|
|
2475
|
+
const path = join11(dir, `${prefix}-${process.pid}.txt`);
|
|
2476
|
+
writeFileSync4(path, text2, "utf8");
|
|
2477
|
+
return path;
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
function removeItem(path) {
|
|
2481
|
+
return `Remove-Item '${path}' -ErrorAction SilentlyContinue`;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// src/platform/index.ts
|
|
2485
|
+
function whichOnPath(bin) {
|
|
2486
|
+
const pathEnv = process.env.PATH;
|
|
2487
|
+
if (pathEnv === void 0 || pathEnv.length === 0) {
|
|
2488
|
+
return null;
|
|
2489
|
+
}
|
|
2490
|
+
const isWindows = process.platform === "win32";
|
|
2491
|
+
const mode = isWindows ? constants.F_OK : constants.X_OK;
|
|
2492
|
+
const extensions = isWindows ? (process.env.PATHEXT ?? ".EXE").split(delimiter) : [""];
|
|
2493
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
2494
|
+
if (dir.length === 0) {
|
|
2495
|
+
continue;
|
|
2496
|
+
}
|
|
2497
|
+
for (const extension of extensions) {
|
|
2498
|
+
const candidate = join12(dir, `${bin}${extension}`);
|
|
2499
|
+
try {
|
|
2500
|
+
accessSync(candidate, mode);
|
|
2501
|
+
return candidate;
|
|
2502
|
+
} catch {
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
return null;
|
|
2507
|
+
}
|
|
2508
|
+
function selectPlatform(id = process.platform) {
|
|
2509
|
+
switch (id) {
|
|
2510
|
+
case "darwin":
|
|
2511
|
+
return new DarwinPlatform();
|
|
2512
|
+
case "linux":
|
|
2513
|
+
return new LinuxPlatform(whichOnPath);
|
|
2514
|
+
case "win32":
|
|
2515
|
+
return new Win32Platform(whichOnPath);
|
|
2516
|
+
default:
|
|
2517
|
+
throw new Error(`unsupported platform: ${id}`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
function spawnDetached(argv) {
|
|
2521
|
+
const command = argv[0];
|
|
2522
|
+
if (command === void 0) {
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const child = spawn(command, argv.slice(1), {
|
|
2526
|
+
detached: true,
|
|
2527
|
+
stdio: "ignore"
|
|
2528
|
+
});
|
|
2529
|
+
child.on("error", () => {
|
|
2530
|
+
});
|
|
2531
|
+
child.unref();
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// src/core/control.ts
|
|
2535
|
+
var READING_PIDFILE_NAME = "reading.pid";
|
|
2536
|
+
var SIGNAL_STOP = "SIGSTOP";
|
|
2537
|
+
var SIGNAL_CONT = "SIGCONT";
|
|
2538
|
+
var SIGNAL_TERM = "SIGTERM";
|
|
2539
|
+
var MSG_PAUSED = "turnbell: reading paused";
|
|
2540
|
+
var MSG_RESUMED = "turnbell: reading resumed";
|
|
2541
|
+
var MSG_STOPPED = "turnbell: reading stopped";
|
|
2542
|
+
var MSG_NOTHING = "turnbell: nothing is being read";
|
|
2543
|
+
var MSG_PAUSE_UNSUPPORTED = "turnbell: pause is not supported on Windows \u2014 use turnbell stop";
|
|
2544
|
+
var INTEGER_RE2 = /^-?\d+$/;
|
|
2545
|
+
function defaultDeps() {
|
|
2546
|
+
return {
|
|
2547
|
+
platform: selectPlatform(),
|
|
2548
|
+
kill: process.kill.bind(process),
|
|
2549
|
+
pidPath: join13(turnbellHome(), READING_PIDFILE_NAME)
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
function readPid(pidPath) {
|
|
2553
|
+
let raw;
|
|
2554
|
+
try {
|
|
2555
|
+
raw = readFileSync5(pidPath, "utf8").trim();
|
|
2556
|
+
} catch {
|
|
2557
|
+
return null;
|
|
2558
|
+
}
|
|
2559
|
+
if (!INTEGER_RE2.test(raw)) {
|
|
2560
|
+
return null;
|
|
2561
|
+
}
|
|
2562
|
+
return Number.parseInt(raw, 10);
|
|
2563
|
+
}
|
|
2564
|
+
function clearPid(pidPath) {
|
|
2565
|
+
try {
|
|
2566
|
+
unlinkSync(pidPath);
|
|
2567
|
+
} catch {
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
function signalReader(deps, signal, successMessage) {
|
|
2571
|
+
const pid = readPid(deps.pidPath);
|
|
2572
|
+
if (pid === null) {
|
|
2573
|
+
return MSG_NOTHING;
|
|
2574
|
+
}
|
|
2575
|
+
try {
|
|
2576
|
+
if (signal === void 0) {
|
|
2577
|
+
deps.kill(pid);
|
|
2578
|
+
} else {
|
|
2579
|
+
deps.kill(pid, signal);
|
|
2580
|
+
}
|
|
2581
|
+
} catch {
|
|
2582
|
+
clearPid(deps.pidPath);
|
|
2583
|
+
return MSG_NOTHING;
|
|
2584
|
+
}
|
|
2585
|
+
return successMessage;
|
|
2586
|
+
}
|
|
2587
|
+
function pauseReading(deps = defaultDeps()) {
|
|
2588
|
+
if (!deps.platform.canPauseResume) {
|
|
2589
|
+
return MSG_PAUSE_UNSUPPORTED;
|
|
2590
|
+
}
|
|
2591
|
+
return signalReader(deps, SIGNAL_STOP, MSG_PAUSED);
|
|
2592
|
+
}
|
|
2593
|
+
function resumeReading(deps = defaultDeps()) {
|
|
2594
|
+
if (!deps.platform.canPauseResume) {
|
|
2595
|
+
return MSG_PAUSE_UNSUPPORTED;
|
|
2596
|
+
}
|
|
2597
|
+
return signalReader(deps, SIGNAL_CONT, MSG_RESUMED);
|
|
2598
|
+
}
|
|
2599
|
+
function stopReading(deps = defaultDeps()) {
|
|
2600
|
+
const signal = deps.platform.canPauseResume ? SIGNAL_TERM : void 0;
|
|
2601
|
+
const message = signalReader(deps, signal, MSG_STOPPED);
|
|
2602
|
+
if (message === MSG_STOPPED) {
|
|
2603
|
+
clearPid(deps.pidPath);
|
|
2604
|
+
}
|
|
2605
|
+
return message;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// src/core/doctor.ts
|
|
2609
|
+
import { homedir as homedir2 } from "os";
|
|
2610
|
+
var MIN_NODE_MAJOR = 20;
|
|
2611
|
+
var NODE_KEY = "node";
|
|
2612
|
+
var NODE_LABEL = "Node.js";
|
|
2613
|
+
var NODE_FIX = `upgrade Node to >= ${MIN_NODE_MAJOR}`;
|
|
2614
|
+
var BETA_NOTES = {
|
|
2615
|
+
linux: "Linux support is beta \u2014 engines are mock-tested, not yet live-verified.",
|
|
2616
|
+
win32: "Windows support is beta \u2014 engines are mock-tested, not yet live-verified."
|
|
2617
|
+
};
|
|
2618
|
+
function stripVersionPrefix(version) {
|
|
2619
|
+
return version.startsWith("v") ? version.slice(1) : version;
|
|
2620
|
+
}
|
|
2621
|
+
function parseMajor(version) {
|
|
2622
|
+
const major = Number.parseInt(stripVersionPrefix(version).split(".")[0] ?? "", 10);
|
|
2623
|
+
return Number.isNaN(major) ? null : major;
|
|
2624
|
+
}
|
|
2625
|
+
function checkNode(nodeVersion) {
|
|
2626
|
+
const major = parseMajor(nodeVersion);
|
|
2627
|
+
const ok = major !== null && major >= MIN_NODE_MAJOR;
|
|
2628
|
+
const version = `v${stripVersionPrefix(nodeVersion)}`;
|
|
2629
|
+
const detail = ok ? `${version} (>= ${MIN_NODE_MAJOR} required)` : `${version} is too old (>= ${MIN_NODE_MAJOR} required)`;
|
|
2630
|
+
return {
|
|
2631
|
+
key: NODE_KEY,
|
|
2632
|
+
label: NODE_LABEL,
|
|
2633
|
+
ok,
|
|
2634
|
+
required: true,
|
|
2635
|
+
detail,
|
|
2636
|
+
fix: ok ? null : NODE_FIX
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
function checkBinaries(platform, which) {
|
|
2640
|
+
const betaNote = BETA_NOTES[platform.id];
|
|
2641
|
+
return platform.requiredBinaries.map((entry) => {
|
|
2642
|
+
const resolved = which(entry.name);
|
|
2643
|
+
const ok = resolved !== null;
|
|
2644
|
+
let detail = ok ? `found at ${resolved}` : "not found on PATH";
|
|
2645
|
+
if (betaNote !== void 0) {
|
|
2646
|
+
detail = `${detail} \u2014 ${betaNote}`;
|
|
2647
|
+
}
|
|
2648
|
+
return {
|
|
2649
|
+
key: entry.name,
|
|
2650
|
+
label: entry.name,
|
|
2651
|
+
ok,
|
|
2652
|
+
required: entry.optional !== true,
|
|
2653
|
+
detail,
|
|
2654
|
+
fix: entry.fix
|
|
2655
|
+
};
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
async function checkAdapter(agent, deps) {
|
|
2659
|
+
let ok = false;
|
|
2660
|
+
let detail = "not installed";
|
|
2661
|
+
try {
|
|
2662
|
+
const result = await agent.detect(deps);
|
|
2663
|
+
ok = result.installed;
|
|
2664
|
+
if (result.installed) {
|
|
2665
|
+
detail = result.degraded === void 0 ? "installed" : `installed (degraded: ${result.degraded})`;
|
|
2666
|
+
}
|
|
2667
|
+
} catch {
|
|
2668
|
+
detail = "not installed (detection failed)";
|
|
2669
|
+
}
|
|
2670
|
+
return { key: agent.id, label: agent.title, ok, required: false, detail, fix: null };
|
|
2671
|
+
}
|
|
2672
|
+
async function checkAll(deps) {
|
|
2673
|
+
const nodeVersion = deps.nodeVersion ?? process.versions.node;
|
|
2674
|
+
const adapterDeps2 = {
|
|
2675
|
+
home: deps.home ?? homedir2(),
|
|
2676
|
+
which: deps.which
|
|
2677
|
+
};
|
|
2678
|
+
const adapterChecks = await Promise.all(
|
|
2679
|
+
(deps.adapters ?? []).map((agent) => checkAdapter(agent, adapterDeps2))
|
|
2680
|
+
);
|
|
2681
|
+
return [
|
|
2682
|
+
checkNode(nodeVersion),
|
|
2683
|
+
...checkBinaries(deps.platform, deps.which),
|
|
2684
|
+
...adapterChecks
|
|
2685
|
+
];
|
|
2686
|
+
}
|
|
2687
|
+
function allRequiredOk(checks) {
|
|
2688
|
+
return checks.every((check) => !check.required || check.ok);
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// src/adapters/instruction.ts
|
|
2692
|
+
import { join as join14 } from "path";
|
|
2693
|
+
var READALOUD_MARKER = "turnbell:readaloud";
|
|
2694
|
+
var LEGACY_READALOUD_MARKER = "hollr:readaloud";
|
|
2695
|
+
function readaloudTempDir() {
|
|
2696
|
+
return join14(turnbellHome(), "readaloud");
|
|
2697
|
+
}
|
|
2698
|
+
function readaloudLedgerKey(adapterId) {
|
|
2699
|
+
return `${adapterId}:readaloud`;
|
|
2700
|
+
}
|
|
2701
|
+
function buildReadaloudBlock(openCommand) {
|
|
2702
|
+
const dir = readaloudTempDir();
|
|
2703
|
+
return [
|
|
2704
|
+
"Your responses are being read aloud (text-to-speech). For the FINAL message each turn:",
|
|
2705
|
+
"- Write plain, speakable sentences. No headers, tables, bullet lists, or inline backticks in the spoken part.",
|
|
2706
|
+
`- When the answer needs code, long output, or dense technical detail, DON'T speak it: write it to a temp .md file under ${dir} and open it with \`${openCommand} <file>\`, then say one sentence pointing the user there.`,
|
|
2707
|
+
"- Keep file-dumps INTENTIONAL and rare \u2014 only when detail truly won't read aloud well. Never create files for short or conversational answers.",
|
|
2708
|
+
"This shapes only how you present the final message; it does not change the work you do."
|
|
2709
|
+
].join("\n");
|
|
2710
|
+
}
|
|
2711
|
+
function injectReadaloud(memoryPath, openCommand, adapterId) {
|
|
2712
|
+
return wireMarkedSection(
|
|
2713
|
+
memoryPath,
|
|
2714
|
+
READALOUD_MARKER,
|
|
2715
|
+
buildReadaloudBlock(openCommand),
|
|
2716
|
+
readaloudLedgerKey(adapterId),
|
|
2717
|
+
[LEGACY_READALOUD_MARKER]
|
|
2718
|
+
);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// src/core/prune.ts
|
|
2722
|
+
import { readdirSync as readdirSync2, statSync as statSync9, unlinkSync as unlinkSync2 } from "fs";
|
|
2723
|
+
import { join as join15 } from "path";
|
|
2724
|
+
var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2725
|
+
function pruneReadaloudDir(dir, now, ttlMs = DEFAULT_TTL_MS) {
|
|
2726
|
+
let entries;
|
|
2727
|
+
try {
|
|
2728
|
+
entries = readdirSync2(dir);
|
|
2729
|
+
} catch {
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
for (const entry of entries) {
|
|
2733
|
+
const path = join15(dir, entry);
|
|
2734
|
+
try {
|
|
2735
|
+
const stat = statSync9(path);
|
|
2736
|
+
if (stat.isFile() && now.getTime() - stat.mtimeMs > ttlMs) {
|
|
2737
|
+
unlinkSync2(path);
|
|
2738
|
+
}
|
|
2739
|
+
} catch {
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// src/core/router.ts
|
|
2745
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2746
|
+
import { join as join16 } from "path";
|
|
2747
|
+
var NOTIFY_TITLE = "turnbell";
|
|
2748
|
+
var HINT_MESSAGE = "turnbell: not configured \u2014 run turnbell init\n";
|
|
2749
|
+
var HINT_MARKER = "hint-shown";
|
|
2750
|
+
var EVENTS_LOG = "events.log";
|
|
2751
|
+
var EVENTS_LOG_CAP = 50;
|
|
2752
|
+
var SUPPRESS = "suppress";
|
|
2753
|
+
var LINE_BUILDERS = {
|
|
2754
|
+
done: (agentTitle, project) => `${agentTitle} response is ready in ${project}`,
|
|
2755
|
+
blocked: (agentTitle, project) => `${agentTitle} needs your input in ${project}`,
|
|
2756
|
+
error: (agentTitle, project) => `${agentTitle} hit an error in ${project}`
|
|
2757
|
+
};
|
|
2758
|
+
var EXIT_OK = 0;
|
|
2759
|
+
var EXIT_HINT = 1;
|
|
2760
|
+
function route(ev, cfg, deps, now) {
|
|
2761
|
+
try {
|
|
2762
|
+
if (quietActive(now)) {
|
|
2763
|
+
return EXIT_OK;
|
|
2764
|
+
}
|
|
2765
|
+
if (isMuted(ev.cwd)) {
|
|
2766
|
+
return EXIT_OK;
|
|
2767
|
+
}
|
|
2768
|
+
if (!isConfigured(ev.cwd)) {
|
|
2769
|
+
return showHintOnce();
|
|
2770
|
+
}
|
|
2771
|
+
if (isProjectEnabled(ev.cwd)) {
|
|
2772
|
+
dispatchRouted(ev, cfg, deps, now);
|
|
2773
|
+
return EXIT_OK;
|
|
2774
|
+
}
|
|
2775
|
+
if (cfg.activation === "opt-in") {
|
|
2776
|
+
return EXIT_OK;
|
|
2777
|
+
}
|
|
2778
|
+
dispatchRouted(ev, cfg, deps, now);
|
|
2779
|
+
return EXIT_OK;
|
|
2780
|
+
} catch {
|
|
2781
|
+
return EXIT_OK;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
function showHintOnce() {
|
|
2785
|
+
const marker = join16(turnbellHome(), HINT_MARKER);
|
|
2786
|
+
if (existsSync5(marker)) {
|
|
2787
|
+
return EXIT_OK;
|
|
2788
|
+
}
|
|
2789
|
+
try {
|
|
2790
|
+
mkdirSync5(turnbellHome(), { recursive: true });
|
|
2791
|
+
writeFileSync5(marker, "");
|
|
2792
|
+
} catch {
|
|
2793
|
+
return EXIT_OK;
|
|
2794
|
+
}
|
|
2795
|
+
process.stderr.write(HINT_MESSAGE);
|
|
2796
|
+
return EXIT_HINT;
|
|
2797
|
+
}
|
|
2798
|
+
function dispatchRouted(ev, cfg, deps, now) {
|
|
2799
|
+
const quiet = inQuietHours(cfg.quietHours, now);
|
|
2800
|
+
fireWebhooks(ev, cfg, deps, quiet);
|
|
2801
|
+
fireSinks(ev, cfg, deps, effectiveMode(cfg, ev.event), quiet);
|
|
2802
|
+
appendEventLog(ev);
|
|
2803
|
+
}
|
|
2804
|
+
function fireWebhooks(ev, cfg, deps, quiet) {
|
|
2805
|
+
if (quiet && cfg.quietHoursWebhooks === SUPPRESS) {
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
deps.webhooks(ev);
|
|
2809
|
+
}
|
|
2810
|
+
function fireSinks(ev, cfg, deps, mode, quiet) {
|
|
2811
|
+
const line = LINE_BUILDERS[ev.event](ev.agentTitle, ev.project);
|
|
2812
|
+
if (mode === "announce" || mode === "readaloud") {
|
|
2813
|
+
const spoken = mode === "readaloud" ? readaloudSpeech(ev, cfg, line) : line;
|
|
2814
|
+
if (!quiet) {
|
|
2815
|
+
speak(deps, cfg, spoken);
|
|
2816
|
+
}
|
|
2817
|
+
if (cfg.notify.desktop) {
|
|
2818
|
+
desktopNotify(deps, line);
|
|
2819
|
+
}
|
|
2820
|
+
} else if (mode === "notify") {
|
|
2821
|
+
if (!quiet && cfg.notify.sound !== null) {
|
|
2822
|
+
speak(deps, cfg, "");
|
|
2823
|
+
}
|
|
2824
|
+
desktopNotify(deps, line);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
function readaloudSpeech(ev, cfg, line) {
|
|
2828
|
+
const raw = ev.lastResponse;
|
|
2829
|
+
if (raw === null || raw === void 0 || raw.length === 0) {
|
|
2830
|
+
return line;
|
|
2831
|
+
}
|
|
2832
|
+
const prepared = prepareSpeechText(raw, cfg.readaloud.maxChars, cfg.readaloud.stripCode);
|
|
2833
|
+
return prepared.length > 0 ? prepared : line;
|
|
2834
|
+
}
|
|
2835
|
+
function speak(deps, cfg, text2) {
|
|
2836
|
+
deps.speak({
|
|
2837
|
+
text: text2,
|
|
2838
|
+
voice: cfg.voice.name,
|
|
2839
|
+
rateWpm: cfg.voice.rateWpm,
|
|
2840
|
+
sound: cfg.notify.sound,
|
|
2841
|
+
platform: deps.platform
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
function desktopNotify(deps, line) {
|
|
2845
|
+
const argv = deps.platform.notifyArgv(NOTIFY_TITLE, line);
|
|
2846
|
+
if (argv !== null) {
|
|
2847
|
+
deps.notify(argv);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
function effectiveMode(cfg, event) {
|
|
2851
|
+
const mode = readMode(cfg, event);
|
|
2852
|
+
if (mode === "readaloud" && event !== "done") {
|
|
2853
|
+
return "announce";
|
|
2854
|
+
}
|
|
2855
|
+
return mode;
|
|
2856
|
+
}
|
|
2857
|
+
function readMode(cfg, event) {
|
|
2858
|
+
const events = cfg.events;
|
|
2859
|
+
if (!isRecord10(events)) {
|
|
2860
|
+
return void 0;
|
|
2861
|
+
}
|
|
2862
|
+
const entry = events[event];
|
|
2863
|
+
if (!isRecord10(entry)) {
|
|
2864
|
+
return void 0;
|
|
2865
|
+
}
|
|
2866
|
+
return typeof entry.mode === "string" ? entry.mode : void 0;
|
|
2867
|
+
}
|
|
2868
|
+
function isRecord10(value) {
|
|
2869
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2870
|
+
}
|
|
2871
|
+
function appendEventLog(ev) {
|
|
2872
|
+
try {
|
|
2873
|
+
const home = turnbellHome();
|
|
2874
|
+
const path = join16(home, EVENTS_LOG);
|
|
2875
|
+
const line = `${ev.ts} ${ev.agent} ${ev.event} ${ev.project}`;
|
|
2876
|
+
mkdirSync5(home, { recursive: true });
|
|
2877
|
+
const lines = [...readLogLines(path), line].slice(-EVENTS_LOG_CAP);
|
|
2878
|
+
writeFileSync5(path, `${lines.join("\n")}
|
|
2879
|
+
`);
|
|
2880
|
+
} catch {
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
function readLogLines(path) {
|
|
2884
|
+
try {
|
|
2885
|
+
return readFileSync6(path, "utf8").split("\n").filter((entry) => entry.length > 0);
|
|
2886
|
+
} catch {
|
|
2887
|
+
return [];
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// src/sinks/webhook.ts
|
|
2892
|
+
import { chmodSync, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
2893
|
+
import { dirname as dirname3, join as join17 } from "path";
|
|
2894
|
+
var PAYLOAD_VERSION = 1;
|
|
2895
|
+
var TIMEOUT_MS = 5e3;
|
|
2896
|
+
var LOG_CAP = 100;
|
|
2897
|
+
var LOG_FILE = "webhook.log";
|
|
2898
|
+
var CONFIG_FILE2 = "config.json";
|
|
2899
|
+
var CONFIG_MODE = 384;
|
|
2900
|
+
var HTTP_SCHEME2 = "http:";
|
|
2901
|
+
var HTTPS_SCHEME = "https:";
|
|
2902
|
+
var PRIORITY_DONE = "default";
|
|
2903
|
+
var PRIORITY_OTHER = "high";
|
|
2904
|
+
var CONTENT_TYPE_JSON = "application/json";
|
|
2905
|
+
var CONTENT_TYPE_FORM = "application/x-www-form-urlencoded";
|
|
2906
|
+
var PUSHOVER_TOKEN = "token";
|
|
2907
|
+
var PUSHOVER_USER = "user";
|
|
2908
|
+
var DONE_EVENT2 = "done";
|
|
2909
|
+
var OUTCOME_OK = "ok";
|
|
2910
|
+
var OUTCOME_NETWORK_ERROR = "error";
|
|
2911
|
+
var OUTCOME_SKIP = "skip";
|
|
2912
|
+
var SKIP_REASON_HTTP = "http-not-allowed";
|
|
2913
|
+
var SKIP_REASON_UNKNOWN_PROVIDER = "unknown-provider";
|
|
2914
|
+
var SKIP_REASON_DELIVERY_ERROR = "delivery-error";
|
|
2915
|
+
function webhookPayload(ev) {
|
|
2916
|
+
return {
|
|
2917
|
+
v: PAYLOAD_VERSION,
|
|
2918
|
+
ts: ev.ts,
|
|
2919
|
+
agent: ev.agent,
|
|
2920
|
+
event: ev.event,
|
|
2921
|
+
project: ev.project,
|
|
2922
|
+
summary: ev.summary
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
function titleLine(ev) {
|
|
2926
|
+
return `turnbell: ${ev.agentTitle} ${ev.event} in ${ev.project}`;
|
|
2927
|
+
}
|
|
2928
|
+
function withoutKeys(headers, drop) {
|
|
2929
|
+
const out = {};
|
|
2930
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2931
|
+
if (!drop.includes(key)) {
|
|
2932
|
+
out[key] = value;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
return out;
|
|
2936
|
+
}
|
|
2937
|
+
function formatNtfy(ev, headers) {
|
|
2938
|
+
return {
|
|
2939
|
+
headers: {
|
|
2940
|
+
...headers,
|
|
2941
|
+
Title: titleLine(ev),
|
|
2942
|
+
Priority: ev.event === DONE_EVENT2 ? PRIORITY_DONE : PRIORITY_OTHER
|
|
2943
|
+
},
|
|
2944
|
+
body: ev.summary
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
function formatPushover(ev, headers) {
|
|
2948
|
+
const form = new URLSearchParams();
|
|
2949
|
+
const token = headers[PUSHOVER_TOKEN];
|
|
2950
|
+
const user = headers[PUSHOVER_USER];
|
|
2951
|
+
if (token !== void 0) {
|
|
2952
|
+
form.set(PUSHOVER_TOKEN, token);
|
|
2953
|
+
}
|
|
2954
|
+
if (user !== void 0) {
|
|
2955
|
+
form.set(PUSHOVER_USER, user);
|
|
2956
|
+
}
|
|
2957
|
+
form.set("title", titleLine(ev));
|
|
2958
|
+
form.set("message", ev.summary);
|
|
2959
|
+
return {
|
|
2960
|
+
headers: {
|
|
2961
|
+
...withoutKeys(headers, [PUSHOVER_TOKEN, PUSHOVER_USER]),
|
|
2962
|
+
"content-type": CONTENT_TYPE_FORM
|
|
2963
|
+
},
|
|
2964
|
+
body: form.toString()
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
function formatSlack(ev, headers) {
|
|
2968
|
+
return {
|
|
2969
|
+
headers: { ...headers, "content-type": CONTENT_TYPE_JSON },
|
|
2970
|
+
body: JSON.stringify({ text: `${titleLine(ev)} \u2014 ${ev.summary}` })
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
function formatGeneric(ev, headers) {
|
|
2974
|
+
return {
|
|
2975
|
+
headers: { ...headers, "content-type": CONTENT_TYPE_JSON },
|
|
2976
|
+
body: JSON.stringify(webhookPayload(ev))
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
var FORMATTERS = {
|
|
2980
|
+
ntfy: formatNtfy,
|
|
2981
|
+
pushover: formatPushover,
|
|
2982
|
+
slack: formatSlack,
|
|
2983
|
+
generic: formatGeneric
|
|
2984
|
+
};
|
|
2985
|
+
function urlAllowed(url, allowHttp) {
|
|
2986
|
+
try {
|
|
2987
|
+
const scheme = new URL(url).protocol;
|
|
2988
|
+
if (scheme === HTTPS_SCHEME) {
|
|
2989
|
+
return true;
|
|
2990
|
+
}
|
|
2991
|
+
if (scheme === HTTP_SCHEME2) {
|
|
2992
|
+
return allowHttp;
|
|
2993
|
+
}
|
|
2994
|
+
return false;
|
|
2995
|
+
} catch {
|
|
2996
|
+
return false;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
async function tryOnce(url, req, fetchFn) {
|
|
3000
|
+
const controller = new AbortController();
|
|
3001
|
+
const timer = setTimeout(() => {
|
|
3002
|
+
controller.abort();
|
|
3003
|
+
}, TIMEOUT_MS);
|
|
3004
|
+
try {
|
|
3005
|
+
const res = await fetchFn(url, {
|
|
3006
|
+
method: "POST",
|
|
3007
|
+
headers: req.headers,
|
|
3008
|
+
body: req.body,
|
|
3009
|
+
signal: controller.signal
|
|
3010
|
+
});
|
|
3011
|
+
return res.ok ? { kind: "ok" } : { kind: "http", status: res.status };
|
|
3012
|
+
} catch {
|
|
3013
|
+
return { kind: "network" };
|
|
3014
|
+
} finally {
|
|
3015
|
+
clearTimeout(timer);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
async function attemptDelivery(ev, target, req, fetchFn) {
|
|
3019
|
+
const maxRetries = 1;
|
|
3020
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
3021
|
+
const result = await tryOnce(target.url, req, fetchFn);
|
|
3022
|
+
if (result.kind === "ok") {
|
|
3023
|
+
return logLine(ev, OUTCOME_OK, target.name);
|
|
3024
|
+
}
|
|
3025
|
+
if (result.kind === "http") {
|
|
3026
|
+
return logLine(ev, String(result.status), target.name);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
return logLine(ev, OUTCOME_NETWORK_ERROR, target.name);
|
|
3030
|
+
}
|
|
3031
|
+
async function deliverTarget(ev, target, allowHttp, fetchFn) {
|
|
3032
|
+
const httpAllowed = target.allowHttp ?? allowHttp;
|
|
3033
|
+
if (!urlAllowed(target.url, httpAllowed)) {
|
|
3034
|
+
return logLine(ev, OUTCOME_SKIP, target.name, SKIP_REASON_HTTP);
|
|
3035
|
+
}
|
|
3036
|
+
const formatter = FORMATTERS[target.provider];
|
|
3037
|
+
if (formatter === void 0) {
|
|
3038
|
+
return logLine(ev, OUTCOME_SKIP, target.name, SKIP_REASON_UNKNOWN_PROVIDER);
|
|
3039
|
+
}
|
|
3040
|
+
const req = formatter(ev, target.headers ?? {});
|
|
3041
|
+
return attemptDelivery(ev, target, req, fetchFn);
|
|
3042
|
+
}
|
|
3043
|
+
async function deliverTargetSafe(ev, target, allowHttp, fetchFn) {
|
|
3044
|
+
try {
|
|
3045
|
+
return await deliverTarget(ev, target, allowHttp, fetchFn);
|
|
3046
|
+
} catch {
|
|
3047
|
+
return logLine(ev, OUTCOME_SKIP, target.name, SKIP_REASON_DELIVERY_ERROR);
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
function targetMatches(target, event) {
|
|
3051
|
+
return Array.isArray(target.events) && target.events.includes(event);
|
|
3052
|
+
}
|
|
3053
|
+
function logLine(ev, status, name, reason) {
|
|
3054
|
+
const base = `${ev.ts} ${status} ${name}`;
|
|
3055
|
+
return reason === void 0 ? base : `${base} ${reason}`;
|
|
3056
|
+
}
|
|
3057
|
+
function readLogLines2(path) {
|
|
3058
|
+
try {
|
|
3059
|
+
return readFileSync7(path, "utf8").split("\n").filter((entry) => entry.length > 0);
|
|
3060
|
+
} catch {
|
|
3061
|
+
return [];
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
function appendLog(path, lines) {
|
|
3065
|
+
if (lines.length === 0) {
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
try {
|
|
3069
|
+
mkdirSync6(dirname3(path), { recursive: true });
|
|
3070
|
+
const all = [...readLogLines2(path), ...lines].slice(-LOG_CAP);
|
|
3071
|
+
writeFileSync6(path, `${all.join("\n")}
|
|
3072
|
+
`);
|
|
3073
|
+
} catch {
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
async function fireWebhooks2(ev, targets, opts) {
|
|
3077
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
3078
|
+
const logPath = opts.logPath ?? join17(turnbellHome(), LOG_FILE);
|
|
3079
|
+
const matched = targets.filter((target) => targetMatches(target, ev.event));
|
|
3080
|
+
const lines = await Promise.all(
|
|
3081
|
+
matched.map((target) => deliverTargetSafe(ev, target, opts.allowHttp, fetchFn))
|
|
3082
|
+
);
|
|
3083
|
+
appendLog(logPath, lines);
|
|
3084
|
+
}
|
|
3085
|
+
function hardenConfig(targets) {
|
|
3086
|
+
const hasSecrets = targets.some(
|
|
3087
|
+
(target) => target.headers !== void 0 && Object.keys(target.headers).length > 0
|
|
3088
|
+
);
|
|
3089
|
+
if (!hasSecrets) {
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
try {
|
|
3093
|
+
chmodSync(join17(turnbellHome(), CONFIG_FILE2), CONFIG_MODE);
|
|
3094
|
+
} catch {
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// src/cli/emit.ts
|
|
3099
|
+
var MAX_STDIN_BYTES = 1024 * 1024;
|
|
3100
|
+
var WEBHOOK_CAP_MS = 6e3;
|
|
3101
|
+
function capTimer(ms) {
|
|
3102
|
+
let timer;
|
|
3103
|
+
const promise = new Promise((resolve) => {
|
|
3104
|
+
timer = setTimeout(resolve, ms);
|
|
3105
|
+
});
|
|
3106
|
+
return {
|
|
3107
|
+
promise,
|
|
3108
|
+
cancel: () => {
|
|
3109
|
+
if (timer !== void 0) {
|
|
3110
|
+
clearTimeout(timer);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
async function settleWebhooks(pending) {
|
|
3116
|
+
const cap = capTimer(WEBHOOK_CAP_MS);
|
|
3117
|
+
try {
|
|
3118
|
+
await Promise.race([pending, cap.promise]);
|
|
3119
|
+
} catch {
|
|
3120
|
+
} finally {
|
|
3121
|
+
cap.cancel();
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
var EMPTY_PAYLOAD = "{}";
|
|
3125
|
+
function isRecord11(value) {
|
|
3126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3127
|
+
}
|
|
3128
|
+
function parseJsonObject2(raw) {
|
|
3129
|
+
try {
|
|
3130
|
+
const parsed = JSON.parse(raw);
|
|
3131
|
+
return isRecord11(parsed) ? parsed : {};
|
|
3132
|
+
} catch {
|
|
3133
|
+
return {};
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
function parseEmitFlags(args) {
|
|
3137
|
+
const flags = {
|
|
3138
|
+
agent: "",
|
|
3139
|
+
event: "",
|
|
3140
|
+
summary: null,
|
|
3141
|
+
payloadStdin: false,
|
|
3142
|
+
payloadArgv: null
|
|
3143
|
+
};
|
|
3144
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3145
|
+
const arg = args[index];
|
|
3146
|
+
if (arg === "--payload-stdin") {
|
|
3147
|
+
flags.payloadStdin = true;
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
index += consumeValueFlag(flags, arg, args[index + 1]);
|
|
3151
|
+
}
|
|
3152
|
+
return flags;
|
|
3153
|
+
}
|
|
3154
|
+
function consumeValueFlag(flags, flag, value) {
|
|
3155
|
+
switch (flag) {
|
|
3156
|
+
case "--agent":
|
|
3157
|
+
flags.agent = value ?? "";
|
|
3158
|
+
return 1;
|
|
3159
|
+
case "--event":
|
|
3160
|
+
flags.event = value ?? "";
|
|
3161
|
+
return 1;
|
|
3162
|
+
case "--summary":
|
|
3163
|
+
flags.summary = value ?? null;
|
|
3164
|
+
return 1;
|
|
3165
|
+
case "--payload-argv":
|
|
3166
|
+
flags.payloadArgv = value ?? null;
|
|
3167
|
+
return 1;
|
|
3168
|
+
default:
|
|
3169
|
+
return 0;
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
async function resolvePayloadRaw(flags, deps) {
|
|
3173
|
+
if (flags.payloadStdin) {
|
|
3174
|
+
const raw = await deps.readStdin();
|
|
3175
|
+
return Buffer.byteLength(raw, "utf8") > MAX_STDIN_BYTES ? EMPTY_PAYLOAD : raw;
|
|
3176
|
+
}
|
|
3177
|
+
if (flags.payloadArgv !== null) {
|
|
3178
|
+
return flags.payloadArgv;
|
|
3179
|
+
}
|
|
3180
|
+
return EMPTY_PAYLOAD;
|
|
3181
|
+
}
|
|
3182
|
+
function stringField(value, fallback) {
|
|
3183
|
+
return typeof value === "string" ? value : fallback;
|
|
3184
|
+
}
|
|
3185
|
+
function buildEmitEvent(flags, payload, now) {
|
|
3186
|
+
const cwd = stringField(payload.cwd, process.cwd());
|
|
3187
|
+
const lastResponse = typeof payload.lastResponse === "string" ? payload.lastResponse : null;
|
|
3188
|
+
return {
|
|
3189
|
+
v: 1,
|
|
3190
|
+
ts: now.toISOString(),
|
|
3191
|
+
agent: flags.agent,
|
|
3192
|
+
agentTitle: flags.agent,
|
|
3193
|
+
event: flags.event,
|
|
3194
|
+
cwd,
|
|
3195
|
+
project: projectLabel(cwd),
|
|
3196
|
+
summary: flags.summary ?? stringField(payload.summary, ""),
|
|
3197
|
+
lastResponse
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
function normalizeEmit(flags, payload, now) {
|
|
3201
|
+
const adapter = byId(flags.agent);
|
|
3202
|
+
if (adapter === void 0) {
|
|
3203
|
+
return buildEmitEvent(flags, payload, now);
|
|
3204
|
+
}
|
|
3205
|
+
return adapter.normalize(payload, flags.event);
|
|
3206
|
+
}
|
|
3207
|
+
var EXIT_NO_EVENT = 0;
|
|
3208
|
+
var READALOUD_MODE = "readaloud";
|
|
3209
|
+
var READALOUD_EVENT = "done";
|
|
3210
|
+
function isReadaloudEvent(cfg, event) {
|
|
3211
|
+
if (event !== READALOUD_EVENT) {
|
|
3212
|
+
return false;
|
|
3213
|
+
}
|
|
3214
|
+
const entry = cfg.events[event];
|
|
3215
|
+
return isRecord11(entry) && entry.mode === READALOUD_MODE;
|
|
3216
|
+
}
|
|
3217
|
+
async function hydrateLastResponse(flags, event, payload, cfg) {
|
|
3218
|
+
if (!isReadaloudEvent(cfg, event.event)) {
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
const adapter = byId(flags.agent);
|
|
3222
|
+
if (adapter === void 0) {
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
event.lastResponse = await adapter.readLastResponse(payload);
|
|
3226
|
+
}
|
|
3227
|
+
async function runEmit(args, deps) {
|
|
3228
|
+
const flags = parseEmitFlags(args);
|
|
3229
|
+
const payload = parseJsonObject2(await resolvePayloadRaw(flags, deps));
|
|
3230
|
+
const event = normalizeEmit(flags, payload, /* @__PURE__ */ new Date());
|
|
3231
|
+
if (event === null) {
|
|
3232
|
+
return EXIT_NO_EVENT;
|
|
3233
|
+
}
|
|
3234
|
+
const cfg = loadConfig(event.cwd);
|
|
3235
|
+
hardenConfig(cfg.webhooks);
|
|
3236
|
+
await hydrateLastResponse(flags, event, payload, cfg);
|
|
3237
|
+
const code = route(
|
|
3238
|
+
event,
|
|
3239
|
+
cfg,
|
|
3240
|
+
{
|
|
3241
|
+
platform: deps.platform,
|
|
3242
|
+
speak: deps.speak,
|
|
3243
|
+
notify: deps.notify,
|
|
3244
|
+
webhooks: (ev) => {
|
|
3245
|
+
deps.webhooks(ev, cfg.webhooks, cfg.allowHttp);
|
|
3246
|
+
}
|
|
3247
|
+
},
|
|
3248
|
+
/* @__PURE__ */ new Date()
|
|
3249
|
+
);
|
|
3250
|
+
await settleWebhooks(deps.awaitWebhooks());
|
|
3251
|
+
try {
|
|
3252
|
+
pruneReadaloudDir(readaloudTempDir(), /* @__PURE__ */ new Date());
|
|
3253
|
+
} catch {
|
|
3254
|
+
}
|
|
3255
|
+
return code;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// src/cli/init.ts
|
|
3259
|
+
import { spawnSync } from "child_process";
|
|
3260
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3261
|
+
import { homedir as homedir3 } from "os";
|
|
3262
|
+
import { join as join19 } from "path";
|
|
3263
|
+
import * as clack from "@clack/prompts";
|
|
3264
|
+
|
|
3265
|
+
// src/cli/init-steps.ts
|
|
3266
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "fs";
|
|
3267
|
+
import { join as join18 } from "path";
|
|
3268
|
+
|
|
3269
|
+
// src/cli/init-sinks.ts
|
|
3270
|
+
var OS_DEFAULT_VOICE = "__os_default__";
|
|
3271
|
+
var MAX_PROMPT_ATTEMPTS = 5;
|
|
3272
|
+
var HTTPS_SCHEME2 = "https:";
|
|
3273
|
+
var HTTP_SCHEME3 = "http:";
|
|
3274
|
+
var PUSHOVER_PROVIDER = "pushover";
|
|
3275
|
+
var EVENT_NAMES = ["done", "blocked", "error"];
|
|
3276
|
+
var MODES = ["announce", "readaloud", "notify", "silent"];
|
|
3277
|
+
var PROVIDERS = ["ntfy", "pushover", "slack", "generic"];
|
|
3278
|
+
var QUIET_HOURS_OPTIONS = ["fire", "suppress"];
|
|
3279
|
+
var QUIET_HOURS_RE = /^([01]\d|2[0-3]):[0-5]\d-([01]\d|2[0-3]):[0-5]\d$/;
|
|
3280
|
+
async function collectOpenCommand(io, events, existing, defaultOpen) {
|
|
3281
|
+
if (events.done.mode !== "readaloud") {
|
|
3282
|
+
return existing.readaloud.openCommand;
|
|
3283
|
+
}
|
|
3284
|
+
io.note(
|
|
3285
|
+
"Read-aloud keeps responses suitable for listening; code and extra detail open in your markdown viewer instead of being spoken."
|
|
3286
|
+
);
|
|
3287
|
+
const seed = existing.readaloud.openCommand.length > 0 ? existing.readaloud.openCommand : defaultOpen;
|
|
3288
|
+
const raw = (await io.text({ message: "Command to open a markdown file", initialValue: seed })).trim();
|
|
3289
|
+
return raw.length === 0 ? seed : raw;
|
|
3290
|
+
}
|
|
3291
|
+
async function collectModes(io, existing) {
|
|
3292
|
+
const events = structuredClone(existing.events);
|
|
3293
|
+
for (const event of EVENT_NAMES) {
|
|
3294
|
+
const mode = await io.select({
|
|
3295
|
+
message: `How should "${event}" events be announced?`,
|
|
3296
|
+
options: MODES.map((value) => ({ value, label: value })),
|
|
3297
|
+
initialValue: existing.events[event].mode
|
|
3298
|
+
});
|
|
3299
|
+
events[event] = { mode };
|
|
3300
|
+
}
|
|
3301
|
+
return events;
|
|
3302
|
+
}
|
|
3303
|
+
async function collectVoice(io, enumerate, existing) {
|
|
3304
|
+
const voice = structuredClone(existing.voice);
|
|
3305
|
+
const pickInstalled = await io.confirm({
|
|
3306
|
+
message: "Choose from installed voices? (otherwise the OS default voice is used)",
|
|
3307
|
+
initialValue: existing.voice.name !== null
|
|
3308
|
+
});
|
|
3309
|
+
if (!pickInstalled) {
|
|
3310
|
+
voice.name = null;
|
|
3311
|
+
return voice;
|
|
3312
|
+
}
|
|
3313
|
+
const names = enumerate();
|
|
3314
|
+
if (names.length === 0) {
|
|
3315
|
+
io.note("No installed voices found; using the OS default voice.");
|
|
3316
|
+
return voice;
|
|
3317
|
+
}
|
|
3318
|
+
const chosen = await io.select({
|
|
3319
|
+
message: "Voice",
|
|
3320
|
+
options: [
|
|
3321
|
+
{ value: OS_DEFAULT_VOICE, label: "OS default" },
|
|
3322
|
+
...names.map((name) => ({ value: name, label: name }))
|
|
3323
|
+
],
|
|
3324
|
+
initialValue: existing.voice.name ?? OS_DEFAULT_VOICE
|
|
3325
|
+
});
|
|
3326
|
+
voice.name = chosen === OS_DEFAULT_VOICE ? null : chosen;
|
|
3327
|
+
return voice;
|
|
3328
|
+
}
|
|
3329
|
+
async function collectSound(io, existing) {
|
|
3330
|
+
const name = (await io.text({
|
|
3331
|
+
message: "Notification sound name (leave blank for none)",
|
|
3332
|
+
initialValue: existing.notify.sound ?? ""
|
|
3333
|
+
})).trim();
|
|
3334
|
+
return name.length === 0 ? null : name;
|
|
3335
|
+
}
|
|
3336
|
+
async function promptQuietHours(io, existing) {
|
|
3337
|
+
const seed = existing.quietHours ?? "";
|
|
3338
|
+
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt += 1) {
|
|
3339
|
+
const raw = (await io.text({
|
|
3340
|
+
message: "Quiet hours as HH:MM-HH:MM (leave blank for none)",
|
|
3341
|
+
initialValue: seed
|
|
3342
|
+
})).trim();
|
|
3343
|
+
if (raw.length === 0) {
|
|
3344
|
+
return null;
|
|
3345
|
+
}
|
|
3346
|
+
if (QUIET_HOURS_RE.test(raw)) {
|
|
3347
|
+
return raw;
|
|
3348
|
+
}
|
|
3349
|
+
io.note("Invalid format; expected HH:MM-HH:MM (e.g. 22:00-08:00).");
|
|
3350
|
+
}
|
|
3351
|
+
return null;
|
|
3352
|
+
}
|
|
3353
|
+
async function collectQuietHours(io, existing) {
|
|
3354
|
+
const spec = await promptQuietHours(io, existing);
|
|
3355
|
+
if (spec === null) {
|
|
3356
|
+
return { spec: null, webhooks: existing.quietHoursWebhooks };
|
|
3357
|
+
}
|
|
3358
|
+
const webhooks = await io.select({
|
|
3359
|
+
message: "During quiet hours, webhooks should",
|
|
3360
|
+
options: QUIET_HOURS_OPTIONS.map((value) => ({ value, label: value })),
|
|
3361
|
+
initialValue: existing.quietHoursWebhooks
|
|
3362
|
+
});
|
|
3363
|
+
return { spec, webhooks };
|
|
3364
|
+
}
|
|
3365
|
+
function urlScheme2(url) {
|
|
3366
|
+
try {
|
|
3367
|
+
return new URL(url).protocol;
|
|
3368
|
+
} catch {
|
|
3369
|
+
return null;
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
async function promptWebhookUrl(io) {
|
|
3373
|
+
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt += 1) {
|
|
3374
|
+
const url = (await io.text({ message: "Webhook URL (https)" })).trim();
|
|
3375
|
+
const scheme = urlScheme2(url);
|
|
3376
|
+
if (scheme === HTTPS_SCHEME2) {
|
|
3377
|
+
return { url, allowHttp: false };
|
|
3378
|
+
}
|
|
3379
|
+
if (scheme === HTTP_SCHEME3) {
|
|
3380
|
+
const allow = await io.confirm({
|
|
3381
|
+
message: "That URL is insecure http \u2014 send events over http anyway?",
|
|
3382
|
+
initialValue: false
|
|
3383
|
+
});
|
|
3384
|
+
if (allow) {
|
|
3385
|
+
return { url, allowHttp: true };
|
|
3386
|
+
}
|
|
3387
|
+
io.note("Enter an https:// URL instead.");
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
io.note("Invalid URL; it must start with https:// (or http:// if you opt in).");
|
|
3391
|
+
}
|
|
3392
|
+
return null;
|
|
3393
|
+
}
|
|
3394
|
+
async function collectHeaders(io) {
|
|
3395
|
+
const headers = {};
|
|
3396
|
+
let more = await io.confirm({ message: "Add request headers?", initialValue: false });
|
|
3397
|
+
while (more) {
|
|
3398
|
+
const key = (await io.text({ message: "Header name (blank to finish)" })).trim();
|
|
3399
|
+
if (key.length === 0) {
|
|
3400
|
+
break;
|
|
3401
|
+
}
|
|
3402
|
+
headers[key] = await io.text({ message: `Value for "${key}"` });
|
|
3403
|
+
more = await io.confirm({ message: "Add another header?", initialValue: false });
|
|
3404
|
+
}
|
|
3405
|
+
return headers;
|
|
3406
|
+
}
|
|
3407
|
+
async function collectOneWebhook(io) {
|
|
3408
|
+
const provider = await io.select({
|
|
3409
|
+
message: "Webhook provider",
|
|
3410
|
+
options: PROVIDERS.map((value) => ({ value, label: value })),
|
|
3411
|
+
initialValue: "ntfy"
|
|
3412
|
+
});
|
|
3413
|
+
const name = (await io.text({ message: "A name for this webhook", initialValue: provider })).trim();
|
|
3414
|
+
const resolved = await promptWebhookUrl(io);
|
|
3415
|
+
if (resolved === null) {
|
|
3416
|
+
io.note("No valid URL provided; skipping this webhook.");
|
|
3417
|
+
return null;
|
|
3418
|
+
}
|
|
3419
|
+
const events = await io.multiselect({
|
|
3420
|
+
message: "Which events fire this webhook?",
|
|
3421
|
+
options: EVENT_NAMES.map((value) => ({ value, label: value })),
|
|
3422
|
+
initialValues: [...EVENT_NAMES],
|
|
3423
|
+
required: true
|
|
3424
|
+
});
|
|
3425
|
+
if (provider === PUSHOVER_PROVIDER) {
|
|
3426
|
+
io.note('Pushover: put your app "token" and "user" key in headers.');
|
|
3427
|
+
}
|
|
3428
|
+
const headers = await collectHeaders(io);
|
|
3429
|
+
const target = {
|
|
3430
|
+
name: name.length === 0 ? provider : name,
|
|
3431
|
+
provider,
|
|
3432
|
+
url: resolved.url,
|
|
3433
|
+
events
|
|
3434
|
+
};
|
|
3435
|
+
if (Object.keys(headers).length > 0) {
|
|
3436
|
+
target.headers = headers;
|
|
3437
|
+
}
|
|
3438
|
+
if (resolved.allowHttp) {
|
|
3439
|
+
target.allowHttp = true;
|
|
3440
|
+
}
|
|
3441
|
+
return target;
|
|
3442
|
+
}
|
|
3443
|
+
async function collectWebhooks(io, existing) {
|
|
3444
|
+
const targets = existing.webhooks.map((target) => structuredClone(target));
|
|
3445
|
+
let add = await io.confirm({ message: "Add a webhook?", initialValue: false });
|
|
3446
|
+
while (add) {
|
|
3447
|
+
const built = await collectOneWebhook(io);
|
|
3448
|
+
if (built !== null) {
|
|
3449
|
+
targets.push(built);
|
|
3450
|
+
}
|
|
3451
|
+
add = await io.confirm({ message: "Add another webhook?", initialValue: false });
|
|
3452
|
+
}
|
|
3453
|
+
return targets;
|
|
3454
|
+
}
|
|
3455
|
+
async function collectSinkConfig(io, enumerate, existing, defaultOpen) {
|
|
3456
|
+
const config = structuredClone(existing);
|
|
3457
|
+
config.events = await collectModes(io, existing);
|
|
3458
|
+
config.readaloud = {
|
|
3459
|
+
...config.readaloud,
|
|
3460
|
+
openCommand: await collectOpenCommand(io, config.events, existing, defaultOpen)
|
|
3461
|
+
};
|
|
3462
|
+
config.voice = await collectVoice(io, enumerate, existing);
|
|
3463
|
+
config.notify = { ...config.notify, sound: await collectSound(io, existing) };
|
|
3464
|
+
const quiet = await collectQuietHours(io, existing);
|
|
3465
|
+
config.quietHours = quiet.spec;
|
|
3466
|
+
config.quietHoursWebhooks = quiet.webhooks;
|
|
3467
|
+
config.webhooks = await collectWebhooks(io, existing);
|
|
3468
|
+
return config;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// src/cli/init-steps.ts
|
|
3472
|
+
var CONFIG_FILE3 = "config.json";
|
|
3473
|
+
var JSON_INDENT2 = 2;
|
|
3474
|
+
var XCODE_SELECT_FIX = "xcode-select --install";
|
|
3475
|
+
var CHECK_MARK = "\u2713";
|
|
3476
|
+
var CROSS_MARK = "\u2717";
|
|
3477
|
+
function adapterDeps(deps) {
|
|
3478
|
+
return { home: deps.home, which: deps.which };
|
|
3479
|
+
}
|
|
3480
|
+
function isWired(wiredKeys, id) {
|
|
3481
|
+
return wiredKeys.some(
|
|
3482
|
+
(key) => key !== readaloudLedgerKey(id) && (key.split(":")[0] ?? key) === id
|
|
3483
|
+
);
|
|
3484
|
+
}
|
|
3485
|
+
function capabilityBadges(caps) {
|
|
3486
|
+
const badge = (ok, label) => `${ok ? CHECK_MARK : CROSS_MARK}${label}`;
|
|
3487
|
+
return [
|
|
3488
|
+
badge(caps.done, "done"),
|
|
3489
|
+
badge(caps.blocked, "blocked"),
|
|
3490
|
+
badge(caps.readAloud, "read-aloud"),
|
|
3491
|
+
badge(caps.slashCommand, "slash")
|
|
3492
|
+
].join(" ");
|
|
3493
|
+
}
|
|
3494
|
+
function agentOption(adapter, detection) {
|
|
3495
|
+
const flags = [];
|
|
3496
|
+
if (!detection.installed) {
|
|
3497
|
+
flags.push("not detected");
|
|
3498
|
+
}
|
|
3499
|
+
if (detection.degraded !== void 0) {
|
|
3500
|
+
flags.push("degraded");
|
|
3501
|
+
}
|
|
3502
|
+
const badges = capabilityBadges(adapter.capabilities);
|
|
3503
|
+
const hint = flags.length === 0 ? badges : `${badges} \xB7 ${flags.join(", ")}`;
|
|
3504
|
+
return { value: adapter.id, label: `${adapter.title} \u2014 ${adapter.tagline}`, hint };
|
|
3505
|
+
}
|
|
3506
|
+
var CONFIG_MODE2 = 384;
|
|
3507
|
+
function writeGlobalConfig(config) {
|
|
3508
|
+
const home = turnbellHome();
|
|
3509
|
+
mkdirSync7(home, { recursive: true });
|
|
3510
|
+
const path = join18(home, CONFIG_FILE3);
|
|
3511
|
+
writeFileSync7(path, `${JSON.stringify(config, null, JSON_INDENT2)}
|
|
3512
|
+
`, {
|
|
3513
|
+
encoding: "utf8",
|
|
3514
|
+
mode: CONFIG_MODE2
|
|
3515
|
+
});
|
|
3516
|
+
hardenConfig(config.webhooks);
|
|
3517
|
+
return path;
|
|
3518
|
+
}
|
|
3519
|
+
function noteBetaPlatform(deps) {
|
|
3520
|
+
if (deps.platform.id === "linux" || deps.platform.id === "win32") {
|
|
3521
|
+
deps.io.note(
|
|
3522
|
+
`${deps.platform.id} support is beta \u2014 engines are mock-tested, not yet live-verified.`,
|
|
3523
|
+
"Beta platform"
|
|
3524
|
+
);
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
async function stepMigrate(deps) {
|
|
3528
|
+
if (!deps.detectV1()) {
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
const doImport = await deps.io.confirm({
|
|
3532
|
+
message: "A turnbell v1 config was found. Import it?",
|
|
3533
|
+
initialValue: true
|
|
3534
|
+
});
|
|
3535
|
+
if (!doImport) {
|
|
3536
|
+
return;
|
|
3537
|
+
}
|
|
3538
|
+
const migrated = deps.migrate();
|
|
3539
|
+
deps.io.note(
|
|
3540
|
+
migrated ? "Imported your v1 config." : "Nothing imported (a v2 config already exists)."
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
function formatFailingCheck(check) {
|
|
3544
|
+
const fix = check.fix === null ? "" : `
|
|
3545
|
+
fix: ${check.fix}`;
|
|
3546
|
+
return `${CROSS_MARK} ${check.label} \u2014 ${check.detail}${fix}`;
|
|
3547
|
+
}
|
|
3548
|
+
async function stepDoctor(deps) {
|
|
3549
|
+
const checks = await checkAll({
|
|
3550
|
+
which: deps.which,
|
|
3551
|
+
platform: deps.platform,
|
|
3552
|
+
adapters: deps.adapters,
|
|
3553
|
+
home: deps.home
|
|
3554
|
+
});
|
|
3555
|
+
if (allRequiredOk(checks)) {
|
|
3556
|
+
return true;
|
|
3557
|
+
}
|
|
3558
|
+
const failing = checks.filter((check) => check.required && !check.ok);
|
|
3559
|
+
deps.io.note(failing.map(formatFailingCheck).join("\n"), "Missing prerequisites");
|
|
3560
|
+
for (const check of failing) {
|
|
3561
|
+
if (check.fix === XCODE_SELECT_FIX) {
|
|
3562
|
+
const run2 = await deps.io.confirm({
|
|
3563
|
+
message: `Run \`${check.fix}\` now?`,
|
|
3564
|
+
initialValue: false
|
|
3565
|
+
});
|
|
3566
|
+
if (run2) {
|
|
3567
|
+
deps.autoRunFix(check.fix);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
return deps.io.confirm({ message: "Continue setup anyway?", initialValue: false });
|
|
3572
|
+
}
|
|
3573
|
+
function wireSummary(adapter) {
|
|
3574
|
+
const caps = adapter.capabilities;
|
|
3575
|
+
const parts = [];
|
|
3576
|
+
const alerts = [];
|
|
3577
|
+
if (caps.done) {
|
|
3578
|
+
alerts.push("done");
|
|
3579
|
+
}
|
|
3580
|
+
if (caps.blocked) {
|
|
3581
|
+
alerts.push("needs-input");
|
|
3582
|
+
}
|
|
3583
|
+
if (alerts.length > 0) {
|
|
3584
|
+
parts.push(`${alerts.join(" + ")} alerts`);
|
|
3585
|
+
}
|
|
3586
|
+
if (caps.readAloud) {
|
|
3587
|
+
parts.push("read-aloud");
|
|
3588
|
+
}
|
|
3589
|
+
if (caps.slashCommand) {
|
|
3590
|
+
parts.push("/turnbell command");
|
|
3591
|
+
}
|
|
3592
|
+
if (parts.length === 0) {
|
|
3593
|
+
return adapter.title;
|
|
3594
|
+
}
|
|
3595
|
+
return `${adapter.title}: ${parts.join(", ")}`;
|
|
3596
|
+
}
|
|
3597
|
+
async function wireAgent(deps, adapter) {
|
|
3598
|
+
const result = await adapter.wire(adapterDeps(deps));
|
|
3599
|
+
for (const warning of result.warnings) {
|
|
3600
|
+
deps.io.note(warning, "Warning");
|
|
3601
|
+
}
|
|
3602
|
+
if (!result.changed) {
|
|
3603
|
+
deps.io.note(`${adapter.title} is already configured.`);
|
|
3604
|
+
return;
|
|
3605
|
+
}
|
|
3606
|
+
deps.io.note(
|
|
3607
|
+
`${wireSummary(adapter)}
|
|
3608
|
+
Silence it here anytime with \`turnbell off\`.`,
|
|
3609
|
+
`${adapter.title} \u2014 set up`
|
|
3610
|
+
);
|
|
3611
|
+
const seeDiff = await deps.io.confirm({
|
|
3612
|
+
message: "Show exactly what changed?",
|
|
3613
|
+
initialValue: false
|
|
3614
|
+
});
|
|
3615
|
+
if (seeDiff) {
|
|
3616
|
+
deps.io.note(result.diff, `${adapter.title} \u2014 changes applied`);
|
|
3617
|
+
}
|
|
3618
|
+
const keep = await deps.io.confirm({
|
|
3619
|
+
message: `Keep these changes to ${adapter.title}?`,
|
|
3620
|
+
initialValue: true
|
|
3621
|
+
});
|
|
3622
|
+
if (!keep) {
|
|
3623
|
+
await adapter.unwire(adapterDeps(deps));
|
|
3624
|
+
deps.io.note(`Reverted ${adapter.title}.`);
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
async function stepAgents(deps) {
|
|
3628
|
+
const detections = await Promise.all(
|
|
3629
|
+
deps.adapters.map((adapter) => adapter.detect(adapterDeps(deps)))
|
|
3630
|
+
);
|
|
3631
|
+
const wiredKeys = listWiredKeys();
|
|
3632
|
+
const options = deps.adapters.map(
|
|
3633
|
+
(adapter, index) => agentOption(adapter, detections[index] ?? { installed: false })
|
|
3634
|
+
);
|
|
3635
|
+
const initialValues = deps.adapters.filter((adapter) => isWired(wiredKeys, adapter.id)).map((adapter) => adapter.id);
|
|
3636
|
+
const selected = await deps.io.multiselect({
|
|
3637
|
+
message: "Select the agents turnbell should wire (space to toggle)",
|
|
3638
|
+
options,
|
|
3639
|
+
initialValues,
|
|
3640
|
+
required: false
|
|
3641
|
+
});
|
|
3642
|
+
const selectedSet = new Set(selected);
|
|
3643
|
+
for (const adapter of deps.adapters) {
|
|
3644
|
+
const wired = isWired(wiredKeys, adapter.id);
|
|
3645
|
+
const picked = selectedSet.has(adapter.id);
|
|
3646
|
+
if (picked && !wired) {
|
|
3647
|
+
await wireAgent(deps, adapter);
|
|
3648
|
+
} else if (!picked && wired) {
|
|
3649
|
+
await adapter.unwire(adapterDeps(deps));
|
|
3650
|
+
deps.io.note(`Unwired ${adapter.title}.`);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
async function stepActivation(io, current) {
|
|
3655
|
+
const choice = await io.select({
|
|
3656
|
+
message: "When should turnbell speak up?",
|
|
3657
|
+
options: [
|
|
3658
|
+
{ value: "all", label: "In every project \u2014 turn it off where you don't want it" },
|
|
3659
|
+
{ value: "opt-in", label: "Only in projects I turn on" }
|
|
3660
|
+
],
|
|
3661
|
+
initialValue: current
|
|
3662
|
+
});
|
|
3663
|
+
if (choice === "opt-in") {
|
|
3664
|
+
io.note("turnbell will stay quiet until you run `turnbell on` inside a project.");
|
|
3665
|
+
}
|
|
3666
|
+
return choice;
|
|
3667
|
+
}
|
|
3668
|
+
async function stepInjectInstructions(deps, config, interactive) {
|
|
3669
|
+
const wiredKeys = listWiredKeys();
|
|
3670
|
+
const wantReadaloud = config.events.done.mode === "readaloud";
|
|
3671
|
+
for (const adapter of deps.adapters) {
|
|
3672
|
+
if (!adapter.capabilities.instructionInjection || adapter.memoryPath === void 0) {
|
|
3673
|
+
continue;
|
|
3674
|
+
}
|
|
3675
|
+
if (!wantReadaloud || !isWired(wiredKeys, adapter.id)) {
|
|
3676
|
+
unwireFromLedger(readaloudLedgerKey(adapter.id));
|
|
3677
|
+
continue;
|
|
3678
|
+
}
|
|
3679
|
+
const path = adapter.memoryPath(adapterDeps(deps));
|
|
3680
|
+
const op = injectReadaloud(path, config.readaloud.openCommand, adapter.id);
|
|
3681
|
+
if (!op.changed) {
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3684
|
+
if (!interactive) {
|
|
3685
|
+
op.apply();
|
|
3686
|
+
continue;
|
|
3687
|
+
}
|
|
3688
|
+
deps.io.note(
|
|
3689
|
+
`${adapter.title}: a read-aloud instruction is ready for ${path} \u2014 kept only if you confirm below (reversible: re-run \`turnbell init\` with read-aloud off, or \`turnbell uninstall\`).`
|
|
3690
|
+
);
|
|
3691
|
+
const seeDiff = await deps.io.confirm({
|
|
3692
|
+
message: "Show exactly what changed?",
|
|
3693
|
+
initialValue: false
|
|
3694
|
+
});
|
|
3695
|
+
if (seeDiff) {
|
|
3696
|
+
deps.io.note(op.diff, `${adapter.title} \u2014 read-aloud instruction`);
|
|
3697
|
+
}
|
|
3698
|
+
const keep = await deps.io.confirm({
|
|
3699
|
+
message: `Keep this for ${adapter.title}?`,
|
|
3700
|
+
initialValue: true
|
|
3701
|
+
});
|
|
3702
|
+
if (keep) {
|
|
3703
|
+
op.apply();
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
async function stepSummary(io, config) {
|
|
3708
|
+
const lines = [
|
|
3709
|
+
`done: ${config.events.done.mode}`,
|
|
3710
|
+
`blocked: ${config.events.blocked.mode}`,
|
|
3711
|
+
`error: ${config.events.error.mode}`,
|
|
3712
|
+
`voice: ${config.voice.name ?? "OS default"}`,
|
|
3713
|
+
`quiet hours: ${config.quietHours ?? "none"}`,
|
|
3714
|
+
`webhooks: ${config.webhooks.length}`
|
|
3715
|
+
];
|
|
3716
|
+
io.note(lines.join("\n"), "turnbell is configured");
|
|
3717
|
+
return io.confirm({ message: "Preview it now with `turnbell test`?", initialValue: true });
|
|
3718
|
+
}
|
|
3719
|
+
async function runInitYes(deps) {
|
|
3720
|
+
if (deps.detectV1()) {
|
|
3721
|
+
deps.migrate();
|
|
3722
|
+
}
|
|
3723
|
+
const detections = await Promise.all(
|
|
3724
|
+
deps.adapters.map((adapter) => adapter.detect(adapterDeps(deps)))
|
|
3725
|
+
);
|
|
3726
|
+
for (const [index, adapter] of deps.adapters.entries()) {
|
|
3727
|
+
if (detections[index]?.installed === true) {
|
|
3728
|
+
await adapter.wire(adapterDeps(deps));
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
const cwd = process.cwd();
|
|
3732
|
+
const loaded = isConfigured(cwd) ? loadConfig(cwd) : structuredClone(DEFAULTS);
|
|
3733
|
+
const config = migrateHttpOptIn(loaded).config;
|
|
3734
|
+
if (config.readaloud.openCommand.length === 0) {
|
|
3735
|
+
config.readaloud.openCommand = defaultOpenCommand(deps.platform.id);
|
|
3736
|
+
}
|
|
3737
|
+
await stepInjectInstructions(deps, config, false);
|
|
3738
|
+
const configPath2 = writeGlobalConfig(config);
|
|
3739
|
+
return { runTest: false, configPath: configPath2 };
|
|
3740
|
+
}
|
|
3741
|
+
async function runInit(deps, opts) {
|
|
3742
|
+
if (opts.yes) {
|
|
3743
|
+
return runInitYes(deps);
|
|
3744
|
+
}
|
|
3745
|
+
noteBetaPlatform(deps);
|
|
3746
|
+
await stepMigrate(deps);
|
|
3747
|
+
const proceed = await stepDoctor(deps);
|
|
3748
|
+
if (!proceed) {
|
|
3749
|
+
return { runTest: false, configPath: null };
|
|
3750
|
+
}
|
|
3751
|
+
await stepAgents(deps);
|
|
3752
|
+
const existing = migrateHttpOptIn(loadConfig(process.cwd())).config;
|
|
3753
|
+
const activation = await stepActivation(deps.io, existing.activation);
|
|
3754
|
+
const config = await collectSinkConfig(
|
|
3755
|
+
deps.io,
|
|
3756
|
+
deps.enumerateVoices,
|
|
3757
|
+
existing,
|
|
3758
|
+
defaultOpenCommand(deps.platform.id)
|
|
3759
|
+
);
|
|
3760
|
+
config.activation = activation;
|
|
3761
|
+
await stepInjectInstructions(deps, config, true);
|
|
3762
|
+
const configPath2 = writeGlobalConfig(config);
|
|
3763
|
+
const runTest2 = await stepSummary(deps.io, config);
|
|
3764
|
+
return { runTest: runTest2, configPath: configPath2 };
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
// src/cli/uninstall.ts
|
|
3768
|
+
import { rmSync as rmSync3 } from "fs";
|
|
3769
|
+
var EXIT_OK2 = 0;
|
|
3770
|
+
var READALOUD_SUFFIX = ":readaloud";
|
|
3771
|
+
function keyLabel(key) {
|
|
3772
|
+
const id = key.split(":")[0] ?? key;
|
|
3773
|
+
return byId(id)?.title ?? key;
|
|
3774
|
+
}
|
|
3775
|
+
function errorMessage(error) {
|
|
3776
|
+
return error instanceof Error ? error.message : String(error);
|
|
3777
|
+
}
|
|
3778
|
+
async function runUninstall(io, deps) {
|
|
3779
|
+
const keys = listWiredKeys();
|
|
3780
|
+
if (keys.length === 0) {
|
|
3781
|
+
io.note("No wired integrations found.");
|
|
3782
|
+
} else {
|
|
3783
|
+
io.note(keys.map((key) => `- ${keyLabel(key)} (${key})`).join("\n"), "Will unwire");
|
|
3784
|
+
}
|
|
3785
|
+
const proceed = await io.confirm({
|
|
3786
|
+
message: "Reverse every turnbell integration?",
|
|
3787
|
+
initialValue: false
|
|
3788
|
+
});
|
|
3789
|
+
if (!proceed) {
|
|
3790
|
+
io.note("Nothing changed.");
|
|
3791
|
+
return EXIT_OK2;
|
|
3792
|
+
}
|
|
3793
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3794
|
+
for (const key of keys) {
|
|
3795
|
+
const id = key.split(":")[0] ?? key;
|
|
3796
|
+
const adapter = byId(id);
|
|
3797
|
+
const label = keyLabel(key);
|
|
3798
|
+
if (adapter !== void 0 && !key.endsWith(READALOUD_SUFFIX)) {
|
|
3799
|
+
if (!seen.has(id)) {
|
|
3800
|
+
seen.add(id);
|
|
3801
|
+
try {
|
|
3802
|
+
await adapter.unwire(deps);
|
|
3803
|
+
io.note(`Unwired ${label}.`);
|
|
3804
|
+
} catch (error) {
|
|
3805
|
+
io.note(`Could not fully reverse ${label}: ${errorMessage(error)}`);
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
} else {
|
|
3809
|
+
try {
|
|
3810
|
+
unwireFromLedger(key);
|
|
3811
|
+
io.note(`Unwired ${label}.`);
|
|
3812
|
+
} catch (error) {
|
|
3813
|
+
io.note(`Could not fully reverse ${label}: ${errorMessage(error)}`);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
const deleteHome = await io.confirm({
|
|
3818
|
+
message: "Also delete turnbell's config, logs, and ledger (TURNBELL_HOME)?",
|
|
3819
|
+
initialValue: false
|
|
3820
|
+
});
|
|
3821
|
+
if (deleteHome) {
|
|
3822
|
+
rmSync3(turnbellHome(), { recursive: true, force: true });
|
|
3823
|
+
io.note("Deleted TURNBELL_HOME.");
|
|
3824
|
+
}
|
|
3825
|
+
return EXIT_OK2;
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
// src/cli/init.ts
|
|
3829
|
+
var EXIT_OK3 = 0;
|
|
3830
|
+
var CANCEL_MESSAGE = "Setup cancelled.";
|
|
3831
|
+
var V1_CONFIG = [".claude", "hollr", "config.json"];
|
|
3832
|
+
function unwrap(value) {
|
|
3833
|
+
if (clack.isCancel(value)) {
|
|
3834
|
+
clack.cancel(CANCEL_MESSAGE);
|
|
3835
|
+
throw new Error(CANCEL_MESSAGE);
|
|
3836
|
+
}
|
|
3837
|
+
return value;
|
|
3838
|
+
}
|
|
3839
|
+
function toClackOptions(options) {
|
|
3840
|
+
return options.map((option) => ({
|
|
3841
|
+
value: option.value,
|
|
3842
|
+
label: option.label,
|
|
3843
|
+
...option.hint === void 0 ? {} : { hint: option.hint }
|
|
3844
|
+
}));
|
|
3845
|
+
}
|
|
3846
|
+
function clackIo() {
|
|
3847
|
+
return {
|
|
3848
|
+
async multiselect(opts) {
|
|
3849
|
+
const chosen = unwrap(
|
|
3850
|
+
await clack.multiselect({
|
|
3851
|
+
message: opts.message,
|
|
3852
|
+
options: toClackOptions(opts.options),
|
|
3853
|
+
...opts.initialValues === void 0 ? {} : { initialValues: opts.initialValues },
|
|
3854
|
+
required: opts.required ?? false
|
|
3855
|
+
})
|
|
3856
|
+
);
|
|
3857
|
+
return chosen;
|
|
3858
|
+
},
|
|
3859
|
+
async select(opts) {
|
|
3860
|
+
const chosen = unwrap(
|
|
3861
|
+
await clack.select({
|
|
3862
|
+
message: opts.message,
|
|
3863
|
+
options: toClackOptions(opts.options),
|
|
3864
|
+
...opts.initialValue === void 0 ? {} : { initialValue: opts.initialValue }
|
|
3865
|
+
})
|
|
3866
|
+
);
|
|
3867
|
+
return chosen;
|
|
3868
|
+
},
|
|
3869
|
+
async text(opts) {
|
|
3870
|
+
return unwrap(
|
|
3871
|
+
await clack.text({
|
|
3872
|
+
message: opts.message,
|
|
3873
|
+
...opts.placeholder === void 0 ? {} : { placeholder: opts.placeholder },
|
|
3874
|
+
...opts.initialValue === void 0 ? {} : { initialValue: opts.initialValue }
|
|
3875
|
+
})
|
|
3876
|
+
);
|
|
3877
|
+
},
|
|
3878
|
+
async confirm(opts) {
|
|
3879
|
+
return unwrap(
|
|
3880
|
+
await clack.confirm({
|
|
3881
|
+
message: opts.message,
|
|
3882
|
+
initialValue: opts.initialValue ?? false
|
|
3883
|
+
})
|
|
3884
|
+
);
|
|
3885
|
+
},
|
|
3886
|
+
note(message, title) {
|
|
3887
|
+
clack.note(message, title);
|
|
3888
|
+
}
|
|
3889
|
+
};
|
|
3890
|
+
}
|
|
3891
|
+
function enumerateVoices(platform) {
|
|
3892
|
+
const argv = platform.enumerateVoicesArgv();
|
|
3893
|
+
const command = argv?.[0];
|
|
3894
|
+
if (argv === null || command === void 0) {
|
|
3895
|
+
return [];
|
|
3896
|
+
}
|
|
3897
|
+
const result = spawnSync(command, argv.slice(1), { encoding: "utf8" });
|
|
3898
|
+
if (result.status !== 0 || typeof result.stdout !== "string") {
|
|
3899
|
+
return [];
|
|
3900
|
+
}
|
|
3901
|
+
return platform.parseVoicesOutput(result.stdout);
|
|
3902
|
+
}
|
|
3903
|
+
function autoRunFix(command) {
|
|
3904
|
+
const parts = command.split(" ");
|
|
3905
|
+
const bin = parts[0];
|
|
3906
|
+
if (bin === void 0) {
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3909
|
+
spawnSync(bin, parts.slice(1), { stdio: "inherit" });
|
|
3910
|
+
}
|
|
3911
|
+
function realInitDeps() {
|
|
3912
|
+
const platform = selectPlatform();
|
|
3913
|
+
return {
|
|
3914
|
+
io: clackIo(),
|
|
3915
|
+
adapters,
|
|
3916
|
+
platform,
|
|
3917
|
+
home: homedir3(),
|
|
3918
|
+
which: whichOnPath,
|
|
3919
|
+
detectV1: () => existsSync6(join19(homedir3(), ...V1_CONFIG)),
|
|
3920
|
+
migrate: migrateV1,
|
|
3921
|
+
enumerateVoices: () => enumerateVoices(platform),
|
|
3922
|
+
autoRunFix
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3925
|
+
async function runInitCli(argv, runTestFn) {
|
|
3926
|
+
const yes = argv.includes("--yes") || argv.includes("-y");
|
|
3927
|
+
clack.intro("turnbell setup");
|
|
3928
|
+
const result = await runInit(realInitDeps(), { yes });
|
|
3929
|
+
if (result.configPath === null) {
|
|
3930
|
+
clack.outro("Setup stopped \u2014 nothing was written.");
|
|
3931
|
+
return EXIT_OK3;
|
|
3932
|
+
}
|
|
3933
|
+
clack.outro(`Wrote ${result.configPath}`);
|
|
3934
|
+
if (result.runTest) {
|
|
3935
|
+
await runTestFn();
|
|
3936
|
+
}
|
|
3937
|
+
return EXIT_OK3;
|
|
3938
|
+
}
|
|
3939
|
+
function runUninstallCli() {
|
|
3940
|
+
clack.intro("turnbell uninstall");
|
|
3941
|
+
return runUninstall(clackIo(), { home: homedir3(), which: whichOnPath });
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
// src/cli/mute.ts
|
|
3945
|
+
import { mkdirSync as mkdirSync8, unlinkSync as unlinkSync3, writeFileSync as writeFileSync8 } from "fs";
|
|
3946
|
+
import { dirname as dirname4, join as join20 } from "path";
|
|
3947
|
+
var PROJECTS_DIR = "projects";
|
|
3948
|
+
var MUTED_SUFFIX = ".muted";
|
|
3949
|
+
var ENABLED_SUFFIX2 = ".enabled";
|
|
3950
|
+
var EXIT_OK4 = 0;
|
|
3951
|
+
function muteFlagPath2(cwd) {
|
|
3952
|
+
return join20(turnbellHome(), PROJECTS_DIR, `${encodeCwd(cwd)}${MUTED_SUFFIX}`);
|
|
3953
|
+
}
|
|
3954
|
+
function enabledFlagPath(cwd) {
|
|
3955
|
+
return join20(turnbellHome(), PROJECTS_DIR, `${encodeCwd(cwd)}${ENABLED_SUFFIX2}`);
|
|
3956
|
+
}
|
|
3957
|
+
function desiredState(sub, cwd) {
|
|
3958
|
+
if (sub === "on") {
|
|
3959
|
+
return true;
|
|
3960
|
+
}
|
|
3961
|
+
if (sub === "off") {
|
|
3962
|
+
return false;
|
|
3963
|
+
}
|
|
3964
|
+
return !isMuted(cwd);
|
|
3965
|
+
}
|
|
3966
|
+
function createFlag(path) {
|
|
3967
|
+
try {
|
|
3968
|
+
mkdirSync8(dirname4(path), { recursive: true });
|
|
3969
|
+
writeFileSync8(path, "");
|
|
3970
|
+
} catch (cause) {
|
|
3971
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
3972
|
+
throw new Error(`could not write mute flag at ${path}: ${reason}`);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
function removeFlag(path) {
|
|
3976
|
+
try {
|
|
3977
|
+
unlinkSync3(path);
|
|
3978
|
+
} catch {
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
function setProjectState(on, cwd) {
|
|
3982
|
+
const mutedPath = muteFlagPath2(cwd);
|
|
3983
|
+
const enabledPath = enabledFlagPath(cwd);
|
|
3984
|
+
if (on) {
|
|
3985
|
+
createFlag(enabledPath);
|
|
3986
|
+
removeFlag(mutedPath);
|
|
3987
|
+
} else {
|
|
3988
|
+
createFlag(mutedPath);
|
|
3989
|
+
removeFlag(enabledPath);
|
|
3990
|
+
}
|
|
3991
|
+
const label = projectLabel(cwd);
|
|
3992
|
+
process.stdout.write(`turnbell: ${on ? "on for" : "off for"} ${label}
|
|
3993
|
+
`);
|
|
3994
|
+
return EXIT_OK4;
|
|
3995
|
+
}
|
|
3996
|
+
function runMute(args, cwd) {
|
|
3997
|
+
const muted = desiredState(args[0], cwd);
|
|
3998
|
+
return setProjectState(!muted, cwd);
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
// src/cli/run.ts
|
|
4002
|
+
import { basename as basename3 } from "path";
|
|
4003
|
+
import { StringDecoder } from "string_decoder";
|
|
4004
|
+
var SEPARATOR = "--";
|
|
4005
|
+
var STREAM_FLAG = "--announce-stream";
|
|
4006
|
+
var STREAM_FORMAT_CURSOR = "cursor";
|
|
4007
|
+
var RESULT_EVENT_TYPE = "result";
|
|
4008
|
+
var RESULT_TEXT_FIELD = "result";
|
|
4009
|
+
var LINE_SEPARATOR = "\n";
|
|
4010
|
+
var WRAPPER_AGENT = "wrapper";
|
|
4011
|
+
var EVENT_DONE = "done";
|
|
4012
|
+
var EVENT_ERROR = "error";
|
|
4013
|
+
var EXIT_OK5 = 0;
|
|
4014
|
+
var EXIT_USAGE = 2;
|
|
4015
|
+
var STREAM_DRAIN_GRACE_MS = 2e3;
|
|
4016
|
+
var SPAWN_FAILED = 127;
|
|
4017
|
+
var SIGNAL_EXIT_CODE = 1;
|
|
4018
|
+
var RUN_USAGE = "usage: turnbell run [--announce-stream cursor] -- <cmd> [args...]";
|
|
4019
|
+
function isRecord12(value) {
|
|
4020
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4021
|
+
}
|
|
4022
|
+
function parseStreamFlag(pre) {
|
|
4023
|
+
const index = pre.indexOf(STREAM_FLAG);
|
|
4024
|
+
if (index === -1) {
|
|
4025
|
+
return { cursor: false };
|
|
4026
|
+
}
|
|
4027
|
+
const value = pre[index + 1];
|
|
4028
|
+
if (value !== STREAM_FORMAT_CURSOR) {
|
|
4029
|
+
return { cursor: false, error: `unsupported stream format: ${value ?? ""}` };
|
|
4030
|
+
}
|
|
4031
|
+
return { cursor: true };
|
|
4032
|
+
}
|
|
4033
|
+
function parseRunArgs(args) {
|
|
4034
|
+
const separator = args.indexOf(SEPARATOR);
|
|
4035
|
+
if (separator === -1) {
|
|
4036
|
+
return { error: "missing `--` before the command" };
|
|
4037
|
+
}
|
|
4038
|
+
const child = args.slice(separator + 1);
|
|
4039
|
+
const command = child[0];
|
|
4040
|
+
if (command === void 0) {
|
|
4041
|
+
return { error: "no command given after `--`" };
|
|
4042
|
+
}
|
|
4043
|
+
const stream = parseStreamFlag(args.slice(0, separator));
|
|
4044
|
+
if (stream.error !== void 0) {
|
|
4045
|
+
return { error: stream.error };
|
|
4046
|
+
}
|
|
4047
|
+
return { streamCursor: stream.cursor, command, commandArgs: child.slice(1) };
|
|
4048
|
+
}
|
|
4049
|
+
function parseCursorResult(line) {
|
|
4050
|
+
const trimmed = line.trim();
|
|
4051
|
+
if (trimmed.length === 0) {
|
|
4052
|
+
return null;
|
|
4053
|
+
}
|
|
4054
|
+
let parsed;
|
|
4055
|
+
try {
|
|
4056
|
+
parsed = JSON.parse(trimmed);
|
|
4057
|
+
} catch {
|
|
4058
|
+
return null;
|
|
4059
|
+
}
|
|
4060
|
+
if (!isRecord12(parsed) || parsed.type !== RESULT_EVENT_TYPE) {
|
|
4061
|
+
return null;
|
|
4062
|
+
}
|
|
4063
|
+
const text2 = parsed[RESULT_TEXT_FIELD];
|
|
4064
|
+
return typeof text2 === "string" ? text2 : null;
|
|
4065
|
+
}
|
|
4066
|
+
function createResultParser(out) {
|
|
4067
|
+
let lastResponse = null;
|
|
4068
|
+
let buffer = "";
|
|
4069
|
+
const consume = (line) => {
|
|
4070
|
+
const found = parseCursorResult(line);
|
|
4071
|
+
if (found !== null) {
|
|
4072
|
+
lastResponse = found;
|
|
4073
|
+
}
|
|
4074
|
+
};
|
|
4075
|
+
return {
|
|
4076
|
+
feed(text2) {
|
|
4077
|
+
if (text2.length === 0) {
|
|
4078
|
+
return;
|
|
4079
|
+
}
|
|
4080
|
+
out(text2);
|
|
4081
|
+
buffer += text2;
|
|
4082
|
+
const parts = buffer.split(LINE_SEPARATOR);
|
|
4083
|
+
buffer = parts.pop() ?? "";
|
|
4084
|
+
for (const line of parts) {
|
|
4085
|
+
consume(line);
|
|
4086
|
+
}
|
|
4087
|
+
},
|
|
4088
|
+
flush() {
|
|
4089
|
+
consume(buffer);
|
|
4090
|
+
buffer = "";
|
|
4091
|
+
},
|
|
4092
|
+
read: () => lastResponse
|
|
4093
|
+
};
|
|
4094
|
+
}
|
|
4095
|
+
function attachStreamCapture(child, deps) {
|
|
4096
|
+
const parser = createResultParser(deps.out);
|
|
4097
|
+
const stdout = child.stdout;
|
|
4098
|
+
if (stdout === null) {
|
|
4099
|
+
return { read: parser.read, drained: Promise.resolve() };
|
|
4100
|
+
}
|
|
4101
|
+
let resolveDrained = null;
|
|
4102
|
+
const drained = new Promise((resolve) => {
|
|
4103
|
+
resolveDrained = resolve;
|
|
4104
|
+
});
|
|
4105
|
+
const decoder = new StringDecoder("utf8");
|
|
4106
|
+
stdout.on("data", (chunk) => {
|
|
4107
|
+
parser.feed(decoder.write(chunk));
|
|
4108
|
+
});
|
|
4109
|
+
stdout.on("end", () => {
|
|
4110
|
+
parser.feed(decoder.end());
|
|
4111
|
+
parser.flush();
|
|
4112
|
+
resolveDrained?.();
|
|
4113
|
+
});
|
|
4114
|
+
return { read: parser.read, drained };
|
|
4115
|
+
}
|
|
4116
|
+
function buildWrapperEvent(deps, command, exitCode, lastResponse) {
|
|
4117
|
+
const now = deps.now();
|
|
4118
|
+
return {
|
|
4119
|
+
v: 1,
|
|
4120
|
+
ts: now.toISOString(),
|
|
4121
|
+
agent: WRAPPER_AGENT,
|
|
4122
|
+
agentTitle: basename3(command),
|
|
4123
|
+
event: exitCode === EXIT_OK5 ? EVENT_DONE : EVENT_ERROR,
|
|
4124
|
+
cwd: deps.cwd,
|
|
4125
|
+
project: projectLabel(deps.cwd),
|
|
4126
|
+
summary: "",
|
|
4127
|
+
lastResponse
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
async function emitEvent(deps, event) {
|
|
4131
|
+
const cfg = loadConfig(event.cwd);
|
|
4132
|
+
hardenConfig(cfg.webhooks);
|
|
4133
|
+
route(
|
|
4134
|
+
event,
|
|
4135
|
+
cfg,
|
|
4136
|
+
{
|
|
4137
|
+
platform: deps.platform,
|
|
4138
|
+
speak: deps.speak,
|
|
4139
|
+
notify: deps.notify,
|
|
4140
|
+
webhooks: (ev) => {
|
|
4141
|
+
deps.webhooks(ev, cfg.webhooks, cfg.allowHttp);
|
|
4142
|
+
}
|
|
4143
|
+
},
|
|
4144
|
+
deps.now()
|
|
4145
|
+
);
|
|
4146
|
+
await settleWebhooks(deps.awaitWebhooks());
|
|
4147
|
+
}
|
|
4148
|
+
async function finishExit(deps, command, exitCode, capture) {
|
|
4149
|
+
try {
|
|
4150
|
+
const lastResponse = exitCode === EXIT_OK5 && capture !== null ? capture() : null;
|
|
4151
|
+
await emitEvent(deps, buildWrapperEvent(deps, command, exitCode, lastResponse));
|
|
4152
|
+
} catch {
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
function awaitChild(child, deps, command, capture) {
|
|
4156
|
+
return new Promise((resolve) => {
|
|
4157
|
+
let settled = false;
|
|
4158
|
+
const finish = (code) => {
|
|
4159
|
+
if (settled) {
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
settled = true;
|
|
4163
|
+
const read = capture !== null ? capture.read : null;
|
|
4164
|
+
void finishExit(deps, command, code, read).finally(() => {
|
|
4165
|
+
resolve(code);
|
|
4166
|
+
});
|
|
4167
|
+
};
|
|
4168
|
+
child.on("error", () => {
|
|
4169
|
+
finish(SPAWN_FAILED);
|
|
4170
|
+
});
|
|
4171
|
+
child.on("exit", (code) => {
|
|
4172
|
+
const graced = capture === null ? Promise.resolve() : Promise.race([capture.drained, deps.delay(STREAM_DRAIN_GRACE_MS)]);
|
|
4173
|
+
void graced.then(() => {
|
|
4174
|
+
finish(code ?? SIGNAL_EXIT_CODE);
|
|
4175
|
+
});
|
|
4176
|
+
});
|
|
4177
|
+
});
|
|
4178
|
+
}
|
|
4179
|
+
function runWrapper(args, deps) {
|
|
4180
|
+
const parsed = parseRunArgs(args);
|
|
4181
|
+
if ("error" in parsed) {
|
|
4182
|
+
process.stderr.write(`turnbell run: ${parsed.error}
|
|
4183
|
+
${RUN_USAGE}
|
|
4184
|
+
`);
|
|
4185
|
+
return Promise.resolve(EXIT_USAGE);
|
|
4186
|
+
}
|
|
4187
|
+
const mode = parsed.streamCursor ? "stream" : "inherit";
|
|
4188
|
+
const child = deps.spawn(parsed.command, parsed.commandArgs, mode);
|
|
4189
|
+
const capture = parsed.streamCursor ? attachStreamCapture(child, deps) : null;
|
|
4190
|
+
return awaitChild(child, deps, parsed.command, capture);
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
// src/cli/test.ts
|
|
4194
|
+
var TEST_AGENT = "turnbell-test";
|
|
4195
|
+
var TEST_TITLE = "turnbell";
|
|
4196
|
+
var TEST_EVENT = "done";
|
|
4197
|
+
var TEST_SUMMARY = "turnbell test";
|
|
4198
|
+
var PAYLOAD_INDENT = 2;
|
|
4199
|
+
var EXIT_OK6 = 0;
|
|
4200
|
+
function parseTestFlags(args) {
|
|
4201
|
+
return {
|
|
4202
|
+
webhook: args.includes("--webhook"),
|
|
4203
|
+
showPayload: args.includes("--show-payload")
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4206
|
+
function buildTestEvent(cwd, now) {
|
|
4207
|
+
return {
|
|
4208
|
+
v: 1,
|
|
4209
|
+
ts: now.toISOString(),
|
|
4210
|
+
agent: TEST_AGENT,
|
|
4211
|
+
agentTitle: TEST_TITLE,
|
|
4212
|
+
event: TEST_EVENT,
|
|
4213
|
+
cwd,
|
|
4214
|
+
project: projectLabel(cwd),
|
|
4215
|
+
summary: TEST_SUMMARY
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
async function runTest(args, deps, now) {
|
|
4219
|
+
const flags = parseTestFlags(args);
|
|
4220
|
+
const event = buildTestEvent(deps.cwd, now);
|
|
4221
|
+
if (flags.showPayload) {
|
|
4222
|
+
deps.out(`${JSON.stringify(webhookPayload(event), null, PAYLOAD_INDENT)}
|
|
4223
|
+
`);
|
|
4224
|
+
return EXIT_OK6;
|
|
4225
|
+
}
|
|
4226
|
+
const cfg = loadConfig(deps.cwd);
|
|
4227
|
+
const code = route(
|
|
4228
|
+
event,
|
|
4229
|
+
cfg,
|
|
4230
|
+
{
|
|
4231
|
+
platform: deps.platform,
|
|
4232
|
+
speak: deps.speak,
|
|
4233
|
+
notify: deps.notify,
|
|
4234
|
+
webhooks: (ev) => {
|
|
4235
|
+
if (flags.webhook) {
|
|
4236
|
+
deps.webhooks(ev, cfg.webhooks, cfg.allowHttp);
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
},
|
|
4240
|
+
now
|
|
4241
|
+
);
|
|
4242
|
+
await settleWebhooks(deps.awaitWebhooks());
|
|
4243
|
+
return code;
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
// src/cli/status.ts
|
|
4247
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
4248
|
+
import { join as join21 } from "path";
|
|
4249
|
+
var LOG_TAIL = 5;
|
|
4250
|
+
var WEBHOOK_LOG = "webhook.log";
|
|
4251
|
+
var EVENTS_LOG2 = "events.log";
|
|
4252
|
+
var EXIT_OK7 = 0;
|
|
4253
|
+
var NONE = "none";
|
|
4254
|
+
var UNKNOWN_MODE = "unknown";
|
|
4255
|
+
var DEFAULT_VOICE = "system default";
|
|
4256
|
+
var MS_PER_MINUTE = 6e4;
|
|
4257
|
+
var EVENT_NAMES2 = ["done", "blocked", "error"];
|
|
4258
|
+
function isRecord13(value) {
|
|
4259
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4260
|
+
}
|
|
4261
|
+
function wiredLabel(key) {
|
|
4262
|
+
const id = key.split(":")[0] ?? key;
|
|
4263
|
+
return byId(id)?.title ?? key;
|
|
4264
|
+
}
|
|
4265
|
+
function eventMode(config, event) {
|
|
4266
|
+
const events = config.events;
|
|
4267
|
+
if (isRecord13(events)) {
|
|
4268
|
+
const entry = events[event];
|
|
4269
|
+
if (isRecord13(entry) && typeof entry.mode === "string") {
|
|
4270
|
+
return entry.mode;
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
return UNKNOWN_MODE;
|
|
4274
|
+
}
|
|
4275
|
+
function webhookNames(config) {
|
|
4276
|
+
const targets = config.webhooks;
|
|
4277
|
+
if (!Array.isArray(targets)) {
|
|
4278
|
+
return [];
|
|
4279
|
+
}
|
|
4280
|
+
return targets.map((target) => isRecord13(target) && typeof target.name === "string" ? target.name : "").filter((name) => name.length > 0);
|
|
4281
|
+
}
|
|
4282
|
+
function section(title, lines) {
|
|
4283
|
+
const body = lines.length === 0 ? [` ${NONE}`] : lines.map((line) => ` ${line}`);
|
|
4284
|
+
return [`${title}:`, ...body].join("\n");
|
|
4285
|
+
}
|
|
4286
|
+
function wiredSection(keys) {
|
|
4287
|
+
const labels = [...new Set(keys.map(wiredLabel))];
|
|
4288
|
+
return section("Wired adapters", labels);
|
|
4289
|
+
}
|
|
4290
|
+
function eventsSection(config) {
|
|
4291
|
+
return section(
|
|
4292
|
+
"Events",
|
|
4293
|
+
EVENT_NAMES2.map((event) => `${event}: ${eventMode(config, event)}`)
|
|
4294
|
+
);
|
|
4295
|
+
}
|
|
4296
|
+
function configSection(config) {
|
|
4297
|
+
const voice = config.voice.name ?? DEFAULT_VOICE;
|
|
4298
|
+
return [
|
|
4299
|
+
`Voice: ${voice} @ ${config.voice.rateWpm} wpm`,
|
|
4300
|
+
`Quiet hours: ${config.quietHours ?? NONE}`,
|
|
4301
|
+
`Webhooks: ${webhookNames(config).join(", ") || NONE}`
|
|
4302
|
+
].join("\n");
|
|
4303
|
+
}
|
|
4304
|
+
function scopeLine(activation) {
|
|
4305
|
+
return activation === "opt-in" ? "Notifications: on only where you turn it on" : "Notifications: on in every project";
|
|
4306
|
+
}
|
|
4307
|
+
function projectStateLine(model) {
|
|
4308
|
+
if (model.muted) {
|
|
4309
|
+
return "This project: off for this project \u2014 run 'turnbell on' to enable";
|
|
4310
|
+
}
|
|
4311
|
+
if (model.enabled) {
|
|
4312
|
+
return "This project: on for this project";
|
|
4313
|
+
}
|
|
4314
|
+
if (model.activation === "opt-in") {
|
|
4315
|
+
return "This project: not turned on here \u2014 run 'turnbell on' to enable";
|
|
4316
|
+
}
|
|
4317
|
+
return "This project: on for this project";
|
|
4318
|
+
}
|
|
4319
|
+
function quietLine(quiet) {
|
|
4320
|
+
if (!quiet.active) {
|
|
4321
|
+
return "Quiet: no";
|
|
4322
|
+
}
|
|
4323
|
+
if (quiet.remainingMinutes === null) {
|
|
4324
|
+
return "Quiet: quiet until you run 'turnbell quiet off'";
|
|
4325
|
+
}
|
|
4326
|
+
return `Quiet: quiet for ${quiet.remainingMinutes} more minutes`;
|
|
4327
|
+
}
|
|
4328
|
+
function projectSection(model) {
|
|
4329
|
+
return [
|
|
4330
|
+
`Project: ${projectLabel(model.cwd)} (${model.cwd})`,
|
|
4331
|
+
scopeLine(model.activation),
|
|
4332
|
+
projectStateLine(model),
|
|
4333
|
+
quietLine(model.quiet),
|
|
4334
|
+
`Pause/resume: ${model.canPauseResume ? "supported" : "unsupported"}`
|
|
4335
|
+
].join("\n");
|
|
4336
|
+
}
|
|
4337
|
+
function formatStatus(model) {
|
|
4338
|
+
return [
|
|
4339
|
+
"turnbell status",
|
|
4340
|
+
wiredSection(model.wiredKeys),
|
|
4341
|
+
eventsSection(model.config),
|
|
4342
|
+
configSection(model.config),
|
|
4343
|
+
projectSection(model),
|
|
4344
|
+
section("Recent webhook deliveries", model.webhookLog),
|
|
4345
|
+
section("Recent events", model.eventsLog),
|
|
4346
|
+
""
|
|
4347
|
+
].join("\n\n");
|
|
4348
|
+
}
|
|
4349
|
+
function readLogTail(path) {
|
|
4350
|
+
try {
|
|
4351
|
+
return readFileSync8(path, "utf8").split("\n").filter((line) => line.length > 0).slice(-LOG_TAIL);
|
|
4352
|
+
} catch {
|
|
4353
|
+
return [];
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
function readQuietRemaining(now) {
|
|
4357
|
+
try {
|
|
4358
|
+
const raw = readFileSync8(quietUntilPath(), "utf8").trim();
|
|
4359
|
+
if (!/^-?\d+$/.test(raw)) {
|
|
4360
|
+
return null;
|
|
4361
|
+
}
|
|
4362
|
+
const remaining = Number.parseInt(raw, 10) - now.getTime();
|
|
4363
|
+
return remaining > 0 ? remaining : null;
|
|
4364
|
+
} catch {
|
|
4365
|
+
return null;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
function runStatus(io) {
|
|
4369
|
+
const home = turnbellHome();
|
|
4370
|
+
const config = loadConfig(io.cwd);
|
|
4371
|
+
const now = /* @__PURE__ */ new Date();
|
|
4372
|
+
const quietMs = readQuietRemaining(now);
|
|
4373
|
+
const model = {
|
|
4374
|
+
cwd: io.cwd,
|
|
4375
|
+
config,
|
|
4376
|
+
muted: isMuted(io.cwd),
|
|
4377
|
+
enabled: isProjectEnabled(io.cwd),
|
|
4378
|
+
activation: config.activation,
|
|
4379
|
+
quiet: {
|
|
4380
|
+
active: quietActive(now),
|
|
4381
|
+
remainingMinutes: quietMs === null ? null : Math.ceil(quietMs / MS_PER_MINUTE)
|
|
4382
|
+
},
|
|
4383
|
+
canPauseResume: io.platform.canPauseResume,
|
|
4384
|
+
wiredKeys: listWiredKeys(),
|
|
4385
|
+
webhookLog: readLogTail(join21(home, WEBHOOK_LOG)),
|
|
4386
|
+
eventsLog: readLogTail(join21(home, EVENTS_LOG2))
|
|
4387
|
+
};
|
|
4388
|
+
io.out(formatStatus(model));
|
|
4389
|
+
return EXIT_OK7;
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
// src/cli/quiet.ts
|
|
4393
|
+
import { mkdirSync as mkdirSync9, unlinkSync as unlinkSync4, writeFileSync as writeFileSync9 } from "fs";
|
|
4394
|
+
import { dirname as dirname5 } from "path";
|
|
4395
|
+
var EXIT_OK8 = 0;
|
|
4396
|
+
var QUIET_INDEFINITE2 = "indefinite";
|
|
4397
|
+
var DURATION_RE = /^(\d+)([smh])$/;
|
|
4398
|
+
var UNIT_MS = { s: 1e3, m: 6e4, h: 36e5 };
|
|
4399
|
+
function parseDuration(spec) {
|
|
4400
|
+
const match = DURATION_RE.exec(spec);
|
|
4401
|
+
if (match === null) {
|
|
4402
|
+
return null;
|
|
4403
|
+
}
|
|
4404
|
+
const amount = Number.parseInt(match[1] ?? "", 10);
|
|
4405
|
+
const unit = UNIT_MS[match[2] ?? ""];
|
|
4406
|
+
if (amount <= 0 || unit === void 0) {
|
|
4407
|
+
return null;
|
|
4408
|
+
}
|
|
4409
|
+
return amount * unit;
|
|
4410
|
+
}
|
|
4411
|
+
function writeMarker(body) {
|
|
4412
|
+
const path = quietUntilPath();
|
|
4413
|
+
mkdirSync9(dirname5(path), { recursive: true });
|
|
4414
|
+
writeFileSync9(path, body);
|
|
4415
|
+
}
|
|
4416
|
+
function clearMarker() {
|
|
4417
|
+
try {
|
|
4418
|
+
unlinkSync4(quietUntilPath());
|
|
4419
|
+
} catch {
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
function runQuiet(args, now) {
|
|
4423
|
+
const arg = args[0];
|
|
4424
|
+
if (arg === "off") {
|
|
4425
|
+
clearMarker();
|
|
4426
|
+
process.stdout.write("turnbell: back on\n");
|
|
4427
|
+
return EXIT_OK8;
|
|
4428
|
+
}
|
|
4429
|
+
if (arg === void 0) {
|
|
4430
|
+
writeMarker(QUIET_INDEFINITE2);
|
|
4431
|
+
process.stdout.write("turnbell: quiet until you run `turnbell quiet off`\n");
|
|
4432
|
+
return EXIT_OK8;
|
|
4433
|
+
}
|
|
4434
|
+
const ms = parseDuration(arg);
|
|
4435
|
+
if (ms === null) {
|
|
4436
|
+
throw new Error(`could not understand '${arg}' \u2014 try e.g. 30m, 1h, or 90s`);
|
|
4437
|
+
}
|
|
4438
|
+
writeMarker(String(now.getTime() + ms));
|
|
4439
|
+
process.stdout.write(`turnbell: quiet for ${arg}
|
|
4440
|
+
`);
|
|
4441
|
+
return EXIT_OK8;
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
// src/platform/sequencer.ts
|
|
4445
|
+
import { join as join22 } from "path";
|
|
4446
|
+
import { fileURLToPath } from "url";
|
|
4447
|
+
var NODE_BIN = "node";
|
|
4448
|
+
var READING_PIDFILE_NAME2 = "reading.pid";
|
|
4449
|
+
var HELPER_PATH = fileURLToPath(
|
|
4450
|
+
new URL("../scripts/helper/play-then-say.mjs", import.meta.url)
|
|
4451
|
+
);
|
|
4452
|
+
function speakSequenced(opts) {
|
|
4453
|
+
const soundArgv = opts.sound === null ? null : opts.platform.soundArgv(opts.sound);
|
|
4454
|
+
const voiceArgv = opts.platform.voiceArgv(opts.text, opts.voice, opts.rateWpm);
|
|
4455
|
+
if (soundArgv === null && voiceArgv === null) {
|
|
4456
|
+
return;
|
|
4457
|
+
}
|
|
4458
|
+
const pidPath = join22(turnbellHome(), READING_PIDFILE_NAME2);
|
|
4459
|
+
const payload = JSON.stringify({ soundArgv, voiceArgv, pidPath });
|
|
4460
|
+
spawnDetached([NODE_BIN, HELPER_PATH, payload]);
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
// src/index.ts
|
|
4464
|
+
var CLI_NAME = "turnbell";
|
|
4465
|
+
var EXIT_OK9 = 0;
|
|
4466
|
+
var EXIT_REQUIRED_FAIL = 1;
|
|
4467
|
+
var EXIT_ERROR = 1;
|
|
4468
|
+
var EXIT_USAGE2 = 2;
|
|
4469
|
+
var USAGE = "usage: turnbell <init|uninstall|emit|run|test|status|on|off|quiet|pause|resume|stop|mute|doctor> [options] (--version for version)";
|
|
4470
|
+
var MARK_OK = "\u2714";
|
|
4471
|
+
var MARK_MISSING = "\u2716";
|
|
4472
|
+
var VERSION = "1.0.0";
|
|
4473
|
+
function getVersionString() {
|
|
4474
|
+
return `${CLI_NAME} ${VERSION}`;
|
|
4475
|
+
}
|
|
4476
|
+
function realSinks() {
|
|
4477
|
+
let pendingWebhooks = Promise.resolve();
|
|
4478
|
+
return {
|
|
4479
|
+
platform: selectPlatform(),
|
|
4480
|
+
speak: speakSequenced,
|
|
4481
|
+
notify: (argv) => {
|
|
4482
|
+
spawnDetached(argv);
|
|
4483
|
+
},
|
|
4484
|
+
webhooks: (ev, targets, allowHttp) => {
|
|
4485
|
+
pendingWebhooks = fireWebhooks2(ev, targets, { allowHttp });
|
|
4486
|
+
},
|
|
4487
|
+
awaitWebhooks: () => pendingWebhooks
|
|
4488
|
+
};
|
|
4489
|
+
}
|
|
4490
|
+
function realEmitDeps() {
|
|
4491
|
+
return { readStdin, ...realSinks() };
|
|
4492
|
+
}
|
|
4493
|
+
function realSpawn(command, args, mode) {
|
|
4494
|
+
const stdio = mode === "stream" ? ["inherit", "pipe", "inherit"] : "inherit";
|
|
4495
|
+
return spawn2(command, args, { stdio });
|
|
4496
|
+
}
|
|
4497
|
+
function realDelay(ms) {
|
|
4498
|
+
return new Promise((resolve) => {
|
|
4499
|
+
setTimeout(resolve, ms).unref();
|
|
4500
|
+
});
|
|
4501
|
+
}
|
|
4502
|
+
function realWrapperDeps() {
|
|
4503
|
+
return {
|
|
4504
|
+
spawn: realSpawn,
|
|
4505
|
+
out: (chunk) => {
|
|
4506
|
+
process.stdout.write(chunk);
|
|
4507
|
+
},
|
|
4508
|
+
cwd: process.cwd(),
|
|
4509
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
4510
|
+
delay: realDelay,
|
|
4511
|
+
...realSinks()
|
|
4512
|
+
};
|
|
4513
|
+
}
|
|
4514
|
+
function realTestDeps() {
|
|
4515
|
+
return {
|
|
4516
|
+
cwd: process.cwd(),
|
|
4517
|
+
out: (text2) => {
|
|
4518
|
+
process.stdout.write(text2);
|
|
4519
|
+
},
|
|
4520
|
+
...realSinks()
|
|
4521
|
+
};
|
|
4522
|
+
}
|
|
4523
|
+
async function readStdin() {
|
|
4524
|
+
const chunks = [];
|
|
4525
|
+
let total = 0;
|
|
4526
|
+
for await (const chunk of process.stdin) {
|
|
4527
|
+
const buffer = chunk;
|
|
4528
|
+
total += buffer.length;
|
|
4529
|
+
if (total > MAX_STDIN_BYTES) {
|
|
4530
|
+
break;
|
|
4531
|
+
}
|
|
4532
|
+
chunks.push(buffer);
|
|
4533
|
+
}
|
|
4534
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
4535
|
+
}
|
|
4536
|
+
async function runEmitSafe(args) {
|
|
4537
|
+
try {
|
|
4538
|
+
return await runEmit(args, realEmitDeps());
|
|
4539
|
+
} catch {
|
|
4540
|
+
return EXIT_OK9;
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
function emitControl(message) {
|
|
4544
|
+
process.stdout.write(`${message}
|
|
4545
|
+
`);
|
|
4546
|
+
return EXIT_OK9;
|
|
4547
|
+
}
|
|
4548
|
+
async function runDoctor() {
|
|
4549
|
+
const checks = await checkAll({
|
|
4550
|
+
which: whichOnPath,
|
|
4551
|
+
platform: selectPlatform(),
|
|
4552
|
+
adapters
|
|
4553
|
+
});
|
|
4554
|
+
for (const check of checks) {
|
|
4555
|
+
printCheck(check);
|
|
4556
|
+
}
|
|
4557
|
+
return allRequiredOk(checks) ? EXIT_OK9 : EXIT_REQUIRED_FAIL;
|
|
4558
|
+
}
|
|
4559
|
+
function printCheck(check) {
|
|
4560
|
+
const mark = check.ok ? MARK_OK : MARK_MISSING;
|
|
4561
|
+
process.stdout.write(`${mark} ${check.label} \u2014 ${check.detail}
|
|
4562
|
+
`);
|
|
4563
|
+
if (!check.ok && check.fix !== null) {
|
|
4564
|
+
process.stdout.write(` fix: ${check.fix}
|
|
4565
|
+
`);
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
async function run(argv) {
|
|
4569
|
+
const [command, ...rest] = argv;
|
|
4570
|
+
switch (command) {
|
|
4571
|
+
case "--version":
|
|
4572
|
+
case "-v":
|
|
4573
|
+
return emitControl(getVersionString());
|
|
4574
|
+
case "init":
|
|
4575
|
+
return runInitCli(rest, () => runTest([], realTestDeps(), /* @__PURE__ */ new Date()));
|
|
4576
|
+
case "uninstall":
|
|
4577
|
+
return runUninstallCli();
|
|
4578
|
+
case "emit":
|
|
4579
|
+
return runEmitSafe(rest);
|
|
4580
|
+
case "run":
|
|
4581
|
+
return runWrapper(rest, realWrapperDeps());
|
|
4582
|
+
case "test":
|
|
4583
|
+
return runTest(rest, realTestDeps(), /* @__PURE__ */ new Date());
|
|
4584
|
+
case "status":
|
|
4585
|
+
return runStatus({
|
|
4586
|
+
cwd: process.cwd(),
|
|
4587
|
+
platform: selectPlatform(),
|
|
4588
|
+
out: (text2) => {
|
|
4589
|
+
process.stdout.write(text2);
|
|
4590
|
+
}
|
|
4591
|
+
});
|
|
4592
|
+
case "pause":
|
|
4593
|
+
return emitControl(pauseReading());
|
|
4594
|
+
case "resume":
|
|
4595
|
+
return emitControl(resumeReading());
|
|
4596
|
+
case "stop":
|
|
4597
|
+
return emitControl(stopReading());
|
|
4598
|
+
case "quiet":
|
|
4599
|
+
return runQuiet(rest, /* @__PURE__ */ new Date());
|
|
4600
|
+
case "on":
|
|
4601
|
+
return setProjectState(true, process.cwd());
|
|
4602
|
+
case "off":
|
|
4603
|
+
return setProjectState(false, process.cwd());
|
|
4604
|
+
case "unmute":
|
|
4605
|
+
return setProjectState(true, process.cwd());
|
|
4606
|
+
case "mute":
|
|
4607
|
+
return runMute(rest, process.cwd());
|
|
4608
|
+
case "doctor":
|
|
4609
|
+
return runDoctor();
|
|
4610
|
+
default:
|
|
4611
|
+
process.stderr.write(`${USAGE}
|
|
4612
|
+
`);
|
|
4613
|
+
return EXIT_USAGE2;
|
|
4614
|
+
}
|
|
4615
|
+
}
|
|
4616
|
+
async function main(argv) {
|
|
4617
|
+
try {
|
|
4618
|
+
migrateLegacyHome();
|
|
4619
|
+
return await run(argv);
|
|
4620
|
+
} catch (error) {
|
|
4621
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4622
|
+
process.stderr.write(`${CLI_NAME}: ${message}
|
|
4623
|
+
`);
|
|
4624
|
+
return EXIT_ERROR;
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4627
|
+
var LEGACY_BIN_NAME = "hollr";
|
|
4628
|
+
var RENAME_NOTICE = "hollr is now turnbell. This alias keeps existing hooks working; install the new package (npm i -g turnbell && npm rm -g hollr-cli) and re-run `turnbell init` to update your wiring.";
|
|
4629
|
+
function printRenameNoticeIfLegacyInvocation(argv, stderr) {
|
|
4630
|
+
const entry = argv[1];
|
|
4631
|
+
if (entry !== void 0 && basename4(entry) === LEGACY_BIN_NAME) {
|
|
4632
|
+
stderr(`${RENAME_NOTICE}
|
|
4633
|
+
`);
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4636
|
+
function isEntryModule(entry, modulePath, resolve) {
|
|
4637
|
+
if (entry === void 0) {
|
|
4638
|
+
return false;
|
|
4639
|
+
}
|
|
4640
|
+
if (entry === modulePath) {
|
|
4641
|
+
return true;
|
|
4642
|
+
}
|
|
4643
|
+
try {
|
|
4644
|
+
return resolve(entry) === modulePath;
|
|
4645
|
+
} catch {
|
|
4646
|
+
return false;
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
function isExecutedDirectly() {
|
|
4650
|
+
return isEntryModule(
|
|
4651
|
+
process.argv[1],
|
|
4652
|
+
fileURLToPath2(import.meta.url),
|
|
4653
|
+
realpathSync
|
|
4654
|
+
);
|
|
4655
|
+
}
|
|
4656
|
+
if (isExecutedDirectly()) {
|
|
4657
|
+
printRenameNoticeIfLegacyInvocation(process.argv, (line) => {
|
|
4658
|
+
process.stderr.write(line);
|
|
4659
|
+
});
|
|
4660
|
+
main(process.argv.slice(2)).then((code) => {
|
|
4661
|
+
process.exit(code);
|
|
4662
|
+
}).catch(() => {
|
|
4663
|
+
process.exit(EXIT_ERROR);
|
|
4664
|
+
});
|
|
4665
|
+
}
|
|
4666
|
+
export {
|
|
4667
|
+
VERSION,
|
|
4668
|
+
getVersionString,
|
|
4669
|
+
isEntryModule,
|
|
4670
|
+
main,
|
|
4671
|
+
printRenameNoticeIfLegacyInvocation,
|
|
4672
|
+
run
|
|
4673
|
+
};
|