recmp3-cli 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/dist/index.js ADDED
@@ -0,0 +1,1960 @@
1
+ #!/usr/bin/env node
2
+ import { setSecret, keychainAvailable } from './chunk-FNZ6ZCOK.js';
3
+ import { loadConfig, providerUploads, createProvider, configFilePath, getApiKey, RecmpConfigSchema, paths, saveConfig, resetConfigCache } from './chunk-XGHYROLT.js';
4
+ import './chunk-NY5EJT5D.js';
5
+ import { initLogger, redactKey, log } from './chunk-DDXRBIWU.js';
6
+ import { checkFfmpegVersion, supportsInputFormat, findFfmpeg } from './chunk-7NR5CU7W.js';
7
+ import { ExitCode, RecmpError, InputError, ConfigError, AudioCaptureError } from './chunk-NUWDWBJQ.js';
8
+ import { Command } from 'commander';
9
+ import pc2 from 'picocolors';
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import { mkdtemp, writeFile, rm, mkdir, stat, readdir } from 'fs/promises';
15
+ import { join, basename, dirname } from 'path';
16
+ import { createInterface } from 'readline';
17
+ import { randomBytes } from 'crypto';
18
+ import { tmpdir } from 'os';
19
+ import { execFile } from 'child_process';
20
+ import { promisify } from 'util';
21
+ import { render, useApp, useInput, Box, Text } from 'ink';
22
+ import { useState, useRef, useEffect, useCallback } from 'react';
23
+ import { jsx, jsxs } from 'react/jsx-runtime';
24
+
25
+ var SCHEMA_VERSION = 1;
26
+ var HumanSink = class {
27
+ ok(_command, _payload, humanRender) {
28
+ if (humanRender) humanRender();
29
+ }
30
+ fail(error, _command) {
31
+ process.stderr.write(`
32
+ ${pc2.red("\u2717")} ${error.message}
33
+
34
+ `);
35
+ }
36
+ };
37
+ var JsonSink = class {
38
+ ok(command, payload) {
39
+ const envelope = {
40
+ ok: true,
41
+ command,
42
+ schemaVersion: SCHEMA_VERSION,
43
+ data: payload
44
+ };
45
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}
46
+ `);
47
+ }
48
+ fail(error, command) {
49
+ const envelope = {
50
+ ok: false,
51
+ command,
52
+ schemaVersion: SCHEMA_VERSION,
53
+ error
54
+ };
55
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}
56
+ `);
57
+ }
58
+ };
59
+ var CaptureSink = class {
60
+ envelope = null;
61
+ ok(command, payload) {
62
+ this.envelope = {
63
+ ok: true,
64
+ command,
65
+ schemaVersion: SCHEMA_VERSION,
66
+ data: payload
67
+ };
68
+ }
69
+ fail(error, command) {
70
+ this.envelope = {
71
+ ok: false,
72
+ command,
73
+ schemaVersion: SCHEMA_VERSION,
74
+ error
75
+ };
76
+ }
77
+ };
78
+ function toErrorPayload(err) {
79
+ if (err instanceof RecmpError) {
80
+ return { code: err.code, message: err.message, exitCode: err.exitCode };
81
+ }
82
+ if (err instanceof Error) {
83
+ return {
84
+ code: "UNEXPECTED_ERROR",
85
+ message: err.message,
86
+ exitCode: ExitCode.UNKNOWN
87
+ };
88
+ }
89
+ return {
90
+ code: "UNKNOWN_ERROR",
91
+ message: String(err),
92
+ exitCode: ExitCode.UNKNOWN
93
+ };
94
+ }
95
+
96
+ // src/agent/context.ts
97
+ var AgentContext = class _AgentContext {
98
+ json;
99
+ yes;
100
+ quiet;
101
+ color;
102
+ sink;
103
+ constructor(opts = {}) {
104
+ this.json = opts.json ?? false;
105
+ this.yes = opts.yes ?? false;
106
+ this.quiet = opts.quiet ?? false;
107
+ this.color = opts.color ?? true;
108
+ this.sink = opts.sink ?? (this.json ? new JsonSink() : new HumanSink());
109
+ }
110
+ /** Build the context from resolved global option values + environment. */
111
+ static fromGlobals(opts) {
112
+ const json = Boolean(opts.json) || process.env.RECMP3_JSON === "1";
113
+ const yes = Boolean(opts.yes) || process.env.RECMP3_YES === "1" || process.env.RECMP3_SKIP_CONSENT === "1";
114
+ const quiet = Boolean(opts.quiet) || process.env.RECMP3_QUIET === "1";
115
+ const color = opts.color !== false && !process.env.NO_COLOR && process.stdout.isTTY !== false;
116
+ return new _AgentContext({
117
+ json,
118
+ yes,
119
+ quiet,
120
+ color,
121
+ sink: json ? new JsonSink() : new HumanSink()
122
+ });
123
+ }
124
+ /** Context for MCP tool calls: captured JSON, prompts auto-skipped, no chatter. */
125
+ static forCapture() {
126
+ return new _AgentContext({
127
+ json: true,
128
+ yes: true,
129
+ quiet: true,
130
+ color: false,
131
+ sink: new CaptureSink()
132
+ });
133
+ }
134
+ /** Emit a successful result. `humanRender` runs only in human (non-json) mode. */
135
+ ok(command, payload, humanRender) {
136
+ this.sink.ok(command, payload, humanRender);
137
+ }
138
+ /** Emit a failure envelope. Does not exit — the caller controls process exit. */
139
+ fail(err, command) {
140
+ this.sink.fail(toErrorPayload(err), command);
141
+ }
142
+ /** Write progress/diagnostic chatter to stderr unless quiet. */
143
+ note(text) {
144
+ if (!this.quiet) process.stderr.write(text);
145
+ }
146
+ };
147
+
148
+ // src/agent/stdin.ts
149
+ async function readStdinBuffer() {
150
+ const chunks = [];
151
+ for await (const chunk of process.stdin) {
152
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
153
+ }
154
+ return Buffer.concat(chunks);
155
+ }
156
+ async function readStdinText() {
157
+ return (await readStdinBuffer()).toString("utf-8");
158
+ }
159
+
160
+ // src/commands/config.ts
161
+ var ENV_VAR = {
162
+ groq: "GROQ_API_KEY",
163
+ openai: "OPENAI_API_KEY"
164
+ };
165
+ async function prompt(question) {
166
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
167
+ return new Promise((resolve) => {
168
+ rl.question(question, (answer) => {
169
+ rl.close();
170
+ resolve(answer.trim());
171
+ });
172
+ rl.once("close", () => resolve(""));
173
+ });
174
+ }
175
+ async function confirm(question, defaultYes = true) {
176
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
177
+ const answer = await prompt(`${question} ${hint} `);
178
+ if (!answer) return defaultYes;
179
+ return answer.toLowerCase().startsWith("y");
180
+ }
181
+ async function runConfigInit(opts, ctx2) {
182
+ const flagDriven = Boolean(
183
+ opts.provider || opts.lang || opts.outdir || opts.key
184
+ );
185
+ const interactive = !flagDriven && !ctx2.yes && process.stdout.isTTY === true && !ctx2.json;
186
+ if (interactive) {
187
+ return runConfigInitInteractive();
188
+ }
189
+ const providerName = ["groq", "openai", "local-whisper"].includes(opts.provider ?? "") ? opts.provider : "groq";
190
+ const config = RecmpConfigSchema.parse({});
191
+ config.provider.default = providerName;
192
+ if (opts.lang) config.transcription.defaultLanguage = opts.lang;
193
+ config.output.recordingDir = opts.outdir ?? paths.recordings;
194
+ await mkdir(dirname(configFilePath), { recursive: true });
195
+ await mkdir(config.output.recordingDir, { recursive: true });
196
+ await saveConfig(config);
197
+ let keychainStored = false;
198
+ if (opts.key && (providerName === "groq" || providerName === "openai")) {
199
+ keychainStored = await setSecret(ENV_VAR[providerName], opts.key);
200
+ if (!keychainStored) {
201
+ ctx2.note(
202
+ pc2.yellow(
203
+ " OS keychain unavailable \u2014 set the key via env var instead.\n"
204
+ )
205
+ );
206
+ }
207
+ }
208
+ ctx2.ok(
209
+ "config init",
210
+ {
211
+ configPath: configFilePath,
212
+ provider: providerName,
213
+ recordingDir: config.output.recordingDir,
214
+ keychainStored
215
+ },
216
+ () => {
217
+ console.log(`${pc2.green("\u2713")} Config saved: ${configFilePath}`);
218
+ console.log(`${pc2.green("\u2713")} Provider: ${providerName}`);
219
+ if (keychainStored)
220
+ console.log(`${pc2.green("\u2713")} API key stored in OS keychain`);
221
+ }
222
+ );
223
+ }
224
+ async function runConfigInitInteractive() {
225
+ console.log(`
226
+ ${pc2.bold("recmp3 \u2014 First-time setup")}`);
227
+ console.log(pc2.gray(`Config will be saved to: ${configFilePath}
228
+ `));
229
+ console.log(`${pc2.bold("1. Transcription provider")}`);
230
+ console.log(
231
+ ` ${pc2.cyan("groq")} \u2014 Groq Whisper API (fast, cheap, recommended)`
232
+ );
233
+ console.log(` ${pc2.cyan("openai")} \u2014 OpenAI Whisper API`);
234
+ console.log(` ${pc2.cyan("local-whisper")} \u2014 local whisper.cpp (no upload)`);
235
+ const providerInput = await prompt("\n Choice [groq]: ");
236
+ const providerName = ["groq", "openai", "local-whisper"].includes(providerInput) ? providerInput : "groq";
237
+ console.log(`
238
+ ${pc2.bold("2. API key")}`);
239
+ if (providerName === "local-whisper") {
240
+ console.log(
241
+ ` ${pc2.gray("No API key needed. Set RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL.")}`
242
+ );
243
+ } else {
244
+ const envVar = ENV_VAR[providerName];
245
+ const existingKey = await getApiKey(providerName);
246
+ if (existingKey) {
247
+ console.log(
248
+ ` ${pc2.green("\u2713")} ${envVar} is already set: ${redactKey(existingKey)}`
249
+ );
250
+ } else {
251
+ const entered = await prompt(
252
+ ` Paste ${envVar} (stored in OS keychain), or leave blank: `
253
+ );
254
+ if (entered) {
255
+ const stored = await setSecret(envVar, entered);
256
+ console.log(
257
+ stored ? ` ${pc2.green("\u2713")} Stored in OS keychain` : ` ${pc2.yellow("!")} Keychain unavailable \u2014 set ${envVar} as an env var.`
258
+ );
259
+ } else {
260
+ const ok = await confirm(
261
+ " Continue without setting the key for now?",
262
+ true
263
+ );
264
+ if (!ok) {
265
+ console.log(
266
+ pc2.gray("\n Setup cancelled. Re-run after setting the API key.\n")
267
+ );
268
+ return;
269
+ }
270
+ }
271
+ }
272
+ }
273
+ console.log(`
274
+ ${pc2.bold("3. Default language")}`);
275
+ const lang = await prompt(" Language code (e.g. es, en) [auto]: ");
276
+ console.log(`
277
+ ${pc2.bold("4. Recordings directory")}`);
278
+ console.log(` ${pc2.gray(`Default: ${paths.recordings}`)}`);
279
+ const outDir = await prompt(" Directory [default]: ");
280
+ const config = RecmpConfigSchema.parse({});
281
+ config.provider.default = providerName;
282
+ if (lang) config.transcription.defaultLanguage = lang;
283
+ config.output.recordingDir = outDir || paths.recordings;
284
+ await mkdir(dirname(configFilePath), { recursive: true });
285
+ await mkdir(config.output.recordingDir, { recursive: true });
286
+ await saveConfig(config);
287
+ console.log(`
288
+ ${pc2.green("\u2713")} Config saved: ${configFilePath}`);
289
+ console.log(
290
+ `${pc2.green("\u2713")} Recordings directory: ${config.output.recordingDir}`
291
+ );
292
+ console.log(`
293
+ ${pc2.cyan("recmp3 doctor")} \u2014 verify setup
294
+ `);
295
+ }
296
+ async function runConfigShow(ctx2) {
297
+ const config = await loadConfig();
298
+ const groqKey = await getApiKey("groq");
299
+ const openaiKey = await getApiKey("openai");
300
+ const payload = {
301
+ configPath: existsSync(configFilePath) ? configFilePath : null,
302
+ provider: {
303
+ default: config.provider.default,
304
+ groqModel: config.provider.groq?.model ?? "whisper-large-v3-turbo",
305
+ openaiModel: config.provider.openai?.model ?? "whisper-1",
306
+ local: config.provider.local ?? null
307
+ },
308
+ keys: {
309
+ groq: groqKey ? redactKey(groqKey) : null,
310
+ openai: openaiKey ? redactKey(openaiKey) : null
311
+ },
312
+ audio: config.audio,
313
+ output: config.output,
314
+ transcription: config.transcription
315
+ };
316
+ ctx2.ok("config show", payload, () => {
317
+ console.log(`
318
+ ${pc2.bold("recmp3 configuration")}`);
319
+ console.log(
320
+ pc2.gray(
321
+ `Config file: ${payload.configPath ?? `${configFilePath} (not found \u2014 using defaults)`}
322
+ `
323
+ )
324
+ );
325
+ console.log(`${pc2.bold("Provider")}`);
326
+ console.log(` default: ${pc2.cyan(config.provider.default)}`);
327
+ console.log(` groq model: ${payload.provider.groqModel}`);
328
+ console.log(` openai model:${payload.provider.openaiModel}`);
329
+ console.log(`
330
+ ${pc2.bold("API keys")}`);
331
+ console.log(
332
+ ` GROQ_API_KEY: ${groqKey ? pc2.green(`set (${redactKey(groqKey)})`) : pc2.red("not set")}`
333
+ );
334
+ console.log(
335
+ ` OPENAI_API_KEY: ${openaiKey ? pc2.green(`set (${redactKey(openaiKey)})`) : pc2.gray("not set")}`
336
+ );
337
+ console.log(`
338
+ ${pc2.bold("Audio")}`);
339
+ console.log(` source: ${config.audio.source}`);
340
+ console.log(`
341
+ ${pc2.bold("Output")}`);
342
+ console.log(` recordingDir: ${config.output.recordingDir}`);
343
+ console.log(`
344
+ ${pc2.bold("Transcription")}`);
345
+ console.log(
346
+ ` language: ${config.transcription.defaultLanguage ?? "auto-detect"}
347
+ `
348
+ );
349
+ });
350
+ }
351
+ async function runConfigPath(ctx2) {
352
+ ctx2.ok(
353
+ "config path",
354
+ { path: configFilePath },
355
+ () => console.log(configFilePath)
356
+ );
357
+ }
358
+ async function runConfigSet(key, value, ctx2) {
359
+ const config = await loadConfig();
360
+ const parts = key.split(".");
361
+ let obj = config;
362
+ for (let i = 0; i < parts.length - 1; i++) {
363
+ const part = parts[i];
364
+ if (typeof obj[part] !== "object" || obj[part] === null) obj[part] = {};
365
+ obj = obj[part];
366
+ }
367
+ const lastKey = parts[parts.length - 1];
368
+ if (value === "true") obj[lastKey] = true;
369
+ else if (value === "false") obj[lastKey] = false;
370
+ else if (!Number.isNaN(Number(value))) obj[lastKey] = Number(value);
371
+ else obj[lastKey] = value;
372
+ const parsed = RecmpConfigSchema.safeParse(config);
373
+ if (!parsed.success) {
374
+ throw new ConfigError(`Invalid config value: ${parsed.error.message}`);
375
+ }
376
+ await saveConfig(parsed.data);
377
+ resetConfigCache();
378
+ ctx2.ok(
379
+ "config set",
380
+ { key, value },
381
+ () => console.log(`${pc2.green("\u2713")} Set ${key} = ${value}`)
382
+ );
383
+ }
384
+ async function runConfigSetKey(provider, opts, ctx2) {
385
+ if (provider !== "groq" && provider !== "openai") {
386
+ throw new InputError(
387
+ `Unknown provider: "${provider}". Valid: groq, openai.`
388
+ );
389
+ }
390
+ let value = opts.key;
391
+ if (!value && process.stdin.isTTY !== true) {
392
+ value = (await readStdinText()).trim();
393
+ }
394
+ if (!value) value = process.env[ENV_VAR[provider]];
395
+ if (!value) {
396
+ throw new InputError(
397
+ "No key provided. Use --key, pipe it on stdin, or set the *_API_KEY env var."
398
+ );
399
+ }
400
+ if (!await keychainAvailable()) {
401
+ throw new ConfigError(
402
+ "OS keychain (keytar) is unavailable on this machine."
403
+ );
404
+ }
405
+ await setSecret(ENV_VAR[provider], value);
406
+ ctx2.ok(
407
+ "config set-key",
408
+ { provider, stored: true, backend: "keychain" },
409
+ () => console.log(
410
+ `${pc2.green("\u2713")} Stored ${ENV_VAR[provider]} in the OS keychain (${redactKey(value)})`
411
+ )
412
+ );
413
+ }
414
+ function printCheck(check) {
415
+ const icon = check.ok ? pc2.green("\u2713") : pc2.red("\u2717");
416
+ const label = pc2.bold(check.label.padEnd(30));
417
+ const detail = check.detail ? pc2.gray(check.detail) : "";
418
+ console.log(` ${icon} ${label} ${detail}`);
419
+ if (!check.ok && check.hint) {
420
+ console.log(` ${pc2.yellow("\u2192")} ${pc2.yellow(check.hint)}`);
421
+ }
422
+ }
423
+ async function runDoctor(ctx2) {
424
+ const checks = [];
425
+ const nodeVersion = process.version;
426
+ const [nodeMajor] = nodeVersion.slice(1).split(".").map(Number);
427
+ const nodeOk = nodeMajor >= 20;
428
+ checks.push({
429
+ label: "Node.js version",
430
+ ok: nodeOk,
431
+ detail: nodeVersion,
432
+ hint: nodeOk ? void 0 : "Requires Node.js 20+. Visit https://nodejs.org/"
433
+ });
434
+ const platform = process.platform;
435
+ const platformLabels = {
436
+ linux: "Linux",
437
+ darwin: "macOS",
438
+ win32: "Windows"
439
+ };
440
+ const platformLabel = platformLabels[platform] ?? platform;
441
+ const platformSupported = ["linux", "darwin", "win32"].includes(platform);
442
+ checks.push({
443
+ label: "Platform",
444
+ ok: platformSupported,
445
+ detail: `${platformLabel} (${process.arch})`,
446
+ hint: platformSupported ? void 0 : `Platform "${platform}" may not be fully supported.`
447
+ });
448
+ const ffmpegCheck = await checkFfmpegVersion();
449
+ checks.push({
450
+ label: "ffmpeg",
451
+ ok: ffmpegCheck.meets,
452
+ detail: ffmpegCheck.version,
453
+ hint: ffmpegCheck.meets ? void 0 : "Requires ffmpeg 4.4+. Install: sudo apt install ffmpeg"
454
+ });
455
+ if (ffmpegCheck.meets) {
456
+ const backendFormats = {
457
+ linux: "pulse",
458
+ darwin: "avfoundation",
459
+ win32: "dshow"
460
+ };
461
+ const backendFormat = backendFormats[platform] ?? "pulse";
462
+ const backendOk = await supportsInputFormat(backendFormat).catch(
463
+ () => false
464
+ );
465
+ checks.push({
466
+ label: "Audio backend",
467
+ ok: backendOk,
468
+ detail: backendFormat,
469
+ hint: backendOk ? void 0 : `ffmpeg missing "${backendFormat}" input support. Reinstall ffmpeg.`
470
+ });
471
+ }
472
+ const configExists = existsSync(configFilePath);
473
+ checks.push({
474
+ label: "Config file",
475
+ ok: true,
476
+ detail: configExists ? configFilePath : `${configFilePath} (using defaults)`
477
+ });
478
+ let config;
479
+ try {
480
+ config = await loadConfig();
481
+ } catch (err) {
482
+ checks.push({
483
+ label: "Config load",
484
+ ok: false,
485
+ detail: err instanceof Error ? err.message : String(err),
486
+ hint: "Run: recmp3 config init"
487
+ });
488
+ }
489
+ if (config) {
490
+ const providerName = config.provider.default;
491
+ if (providerName === "local-whisper") {
492
+ const { LocalWhisperProvider } = await import('./local-whisper-VH26RX7Y.js');
493
+ const provider = new LocalWhisperProvider(config.provider.local ?? {});
494
+ const ping = await provider.ping();
495
+ checks.push({
496
+ label: "Provider: local-whisper",
497
+ ok: ping.ok,
498
+ detail: ping.ok ? `ready (${ping.latencyMs}ms)` : ping.error,
499
+ hint: ping.ok ? void 0 : "Set RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL."
500
+ });
501
+ } else {
502
+ const apiKey = await getApiKey(providerName);
503
+ const keyOk = Boolean(apiKey);
504
+ checks.push({
505
+ label: `Provider: ${providerName}`,
506
+ ok: keyOk,
507
+ detail: keyOk ? `API key set (${redactKey(apiKey)})` : "API key not set",
508
+ hint: keyOk ? void 0 : `Set ${providerName === "groq" ? "GROQ_API_KEY" : "OPENAI_API_KEY"}, or run: recmp3 config set-key ${providerName}`
509
+ });
510
+ if (keyOk) {
511
+ try {
512
+ const { createProvider: createProvider2 } = await import('./registry-D5SOVUEJ.js');
513
+ const provider = await createProvider2(config);
514
+ const ping = provider.ping ? await provider.ping() : null;
515
+ if (ping) {
516
+ checks.push({
517
+ label: "Provider ping",
518
+ ok: ping.ok,
519
+ detail: ping.ok ? `${ping.latencyMs}ms` : ping.error,
520
+ hint: ping.ok ? void 0 : "Check network connection or API key validity."
521
+ });
522
+ }
523
+ } catch (err) {
524
+ checks.push({
525
+ label: "Provider ping",
526
+ ok: false,
527
+ detail: err instanceof Error ? err.message : String(err),
528
+ hint: "Check network connectivity."
529
+ });
530
+ }
531
+ }
532
+ }
533
+ checks.push({
534
+ label: "Recordings directory",
535
+ ok: true,
536
+ detail: config.output.recordingDir
537
+ });
538
+ }
539
+ const allOk = checks.every((c) => c.ok);
540
+ ctx2.ok("doctor", { ok: allOk, checks }, () => {
541
+ console.log(`
542
+ ${pc2.bold("recmp3 doctor \u2014 preflight checks")}
543
+ `);
544
+ for (const check of checks) printCheck(check);
545
+ console.log("");
546
+ if (allOk) {
547
+ console.log(
548
+ pc2.green(" \u2713 All checks passed. Run: recmp3 record --transcribe\n")
549
+ );
550
+ } else {
551
+ console.log(
552
+ pc2.yellow(
553
+ " Some checks failed. Address the issues above and re-run: recmp3 doctor\n"
554
+ )
555
+ );
556
+ }
557
+ });
558
+ if (!allOk) process.exitCode = 1;
559
+ }
560
+
561
+ // src/agent/manifest.ts
562
+ var GLOBAL_FLAGS = [
563
+ {
564
+ name: "--json",
565
+ type: "boolean",
566
+ description: "Emit a stable JSON envelope on stdout",
567
+ env: "RECMP3_JSON"
568
+ },
569
+ {
570
+ name: "--yes",
571
+ type: "boolean",
572
+ description: "Skip all interactive prompts",
573
+ env: "RECMP3_YES"
574
+ },
575
+ {
576
+ name: "--quiet",
577
+ type: "boolean",
578
+ description: "Suppress stderr chatter",
579
+ env: "RECMP3_QUIET"
580
+ },
581
+ {
582
+ name: "--no-color",
583
+ type: "boolean",
584
+ description: "Disable colored output",
585
+ env: "NO_COLOR"
586
+ }
587
+ ];
588
+ var MANIFEST = {
589
+ name: "recmp3",
590
+ version: "1.0.0",
591
+ description: "Record audio, transcribe with AI, output developer-ready prompts.",
592
+ globalFlags: GLOBAL_FLAGS,
593
+ exitCodes: {
594
+ success: ExitCode.SUCCESS,
595
+ unknown: ExitCode.UNKNOWN,
596
+ config: ExitCode.CONFIG,
597
+ audio: ExitCode.AUDIO,
598
+ transcription: ExitCode.TRANSCRIPTION,
599
+ network: ExitCode.NETWORK,
600
+ localWhisper: ExitCode.LOCAL_WHISPER,
601
+ input: ExitCode.INPUT,
602
+ userAbort: ExitCode.USER_ABORT
603
+ },
604
+ commands: [
605
+ {
606
+ name: "transcribe",
607
+ tool: "recmp3_transcribe",
608
+ summary: "Transcribe an existing audio file.",
609
+ agentSafe: true,
610
+ args: [
611
+ {
612
+ name: "file",
613
+ required: true,
614
+ description: 'Audio file path, or "-" for stdin'
615
+ }
616
+ ],
617
+ flags: [
618
+ {
619
+ name: "--provider",
620
+ type: "string",
621
+ description: "groq | openai | local-whisper"
622
+ },
623
+ {
624
+ name: "--lang",
625
+ type: "string",
626
+ description: "Force language code (e.g. es, en)"
627
+ },
628
+ {
629
+ name: "--copy",
630
+ type: "boolean",
631
+ description: "Copy transcript to clipboard"
632
+ }
633
+ ],
634
+ stdin: true,
635
+ stdout: "json"
636
+ },
637
+ {
638
+ name: "prompt",
639
+ tool: "recmp3_prompt",
640
+ summary: "Wrap a transcript in a developer prompt template (no network).",
641
+ agentSafe: true,
642
+ args: [
643
+ {
644
+ name: "file",
645
+ required: true,
646
+ description: 'Transcript file path, or "-" for stdin'
647
+ }
648
+ ],
649
+ flags: [
650
+ {
651
+ name: "--template",
652
+ type: "string",
653
+ description: "claude-code | prd | bug | todo | meeting-notes | commit-message | raw",
654
+ default: "claude-code"
655
+ },
656
+ {
657
+ name: "--out",
658
+ type: "string",
659
+ description: "Write output to a file"
660
+ },
661
+ {
662
+ name: "--copy",
663
+ type: "boolean",
664
+ description: "Copy output to clipboard"
665
+ }
666
+ ],
667
+ stdin: true,
668
+ stdout: "json"
669
+ },
670
+ {
671
+ name: "sources",
672
+ tool: "recmp3_sources",
673
+ summary: "List available audio input sources for the OS.",
674
+ agentSafe: true,
675
+ flags: [],
676
+ stdin: false,
677
+ stdout: "json"
678
+ },
679
+ {
680
+ name: "doctor",
681
+ tool: "recmp3_doctor",
682
+ summary: "Run preflight checks (Node, ffmpeg, audio backend, provider, etc.).",
683
+ agentSafe: true,
684
+ flags: [],
685
+ stdin: false,
686
+ stdout: "json"
687
+ },
688
+ {
689
+ name: "config show",
690
+ tool: "recmp3_config_show",
691
+ summary: "Show resolved configuration (API keys redacted).",
692
+ agentSafe: true,
693
+ flags: [],
694
+ stdin: false,
695
+ stdout: "json"
696
+ },
697
+ {
698
+ name: "manifest",
699
+ tool: "recmp3_manifest",
700
+ summary: "Print the command/tool manifest.",
701
+ agentSafe: true,
702
+ flags: [],
703
+ stdin: false,
704
+ stdout: "json"
705
+ },
706
+ {
707
+ name: "record",
708
+ tool: "recmp3_record",
709
+ summary: "Record audio. Agent/headless mode requires --duration.",
710
+ agentSafe: true,
711
+ flags: [
712
+ {
713
+ name: "--duration",
714
+ type: "number",
715
+ description: "Headless: record N seconds then stop"
716
+ },
717
+ { name: "--name", type: "string", description: "Output filename stem" },
718
+ { name: "--out", type: "string", description: "Output directory" },
719
+ {
720
+ name: "--transcribe",
721
+ type: "boolean",
722
+ description: "Transcribe after recording"
723
+ },
724
+ {
725
+ name: "--provider",
726
+ type: "string",
727
+ description: "groq | openai | local-whisper"
728
+ },
729
+ { name: "--lang", type: "string", description: "Force language code" },
730
+ {
731
+ name: "--source",
732
+ type: "string",
733
+ description: 'Audio source id, or "auto" for the best physical mic'
734
+ }
735
+ ],
736
+ stdin: false,
737
+ stdout: "json"
738
+ },
739
+ {
740
+ name: "config init",
741
+ summary: "First-time setup. Flag-driven when non-interactive.",
742
+ agentSafe: false,
743
+ flags: [
744
+ {
745
+ name: "--provider",
746
+ type: "string",
747
+ description: "groq | openai | local-whisper"
748
+ },
749
+ {
750
+ name: "--lang",
751
+ type: "string",
752
+ description: "Default language code"
753
+ },
754
+ {
755
+ name: "--outdir",
756
+ type: "string",
757
+ description: "Recordings output directory"
758
+ },
759
+ {
760
+ name: "--key",
761
+ type: "string",
762
+ description: "API key to store in the OS keychain"
763
+ }
764
+ ],
765
+ stdin: false,
766
+ stdout: "none"
767
+ },
768
+ {
769
+ name: "config set-key",
770
+ summary: "Store an API key in the OS keychain.",
771
+ agentSafe: false,
772
+ args: [
773
+ { name: "provider", required: true, description: "groq | openai" }
774
+ ],
775
+ flags: [
776
+ {
777
+ name: "--key",
778
+ type: "string",
779
+ description: "Key value (else stdin or *_API_KEY env)"
780
+ }
781
+ ],
782
+ stdin: true,
783
+ stdout: "none"
784
+ },
785
+ {
786
+ name: "mcp",
787
+ summary: "Start the Model Context Protocol server over stdio.",
788
+ agentSafe: false,
789
+ flags: [],
790
+ stdin: true,
791
+ stdout: "none"
792
+ }
793
+ ]
794
+ };
795
+
796
+ // src/commands/manifest.ts
797
+ async function runManifest(ctx2) {
798
+ ctx2.ok("manifest", MANIFEST, () => {
799
+ process.stdout.write(`${JSON.stringify(MANIFEST, null, 2)}
800
+ `);
801
+ });
802
+ }
803
+
804
+ // src/output/clipboard.ts
805
+ async function copyToClipboard(text) {
806
+ try {
807
+ const clipboardy = await import('clipboardy');
808
+ await clipboardy.default.write(text);
809
+ return true;
810
+ } catch (err) {
811
+ log.info(
812
+ `Clipboard copy failed (headless or missing xclip/wl-copy): ${err instanceof Error ? err.message : String(err)}`
813
+ );
814
+ return false;
815
+ }
816
+ }
817
+
818
+ // src/commands/prompt.ts
819
+ var TEMPLATES = {
820
+ raw: (text) => text,
821
+ "claude-code": (text, name) => `# Claude Code Prompt${name ? ` \u2014 ${name}` : ""}
822
+
823
+ ## Objective
824
+ ${text}
825
+
826
+ ## Context
827
+ [Add relevant codebase context here]
828
+
829
+ ## Scope
830
+ - In scope: [define boundaries]
831
+ - Out of scope: [define exclusions]
832
+
833
+ ## Constraints
834
+ - [Technical constraints]
835
+ - [Time constraints]
836
+
837
+ ## Acceptance Criteria
838
+ - [ ] [Add specific acceptance criteria]
839
+ - [ ] All existing tests pass
840
+ - [ ] No regressions introduced
841
+
842
+ ## Verification
843
+ \`\`\`bash
844
+ # Add verification commands here
845
+ \`\`\`
846
+
847
+ ## CKIS writeback suggestion
848
+ [Note any architectural decisions made during implementation]
849
+ `,
850
+ prd: (text, name) => `# Product Requirements Document${name ? ` \u2014 ${name}` : ""}
851
+
852
+ **Created:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
853
+ **Status:** Draft
854
+
855
+ ## Problem Statement
856
+ ${text}
857
+
858
+ ## Goals
859
+ - [Primary goal]
860
+ - [Secondary goal]
861
+
862
+ ## Non-Goals
863
+ - [What this does NOT address]
864
+
865
+ ## User Stories
866
+ - As a [user], I want [feature] so that [benefit]
867
+
868
+ ## Requirements
869
+ ### Functional
870
+ - [ ] [Requirement 1]
871
+
872
+ ### Non-Functional
873
+ - [ ] [Performance requirement]
874
+ - [ ] [Security requirement]
875
+
876
+ ## Success Metrics
877
+ - [Metric 1]
878
+
879
+ ## Open Questions
880
+ - [Question 1]
881
+
882
+ ## Timeline
883
+ - [Milestone]: [Date]
884
+ `,
885
+ bug: (text, name) => `# Bug Report${name ? ` \u2014 ${name}` : ""}
886
+
887
+ **Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
888
+ **Severity:** [critical / high / medium / low]
889
+
890
+ ## Description
891
+ ${text}
892
+
893
+ ## Steps to Reproduce
894
+ 1. [Step 1]
895
+ 2. [Step 2]
896
+ 3. [Step 3]
897
+
898
+ ## Expected Behavior
899
+ [What should happen]
900
+
901
+ ## Actual Behavior
902
+ [What actually happens]
903
+
904
+ ## Environment
905
+ - OS: ${process.platform}
906
+ - Node: ${process.version}
907
+
908
+ ## Possible Fix
909
+ [Your hypothesis]
910
+
911
+ ## Attachments
912
+ - [ ] Screenshots
913
+ - [ ] Logs
914
+ - [ ] Reproduction repo
915
+ `,
916
+ "meeting-notes": (text, name) => `# Meeting Notes${name ? ` \u2014 ${name}` : ""}
917
+
918
+ **Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
919
+
920
+ ## Summary
921
+ ${text}
922
+
923
+ ## Action Items
924
+ - [ ] [Owner] \u2014 [Action] \u2014 [Due date]
925
+
926
+ ## Decisions Made
927
+ - [Decision 1]
928
+
929
+ ## Open Questions
930
+ - [Question 1]
931
+
932
+ ## Next Meeting
933
+ - [ ] Schedule: [Date/Time]
934
+ `,
935
+ todo: (text) => {
936
+ const lines = text.split(/[.!?]+/).filter((l) => l.trim().length > 5);
937
+ const todos = lines.map((l) => `- [ ] ${l.trim()}`).join("\n");
938
+ return `# TODO List
939
+
940
+ ${todos}
941
+ `;
942
+ },
943
+ "commit-message": (text) => {
944
+ const firstSentence = text.split(/[.!?]/)[0]?.trim() ?? text;
945
+ const subject = firstSentence.slice(0, 72).toLowerCase().replace(/^i /, "");
946
+ const body = text.length > firstSentence.length ? `
947
+
948
+ ${text}` : "";
949
+ return `${subject}${body}
950
+ `;
951
+ }
952
+ };
953
+ function listTemplates() {
954
+ console.log(`
955
+ ${pc2.bold("Available prompt templates:")}
956
+ `);
957
+ for (const name of Object.keys(TEMPLATES)) {
958
+ console.log(` ${pc2.cyan(name)}`);
959
+ }
960
+ console.log(
961
+ "\n Usage: recmp3 prompt <transcript.txt> --template claude-code\n"
962
+ );
963
+ }
964
+ async function runPrompt(transcriptFile, opts, ctx2) {
965
+ if (opts.listTemplates) {
966
+ listTemplates();
967
+ return;
968
+ }
969
+ const templateName = opts.template ?? "claude-code";
970
+ const templateFn = TEMPLATES[templateName];
971
+ if (!templateFn) {
972
+ throw new InputError(
973
+ `Unknown template: "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`
974
+ );
975
+ }
976
+ let text;
977
+ let name;
978
+ if (transcriptFile === "-") {
979
+ text = (await readStdinText()).trim();
980
+ if (!text) throw new InputError("No text received on stdin.");
981
+ } else {
982
+ if (!existsSync(transcriptFile)) {
983
+ throw new InputError(`File not found: ${transcriptFile}`);
984
+ }
985
+ text = readFileSync(transcriptFile, "utf-8").trim();
986
+ name = basename(transcriptFile, ".txt");
987
+ }
988
+ const output = templateFn(text, name);
989
+ if (opts.out) {
990
+ await writeFile(opts.out, output, "utf-8");
991
+ ctx2.note(`${pc2.green("\u2713")} Written to: ${opts.out}
992
+ `);
993
+ }
994
+ if (opts.copy) {
995
+ const copied = await copyToClipboard(output);
996
+ if (copied) ctx2.note(pc2.gray(" Copied to clipboard.\n"));
997
+ }
998
+ ctx2.ok(
999
+ "prompt",
1000
+ { template: templateName, output },
1001
+ () => process.stdout.write(output)
1002
+ );
1003
+ }
1004
+
1005
+ // src/audio/auto-source.ts
1006
+ var MONITOR_RE = /\.monitor\b|\bmonitor\b/i;
1007
+ function pickAutoSource(sources) {
1008
+ const physical = sources.filter(
1009
+ (s) => s.id !== "default" && !MONITOR_RE.test(s.id) && !MONITOR_RE.test(s.label)
1010
+ );
1011
+ const preferred = physical.find((s) => /input/i.test(s.id));
1012
+ return (preferred ?? physical[0])?.id ?? "default";
1013
+ }
1014
+
1015
+ // src/audio/capture.ts
1016
+ var _factory = null;
1017
+ async function getAudioFactory() {
1018
+ if (_factory) return _factory;
1019
+ const platform = process.platform;
1020
+ if (platform === "linux") {
1021
+ const { LinuxPulseCaptureFactory } = await import('./linux-pulse-AROLYZNB.js');
1022
+ _factory = new LinuxPulseCaptureFactory();
1023
+ } else if (platform === "darwin") {
1024
+ const { MacAvFoundationFactory } = await import('./mac-avfoundation-COPCFRZT.js');
1025
+ _factory = new MacAvFoundationFactory();
1026
+ } else if (platform === "win32") {
1027
+ const { WindowsDshowFactory } = await import('./windows-dshow-O2GU4ZLR.js');
1028
+ _factory = new WindowsDshowFactory();
1029
+ } else {
1030
+ throw new Error(
1031
+ `Unsupported platform: ${platform}. Supported platforms: linux, darwin, win32.`
1032
+ );
1033
+ }
1034
+ return _factory;
1035
+ }
1036
+ async function ensureUploadConsent(ctx2) {
1037
+ const config = await loadConfig();
1038
+ if (config.consent.uploadsAcknowledged) return;
1039
+ if (ctx2.yes) {
1040
+ config.consent.uploadsAcknowledged = true;
1041
+ config.consent.acknowledgedAt = (/* @__PURE__ */ new Date()).toISOString();
1042
+ await saveConfig(config).catch(() => {
1043
+ });
1044
+ return;
1045
+ }
1046
+ if (!process.stdout.isTTY || ctx2.json) {
1047
+ ctx2.note(
1048
+ `${pc2.yellow("\u26A0 recmp3 will upload audio to the configured provider for transcription.\n")} Pass --yes or set RECMP3_YES=1 to suppress this in scripts.
1049
+ `
1050
+ );
1051
+ return;
1052
+ }
1053
+ process.stdout.write(
1054
+ `
1055
+ ${pc2.bold(" recmp3 will upload your audio to the transcription provider.\n")}${pc2.gray(" Audio is transmitted over HTTPS. Provider data retention terms apply.\n")}${pc2.gray(` Current provider: ${config.provider.default}
1056
+ `)}
1057
+ Continue? [Y/n] `
1058
+ );
1059
+ const answer = await new Promise((resolve) => {
1060
+ const rl = createInterface({
1061
+ input: process.stdin,
1062
+ output: process.stdout
1063
+ });
1064
+ rl.once("line", (line) => {
1065
+ rl.close();
1066
+ resolve(line.trim().toLowerCase());
1067
+ });
1068
+ rl.once("close", () => resolve("y"));
1069
+ });
1070
+ if (answer === "n" || answer === "no") {
1071
+ process.stdout.write(pc2.gray(" Cancelled. No audio was uploaded.\n\n"));
1072
+ process.exit(0);
1073
+ }
1074
+ config.consent.uploadsAcknowledged = true;
1075
+ config.consent.acknowledgedAt = (/* @__PURE__ */ new Date()).toISOString();
1076
+ await saveConfig(config).catch(() => {
1077
+ });
1078
+ process.stdout.write("\n");
1079
+ }
1080
+ function formatDate(d = /* @__PURE__ */ new Date()) {
1081
+ const pad = (n) => String(n).padStart(2, "0");
1082
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
1083
+ }
1084
+ function generateRecordingName(opts) {
1085
+ const ext = opts.ext ?? "wav";
1086
+ const ts = formatDate();
1087
+ if (opts.name) {
1088
+ const slug = opts.name.toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
1089
+ return `${slug}-${ts}.${ext}`;
1090
+ }
1091
+ const prefix = opts.prefix ?? "rec";
1092
+ return `${prefix}-${ts}.${ext}`;
1093
+ }
1094
+ function transcriptPath(audioPath, ext) {
1095
+ const base = audioPath.replace(/\.(wav|mp3|m4a|ogg|flac)$/i, "");
1096
+ return `${base}.${ext}`;
1097
+ }
1098
+ function buildOutputPath(dir, name) {
1099
+ return join(dir, name);
1100
+ }
1101
+ async function writeTranscriptFiles(audioPath, result) {
1102
+ const txtPath = transcriptPath(audioPath, "txt");
1103
+ const jsonPath = transcriptPath(audioPath, "json");
1104
+ await writeFile(txtPath, `${result.text}
1105
+ `, "utf-8");
1106
+ const meta = {
1107
+ text: result.text,
1108
+ provider: result.provider,
1109
+ model: result.model,
1110
+ language: result.language,
1111
+ durationSec: result.durationSec,
1112
+ latencyMs: result.latencyMs,
1113
+ audioFile: audioPath,
1114
+ segments: result.segments
1115
+ };
1116
+ await writeFile(jsonPath, `${JSON.stringify(meta, null, 2)}
1117
+ `, "utf-8");
1118
+ return { txtPath, jsonPath };
1119
+ }
1120
+ var execFileAsync = promisify(execFile);
1121
+ async function transcribeWithChunking(provider, input, chunkSeconds = 600) {
1122
+ const fileStat = await stat(input.audioPath);
1123
+ if (fileStat.size <= provider.maxFileSizeBytes) {
1124
+ return provider.transcribe(input);
1125
+ }
1126
+ const tmpDir = join(
1127
+ (await import('os')).tmpdir(),
1128
+ `recmp3-chunks-${Date.now()}`
1129
+ );
1130
+ await mkdir(tmpDir, { recursive: true });
1131
+ const ffmpeg = await findFfmpeg();
1132
+ const chunkPattern = join(tmpDir, "chunk-%04d.wav");
1133
+ await execFileAsync(ffmpeg, [
1134
+ "-hide_banner",
1135
+ "-loglevel",
1136
+ "error",
1137
+ "-i",
1138
+ input.audioPath,
1139
+ "-f",
1140
+ "segment",
1141
+ "-segment_time",
1142
+ String(chunkSeconds),
1143
+ "-c",
1144
+ "copy",
1145
+ chunkPattern
1146
+ ]);
1147
+ const files = (await readdir(tmpDir)).filter((f) => f.startsWith("chunk-") && f.endsWith(".wav")).sort().map((f) => join(tmpDir, f));
1148
+ if (files.length === 0) {
1149
+ return provider.transcribe(input);
1150
+ }
1151
+ const results = [];
1152
+ for (const chunkPath of files) {
1153
+ const result = await provider.transcribe({
1154
+ ...input,
1155
+ audioPath: chunkPath
1156
+ });
1157
+ results.push(result);
1158
+ }
1159
+ const combinedText = results.map((r) => r.text).join(" ");
1160
+ const totalLatency = results.reduce((sum, r) => sum + r.latencyMs, 0);
1161
+ return {
1162
+ text: combinedText,
1163
+ language: results[0]?.language,
1164
+ durationSec: results.reduce((sum, r) => sum + (r.durationSec ?? 0), 0),
1165
+ raw: results.map((r) => r.raw),
1166
+ provider: results[0]?.provider ?? provider.name,
1167
+ model: results[0]?.model ?? "",
1168
+ latencyMs: totalLatency
1169
+ };
1170
+ }
1171
+ var execFileAsync2 = promisify(execFile);
1172
+ async function concatSegments(segments, outputPath, tmpDir, format = "wav") {
1173
+ const validSegments = segments.filter((s) => s.sizeBytes > 0);
1174
+ if (validSegments.length === 0) {
1175
+ throw new AudioCaptureError(
1176
+ "No audio was recorded. The recording was empty or too short."
1177
+ );
1178
+ }
1179
+ const ffmpeg = await findFfmpeg();
1180
+ if (validSegments.length === 1) {
1181
+ if (format === "wav") {
1182
+ const { copyFile } = await import('fs/promises');
1183
+ await copyFile(validSegments[0].path, outputPath);
1184
+ return outputPath;
1185
+ }
1186
+ await execFileAsync2(ffmpeg, [
1187
+ "-hide_banner",
1188
+ "-loglevel",
1189
+ "error",
1190
+ "-i",
1191
+ validSegments[0].path,
1192
+ "-c:a",
1193
+ "libmp3lame",
1194
+ "-b:a",
1195
+ "192k",
1196
+ "-y",
1197
+ outputPath
1198
+ ]);
1199
+ return outputPath;
1200
+ }
1201
+ const listPath = join(tmpDir, "concat-list.txt");
1202
+ const listContent = validSegments.map((s) => `file '${s.path.replace(/'/g, "'\\''")}'`).join("\n");
1203
+ await writeFile(listPath, listContent, "utf-8");
1204
+ const codecArgs = format === "mp3" ? ["-c:a", "libmp3lame", "-b:a", "192k"] : ["-c", "copy"];
1205
+ await execFileAsync2(ffmpeg, [
1206
+ "-hide_banner",
1207
+ "-loglevel",
1208
+ "error",
1209
+ "-f",
1210
+ "concat",
1211
+ "-safe",
1212
+ "0",
1213
+ "-i",
1214
+ listPath,
1215
+ ...codecArgs,
1216
+ "-y",
1217
+ outputPath
1218
+ ]);
1219
+ return outputPath;
1220
+ }
1221
+ var RecorderUI = ({
1222
+ capture,
1223
+ captureOpts,
1224
+ outputPath,
1225
+ onResult
1226
+ }) => {
1227
+ const { exit } = useApp();
1228
+ const [status, setStatus] = useState("recording");
1229
+ const [elapsedMs, setElapsedMs] = useState(0);
1230
+ const [segmentCount, setSegmentCount] = useState(1);
1231
+ const [statusMessage, setStatusMessage] = useState("");
1232
+ const [busy, setBusy] = useState(false);
1233
+ const segmentsRef = useRef([]);
1234
+ const accumulatedMsRef = useRef(0);
1235
+ const segmentStartRef = useRef(Date.now());
1236
+ const currentSegmentIndexRef = useRef(1);
1237
+ const isRecordingRef = useRef(true);
1238
+ useEffect(() => {
1239
+ const interval = setInterval(() => {
1240
+ if (isRecordingRef.current) {
1241
+ setElapsedMs(
1242
+ accumulatedMsRef.current + (Date.now() - segmentStartRef.current)
1243
+ );
1244
+ }
1245
+ }, 100);
1246
+ return () => clearInterval(interval);
1247
+ }, []);
1248
+ const stopCurrentSegment = useCallback(async () => {
1249
+ if (!isRecordingRef.current) return null;
1250
+ try {
1251
+ const segment = await capture.stop();
1252
+ accumulatedMsRef.current += Date.now() - segmentStartRef.current;
1253
+ isRecordingRef.current = false;
1254
+ segmentsRef.current.push(segment);
1255
+ return segment;
1256
+ } catch {
1257
+ return null;
1258
+ }
1259
+ }, [capture]);
1260
+ const startNewSegment = useCallback(async () => {
1261
+ currentSegmentIndexRef.current += 1;
1262
+ const segPath = `${captureOpts.tmpDir}/segment-${String(currentSegmentIndexRef.current).padStart(4, "0")}.wav`;
1263
+ await capture.start({ ...captureOpts, outputPath: segPath });
1264
+ segmentStartRef.current = Date.now();
1265
+ isRecordingRef.current = true;
1266
+ setSegmentCount(currentSegmentIndexRef.current + 1);
1267
+ }, [capture, captureOpts]);
1268
+ const handlePause = useCallback(async () => {
1269
+ if (busy || status !== "recording") return;
1270
+ setBusy(true);
1271
+ await stopCurrentSegment();
1272
+ setStatus("paused");
1273
+ setBusy(false);
1274
+ }, [busy, status, stopCurrentSegment]);
1275
+ const handleResume = useCallback(async () => {
1276
+ if (busy || status !== "paused") return;
1277
+ setBusy(true);
1278
+ await startNewSegment();
1279
+ setStatus("recording");
1280
+ setBusy(false);
1281
+ }, [busy, status, startNewSegment]);
1282
+ const handleSave = useCallback(async () => {
1283
+ if (busy || status !== "recording" && status !== "paused") return;
1284
+ setBusy(true);
1285
+ setStatus("saving");
1286
+ try {
1287
+ await stopCurrentSegment();
1288
+ setStatusMessage("Concatenating segments...");
1289
+ const finalPath = await concatSegments(
1290
+ segmentsRef.current,
1291
+ outputPath,
1292
+ captureOpts.tmpDir,
1293
+ "wav"
1294
+ );
1295
+ setStatusMessage("");
1296
+ setStatus("done");
1297
+ onResult({
1298
+ cancelled: false,
1299
+ outputPath: finalPath,
1300
+ segments: segmentsRef.current,
1301
+ totalDurationMs: accumulatedMsRef.current
1302
+ });
1303
+ setTimeout(() => exit(), 800);
1304
+ } catch (err) {
1305
+ setStatus("error");
1306
+ setStatusMessage(err instanceof Error ? err.message : String(err));
1307
+ setTimeout(() => exit(), 2e3);
1308
+ }
1309
+ }, [
1310
+ busy,
1311
+ status,
1312
+ stopCurrentSegment,
1313
+ outputPath,
1314
+ captureOpts.tmpDir,
1315
+ onResult,
1316
+ exit
1317
+ ]);
1318
+ const handleCancel = useCallback(async () => {
1319
+ if (busy) return;
1320
+ setBusy(true);
1321
+ await capture.dispose();
1322
+ setStatus("cancelled");
1323
+ onResult({ cancelled: true, segments: [], totalDurationMs: 0 });
1324
+ setTimeout(() => exit(), 300);
1325
+ }, [busy, capture, onResult, exit]);
1326
+ useInput((input, key) => {
1327
+ if (key.ctrl && input === "c") {
1328
+ handleCancel();
1329
+ return;
1330
+ }
1331
+ if (input === "c" || key.escape) {
1332
+ handleCancel();
1333
+ return;
1334
+ }
1335
+ if (input === "p" || input === " ") {
1336
+ if (status === "recording") handlePause();
1337
+ else if (status === "paused") handleResume();
1338
+ return;
1339
+ }
1340
+ if (input === "s" || key.return) {
1341
+ handleSave();
1342
+ return;
1343
+ }
1344
+ });
1345
+ const elapsed = Math.floor(elapsedMs / 1e3);
1346
+ const h = Math.floor(elapsed / 3600);
1347
+ const m = Math.floor(elapsed % 3600 / 60);
1348
+ const s = elapsed % 60;
1349
+ const timeStr = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1350
+ const statusColors = {
1351
+ recording: "red",
1352
+ paused: "yellow",
1353
+ saving: "cyan",
1354
+ done: "green",
1355
+ cancelled: "gray",
1356
+ error: "red"
1357
+ };
1358
+ const statusLabels = {
1359
+ recording: "\u25CF REC",
1360
+ paused: "\u2016 PAUSED",
1361
+ saving: "\u25CC SAVING",
1362
+ done: "\u2713 DONE",
1363
+ cancelled: "\u2717 CANCELLED",
1364
+ error: "\u2717 ERROR"
1365
+ };
1366
+ const showControls = status === "recording" || status === "paused";
1367
+ return /* @__PURE__ */ jsxs(
1368
+ Box,
1369
+ {
1370
+ flexDirection: "column",
1371
+ borderStyle: "round",
1372
+ paddingX: 2,
1373
+ paddingY: 1,
1374
+ width: 44,
1375
+ children: [
1376
+ /* @__PURE__ */ jsx(Box, { justifyContent: "center", children: /* @__PURE__ */ jsxs(
1377
+ Text,
1378
+ {
1379
+ bold: true,
1380
+ color: statusColors[status],
1381
+ children: [
1382
+ statusLabels[status],
1383
+ " ",
1384
+ timeStr
1385
+ ]
1386
+ }
1387
+ ) }),
1388
+ statusMessage ? /* @__PURE__ */ jsx(Box, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "cyan", children: statusMessage }) }) : showControls ? /* @__PURE__ */ jsx(Box, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
1389
+ status === "recording" ? "[p] pause" : "[p] resume",
1390
+ " [s] save [c] cancel"
1391
+ ] }) }) : null
1392
+ ]
1393
+ }
1394
+ );
1395
+ };
1396
+ async function runRecorderTUI(capture, captureOpts, outputPath) {
1397
+ return new Promise((resolve) => {
1398
+ let result = null;
1399
+ const { waitUntilExit } = render(
1400
+ /* @__PURE__ */ jsx(
1401
+ RecorderUI,
1402
+ {
1403
+ capture,
1404
+ captureOpts: {
1405
+ ...captureOpts,
1406
+ outputPath: `${captureOpts.tmpDir}/segment-0001.wav`
1407
+ },
1408
+ outputPath,
1409
+ onResult: (r) => {
1410
+ result = r;
1411
+ }
1412
+ }
1413
+ ),
1414
+ { exitOnCtrlC: false }
1415
+ );
1416
+ waitUntilExit().then(() => {
1417
+ resolve(result ?? { cancelled: true, segments: [], totalDurationMs: 0 });
1418
+ });
1419
+ });
1420
+ }
1421
+
1422
+ // src/commands/record.ts
1423
+ async function resolveSource(opts, config, factory) {
1424
+ const requested = opts.source ?? config.audio.source;
1425
+ if (requested !== "auto") return requested;
1426
+ const sources = await factory.listSources();
1427
+ return pickAutoSource(sources);
1428
+ }
1429
+ async function runRecord(opts, ctx2) {
1430
+ const config = await loadConfig();
1431
+ const durationSec = opts.duration ? Number(opts.duration) : void 0;
1432
+ const headless = opts.tui === false || durationSec !== void 0 || ctx2.json || process.stdout.isTTY !== true;
1433
+ const outDir = opts.out ?? config.output.recordingDir;
1434
+ await mkdir(outDir, { recursive: true });
1435
+ if (headless) {
1436
+ await recordHeadless(opts, ctx2, config, outDir, durationSec);
1437
+ } else {
1438
+ await recordTui(opts, ctx2, config, outDir);
1439
+ }
1440
+ }
1441
+ async function recordHeadless(opts, ctx2, config, outDir, durationSec) {
1442
+ if (opts.mp3)
1443
+ ctx2.note(pc2.yellow(" --mp3 is ignored in headless mode; saving WAV.\n"));
1444
+ const filename = generateRecordingName({
1445
+ name: opts.name,
1446
+ prefix: config.output.namePrefix,
1447
+ ext: "wav"
1448
+ });
1449
+ const outputPath = buildOutputPath(outDir, filename);
1450
+ const factory = await getAudioFactory();
1451
+ const capture = factory.create();
1452
+ const source = await resolveSource(opts, config, factory);
1453
+ try {
1454
+ await capture.start({
1455
+ source,
1456
+ outputPath,
1457
+ sampleRate: 16e3,
1458
+ channels: 1,
1459
+ format: "wav"
1460
+ });
1461
+ } catch (err) {
1462
+ if (err instanceof RecmpError) throw err;
1463
+ throw new RecmpError(
1464
+ "AUDIO_START_FAILED",
1465
+ `Failed to start recording: ${err instanceof Error ? err.message : String(err)}`,
1466
+ 3
1467
+ );
1468
+ }
1469
+ ctx2.note(
1470
+ pc2.cyan(
1471
+ durationSec !== void 0 ? ` Recording for ${durationSec}s...
1472
+ ` : " Recording... press Ctrl+C to stop.\n"
1473
+ )
1474
+ );
1475
+ await new Promise((resolve) => {
1476
+ let done = false;
1477
+ const finish = () => {
1478
+ if (done) return;
1479
+ done = true;
1480
+ process.off("SIGINT", finish);
1481
+ if (timer) clearTimeout(timer);
1482
+ resolve();
1483
+ };
1484
+ const timer = durationSec !== void 0 ? setTimeout(finish, durationSec * 1e3) : null;
1485
+ process.on("SIGINT", finish);
1486
+ });
1487
+ const segment = await capture.stop();
1488
+ await capture.dispose().catch(() => {
1489
+ });
1490
+ ctx2.note(`${pc2.green("\u2713")} Saved: ${segment.path}
1491
+ `);
1492
+ const transcription = opts.transcribe ? await transcribeRecording(segment.path, opts, ctx2, config) : void 0;
1493
+ ctx2.ok(
1494
+ "record",
1495
+ {
1496
+ audioPath: segment.path,
1497
+ durationSec: segment.durationSec,
1498
+ sizeBytes: segment.sizeBytes,
1499
+ transcription
1500
+ },
1501
+ () => {
1502
+ if (transcription) process.stdout.write(`${transcription.text}
1503
+ `);
1504
+ }
1505
+ );
1506
+ }
1507
+ async function recordTui(opts, ctx2, config, outDir) {
1508
+ const ext = opts.mp3 ? "mp3" : "wav";
1509
+ const filename = generateRecordingName({
1510
+ name: opts.name,
1511
+ prefix: config.output.namePrefix,
1512
+ ext
1513
+ });
1514
+ const outputPath = buildOutputPath(outDir, filename);
1515
+ const sessionId = randomBytes(4).toString("hex");
1516
+ const tmpDir = join(tmpdir(), `recmp3-${sessionId}`);
1517
+ await mkdir(tmpDir, { recursive: true });
1518
+ const factory = await getAudioFactory();
1519
+ const capture = factory.create();
1520
+ const source = await resolveSource(opts, config, factory);
1521
+ const firstSegPath = join(tmpDir, "segment-0001.wav");
1522
+ try {
1523
+ await capture.start({
1524
+ source,
1525
+ outputPath: firstSegPath,
1526
+ sampleRate: 16e3,
1527
+ channels: 1,
1528
+ format: "wav"
1529
+ });
1530
+ } catch (err) {
1531
+ await rm(tmpDir, { recursive: true, force: true });
1532
+ if (err instanceof RecmpError) throw err;
1533
+ throw new RecmpError(
1534
+ "AUDIO_START_FAILED",
1535
+ `Failed to start recording: ${err instanceof Error ? err.message : String(err)}`,
1536
+ 3
1537
+ );
1538
+ }
1539
+ let result;
1540
+ try {
1541
+ result = await runRecorderTUI(
1542
+ capture,
1543
+ { source, sampleRate: 16e3, channels: 1, format: "wav", tmpDir },
1544
+ outputPath
1545
+ );
1546
+ } finally {
1547
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {
1548
+ });
1549
+ }
1550
+ if (result.cancelled || !result.outputPath) {
1551
+ ctx2.note(pc2.gray(" Recording cancelled.\n"));
1552
+ return;
1553
+ }
1554
+ process.stdout.write(`
1555
+ ${pc2.green("\u2713")} Saved: ${result.outputPath}
1556
+ `);
1557
+ if (opts.transcribe) {
1558
+ const transcription = await transcribeRecording(
1559
+ result.outputPath,
1560
+ opts,
1561
+ ctx2,
1562
+ config
1563
+ );
1564
+ const shouldPrint = opts.print !== false && config.ui.printOnTranscribe;
1565
+ if (shouldPrint) process.stdout.write(`
1566
+ ${transcription.text}
1567
+
1568
+ `);
1569
+ }
1570
+ }
1571
+ async function transcribeRecording(audioPath, opts, ctx2, config) {
1572
+ const providerConfig = { ...config };
1573
+ if (opts.provider) {
1574
+ providerConfig.provider.default = opts.provider;
1575
+ }
1576
+ if (providerUploads(providerConfig.provider.default)) {
1577
+ await ensureUploadConsent(ctx2);
1578
+ }
1579
+ ctx2.note(pc2.cyan(" Transcribing...\n"));
1580
+ const provider = await createProvider(providerConfig);
1581
+ const transcription = await transcribeWithChunking(
1582
+ provider,
1583
+ {
1584
+ audioPath,
1585
+ language: opts.lang ?? config.transcription.defaultLanguage,
1586
+ responseFormat: "verbose_json"
1587
+ },
1588
+ config.transcription.chunking.chunkSeconds
1589
+ );
1590
+ let transcriptPath2;
1591
+ if (config.output.saveTranscriptToFile) {
1592
+ const { txtPath } = await writeTranscriptFiles(audioPath, transcription);
1593
+ transcriptPath2 = txtPath;
1594
+ ctx2.note(`${pc2.green("\u2713")} Transcript: ${txtPath}
1595
+ `);
1596
+ }
1597
+ const shouldCopy = opts.copy !== false && config.ui.clipboardOnTranscribe;
1598
+ if (shouldCopy) {
1599
+ const copied = await copyToClipboard(transcription.text);
1600
+ if (copied) ctx2.note(pc2.gray(" Copied to clipboard.\n"));
1601
+ }
1602
+ ctx2.note(
1603
+ pc2.gray(
1604
+ ` Provider: ${transcription.provider} \xB7 Model: ${transcription.model} \xB7 ${(transcription.latencyMs / 1e3).toFixed(1)}s
1605
+ `
1606
+ )
1607
+ );
1608
+ return {
1609
+ text: transcription.text,
1610
+ provider: transcription.provider,
1611
+ model: transcription.model,
1612
+ language: transcription.language,
1613
+ durationSec: transcription.durationSec,
1614
+ latencyMs: transcription.latencyMs,
1615
+ segments: transcription.segments,
1616
+ transcriptPath: transcriptPath2
1617
+ };
1618
+ }
1619
+ async function runSources(ctx2) {
1620
+ let sources = [];
1621
+ try {
1622
+ const factory = await getAudioFactory();
1623
+ sources = await factory.listSources();
1624
+ } catch (err) {
1625
+ throw err instanceof RecmpError ? err : new RecmpError(
1626
+ "AUDIO_CAPTURE_ERROR",
1627
+ `Failed to list audio sources: ${err instanceof Error ? err.message : String(err)}. Make sure ffmpeg is installed.`,
1628
+ 3
1629
+ );
1630
+ }
1631
+ const platform = process.platform;
1632
+ const recommended = sources.length ? pickAutoSource(sources) : "default";
1633
+ ctx2.ok("sources", { platform, sources, recommended }, () => {
1634
+ const platformLabels = {
1635
+ linux: "Linux (PulseAudio/PipeWire)",
1636
+ darwin: "macOS (AVFoundation)",
1637
+ win32: "Windows (DirectShow)"
1638
+ };
1639
+ const platformLabel = platformLabels[platform] ?? platform;
1640
+ console.log(`
1641
+ ${pc2.bold("Audio sources")} on ${platformLabel}:
1642
+ `);
1643
+ if (sources.length === 0) {
1644
+ console.log(
1645
+ pc2.yellow(" No audio sources found. Check your audio hardware.")
1646
+ );
1647
+ return;
1648
+ }
1649
+ for (const source of sources) {
1650
+ const markers = [
1651
+ source.isDefault ? pc2.green(" (default)") : "",
1652
+ source.id === recommended ? pc2.yellow(" (recommended)") : ""
1653
+ ].join("");
1654
+ const id = pc2.cyan(source.id);
1655
+ const label = source.label !== source.id ? pc2.gray(` \u2014 ${source.label}`) : "";
1656
+ console.log(` ${id}${label}${markers}`);
1657
+ }
1658
+ console.log(
1659
+ `
1660
+ ${pc2.gray(" Best mic:")} ${pc2.cyan("recmp3 record --source auto")}`
1661
+ );
1662
+ console.log(`${pc2.gray(" Specific:")} recmp3 record --source <id>`);
1663
+ console.log(
1664
+ pc2.gray(" Or set: RECMP3_SOURCE=<id> in your environment\n")
1665
+ );
1666
+ });
1667
+ }
1668
+ async function runTranscribe(audioFile, opts, ctx2) {
1669
+ let path = audioFile;
1670
+ let tmpFromStdin = null;
1671
+ if (audioFile === "-") {
1672
+ const buf = await readStdinBuffer();
1673
+ if (buf.length === 0) throw new InputError("No audio received on stdin.");
1674
+ const dir = await mkdtemp(join(tmpdir(), "recmp3-stdin-"));
1675
+ tmpFromStdin = join(dir, "input.wav");
1676
+ await writeFile(tmpFromStdin, buf);
1677
+ path = tmpFromStdin;
1678
+ } else if (!existsSync(audioFile)) {
1679
+ throw new InputError(`File not found: ${audioFile}`);
1680
+ }
1681
+ try {
1682
+ const config = await loadConfig();
1683
+ if (opts.provider) {
1684
+ config.provider.default = opts.provider;
1685
+ }
1686
+ if (providerUploads(config.provider.default)) {
1687
+ await ensureUploadConsent(ctx2);
1688
+ }
1689
+ const provider = await createProvider(config);
1690
+ ctx2.note(pc2.cyan(` Transcribing with ${provider.name}...
1691
+ `));
1692
+ const result = await transcribeWithChunking(
1693
+ provider,
1694
+ {
1695
+ audioPath: path,
1696
+ language: opts.lang ?? config.transcription.defaultLanguage,
1697
+ responseFormat: "verbose_json"
1698
+ },
1699
+ config.transcription.chunking.chunkSeconds
1700
+ );
1701
+ let transcriptPath2;
1702
+ if (config.output.saveTranscriptToFile && !tmpFromStdin) {
1703
+ const { txtPath } = await writeTranscriptFiles(audioFile, result);
1704
+ transcriptPath2 = txtPath;
1705
+ ctx2.note(`${pc2.green("\u2713")} Transcript saved: ${txtPath}
1706
+ `);
1707
+ }
1708
+ if (opts.copy) {
1709
+ const copied = await copyToClipboard(result.text);
1710
+ if (copied) ctx2.note(pc2.gray(" Copied to clipboard.\n"));
1711
+ }
1712
+ ctx2.note(
1713
+ pc2.gray(
1714
+ ` ${provider.name} \xB7 ${result.model} \xB7 ${(result.latencyMs / 1e3).toFixed(1)}s
1715
+ `
1716
+ )
1717
+ );
1718
+ ctx2.ok(
1719
+ "transcribe",
1720
+ {
1721
+ text: result.text,
1722
+ provider: result.provider,
1723
+ model: result.model,
1724
+ language: result.language,
1725
+ durationSec: result.durationSec,
1726
+ latencyMs: result.latencyMs,
1727
+ segments: result.segments,
1728
+ transcriptPath: transcriptPath2
1729
+ },
1730
+ // Human mode: transcript text on stdout (pipeable), nothing else.
1731
+ () => process.stdout.write(`${result.text}
1732
+ `)
1733
+ );
1734
+ } finally {
1735
+ if (tmpFromStdin) {
1736
+ await rm(join(tmpFromStdin, ".."), {
1737
+ recursive: true,
1738
+ force: true
1739
+ }).catch(() => {
1740
+ });
1741
+ }
1742
+ }
1743
+ }
1744
+
1745
+ // src/agent/mcp.ts
1746
+ async function callCommand(run, args) {
1747
+ const ctx2 = AgentContext.forCapture();
1748
+ const sink = ctx2.sink;
1749
+ try {
1750
+ await run(args, ctx2);
1751
+ } catch (err) {
1752
+ ctx2.fail(err, "mcp");
1753
+ }
1754
+ const envelope = sink.envelope;
1755
+ return {
1756
+ isError: envelope?.ok === false,
1757
+ content: [
1758
+ { type: "text", text: JSON.stringify(envelope, null, 2) }
1759
+ ]
1760
+ };
1761
+ }
1762
+ function descriptionFor(tool) {
1763
+ return MANIFEST.commands.find((c) => c.tool === tool)?.summary ?? "";
1764
+ }
1765
+ function register(server, tool, inputSchema, run) {
1766
+ server.registerTool(
1767
+ tool,
1768
+ { description: descriptionFor(tool), inputSchema },
1769
+ async (args) => callCommand(run, args ?? {})
1770
+ );
1771
+ }
1772
+ async function runMcpServer() {
1773
+ const server = new McpServer({
1774
+ name: MANIFEST.name,
1775
+ version: MANIFEST.version
1776
+ });
1777
+ register(
1778
+ server,
1779
+ "recmp3_transcribe",
1780
+ {
1781
+ file: z.string().describe("Audio file path (must exist on the server host)"),
1782
+ provider: z.string().optional(),
1783
+ lang: z.string().optional()
1784
+ },
1785
+ (a, ctx2) => runTranscribe(
1786
+ a.file,
1787
+ { provider: a.provider, lang: a.lang },
1788
+ ctx2
1789
+ )
1790
+ );
1791
+ register(
1792
+ server,
1793
+ "recmp3_prompt",
1794
+ {
1795
+ file: z.string().describe('Transcript file path, or "-" for stdin'),
1796
+ template: z.string().optional()
1797
+ },
1798
+ (a, ctx2) => runPrompt(a.file, { template: a.template }, ctx2)
1799
+ );
1800
+ register(server, "recmp3_sources", {}, (_a, ctx2) => runSources(ctx2));
1801
+ register(server, "recmp3_doctor", {}, (_a, ctx2) => runDoctor(ctx2));
1802
+ register(server, "recmp3_config_show", {}, (_a, ctx2) => runConfigShow(ctx2));
1803
+ register(server, "recmp3_manifest", {}, (_a, ctx2) => runManifest(ctx2));
1804
+ register(
1805
+ server,
1806
+ "recmp3_record",
1807
+ {
1808
+ duration: z.number().describe("Seconds to record (headless)"),
1809
+ name: z.string().optional(),
1810
+ out: z.string().optional(),
1811
+ transcribe: z.boolean().optional(),
1812
+ provider: z.string().optional(),
1813
+ lang: z.string().optional(),
1814
+ source: z.string().optional().describe('Audio source id, or "auto" for the best physical mic')
1815
+ },
1816
+ (a, ctx2) => runRecord(
1817
+ {
1818
+ duration: String(a.duration),
1819
+ name: a.name,
1820
+ out: a.out,
1821
+ transcribe: a.transcribe,
1822
+ provider: a.provider,
1823
+ lang: a.lang,
1824
+ source: a.source
1825
+ },
1826
+ ctx2
1827
+ )
1828
+ );
1829
+ let closing = false;
1830
+ const shutdown = async () => {
1831
+ if (closing) return;
1832
+ closing = true;
1833
+ try {
1834
+ await server.close();
1835
+ } catch {
1836
+ }
1837
+ process.exit(0);
1838
+ };
1839
+ process.once("SIGTERM", shutdown);
1840
+ process.once("SIGINT", shutdown);
1841
+ process.stdin.once("end", shutdown);
1842
+ const transport = new StdioServerTransport();
1843
+ await server.connect(transport);
1844
+ }
1845
+
1846
+ // src/index.ts
1847
+ var VERSION = "1.0.0";
1848
+ var program = new Command();
1849
+ var ctx = new AgentContext();
1850
+ program.name("recmp3").description(
1851
+ "Record audio, transcribe with AI, output developer-ready prompts."
1852
+ ).version(VERSION, "-v, --version").option(
1853
+ "--json",
1854
+ "Emit a stable machine-readable JSON envelope on stdout",
1855
+ false
1856
+ ).option("-y, --yes", "Skip all interactive prompts (consent, setup)", false).option("--quiet", "Suppress progress/diagnostic output on stderr", false).option("--no-color", "Disable colored output").option("--debug", "Enable debug output", false).option("--verbose", "Enable verbose output", false).hook("preAction", (_thisCommand, actionCommand) => {
1857
+ const opts = actionCommand.optsWithGlobals();
1858
+ initLogger({ debug: opts.debug, verbose: opts.verbose });
1859
+ ctx = AgentContext.fromGlobals(opts);
1860
+ });
1861
+ program.action(async () => {
1862
+ await handleError("record", () => runRecord({}, ctx));
1863
+ });
1864
+ program.command("record").description("Record audio from your microphone").option("-n, --name <name>", 'Output filename stem (e.g. "my-idea")').option("-o, --out <dir>", "Output directory").option("-t, --transcribe", "Transcribe immediately after recording").option("--mp3", "Save as MP3 instead of WAV (post-processing)").option(
1865
+ "--provider <name>",
1866
+ "Override transcription provider (groq, openai, local-whisper)"
1867
+ ).option("--lang <code>", "Force language code (e.g. es, en)").option(
1868
+ "--source <id>",
1869
+ 'Audio source id, or "auto" to pick the best physical mic'
1870
+ ).option(
1871
+ "--duration <seconds>",
1872
+ "Headless: record for N seconds then stop (no TUI)"
1873
+ ).option(
1874
+ "--no-tui",
1875
+ "Force headless capture (record until SIGINT or --duration)"
1876
+ ).option(
1877
+ "--copy",
1878
+ "Copy transcript to clipboard (default: on with --transcribe)"
1879
+ ).option("--no-copy", "Do not copy transcript to clipboard").option(
1880
+ "--print",
1881
+ "Print transcript to stdout (default: on with --transcribe)"
1882
+ ).option("--no-print", "Do not print transcript to stdout").action(async (opts) => {
1883
+ await handleError("record", () => runRecord(opts, ctx));
1884
+ });
1885
+ program.command("transcribe <file>").description('Transcribe an existing audio file ("-" reads audio from stdin)').option(
1886
+ "--provider <name>",
1887
+ "Override provider (groq, openai, local-whisper)"
1888
+ ).option("--lang <code>", "Force language code").option("--copy", "Copy transcript to clipboard").action(async (file, opts) => {
1889
+ await handleError("transcribe", () => runTranscribe(file, opts, ctx));
1890
+ });
1891
+ program.command("sources").description("List available audio input sources for your OS").action(async () => {
1892
+ await handleError("sources", () => runSources(ctx));
1893
+ });
1894
+ var configCmd = program.command("config").description("Manage recmp3 configuration");
1895
+ configCmd.command("init").description(
1896
+ "First-time setup (interactive, or flag-driven when non-interactive)"
1897
+ ).option("--provider <name>", "Provider (groq, openai, local-whisper)").option("--lang <code>", "Default language code").option("--outdir <dir>", "Recordings output directory").option("--key <value>", "API key to store in the OS keychain").action(async (opts) => {
1898
+ await handleError("config init", () => runConfigInit(opts, ctx));
1899
+ });
1900
+ configCmd.command("show").description("Show resolved configuration (API keys redacted)").action(async () => {
1901
+ await handleError("config show", () => runConfigShow(ctx));
1902
+ });
1903
+ configCmd.command("path").description("Print path to config file").action(async () => {
1904
+ await handleError("config path", () => runConfigPath(ctx));
1905
+ });
1906
+ configCmd.command("set <key> <value>").description("Set a config value (e.g. provider.default groq)").action(async (key, value) => {
1907
+ await handleError("config set", () => runConfigSet(key, value, ctx));
1908
+ });
1909
+ configCmd.command("set-key <provider>").description(
1910
+ "Store an API key in the OS keychain (value from --key, stdin, or env)"
1911
+ ).option(
1912
+ "--key <value>",
1913
+ "API key value (otherwise read from stdin or *_API_KEY env)"
1914
+ ).action(async (provider, opts) => {
1915
+ await handleError(
1916
+ "config set-key",
1917
+ () => runConfigSetKey(provider, opts, ctx)
1918
+ );
1919
+ });
1920
+ program.command("doctor").description("Run preflight checks to verify your setup").action(async () => {
1921
+ await handleError("doctor", () => runDoctor(ctx));
1922
+ });
1923
+ program.command("prompt <file>").description(
1924
+ 'Wrap a transcript file in a prompt template ("-" reads from stdin)'
1925
+ ).option(
1926
+ "-t, --template <name>",
1927
+ "Template name (claude-code, prd, bug, todo, meeting-notes, commit-message, raw)",
1928
+ "claude-code"
1929
+ ).option("--copy", "Copy output to clipboard").option("--out <file>", "Write output to a file").option("--list-templates", "List available templates").action(async (file, opts) => {
1930
+ if (opts.listTemplates) {
1931
+ listTemplates();
1932
+ return;
1933
+ }
1934
+ await handleError("prompt", () => runPrompt(file, opts, ctx));
1935
+ });
1936
+ program.command("manifest").description("Print the command/tool manifest (use --json for machine form)").action(async () => {
1937
+ await handleError("manifest", () => runManifest(ctx));
1938
+ });
1939
+ program.command("mcp").description("Start the Model Context Protocol server over stdio").action(async () => {
1940
+ await runMcpServer();
1941
+ });
1942
+ async function handleError(command, fn) {
1943
+ try {
1944
+ await fn();
1945
+ } catch (err) {
1946
+ const payload = toErrorPayload(err);
1947
+ ctx.fail(err, command);
1948
+ if (process.env.RECMP3_DEBUG && err instanceof Error) {
1949
+ process.stderr.write(`${err.stack ?? ""}
1950
+ `);
1951
+ }
1952
+ process.exit(payload.exitCode);
1953
+ }
1954
+ }
1955
+ program.parseAsync(process.argv).catch((err) => {
1956
+ const payload = toErrorPayload(err);
1957
+ process.stderr.write(`${pc2.red("\u2717")} ${payload.message}
1958
+ `);
1959
+ process.exit(err instanceof RecmpError ? err.exitCode : ExitCode.UNKNOWN);
1960
+ });