@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.
@@ -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();