From 16a7c04e81f257db6eb5e65a3f3c095c3a70f39e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 20:32:41 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20agent-native=20retrofit=20(v0.2.0)?= =?UTF-8?q?=20=E2=80=94=20JSON=20mode,=20MCP=20server,=20local=20Whisper,?= =?UTF-8?q?=20keychain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-breaking dual interface so every human TUI flow has a fully non-interactive equivalent for terminal AI agents and scripts. - Agent layer (src/agent): AgentContext + OutputSink (Human/Json/Capture), stable versioned JSON envelope, global --json/--yes/--quiet/--no-color - Stable POSIX exit-code contract in src/errors.ts (adds 6=local-whisper, 7=input) - stdin/stdout composability: transcribe - and prompt - read from stdin - Headless record (--duration/--no-tui), non-interactive config init - recmp3 manifest (discoverable surface) + recmp3 mcp (stdio MCP server) - OS keychain key storage via keytar + config set-key (env still wins) - LocalWhisperProvider (whisper.cpp, no upload) behind TranscriptionProvider - Expanded vitest+msw suite (43 tests); CI test step + OS matrix; release workflow - Fix package.json license MIT -> AGPL-3.0-or-later; docs/AGENTS.md guide https://claude.ai/code/session_01VXWFjbi3ALuvCLVeHdiB79 --- .env.example | 13 +- .github/CODEOWNERS | 2 + .github/workflows/ci.yml | 23 +- .github/workflows/release.yml | 38 ++ .gitignore | 3 + AI-RULES.md | 11 +- CHANGELOG.md | 26 + README.md | 66 +- TODO.md | 22 +- biome.json | 3 + docs/AGENTS.md | 99 +++ package-lock.json | 1026 +++++++++++++++++++++++++++- package.json | 21 +- src/agent/context.ts | 84 +++ src/agent/manifest.ts | 268 ++++++++ src/agent/mcp.ts | 125 ++++ src/agent/output.ts | 117 ++++ src/agent/stdin.ts | 13 + src/audio/capture.ts | 6 +- src/audio/concat.ts | 70 +- src/audio/ffmpeg.ts | 9 +- src/audio/linux-pulse.ts | 73 +- src/audio/mac-avfoundation.ts | 99 ++- src/audio/windows-dshow.ts | 111 ++- src/commands/config.ts | 323 ++++++--- src/commands/doctor.ts | 169 +++-- src/commands/manifest.ts | 8 + src/commands/prompt.ts | 66 +- src/commands/record.ts | 283 ++++++-- src/commands/sources.ts | 73 +- src/commands/transcribe.ts | 120 ++-- src/config/load.ts | 52 +- src/config/paths.ts | 14 +- src/config/schema.ts | 9 +- src/consent.ts | 35 +- src/errors.ts | 43 +- src/index.ts | 190 ++++-- src/log.ts | 8 +- src/output/clipboard.ts | 3 +- src/output/filenames.ts | 2 +- src/output/writer.ts | 10 +- src/secrets/keychain.ts | 72 ++ src/transcription/chunking.ts | 41 +- src/transcription/groq.ts | 54 +- src/transcription/local-whisper.ts | 126 ++++ src/transcription/openai.ts | 72 +- src/transcription/registry.ts | 36 +- src/transcription/whisper-bin.ts | 57 ++ src/tui/recorder.tsx | 115 +++- test/unit/config.test.ts | 8 + test/unit/keychain.test.ts | 51 ++ test/unit/local-whisper.test.ts | 38 ++ test/unit/manifest.test.ts | 37 + test/unit/mcp.test.ts | 27 + test/unit/output-envelope.test.ts | 63 ++ test/unit/prompt-stdin.test.ts | 47 ++ vitest.config.ts | 8 + 57 files changed, 3933 insertions(+), 655 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/release.yml create mode 100644 docs/AGENTS.md create mode 100644 src/agent/context.ts create mode 100644 src/agent/manifest.ts create mode 100644 src/agent/mcp.ts create mode 100644 src/agent/output.ts create mode 100644 src/agent/stdin.ts create mode 100644 src/commands/manifest.ts create mode 100644 src/secrets/keychain.ts create mode 100644 src/transcription/local-whisper.ts create mode 100644 src/transcription/whisper-bin.ts create mode 100644 test/unit/keychain.test.ts create mode 100644 test/unit/local-whisper.test.ts create mode 100644 test/unit/manifest.test.ts create mode 100644 test/unit/mcp.test.ts create mode 100644 test/unit/output-envelope.test.ts create mode 100644 test/unit/prompt-stdin.test.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index 21aa5df..340f4a8 100644 --- a/.env.example +++ b/.env.example @@ -6,12 +6,21 @@ GROQ_API_KEY= OPENAI_API_KEY= # ── Optional overrides ──────────────────────────────────────────────────────── -# RECMP3_PROVIDER=groq +# RECMP3_PROVIDER=groq # groq | openai | local-whisper # RECMP3_MODEL=whisper-large-v3-turbo # RECMP3_SOURCE=default # RECMP3_LANG= # RECMP3_OUTDIR= -# RECMP3_SKIP_INIT=1 + +# ── Local Whisper (no upload) ───────────────────────────────────────────────── +# RECMP3_WHISPER_BIN=/usr/local/bin/whisper-cli +# RECMP3_WHISPER_MODEL=/models/ggml-base.en.bin + +# ── Agent / scripting mode ──────────────────────────────────────────────────── +# RECMP3_JSON=1 # always emit JSON envelopes +# RECMP3_YES=1 # skip all prompts (consent, setup) +# RECMP3_QUIET=1 # suppress stderr chatter +# RECMP3_SKIP_CONSENT=1 # legacy alias for skipping upload consent # RECMP3_PLAIN=1 # ── Dev / Debug ─────────────────────────────────────────────────────────────── diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..79db925 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owner for everything in this repo +* @aedneth diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6972c..ab3b06b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,20 +2,26 @@ name: CI on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ['20', '22'] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ matrix.node }} cache: 'npm' - name: Install dependencies @@ -24,8 +30,11 @@ jobs: - name: Type check run: npm run typecheck - - name: Build - run: npm run build - - name: Lint run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5c5f205 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 4be9563..1579e65 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ coverage/ # Per-project CKIS second brain (personal tooling — not part of the public repo) .brain/ + +# Private architecture planning — strip before public merges +docs/planning/ diff --git a/AI-RULES.md b/AI-RULES.md index 99c0025..bd07f0a 100644 --- a/AI-RULES.md +++ b/AI-RULES.md @@ -6,7 +6,7 @@ Rules for AI agents (Claude Code, Codex, Gemini CLI, etc.) working in this repo. - **ESM only.** `"type": "module"` in package.json. All imports must use `.js` extensions. No CommonJS `require()`. - **Node.js 20+.** Use native `fetch`, `FormData`, `Blob`, `AbortSignal.timeout()` — no polyfills. -- **No new runtime dependencies** without explicit approval. Current dep count is intentionally minimal. +- **No new runtime dependencies** without explicit approval. Current dep count is intentionally minimal. (`@modelcontextprotocol/sdk` was approved for the MCP server; `keytar` is an optional, lazily-loaded native dep.) - **tsup is the bundler.** Do not introduce webpack, rollup, esbuild directly, or vite. - **No `any`.** TypeScript strict mode is on. Use `unknown` and narrow — do not cast to `any`. @@ -20,7 +20,14 @@ Platform audio backends live in `src/audio/`. `getAudioFactory()` in `src/audio/ ## Error handling -All user-facing errors go through the typed error classes in `src/errors.ts`. Each class has a specific `exitCode`. Don't `process.exit()` directly from business logic — throw the appropriate error class and let `handleError()` in `src/index.ts` catch it. +All user-facing errors go through the typed error classes in `src/errors.ts`. Each class has a specific `exitCode` (see the exported `ExitCode` map). Don't `process.exit()` directly from business logic — throw the appropriate error class and let `handleError()` in `src/index.ts` catch it. + +## Agent-native layer (do not break) + +- Every command receives an `AgentContext` (`src/agent/context.ts`) and emits results via `ctx.ok(command, payload, humanRender?)` — never write results straight to stdout. Human-only chatter goes through `ctx.note()` (stderr, suppressed by `--quiet`). +- The JSON envelope shape in `src/agent/output.ts` and per-command `data` payloads are a public contract. Bump `SCHEMA_VERSION` on any breaking change. +- `src/agent/manifest.ts` is the single source of truth for the command/tool surface; the MCP server (`src/agent/mcp.ts`) derives tools from it. Keep them in sync. +- Local providers must not trigger upload consent — gate `ensureUploadConsent()` behind `providerUploads()`. ## Security rules diff --git a/CHANGELOG.md b/CHANGELOG.md index ec72735..6f96d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/). +## [0.2.0] — 2026-05-31 + +### Added + +- **Agent-native layer** — every command works fully non-interactively for AI agents and scripts: + - Global `--json` flag (and `RECMP3_JSON=1`) emits a stable, versioned JSON envelope on stdout. + - Global `--yes` (and `RECMP3_YES=1`) skips all prompts; global `--quiet` and `--no-color`. + - Documented, deterministic POSIX exit-code contract (0/1/2/3/4/5/6/7/130). + - `stdin`/`stdout` composability: `transcribe -` reads audio from stdin, `prompt -` reads text from stdin. +- `recmp3 manifest` — discoverable command/tool manifest (`--json` for machine form). +- `recmp3 mcp` — Model Context Protocol server over stdio exposing `recmp3_*` tools. +- Headless recording — `recmp3 record --duration ` / `--no-tui` capture without the Ink TUI. +- Non-interactive `recmp3 config init` (flag-driven) and new `recmp3 config set-key` (OS keychain). +- **Local Whisper backend** — `--provider local-whisper` runs a whisper.cpp binary with no upload. +- **OS keychain key storage** via `keytar` (env vars still take precedence; graceful fallback). +- Expanded vitest + msw test suite; CI now runs tests on an OS matrix (Linux/macOS/Windows × Node 20/22). +- `release.yml` workflow to publish to npm on `v*` tags; `docs/AGENTS.md` agent-integration guide. + +### Fixed + +- `package.json` license corrected from `MIT` to `AGPL-3.0-or-later` to match the LICENSE file. + +### Changed + +- `getApiKey` and provider construction are now async (keychain-aware). Internal only; CLI behavior unchanged. + ## [0.1.0] — 2026-05-10 ### Added diff --git a/README.md b/README.md index bef6b39..953eab8 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ [![CI](https://github.com/aedneth/recmp3-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/aedneth/recmp3-cli/actions/workflows/ci.yml) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -[![version](https://img.shields.io/badge/version-0.1.0-blue)](https://github.com/aedneth/recmp3-cli/releases) +[![version](https://img.shields.io/badge/version-0.2.0-blue)](https://github.com/aedneth/recmp3-cli/releases) Record audio from any terminal, transcribe with Groq Whisper, get developer-ready output. +A first-class tool for **both humans and terminal AI agents** — every interactive flow has a +fully non-interactive, JSON-emitting equivalent, plus a built-in MCP server. ``` recmp3 record --name "my standup" @@ -17,6 +19,8 @@ recmp3 prompt standup.wav --template claude-code | pbcopy - **Transcribes** via Groq `whisper-large-v3-turbo` (or OpenAI Whisper) - **Formats** output with 7 developer templates: `claude-code`, `prd`, `bug`, `meeting-notes`, `todo`, `commit-message`, `raw` - **Cross-platform:** Linux (PulseAudio/PipeWire), macOS (AVFoundation), Windows (DirectShow) +- **Agent-native:** global `--json` envelopes, `--yes`, deterministic exit codes, stdin/stdout piping, a discoverable `manifest`, and an MCP server — see [Agent & scripting use](#agent--scripting-use) +- **Local option:** `--provider local-whisper` transcribes on-device via whisper.cpp (no upload) ## Requirements @@ -105,10 +109,52 @@ Run 8 system checks: Node version, platform support, ffmpeg version, audio backe ### `recmp3 config` ```bash -recmp3 config init # Interactive setup wizard +recmp3 config init # Setup (interactive, or flag-driven: --provider/--lang/--outdir/--key) recmp3 config show # Display current config (API key redacted) recmp3 config path # Print config file path -recmp3 config set # Set a config key +recmp3 config set # Set a config key +recmp3 config set-key groq --key gsk_... # Store an API key in the OS keychain +``` + +## Agent & scripting use + +Every command is usable by AI agents (Claude Code, Codex, Gemini CLI, …) and shell scripts +with no TTY and no prompts. See [`docs/AGENTS.md`](docs/AGENTS.md) for the full reference. + +```bash +# Stable JSON envelope on stdout; chatter on stderr +recmp3 transcribe meeting.wav --json --yes | jq -r .data.text + +# Compose via pipes: transcribe → template +recmp3 transcribe meeting.wav --json --yes | jq -r .data.text | recmp3 prompt - --template prd + +# Headless recording (no Ink TUI) +recmp3 record --duration 5 --json --yes + +# Discover the command/tool surface +recmp3 manifest --json +``` + +**Exit codes:** `0` success · `1` unknown · `2` config · `3` audio/ffmpeg · `4` transcription · +`5` network · `6` local-whisper · `7` input · `130` user abort. + +### MCP server + +`recmp3` ships a Model Context Protocol server over stdio. Register it with any MCP client: + +```jsonc +{ "mcpServers": { "recmp3": { "command": "recmp3", "args": ["mcp"] } } } +``` + +Tools: `recmp3_transcribe`, `recmp3_prompt`, `recmp3_sources`, `recmp3_doctor`, +`recmp3_config_show`, `recmp3_record`, `recmp3_manifest`. + +### Local, no-upload transcription + +```bash +export RECMP3_WHISPER_BIN=/usr/local/bin/whisper-cli # whisper.cpp binary +export RECMP3_WHISPER_MODEL=/models/ggml-base.en.bin +recmp3 transcribe clip.wav --provider local-whisper --json ``` ## Use Cases @@ -146,14 +192,20 @@ Environment variables override config file values: | `RECMP3_FFMPEG_PATH` | Path to ffmpeg binary | | `RECMP3_OUTDIR` | Default recordings output directory | | `RECMP3_LANG` | Default language hint (e.g. `es`, `en`) | +| `RECMP3_WHISPER_BIN` | Path to a whisper.cpp binary (for `local-whisper`) | +| `RECMP3_WHISPER_MODEL` | Path to a GGML model file (for `local-whisper`) | +| `RECMP3_JSON` | `1` to always emit JSON envelopes | +| `RECMP3_YES` | `1` to skip all prompts | +| `RECMP3_QUIET` | `1` to suppress stderr chatter | | `RECMP3_SKIP_CONSENT` | `1` to skip upload consent prompt | ## Providers -| Provider | Default model | Max file size | -|---|---|---| -| Groq | `whisper-large-v3-turbo` | 25 MB | -| OpenAI | `whisper-1` | 25 MB | +| Provider | Default model | Max file size | Upload | +|---|---|---|---| +| Groq | `whisper-large-v3-turbo` | 25 MB | yes | +| OpenAI | `whisper-1` | 25 MB | yes | +| local-whisper | whisper.cpp GGML model | unlimited | **no (on-device)** | Audio is captured as WAV 16kHz mono (~1 MB/min), so the 25 MB limit covers ~25 minutes per recording. Longer recordings are chunked automatically. diff --git a/TODO.md b/TODO.md index a676229..b94733b 100644 --- a/TODO.md +++ b/TODO.md @@ -29,15 +29,21 @@ - [x] CKIS documentation: `02-projects/recmp3-cli/_overview.md` created - [x] CKIS documentation: `04-resources/tools/pop-os-audio-mp3-ffmpeg.md` superseded -## v0.2.0 +## v0.2.0 — agent-native retrofit -- [ ] OS keychain storage via `keytar` (API keys currently env-var only) -- [ ] `recmp3 config init` — store key in keychain instead of instructing user to set env var -- [ ] Local Whisper backend (no upload) — add `LocalWhisperProvider` class -- [ ] `recmp3 sources` — auto-detect physical mic vs `default` virtual device (mark physical as recommended) -- [ ] Integration test: full record → transcribe → prompt pipeline with mock ffmpeg -- [ ] `recmp3 record --watch` — continuous recording mode (auto-split by silence) -- [ ] Publish to npm +- [x] Agent-native layer: global `--json`/`--yes`/`--quiet`, stable JSON envelope, exit-code contract +- [x] stdin/stdout composability (`transcribe -`, `prompt -`) +- [x] `recmp3 manifest` (discoverable command/tool surface) +- [x] `recmp3 mcp` (Model Context Protocol stdio server) +- [x] Headless `recmp3 record --duration` / `--no-tui` +- [x] OS keychain storage via `keytar` + `recmp3 config set-key` +- [x] Non-interactive `recmp3 config init` (flag-driven) +- [x] Local Whisper backend (`LocalWhisperProvider`, whisper.cpp, no upload) +- [x] Expanded vitest + msw suite; CI test step + OS matrix; npm release workflow +- [x] License fixed to AGPL-3.0-or-later in package.json +- [ ] `recmp3 sources` — auto-detect physical mic vs `default` virtual device (moved to v0.3.0) +- [ ] `recmp3 record --watch` — continuous recording mode (moved to v0.3.0) +- [ ] Publish to npm (run the release workflow by tagging `v0.2.0`) ## Backlog / ideas diff --git a/biome.json b/biome.json index 9d840b5..bb56a97 100644 --- a/biome.json +++ b/biome.json @@ -9,6 +9,9 @@ "recommended": true, "suspicious": { "noExplicitAny": "warn" + }, + "style": { + "noNonNullAssertion": "warn" } } }, diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..e4c1271 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,99 @@ +# Using recmp3 from AI agents + +`recmp3` is a first-class tool for terminal AI agents (Claude Code, Codex, Gemini CLI, +Hermes, OpenClaw, OpenCode, DeepSeek) as well as plain shell scripts. Every interactive +flow has a fully non-interactive equivalent: no command requires a TTY in agent mode. + +## The three things every agent needs + +1. **`--json`** — emit a stable JSON envelope on stdout (or set `RECMP3_JSON=1`). +2. **`--yes`** — skip every prompt, including upload consent (or set `RECMP3_YES=1`). +3. **Deterministic exit codes** — see the table below. + +## JSON envelope + +Every command emits exactly one envelope on stdout in `--json` mode: + +```jsonc +// success +{ "ok": true, "command": "transcribe", "schemaVersion": 1, "data": { /* ... */ } } +// failure +{ "ok": false, "command": "transcribe", "schemaVersion": 1, + "error": { "code": "INPUT_ERROR", "message": "File not found: x.wav", "exitCode": 7 } } +``` + +Progress/diagnostic text goes to **stderr** (suppress it with `--quiet`); results go to +**stdout**, so pipes stay clean. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | unknown / unexpected error | +| 2 | config / usage error | +| 3 | audio capture / ffmpeg | +| 4 | transcription (cloud provider) | +| 5 | network | +| 6 | local whisper (binary/model) | +| 7 | input / file-not-found / bad argument | +| 130 | user abort (SIGINT) | + +## Discovery + +```bash +recmp3 manifest --json # full command/tool surface, flags, exit codes +``` + +## Composability (stdin/stdout) + +```bash +# transcribe → template, fully piped +recmp3 transcribe meeting.wav --json --yes \ + | jq -r .data.text \ + | recmp3 prompt - --template prd + +# pipe audio bytes in, transcript text out +cat clip.wav | recmp3 transcribe - --yes +``` + +## Headless recording + +The Ink TUI is replaced by a headless capture path whenever stdout is not a TTY, when +`--json` is set, or when `--duration` is given: + +```bash +recmp3 record --duration 5 --json --yes # record 5s, emit envelope +``` + +## MCP server + +`recmp3` ships a Model Context Protocol server over stdio. Point any MCP client at it: + +```jsonc +// e.g. Claude Code / Codex MCP config +{ + "mcpServers": { + "recmp3": { "command": "recmp3", "args": ["mcp"] } + } +} +``` + +Exposed tools: `recmp3_transcribe`, `recmp3_prompt`, `recmp3_sources`, `recmp3_doctor`, +`recmp3_config_show`, `recmp3_record` (requires `duration`), `recmp3_manifest`. Each tool +returns the same JSON envelope as the CLI. + +## Credentials without prompts + +```bash +export GROQ_API_KEY=gsk_... # env var (highest precedence), or +recmp3 config set-key groq --key gsk_... # store in the OS keychain +``` + +For fully local, no-upload transcription, point recmp3 at a whisper.cpp build: + +```bash +export RECMP3_WHISPER_BIN=/usr/local/bin/whisper-cli +export RECMP3_WHISPER_MODEL=/models/ggml-base.en.bin +recmp3 transcribe clip.wav --provider local-whisper --json +``` diff --git a/package-lock.json b/package-lock.json index 4cf5ba9..d7eff45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@commander-js/extra-typings": "^13.1.0", + "@modelcontextprotocol/sdk": "^1.29.0", "clipboardy": "^4.0.0", "commander": "^13.1.0", "dotenv": "^16.4.5", @@ -217,15 +217,6 @@ "node": ">=14.21.3" } }, - "node_modules/@commander-js/extra-typings": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", - "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", - "license": "MIT", - "peerDependencies": { - "commander": "~13.1.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -668,6 +659,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", @@ -794,6 +797,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.8", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.8.tgz", @@ -1373,6 +1416,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1386,6 +1442,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1487,6 +1576,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1528,6 +1641,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1538,6 +1660,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1860,6 +2011,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1883,6 +2056,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1908,7 +2107,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1958,6 +2156,15 @@ "node": ">=4.0.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1980,12 +2187,41 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2020,6 +2256,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2027,6 +2281,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.46.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", @@ -2089,6 +2355,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -2108,6 +2380,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -2151,6 +2453,82 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -2168,6 +2546,22 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", @@ -2196,6 +2590,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -2208,6 +2623,24 @@ "rollup": "^4.34.8" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2230,6 +2663,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2252,6 +2694,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", @@ -2284,6 +2763,18 @@ "license": "MIT", "optional": true }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphql": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", @@ -2294,6 +2785,30 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -2305,6 +2820,35 @@ "set-cookie-parser": "^3.0.1" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -2314,6 +2858,22 @@ "node": ">=16.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2351,8 +2911,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -2415,6 +2974,24 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -2494,6 +3071,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -2554,6 +3137,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2570,6 +3162,18 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -2669,12 +3273,67 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -2746,7 +3405,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -2858,6 +3516,15 @@ "license": "MIT", "optional": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -2909,18 +3576,40 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -3016,6 +3705,15 @@ "dev": true, "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -3087,6 +3785,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -3199,6 +3906,19 @@ "node": ">=10" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -3210,6 +3930,45 @@ "once": "^1.3.1" } }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -3293,6 +4052,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -3411,6 +4179,32 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3432,6 +4226,12 @@ "license": "MIT", "optional": true }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3454,6 +4254,51 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -3461,6 +4306,12 @@ "dev": true, "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3482,6 +4333,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3622,7 +4545,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -3910,6 +4832,15 @@ "dev": true, "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -4038,6 +4969,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4066,6 +5028,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/until-async": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", @@ -4083,6 +5054,15 @@ "license": "MIT", "optional": true }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -4744,8 +5724,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ws": { "version": "8.20.0", @@ -4876,6 +5855,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index dfbc0d0..5029a6f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,20 @@ { "name": "recmp3-cli", - "version": "0.1.0", - "description": "Record audio, transcribe with AI, output developer-ready prompts — from a single terminal command.", + "version": "0.2.0", + "description": "Record audio, transcribe with AI, output developer-ready prompts — for humans and AI agents, from a single terminal command.", "type": "module", "bin": { "recmp3": "dist/index.js" }, + "files": [ + "dist", + "LICENSE", + "LICENSE-COMMERCIAL.md", + "README.md" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsup", "postbuild": "chmod +x dist/index.js", @@ -18,6 +27,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "clipboardy": "^4.0.0", "commander": "^13.1.0", "dotenv": "^16.4.5", @@ -44,7 +54,7 @@ "engines": { "node": ">=20.0.0" }, - "license": "MIT", + "license": "AGPL-3.0-or-later", "repository": { "type": "git", "url": "https://github.com/eduardoborjas/recmp3-cli" @@ -57,6 +67,9 @@ "whisper", "groq", "developer-tools", - "vibecoding" + "vibecoding", + "mcp", + "ai-agent", + "agent-native" ] } diff --git a/src/agent/context.ts b/src/agent/context.ts new file mode 100644 index 0000000..9500e8a --- /dev/null +++ b/src/agent/context.ts @@ -0,0 +1,84 @@ +import { + CaptureSink, + HumanSink, + JsonSink, + type OutputSink, + toErrorPayload, +} from './output.js'; + +export interface AgentContextOptions { + json?: boolean; + yes?: boolean; + quiet?: boolean; + color?: boolean; + sink?: OutputSink; +} + +/** + * Per-invocation runtime mode. Built once in `index.ts` from the resolved global + * flags + environment, then threaded into every command so a single code path serves + * humans, scripting agents (`--json`), and the MCP server (CaptureSink). + */ +export class AgentContext { + readonly json: boolean; + readonly yes: boolean; + readonly quiet: boolean; + readonly color: boolean; + readonly sink: OutputSink; + + constructor(opts: AgentContextOptions = {}) { + this.json = opts.json ?? false; + this.yes = opts.yes ?? false; + this.quiet = opts.quiet ?? false; + this.color = opts.color ?? true; + this.sink = opts.sink ?? (this.json ? new JsonSink() : new HumanSink()); + } + + /** Build the context from resolved global option values + environment. */ + static fromGlobals(opts: Record): AgentContext { + const json = Boolean(opts.json) || process.env.RECMP3_JSON === '1'; + const yes = + Boolean(opts.yes) || + process.env.RECMP3_YES === '1' || + process.env.RECMP3_SKIP_CONSENT === '1'; + const quiet = Boolean(opts.quiet) || process.env.RECMP3_QUIET === '1'; + // commander stores --no-color as opts.color === false + const color = + opts.color !== false && + !process.env.NO_COLOR && + process.stdout.isTTY !== false; + return new AgentContext({ + json, + yes, + quiet, + color, + sink: json ? new JsonSink() : new HumanSink(), + }); + } + + /** Context for MCP tool calls: captured JSON, prompts auto-skipped, no chatter. */ + static forCapture(): AgentContext { + return new AgentContext({ + json: true, + yes: true, + quiet: true, + color: false, + sink: new CaptureSink(), + }); + } + + /** Emit a successful result. `humanRender` runs only in human (non-json) mode. */ + ok(command: string, payload: unknown, humanRender?: () => void): void { + this.sink.ok(command, payload, humanRender); + } + + /** Emit a failure envelope. Does not exit — the caller controls process exit. */ + fail(err: unknown, command: string): void { + this.sink.fail(toErrorPayload(err), command); + } + + /** Write progress/diagnostic chatter to stderr unless quiet. */ + note(text: string): void { + if (!this.quiet) process.stderr.write(text); + } +} diff --git a/src/agent/manifest.ts b/src/agent/manifest.ts new file mode 100644 index 0000000..4e4e7f1 --- /dev/null +++ b/src/agent/manifest.ts @@ -0,0 +1,268 @@ +import { ExitCode } from '../errors.js'; + +export interface ManifestFlag { + name: string; + type: 'string' | 'boolean' | 'number'; + description: string; + env?: string; + default?: string | boolean | number; +} + +export interface ManifestCommand { + name: string; + /** MCP tool name (snake_case, recmp3_-prefixed) when agentSafe. */ + tool?: string; + summary: string; + /** True when the command runs fully non-interactively and is safe to expose via MCP. */ + agentSafe: boolean; + args?: Array<{ name: string; required: boolean; description: string }>; + flags: ManifestFlag[]; + stdin: boolean; + stdout: 'json' | 'text' | 'none'; +} + +export interface Manifest { + name: string; + version: string; + description: string; + globalFlags: ManifestFlag[]; + exitCodes: Record; + commands: ManifestCommand[]; +} + +const GLOBAL_FLAGS: ManifestFlag[] = [ + { + name: '--json', + type: 'boolean', + description: 'Emit a stable JSON envelope on stdout', + env: 'RECMP3_JSON', + }, + { + name: '--yes', + type: 'boolean', + description: 'Skip all interactive prompts', + env: 'RECMP3_YES', + }, + { + name: '--quiet', + type: 'boolean', + description: 'Suppress stderr chatter', + env: 'RECMP3_QUIET', + }, + { + name: '--no-color', + type: 'boolean', + description: 'Disable colored output', + env: 'NO_COLOR', + }, +]; + +export const MANIFEST: Manifest = { + name: 'recmp3', + version: '0.2.0', + description: + 'Record audio, transcribe with AI, output developer-ready prompts.', + globalFlags: GLOBAL_FLAGS, + exitCodes: { + success: ExitCode.SUCCESS, + unknown: ExitCode.UNKNOWN, + config: ExitCode.CONFIG, + audio: ExitCode.AUDIO, + transcription: ExitCode.TRANSCRIPTION, + network: ExitCode.NETWORK, + localWhisper: ExitCode.LOCAL_WHISPER, + input: ExitCode.INPUT, + userAbort: ExitCode.USER_ABORT, + }, + commands: [ + { + name: 'transcribe', + tool: 'recmp3_transcribe', + summary: 'Transcribe an existing audio file.', + agentSafe: true, + args: [ + { + name: 'file', + required: true, + description: 'Audio file path, or "-" for stdin', + }, + ], + flags: [ + { + name: '--provider', + type: 'string', + description: 'groq | openai | local-whisper', + }, + { + name: '--lang', + type: 'string', + description: 'Force language code (e.g. es, en)', + }, + { + name: '--copy', + type: 'boolean', + description: 'Copy transcript to clipboard', + }, + ], + stdin: true, + stdout: 'json', + }, + { + name: 'prompt', + tool: 'recmp3_prompt', + summary: 'Wrap a transcript in a developer prompt template (no network).', + agentSafe: true, + args: [ + { + name: 'file', + required: true, + description: 'Transcript file path, or "-" for stdin', + }, + ], + flags: [ + { + name: '--template', + type: 'string', + description: + 'claude-code | prd | bug | todo | meeting-notes | commit-message | raw', + default: 'claude-code', + }, + { + name: '--out', + type: 'string', + description: 'Write output to a file', + }, + { + name: '--copy', + type: 'boolean', + description: 'Copy output to clipboard', + }, + ], + stdin: true, + stdout: 'json', + }, + { + name: 'sources', + tool: 'recmp3_sources', + summary: 'List available audio input sources for the OS.', + agentSafe: true, + flags: [], + stdin: false, + stdout: 'json', + }, + { + name: 'doctor', + tool: 'recmp3_doctor', + summary: + 'Run preflight checks (Node, ffmpeg, audio backend, provider, etc.).', + agentSafe: true, + flags: [], + stdin: false, + stdout: 'json', + }, + { + name: 'config show', + tool: 'recmp3_config_show', + summary: 'Show resolved configuration (API keys redacted).', + agentSafe: true, + flags: [], + stdin: false, + stdout: 'json', + }, + { + name: 'manifest', + tool: 'recmp3_manifest', + summary: 'Print the command/tool manifest.', + agentSafe: true, + flags: [], + stdin: false, + stdout: 'json', + }, + { + name: 'record', + tool: 'recmp3_record', + summary: 'Record audio. Agent/headless mode requires --duration.', + agentSafe: true, + flags: [ + { + name: '--duration', + type: 'number', + description: 'Headless: record N seconds then stop', + }, + { name: '--name', type: 'string', description: 'Output filename stem' }, + { name: '--out', type: 'string', description: 'Output directory' }, + { + name: '--transcribe', + type: 'boolean', + description: 'Transcribe after recording', + }, + { + name: '--provider', + type: 'string', + description: 'groq | openai | local-whisper', + }, + { name: '--lang', type: 'string', description: 'Force language code' }, + ], + stdin: false, + stdout: 'json', + }, + { + name: 'config init', + summary: 'First-time setup. Flag-driven when non-interactive.', + agentSafe: false, + flags: [ + { + name: '--provider', + type: 'string', + description: 'groq | openai | local-whisper', + }, + { + name: '--lang', + type: 'string', + description: 'Default language code', + }, + { + name: '--outdir', + type: 'string', + description: 'Recordings output directory', + }, + { + name: '--key', + type: 'string', + description: 'API key to store in the OS keychain', + }, + ], + stdin: false, + stdout: 'none', + }, + { + name: 'config set-key', + summary: 'Store an API key in the OS keychain.', + agentSafe: false, + args: [ + { name: 'provider', required: true, description: 'groq | openai' }, + ], + flags: [ + { + name: '--key', + type: 'string', + description: 'Key value (else stdin or *_API_KEY env)', + }, + ], + stdin: true, + stdout: 'none', + }, + { + name: 'mcp', + summary: 'Start the Model Context Protocol server over stdio.', + agentSafe: false, + flags: [], + stdin: true, + stdout: 'none', + }, + ], +}; + +export function agentTools(): ManifestCommand[] { + return MANIFEST.commands.filter((c) => c.agentSafe && c.tool); +} diff --git a/src/agent/mcp.ts b/src/agent/mcp.ts new file mode 100644 index 0000000..d986941 --- /dev/null +++ b/src/agent/mcp.ts @@ -0,0 +1,125 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { type ZodRawShape, z } from 'zod'; +import { runConfigShow } from '../commands/config.js'; +import { runDoctor } from '../commands/doctor.js'; +import { runManifest } from '../commands/manifest.js'; +import { runPrompt } from '../commands/prompt.js'; +import { runRecord } from '../commands/record.js'; +import { runSources } from '../commands/sources.js'; +import { runTranscribe } from '../commands/transcribe.js'; +import { AgentContext } from './context.js'; +import { MANIFEST } from './manifest.js'; +import type { CaptureSink } from './output.js'; + +type ToolRun = ( + args: Record, + ctx: AgentContext +) => Promise; + +/** + * Run a command in capture mode and return its JSON envelope as MCP text content. + * Any thrown error becomes a captured error envelope (isError flagged for the client). + */ +async function callCommand(run: ToolRun, args: Record) { + const ctx = AgentContext.forCapture(); + const sink = ctx.sink as CaptureSink; + try { + await run(args, ctx); + } catch (err: unknown) { + ctx.fail(err, 'mcp'); + } + const envelope = sink.envelope; + return { + isError: envelope?.ok === false, + content: [ + { type: 'text' as const, text: JSON.stringify(envelope, null, 2) }, + ], + }; +} + +function descriptionFor(tool: string): string { + return MANIFEST.commands.find((c) => c.tool === tool)?.summary ?? ''; +} + +function register( + server: McpServer, + tool: string, + inputSchema: ZodRawShape, + run: ToolRun +) { + server.registerTool( + tool, + { description: descriptionFor(tool), inputSchema }, + async (args: Record) => callCommand(run, args ?? {}) + ); +} + +export async function runMcpServer(): Promise { + const server = new McpServer({ + name: MANIFEST.name, + version: MANIFEST.version, + }); + + register( + server, + 'recmp3_transcribe', + { + file: z + .string() + .describe('Audio file path (must exist on the server host)'), + provider: z.string().optional(), + lang: z.string().optional(), + }, + (a, ctx) => + runTranscribe( + a.file as string, + { provider: a.provider as string, lang: a.lang as string }, + ctx + ) + ); + + register( + server, + 'recmp3_prompt', + { + file: z.string().describe('Transcript file path, or "-" for stdin'), + template: z.string().optional(), + }, + (a, ctx) => + runPrompt(a.file as string, { template: a.template as string }, ctx) + ); + + register(server, 'recmp3_sources', {}, (_a, ctx) => runSources(ctx)); + register(server, 'recmp3_doctor', {}, (_a, ctx) => runDoctor(ctx)); + register(server, 'recmp3_config_show', {}, (_a, ctx) => runConfigShow(ctx)); + register(server, 'recmp3_manifest', {}, (_a, ctx) => runManifest(ctx)); + + register( + server, + 'recmp3_record', + { + duration: z.number().describe('Seconds to record (headless)'), + name: z.string().optional(), + out: z.string().optional(), + transcribe: z.boolean().optional(), + provider: z.string().optional(), + lang: z.string().optional(), + }, + (a, ctx) => + runRecord( + { + duration: String(a.duration), + name: a.name as string, + out: a.out as string, + transcribe: a.transcribe as boolean, + provider: a.provider as string, + lang: a.lang as string, + }, + ctx + ) + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/src/agent/output.ts b/src/agent/output.ts new file mode 100644 index 0000000..fb2ff9f --- /dev/null +++ b/src/agent/output.ts @@ -0,0 +1,117 @@ +import pc from 'picocolors'; +import { ExitCode, RecmpError } from '../errors.js'; + +/** Bumped only on breaking changes to the envelope contract. */ +export const SCHEMA_VERSION = 1; + +export interface SuccessEnvelope { + ok: true; + command: string; + schemaVersion: number; + data: unknown; +} + +export interface ErrorEnvelope { + ok: false; + command: string; + schemaVersion: number; + error: { + code: string; + message: string; + exitCode: number; + details?: unknown; + }; +} + +export type Envelope = SuccessEnvelope | ErrorEnvelope; + +/** + * An OutputSink decides how a command's result is rendered: + * - HumanSink → colored, human-readable text (stdout for results, stderr for chatter) + * - JsonSink → a single stable JSON envelope on stdout + * - CaptureSink → an in-memory payload (used by the MCP server) + * + * Commands call `ok(command, payload, humanRender?)` exactly once on success, and the + * CLI layer calls `fail(error, command)` for failures. This keeps the same command + * logic usable by humans, scripting agents, and MCP tools. + */ +export interface OutputSink { + ok(command: string, payload: unknown, humanRender?: () => void): void; + fail(error: ErrorEnvelope['error'], command: string): void; +} + +/** Human sink: payload-as-JSON is irrelevant; the optional humanRender does the work. */ +export class HumanSink implements OutputSink { + ok(_command: string, _payload: unknown, humanRender?: () => void): void { + if (humanRender) humanRender(); + } + + fail(error: ErrorEnvelope['error'], _command: string): void { + process.stderr.write(`\n${pc.red('✗')} ${error.message}\n\n`); + } +} + +/** JSON sink: one envelope per invocation, written to stdout. */ +export class JsonSink implements OutputSink { + ok(command: string, payload: unknown): void { + const envelope: SuccessEnvelope = { + ok: true, + command, + schemaVersion: SCHEMA_VERSION, + data: payload, + }; + process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + } + + fail(error: ErrorEnvelope['error'], command: string): void { + const envelope: ErrorEnvelope = { + ok: false, + command, + schemaVersion: SCHEMA_VERSION, + error, + }; + process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + } +} + +/** Capture sink: keeps the last envelope in memory for the MCP server to forward. */ +export class CaptureSink implements OutputSink { + envelope: Envelope | null = null; + + ok(command: string, payload: unknown): void { + this.envelope = { + ok: true, + command, + schemaVersion: SCHEMA_VERSION, + data: payload, + }; + } + + fail(error: ErrorEnvelope['error'], command: string): void { + this.envelope = { + ok: false, + command, + schemaVersion: SCHEMA_VERSION, + error, + }; + } +} + +/** Normalize any thrown value into the envelope error shape. */ +export function toErrorPayload(err: unknown): ErrorEnvelope['error'] { + if (err instanceof RecmpError) { + return { code: err.code, message: err.message, exitCode: err.exitCode }; + } + if (err instanceof Error) { + return { + code: 'UNEXPECTED_ERROR', + message: err.message, + exitCode: ExitCode.UNKNOWN, + }; + } + return { + code: 'UNKNOWN_ERROR', + message: String(err), + exitCode: ExitCode.UNKNOWN, + }; +} diff --git a/src/agent/stdin.ts b/src/agent/stdin.ts new file mode 100644 index 0000000..84c7950 --- /dev/null +++ b/src/agent/stdin.ts @@ -0,0 +1,13 @@ +/** Read all of stdin as a Buffer (for piped audio bytes). */ +export async function readStdinBuffer(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +/** Read all of stdin as UTF-8 text (for piped transcript text). */ +export async function readStdinText(): Promise { + return (await readStdinBuffer()).toString('utf-8'); +} diff --git a/src/audio/capture.ts b/src/audio/capture.ts index 6be1a2b..f8ff1d8 100644 --- a/src/audio/capture.ts +++ b/src/audio/capture.ts @@ -1,4 +1,4 @@ -import { AudioCaptureFactory } from './types.js'; +import type { AudioCaptureFactory } from './types.js'; let _factory: AudioCaptureFactory | null = null; @@ -17,7 +17,9 @@ export async function getAudioFactory(): Promise { const { WindowsDshowFactory } = await import('./windows-dshow.js'); _factory = new WindowsDshowFactory(); } else { - throw new Error(`Unsupported platform: ${platform}. Supported platforms: linux, darwin, win32.`); + throw new Error( + `Unsupported platform: ${platform}. Supported platforms: linux, darwin, win32.` + ); } return _factory; diff --git a/src/audio/concat.ts b/src/audio/concat.ts index e5976bc..b3e1eaa 100644 --- a/src/audio/concat.ts +++ b/src/audio/concat.ts @@ -1,10 +1,10 @@ -import { writeFile, stat } from 'fs/promises'; -import { join } from 'path'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; -import { CaptureSegment } from './types.js'; +import { execFile } from 'node:child_process'; +import { stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; import { AudioCaptureError } from '../errors.js'; import { findFfmpeg } from './ffmpeg.js'; +import type { CaptureSegment } from './types.js'; const execFileAsync = promisify(execFile); @@ -12,12 +12,14 @@ export async function concatSegments( segments: CaptureSegment[], outputPath: string, tmpDir: string, - format: 'wav' | 'mp3' = 'wav', + format: 'wav' | 'mp3' = 'wav' ): Promise { const validSegments = segments.filter((s) => s.sizeBytes > 0); if (validSegments.length === 0) { - throw new AudioCaptureError('No audio was recorded. The recording was empty or too short.'); + throw new AudioCaptureError( + 'No audio was recorded. The recording was empty or too short.' + ); } const ffmpeg = await findFfmpeg(); @@ -26,17 +28,23 @@ export async function concatSegments( // No concat needed — just encode the single segment if (format === 'wav') { // Already WAV — just move/copy it - const { copyFile } = await import('fs/promises'); + const { copyFile } = await import('node:fs/promises'); await copyFile(validSegments[0].path, outputPath); return outputPath; } // Convert to MP3 await execFileAsync(ffmpeg, [ - '-hide_banner', '-loglevel', 'error', - '-i', validSegments[0].path, - '-c:a', 'libmp3lame', - '-b:a', '192k', - '-y', outputPath, + '-hide_banner', + '-loglevel', + 'error', + '-i', + validSegments[0].path, + '-c:a', + 'libmp3lame', + '-b:a', + '192k', + '-y', + outputPath, ]); return outputPath; } @@ -48,17 +56,22 @@ export async function concatSegments( .join('\n'); await writeFile(listPath, listContent, 'utf-8'); - const codecArgs = format === 'mp3' - ? ['-c:a', 'libmp3lame', '-b:a', '192k'] - : ['-c', 'copy']; + const codecArgs = + format === 'mp3' ? ['-c:a', 'libmp3lame', '-b:a', '192k'] : ['-c', 'copy']; await execFileAsync(ffmpeg, [ - '-hide_banner', '-loglevel', 'error', - '-f', 'concat', - '-safe', '0', - '-i', listPath, + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'concat', + '-safe', + '0', + '-i', + listPath, ...codecArgs, - '-y', outputPath, + '-y', + outputPath, ]); return outputPath; @@ -66,16 +79,19 @@ export async function concatSegments( export async function getAudioDuration(filePath: string): Promise { try { - const { execFile: ef } = await import('child_process'); - const { promisify: p } = await import('util'); + const { execFile: ef } = await import('node:child_process'); + const { promisify: p } = await import('node:util'); const execAsync = p(ef); const { stderr } = await execAsync('ffprobe', [ - '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=noprint_wrappers=1:nokey=1', + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', filePath, ]); - return parseFloat(stderr.trim()) || 0; + return Number.parseFloat(stderr.trim()) || 0; } catch { return 0; } diff --git a/src/audio/ffmpeg.ts b/src/audio/ffmpeg.ts index be8f1cb..f5b9100 100644 --- a/src/audio/ffmpeg.ts +++ b/src/audio/ffmpeg.ts @@ -1,5 +1,5 @@ -import { execFile } from 'child_process'; -import { promisify } from 'util'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); @@ -24,7 +24,10 @@ export async function findFfmpeg(): Promise { } } -export async function checkFfmpegVersion(): Promise<{ version: string; meets: boolean }> { +export async function checkFfmpegVersion(): Promise<{ + version: string; + meets: boolean; +}> { try { const ffmpeg = await findFfmpeg(); const { stdout, stderr } = await execFileAsync(ffmpeg, ['-version']); diff --git a/src/audio/linux-pulse.ts b/src/audio/linux-pulse.ts index db768c2..67bec5e 100644 --- a/src/audio/linux-pulse.ts +++ b/src/audio/linux-pulse.ts @@ -1,10 +1,16 @@ -import { spawn, ChildProcess } from 'child_process'; -import { stat } from 'fs/promises'; -import { AudioCapture, AudioCaptureFactory, AudioSource, CaptureOptions, CaptureSegment } from './types.js'; +import { type ChildProcess, spawn } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { promisify } from 'node:util'; import { AudioCaptureError } from '../errors.js'; import { findFfmpeg } from './ffmpeg.js'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; +import type { + AudioCapture, + AudioCaptureFactory, + AudioSource, + CaptureOptions, + CaptureSegment, +} from './types.js'; const execFileAsync = promisify(execFile); @@ -18,12 +24,18 @@ export class LinuxPulseCapture implements AudioCapture { const ffmpeg = await findFfmpeg(); const args = [ '-hide_banner', - '-loglevel', 'error', - '-f', 'pulse', - '-i', opts.source, - '-ac', String(opts.channels), - '-ar', String(opts.sampleRate), - '-c:a', 'pcm_s16le', + '-loglevel', + 'error', + '-f', + 'pulse', + '-i', + opts.source, + '-ac', + String(opts.channels), + '-ar', + String(opts.sampleRate), + '-c:a', + 'pcm_s16le', '-y', opts.outputPath, ]; @@ -64,13 +76,19 @@ export class LinuxPulseCapture implements AudioCapture { setTimeout(() => { if (!proc.pid) { clearTimeout(timer); - reject(new AudioCaptureError(`ffmpeg failed to start. Check audio source: "${opts.source}"`)); + reject( + new AudioCaptureError( + `ffmpeg failed to start. Check audio source: "${opts.source}"` + ) + ); } }, 200); }); if (!proc.pid) { - throw new AudioCaptureError(`ffmpeg failed to start. Run 'recmp3 sources' to list available audio sources.`); + throw new AudioCaptureError( + `ffmpeg failed to start. Run 'recmp3 sources' to list available audio sources.` + ); } } @@ -146,7 +164,11 @@ export class LinuxPulseCaptureFactory implements AudioCaptureFactory { async listSources(): Promise { try { - const { stdout } = await execFileAsync('pactl', ['list', 'sources', 'short']); + const { stdout } = await execFileAsync('pactl', [ + 'list', + 'sources', + 'short', + ]); const sources: AudioSource[] = []; for (const line of stdout.trim().split('\n')) { @@ -166,7 +188,11 @@ export class LinuxPulseCaptureFactory implements AudioCaptureFactory { // Add 'default' as the first option const hasDefault = sources.some((s) => s.id === 'default'); if (!hasDefault) { - sources.unshift({ id: 'default', label: 'default (system default)', isDefault: true }); + sources.unshift({ + id: 'default', + label: 'default (system default)', + isDefault: true, + }); } return sources; @@ -174,17 +200,26 @@ export class LinuxPulseCaptureFactory implements AudioCaptureFactory { // pactl not available — fall back to ffmpeg pulse enumeration try { const ffmpeg = await findFfmpeg(); - const { stderr } = await execFileAsync(ffmpeg, ['-sources', 'pulse', '-hide_banner']); - const sources: AudioSource[] = [{ id: 'default', label: 'default (system default)', isDefault: true }]; + const { stderr } = await execFileAsync(ffmpeg, [ + '-sources', + 'pulse', + '-hide_banner', + ]); + const sources: AudioSource[] = [ + { id: 'default', label: 'default (system default)', isDefault: true }, + ]; for (const line of stderr.split('\n')) { const match = line.match(/^\s+(\S+)\s/); - if (match) sources.push({ id: match[1], label: match[1], isDefault: false }); + if (match) + sources.push({ id: match[1], label: match[1], isDefault: false }); } return sources; } catch { - return [{ id: 'default', label: 'default (system default)', isDefault: true }]; + return [ + { id: 'default', label: 'default (system default)', isDefault: true }, + ]; } } } diff --git a/src/audio/mac-avfoundation.ts b/src/audio/mac-avfoundation.ts index c9734e9..d5ef30f 100644 --- a/src/audio/mac-avfoundation.ts +++ b/src/audio/mac-avfoundation.ts @@ -1,9 +1,15 @@ -import { spawn, ChildProcess, execFile } from 'child_process'; -import { stat } from 'fs/promises'; -import { promisify } from 'util'; -import { AudioCapture, AudioCaptureFactory, AudioSource, CaptureOptions, CaptureSegment } from './types.js'; +import { type ChildProcess, execFile, spawn } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { promisify } from 'node:util'; import { AudioCaptureError } from '../errors.js'; import { findFfmpeg } from './ffmpeg.js'; +import type { + AudioCapture, + AudioCaptureFactory, + AudioSource, + CaptureOptions, + CaptureSegment, +} from './types.js'; const execFileAsync = promisify(execFile); @@ -16,15 +22,25 @@ export class MacAvFoundationCapture implements AudioCapture { async start(opts: CaptureOptions): Promise { const ffmpeg = await findFfmpeg(); // On macOS, source format is ":N" (audio device index, no video) - const source = opts.source.startsWith(':') ? opts.source : `:${opts.source}`; + const source = opts.source.startsWith(':') + ? opts.source + : `:${opts.source}`; const args = [ - '-hide_banner', '-loglevel', 'error', - '-f', 'avfoundation', - '-i', source, - '-ac', String(opts.channels), - '-ar', String(opts.sampleRate), - '-c:a', 'pcm_s16le', - '-y', opts.outputPath, + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'avfoundation', + '-i', + source, + '-ac', + String(opts.channels), + '-ar', + String(opts.sampleRate), + '-c:a', + 'pcm_s16le', + '-y', + opts.outputPath, ]; this.outputPath = opts.outputPath; @@ -39,13 +55,18 @@ export class MacAvFoundationCapture implements AudioCapture { proc.on('error', (err) => { clearTimeout(timer); this.recording = false; - reject(new AudioCaptureError(`Failed to start ffmpeg: ${err.message}. Grant microphone access in System Settings → Privacy & Security → Microphone.`)); + reject( + new AudioCaptureError( + `Failed to start ffmpeg: ${err.message}. Grant microphone access in System Settings → Privacy & Security → Microphone.` + ) + ); }); }); } async stop(): Promise { - if (!this.process || !this.recording) throw new AudioCaptureError('Not recording.'); + if (!this.process || !this.recording) + throw new AudioCaptureError('Not recording.'); const proc = this.process; const startedAt = this.startedAt ?? new Date(); @@ -57,7 +78,12 @@ export class MacAvFoundationCapture implements AudioCapture { await new Promise((resolve) => { proc.on('close', resolve); - try { proc.stdin?.write('q'); proc.stdin?.end(); } catch { proc.kill('SIGTERM'); } + try { + proc.stdin?.write('q'); + proc.stdin?.end(); + } catch { + proc.kill('SIGTERM'); + } setTimeout(() => proc.kill('SIGTERM'), 5000); }); @@ -71,38 +97,65 @@ export class MacAvFoundationCapture implements AudioCapture { }; } - isRecording() { return this.recording; } + isRecording() { + return this.recording; + } async dispose(): Promise { - if (this.process) { try { this.process.kill('SIGTERM'); } catch {} this.process = null; } + if (this.process) { + try { + this.process.kill('SIGTERM'); + } catch {} + this.process = null; + } this.recording = false; } } export class MacAvFoundationFactory implements AudioCaptureFactory { - create(): AudioCapture { return new MacAvFoundationCapture(); } + create(): AudioCapture { + return new MacAvFoundationCapture(); + } async listSources(): Promise { try { const ffmpeg = await findFfmpeg(); - const { stderr } = await execFileAsync(ffmpeg, ['-f', 'avfoundation', '-list_devices', 'true', '-i', '']); + const { stderr } = await execFileAsync(ffmpeg, [ + '-f', + 'avfoundation', + '-list_devices', + 'true', + '-i', + '', + ]); const sources: AudioSource[] = []; let inAudioSection = false; for (const line of stderr.split('\n')) { - if (line.includes('AVFoundation audio devices:')) { inAudioSection = true; continue; } + if (line.includes('AVFoundation audio devices:')) { + inAudioSection = true; + continue; + } if (!inAudioSection) continue; const match = line.match(/\[(\d+)\] (.+)/); if (match) { - sources.push({ id: match[1], label: match[2], isDefault: match[1] === '0' }); + sources.push({ + id: match[1], + label: match[2], + isDefault: match[1] === '0', + }); } } - return sources.length > 0 ? sources : [{ id: '0', label: 'Default audio device', isDefault: true }]; + return sources.length > 0 + ? sources + : [{ id: '0', label: 'Default audio device', isDefault: true }]; } catch { return [{ id: '0', label: 'Default audio device', isDefault: true }]; } } - defaultSource(): string { return process.env.RECMP3_SOURCE ?? '0'; } + defaultSource(): string { + return process.env.RECMP3_SOURCE ?? '0'; + } } diff --git a/src/audio/windows-dshow.ts b/src/audio/windows-dshow.ts index 7ea5e40..44899ef 100644 --- a/src/audio/windows-dshow.ts +++ b/src/audio/windows-dshow.ts @@ -1,9 +1,15 @@ -import { spawn, ChildProcess, execFile } from 'child_process'; -import { stat } from 'fs/promises'; -import { promisify } from 'util'; -import { AudioCapture, AudioCaptureFactory, AudioSource, CaptureOptions, CaptureSegment } from './types.js'; +import { type ChildProcess, execFile, spawn } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { promisify } from 'node:util'; import { AudioCaptureError } from '../errors.js'; import { findFfmpeg } from './ffmpeg.js'; +import type { + AudioCapture, + AudioCaptureFactory, + AudioSource, + CaptureOptions, + CaptureSegment, +} from './types.js'; const execFileAsync = promisify(execFile); @@ -15,15 +21,24 @@ export class WindowsDshowCapture implements AudioCapture { async start(opts: CaptureOptions): Promise { const ffmpeg = await findFfmpeg(); - const deviceName = opts.source === 'default' ? await this.getDefaultDevice() : opts.source; + const deviceName = + opts.source === 'default' ? await this.getDefaultDevice() : opts.source; const args = [ - '-hide_banner', '-loglevel', 'error', - '-f', 'dshow', - '-i', `audio=${deviceName}`, - '-ac', String(opts.channels), - '-ar', String(opts.sampleRate), - '-c:a', 'pcm_s16le', - '-y', opts.outputPath, + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'dshow', + '-i', + `audio=${deviceName}`, + '-ac', + String(opts.channels), + '-ar', + String(opts.sampleRate), + '-c:a', + 'pcm_s16le', + '-y', + opts.outputPath, ]; this.outputPath = opts.outputPath; @@ -38,19 +53,25 @@ export class WindowsDshowCapture implements AudioCapture { proc.on('error', (err) => { clearTimeout(timer); this.recording = false; - reject(new AudioCaptureError(`Failed to start recording: ${err.message}. Run 'recmp3 sources' to list available devices.`)); + reject( + new AudioCaptureError( + `Failed to start recording: ${err.message}. Run 'recmp3 sources' to list available devices.` + ) + ); }); }); } private async getDefaultDevice(): Promise { const sources = await this.listSources(); - const first = sources.find((s) => !s.label.includes('monitor')) ?? sources[0]; + const first = + sources.find((s) => !s.label.includes('monitor')) ?? sources[0]; return first?.id ?? 'Microphone'; } async stop(): Promise { - if (!this.process || !this.recording) throw new AudioCaptureError('Not recording.'); + if (!this.process || !this.recording) + throw new AudioCaptureError('Not recording.'); const proc = this.process; const startedAt = this.startedAt ?? new Date(); @@ -62,7 +83,12 @@ export class WindowsDshowCapture implements AudioCapture { await new Promise((resolve) => { proc.on('close', resolve); - try { proc.stdin?.write('q'); proc.stdin?.end(); } catch { proc.kill('SIGTERM'); } + try { + proc.stdin?.write('q'); + proc.stdin?.end(); + } catch { + proc.kill('SIGTERM'); + } setTimeout(() => proc.kill('SIGTERM'), 5000); }); @@ -76,31 +102,58 @@ export class WindowsDshowCapture implements AudioCapture { }; } - isRecording() { return this.recording; } + isRecording() { + return this.recording; + } async dispose(): Promise { - if (this.process) { try { this.process.kill('SIGTERM'); } catch {} this.process = null; } + if (this.process) { + try { + this.process.kill('SIGTERM'); + } catch {} + this.process = null; + } this.recording = false; } async listSources(): Promise { try { const ffmpeg = await findFfmpeg(); - const { stderr } = await execFileAsync(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy']); + const { stderr } = await execFileAsync(ffmpeg, [ + '-list_devices', + 'true', + '-f', + 'dshow', + '-i', + 'dummy', + ]); const sources: AudioSource[] = []; let inAudioSection = false; for (const line of stderr.split('\n')) { - if (line.includes('DirectShow audio devices')) { inAudioSection = true; continue; } - if (line.includes('DirectShow video devices')) { inAudioSection = false; continue; } + if (line.includes('DirectShow audio devices')) { + inAudioSection = true; + continue; + } + if (line.includes('DirectShow video devices')) { + inAudioSection = false; + continue; + } if (!inAudioSection) continue; const match = line.match(/"([^"]+)"/); - if (match) sources.push({ id: match[1], label: match[1], isDefault: sources.length === 0 }); + if (match) + sources.push({ + id: match[1], + label: match[1], + isDefault: sources.length === 0, + }); } return sources; } catch { - return [{ id: 'Microphone', label: 'Microphone (default)', isDefault: true }]; + return [ + { id: 'Microphone', label: 'Microphone (default)', isDefault: true }, + ]; } } } @@ -108,9 +161,15 @@ export class WindowsDshowCapture implements AudioCapture { export class WindowsDshowFactory implements AudioCaptureFactory { private capture = new WindowsDshowCapture(); - create(): AudioCapture { return new WindowsDshowCapture(); } + create(): AudioCapture { + return new WindowsDshowCapture(); + } - async listSources(): Promise { return this.capture.listSources(); } + async listSources(): Promise { + return this.capture.listSources(); + } - defaultSource(): string { return process.env.RECMP3_SOURCE ?? 'default'; } + defaultSource(): string { + return process.env.RECMP3_SOURCE ?? 'default'; + } } diff --git a/src/commands/config.ts b/src/commands/config.ts index 3ca39f5..0ef2d37 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,17 +1,34 @@ -import { createInterface } from 'readline'; -import { existsSync } from 'fs'; -import { mkdir } from 'fs/promises'; -import { dirname } from 'path'; +import { existsSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { createInterface } from 'node:readline'; import pc from 'picocolors'; +import type { AgentContext } from '../agent/context.js'; +import { readStdinText } from '../agent/stdin.js'; +import { + getApiKey, + loadConfig, + resetConfigCache, + saveConfig, +} from '../config/load.js'; import { configFilePath, paths } from '../config/paths.js'; -import { loadConfig, saveConfig, resetConfigCache, getApiKey } from '../config/load.js'; import { RecmpConfigSchema } from '../config/schema.js'; +import { ConfigError, InputError } from '../errors.js'; import { redactKey } from '../log.js'; +import { keychainAvailable, setSecret } from '../secrets/keychain.js'; + +const ENV_VAR: Record<'groq' | 'openai', string> = { + groq: 'GROQ_API_KEY', + openai: 'OPENAI_API_KEY', +}; async function prompt(question: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { - rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); rl.once('close', () => resolve('')); }); } @@ -23,122 +40,219 @@ async function confirm(question: string, defaultYes = true): Promise { return answer.toLowerCase().startsWith('y'); } -export async function runConfigInit(): Promise { - if (!process.stdout.isTTY) { - console.error(`${pc.red('✗')} recmp3 config init requires an interactive terminal.`); - process.exit(1); +export interface ConfigInitOptions { + provider?: string; + lang?: string; + outdir?: string; + key?: string; +} + +export async function runConfigInit( + opts: ConfigInitOptions, + ctx: AgentContext +): Promise { + const flagDriven = Boolean( + opts.provider || opts.lang || opts.outdir || opts.key + ); + const interactive = + !flagDriven && !ctx.yes && process.stdout.isTTY === true && !ctx.json; + + if (interactive) { + return runConfigInitInteractive(); } + // Non-interactive / flag-driven setup — no prompts. + const providerName = ( + ['groq', 'openai', 'local-whisper'].includes(opts.provider ?? '') + ? opts.provider + : 'groq' + ) as 'groq' | 'openai' | 'local-whisper'; + + const config = RecmpConfigSchema.parse({}); + config.provider.default = providerName; + if (opts.lang) config.transcription.defaultLanguage = opts.lang; + config.output.recordingDir = opts.outdir ?? paths.recordings; + + await mkdir(dirname(configFilePath), { recursive: true }); + await mkdir(config.output.recordingDir, { recursive: true }); + await saveConfig(config); + + let keychainStored = false; + if (opts.key && (providerName === 'groq' || providerName === 'openai')) { + keychainStored = await setSecret(ENV_VAR[providerName], opts.key); + if (!keychainStored) { + ctx.note( + pc.yellow( + ' OS keychain unavailable — set the key via env var instead.\n' + ) + ); + } + } + + ctx.ok( + 'config init', + { + configPath: configFilePath, + provider: providerName, + recordingDir: config.output.recordingDir, + keychainStored, + }, + () => { + console.log(`${pc.green('✓')} Config saved: ${configFilePath}`); + console.log(`${pc.green('✓')} Provider: ${providerName}`); + if (keychainStored) + console.log(`${pc.green('✓')} API key stored in OS keychain`); + } + ); +} + +async function runConfigInitInteractive(): Promise { console.log(`\n${pc.bold('recmp3 — First-time setup')}`); console.log(pc.gray(`Config will be saved to: ${configFilePath}\n`)); - // Provider selection console.log(`${pc.bold('1. Transcription provider')}`); - console.log(` ${pc.cyan('groq')} — Groq Whisper API (fast, cheap, recommended)`); + console.log( + ` ${pc.cyan('groq')} — Groq Whisper API (fast, cheap, recommended)` + ); console.log(` ${pc.cyan('openai')} — OpenAI Whisper API`); + console.log(` ${pc.cyan('local-whisper')} — local whisper.cpp (no upload)`); const providerInput = await prompt('\n Choice [groq]: '); - const providerName = (['groq', 'openai'].includes(providerInput) ? providerInput : 'groq') as 'groq' | 'openai'; + const providerName = ( + ['groq', 'openai', 'local-whisper'].includes(providerInput) + ? providerInput + : 'groq' + ) as 'groq' | 'openai' | 'local-whisper'; - // API key instructions console.log(`\n${pc.bold('2. API key')}`); - const envVar = providerName === 'groq' ? 'GROQ_API_KEY' : 'OPENAI_API_KEY'; - const existingKey = getApiKey(providerName); - - if (existingKey) { - console.log(` ${pc.green('✓')} ${envVar} is already set: ${redactKey(existingKey)}`); + if (providerName === 'local-whisper') { + console.log( + ` ${pc.gray('No API key needed. Set RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL.')}` + ); } else { - console.log(` ${pc.yellow('!')} ${envVar} is not set.`); - console.log(`\n Add to your shell profile (~/.bashrc or ~/.zshrc):`); - console.log(` ${pc.cyan(`export ${envVar}=your_key_here`)}\n`); - console.log(` Then reload: ${pc.cyan('source ~/.bashrc')}\n`); - const ok = await confirm(' Continue without setting the key for now?', true); - if (!ok) { - console.log(pc.gray('\n Setup cancelled. Re-run after setting the API key.\n')); - process.exit(0); + const envVar = ENV_VAR[providerName]; + const existingKey = await getApiKey(providerName); + if (existingKey) { + console.log( + ` ${pc.green('✓')} ${envVar} is already set: ${redactKey(existingKey)}` + ); + } else { + const entered = await prompt( + ` Paste ${envVar} (stored in OS keychain), or leave blank: ` + ); + if (entered) { + const stored = await setSecret(envVar, entered); + console.log( + stored + ? ` ${pc.green('✓')} Stored in OS keychain` + : ` ${pc.yellow('!')} Keychain unavailable — set ${envVar} as an env var.` + ); + } else { + const ok = await confirm( + ' Continue without setting the key for now?', + true + ); + if (!ok) { + console.log( + pc.gray('\n Setup cancelled. Re-run after setting the API key.\n') + ); + return; + } + } } } - // Language console.log(`\n${pc.bold('3. Default language')}`); - console.log(` ${pc.gray('Leave blank for auto-detect (works for Spanish and English)')}`); const lang = await prompt(' Language code (e.g. es, en) [auto]: '); - // Output directory console.log(`\n${pc.bold('4. Recordings directory')}`); console.log(` ${pc.gray(`Default: ${paths.recordings}`)}`); const outDir = await prompt(' Directory [default]: '); - // Build config const config = RecmpConfigSchema.parse({}); config.provider.default = providerName; if (lang) config.transcription.defaultLanguage = lang; - if (outDir) config.output.recordingDir = outDir; - else config.output.recordingDir = paths.recordings; + config.output.recordingDir = outDir || paths.recordings; await mkdir(dirname(configFilePath), { recursive: true }); await mkdir(config.output.recordingDir, { recursive: true }); await saveConfig(config); console.log(`\n${pc.green('✓')} Config saved: ${configFilePath}`); - console.log(`${pc.green('✓')} Recordings directory: ${config.output.recordingDir}`); - - if (!existingKey) { - console.log(`\n${pc.yellow(' Next:')} Set ${envVar} in your environment, then run:`); - console.log(` ${pc.cyan('recmp3 doctor')} — verify setup`); - console.log(` ${pc.cyan('recmp3 record --transcribe')} — test it\n`); - } else { - console.log(`\n ${pc.cyan('recmp3 doctor')} — verify setup`); - console.log(` ${pc.cyan('recmp3 record --transcribe')} — start recording\n`); - } + console.log( + `${pc.green('✓')} Recordings directory: ${config.output.recordingDir}` + ); + console.log(`\n ${pc.cyan('recmp3 doctor')} — verify setup\n`); } -export async function runConfigShow(): Promise { +export async function runConfigShow(ctx: AgentContext): Promise { const config = await loadConfig(); - const groqKey = getApiKey('groq'); - const openaiKey = getApiKey('openai'); - - console.log(`\n${pc.bold('recmp3 configuration')}`); - console.log(pc.gray(`Config file: ${existsSync(configFilePath) ? configFilePath : `${configFilePath} (not found — using defaults)`}\n`)); - - console.log(`${pc.bold('Provider')}`); - console.log(` default: ${pc.cyan(config.provider.default)}`); - console.log(` groq model: ${config.provider.groq?.model ?? 'whisper-large-v3-turbo'}`); - console.log(` openai model:${config.provider.openai?.model ?? 'whisper-1'}`); - - console.log(`\n${pc.bold('API keys')}`); - console.log(` GROQ_API_KEY: ${groqKey ? pc.green(`set (${redactKey(groqKey)})`) : pc.red('not set')}`); - console.log(` OPENAI_API_KEY: ${openaiKey ? pc.green(`set (${redactKey(openaiKey)})`) : pc.gray('not set')}`); - - console.log(`\n${pc.bold('Audio')}`); - console.log(` source: ${config.audio.source}`); - console.log(` sample rate: ${config.audio.sampleRate} Hz`); - console.log(` channels: ${config.audio.channels} (mono)`); - - console.log(`\n${pc.bold('Output')}`); - console.log(` recordingDir: ${config.output.recordingDir}`); - console.log(` namePrefix: ${config.output.namePrefix}`); - console.log(` keepAudio: ${config.output.keepAudio}`); - - console.log(`\n${pc.bold('Transcription')}`); - console.log(` language: ${config.transcription.defaultLanguage ?? 'auto-detect'}`); - console.log(` chunking: ${config.transcription.chunking.enabled ? `enabled (${config.transcription.chunking.chunkSeconds}s chunks)` : 'disabled'}\n`); + const groqKey = await getApiKey('groq'); + const openaiKey = await getApiKey('openai'); + + const payload = { + configPath: existsSync(configFilePath) ? configFilePath : null, + provider: { + default: config.provider.default, + groqModel: config.provider.groq?.model ?? 'whisper-large-v3-turbo', + openaiModel: config.provider.openai?.model ?? 'whisper-1', + local: config.provider.local ?? null, + }, + keys: { + groq: groqKey ? redactKey(groqKey) : null, + openai: openaiKey ? redactKey(openaiKey) : null, + }, + audio: config.audio, + output: config.output, + transcription: config.transcription, + }; + + ctx.ok('config show', payload, () => { + console.log(`\n${pc.bold('recmp3 configuration')}`); + console.log( + pc.gray( + `Config file: ${payload.configPath ?? `${configFilePath} (not found — using defaults)`}\n` + ) + ); + console.log(`${pc.bold('Provider')}`); + console.log(` default: ${pc.cyan(config.provider.default)}`); + console.log(` groq model: ${payload.provider.groqModel}`); + console.log(` openai model:${payload.provider.openaiModel}`); + console.log(`\n${pc.bold('API keys')}`); + console.log( + ` GROQ_API_KEY: ${groqKey ? pc.green(`set (${redactKey(groqKey)})`) : pc.red('not set')}` + ); + console.log( + ` OPENAI_API_KEY: ${openaiKey ? pc.green(`set (${redactKey(openaiKey)})`) : pc.gray('not set')}` + ); + console.log(`\n${pc.bold('Audio')}`); + console.log(` source: ${config.audio.source}`); + console.log(`\n${pc.bold('Output')}`); + console.log(` recordingDir: ${config.output.recordingDir}`); + console.log(`\n${pc.bold('Transcription')}`); + console.log( + ` language: ${config.transcription.defaultLanguage ?? 'auto-detect'}\n` + ); + }); } -export async function runConfigPath(): Promise { - console.log(configFilePath); +export async function runConfigPath(ctx: AgentContext): Promise { + ctx.ok('config path', { path: configFilePath }, () => + console.log(configFilePath) + ); } -export async function runConfigSet(key: string, value: string): Promise { +export async function runConfigSet( + key: string, + value: string, + ctx: AgentContext +): Promise { const config = await loadConfig(); const parts = key.split('.'); let obj: Record = config as Record; - for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; - if (typeof obj[part] !== 'object' || obj[part] === null) { - obj[part] = {}; - } + if (typeof obj[part] !== 'object' || obj[part] === null) obj[part] = {}; obj = obj[part] as Record; } @@ -150,11 +264,56 @@ export async function runConfigSet(key: string, value: string): Promise { const parsed = RecmpConfigSchema.safeParse(config); if (!parsed.success) { - console.error(`${pc.red('✗')} Invalid config value: ${parsed.error.message}`); - process.exit(1); + throw new ConfigError(`Invalid config value: ${parsed.error.message}`); } await saveConfig(parsed.data); resetConfigCache(); - console.log(`${pc.green('✓')} Set ${key} = ${value}`); + ctx.ok('config set', { key, value }, () => + console.log(`${pc.green('✓')} Set ${key} = ${value}`) + ); +} + +export interface ConfigSetKeyOptions { + key?: string; +} + +export async function runConfigSetKey( + provider: string, + opts: ConfigSetKeyOptions, + ctx: AgentContext +): Promise { + if (provider !== 'groq' && provider !== 'openai') { + throw new InputError( + `Unknown provider: "${provider}". Valid: groq, openai.` + ); + } + + // Value precedence: --key → stdin (if piped) → *_API_KEY env. + let value = opts.key; + if (!value && process.stdin.isTTY !== true) { + value = (await readStdinText()).trim(); + } + if (!value) value = process.env[ENV_VAR[provider]]; + if (!value) { + throw new InputError( + 'No key provided. Use --key, pipe it on stdin, or set the *_API_KEY env var.' + ); + } + + if (!(await keychainAvailable())) { + throw new ConfigError( + 'OS keychain (keytar) is unavailable on this machine.' + ); + } + + await setSecret(ENV_VAR[provider], value); + ctx.ok( + 'config set-key', + { provider, stored: true, backend: 'keychain' }, + () => + console.log( + `${pc.green('✓')} Stored ${ENV_VAR[provider]} in the OS keychain (${redactKey(value!)})` + ) + ); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 5c227ea..08ebcc0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,9 +1,11 @@ +import { existsSync } from 'node:fs'; import pc from 'picocolors'; -import { checkFfmpegVersion, findFfmpeg, supportsInputFormat } from '../audio/ffmpeg.js'; -import { loadConfig, getApiKey } from '../config/load.js'; +import type { AgentContext } from '../agent/context.js'; +import { checkFfmpegVersion, supportsInputFormat } from '../audio/ffmpeg.js'; +import { getApiKey, loadConfig } from '../config/load.js'; import { configFilePath } from '../config/paths.js'; +import type { RecmpConfig } from '../config/schema.js'; import { redactKey } from '../log.js'; -import { existsSync } from 'fs'; interface Check { label: string; @@ -22,11 +24,8 @@ function printCheck(check: Check) { } } -export async function runDoctor(): Promise { - console.log(`\n${pc.bold('recmp3 doctor — preflight checks')}\n`); - +export async function runDoctor(ctx: AgentContext): Promise { const checks: Check[] = []; - let allOk = true; // 1. Node version const nodeVersion = process.version; @@ -36,19 +35,27 @@ export async function runDoctor(): Promise { label: 'Node.js version', ok: nodeOk, detail: nodeVersion, - hint: nodeOk ? undefined : 'Requires Node.js 20+. Visit https://nodejs.org/', + hint: nodeOk + ? undefined + : 'Requires Node.js 20+. Visit https://nodejs.org/', }); // 2. Platform const platform = process.platform; - const platformLabels: Record = { linux: 'Linux', darwin: 'macOS', win32: 'Windows' }; + const platformLabels: Record = { + linux: 'Linux', + darwin: 'macOS', + win32: 'Windows', + }; const platformLabel = platformLabels[platform] ?? platform; const platformSupported = ['linux', 'darwin', 'win32'].includes(platform); checks.push({ label: 'Platform', ok: platformSupported, detail: `${platformLabel} (${process.arch})`, - hint: platformSupported ? undefined : `Platform "${platform}" may not be fully supported.`, + hint: platformSupported + ? undefined + : `Platform "${platform}" may not be fully supported.`, }); // 3. ffmpeg @@ -57,19 +64,29 @@ export async function runDoctor(): Promise { label: 'ffmpeg', ok: ffmpegCheck.meets, detail: ffmpegCheck.version, - hint: ffmpegCheck.meets ? undefined : 'Requires ffmpeg 4.4+. Install: sudo apt install ffmpeg', + hint: ffmpegCheck.meets + ? undefined + : 'Requires ffmpeg 4.4+. Install: sudo apt install ffmpeg', }); // 4. Audio backend if (ffmpegCheck.meets) { - const backendFormats: Record = { linux: 'pulse', darwin: 'avfoundation', win32: 'dshow' }; + const backendFormats: Record = { + linux: 'pulse', + darwin: 'avfoundation', + win32: 'dshow', + }; const backendFormat = backendFormats[platform] ?? 'pulse'; - const backendOk = await supportsInputFormat(backendFormat).catch(() => false); + const backendOk = await supportsInputFormat(backendFormat).catch( + () => false + ); checks.push({ label: 'Audio backend', ok: backendOk, detail: backendFormat, - hint: backendOk ? undefined : `ffmpeg missing "${backendFormat}" input support. Reinstall ffmpeg.`, + hint: backendOk + ? undefined + : `ffmpeg missing "${backendFormat}" input support. Reinstall ffmpeg.`, }); } @@ -78,11 +95,13 @@ export async function runDoctor(): Promise { checks.push({ label: 'Config file', ok: true, - detail: configExists ? configFilePath : `${configFilePath} (using defaults)`, + detail: configExists + ? configFilePath + : `${configFilePath} (using defaults)`, }); // 6. Load config - let config; + let config: RecmpConfig | undefined; try { config = await loadConfig(); } catch (err: unknown) { @@ -92,68 +111,96 @@ export async function runDoctor(): Promise { detail: err instanceof Error ? err.message : String(err), hint: 'Run: recmp3 config init', }); - allOk = false; } if (config) { - // 7. Provider const providerName = config.provider.default; - const apiKey = getApiKey(providerName); - const keyOk = Boolean(apiKey); - - checks.push({ - label: `Provider: ${providerName}`, - ok: keyOk, - detail: keyOk ? `API key set (${redactKey(apiKey!)})` : 'API key not set', - hint: keyOk ? undefined : - `Set ${providerName === 'groq' ? 'GROQ_API_KEY' : 'OPENAI_API_KEY'} env var, or run: recmp3 config init`, - }); - // 8. Network ping (only if key is set) - if (keyOk) { - process.stdout.write(` ${pc.gray('○')} ${pc.bold('Provider ping'.padEnd(30))} ${pc.gray('checking...')}\r`); - try { - const { createProvider } = await import('../transcription/registry.js'); - const provider = createProvider(config); - const ping = provider.ping ? await provider.ping() : null; - if (ping) { + if (providerName === 'local-whisper') { + // 7/8. Local whisper: binary + model present (no network) + const { LocalWhisperProvider } = await import( + '../transcription/local-whisper.js' + ); + const provider = new LocalWhisperProvider(config.provider.local ?? {}); + const ping = await provider.ping(); + checks.push({ + label: 'Provider: local-whisper', + ok: ping.ok, + detail: ping.ok ? `ready (${ping.latencyMs}ms)` : ping.error, + hint: ping.ok + ? undefined + : 'Set RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL.', + }); + } else { + // 7. Cloud provider API key + const apiKey = await getApiKey(providerName); + const keyOk = Boolean(apiKey); + checks.push({ + label: `Provider: ${providerName}`, + ok: keyOk, + detail: keyOk + ? `API key set (${redactKey(apiKey!)})` + : 'API key not set', + hint: keyOk + ? undefined + : `Set ${providerName === 'groq' ? 'GROQ_API_KEY' : 'OPENAI_API_KEY'}, or run: recmp3 config set-key ${providerName}`, + }); + + // 8. Network ping (only if key is set) + if (keyOk) { + try { + const { createProvider } = await import( + '../transcription/registry.js' + ); + const provider = await createProvider(config); + const ping = provider.ping ? await provider.ping() : null; + if (ping) { + checks.push({ + label: 'Provider ping', + ok: ping.ok, + detail: ping.ok ? `${ping.latencyMs}ms` : ping.error, + hint: ping.ok + ? undefined + : 'Check network connection or API key validity.', + }); + } + } catch (err: unknown) { checks.push({ - label: `Provider ping`, - ok: ping.ok, - detail: ping.ok ? `${ping.latencyMs}ms` : ping.error, - hint: ping.ok ? undefined : 'Check network connection or API key validity.', + label: 'Provider ping', + ok: false, + detail: err instanceof Error ? err.message : String(err), + hint: 'Check network connectivity.', }); } - } catch (err: unknown) { - checks.push({ - label: 'Provider ping', - ok: false, - detail: err instanceof Error ? err.message : String(err), - hint: 'Check network connectivity.', - }); } } // 9. Recordings directory - const recDir = config.output.recordingDir!; checks.push({ label: 'Recordings directory', ok: true, - detail: recDir, + detail: config.output.recordingDir!, }); } - // Print all checks - for (const check of checks) { - printCheck(check); - if (!check.ok) allOk = false; - } + const allOk = checks.every((c) => c.ok); + + ctx.ok('doctor', { ok: allOk, checks }, () => { + console.log(`\n${pc.bold('recmp3 doctor — preflight checks')}\n`); + for (const check of checks) printCheck(check); + console.log(''); + if (allOk) { + console.log( + pc.green(' ✓ All checks passed. Run: recmp3 record --transcribe\n') + ); + } else { + console.log( + pc.yellow( + ' Some checks failed. Address the issues above and re-run: recmp3 doctor\n' + ) + ); + } + }); - console.log(''); - if (allOk) { - console.log(pc.green(` ✓ All checks passed. Run: recmp3 record --transcribe\n`)); - } else { - console.log(pc.yellow(` Some checks failed. Address the issues above and re-run: recmp3 doctor\n`)); - process.exit(1); - } + if (!allOk) process.exitCode = 1; } diff --git a/src/commands/manifest.ts b/src/commands/manifest.ts new file mode 100644 index 0000000..5c0ef16 --- /dev/null +++ b/src/commands/manifest.ts @@ -0,0 +1,8 @@ +import type { AgentContext } from '../agent/context.js'; +import { MANIFEST } from '../agent/manifest.js'; + +export async function runManifest(ctx: AgentContext): Promise { + ctx.ok('manifest', MANIFEST, () => { + process.stdout.write(`${JSON.stringify(MANIFEST, null, 2)}\n`); + }); +} diff --git a/src/commands/prompt.ts b/src/commands/prompt.ts index d1462bc..d1c6a0d 100644 --- a/src/commands/prompt.ts +++ b/src/commands/prompt.ts @@ -1,13 +1,19 @@ -import { existsSync, readFileSync } from 'fs'; -import { writeFile } from 'fs/promises'; -import { basename } from 'path'; +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; import pc from 'picocolors'; +import type { AgentContext } from '../agent/context.js'; +import { readStdinText } from '../agent/stdin.js'; +import { InputError } from '../errors.js'; import { copyToClipboard } from '../output/clipboard.js'; const TEMPLATES: Record string> = { raw: (text) => text, - 'claude-code': (text, name) => `# Claude Code Prompt${name ? ` — ${name}` : ''} + 'claude-code': ( + text, + name + ) => `# Claude Code Prompt${name ? ` — ${name}` : ''} ## Objective ${text} @@ -37,7 +43,10 @@ ${text} [Note any architectural decisions made during implementation] `, - prd: (text, name) => `# Product Requirements Document${name ? ` — ${name}` : ''} + prd: ( + text, + name + ) => `# Product Requirements Document${name ? ` — ${name}` : ''} **Created:** ${new Date().toISOString().split('T')[0]} **Status:** Draft @@ -144,7 +153,9 @@ export function listTemplates(): void { for (const name of Object.keys(TEMPLATES)) { console.log(` ${pc.cyan(name)}`); } - console.log(`\n Usage: recmp3 prompt --template claude-code\n`); + console.log( + '\n Usage: recmp3 prompt --template claude-code\n' + ); } export interface PromptOptions { @@ -154,39 +165,52 @@ export interface PromptOptions { listTemplates?: boolean; } -export async function runPrompt(transcriptFile: string, opts: PromptOptions = {}): Promise { +export async function runPrompt( + transcriptFile: string, + opts: PromptOptions, + ctx: AgentContext +): Promise { if (opts.listTemplates) { listTemplates(); return; } - if (!existsSync(transcriptFile)) { - console.error(`${pc.red('✗')} File not found: ${transcriptFile}`); - process.exit(1); - } - const templateName = opts.template ?? 'claude-code'; const templateFn = TEMPLATES[templateName]; if (!templateFn) { - console.error(`${pc.red('✗')} Unknown template: "${templateName}"`); - console.error(` Available: ${Object.keys(TEMPLATES).join(', ')}`); - process.exit(1); + throw new InputError( + `Unknown template: "${templateName}". Available: ${Object.keys(TEMPLATES).join(', ')}` + ); + } + + // "-" reads transcript text from stdin (composes with `transcribe ... | prompt -`). + let text: string; + let name: string | undefined; + if (transcriptFile === '-') { + text = (await readStdinText()).trim(); + if (!text) throw new InputError('No text received on stdin.'); + } else { + if (!existsSync(transcriptFile)) { + throw new InputError(`File not found: ${transcriptFile}`); + } + text = readFileSync(transcriptFile, 'utf-8').trim(); + name = basename(transcriptFile, '.txt'); } - const text = readFileSync(transcriptFile, 'utf-8').trim(); - const name = basename(transcriptFile, '.txt'); const output = templateFn(text, name); if (opts.out) { await writeFile(opts.out, output, 'utf-8'); - console.error(`${pc.green('✓')} Written to: ${opts.out}`); + ctx.note(`${pc.green('✓')} Written to: ${opts.out}\n`); } - process.stdout.write(output); - if (opts.copy) { const copied = await copyToClipboard(output); - if (copied) console.error(pc.gray(' Copied to clipboard.')); + if (copied) ctx.note(pc.gray(' Copied to clipboard.\n')); } + + ctx.ok('prompt', { template: templateName, output }, () => + process.stdout.write(output) + ); } diff --git a/src/commands/record.ts b/src/commands/record.ts index c310205..a69e680 100644 --- a/src/commands/record.ts +++ b/src/commands/record.ts @@ -1,18 +1,20 @@ -import { mkdir, rm } from 'fs/promises'; -import { join } from 'path'; -import { tmpdir, platform } from 'os'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import pc from 'picocolors'; -import { loadConfig } from '../config/load.js'; +import type { AgentContext } from '../agent/context.js'; import { getAudioFactory } from '../audio/capture.js'; -import { runRecorderTUI } from '../tui/recorder.js'; -import { generateRecordingName, buildOutputPath } from '../output/filenames.js'; +import { loadConfig } from '../config/load.js'; +import type { RecmpConfig } from '../config/schema.js'; +import { ensureUploadConsent } from '../consent.js'; +import { RecmpError } from '../errors.js'; import { copyToClipboard } from '../output/clipboard.js'; +import { buildOutputPath, generateRecordingName } from '../output/filenames.js'; import { writeTranscriptFiles } from '../output/writer.js'; -import { createProvider } from '../transcription/registry.js'; import { transcribeWithChunking } from '../transcription/chunking.js'; -import { ensureUploadConsent } from '../consent.js'; -import { RecmpError, UserAbortError } from '../errors.js'; +import { createProvider, providerUploads } from '../transcription/registry.js'; +import { type RecorderResult, runRecorderTUI } from '../tui/recorder.js'; export interface RecordOptions { name?: string; @@ -21,25 +23,137 @@ export interface RecordOptions { mp3?: boolean; provider?: string; lang?: string; + duration?: string; + tui?: boolean; // commander sets false for --no-tui copy?: boolean; print?: boolean; - yes?: boolean; } -export async function runRecord(opts: RecordOptions = {}): Promise { - if (!process.stdout.isTTY && !process.env.RECMP3_PLAIN) { - console.error( - pc.red('✗') + ' recmp3 record requires an interactive terminal (TTY).\n' + - ' To transcribe an existing file in a pipe, use: recmp3 transcribe ', - ); - process.exit(1); - } +interface TranscriptionPayload { + text: string; + provider: string; + model: string; + language?: string; + durationSec?: number; + latencyMs: number; + segments?: unknown; + transcriptPath?: string; +} +export async function runRecord( + opts: RecordOptions, + ctx: AgentContext +): Promise { const config = await loadConfig(); + const durationSec = opts.duration ? Number(opts.duration) : undefined; + const headless = + opts.tui === false || + durationSec !== undefined || + ctx.json || + process.stdout.isTTY !== true; + const outDir = opts.out ?? config.output.recordingDir!; await mkdir(outDir, { recursive: true }); + if (headless) { + await recordHeadless(opts, ctx, config, outDir, durationSec); + } else { + await recordTui(opts, ctx, config, outDir); + } +} + +/** Headless capture: no Ink TUI, records for --duration seconds or until SIGINT. */ +async function recordHeadless( + opts: RecordOptions, + ctx: AgentContext, + config: RecmpConfig, + outDir: string, + durationSec?: number +): Promise { + if (opts.mp3) + ctx.note(pc.yellow(' --mp3 is ignored in headless mode; saving WAV.\n')); + + const filename = generateRecordingName({ + name: opts.name, + prefix: config.output.namePrefix, + ext: 'wav', + }); + const outputPath = buildOutputPath(outDir, filename); + + const factory = await getAudioFactory(); + const capture = factory.create(); + const source = config.audio.source; + + try { + await capture.start({ + source, + outputPath, + sampleRate: 16000, + channels: 1, + format: 'wav', + }); + } catch (err: unknown) { + if (err instanceof RecmpError) throw err; + throw new RecmpError( + 'AUDIO_START_FAILED', + `Failed to start recording: ${err instanceof Error ? err.message : String(err)}`, + 3 + ); + } + + ctx.note( + pc.cyan( + durationSec !== undefined + ? ` Recording for ${durationSec}s...\n` + : ' Recording... press Ctrl+C to stop.\n' + ) + ); + + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + process.off('SIGINT', finish); + if (timer) clearTimeout(timer); + resolve(); + }; + const timer = + durationSec !== undefined ? setTimeout(finish, durationSec * 1000) : null; + process.on('SIGINT', finish); + }); + + const segment = await capture.stop(); + await capture.dispose().catch(() => {}); + + ctx.note(`${pc.green('✓')} Saved: ${segment.path}\n`); + + const transcription = opts.transcribe + ? await transcribeRecording(segment.path, opts, ctx, config) + : undefined; + + ctx.ok( + 'record', + { + audioPath: segment.path, + durationSec: segment.durationSec, + sizeBytes: segment.sizeBytes, + transcription, + }, + () => { + if (transcription) process.stdout.write(`${transcription.text}\n`); + } + ); +} + +/** Interactive Ink TUI path (unchanged behavior). */ +async function recordTui( + opts: RecordOptions, + ctx: AgentContext, + config: RecmpConfig, + outDir: string +): Promise { const ext = opts.mp3 ? 'mp3' : 'wav'; const filename = generateRecordingName({ name: opts.name, @@ -48,17 +162,14 @@ export async function runRecord(opts: RecordOptions = {}): Promise { }); const outputPath = buildOutputPath(outDir, filename); - // Intermediate segments go to a temp dir const sessionId = randomBytes(4).toString('hex'); const tmpDir = join(tmpdir(), `recmp3-${sessionId}`); await mkdir(tmpDir, { recursive: true }); const factory = await getAudioFactory(); const capture = factory.create(); - const source = config.audio.source; - // Start the first segment immediately const firstSegPath = join(tmpDir, 'segment-0001.wav'); try { await capture.start({ @@ -71,77 +182,99 @@ export async function runRecord(opts: RecordOptions = {}): Promise { } catch (err: unknown) { await rm(tmpDir, { recursive: true, force: true }); if (err instanceof RecmpError) throw err; - throw new RecmpError('AUDIO_START_FAILED', `Failed to start recording: ${err instanceof Error ? err.message : String(err)}`); + throw new RecmpError( + 'AUDIO_START_FAILED', + `Failed to start recording: ${err instanceof Error ? err.message : String(err)}`, + 3 + ); } - let result; + let result: RecorderResult; try { result = await runRecorderTUI( capture, { source, sampleRate: 16000, channels: 1, format: 'wav', tmpDir }, - outputPath, + outputPath ); } finally { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } if (result.cancelled || !result.outputPath) { - process.stdout.write(pc.gray(' Recording cancelled.\n')); + ctx.note(pc.gray(' Recording cancelled.\n')); return; } - // Print saved path process.stdout.write(`\n${pc.green('✓')} Saved: ${result.outputPath}\n`); - // Transcribe if requested if (opts.transcribe) { - if (opts.yes !== false) { - await ensureUploadConsent({ yes: opts.yes }); - } + const transcription = await transcribeRecording( + result.outputPath, + opts, + ctx, + config + ); + const shouldPrint = opts.print !== false && config.ui.printOnTranscribe; + if (shouldPrint) process.stdout.write(`\n${transcription.text}\n\n`); + } +} - process.stdout.write(pc.cyan(' Transcribing...') + '\n'); - - try { - const providerConfig = { ...config }; - if (opts.provider) { - (providerConfig.provider as { default: string }).default = opts.provider; - } - - const provider = createProvider(providerConfig); - const transcription = await transcribeWithChunking(provider, { - audioPath: result.outputPath, - language: opts.lang ?? config.transcription.defaultLanguage, - responseFormat: 'verbose_json', - }, config.transcription.chunking.chunkSeconds); - - if (config.output.saveTranscriptToFile) { - const { txtPath } = await writeTranscriptFiles(result.outputPath, transcription); - process.stdout.write(`${pc.green('✓')} Transcript: ${txtPath}\n`); - } - - const shouldPrint = opts.print !== false && config.ui.printOnTranscribe; - const shouldCopy = opts.copy !== false && config.ui.clipboardOnTranscribe; - - if (shouldPrint) { - process.stdout.write('\n' + transcription.text + '\n\n'); - } - - if (shouldCopy) { - const copied = await copyToClipboard(transcription.text); - if (copied) { - process.stdout.write(pc.gray(' Copied to clipboard.\n')); - } - } - - process.stdout.write( - pc.gray(` Provider: ${transcription.provider} · Model: ${transcription.model} · ${(transcription.latencyMs / 1000).toFixed(1)}s\n`), - ); - } catch (err: unknown) { - if (err instanceof RecmpError) { - process.stderr.write(`${pc.red('✗')} ${err.message}\n`); - process.exit(err.exitCode); - } - throw err; - } +/** Shared transcription step for both record paths. */ +async function transcribeRecording( + audioPath: string, + opts: RecordOptions, + ctx: AgentContext, + config: RecmpConfig +): Promise { + const providerConfig = { ...config }; + if (opts.provider) { + (providerConfig.provider as { default: string }).default = opts.provider; + } + + if (providerUploads(providerConfig.provider.default)) { + await ensureUploadConsent(ctx); } + + ctx.note(pc.cyan(' Transcribing...\n')); + + const provider = await createProvider(providerConfig); + const transcription = await transcribeWithChunking( + provider, + { + audioPath, + language: opts.lang ?? config.transcription.defaultLanguage, + responseFormat: 'verbose_json', + }, + config.transcription.chunking.chunkSeconds + ); + + let transcriptPath: string | undefined; + if (config.output.saveTranscriptToFile) { + const { txtPath } = await writeTranscriptFiles(audioPath, transcription); + transcriptPath = txtPath; + ctx.note(`${pc.green('✓')} Transcript: ${txtPath}\n`); + } + + const shouldCopy = opts.copy !== false && config.ui.clipboardOnTranscribe; + if (shouldCopy) { + const copied = await copyToClipboard(transcription.text); + if (copied) ctx.note(pc.gray(' Copied to clipboard.\n')); + } + + ctx.note( + pc.gray( + ` Provider: ${transcription.provider} · Model: ${transcription.model} · ${(transcription.latencyMs / 1000).toFixed(1)}s\n` + ) + ); + + return { + text: transcription.text, + provider: transcription.provider, + model: transcription.model, + language: transcription.language, + durationSec: transcription.durationSec, + latencyMs: transcription.latencyMs, + segments: transcription.segments, + transcriptPath, + }; } diff --git a/src/commands/sources.ts b/src/commands/sources.ts index 0c0a57b..413776f 100644 --- a/src/commands/sources.ts +++ b/src/commands/sources.ts @@ -1,50 +1,55 @@ import pc from 'picocolors'; +import type { AgentContext } from '../agent/context.js'; import { getAudioFactory } from '../audio/capture.js'; -import { AudioSource } from '../audio/types.js'; +import type { AudioSource } from '../audio/types.js'; +import { RecmpError } from '../errors.js'; -export interface SourcesOptions { - json?: boolean; -} - -export async function runSources(opts: SourcesOptions = {}): Promise { +export async function runSources(ctx: AgentContext): Promise { let sources: AudioSource[] = []; try { const factory = await getAudioFactory(); sources = await factory.listSources(); } catch (err: unknown) { - if (opts.json) { - process.stdout.write(JSON.stringify({ error: String(err), sources: [] }, null, 2) + '\n'); - } else { - console.error(`${pc.red('✗')} Failed to list audio sources: ${err instanceof Error ? err.message : String(err)}`); - console.error(` Make sure ffmpeg is installed: sudo apt install ffmpeg`); - } - process.exit(1); - } - - if (opts.json) { - process.stdout.write(JSON.stringify(sources, null, 2) + '\n'); - return; + throw err instanceof RecmpError + ? err + : new RecmpError( + 'AUDIO_CAPTURE_ERROR', + `Failed to list audio sources: ${err instanceof Error ? err.message : String(err)}. Make sure ffmpeg is installed.`, + 3 + ); } const platform = process.platform; - const platformLabels: Record = { linux: 'Linux (PulseAudio/PipeWire)', darwin: 'macOS (AVFoundation)', win32: 'Windows (DirectShow)' }; - const platformLabel = platformLabels[platform] ?? platform; - console.log(`\n${pc.bold('Audio sources')} on ${platformLabel}:\n`); - - if (sources.length === 0) { - console.log(pc.yellow(' No audio sources found. Check your audio hardware.')); - return; - } + ctx.ok('sources', { platform, sources }, () => { + const platformLabels: Record = { + linux: 'Linux (PulseAudio/PipeWire)', + darwin: 'macOS (AVFoundation)', + win32: 'Windows (DirectShow)', + }; + const platformLabel = platformLabels[platform] ?? platform; + + console.log(`\n${pc.bold('Audio sources')} on ${platformLabel}:\n`); + + if (sources.length === 0) { + console.log( + pc.yellow(' No audio sources found. Check your audio hardware.') + ); + return; + } - for (const source of sources) { - const marker = source.isDefault ? pc.green(' (default)') : ''; - const id = pc.cyan(source.id); - const label = source.label !== source.id ? pc.gray(` — ${source.label}`) : ''; - console.log(` ${id}${label}${marker}`); - } + for (const source of sources) { + const marker = source.isDefault ? pc.green(' (default)') : ''; + const id = pc.cyan(source.id); + const label = + source.label !== source.id ? pc.gray(` — ${source.label}`) : ''; + console.log(` ${id}${label}${marker}`); + } - console.log(`\n${pc.gray(' Use with: recmp3 record --source ')}`); - console.log(pc.gray(' Or set: RECMP3_SOURCE= in your environment\n')); + console.log(`\n${pc.gray(' Use with: recmp3 record --source ')}`); + console.log( + pc.gray(' Or set: RECMP3_SOURCE= in your environment\n') + ); + }); } diff --git a/src/commands/transcribe.ts b/src/commands/transcribe.ts index 7d0a9d2..9205c4b 100644 --- a/src/commands/transcribe.ts +++ b/src/commands/transcribe.ts @@ -1,59 +1,89 @@ -import { existsSync } from 'fs'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import pc from 'picocolors'; +import type { AgentContext } from '../agent/context.js'; +import { readStdinBuffer } from '../agent/stdin.js'; import { loadConfig } from '../config/load.js'; -import { createProvider } from '../transcription/registry.js'; -import { transcribeWithChunking } from '../transcription/chunking.js'; -import { writeTranscriptFiles } from '../output/writer.js'; -import { copyToClipboard } from '../output/clipboard.js'; import { ensureUploadConsent } from '../consent.js'; -import { RecmpError } from '../errors.js'; +import { InputError } from '../errors.js'; +import { copyToClipboard } from '../output/clipboard.js'; +import { writeTranscriptFiles } from '../output/writer.js'; +import { transcribeWithChunking } from '../transcription/chunking.js'; +import { createProvider, providerUploads } from '../transcription/registry.js'; export interface TranscribeOptions { provider?: string; lang?: string; - json?: boolean; copy?: boolean; - yes?: boolean; } -export async function runTranscribe(audioFile: string, opts: TranscribeOptions = {}): Promise { - if (!existsSync(audioFile)) { - console.error(`${pc.red('✗')} File not found: ${audioFile}`); - process.exit(1); +export async function runTranscribe( + audioFile: string, + opts: TranscribeOptions, + ctx: AgentContext +): Promise { + // Resolve the input path; "-" streams audio from stdin into a temp file. + let path = audioFile; + let tmpFromStdin: string | null = null; + if (audioFile === '-') { + const buf = await readStdinBuffer(); + if (buf.length === 0) throw new InputError('No audio received on stdin.'); + const dir = await mkdtemp(join(tmpdir(), 'recmp3-stdin-')); + tmpFromStdin = join(dir, 'input.wav'); + await writeFile(tmpFromStdin, buf); + path = tmpFromStdin; + } else if (!existsSync(audioFile)) { + throw new InputError(`File not found: ${audioFile}`); } - await ensureUploadConsent({ yes: opts.yes }); - - const config = await loadConfig(); + try { + const config = await loadConfig(); + if (opts.provider) { + (config.provider as { default: string }).default = opts.provider; + } - if (opts.provider) { - (config.provider as { default: string }).default = opts.provider; - } + if (providerUploads(config.provider.default)) { + await ensureUploadConsent(ctx); + } - const provider = createProvider(config); + const provider = await createProvider(config); - process.stderr.write(pc.cyan(` Transcribing with ${provider.name} (${config.provider.default === 'groq' ? config.provider.groq?.model ?? 'whisper-large-v3-turbo' : config.provider.openai?.model ?? 'whisper-1'})...\n`)); + ctx.note(pc.cyan(` Transcribing with ${provider.name}...\n`)); - try { const result = await transcribeWithChunking( provider, { - audioPath: audioFile, + audioPath: path, language: opts.lang ?? config.transcription.defaultLanguage, responseFormat: 'verbose_json', }, - config.transcription.chunking.chunkSeconds, + config.transcription.chunking.chunkSeconds ); - if (config.output.saveTranscriptToFile) { - const { txtPath, jsonPath } = await writeTranscriptFiles(audioFile, result); - process.stderr.write(`${pc.green('✓')} Transcript saved: ${txtPath}\n`); - if (opts.json) process.stderr.write(`${pc.green('✓')} JSON saved: ${jsonPath}\n`); + let transcriptPath: string | undefined; + // Persist transcript only for real on-disk inputs (not piped stdin). + if (config.output.saveTranscriptToFile && !tmpFromStdin) { + const { txtPath } = await writeTranscriptFiles(audioFile, result); + transcriptPath = txtPath; + ctx.note(`${pc.green('✓')} Transcript saved: ${txtPath}\n`); + } + + if (opts.copy) { + const copied = await copyToClipboard(result.text); + if (copied) ctx.note(pc.gray(' Copied to clipboard.\n')); } - // stdout is for the transcript text — pipeable - if (opts.json) { - process.stdout.write(JSON.stringify({ + ctx.note( + pc.gray( + ` ${provider.name} · ${result.model} · ${(result.latencyMs / 1000).toFixed(1)}s\n` + ) + ); + + ctx.ok( + 'transcribe', + { text: result.text, provider: result.provider, model: result.model, @@ -61,27 +91,17 @@ export async function runTranscribe(audioFile: string, opts: TranscribeOptions = durationSec: result.durationSec, latencyMs: result.latencyMs, segments: result.segments, - }, null, 2) + '\n'); - } else { - process.stdout.write(result.text + '\n'); - } - - if (opts.copy) { - const copied = await copyToClipboard(result.text); - if (copied) process.stderr.write(pc.gray(' Copied to clipboard.\n')); - } - - process.stderr.write( - pc.gray(` ${provider.name} · ${result.model} · ${(result.latencyMs / 1000).toFixed(1)}s\n`), + transcriptPath, + }, + // Human mode: transcript text on stdout (pipeable), nothing else. + () => process.stdout.write(`${result.text}\n`) ); - } catch (err: unknown) { - if (err instanceof RecmpError) { - console.error(`${pc.red('✗')} ${err.message}`); - if (err.message.includes('not set')) { - console.error(` Run: recmp3 config init`); - } - process.exit(err.exitCode); + } finally { + if (tmpFromStdin) { + await rm(join(tmpFromStdin, '..'), { + recursive: true, + force: true, + }).catch(() => {}); } - throw err; } } diff --git a/src/config/load.ts b/src/config/load.ts index 10fbfa6..2e7f0cf 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -1,8 +1,8 @@ -import { existsSync, readFileSync } from 'fs'; -import { mkdir, readFile, writeFile } from 'fs/promises'; -import { dirname, join } from 'path'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { configFilePath, paths } from './paths.js'; -import { RecmpConfig, RecmpConfigSchema } from './schema.js'; +import { type RecmpConfig, RecmpConfigSchema } from './schema.js'; let _config: RecmpConfig | null = null; @@ -36,6 +36,20 @@ function applyEnvOverrides(config: RecmpConfig): RecmpConfig { clone.output.recordingDir = process.env.RECMP3_OUTDIR; } + if (process.env.RECMP3_WHISPER_BIN) { + clone.provider.local = { + ...clone.provider.local, + binPath: process.env.RECMP3_WHISPER_BIN, + }; + } + + if (process.env.RECMP3_WHISPER_MODEL) { + clone.provider.local = { + ...clone.provider.local, + modelPath: process.env.RECMP3_WHISPER_MODEL, + }; + } + return clone; } @@ -75,7 +89,11 @@ export function resetConfigCache() { export async function saveConfig(config: RecmpConfig): Promise { await mkdir(dirname(configFilePath), { recursive: true }); - await writeFile(configFilePath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + await writeFile( + configFilePath, + `${JSON.stringify(config, null, 2)}\n`, + 'utf-8' + ); _config = config; } @@ -90,12 +108,20 @@ export async function loadConfigFile(): Promise { } } -export function getApiKey(provider: 'groq' | 'openai'): string | undefined { - if (provider === 'groq') { - return process.env.GROQ_API_KEY; - } - if (provider === 'openai') { - return process.env.OPENAI_API_KEY; - } - return undefined; +const ENV_VAR: Record<'groq' | 'openai', string> = { + groq: 'GROQ_API_KEY', + openai: 'OPENAI_API_KEY', +}; + +/** + * Resolve an API key with precedence: environment variable → OS keychain → undefined. + * Env always wins so CI/agent overrides are honored without touching the keychain. + */ +export async function getApiKey( + provider: 'groq' | 'openai' +): Promise { + const fromEnv = process.env[ENV_VAR[provider]]; + if (fromEnv) return fromEnv; + const { getSecret } = await import('../secrets/keychain.js'); + return getSecret(ENV_VAR[provider]); } diff --git a/src/config/paths.ts b/src/config/paths.ts index 7e627ac..aacacb1 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,5 +1,5 @@ -import { homedir } from 'os'; -import { join } from 'path'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; function xdgConfigHome(): string { return process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'); @@ -13,7 +13,12 @@ function appPaths() { const platform = process.platform; if (platform === 'darwin') { - const appSupport = join(homedir(), 'Library', 'Application Support', 'recmp3'); + const appSupport = join( + homedir(), + 'Library', + 'Application Support', + 'recmp3' + ); return { config: join(homedir(), 'Library', 'Preferences', 'recmp3'), data: appSupport, @@ -23,7 +28,8 @@ function appPaths() { } if (platform === 'win32') { - const appData = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'); + const appData = + process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'); const base = join(appData, 'recmp3'); return { config: base, diff --git a/src/config/schema.ts b/src/config/schema.ts index 6062a23..3a763b8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -4,7 +4,7 @@ export const RecmpConfigSchema = z.object({ version: z.literal(1).default(1), provider: z .object({ - default: z.enum(['groq', 'openai']).default('groq'), + default: z.enum(['groq', 'openai', 'local-whisper']).default('groq'), groq: z .object({ model: z.string().default('whisper-large-v3-turbo'), @@ -19,6 +19,13 @@ export const RecmpConfigSchema = z.object({ timeoutMs: z.number().optional(), }) .optional(), + local: z + .object({ + binPath: z.string().optional(), + modelPath: z.string().optional(), + language: z.string().optional(), + }) + .optional(), }) .default({}), audio: z diff --git a/src/consent.ts b/src/consent.ts index 50417cf..12208e2 100644 --- a/src/consent.ts +++ b/src/consent.ts @@ -1,39 +1,42 @@ -import { createInterface } from 'readline'; +import { createInterface } from 'node:readline'; import pc from 'picocolors'; +import type { AgentContext } from './agent/context.js'; import { loadConfig, saveConfig } from './config/load.js'; -export async function ensureUploadConsent(opts: { yes?: boolean } = {}): Promise { +export async function ensureUploadConsent(ctx: AgentContext): Promise { const config = await loadConfig(); if (config.consent.uploadsAcknowledged) return; - if (opts.yes || process.env.RECMP3_SKIP_CONSENT === '1') { - // Auto-acknowledge in non-interactive mode + + if (ctx.yes) { + // Auto-acknowledge in non-interactive / agent mode config.consent.uploadsAcknowledged = true; config.consent.acknowledgedAt = new Date().toISOString(); await saveConfig(config).catch(() => {}); return; } - if (!process.stdout.isTTY) { - // Non-interactive — warn and continue (don't block scripts) - process.stderr.write( - pc.yellow('⚠ recmp3 will upload audio to the configured provider for transcription.\n') + - ' Set RECMP3_SKIP_CONSENT=1 to suppress this warning in scripts.\n', + if (!process.stdout.isTTY || ctx.json) { + // Non-interactive — warn (on stderr) and continue; never block a pipe. + ctx.note( + `${pc.yellow('⚠ recmp3 will upload audio to the configured provider for transcription.\n')} Pass --yes or set RECMP3_YES=1 to suppress this in scripts.\n` ); return; } process.stdout.write( - '\n' + - pc.bold(' recmp3 will upload your audio to the transcription provider.\n') + - pc.gray(' Audio is transmitted over HTTPS. Provider data retention terms apply.\n') + - pc.gray(` Current provider: ${config.provider.default}\n`) + - '\n Continue? [Y/n] ', + `\n${pc.bold(' recmp3 will upload your audio to the transcription provider.\n')}${pc.gray(' Audio is transmitted over HTTPS. Provider data retention terms apply.\n')}${pc.gray(` Current provider: ${config.provider.default}\n`)}\n Continue? [Y/n] ` ); const answer = await new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.once('line', (line) => { rl.close(); resolve(line.trim().toLowerCase()); }); + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.once('line', (line) => { + rl.close(); + resolve(line.trim().toLowerCase()); + }); rl.once('close', () => resolve('y')); }); diff --git a/src/errors.ts b/src/errors.ts index d0212a2..a764843 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,8 +1,28 @@ +/** + * Stable POSIX-ish exit-code contract. Consumers (scripts, agents) script against + * these — values are part of the public API and must not change. New error classes + * may add new codes, but existing codes keep their meaning. Surfaced in the manifest + * and README. + */ +export const ExitCode = { + SUCCESS: 0, + UNKNOWN: 1, + CONFIG: 2, + AUDIO: 3, + TRANSCRIPTION: 4, + NETWORK: 5, + LOCAL_WHISPER: 6, + INPUT: 7, + USER_ABORT: 130, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + export class RecmpError extends Error { constructor( public readonly code: string, message: string, - public readonly exitCode: number = 1, + public readonly exitCode: number = ExitCode.UNKNOWN ) { super(message); this.name = 'RecmpError'; @@ -24,7 +44,10 @@ export class AudioCaptureError extends RecmpError { } export class TranscriptionError extends RecmpError { - constructor(message: string, public readonly statusCode?: number) { + constructor( + message: string, + public readonly statusCode?: number + ) { super('TRANSCRIPTION_ERROR', message, 4); this.name = 'TranscriptionError'; } @@ -49,8 +72,22 @@ export class FfmpegNotFoundError extends RecmpError { super( 'FFMPEG_NOT_FOUND', 'ffmpeg not found. Install it with: sudo apt install ffmpeg', - 3, + ExitCode.AUDIO ); this.name = 'FfmpegNotFoundError'; } } + +export class LocalWhisperError extends RecmpError { + constructor(message: string) { + super('LOCAL_WHISPER_ERROR', message, ExitCode.LOCAL_WHISPER); + this.name = 'LocalWhisperError'; + } +} + +export class InputError extends RecmpError { + constructor(message: string) { + super('INPUT_ERROR', message, ExitCode.INPUT); + this.name = 'InputError'; + } +} diff --git a/src/index.ts b/src/index.ts index d7eb2b7..8f82a12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,74 +1,114 @@ import { Command } from 'commander'; import pc from 'picocolors'; -import { initLogger } from './log.js'; -import { RecmpError } from './errors.js'; +import { AgentContext } from './agent/context.js'; +import { runMcpServer } from './agent/mcp.js'; +import { toErrorPayload } from './agent/output.js'; +import { + runConfigInit, + runConfigPath, + runConfigSet, + runConfigSetKey, + runConfigShow, +} from './commands/config.js'; +import { runDoctor } from './commands/doctor.js'; +import { runManifest } from './commands/manifest.js'; +import { listTemplates, runPrompt } from './commands/prompt.js'; import { runRecord } from './commands/record.js'; -import { runTranscribe } from './commands/transcribe.js'; import { runSources } from './commands/sources.js'; -import { runConfigInit, runConfigShow, runConfigPath, runConfigSet } from './commands/config.js'; -import { runDoctor } from './commands/doctor.js'; -import { runPrompt, listTemplates } from './commands/prompt.js'; +import { runTranscribe } from './commands/transcribe.js'; +import { ExitCode, RecmpError } from './errors.js'; +import { initLogger } from './log.js'; -const VERSION = '0.1.0'; +const VERSION = '0.2.0'; const program = new Command(); +// Active agent context for the running command. Built in the preAction hook from the +// resolved global flags (commander merges program-level options via optsWithGlobals()). +let ctx = new AgentContext(); + program .name('recmp3') - .description('Record audio, transcribe with AI, output developer-ready prompts.') + .description( + 'Record audio, transcribe with AI, output developer-ready prompts.' + ) .version(VERSION, '-v, --version') + .option( + '--json', + 'Emit a stable machine-readable JSON envelope on stdout', + false + ) + .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) => { - const opts = thisCommand.opts(); + .hook('preAction', (_thisCommand, actionCommand) => { + const opts = actionCommand.optsWithGlobals(); initLogger({ debug: opts.debug, verbose: opts.verbose }); + ctx = AgentContext.fromGlobals(opts); }); // Default action: recmp3 → recmp3 record -program - .action(async () => { - await handleError(() => runRecord({})); - }); +program.action(async () => { + await handleError('record', () => runRecord({}, ctx)); +}); // record command -const recordCmd = program +program .command('record') .description('Record audio from your microphone') .option('-n, --name ', 'Output filename stem (e.g. "my-idea")') .option('-o, --out ', 'Output directory') .option('-t, --transcribe', 'Transcribe immediately after recording') .option('--mp3', 'Save as MP3 instead of WAV (post-processing)') - .option('--provider ', 'Override transcription provider (groq, openai)') + .option( + '--provider ', + 'Override transcription provider (groq, openai, local-whisper)' + ) .option('--lang ', 'Force language code (e.g. es, en)') - .option('--copy', 'Copy transcript to clipboard (default: on with --transcribe)') + .option( + '--duration ', + 'Headless: record for N seconds then stop (no TUI)' + ) + .option( + '--no-tui', + 'Force headless capture (record until SIGINT or --duration)' + ) + .option( + '--copy', + 'Copy transcript to clipboard (default: on with --transcribe)' + ) .option('--no-copy', 'Do not copy transcript to clipboard') - .option('--print', 'Print transcript to stdout (default: on with --transcribe)') + .option( + '--print', + 'Print transcript to stdout (default: on with --transcribe)' + ) .option('--no-print', 'Do not print transcript to stdout') - .option('-y, --yes', 'Skip upload consent prompt') .action(async (opts) => { - await handleError(() => runRecord(opts)); + await handleError('record', () => runRecord(opts, ctx)); }); // transcribe command program .command('transcribe ') - .description('Transcribe an existing audio file') - .option('--provider ', 'Override provider (groq, openai)') + .description('Transcribe an existing audio file ("-" reads audio from stdin)') + .option( + '--provider ', + 'Override provider (groq, openai, local-whisper)' + ) .option('--lang ', 'Force language code') - .option('--json', 'Output full JSON result') .option('--copy', 'Copy transcript to clipboard') - .option('-y, --yes', 'Skip upload consent prompt') .action(async (file, opts) => { - await handleError(() => runTranscribe(file, opts)); + await handleError('transcribe', () => runTranscribe(file, opts, ctx)); }); // sources command program .command('sources') .description('List available audio input sources for your OS') - .option('--json', 'Output as JSON') - .action(async (opts) => { - await handleError(() => runSources(opts)); + .action(async () => { + await handleError('sources', () => runSources(ctx)); }); // config commands @@ -78,30 +118,51 @@ const configCmd = program configCmd .command('init') - .description('Interactive first-time setup') - .action(async () => { - await handleError(() => runConfigInit()); + .description( + 'First-time setup (interactive, or flag-driven when non-interactive)' + ) + .option('--provider ', 'Provider (groq, openai, local-whisper)') + .option('--lang ', 'Default language code') + .option('--outdir ', 'Recordings output directory') + .option('--key ', 'API key to store in the OS keychain') + .action(async (opts) => { + await handleError('config init', () => runConfigInit(opts, ctx)); }); configCmd .command('show') .description('Show resolved configuration (API keys redacted)') .action(async () => { - await handleError(() => runConfigShow()); + await handleError('config show', () => runConfigShow(ctx)); }); configCmd .command('path') .description('Print path to config file') .action(async () => { - await handleError(() => runConfigPath()); + await handleError('config path', () => runConfigPath(ctx)); }); configCmd .command('set ') .description('Set a config value (e.g. provider.default groq)') .action(async (key, value) => { - await handleError(() => runConfigSet(key, value)); + await handleError('config set', () => runConfigSet(key, value, ctx)); + }); + +configCmd + .command('set-key ') + .description( + 'Store an API key in the OS keychain (value from --key, stdin, or env)' + ) + .option( + '--key ', + 'API key value (otherwise read from stdin or *_API_KEY env)' + ) + .action(async (provider, opts) => { + await handleError('config set-key', () => + runConfigSetKey(provider, opts, ctx) + ); }); // doctor command @@ -109,43 +170,66 @@ program .command('doctor') .description('Run preflight checks to verify your setup') .action(async () => { - await handleError(() => runDoctor()); + await handleError('doctor', () => runDoctor(ctx)); }); // prompt command program .command('prompt ') - .description('Wrap a transcript file in a prompt template') - .option('-t, --template ', 'Template name (claude-code, prd, bug, todo, meeting-notes, commit-message, raw)', 'claude-code') + .description( + 'Wrap a transcript file in a prompt template ("-" reads from stdin)' + ) + .option( + '-t, --template ', + 'Template name (claude-code, prd, bug, todo, meeting-notes, commit-message, raw)', + 'claude-code' + ) .option('--copy', 'Copy output to clipboard') .option('--out ', 'Write output to a file') .option('--list-templates', 'List available templates') .action(async (file, opts) => { - if (opts.listTemplates) { listTemplates(); return; } - await handleError(() => runPrompt(file, opts)); + if (opts.listTemplates) { + listTemplates(); + return; + } + await handleError('prompt', () => runPrompt(file, opts, ctx)); + }); + +// manifest command — discoverable command/tool surface +program + .command('manifest') + .description('Print the command/tool manifest (use --json for machine form)') + .action(async () => { + await handleError('manifest', () => runManifest(ctx)); + }); + +// mcp command — stdio MCP server +program + .command('mcp') + .description('Start the Model Context Protocol server over stdio') + .action(async () => { + await runMcpServer(); }); // Global error handler -async function handleError(fn: () => Promise): Promise { +async function handleError( + command: string, + fn: () => Promise +): Promise { try { await fn(); } catch (err: unknown) { - if (err instanceof RecmpError) { - console.error(`\n${pc.red('✗')} ${err.message}\n`); - if (process.env.RECMP3_DEBUG) console.error(err.stack); - process.exit(err.exitCode); - } - if (err instanceof Error) { - console.error(`\n${pc.red('✗')} Unexpected error: ${err.message}\n`); - if (process.env.RECMP3_DEBUG) console.error(err.stack); - } else { - console.error(`\n${pc.red('✗')} Unknown error\n`); + const payload = toErrorPayload(err); + ctx.fail(err, command); + if (process.env.RECMP3_DEBUG && err instanceof Error) { + process.stderr.write(`${err.stack ?? ''}\n`); } - process.exit(1); + process.exit(payload.exitCode); } } program.parseAsync(process.argv).catch((err) => { - console.error(`${pc.red('✗')} ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); + const payload = toErrorPayload(err); + process.stderr.write(`${pc.red('✗')} ${payload.message}\n`); + process.exit(err instanceof RecmpError ? err.exitCode : ExitCode.UNKNOWN); }); diff --git a/src/log.ts b/src/log.ts index b4a5110..fa2149f 100644 --- a/src/log.ts +++ b/src/log.ts @@ -9,12 +9,16 @@ export function initLogger(opts: { debug?: boolean; verbose?: boolean }) { export const log = { debug(msg: string, ...args: unknown[]) { if (debugEnabled) { - process.stderr.write(`[debug] ${msg} ${args.length ? JSON.stringify(args) : ''}\n`); + process.stderr.write( + `[debug] ${msg} ${args.length ? JSON.stringify(args) : ''}\n` + ); } }, info(msg: string, ...args: unknown[]) { if (verboseEnabled) { - process.stderr.write(`[info] ${msg} ${args.length ? JSON.stringify(args) : ''}\n`); + process.stderr.write( + `[info] ${msg} ${args.length ? JSON.stringify(args) : ''}\n` + ); } }, warn(msg: string) { diff --git a/src/output/clipboard.ts b/src/output/clipboard.ts index 5715c9c..350e523 100644 --- a/src/output/clipboard.ts +++ b/src/output/clipboard.ts @@ -7,8 +7,7 @@ export async function copyToClipboard(text: string): Promise { return true; } catch (err: unknown) { log.info( - 'Clipboard copy failed (headless or missing xclip/wl-copy): ' + - (err instanceof Error ? err.message : String(err)), + `Clipboard copy failed (headless or missing xclip/wl-copy): ${err instanceof Error ? err.message : String(err)}` ); return false; } diff --git a/src/output/filenames.ts b/src/output/filenames.ts index aefb857..82d4fc4 100644 --- a/src/output/filenames.ts +++ b/src/output/filenames.ts @@ -1,4 +1,4 @@ -import { join } from 'path'; +import { join } from 'node:path'; function formatDate(d: Date = new Date()): string { const pad = (n: number) => String(n).padStart(2, '0'); diff --git a/src/output/writer.ts b/src/output/writer.ts index 0410e81..e6fdf9a 100644 --- a/src/output/writer.ts +++ b/src/output/writer.ts @@ -1,15 +1,15 @@ -import { writeFile } from 'fs/promises'; -import { TranscriptionResult } from '../transcription/types.js'; +import { writeFile } from 'node:fs/promises'; +import type { TranscriptionResult } from '../transcription/types.js'; import { transcriptPath } from './filenames.js'; export async function writeTranscriptFiles( audioPath: string, - result: TranscriptionResult, + result: TranscriptionResult ): Promise<{ txtPath: string; jsonPath: string }> { const txtPath = transcriptPath(audioPath, 'txt'); const jsonPath = transcriptPath(audioPath, 'json'); - await writeFile(txtPath, result.text + '\n', 'utf-8'); + await writeFile(txtPath, `${result.text}\n`, 'utf-8'); const meta = { text: result.text, @@ -21,7 +21,7 @@ export async function writeTranscriptFiles( audioFile: audioPath, segments: result.segments, }; - await writeFile(jsonPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); + await writeFile(jsonPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf-8'); return { txtPath, jsonPath }; } diff --git a/src/secrets/keychain.ts b/src/secrets/keychain.ts new file mode 100644 index 0000000..7b5d19e --- /dev/null +++ b/src/secrets/keychain.ts @@ -0,0 +1,72 @@ +import { log } from '../log.js'; + +const SERVICE = 'recmp3'; + +// keytar is an optional native dependency. We load it lazily and degrade gracefully: +// if the native module is missing or fails to build, key storage falls back to +// environment variables only (no crash, single warning). +type Keytar = { + getPassword(service: string, account: string): Promise; + setPassword( + service: string, + account: string, + password: string + ): Promise; + deletePassword(service: string, account: string): Promise; +}; + +let _keytar: Keytar | null | undefined; +let _warned = false; + +async function getKeytar(): Promise { + if (_keytar !== undefined) return _keytar; + try { + const mod = (await import('keytar')) as unknown as { + default?: Keytar; + } & Keytar; + _keytar = (mod.default ?? mod) as Keytar; + } catch { + _keytar = null; + if (!_warned) { + _warned = true; + log.info( + 'OS keychain (keytar) unavailable — falling back to environment variables.' + ); + } + } + return _keytar; +} + +/** True when the OS keychain backend is available on this machine. */ +export async function keychainAvailable(): Promise { + return (await getKeytar()) !== null; +} + +export async function getSecret(account: string): Promise { + const kt = await getKeytar(); + if (!kt) return undefined; + try { + return (await kt.getPassword(SERVICE, account)) ?? undefined; + } catch (err: unknown) { + log.info( + `keychain read failed for ${account}: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +export async function setSecret( + account: string, + value: string +): Promise { + const kt = await getKeytar(); + if (!kt) return false; + await kt.setPassword(SERVICE, account, value); + return true; +} + +export async function deleteSecret(account: string): Promise { + const kt = await getKeytar(); + if (!kt) return false; + return kt.deletePassword(SERVICE, account); +} diff --git a/src/transcription/chunking.ts b/src/transcription/chunking.ts index dd2328e..3361de9 100644 --- a/src/transcription/chunking.ts +++ b/src/transcription/chunking.ts @@ -1,16 +1,20 @@ -import { execFile } from 'child_process'; -import { mkdir, readdir, stat } from 'fs/promises'; -import { basename, join } from 'path'; -import { promisify } from 'util'; +import { execFile } from 'node:child_process'; +import { mkdir, readdir, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { promisify } from 'node:util'; import { findFfmpeg } from '../audio/ffmpeg.js'; -import { TranscriptionInput, TranscriptionProvider, TranscriptionResult } from './types.js'; +import type { + TranscriptionInput, + TranscriptionProvider, + TranscriptionResult, +} from './types.js'; const execFileAsync = promisify(execFile); export async function transcribeWithChunking( provider: TranscriptionProvider, input: TranscriptionInput, - chunkSeconds = 600, + chunkSeconds = 600 ): Promise { const fileStat = await stat(input.audioPath); @@ -21,8 +25,8 @@ export async function transcribeWithChunking( // File is too large — split into chunks and transcribe sequentially const tmpDir = join( - (await import('os')).tmpdir(), - `recmp3-chunks-${Date.now()}`, + (await import('node:os')).tmpdir(), + `recmp3-chunks-${Date.now()}` ); await mkdir(tmpDir, { recursive: true }); @@ -30,11 +34,17 @@ export async function transcribeWithChunking( const chunkPattern = join(tmpDir, 'chunk-%04d.wav'); await execFileAsync(ffmpeg, [ - '-hide_banner', '-loglevel', 'error', - '-i', input.audioPath, - '-f', 'segment', - '-segment_time', String(chunkSeconds), - '-c', 'copy', + '-hide_banner', + '-loglevel', + 'error', + '-i', + input.audioPath, + '-f', + 'segment', + '-segment_time', + String(chunkSeconds), + '-c', + 'copy', chunkPattern, ]); @@ -50,7 +60,10 @@ export async function transcribeWithChunking( const results: TranscriptionResult[] = []; for (const chunkPath of files) { - const result = await provider.transcribe({ ...input, audioPath: chunkPath }); + const result = await provider.transcribe({ + ...input, + audioPath: chunkPath, + }); results.push(result); } diff --git a/src/transcription/groq.ts b/src/transcription/groq.ts index 3a4b960..b63808b 100644 --- a/src/transcription/groq.ts +++ b/src/transcription/groq.ts @@ -1,8 +1,8 @@ -import { readFile } from 'fs/promises'; -import { basename, extname } from 'path'; +import { readFile } from 'node:fs/promises'; +import { basename, extname } from 'node:path'; import { NetworkError, TranscriptionError } from '../errors.js'; import { log } from '../log.js'; -import { +import type { ProviderConfig, TranscriptionInput, TranscriptionProvider, @@ -11,7 +11,18 @@ import { const GROQ_BASE_URL = 'https://api.groq.com/openai/v1'; const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB -const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'] as const; +const SUPPORTED_FORMATS = [ + 'flac', + 'm4a', + 'mp3', + 'mp4', + 'mpeg', + 'mpga', + 'oga', + 'ogg', + 'wav', + 'webm', +] as const; function getMimeType(filePath: string): string { const ext = extname(filePath).toLowerCase().slice(1); @@ -42,10 +53,18 @@ export class GroqProvider implements TranscriptionProvider { } async transcribe(input: TranscriptionInput): Promise { - const { audioPath, language, prompt, responseFormat = 'verbose_json', signal } = input; + const { + audioPath, + language, + prompt, + responseFormat = 'verbose_json', + signal, + } = input; const t0 = Date.now(); - log.info(`Transcribing with Groq (${this.config.model}): ${basename(audioPath)}`); + log.info( + `Transcribing with Groq (${this.config.model}): ${basename(audioPath)}` + ); const audioBuffer = await readFile(audioPath); const mimeType = getMimeType(audioPath); @@ -76,9 +95,13 @@ export class GroqProvider implements TranscriptionProvider { } catch (err: unknown) { clearTimeout(timeout); if (err instanceof Error && err.name === 'AbortError') { - throw new TranscriptionError('Transcription timed out after ' + Math.round(this.timeoutMs / 1000) + 's.'); + throw new TranscriptionError( + `Transcription timed out after ${Math.round(this.timeoutMs / 1000)}s.` + ); } - throw new NetworkError(`Network error connecting to Groq: ${err instanceof Error ? err.message : String(err)}`); + throw new NetworkError( + `Network error connecting to Groq: ${err instanceof Error ? err.message : String(err)}` + ); } finally { clearTimeout(timeout); } @@ -87,11 +110,11 @@ export class GroqProvider implements TranscriptionProvider { const body = await response.text().catch(() => ''); throw new TranscriptionError( `Groq API error ${response.status}: ${body || response.statusText}`, - response.status, + response.status ); } - const raw = await response.json() as Record; + const raw = (await response.json()) as Record; const text = typeof raw === 'string' ? raw : ((raw.text as string) ?? ''); const latencyMs = Date.now() - t0; @@ -101,7 +124,11 @@ export class GroqProvider implements TranscriptionProvider { text: text.trim(), language: raw.language as string | undefined, durationSec: raw.duration as number | undefined, - segments: (raw.segments as Array<{ start: number; end: number; text: string }> | undefined)?.map((s) => ({ + segments: ( + raw.segments as + | Array<{ start: number; end: number; text: string }> + | undefined + )?.map((s) => ({ startSec: s.start, endSec: s.end, text: s.text, @@ -125,7 +152,10 @@ export class GroqProvider implements TranscriptionProvider { } return { ok: true, latencyMs: Date.now() - t0 }; } catch (err: unknown) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; } } } diff --git a/src/transcription/local-whisper.ts b/src/transcription/local-whisper.ts new file mode 100644 index 0000000..7da3865 --- /dev/null +++ b/src/transcription/local-whisper.ts @@ -0,0 +1,126 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { promisify } from 'node:util'; +import { LocalWhisperError } from '../errors.js'; +import { log } from '../log.js'; +import type { + TranscriptionInput, + TranscriptionProvider, + TranscriptionResult, +} from './types.js'; +import { findWhisperBin, findWhisperModel } from './whisper-bin.js'; + +const execFileAsync = promisify(execFile); + +interface LocalConfig { + binPath?: string; + modelPath?: string; + language?: string; +} + +interface WhisperJsonSegment { + offsets?: { from?: number; to?: number }; + text?: string; +} + +interface WhisperJson { + result?: { language?: string }; + transcription?: WhisperJsonSegment[]; +} + +/** + * Local, no-upload transcription via a whisper.cpp binary. Audio never leaves the + * machine, so no upload consent is required. Expects 16 kHz mono WAV (what `recmp3 + * record` produces); other inputs may need pre-conversion via ffmpeg. + */ +export class LocalWhisperProvider implements TranscriptionProvider { + readonly name = 'local-whisper' as const; + readonly maxFileSizeBytes = Number.POSITIVE_INFINITY; + readonly supportedFormats = ['wav', 'mp3', 'flac', 'ogg'] as const; + + constructor(private config: LocalConfig = {}) {} + + async transcribe(input: TranscriptionInput): Promise { + const { audioPath, language, signal } = input; + const t0 = Date.now(); + + const bin = await findWhisperBin(this.config.binPath); + const model = findWhisperModel(this.config.modelPath); + const lang = language ?? this.config.language; + + log.info(`Transcribing locally with whisper.cpp: ${basename(audioPath)}`); + + const workDir = await mkdtemp(join(tmpdir(), 'recmp3-whisper-')); + const outPrefix = join(workDir, 'out'); + const args = ['-m', model, '-f', audioPath, '-oj', '-of', outPrefix]; + if (lang) args.push('-l', lang); + + try { + await execFileAsync(bin, args, { signal, maxBuffer: 64 * 1024 * 1024 }); + } catch (err: unknown) { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + throw new LocalWhisperError( + `whisper.cpp failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + + const jsonPath = `${outPrefix}.json`; + if (!existsSync(jsonPath)) { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + throw new LocalWhisperError('whisper.cpp produced no JSON output.'); + } + + let parsed: WhisperJson; + try { + parsed = JSON.parse(await readFile(jsonPath, 'utf-8')) as WhisperJson; + } catch (err: unknown) { + throw new LocalWhisperError( + `Failed to parse whisper.cpp output: ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + + const segments = (parsed.transcription ?? []).map((s) => ({ + startSec: (s.offsets?.from ?? 0) / 1000, + endSec: (s.offsets?.to ?? 0) / 1000, + text: (s.text ?? '').trim(), + })); + const text = segments + .map((s) => s.text) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + const latencyMs = Date.now() - t0; + + return { + text, + language: parsed.result?.language ?? lang, + durationSec: segments.length + ? segments[segments.length - 1].endSec + : undefined, + segments, + raw: parsed, + provider: 'local-whisper', + model: basename(model), + latencyMs, + }; + } + + async ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const t0 = Date.now(); + try { + await findWhisperBin(this.config.binPath); + findWhisperModel(this.config.modelPath); + return { ok: true, latencyMs: Date.now() - t0 }; + } catch (err: unknown) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } +} diff --git a/src/transcription/openai.ts b/src/transcription/openai.ts index 933b270..7ddfe46 100644 --- a/src/transcription/openai.ts +++ b/src/transcription/openai.ts @@ -1,8 +1,8 @@ -import { readFile } from 'fs/promises'; -import { basename, extname } from 'path'; +import { readFile } from 'node:fs/promises'; +import { basename, extname } from 'node:path'; import { NetworkError, TranscriptionError } from '../errors.js'; import { log } from '../log.js'; -import { +import type { ProviderConfig, TranscriptionInput, TranscriptionProvider, @@ -11,12 +11,27 @@ import { const OPENAI_BASE_URL = 'https://api.openai.com/v1'; const MAX_FILE_SIZE = 25 * 1024 * 1024; -const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'] as const; +const SUPPORTED_FORMATS = [ + 'flac', + 'm4a', + 'mp3', + 'mp4', + 'mpeg', + 'mpga', + 'oga', + 'ogg', + 'wav', + 'webm', +] as const; function getMimeType(filePath: string): string { const ext = extname(filePath).toLowerCase().slice(1); const mimes: Record = { - wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac', + wav: 'audio/wav', + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + ogg: 'audio/ogg', + flac: 'audio/flac', }; return mimes[ext] ?? 'audio/wav'; } @@ -35,10 +50,18 @@ export class OpenAIProvider implements TranscriptionProvider { } async transcribe(input: TranscriptionInput): Promise { - const { audioPath, language, prompt, responseFormat = 'verbose_json', signal } = input; + const { + audioPath, + language, + prompt, + responseFormat = 'verbose_json', + signal, + } = input; const t0 = Date.now(); - log.info(`Transcribing with OpenAI (${this.config.model}): ${basename(audioPath)}`); + log.info( + `Transcribing with OpenAI (${this.config.model}): ${basename(audioPath)}` + ); const audioBuffer = await readFile(audioPath); const form = new FormData(); @@ -62,23 +85,37 @@ export class OpenAIProvider implements TranscriptionProvider { }); } catch (err: unknown) { clearTimeout(timeout); - throw new NetworkError(`Network error connecting to OpenAI: ${err instanceof Error ? err.message : String(err)}`); + throw new NetworkError( + `Network error connecting to OpenAI: ${err instanceof Error ? err.message : String(err)}` + ); } finally { clearTimeout(timeout); } if (!response.ok) { const body = await response.text().catch(() => ''); - throw new TranscriptionError(`OpenAI API error ${response.status}: ${body || response.statusText}`, response.status); + throw new TranscriptionError( + `OpenAI API error ${response.status}: ${body || response.statusText}`, + response.status + ); } - const raw = await response.json() as Record; + const raw = (await response.json()) as Record; return { - text: (typeof raw === 'string' ? raw : ((raw.text as string) ?? '')).trim(), + text: (typeof raw === 'string' + ? raw + : ((raw.text as string) ?? '') + ).trim(), language: raw.language as string | undefined, durationSec: raw.duration as number | undefined, - segments: (raw.segments as Array<{ start: number; end: number; text: string }> | undefined)?.map((s) => ({ - startSec: s.start, endSec: s.end, text: s.text, + segments: ( + raw.segments as + | Array<{ start: number; end: number; text: string }> + | undefined + )?.map((s) => ({ + startSec: s.start, + endSec: s.end, + text: s.text, })), raw, provider: 'openai', @@ -94,9 +131,14 @@ export class OpenAIProvider implements TranscriptionProvider { headers: { Authorization: `Bearer ${this.config.apiKey}` }, signal: AbortSignal.timeout(10_000), }); - return r.ok ? { ok: true, latencyMs: Date.now() - t0 } : { ok: false, error: `HTTP ${r.status}` }; + return r.ok + ? { ok: true, latencyMs: Date.now() - t0 } + : { ok: false, error: `HTTP ${r.status}` }; } catch (err: unknown) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; } } } diff --git a/src/transcription/registry.ts b/src/transcription/registry.ts index c9f0595..7d2c4ea 100644 --- a/src/transcription/registry.ts +++ b/src/transcription/registry.ts @@ -1,20 +1,28 @@ -import { ConfigError } from '../errors.js'; import { getApiKey } from '../config/load.js'; -import { RecmpConfig } from '../config/schema.js'; +import type { RecmpConfig } from '../config/schema.js'; +import { ConfigError } from '../errors.js'; import { GroqProvider } from './groq.js'; +import { LocalWhisperProvider } from './local-whisper.js'; import { OpenAIProvider } from './openai.js'; -import { TranscriptionProvider } from './types.js'; +import type { TranscriptionProvider } from './types.js'; + +/** Providers that send audio to a remote API (and therefore require upload consent). */ +export function providerUploads(name: string): boolean { + return name === 'groq' || name === 'openai'; +} -export function createProvider(config: RecmpConfig): TranscriptionProvider { +export async function createProvider( + config: RecmpConfig +): Promise { const providerName = config.provider.default; if (providerName === 'groq') { - const apiKey = getApiKey('groq'); + const apiKey = await getApiKey('groq'); if (!apiKey) { throw new ConfigError( 'GROQ_API_KEY is not set.\n' + - ' Set it with: export GROQ_API_KEY=your_key\n' + - ' Or run: recmp3 config init', + ' Set it with: export GROQ_API_KEY=your_key\n' + + ' Or run: recmp3 config set-key groq --key your_key' ); } const groqConfig = config.provider.groq; @@ -27,12 +35,12 @@ export function createProvider(config: RecmpConfig): TranscriptionProvider { } if (providerName === 'openai') { - const apiKey = getApiKey('openai'); + const apiKey = await getApiKey('openai'); if (!apiKey) { throw new ConfigError( 'OPENAI_API_KEY is not set.\n' + - ' Set it with: export OPENAI_API_KEY=your_key\n' + - ' Or run: recmp3 config init', + ' Set it with: export OPENAI_API_KEY=your_key\n' + + ' Or run: recmp3 config set-key openai --key your_key' ); } const openaiConfig = config.provider.openai; @@ -44,5 +52,11 @@ export function createProvider(config: RecmpConfig): TranscriptionProvider { }); } - throw new ConfigError(`Unknown provider: "${providerName}". Valid options: groq, openai.`); + if (providerName === 'local-whisper') { + return new LocalWhisperProvider(config.provider.local ?? {}); + } + + throw new ConfigError( + `Unknown provider: "${providerName}". Valid options: groq, openai, local-whisper.` + ); } diff --git a/src/transcription/whisper-bin.ts b/src/transcription/whisper-bin.ts new file mode 100644 index 0000000..73ad7cb --- /dev/null +++ b/src/transcription/whisper-bin.ts @@ -0,0 +1,57 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { promisify } from 'node:util'; +import { LocalWhisperError } from '../errors.js'; + +const execFileAsync = promisify(execFile); + +// whisper.cpp ships its CLI under a few names across versions/distros. +const CANDIDATE_BINS = ['whisper-cli', 'whisper', 'main'] as const; + +/** + * Locate a whisper.cpp binary. Precedence mirrors findFfmpeg(): + * explicit path → RECMP3_WHISPER_BIN → PATH lookup of known names. + */ +export async function findWhisperBin(configPath?: string): Promise { + const explicit = configPath ?? process.env.RECMP3_WHISPER_BIN; + if (explicit) { + if (!existsSync(explicit)) { + throw new LocalWhisperError(`whisper binary not found at: ${explicit}`); + } + return explicit; + } + + const which = process.platform === 'win32' ? 'where' : 'which'; + for (const name of CANDIDATE_BINS) { + try { + const { stdout } = await execFileAsync(which, [name]); + const found = stdout.trim().split('\n')[0]; + if (found) return found; + } catch { + // try next candidate + } + } + + throw new LocalWhisperError( + 'whisper.cpp binary not found. Install whisper.cpp and ensure "whisper-cli" is on PATH, ' + + 'or set RECMP3_WHISPER_BIN / config provider.local.binPath.' + ); +} + +/** + * Resolve the GGML model file. Precedence: explicit path → RECMP3_WHISPER_MODEL. + * There is no PATH fallback — a model file must be provided. + */ +export function findWhisperModel(configPath?: string): string { + const model = configPath ?? process.env.RECMP3_WHISPER_MODEL; + if (!model) { + throw new LocalWhisperError( + 'No whisper model configured. Set RECMP3_WHISPER_MODEL / config provider.local.modelPath ' + + 'to a .bin model file (e.g. ggml-base.en.bin).' + ); + } + if (!existsSync(model)) { + throw new LocalWhisperError(`whisper model not found at: ${model}`); + } + return model; +} diff --git a/src/tui/recorder.tsx b/src/tui/recorder.tsx index 3c54d9f..d2001a8 100644 --- a/src/tui/recorder.tsx +++ b/src/tui/recorder.tsx @@ -1,9 +1,20 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Box, Text, useInput, useApp, render } from 'ink'; -import { AudioCapture, CaptureOptions, CaptureSegment } from '../audio/types.js'; +import { Box, Text, render, useApp, useInput } from 'ink'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { concatSegments } from '../audio/concat.js'; +import type { + AudioCapture, + CaptureOptions, + CaptureSegment, +} from '../audio/types.js'; -type RecStatus = 'recording' | 'paused' | 'saving' | 'done' | 'cancelled' | 'error'; +type RecStatus = + | 'recording' + | 'paused' + | 'saving' + | 'done' + | 'cancelled' + | 'error'; interface RecorderState { status: RecStatus; @@ -26,7 +37,12 @@ interface RecorderProps { onResult: (result: RecorderResult) => void; } -const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, onResult }) => { +const RecorderUI: React.FC = ({ + capture, + captureOpts, + outputPath, + onResult, +}) => { const { exit } = useApp(); const [status, setStatus] = useState('recording'); const [elapsedMs, setElapsedMs] = useState(0); @@ -44,24 +60,27 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, useEffect(() => { const interval = setInterval(() => { if (isRecordingRef.current) { - setElapsedMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current)); + setElapsedMs( + accumulatedMsRef.current + (Date.now() - segmentStartRef.current) + ); } }, 100); return () => clearInterval(interval); }, []); - const stopCurrentSegment = useCallback(async (): Promise => { - if (!isRecordingRef.current) return null; - try { - const segment = await capture.stop(); - accumulatedMsRef.current += Date.now() - segmentStartRef.current; - isRecordingRef.current = false; - segmentsRef.current.push(segment); - return segment; - } catch { - return null; - } - }, [capture]); + const stopCurrentSegment = + useCallback(async (): Promise => { + if (!isRecordingRef.current) return null; + try { + const segment = await capture.stop(); + accumulatedMsRef.current += Date.now() - segmentStartRef.current; + isRecordingRef.current = false; + segmentsRef.current.push(segment); + return segment; + } catch { + return null; + } + }, [capture]); const startNewSegment = useCallback(async () => { currentSegmentIndexRef.current += 1; @@ -101,7 +120,7 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, segmentsRef.current, outputPath, captureOpts.tmpDir, - 'wav', + 'wav' ); setStatusMessage(''); @@ -120,7 +139,15 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, setStatusMessage(err instanceof Error ? err.message : String(err)); setTimeout(() => exit(), 2000); } - }, [busy, status, stopCurrentSegment, outputPath, captureOpts.tmpDir, onResult, exit]); + }, [ + busy, + status, + stopCurrentSegment, + outputPath, + captureOpts.tmpDir, + onResult, + exit, + ]); const handleCancel = useCallback(async () => { if (busy) return; @@ -132,14 +159,23 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, }, [busy, capture, onResult, exit]); useInput((input, key) => { - if (key.ctrl && input === 'c') { handleCancel(); return; } - if (input === 'c' || key.escape) { handleCancel(); return; } + if (key.ctrl && input === 'c') { + handleCancel(); + return; + } + if (input === 'c' || key.escape) { + handleCancel(); + return; + } if (input === 'p' || input === ' ') { if (status === 'recording') handlePause(); else if (status === 'paused') handleResume(); return; } - if (input === 's' || key.return) { handleSave(); return; } + if (input === 's' || key.return) { + handleSave(); + return; + } }); const elapsed = Math.floor(elapsedMs / 1000); @@ -169,10 +205,21 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, const showControls = status === 'recording' || status === 'paused'; return ( - + - - {statusLabels[status]} {timeStr} + + {statusLabels[status]} {timeStr} {statusMessage ? ( @@ -182,7 +229,8 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, ) : showControls ? ( - {status === 'recording' ? '[p] pause' : '[p] resume'} [s] save [c] cancel + {status === 'recording' ? '[p] pause' : '[p] resume'} [s] save [c] + cancel ) : null} @@ -193,7 +241,7 @@ const RecorderUI: React.FC = ({ capture, captureOpts, outputPath, export async function runRecorderTUI( capture: AudioCapture, captureOpts: Omit & { tmpDir: string }, - outputPath: string, + outputPath: string ): Promise { return new Promise((resolve) => { let result: RecorderResult | null = null; @@ -201,11 +249,16 @@ export async function runRecorderTUI( const { waitUntilExit } = render( { result = r; }} + onResult={(r) => { + result = r; + }} />, - { exitOnCtrlC: false }, + { exitOnCtrlC: false } ); waitUntilExit().then(() => { diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 12e028d..4a2f25b 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -44,6 +44,14 @@ describe('config env overrides', () => { expect(cfg.audio.source).toBe('alsa_input.test-device'); }); + it('RECMP3_WHISPER_BIN and RECMP3_WHISPER_MODEL populate provider.local', async () => { + process.env.RECMP3_WHISPER_BIN = '/usr/local/bin/whisper-cli'; + process.env.RECMP3_WHISPER_MODEL = '/models/ggml-base.en.bin'; + const cfg = await loadConfig(); + expect(cfg.provider.local?.binPath).toBe('/usr/local/bin/whisper-cli'); + expect(cfg.provider.local?.modelPath).toBe('/models/ggml-base.en.bin'); + }); + it('loads defaults when no env vars set', async () => { // Remove all recmp3 vars delete process.env.RECMP3_PROVIDER; diff --git a/test/unit/keychain.test.ts b/test/unit/keychain.test.ts new file mode 100644 index 0000000..d01b38b --- /dev/null +++ b/test/unit/keychain.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// In-memory keytar mock so tests never touch the real OS keychain. +const store = new Map(); +vi.mock('keytar', () => ({ + default: { + getPassword: async (service: string, account: string) => store.get(`${service}:${account}`) ?? null, + setPassword: async (service: string, account: string, password: string) => { + store.set(`${service}:${account}`, password); + }, + deletePassword: async (service: string, account: string) => store.delete(`${service}:${account}`), + }, +})); + +import { getApiKey } from '../../src/config/load.js'; +import { getSecret, setSecret } from '../../src/secrets/keychain.js'; + +describe('keychain + getApiKey precedence', () => { + const original = { ...process.env }; + + beforeEach(() => { + process.env = { ...original }; + delete process.env.GROQ_API_KEY; + delete process.env.OPENAI_API_KEY; + store.clear(); + }); + + afterEach(() => { + process.env = original; + }); + + it('stores and reads a secret through the keychain', async () => { + await setSecret('GROQ_API_KEY', 'gsk_keychain'); + expect(await getSecret('GROQ_API_KEY')).toBe('gsk_keychain'); + }); + + it('environment variable takes precedence over the keychain', async () => { + await setSecret('GROQ_API_KEY', 'gsk_keychain'); + process.env.GROQ_API_KEY = 'gsk_env'; + expect(await getApiKey('groq')).toBe('gsk_env'); + }); + + it('falls back to the keychain when the env var is unset', async () => { + await setSecret('OPENAI_API_KEY', 'sk_keychain'); + expect(await getApiKey('openai')).toBe('sk_keychain'); + }); + + it('returns undefined when neither source has a key', async () => { + expect(await getApiKey('groq')).toBeUndefined(); + }); +}); diff --git a/test/unit/local-whisper.test.ts b/test/unit/local-whisper.test.ts new file mode 100644 index 0000000..8291b39 --- /dev/null +++ b/test/unit/local-whisper.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LocalWhisperProvider } from '../../src/transcription/local-whisper.js'; +import { findWhisperModel } from '../../src/transcription/whisper-bin.js'; + +describe('LocalWhisperProvider', () => { + const original = { ...process.env }; + + beforeEach(() => { + process.env = { ...original }; + delete process.env.RECMP3_WHISPER_BIN; + delete process.env.RECMP3_WHISPER_MODEL; + }); + + afterEach(() => { + process.env = original; + }); + + it('has no upload size limit and the local-whisper name', () => { + const provider = new LocalWhisperProvider(); + expect(provider.name).toBe('local-whisper'); + expect(provider.maxFileSizeBytes).toBe(Number.POSITIVE_INFINITY); + }); + + it('ping reports not-ok (exit code 6 path) when no binary/model is configured', async () => { + const provider = new LocalWhisperProvider(); + const ping = await provider.ping(); + expect(ping.ok).toBe(false); + expect(ping.error).toBeTruthy(); + }); + + it('findWhisperModel throws a LocalWhisperError when no model is set', () => { + expect(() => findWhisperModel()).toThrowError(/model/i); + }); + + it('findWhisperModel throws when the model path does not exist', () => { + expect(() => findWhisperModel('/no/such/model.bin')).toThrowError(/not found/i); + }); +}); diff --git a/test/unit/manifest.test.ts b/test/unit/manifest.test.ts new file mode 100644 index 0000000..7de9994 --- /dev/null +++ b/test/unit/manifest.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { ExitCode } from '../../src/errors.js'; +import { MANIFEST, agentTools } from '../../src/agent/manifest.js'; + +describe('command manifest', () => { + it('declares the agent-native global flags', () => { + const names = MANIFEST.globalFlags.map((f) => f.name); + expect(names).toContain('--json'); + expect(names).toContain('--yes'); + expect(names).toContain('--quiet'); + }); + + it('exposes the stable exit-code contract', () => { + expect(MANIFEST.exitCodes.success).toBe(ExitCode.SUCCESS); + expect(MANIFEST.exitCodes.transcription).toBe(ExitCode.TRANSCRIPTION); + expect(MANIFEST.exitCodes.localWhisper).toBe(ExitCode.LOCAL_WHISPER); + expect(MANIFEST.exitCodes.input).toBe(ExitCode.INPUT); + }); + + it('gives every agent-safe command a unique snake_case tool name and flags array', () => { + const tools = agentTools(); + expect(tools.length).toBeGreaterThan(0); + const toolNames = new Set(); + for (const cmd of tools) { + expect(cmd.tool).toMatch(/^recmp3_[a-z_]+$/); + expect(toolNames.has(cmd.tool!)).toBe(false); + toolNames.add(cmd.tool!); + expect(Array.isArray(cmd.flags)).toBe(true); + } + }); + + it('does not expose interactive commands (mcp, config init) as agent tools', () => { + const toolNames = agentTools().map((c) => c.name); + expect(toolNames).not.toContain('mcp'); + expect(toolNames).not.toContain('config init'); + }); +}); diff --git a/test/unit/mcp.test.ts b/test/unit/mcp.test.ts new file mode 100644 index 0000000..74e5171 --- /dev/null +++ b/test/unit/mcp.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { AgentContext } from '../../src/agent/context.js'; +import { CaptureSink } from '../../src/agent/output.js'; +import { agentTools } from '../../src/agent/manifest.js'; +import { runManifest } from '../../src/commands/manifest.js'; + +describe('MCP tool derivation', () => { + it('derives at least the core agent tools from the manifest', () => { + const names = agentTools().map((c) => c.tool); + expect(names).toContain('recmp3_transcribe'); + expect(names).toContain('recmp3_sources'); + expect(names).toContain('recmp3_manifest'); + }); + + it('a capture-mode command yields a forwardable envelope (the MCP call shape)', async () => { + const sink = new CaptureSink(); + const ctx = new AgentContext({ json: true, yes: true, quiet: true, sink }); + + await runManifest(ctx); + + expect(sink.envelope?.ok).toBe(true); + const data = (sink.envelope as { data: { name: string } }).data; + expect(data.name).toBe('recmp3'); + // The MCP server JSON-stringifies this envelope as tool result content. + expect(() => JSON.stringify(sink.envelope)).not.toThrow(); + }); +}); diff --git a/test/unit/output-envelope.test.ts b/test/unit/output-envelope.test.ts new file mode 100644 index 0000000..acb0cdb --- /dev/null +++ b/test/unit/output-envelope.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { AgentContext } from '../../src/agent/context.js'; +import { CaptureSink, SCHEMA_VERSION, toErrorPayload } from '../../src/agent/output.js'; +import { ConfigError, ExitCode, InputError, LocalWhisperError } from '../../src/errors.js'; + +describe('JSON envelope', () => { + it('wraps a successful payload with ok=true and the schema version', () => { + const sink = new CaptureSink(); + const ctx = new AgentContext({ json: true, sink }); + ctx.ok('demo', { hello: 'world' }); + + expect(sink.envelope).toEqual({ + ok: true, + command: 'demo', + schemaVersion: SCHEMA_VERSION, + data: { hello: 'world' }, + }); + }); + + it('wraps an error with ok=false and the error contract', () => { + const sink = new CaptureSink(); + const ctx = new AgentContext({ json: true, sink }); + ctx.fail(new InputError('bad input'), 'demo'); + + expect(sink.envelope).toMatchObject({ + ok: false, + command: 'demo', + error: { code: 'INPUT_ERROR', message: 'bad input', exitCode: ExitCode.INPUT }, + }); + }); + + it('does not run the human renderer in json mode', () => { + const sink = new CaptureSink(); + const ctx = new AgentContext({ json: true, sink }); + let ran = false; + ctx.ok('demo', {}, () => { + ran = true; + }); + expect(ran).toBe(false); + }); + + it('runs the human renderer in human mode', () => { + const ctx = new AgentContext({ json: false }); + let ran = false; + ctx.ok('demo', {}, () => { + ran = true; + }); + expect(ran).toBe(true); + }); +}); + +describe('toErrorPayload exit-code mapping', () => { + it('maps typed errors to their stable exit codes', () => { + expect(toErrorPayload(new ConfigError('x')).exitCode).toBe(ExitCode.CONFIG); + expect(toErrorPayload(new LocalWhisperError('x')).exitCode).toBe(ExitCode.LOCAL_WHISPER); + expect(toErrorPayload(new InputError('x')).exitCode).toBe(ExitCode.INPUT); + }); + + it('maps unexpected errors to UNKNOWN', () => { + expect(toErrorPayload(new Error('boom')).exitCode).toBe(ExitCode.UNKNOWN); + expect(toErrorPayload('weird').exitCode).toBe(ExitCode.UNKNOWN); + }); +}); diff --git a/test/unit/prompt-stdin.test.ts b/test/unit/prompt-stdin.test.ts new file mode 100644 index 0000000..524496a --- /dev/null +++ b/test/unit/prompt-stdin.test.ts @@ -0,0 +1,47 @@ +import { Readable } from 'node:stream'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AgentContext } from '../../src/agent/context.js'; +import { CaptureSink } from '../../src/agent/output.js'; +import { runPrompt } from '../../src/commands/prompt.js'; + +const realStdin = process.stdin; + +function mockStdin(text: string) { + const stream = Readable.from([Buffer.from(text, 'utf-8')]); + Object.defineProperty(process, 'stdin', { value: stream, configurable: true }); +} + +afterEach(() => { + Object.defineProperty(process, 'stdin', { value: realStdin, configurable: true }); +}); + +describe('prompt reads from stdin via "-"', () => { + it('applies the raw template to piped text', async () => { + mockStdin('hello from a pipe'); + const sink = new CaptureSink(); + const ctx = new AgentContext({ json: true, sink }); + + await runPrompt('-', { template: 'raw' }, ctx); + + expect(sink.envelope?.ok).toBe(true); + expect(sink.envelope).toMatchObject({ + command: 'prompt', + data: { template: 'raw', output: 'hello from a pipe' }, + }); + }); + + it('rejects an unknown template with an INPUT error', async () => { + const ctx = new AgentContext({ json: true, sink: new CaptureSink() }); + await expect(runPrompt('transcript.txt', { template: 'nope' }, ctx)).rejects.toMatchObject({ + code: 'INPUT_ERROR', + }); + }); + + it('errors when stdin is empty', async () => { + mockStdin(''); + const ctx = new AgentContext({ json: true, sink: new CaptureSink() }); + await expect(runPrompt('-', { template: 'raw' }, ctx)).rejects.toMatchObject({ + code: 'INPUT_ERROR', + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a71174d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}); From 249097f348388a3ad38c7ae75ddd1d06ba8a0b73 Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Tue, 2 Jun 2026 04:18:23 -0600 Subject: [PATCH 2/2] fix(ci): enforce LF line endings to unblock Windows CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .gitattributes (* text=auto eol=lf) so git never converts LF→CRLF on Windows checkout. Add lineEnding: lf to biome.json so Biome explicitly asserts LF rather than silently accepting CRLF on Windows runners — the combination was causing all 2 Windows matrix jobs to fail the format check. Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 17 +++++++++++++++++ biome.json | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b821715 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Enforce LF line endings for all text files on all platforms. +# Prevents Biome from failing the format check on Windows (CRLF vs LF diff). +* text=auto eol=lf + +# Binary files — never touch line endings +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.wav binary +*.mp3 binary +*.ogg binary +*.flac binary +*.m4a binary +*.webm binary +*.node binary diff --git a/biome.json b/biome.json index bb56a97..07be5ff 100644 --- a/biome.json +++ b/biome.json @@ -18,7 +18,8 @@ "formatter": { "enabled": true, "indentStyle": "space", - "indentWidth": 2 + "indentWidth": 2, + "lineEnding": "lf" }, "javascript": { "formatter": {