@hasna/recordings 0.0.3
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/.claude/scheduled_tasks.lock +1 -0
- package/LICENSE +21 -0
- package/dist/cli/index.js +1446 -0
- package/dist/mcp/index.js +4882 -0
- package/package.json +46 -0
- package/src/__tests__/agents.test.ts +136 -0
- package/src/__tests__/config.test.ts +252 -0
- package/src/__tests__/database.test.ts +167 -0
- package/src/__tests__/enhancer.test.ts +574 -0
- package/src/__tests__/preload.ts +4 -0
- package/src/__tests__/projects.test.ts +109 -0
- package/src/__tests__/recorder.test.ts +278 -0
- package/src/__tests__/recordings.test.ts +353 -0
- package/src/__tests__/transcriber.test.ts +322 -0
- package/src/__tests__/types.test.ts +75 -0
- package/src/cli/index.ts +1078 -0
- package/src/db/agents.ts +81 -0
- package/src/db/database.ts +126 -0
- package/src/db/projects.ts +71 -0
- package/src/db/recordings.ts +219 -0
- package/src/index.ts +81 -0
- package/src/lib/config.ts +166 -0
- package/src/lib/enhancer.ts +167 -0
- package/src/lib/recorder.ts +198 -0
- package/src/lib/transcriber.ts +105 -0
- package/src/mcp/index.ts +405 -0
- package/src/native/RecordingsHelper.swift +352 -0
- package/src/types/index.ts +138 -0
- package/tsconfig.json +21 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { loadConfig, ensureDataDir } from "../lib/config.js";
|
|
5
|
+
import { getDatabase } from "../db/database.js";
|
|
6
|
+
import {
|
|
7
|
+
createRecording,
|
|
8
|
+
getRecording,
|
|
9
|
+
listRecordings,
|
|
10
|
+
deleteRecording,
|
|
11
|
+
searchRecordings,
|
|
12
|
+
getRecordingStats,
|
|
13
|
+
} from "../db/recordings.js";
|
|
14
|
+
import { registerAgent, getAgent, listAgents } from "../db/agents.js";
|
|
15
|
+
import {
|
|
16
|
+
registerProject,
|
|
17
|
+
listProjects,
|
|
18
|
+
} from "../db/projects.js";
|
|
19
|
+
import {
|
|
20
|
+
startRecording,
|
|
21
|
+
stopRecording,
|
|
22
|
+
isRecording,
|
|
23
|
+
checkRecordingDeps,
|
|
24
|
+
recordDuration,
|
|
25
|
+
} from "../lib/recorder.js";
|
|
26
|
+
import { transcribeAudio } from "../lib/transcriber.js";
|
|
27
|
+
import { processText, needsEnhancement } from "../lib/enhancer.js";
|
|
28
|
+
import type { Recording } from "../types/index.js";
|
|
29
|
+
|
|
30
|
+
const program = new Command();
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.name("recordings")
|
|
34
|
+
.description(
|
|
35
|
+
"Speech-to-text recording tool — record, transcribe, and enhance with AI"
|
|
36
|
+
)
|
|
37
|
+
.version("0.0.1")
|
|
38
|
+
.option("--json", "Output as JSON")
|
|
39
|
+
.option("--agent <name>", "Agent name or ID")
|
|
40
|
+
.option("--project <name>", "Project name or ID")
|
|
41
|
+
.option("--session <id>", "Session ID");
|
|
42
|
+
|
|
43
|
+
// ── record ──────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
program
|
|
46
|
+
.command("record")
|
|
47
|
+
.description("Record from microphone, transcribe, and optionally enhance")
|
|
48
|
+
.option("-d, --duration <seconds>", "Record for specific duration")
|
|
49
|
+
.option("--no-enhance", "Skip AI enhancement")
|
|
50
|
+
.option("-t, --tags <tags>", "Comma-separated tags")
|
|
51
|
+
.option("-l, --language <lang>", "Language code (e.g. en, es, fr)")
|
|
52
|
+
.action(async (opts) => {
|
|
53
|
+
const config = loadConfig();
|
|
54
|
+
ensureDataDir(config);
|
|
55
|
+
|
|
56
|
+
if (opts.language) config.language = opts.language;
|
|
57
|
+
if (opts.noEnhance === false) config.auto_enhance = false;
|
|
58
|
+
|
|
59
|
+
// Check dependencies
|
|
60
|
+
const deps = await checkRecordingDeps();
|
|
61
|
+
if (!deps.available) {
|
|
62
|
+
console.error(chalk.red(`Error: ${deps.message}`));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let audioPath: string;
|
|
67
|
+
|
|
68
|
+
if (opts.duration) {
|
|
69
|
+
// Fixed duration recording
|
|
70
|
+
const seconds = parseInt(opts.duration, 10);
|
|
71
|
+
console.log(
|
|
72
|
+
chalk.blue(`Recording for ${seconds} seconds...`)
|
|
73
|
+
);
|
|
74
|
+
audioPath = await recordDuration(seconds, config);
|
|
75
|
+
console.log(chalk.green("Recording complete."));
|
|
76
|
+
} else {
|
|
77
|
+
// Interactive recording — press Enter to stop
|
|
78
|
+
console.log(
|
|
79
|
+
chalk.blue("Recording... Press") +
|
|
80
|
+
chalk.yellow(" Enter ") +
|
|
81
|
+
chalk.blue("to stop.")
|
|
82
|
+
);
|
|
83
|
+
audioPath = startRecording(config);
|
|
84
|
+
|
|
85
|
+
// Wait for Enter key
|
|
86
|
+
await new Promise<void>((resolve) => {
|
|
87
|
+
process.stdin.setRawMode?.(true);
|
|
88
|
+
process.stdin.resume();
|
|
89
|
+
process.stdin.once("data", () => {
|
|
90
|
+
process.stdin.setRawMode?.(false);
|
|
91
|
+
process.stdin.pause();
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
stopRecording();
|
|
97
|
+
console.log(chalk.green("Recording stopped."));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Transcribe
|
|
101
|
+
console.log(chalk.blue("Transcribing..."));
|
|
102
|
+
const transcription = await transcribeAudio(audioPath, config);
|
|
103
|
+
console.log(chalk.dim(`Raw: ${transcription.text}`));
|
|
104
|
+
|
|
105
|
+
// Process (detect & enhance if needed)
|
|
106
|
+
const processed = await processText(transcription.text, config);
|
|
107
|
+
|
|
108
|
+
if (processed.mode === "enhanced") {
|
|
109
|
+
console.log(chalk.green("\nEnhanced output:"));
|
|
110
|
+
console.log(processed.text);
|
|
111
|
+
} else {
|
|
112
|
+
console.log(chalk.green("\nOutput:"));
|
|
113
|
+
console.log(transcription.text);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Save to database
|
|
117
|
+
const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
|
|
118
|
+
const parentOpts = program.opts();
|
|
119
|
+
|
|
120
|
+
const recording = createRecording({
|
|
121
|
+
audio_path: audioPath,
|
|
122
|
+
raw_text: transcription.text,
|
|
123
|
+
processed_text: processed.mode === "enhanced" ? processed.text : undefined,
|
|
124
|
+
processing_mode: processed.mode,
|
|
125
|
+
model_used: transcription.model,
|
|
126
|
+
enhancement_model: processed.enhancement_model || undefined,
|
|
127
|
+
duration_ms: transcription.duration_ms,
|
|
128
|
+
language: transcription.language || undefined,
|
|
129
|
+
tags,
|
|
130
|
+
agent_id: parentOpts.agent || undefined,
|
|
131
|
+
project_id: parentOpts.project || undefined,
|
|
132
|
+
session_id: parentOpts.session || undefined,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (parentOpts.json) {
|
|
136
|
+
console.log(JSON.stringify(recording, null, 2));
|
|
137
|
+
} else {
|
|
138
|
+
console.log(
|
|
139
|
+
chalk.dim(`\nSaved as ${recording.id.slice(0, 8)}`)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── transcribe ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
program
|
|
147
|
+
.command("transcribe <file>")
|
|
148
|
+
.description("Transcribe an existing audio file")
|
|
149
|
+
.option("--no-enhance", "Skip AI enhancement")
|
|
150
|
+
.option("-t, --tags <tags>", "Comma-separated tags")
|
|
151
|
+
.action(async (file, opts) => {
|
|
152
|
+
const config = loadConfig();
|
|
153
|
+
ensureDataDir(config);
|
|
154
|
+
if (opts.noEnhance === false) config.auto_enhance = false;
|
|
155
|
+
|
|
156
|
+
console.log(chalk.blue("Transcribing..."));
|
|
157
|
+
const transcription = await transcribeAudio(file, config);
|
|
158
|
+
|
|
159
|
+
const processed = await processText(transcription.text, config);
|
|
160
|
+
const parentOpts = program.opts();
|
|
161
|
+
const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
|
|
162
|
+
|
|
163
|
+
const recording = createRecording({
|
|
164
|
+
audio_path: file,
|
|
165
|
+
raw_text: transcription.text,
|
|
166
|
+
processed_text: processed.mode === "enhanced" ? processed.text : undefined,
|
|
167
|
+
processing_mode: processed.mode,
|
|
168
|
+
model_used: transcription.model,
|
|
169
|
+
enhancement_model: processed.enhancement_model || undefined,
|
|
170
|
+
duration_ms: transcription.duration_ms,
|
|
171
|
+
language: transcription.language || undefined,
|
|
172
|
+
tags,
|
|
173
|
+
agent_id: parentOpts.agent || undefined,
|
|
174
|
+
project_id: parentOpts.project || undefined,
|
|
175
|
+
session_id: parentOpts.session || undefined,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (processed.mode === "enhanced") {
|
|
179
|
+
console.log(chalk.green("Enhanced:"));
|
|
180
|
+
console.log(processed.text);
|
|
181
|
+
} else {
|
|
182
|
+
console.log(chalk.green("Transcription:"));
|
|
183
|
+
console.log(transcription.text);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (parentOpts.json) {
|
|
187
|
+
console.log(JSON.stringify(recording, null, 2));
|
|
188
|
+
} else {
|
|
189
|
+
console.log(chalk.dim(`Saved as ${recording.id.slice(0, 8)}`));
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── list ────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
program
|
|
196
|
+
.command("list")
|
|
197
|
+
.description("List recordings")
|
|
198
|
+
.option("-n, --limit <n>", "Max results", "20")
|
|
199
|
+
.option("--mode <mode>", "Filter by mode: raw or enhanced")
|
|
200
|
+
.option("-t, --tags <tags>", "Filter by tags")
|
|
201
|
+
.option("--since <date>", "After date (ISO)")
|
|
202
|
+
.option("--until <date>", "Before date (ISO)")
|
|
203
|
+
.action((opts) => {
|
|
204
|
+
const config = loadConfig();
|
|
205
|
+
getDatabase(config.db_path);
|
|
206
|
+
const parentOpts = program.opts();
|
|
207
|
+
|
|
208
|
+
const recordings = listRecordings({
|
|
209
|
+
limit: parseInt(opts.limit, 10),
|
|
210
|
+
processing_mode: opts.mode,
|
|
211
|
+
tags: opts.tags ? opts.tags.split(",") : undefined,
|
|
212
|
+
since: opts.since,
|
|
213
|
+
until: opts.until,
|
|
214
|
+
agent_id: parentOpts.agent,
|
|
215
|
+
project_id: parentOpts.project,
|
|
216
|
+
session_id: parentOpts.session,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (parentOpts.json) {
|
|
220
|
+
console.log(JSON.stringify(recordings, null, 2));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (recordings.length === 0) {
|
|
225
|
+
console.log(chalk.dim("No recordings found."));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(
|
|
230
|
+
chalk.bold(`${recordings.length} recording(s):\n`)
|
|
231
|
+
);
|
|
232
|
+
for (const r of recordings) {
|
|
233
|
+
console.log(formatRecordingLine(r));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── show ────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
program
|
|
240
|
+
.command("show <id>")
|
|
241
|
+
.description("Show recording details")
|
|
242
|
+
.action((id) => {
|
|
243
|
+
const config = loadConfig();
|
|
244
|
+
getDatabase(config.db_path);
|
|
245
|
+
const parentOpts = program.opts();
|
|
246
|
+
|
|
247
|
+
const recording = getRecording(id);
|
|
248
|
+
if (!recording) {
|
|
249
|
+
console.error(chalk.red(`Recording not found: ${id}`));
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (parentOpts.json) {
|
|
254
|
+
console.log(JSON.stringify(recording, null, 2));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(formatRecordingDetail(recording));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── search ──────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
program
|
|
264
|
+
.command("search <query>")
|
|
265
|
+
.description("Search recordings by text content")
|
|
266
|
+
.option("-n, --limit <n>", "Max results", "20")
|
|
267
|
+
.action((query, opts) => {
|
|
268
|
+
const config = loadConfig();
|
|
269
|
+
getDatabase(config.db_path);
|
|
270
|
+
const parentOpts = program.opts();
|
|
271
|
+
|
|
272
|
+
const results = searchRecordings(query, {
|
|
273
|
+
limit: parseInt(opts.limit, 10),
|
|
274
|
+
agent_id: parentOpts.agent,
|
|
275
|
+
project_id: parentOpts.project,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (parentOpts.json) {
|
|
279
|
+
console.log(JSON.stringify(results, null, 2));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (results.length === 0) {
|
|
284
|
+
console.log(chalk.dim("No results."));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log(chalk.bold(`${results.length} result(s):\n`));
|
|
289
|
+
for (const r of results) {
|
|
290
|
+
console.log(formatRecordingLine(r));
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── delete ──────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
program
|
|
297
|
+
.command("delete <id>")
|
|
298
|
+
.description("Delete a recording")
|
|
299
|
+
.action((id) => {
|
|
300
|
+
const config = loadConfig();
|
|
301
|
+
getDatabase(config.db_path);
|
|
302
|
+
|
|
303
|
+
const deleted = deleteRecording(id);
|
|
304
|
+
if (deleted) {
|
|
305
|
+
console.log(chalk.green(`Deleted recording ${id}`));
|
|
306
|
+
} else {
|
|
307
|
+
console.error(chalk.red(`Recording not found: ${id}`));
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── stats ───────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
program
|
|
315
|
+
.command("stats")
|
|
316
|
+
.description("Show recording statistics")
|
|
317
|
+
.action(() => {
|
|
318
|
+
const config = loadConfig();
|
|
319
|
+
getDatabase(config.db_path);
|
|
320
|
+
const parentOpts = program.opts();
|
|
321
|
+
|
|
322
|
+
const stats = getRecordingStats();
|
|
323
|
+
|
|
324
|
+
if (parentOpts.json) {
|
|
325
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
console.log(chalk.bold("Recording Statistics\n"));
|
|
330
|
+
console.log(` Total: ${stats.total}`);
|
|
331
|
+
console.log(` Raw: ${stats.raw}`);
|
|
332
|
+
console.log(` Enhanced: ${stats.enhanced}`);
|
|
333
|
+
console.log(
|
|
334
|
+
` Duration: ${(stats.total_duration_ms / 1000).toFixed(1)}s`
|
|
335
|
+
);
|
|
336
|
+
if (Object.keys(stats.by_model).length > 0) {
|
|
337
|
+
console.log(` By model:`);
|
|
338
|
+
for (const [model, count] of Object.entries(stats.by_model)) {
|
|
339
|
+
console.log(` ${model}: ${count}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ── agents ──────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
program
|
|
347
|
+
.command("agents")
|
|
348
|
+
.description("List registered agents")
|
|
349
|
+
.action(() => {
|
|
350
|
+
const config = loadConfig();
|
|
351
|
+
getDatabase(config.db_path);
|
|
352
|
+
const parentOpts = program.opts();
|
|
353
|
+
|
|
354
|
+
const agents = listAgents();
|
|
355
|
+
|
|
356
|
+
if (parentOpts.json) {
|
|
357
|
+
console.log(JSON.stringify(agents, null, 2));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (agents.length === 0) {
|
|
362
|
+
console.log(chalk.dim("No agents registered."));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const a of agents) {
|
|
367
|
+
console.log(
|
|
368
|
+
`${chalk.cyan(a.id)} ${chalk.bold(a.name)} (${a.role}) — last seen ${a.last_seen_at}`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ── projects ────────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
program
|
|
376
|
+
.command("projects")
|
|
377
|
+
.description("List registered projects")
|
|
378
|
+
.action(() => {
|
|
379
|
+
const config = loadConfig();
|
|
380
|
+
getDatabase(config.db_path);
|
|
381
|
+
const parentOpts = program.opts();
|
|
382
|
+
|
|
383
|
+
const projects = listProjects();
|
|
384
|
+
|
|
385
|
+
if (parentOpts.json) {
|
|
386
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (projects.length === 0) {
|
|
391
|
+
console.log(chalk.dim("No projects registered."));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const p of projects) {
|
|
396
|
+
console.log(
|
|
397
|
+
`${chalk.cyan(p.id.slice(0, 8))} ${chalk.bold(p.name)} — ${p.path}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ── init ────────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
program
|
|
405
|
+
.command("init")
|
|
406
|
+
.description("Initialize .recordings/ in current directory")
|
|
407
|
+
.action(() => {
|
|
408
|
+
const { mkdirSync, writeFileSync, existsSync } = require("fs") as typeof import("fs");
|
|
409
|
+
const { join } = require("path") as typeof import("path");
|
|
410
|
+
|
|
411
|
+
const dir = join(process.cwd(), ".recordings");
|
|
412
|
+
const audioDir = join(dir, "audio");
|
|
413
|
+
const configFile = join(dir, "config.json");
|
|
414
|
+
|
|
415
|
+
mkdirSync(audioDir, { recursive: true });
|
|
416
|
+
|
|
417
|
+
if (!existsSync(configFile)) {
|
|
418
|
+
const defaultConf = {
|
|
419
|
+
transcription_model: "gpt-4o-mini-transcribe",
|
|
420
|
+
enhancement_model: "gpt-4o",
|
|
421
|
+
language: "en",
|
|
422
|
+
auto_enhance: true,
|
|
423
|
+
};
|
|
424
|
+
writeFileSync(configFile, JSON.stringify(defaultConf, null, 2));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
console.log(chalk.green("Initialized .recordings/ directory"));
|
|
428
|
+
console.log(chalk.dim(" config: .recordings/config.json"));
|
|
429
|
+
console.log(chalk.dim(" audio: .recordings/audio/"));
|
|
430
|
+
console.log(chalk.dim(" db: .recordings/recordings.db"));
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ── check ───────────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
program
|
|
436
|
+
.command("check")
|
|
437
|
+
.description("Check system dependencies (sox, API keys)")
|
|
438
|
+
.action(async () => {
|
|
439
|
+
const config = loadConfig();
|
|
440
|
+
|
|
441
|
+
// Check recording deps
|
|
442
|
+
const deps = await checkRecordingDeps();
|
|
443
|
+
if (deps.available) {
|
|
444
|
+
console.log(chalk.green(`✓ Recording tool: ${deps.tool}`));
|
|
445
|
+
} else {
|
|
446
|
+
console.log(chalk.red(`✗ ${deps.message}`));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Check API key
|
|
450
|
+
if (config.openai_api_key) {
|
|
451
|
+
console.log(
|
|
452
|
+
chalk.green(`✓ OpenAI API key configured`)
|
|
453
|
+
);
|
|
454
|
+
} else {
|
|
455
|
+
console.log(
|
|
456
|
+
chalk.red(
|
|
457
|
+
`✗ OpenAI API key not found. Set OPENAI_API_KEY env var or add to ~/.secrets`
|
|
458
|
+
)
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Check enhancement key
|
|
463
|
+
const enhKey = config.enhancement_api_key || config.openai_api_key;
|
|
464
|
+
if (enhKey) {
|
|
465
|
+
console.log(
|
|
466
|
+
chalk.green(`✓ Enhancement API key configured (model: ${config.enhancement_model})`)
|
|
467
|
+
);
|
|
468
|
+
} else {
|
|
469
|
+
console.log(
|
|
470
|
+
chalk.yellow(`⚠ Enhancement API key not configured — enhancement disabled`)
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ── start ───────────────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
program
|
|
478
|
+
.command("start")
|
|
479
|
+
.description("Launch the menu bar helper app (F5 to toggle recording)")
|
|
480
|
+
.option("--login", "Also add to Login Items so it starts automatically")
|
|
481
|
+
.action(async (opts) => {
|
|
482
|
+
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
483
|
+
const { join: pathJoin } = require("node:path") as typeof import("node:path");
|
|
484
|
+
const { homedir: getHome } = require("node:os") as typeof import("node:os");
|
|
485
|
+
const { existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
|
|
486
|
+
const home = getHome();
|
|
487
|
+
|
|
488
|
+
const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
489
|
+
|
|
490
|
+
if (!fileExists(appPath)) {
|
|
491
|
+
console.error(chalk.red("RecordingsHelper.app not found. Run: recordings shortcut --install"));
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Kill existing instance
|
|
496
|
+
try { execSync("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
|
|
497
|
+
|
|
498
|
+
// Launch
|
|
499
|
+
execSync(`open "${appPath}"`, { stdio: "pipe" });
|
|
500
|
+
console.log(chalk.green("Recordings helper launched — press F5 to record"));
|
|
501
|
+
|
|
502
|
+
if (opts.login) {
|
|
503
|
+
try {
|
|
504
|
+
execSync(
|
|
505
|
+
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`,
|
|
506
|
+
{ stdio: "pipe" }
|
|
507
|
+
);
|
|
508
|
+
console.log(chalk.green("Added to Login Items — will start on boot"));
|
|
509
|
+
} catch {
|
|
510
|
+
console.log(chalk.yellow("Could not add to Login Items — add manually in System Settings > General > Login Items"));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ── stop ────────────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
program
|
|
518
|
+
.command("stop")
|
|
519
|
+
.description("Stop the menu bar helper app")
|
|
520
|
+
.action(() => {
|
|
521
|
+
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
522
|
+
try {
|
|
523
|
+
execSync("pkill -f RecordingsHelper", { stdio: "pipe" });
|
|
524
|
+
console.log(chalk.green("Recordings helper stopped"));
|
|
525
|
+
} catch {
|
|
526
|
+
console.log(chalk.dim("Not running"));
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ── listen ───────────────────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
program
|
|
533
|
+
.command("listen")
|
|
534
|
+
.description("Push-to-talk mode — press Space to start/stop recording, Esc to quit")
|
|
535
|
+
.option("-t, --tags <tags>", "Comma-separated tags for all recordings")
|
|
536
|
+
.option("--no-enhance", "Skip AI enhancement")
|
|
537
|
+
.option("-l, --language <lang>", "Language code")
|
|
538
|
+
.option("--copy", "Copy output to clipboard")
|
|
539
|
+
.option("--paste", "Copy output to clipboard AND paste into frontmost app")
|
|
540
|
+
.action(async (opts) => {
|
|
541
|
+
const config = loadConfig();
|
|
542
|
+
ensureDataDir(config);
|
|
543
|
+
if (opts.language) config.language = opts.language;
|
|
544
|
+
if (opts.noEnhance === false) config.auto_enhance = false;
|
|
545
|
+
|
|
546
|
+
const deps = await checkRecordingDeps();
|
|
547
|
+
if (!deps.available) {
|
|
548
|
+
console.error(chalk.red(`Error: ${deps.message}`));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!config.openai_api_key) {
|
|
553
|
+
console.error(chalk.red("Error: OpenAI API key not configured."));
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
|
|
558
|
+
const parentOpts = program.opts();
|
|
559
|
+
|
|
560
|
+
console.log(chalk.bold("\n Recordings — Push-to-Talk\n"));
|
|
561
|
+
console.log(` ${chalk.yellow("Space")} Start/stop recording`);
|
|
562
|
+
console.log(` ${chalk.yellow("Esc")} Quit\n`);
|
|
563
|
+
|
|
564
|
+
let recording = false;
|
|
565
|
+
let audioPath: string | null = null;
|
|
566
|
+
|
|
567
|
+
process.stdin.setRawMode?.(true);
|
|
568
|
+
process.stdin.resume();
|
|
569
|
+
process.stdin.setEncoding("utf8");
|
|
570
|
+
|
|
571
|
+
const cleanup = () => {
|
|
572
|
+
process.stdin.setRawMode?.(false);
|
|
573
|
+
process.stdin.pause();
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
process.stdin.on("data", async (key: string) => {
|
|
577
|
+
// Esc
|
|
578
|
+
if (key === "\u001b") {
|
|
579
|
+
if (recording) {
|
|
580
|
+
stopRecording();
|
|
581
|
+
}
|
|
582
|
+
cleanup();
|
|
583
|
+
console.log(chalk.dim("\nBye."));
|
|
584
|
+
process.exit(0);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Ctrl+C
|
|
588
|
+
if (key === "\u0003") {
|
|
589
|
+
if (recording) {
|
|
590
|
+
stopRecording();
|
|
591
|
+
}
|
|
592
|
+
cleanup();
|
|
593
|
+
process.exit(0);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Space
|
|
597
|
+
if (key === " ") {
|
|
598
|
+
if (!recording) {
|
|
599
|
+
// Start recording
|
|
600
|
+
try {
|
|
601
|
+
audioPath = startRecording(config);
|
|
602
|
+
recording = true;
|
|
603
|
+
process.stdout.write(chalk.red(" ● Recording... ") + chalk.dim("(Space to stop)"));
|
|
604
|
+
} catch (e) {
|
|
605
|
+
console.error(chalk.red(`\n Error: ${e instanceof Error ? e.message : e}`));
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
// Stop recording
|
|
609
|
+
stopRecording();
|
|
610
|
+
recording = false;
|
|
611
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
612
|
+
|
|
613
|
+
if (!audioPath) return;
|
|
614
|
+
|
|
615
|
+
process.stdout.write(chalk.blue(" Transcribing..."));
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const transcription = await transcribeAudio(audioPath, config);
|
|
619
|
+
const processed = await processText(transcription.text, config);
|
|
620
|
+
|
|
621
|
+
const output = processed.mode === "enhanced" ? processed.text : transcription.text;
|
|
622
|
+
|
|
623
|
+
// Save to DB
|
|
624
|
+
createRecording({
|
|
625
|
+
audio_path: audioPath,
|
|
626
|
+
raw_text: transcription.text,
|
|
627
|
+
processed_text: processed.mode === "enhanced" ? processed.text : undefined,
|
|
628
|
+
processing_mode: processed.mode,
|
|
629
|
+
model_used: transcription.model,
|
|
630
|
+
enhancement_model: processed.enhancement_model || undefined,
|
|
631
|
+
duration_ms: transcription.duration_ms,
|
|
632
|
+
language: transcription.language || undefined,
|
|
633
|
+
tags,
|
|
634
|
+
agent_id: parentOpts.agent || undefined,
|
|
635
|
+
project_id: parentOpts.project || undefined,
|
|
636
|
+
session_id: parentOpts.session || undefined,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Clear line and show output
|
|
640
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
641
|
+
const modeLabel = processed.mode === "enhanced"
|
|
642
|
+
? chalk.green(" [enhanced] ")
|
|
643
|
+
: chalk.dim(" [raw] ");
|
|
644
|
+
console.log(modeLabel + output);
|
|
645
|
+
|
|
646
|
+
// Copy to clipboard / paste
|
|
647
|
+
if (opts.copy || opts.paste) {
|
|
648
|
+
try {
|
|
649
|
+
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
650
|
+
execSync("pbcopy", { input: output, stdio: ["pipe", "pipe", "pipe"] });
|
|
651
|
+
if (opts.paste) {
|
|
652
|
+
// Small delay then Cmd+V via osascript
|
|
653
|
+
execSync(
|
|
654
|
+
`osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'`,
|
|
655
|
+
{ stdio: "pipe" }
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
// Clipboard not available
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
console.log("");
|
|
664
|
+
} catch (e) {
|
|
665
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
666
|
+
console.error(chalk.red(` Error: ${e instanceof Error ? e.message : e}\n`));
|
|
667
|
+
}
|
|
668
|
+
audioPath = null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// ── shortcut ────────────────────────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
program
|
|
677
|
+
.command("shortcut")
|
|
678
|
+
.description("Set up a global keyboard shortcut for recording (macOS)")
|
|
679
|
+
.option("--raycast", "Generate Raycast script command")
|
|
680
|
+
.option("--install", "Set up F5 global shortcut via macOS Services (no extra installs)")
|
|
681
|
+
.option("--karabiner", "Set up Fn key via Karabiner-Elements")
|
|
682
|
+
.option("--skhd", "Generate skhd hotkey config")
|
|
683
|
+
.option("--hammerspoon", "Generate Hammerspoon config")
|
|
684
|
+
.option("--script", "Just output the shell script path")
|
|
685
|
+
.action((opts) => {
|
|
686
|
+
const { writeFileSync, mkdirSync, chmodSync, existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
|
|
687
|
+
const { join: pathJoin } = require("node:path") as typeof import("node:path");
|
|
688
|
+
const { homedir: getHome } = require("node:os") as typeof import("node:os");
|
|
689
|
+
const home = getHome();
|
|
690
|
+
|
|
691
|
+
const scriptDir = pathJoin(home, ".recordings");
|
|
692
|
+
mkdirSync(scriptDir, { recursive: true });
|
|
693
|
+
|
|
694
|
+
const scriptPath = pathJoin(scriptDir, "record-toggle.sh");
|
|
695
|
+
const pidFile = pathJoin(scriptDir, ".recording.pid");
|
|
696
|
+
const recordingsBin = pathJoin(home, ".bun", "bin", "recordings");
|
|
697
|
+
|
|
698
|
+
// Write the toggle script
|
|
699
|
+
const script = `#!/bin/bash
|
|
700
|
+
# Toggle recording on/off. Run this from a global hotkey.
|
|
701
|
+
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
702
|
+
set -e
|
|
703
|
+
|
|
704
|
+
PID_FILE="${pidFile}"
|
|
705
|
+
RECORDINGS="${recordingsBin}"
|
|
706
|
+
|
|
707
|
+
if [ -f "$PID_FILE" ]; then
|
|
708
|
+
# Stop recording
|
|
709
|
+
PID=$(cat "$PID_FILE")
|
|
710
|
+
kill -INT "$PID" 2>/dev/null || true
|
|
711
|
+
rm -f "$PID_FILE"
|
|
712
|
+
|
|
713
|
+
# Find the most recent audio file
|
|
714
|
+
AUDIO_DIR="${pathJoin(scriptDir, "audio")}"
|
|
715
|
+
LATEST=$(ls -t "$AUDIO_DIR"/*.wav 2>/dev/null | head -1)
|
|
716
|
+
|
|
717
|
+
if [ -n "$LATEST" ]; then
|
|
718
|
+
# Transcribe and copy to clipboard
|
|
719
|
+
OUTPUT=$("$RECORDINGS" transcribe "$LATEST" --json 2>/dev/null)
|
|
720
|
+
TEXT=$(echo "$OUTPUT" | grep -o '"processed_text":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
721
|
+
if [ -z "$TEXT" ]; then
|
|
722
|
+
TEXT=$(echo "$OUTPUT" | grep -o '"raw_text":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
723
|
+
fi
|
|
724
|
+
if [ -n "$TEXT" ]; then
|
|
725
|
+
echo -n "$TEXT" | pbcopy
|
|
726
|
+
# Optional: paste into frontmost app
|
|
727
|
+
# osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'
|
|
728
|
+
fi
|
|
729
|
+
fi
|
|
730
|
+
|
|
731
|
+
# Notification
|
|
732
|
+
osascript -e 'display notification "Recording saved and copied to clipboard" with title "Recordings"' 2>/dev/null || true
|
|
733
|
+
else
|
|
734
|
+
# Start recording in background
|
|
735
|
+
mkdir -p "${pathJoin(scriptDir, "audio")}"
|
|
736
|
+
rec -r 16000 -c 1 -b 16 "${pathJoin(scriptDir, "audio")}/recording-$(date +%Y%m%dT%H%M%S).wav" trim 0 300 &
|
|
737
|
+
echo $! > "$PID_FILE"
|
|
738
|
+
|
|
739
|
+
# Notification
|
|
740
|
+
osascript -e 'display notification "Recording started..." with title "Recordings"' 2>/dev/null || true
|
|
741
|
+
fi
|
|
742
|
+
`;
|
|
743
|
+
writeFileSync(scriptPath, script, "utf-8");
|
|
744
|
+
chmodSync(scriptPath, 0o755);
|
|
745
|
+
|
|
746
|
+
if (opts.install) {
|
|
747
|
+
// Install the native menu bar app — no config needed, just works with F5
|
|
748
|
+
const { execSync: exec } = require("node:child_process") as typeof import("node:child_process");
|
|
749
|
+
|
|
750
|
+
const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
751
|
+
const srcSwift = pathJoin(__dirname, "..", "native", "RecordingsHelper.swift");
|
|
752
|
+
const distApp = pathJoin(__dirname, "..", "RecordingsHelper.app");
|
|
753
|
+
|
|
754
|
+
// Copy pre-built app if available, otherwise compile
|
|
755
|
+
if (fileExists(pathJoin(distApp, "Contents", "MacOS", "RecordingsHelper"))) {
|
|
756
|
+
exec(`rm -rf "${appPath}" && cp -R "${distApp}" "${appPath}"`, { stdio: "pipe", shell: "/bin/bash" });
|
|
757
|
+
} else if (fileExists(srcSwift)) {
|
|
758
|
+
// Compile from source
|
|
759
|
+
console.log(chalk.blue("Compiling native helper app..."));
|
|
760
|
+
const appDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents", "MacOS");
|
|
761
|
+
mkdirSync(appDir, { recursive: true });
|
|
762
|
+
|
|
763
|
+
const plistDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents");
|
|
764
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
765
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
766
|
+
<plist version="1.0"><dict>
|
|
767
|
+
<key>CFBundleExecutable</key><string>RecordingsHelper</string>
|
|
768
|
+
<key>CFBundleIdentifier</key><string>com.hasna.recordings-helper</string>
|
|
769
|
+
<key>CFBundleName</key><string>Recordings</string>
|
|
770
|
+
<key>LSUIElement</key><true/>
|
|
771
|
+
<key>NSMicrophoneUsageDescription</key><string>Recordings needs microphone access for speech transcription.</string>
|
|
772
|
+
</dict></plist>`;
|
|
773
|
+
writeFileSync(pathJoin(plistDir, "Info.plist"), plist, "utf-8");
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
exec(
|
|
777
|
+
`DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`,
|
|
778
|
+
{ stdio: "pipe" }
|
|
779
|
+
);
|
|
780
|
+
} catch {
|
|
781
|
+
// Fallback to default toolchain
|
|
782
|
+
exec(
|
|
783
|
+
`swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`,
|
|
784
|
+
{ stdio: "pipe" }
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
} else {
|
|
788
|
+
console.error(chalk.red("Cannot find RecordingsHelper. Run from the project directory or rebuild."));
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Kill existing instance and launch
|
|
793
|
+
try { exec("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
|
|
794
|
+
exec(`open "${appPath}"`, { stdio: "pipe" });
|
|
795
|
+
|
|
796
|
+
// Add to Login Items
|
|
797
|
+
try {
|
|
798
|
+
exec(
|
|
799
|
+
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`,
|
|
800
|
+
{ stdio: "pipe" }
|
|
801
|
+
);
|
|
802
|
+
} catch { /* already exists or no permission */ }
|
|
803
|
+
|
|
804
|
+
console.log(chalk.green("\nRecordings helper installed and running!\n"));
|
|
805
|
+
console.log(` ${chalk.yellow("F5")} Start/stop recording`);
|
|
806
|
+
console.log(` ${chalk.dim("🎙")} Menu bar icon (click for options)`);
|
|
807
|
+
console.log(` ${chalk.dim("Auto")} Starts on login\n`);
|
|
808
|
+
console.log(chalk.dim(" Press F5 → speak → F5 → text is pasted where your cursor is."));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (opts.karabiner) {
|
|
813
|
+
const karabinerDir = pathJoin(home, ".config", "karabiner", "assets", "complex_modifications");
|
|
814
|
+
mkdirSync(karabinerDir, { recursive: true });
|
|
815
|
+
|
|
816
|
+
const rule = {
|
|
817
|
+
title: "Recordings — Fn key to toggle recording",
|
|
818
|
+
rules: [
|
|
819
|
+
{
|
|
820
|
+
description: "Fn key toggles speech recording (open-recordings)",
|
|
821
|
+
manipulators: [
|
|
822
|
+
{
|
|
823
|
+
type: "basic",
|
|
824
|
+
from: {
|
|
825
|
+
key_code: "fn",
|
|
826
|
+
modifiers: { optional: ["any"] },
|
|
827
|
+
},
|
|
828
|
+
to: [
|
|
829
|
+
{
|
|
830
|
+
shell_command: scriptPath,
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const karabinerPath = pathJoin(karabinerDir, "recordings-fn.json");
|
|
840
|
+
writeFileSync(karabinerPath, JSON.stringify(rule, null, 2) + "\n", "utf-8");
|
|
841
|
+
|
|
842
|
+
console.log(chalk.green("Karabiner-Elements rule created!"));
|
|
843
|
+
console.log(chalk.dim(` ${karabinerPath}\n`));
|
|
844
|
+
console.log("To activate:");
|
|
845
|
+
console.log(" 1. Open Karabiner-Elements");
|
|
846
|
+
console.log(" 2. Go to Complex Modifications tab");
|
|
847
|
+
console.log(" 3. Click Add Predefined Rule");
|
|
848
|
+
console.log(' 4. Enable "Fn key toggles speech recording"');
|
|
849
|
+
console.log(chalk.dim("\n Press Fn to start recording, Fn again to stop + copy to clipboard"));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (opts.raycast) {
|
|
854
|
+
const raycastDir = pathJoin(home, ".config", "raycast", "script-commands");
|
|
855
|
+
mkdirSync(raycastDir, { recursive: true });
|
|
856
|
+
const raycastScript = `#!/bin/bash
|
|
857
|
+
|
|
858
|
+
# Required parameters:
|
|
859
|
+
# @raycast.schemaVersion 1
|
|
860
|
+
# @raycast.title Toggle Recording
|
|
861
|
+
# @raycast.mode silent
|
|
862
|
+
# @raycast.packageName Recordings
|
|
863
|
+
|
|
864
|
+
# Optional parameters:
|
|
865
|
+
# @raycast.icon 🎙️
|
|
866
|
+
|
|
867
|
+
${scriptPath}
|
|
868
|
+
`;
|
|
869
|
+
const raycastPath = pathJoin(raycastDir, "toggle-recording.sh");
|
|
870
|
+
writeFileSync(raycastPath, raycastScript, "utf-8");
|
|
871
|
+
chmodSync(raycastPath, 0o755);
|
|
872
|
+
console.log(chalk.green("Raycast script command created!"));
|
|
873
|
+
console.log(chalk.dim(` ${raycastPath}`));
|
|
874
|
+
console.log(chalk.dim(" Open Raycast > Script Commands > reload to see it"));
|
|
875
|
+
console.log(chalk.dim(" Then assign a hotkey in Raycast preferences"));
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (opts.skhd) {
|
|
880
|
+
console.log(chalk.bold("Add to ~/.skhdrc:\n"));
|
|
881
|
+
console.log(chalk.cyan(` fn - space : ${scriptPath}`));
|
|
882
|
+
console.log(chalk.dim("\n Then reload: skhd --restart-service"));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (opts.hammerspoon) {
|
|
887
|
+
console.log(chalk.bold("Add to ~/.hammerspoon/init.lua:\n"));
|
|
888
|
+
console.log(chalk.cyan(` hs.hotkey.bind({"ctrl"}, "space", function()
|
|
889
|
+
hs.execute("${scriptPath}")
|
|
890
|
+
end)`));
|
|
891
|
+
console.log(chalk.dim("\n Then reload Hammerspoon config"));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Default: show all options
|
|
896
|
+
console.log(chalk.bold("Global shortcut script created:"));
|
|
897
|
+
console.log(chalk.cyan(` ${scriptPath}\n`));
|
|
898
|
+
console.log("Bind it to a hotkey using any of these:\n");
|
|
899
|
+
|
|
900
|
+
console.log(chalk.bold(" macOS built-in") + chalk.dim(" (no extra installs — recommended)"));
|
|
901
|
+
console.log(` recordings shortcut --install\n`);
|
|
902
|
+
|
|
903
|
+
console.log(chalk.bold(" Karabiner-Elements") + chalk.dim(" (for Fn key specifically)"));
|
|
904
|
+
console.log(` brew install --cask karabiner-elements`);
|
|
905
|
+
console.log(` recordings shortcut --karabiner\n`);
|
|
906
|
+
|
|
907
|
+
console.log(chalk.bold(" Raycast"));
|
|
908
|
+
console.log(` recordings shortcut --raycast\n`);
|
|
909
|
+
|
|
910
|
+
console.log(chalk.bold(" skhd"));
|
|
911
|
+
console.log(` recordings shortcut --skhd\n`);
|
|
912
|
+
|
|
913
|
+
console.log(chalk.bold(" Hammerspoon"));
|
|
914
|
+
console.log(` recordings shortcut --hammerspoon\n`);
|
|
915
|
+
|
|
916
|
+
console.log(chalk.bold(" macOS Automator"));
|
|
917
|
+
console.log(` 1. Open Automator > Quick Action`);
|
|
918
|
+
console.log(` 2. Add "Run Shell Script" action`);
|
|
919
|
+
console.log(` 3. Paste: ${scriptPath}`);
|
|
920
|
+
console.log(` 4. Save as "Toggle Recording"`);
|
|
921
|
+
console.log(` 5. System Settings > Keyboard > Shortcuts > Services`);
|
|
922
|
+
console.log(` 6. Assign a shortcut to "Toggle Recording"\n`);
|
|
923
|
+
|
|
924
|
+
console.log(chalk.bold(" Alfred"));
|
|
925
|
+
console.log(` Create a workflow with a Hotkey trigger → Run Script: ${scriptPath}\n`);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
function formatRecordingLine(r: Recording): string {
|
|
931
|
+
const id = chalk.cyan(r.id.slice(0, 8));
|
|
932
|
+
const mode =
|
|
933
|
+
r.processing_mode === "enhanced"
|
|
934
|
+
? chalk.green("enhanced")
|
|
935
|
+
: chalk.dim("raw");
|
|
936
|
+
const text = (r.processed_text || r.raw_text).slice(0, 80);
|
|
937
|
+
const date = chalk.dim(r.created_at.slice(0, 16));
|
|
938
|
+
const tags =
|
|
939
|
+
r.tags.length > 0
|
|
940
|
+
? chalk.yellow(` [${r.tags.join(", ")}]`)
|
|
941
|
+
: "";
|
|
942
|
+
|
|
943
|
+
return `${id} ${mode} ${date}${tags}\n ${text}${text.length >= 80 ? "..." : ""}`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function formatRecordingDetail(r: Recording): string {
|
|
947
|
+
const lines: string[] = [
|
|
948
|
+
chalk.bold(`Recording ${r.id.slice(0, 8)}`),
|
|
949
|
+
"",
|
|
950
|
+
` Mode: ${r.processing_mode === "enhanced" ? chalk.green("enhanced") : chalk.dim("raw")}`,
|
|
951
|
+
` Model: ${r.model_used}`,
|
|
952
|
+
];
|
|
953
|
+
|
|
954
|
+
if (r.enhancement_model) {
|
|
955
|
+
lines.push(` Enhanced: ${r.enhancement_model}`);
|
|
956
|
+
}
|
|
957
|
+
if (r.duration_ms) {
|
|
958
|
+
lines.push(` Duration: ${(r.duration_ms / 1000).toFixed(1)}s`);
|
|
959
|
+
}
|
|
960
|
+
if (r.language) {
|
|
961
|
+
lines.push(` Language: ${r.language}`);
|
|
962
|
+
}
|
|
963
|
+
if (r.audio_path) {
|
|
964
|
+
lines.push(` Audio: ${r.audio_path}`);
|
|
965
|
+
}
|
|
966
|
+
if (r.tags.length > 0) {
|
|
967
|
+
lines.push(` Tags: ${r.tags.join(", ")}`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
lines.push(` Created: ${r.created_at}`);
|
|
971
|
+
lines.push("");
|
|
972
|
+
lines.push(chalk.bold("Raw text:"));
|
|
973
|
+
lines.push(r.raw_text);
|
|
974
|
+
|
|
975
|
+
if (r.processed_text && r.processed_text !== r.raw_text) {
|
|
976
|
+
lines.push("");
|
|
977
|
+
lines.push(chalk.bold("Enhanced text:"));
|
|
978
|
+
lines.push(r.processed_text);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return lines.join("\n");
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ── mcp ─────────────────────────────────────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
program
|
|
987
|
+
.command("mcp")
|
|
988
|
+
.description("Install recordings MCP server into Claude Code, Codex, or Gemini")
|
|
989
|
+
.option("--claude", "Install into Claude Code (via `claude mcp add`)")
|
|
990
|
+
.option("--codex", "Install into Codex (~/.codex/config.toml)")
|
|
991
|
+
.option("--gemini", "Install into Gemini (~/.gemini/settings.json)")
|
|
992
|
+
.option("--all", "Install into all supported agents")
|
|
993
|
+
.option("--uninstall", "Remove recordings MCP from config")
|
|
994
|
+
.action(async (opts: { claude?: boolean; codex?: boolean; gemini?: boolean; all?: boolean; uninstall?: boolean }) => {
|
|
995
|
+
const { readFileSync, writeFileSync, existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
|
|
996
|
+
const { join: pathJoin } = require("node:path") as typeof import("node:path");
|
|
997
|
+
const { homedir: getHome } = require("node:os") as typeof import("node:os");
|
|
998
|
+
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
999
|
+
const home = getHome();
|
|
1000
|
+
|
|
1001
|
+
const mcpCmd = process.argv[0]?.includes("bun")
|
|
1002
|
+
? pathJoin(home, ".bun", "bin", "recordings-mcp")
|
|
1003
|
+
: "recordings-mcp";
|
|
1004
|
+
|
|
1005
|
+
const targets = opts.all
|
|
1006
|
+
? ["claude", "codex", "gemini"]
|
|
1007
|
+
: [
|
|
1008
|
+
opts.claude ? "claude" : null,
|
|
1009
|
+
opts.codex ? "codex" : null,
|
|
1010
|
+
opts.gemini ? "gemini" : null,
|
|
1011
|
+
].filter(Boolean) as string[];
|
|
1012
|
+
|
|
1013
|
+
if (targets.length === 0) {
|
|
1014
|
+
console.log(chalk.yellow("Specify a target: --claude, --codex, --gemini, or --all"));
|
|
1015
|
+
console.log(chalk.gray("Example: recordings mcp --all"));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const action = opts.uninstall ? "Removed from" : "Installed into";
|
|
1020
|
+
|
|
1021
|
+
for (const target of targets) {
|
|
1022
|
+
try {
|
|
1023
|
+
// Claude Code: use `claude mcp add/remove` — stores in ~/.claude.json (user scope)
|
|
1024
|
+
if (target === "claude") {
|
|
1025
|
+
if (opts.uninstall) {
|
|
1026
|
+
execSync("claude mcp remove recordings", { stdio: "pipe" });
|
|
1027
|
+
} else {
|
|
1028
|
+
// Remove first if it exists, then add fresh
|
|
1029
|
+
try { execSync("claude mcp remove recordings", { stdio: "pipe" }); } catch { /* ignore if not found */ }
|
|
1030
|
+
execSync(
|
|
1031
|
+
`claude mcp add --transport stdio --scope user recordings -- ${mcpCmd}`,
|
|
1032
|
+
{ stdio: "pipe" }
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
console.log(chalk.green(`${action} Claude Code (user scope in ~/.claude.json)`));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (target === "codex") {
|
|
1039
|
+
const configPath = pathJoin(home, ".codex", "config.toml");
|
|
1040
|
+
if (fileExists(configPath)) {
|
|
1041
|
+
let content = readFileSync(configPath, "utf-8");
|
|
1042
|
+
if (opts.uninstall) {
|
|
1043
|
+
content = content.replace(/\n\[mcp_servers\.recordings\]\ncommand = "[^"]*"\nargs = \[\]\n?/g, "\n");
|
|
1044
|
+
} else if (!content.includes("[mcp_servers.recordings]")) {
|
|
1045
|
+
content += `\n[mcp_servers.recordings]\ncommand = "${mcpCmd}"\nargs = []\n`;
|
|
1046
|
+
}
|
|
1047
|
+
writeFileSync(configPath, content, "utf-8");
|
|
1048
|
+
console.log(chalk.green(`${action} Codex: ${configPath}`));
|
|
1049
|
+
} else {
|
|
1050
|
+
console.log(chalk.yellow(`Codex config not found: ${configPath}`));
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (target === "gemini") {
|
|
1055
|
+
const configPath = pathJoin(home, ".gemini", "settings.json");
|
|
1056
|
+
let config: Record<string, unknown> = {};
|
|
1057
|
+
if (fileExists(configPath)) {
|
|
1058
|
+
config = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
|
1059
|
+
}
|
|
1060
|
+
const servers = (config["mcpServers"] || {}) as Record<string, unknown>;
|
|
1061
|
+
if (opts.uninstall) {
|
|
1062
|
+
delete servers["recordings"];
|
|
1063
|
+
} else {
|
|
1064
|
+
servers["recordings"] = { command: mcpCmd, args: [] };
|
|
1065
|
+
}
|
|
1066
|
+
config["mcpServers"] = servers;
|
|
1067
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1068
|
+
console.log(chalk.green(`${action} Gemini: ${configPath}`));
|
|
1069
|
+
}
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
console.error(chalk.red(`Failed for ${target}: ${e instanceof Error ? e.message : String(e)}`));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// ── Run ─────────────────────────────────────────────────────────────────────
|
|
1077
|
+
|
|
1078
|
+
program.parse();
|