Compare commits
76 commits
feature/tt
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 159547e799 | |||
| a8cbfb3665 | |||
| 4aaa80898d | |||
| 5b6c65d30f | |||
| b1730bfaf4 | |||
| da317b7177 | |||
| 49c4138a1c | |||
| e1033398e9 | |||
| 5b138df664 | |||
| bf5e223abf | |||
| d157978d52 | |||
| e2253b912d | |||
| 4725f148f3 | |||
| 4bccbcc9ee | |||
| 7652e62d47 | |||
| 19272bc0b4 | |||
| eec90209d2 | |||
| 3c3dab1525 | |||
| f8e0f9a368 | |||
| 56352d87ec | |||
| f3146ab087 | |||
| fc7d9101e9 | |||
| 710d2e7593 | |||
| dd6df6dd6d | |||
| 749559e19c | |||
| 0e07268bfb | |||
| 4674cca337 | |||
| 32f9dd5495 | |||
| 7bf8951b62 | |||
| 574ebdc8a1 | |||
| 21dac683a2 | |||
| a03e6e94a9 | |||
| 39cb573619 | |||
| 3235a08633 | |||
| 4912864a75 | |||
| bbee38be80 | |||
| 6392cf3b76 | |||
| 43c3402931 | |||
| f668037582 | |||
| d98ddb3ca7 | |||
| cd3749b07e | |||
| 3e96007f1f | |||
| 6372dfc41f | |||
| fc4934af4a | |||
| c277418349 | |||
| a887e1b185 | |||
| 0c71e2cd54 | |||
| b212fee200 | |||
| 4f9e7a7a1a | |||
| 03dee78e0c | |||
| 7441a9fc19 | |||
| 0ed6a23334 | |||
| 7367cfdfe3 | |||
| af52e89928 | |||
| 91175c70b6 | |||
| 8cb1191fde | |||
| 7578e0ed2a | |||
| efb44c4414 | |||
| e91a8a20a1 | |||
| a6422c51aa | |||
| 7f0498fc84 | |||
| decfd2bde0 | |||
| c051f340a8 | |||
| 22fc4c874f | |||
| ea5b3728a5 | |||
| b9610663a6 | |||
| 5e733a4e14 | |||
| 9da57fe2fc | |||
| 8ff27c5de8 | |||
| 3c9a5e625a | |||
| d9bffdec2b | |||
| 79fd0a93eb | |||
| 57acb09a18 | |||
| f5c8bdfe99 | |||
| 94895aacd5 | |||
| 7b730631b2 |
23 changed files with 2409 additions and 616 deletions
26
AGENTS.md
26
AGENTS.md
|
|
@ -1,26 +1,36 @@
|
|||
# lmc
|
||||
|
||||
Bun CLI tool for streaming LLM chat with inline markdown rendering.
|
||||
TypeScript CLI tool for streaming LLM chat with inline markdown rendering.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
bun run dev # dev run
|
||||
bun run prod # run bundled output
|
||||
bun run check # biome lint + format + organize imports
|
||||
pnpm run dev # dev run (rolldown bundle + node)
|
||||
pnpm run check # biome lint + format + organize imports + typecheck
|
||||
pnpm run build # check + bundle index.ts and tts.ts (minified ESM)
|
||||
pnpm run build:bin # check + bundle with esbuild (CJS) + build SEA binaries
|
||||
pnpm run clean # remove dist/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `index.ts` — CLI entry point, orchestration
|
||||
- `src/index.ts` — LMC CLI entry point, orchestration
|
||||
- `src/tts.ts` — TTSC CLI entry point (text-to-speech, Kokoro pipeline via `uv run`)
|
||||
- `src/config.ts` — `initConfig()`, `loadConfig()`: manages `~/.lmc_config.json`
|
||||
- `src/conversation.ts` — `Conversation` class: load/save to `~/.lmc_convo.json`
|
||||
- `src/errors.ts` — `handleTopLevel()`: TTY-aware error wrapping
|
||||
- `src/prompts.ts` — `commitDiff()`, `reviewFile()`, `reviewDiff()`, `file()`: prompt builders
|
||||
- `src/render/index.ts` — `Renderer` class: streams LLM response, buffers markdown, renders to stdout/stderr
|
||||
- `src/render/markdown.ts` — `renderMarkdown()`: syntax-highlighted markdown via `@comark/ansi` + Shiki
|
||||
- `src/render/markdown.ts` — `renderMarkdown()`: markdown via `marked` + Shiki + `@cliffy/table`
|
||||
- `src/render/codeToANSI.ts` — Shiki token output to ANSI escape sequences
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Bun only** — not Node. Use `Bun.file()`, `Bun.argv`, `Bun.sleep()`.
|
||||
- **Node.js** — Use modern Node APIs (`node:fs/promises`, `node:stream/consumers`, etc.).
|
||||
- **No tests** — none exist.
|
||||
- **Biome config**: double quotes, space indent, auto-import-organize on. Run `bun run check` before committing.
|
||||
- **Biome config**: double quotes, space indent, auto-import-organize on. `pnpm run check` also runs `tsc --noEmit`.
|
||||
- **Build tools**: rolldown for ESM bundles, esbuild for CJS binaries, `node --build-sea` for SEA single-executable apps.
|
||||
- **TTS**: requires Python with `uv` — Kokoro pipeline lives in `tts/`.
|
||||
|
||||
## Style
|
||||
|
||||
|
|
|
|||
125
README.md
125
README.md
|
|
@ -1,15 +1,130 @@
|
|||
# lmc
|
||||
|
||||
To install dependencies:
|
||||
A CLI tool for LLM chats with terminal native streaming.
|
||||
|
||||
## Features
|
||||
|
||||
- **Streaming markdown rendering** — incremental rendering with syntax highlighting
|
||||
- **Conversation persistence** — chat history saved to JSON
|
||||
- **Model configuration** — persist model preferences to JSON
|
||||
- **Reasoning display** — thinking tokens rendered _in style_ on stderr
|
||||
- **Usage stats** — input/output tokens and tokens/sec rate after each response
|
||||
- **TTY-aware** — automatically disables markdown rendering when output is piped
|
||||
- **Text-to-speech** — `ttsc` sub-command for streaming TTS via Kokoro model
|
||||
|
||||
## Requirements
|
||||
|
||||
- [pnpm](https://pnpm.io) v11+
|
||||
- [Node.js](https://nodejs.org) v22+
|
||||
- [uv](https://docs.astral.sh/uv/) — only for `ttsc` (text-to-speech)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun install
|
||||
# Bundle and install locally
|
||||
pnpm build
|
||||
pnpm add --global .
|
||||
|
||||
# Build a single-file exe
|
||||
pnpm build:bin
|
||||
cp dist/lmc dist/ttsc /somewhere/bin/
|
||||
|
||||
# For ttsc only
|
||||
cp -r tts ~/.ttsc
|
||||
```
|
||||
|
||||
To run:
|
||||
_Package to come later_
|
||||
|
||||
## Usage
|
||||
|
||||
### Chat (lmc)
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
lmc [options] <prompt...>
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
| Flag | Short | Type | Description |
|
||||
| ------------ | ----- | ------- | ----------------------------------------------------- |
|
||||
| `--new` | `-n` | boolean | Start a new conversation (clear history) |
|
||||
| `--model` | `-m` | string | Set model name for this session |
|
||||
| `--config` | `-c` | boolean | Initialize a new config file (`~/.lmc_config.json`) |
|
||||
| `--plain` | — | boolean | Disable markdown rendering for both stdout and stderr |
|
||||
| `--plainOut` | — | boolean | Disable markdown rendering for stdout only |
|
||||
| `--plainErr` | — | boolean | Disable markdown rendering for stderr only |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
lmc --config # Initialize/edit config
|
||||
lmc --new "Introduce yourself." # New conversation
|
||||
lmc "What is TypeScript?" # Continue most recent chat
|
||||
lmc -m llama3 "Explain quantum computing" # Chat with specific model on-the-fly
|
||||
lmc --plain "Plain text output" # No markdown rendering
|
||||
```
|
||||
|
||||
### Text-to-Speech (ttsc)
|
||||
|
||||
```bash
|
||||
echo "text" | ttsc [options]
|
||||
```
|
||||
|
||||
| Flag | Short | Type | Description |
|
||||
| -------- | ----- | ------- | -------------------------------------------------------- |
|
||||
| `--wait` | `-w` | boolean | Wait for all processing before exiting (don't auto-play) |
|
||||
| `--url` | `-u` | boolean | Fetch text from a URL instead of stdin |
|
||||
| `--edit` | `-e` | boolean | Edit the input with your $EDITOR before processing |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
echo "Hello, world!" | ttsc
|
||||
echo "Read this aloud" | ttsc --wait
|
||||
echo "https://example.com" | ttsc --url
|
||||
```
|
||||
|
||||
Requires: [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
Wants:
|
||||
|
||||
- `mpv` for playback
|
||||
- `sox` for wav merging with `--wait`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm run dev # dev run
|
||||
pnpm run check # biome lint + format + organize imports
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
index.ts — LMC entry point, orchestration
|
||||
tts.ts — TTSC entry point, orchestration
|
||||
src/render/index.ts — Renderer class: streams LLM response, buffers markdown
|
||||
src/render/markdown.ts — renderMarkdown(): syntax-highlighted markdown via Marked + Shiki
|
||||
src/conversation/ — Conversation persistence
|
||||
src/config/ — App config persistence
|
||||
tts/ — TTS python stub (Kokoro pipeline)
|
||||
out/ — Bundled output (index.js, tts.js)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Conversation history:** `~/.lmc_convo.json`
|
||||
- **Model config:** `~/.lmc_config.json`
|
||||
- **TTS files:** `~/.ttsc/`
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
| ---------------------- | ------------------------------- |
|
||||
| `ai` (Vercel AI SDK) | Streaming text/usage |
|
||||
| `@clack/prompts` | Interactive prompts and spinner |
|
||||
| `@cliffy/table` | ASCII table rendering |
|
||||
| `@mozilla/readability` | HTML-to-text extraction (ttsc) |
|
||||
| `chalk` | Terminal string styling |
|
||||
| `happy-dom` | DOM parsing for URLs (ttsc) |
|
||||
| `shiki` | Syntax highlighting |
|
||||
| `terminal-link` | Clickable terminal hyperlinks |
|
||||
| `marked` | Markdown parsing |
|
||||
|
|
|
|||
196
bun.lock
196
bun.lock
|
|
@ -1,196 +0,0 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "aicl",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.41",
|
||||
"ai": "^6.0.161",
|
||||
"ansi-styles": "^6.2.3",
|
||||
"chalk": "^5.6.2",
|
||||
"cli-table3": "^0.6.5",
|
||||
"shiki": "^4.0.2",
|
||||
"terminal-link": "^5.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.12",
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.98", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ol+nP8PIlj8FjN8qKlxhE89N0woqAaGi9CUBGp1boe3RafpphJ7WMuq/RErSvxtwTqje03TP+zIdzP113krxRg=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
|
||||
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"ai": ["ai@6.0.161", "", { "dependencies": { "@ai-sdk/gateway": "3.0.98", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ufhmijmx2YyWTPAicGgtpLOB/xD7mG8zKs1pT1Trj+JL/3r1rS8fkMi/cHZoChSAQSGB4pgmcWVxDrVTUvK2IQ=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"supports-hyperlinks": ["supports-hyperlinks@4.4.0", "", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.2.2" } }, "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg=="],
|
||||
|
||||
"terminal-link": ["terminal-link@5.0.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "supports-hyperlinks": "^4.1.0" } }, "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
}
|
||||
}
|
||||
93
index.ts
93
index.ts
|
|
@ -1,93 +0,0 @@
|
|||
import { parseArgs } from "node:util";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { streamText } from "ai";
|
||||
|
||||
import { loadConfig, saveConfig } from "./src/config";
|
||||
import {
|
||||
type Conversation,
|
||||
loadConversation,
|
||||
saveConversation,
|
||||
} from "./src/conversation";
|
||||
import Renderer from "./src/render";
|
||||
|
||||
const { values: args, positionals } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
model: { type: "string" },
|
||||
m: { type: "string" },
|
||||
new: { type: "boolean" },
|
||||
n: { type: "boolean" },
|
||||
save: { type: "boolean" },
|
||||
s: { type: "boolean" },
|
||||
plain: { type: "boolean" },
|
||||
plainOut: { type: "boolean" },
|
||||
plainErr: { type: "boolean" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const newConvo = args.new ?? args.n;
|
||||
const plainOut = args.plainOut ?? args.plain ?? !process.stdout.isTTY;
|
||||
const plainErr = args.plainErr ?? args.plain ?? !process.stderr.isTTY;
|
||||
const modelFlag = args.model ?? args.m;
|
||||
const saveConfigFlag = args.save ?? args.s;
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
config.model = modelFlag ?? config.model;
|
||||
|
||||
if (!config.model) {
|
||||
console.error(
|
||||
"Please run 'lmc --model <model> --save' to set a default model.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (saveConfigFlag) {
|
||||
await saveConfig(config);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = new Renderer(plainOut, plainErr);
|
||||
|
||||
const provider = createOpenAICompatible({
|
||||
name: "llama.cpp",
|
||||
apiKey: "local",
|
||||
baseURL: "http://127.0.0.1:8080/v1",
|
||||
includeUsage: true, // Include usage information in streaming responses
|
||||
});
|
||||
|
||||
let convo: Conversation = {
|
||||
model: config.model,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
if (!newConvo) {
|
||||
convo = await loadConversation(config.model);
|
||||
|
||||
if (args.model || args.m) {
|
||||
convo.model = modelFlag ?? config.model;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = positionals.slice(2).join(" ") || "Introduce yourself.";
|
||||
convo.messages.push({ role: "user", content: prompt });
|
||||
|
||||
const { fullStream, text, totalUsage } = streamText({
|
||||
model: provider(convo.model),
|
||||
messages: convo.messages,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await renderer.renderStream(fullStream);
|
||||
const stop = Date.now();
|
||||
|
||||
convo.messages.push({ role: "assistant", content: await text });
|
||||
await saveConversation(convo);
|
||||
|
||||
await renderer.renderStats(totalUsage, start, stop);
|
||||
}
|
||||
|
||||
await main();
|
||||
49
package.json
49
package.json
|
|
@ -1,38 +1,41 @@
|
|||
{
|
||||
"name": "lmc",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"lmc": "out/index.js",
|
||||
"ttsc": "out/tts.js"
|
||||
"lmc": "dist/index.js",
|
||||
"ttsc": "dist/tts.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun run index.ts",
|
||||
"prod": "bun run out/index.js",
|
||||
"check": "bunx --bun @biomejs/biome check --write && bun run tc",
|
||||
"build:js": "bun run check && bun run --parallel build:bundle build:tts && bun run --parallel shebang shebang:tts",
|
||||
"build:bundle": "bun build --target=bun --production --sourcemap --outdir=out index.ts",
|
||||
"build:tts": "bun build --target=bun --production --sourcemap --outdir=out tts.ts",
|
||||
"build:bin": "bun build --compile --outfile=out/lmc index.ts",
|
||||
"shebang": "sed -i '1i#!\\/usr\\/bin\\/env bun' out/index.js",
|
||||
"shebang:tts": "sed -i '1i#!\\/usr\\/bin\\/env bun' out/tts.js",
|
||||
"dev": "pnpm run bundle src/index.ts >/dev/null && node dist/index.js",
|
||||
"check": "biome check --write && pnpm run tc",
|
||||
"clean": "rm -rf dist",
|
||||
"build": "pnpm run check && pnpm run bundle --minify src/index.ts && pnpm run bundle --minify src/tts.ts",
|
||||
"build:bin": "pnpm run check && pnpm run bundle:bin --minify src/index.ts && pnpm run bundle:bin --minify src/tts.ts && pnpm run bin",
|
||||
"bundle": "rolldown --dir=dist --platform=node --transform.target=node22 --format=esm",
|
||||
"bundle:bin": "esbuild --bundle --outdir=dist --platform=node --target=node26 --format=cjs",
|
||||
"bin": "node --build-sea sea-config.json && node --build-sea sea-config-tts.json",
|
||||
"tc": "tsc --noEmit --incremental"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.50",
|
||||
"@biomejs/biome": "2.4.12",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.41",
|
||||
"ai": "^6.0.161",
|
||||
"@clack/prompts": "^1.5.1",
|
||||
"@cliffy/table": "jsr:^1.2.1",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@types/node": "^25.9.3",
|
||||
"ai": "^6.0.206",
|
||||
"ansi-styles": "^6.2.3",
|
||||
"chalk": "^5.6.2",
|
||||
"cli-table3": "^0.6.5",
|
||||
"shiki": "^4.0.2",
|
||||
"terminal-link": "^5.0.0"
|
||||
"esbuild": "^0.28.1",
|
||||
"execa": "^9.6.1",
|
||||
"happy-dom": "^20.10.3",
|
||||
"marked": "^18.0.5",
|
||||
"rolldown": "^1.1.1",
|
||||
"shiki": "^4.2.0",
|
||||
"string-width": "^8.2.1",
|
||||
"terminal-link": "^5.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"wrap-ansi": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1504
pnpm-lock.yaml
generated
Normal file
1504
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
minimumReleaseAge: 4320
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
6
sea-config-tts.json
Normal file
6
sea-config-tts.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"main": "dist/tts.js",
|
||||
"output": "dist/ttsc",
|
||||
"useCodeCache": true,
|
||||
"disableExperimentalSEAWarning": true
|
||||
}
|
||||
6
sea-config.json
Normal file
6
sea-config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"main": "dist/index.js",
|
||||
"output": "dist/lmc",
|
||||
"useCodeCache": true,
|
||||
"disableExperimentalSEAWarning": true
|
||||
}
|
||||
79
src/config.ts
Normal file
79
src/config.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { autocomplete, intro, isCancel, outro, text } from "@clack/prompts";
|
||||
import chalk from "chalk";
|
||||
|
||||
const home = os.homedir();
|
||||
const configPath = `${home}/.lmc_config.json`;
|
||||
|
||||
export interface AppConfig {
|
||||
baseURL: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function initConfig(old: AppConfig | null): Promise<AppConfig> {
|
||||
console.log();
|
||||
intro(chalk.blue("Welcome to LMC. Let's make a new config!"));
|
||||
|
||||
const baseURL = await text({
|
||||
message: "What's your base URL:",
|
||||
defaultValue: old?.baseURL ?? "http://127.0.0.1:8080/v1",
|
||||
placeholder: old?.baseURL ?? "http://127.0.0.1:8080/v1",
|
||||
});
|
||||
if (isCancel(baseURL)) process.exit(1);
|
||||
|
||||
const models = await getModels(baseURL);
|
||||
const initialValue = models.includes(old?.model ?? "")
|
||||
? old?.model
|
||||
: models[0];
|
||||
const model = await autocomplete({
|
||||
message: "Choose a default model:",
|
||||
initialValue,
|
||||
placeholder: initialValue ?? "Start typing...",
|
||||
options: models.map((model) => ({ value: model })),
|
||||
validate: (value) =>
|
||||
models.includes(typeof value === "string" ? value : "")
|
||||
? undefined
|
||||
: `Unable to find model "${value}"`,
|
||||
});
|
||||
if (!model || isCancel(model)) process.exit(1);
|
||||
|
||||
const config = { baseURL, model } satisfies AppConfig;
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, " "));
|
||||
|
||||
outro(chalk.green("Configuration saved!"));
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
type Models = {
|
||||
data: {
|
||||
created: number;
|
||||
id: string;
|
||||
object: "model";
|
||||
owned_by: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
async function getModels(url: string) {
|
||||
const response = await fetch(`${url}/models`);
|
||||
const models = (await response.json()) as Models;
|
||||
return models.data.map((model) => model.id);
|
||||
}
|
||||
|
||||
export async function loadConfig(
|
||||
configFlag: boolean | undefined,
|
||||
): Promise<AppConfig | null> {
|
||||
let config: AppConfig;
|
||||
try {
|
||||
const text = await fs.readFile(configPath, "utf8");
|
||||
config = JSON.parse(text);
|
||||
} catch {
|
||||
if (configFlag) return null;
|
||||
console.error(`Config not found at ${configPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import os from "node:os";
|
||||
import chalk from "chalk";
|
||||
|
||||
const home = os.homedir();
|
||||
const configFile = Bun.file(`${home}/.lmc_config.json`);
|
||||
|
||||
export interface AppConfig {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<AppConfig> {
|
||||
let config: AppConfig;
|
||||
try {
|
||||
config = await configFile.json();
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
await configFile.write(JSON.stringify(config, null, " "));
|
||||
console.log(chalk.green("Configuration saved!"));
|
||||
}
|
||||
38
src/conversation.ts
Normal file
38
src/conversation.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
const home = os.homedir();
|
||||
const convoPath = `${home}/.lmc_convo.json`;
|
||||
|
||||
export interface IConversation {
|
||||
model: string;
|
||||
messages: ModelMessage[];
|
||||
}
|
||||
|
||||
export class Conversation implements IConversation {
|
||||
messages: ModelMessage[] = [];
|
||||
|
||||
constructor(public model: string) {}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const text = await fs.readFile(convoPath, "utf8");
|
||||
const convo: IConversation = JSON.parse(text);
|
||||
this.model = convo.model;
|
||||
this.messages = convo.messages;
|
||||
} catch {
|
||||
console.error("No conversation found, starting one.");
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
await fs.writeFile(
|
||||
convoPath,
|
||||
JSON.stringify({
|
||||
model: this.model,
|
||||
messages: this.messages,
|
||||
} satisfies IConversation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import os from "node:os";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
const home = os.homedir();
|
||||
export const convoFile = Bun.file(`${home}/.lmc_convo.json`);
|
||||
|
||||
export interface Conversation {
|
||||
model: string;
|
||||
messages: ModelMessage[];
|
||||
}
|
||||
|
||||
export async function loadConversation(
|
||||
defaultModel: string,
|
||||
): Promise<Conversation> {
|
||||
try {
|
||||
return await convoFile.json();
|
||||
} catch {
|
||||
return { model: defaultModel, messages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConversation(convo: Conversation): Promise<void> {
|
||||
await convoFile.write(JSON.stringify(convo));
|
||||
}
|
||||
27
src/errors.ts
Normal file
27
src/errors.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { chalkStderr } from "chalk";
|
||||
import wrapAnsi from "wrap-ansi";
|
||||
|
||||
export interface ErrorLike {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const isErrorLike = (err: unknown): err is ErrorLike =>
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"message" in err &&
|
||||
typeof err.message === "string";
|
||||
|
||||
export function handleTopLevel(err: unknown) {
|
||||
if (isErrorLike(err)) {
|
||||
if (process.stderr.isTTY) {
|
||||
const [columns] = process.stderr.getWindowSize();
|
||||
console.error(wrapAnsi(chalkStderr.red(err.message), columns));
|
||||
} else {
|
||||
console.error(err.message);
|
||||
}
|
||||
} else {
|
||||
console.error(String(err));
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
107
src/index.ts
Normal file
107
src/index.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import consumers from "node:stream/consumers";
|
||||
import util from "node:util";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { streamText } from "ai";
|
||||
|
||||
import { initConfig, loadConfig } from "./config";
|
||||
import { Conversation } from "./conversation";
|
||||
import { handleTopLevel } from "./errors";
|
||||
import {
|
||||
base,
|
||||
commitDiff,
|
||||
contextFence,
|
||||
diffFence,
|
||||
fileFence,
|
||||
review,
|
||||
} from "./prompts";
|
||||
import Renderer from "./render";
|
||||
|
||||
async function main() {
|
||||
const { values: args, positionals } = util.parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
model: { type: "string", short: "m" },
|
||||
new: { type: "boolean", short: "n" },
|
||||
config: { type: "boolean", short: "c" },
|
||||
plain: { type: "boolean" },
|
||||
"plain-out": { type: "boolean" },
|
||||
"plain-err": { type: "boolean" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
"git-message": { type: "boolean", short: "g" },
|
||||
review: { type: "boolean", short: "r" },
|
||||
file: { type: "string", short: "f", multiple: true },
|
||||
stdin: { type: "boolean", short: "i" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (args.version) {
|
||||
console.log("0.0.0");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await loadConfig(args.config);
|
||||
|
||||
if (args.config || !config) {
|
||||
await initConfig(config);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = args.model ?? config.model;
|
||||
|
||||
const plainOut = args["plain-out"] ?? args.plain ?? !process.stdout.isTTY;
|
||||
const plainErr = args["plain-err"] ?? args.plain ?? !process.stderr.isTTY;
|
||||
const renderer = new Renderer(plainOut, plainErr);
|
||||
|
||||
const provider = createOpenAICompatible({
|
||||
name: "llama.cpp",
|
||||
apiKey: "local",
|
||||
baseURL: config.baseURL,
|
||||
includeUsage: true, // Include usage information in streaming responses
|
||||
});
|
||||
|
||||
const convo = new Conversation(model);
|
||||
|
||||
if (!args.new) {
|
||||
await convo.load();
|
||||
if (args.model) convo.model = args.model;
|
||||
}
|
||||
|
||||
const input = positionals.join(" ").trim();
|
||||
let stdinText = "";
|
||||
if (args.stdin) stdinText = await consumers.text(process.stdin);
|
||||
const files = [];
|
||||
for (const file of args.file ?? [])
|
||||
files.push(fileFence(file, await fs.readFile(file, "utf8")));
|
||||
|
||||
let prompt = base(input, [
|
||||
...files,
|
||||
stdinText ? contextFence(stdinText) : "",
|
||||
]);
|
||||
if (args["git-message"]) {
|
||||
prompt = commitDiff(stdinText, [...files]);
|
||||
} else if (args.review) {
|
||||
prompt = review([...files, stdinText ? diffFence(stdinText) : "", input]);
|
||||
}
|
||||
|
||||
if (!prompt) throw new Error("No prompt given.");
|
||||
|
||||
convo.messages.push({ role: "user", content: prompt });
|
||||
|
||||
const { fullStream, text, totalUsage } = streamText({
|
||||
model: provider(convo.model),
|
||||
messages: convo.messages,
|
||||
});
|
||||
|
||||
await renderer.renderStream(fullStream);
|
||||
|
||||
convo.messages.push({ role: "assistant", content: await text });
|
||||
await convo.save();
|
||||
|
||||
await renderer.renderStats(totalUsage);
|
||||
}
|
||||
|
||||
main().catch(handleTopLevel);
|
||||
31
src/prompts.ts
Normal file
31
src/prompts.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const blocks = (input?: string[]) =>
|
||||
input?.map((block) => (block ? `${block}\n` : "")).join("") ?? "";
|
||||
|
||||
export const base = (input: string, extra?: string[]) =>
|
||||
`${blocks(extra)}${input}`;
|
||||
|
||||
export const fileFence = (name: string, input: string) => `<file name="${name}">
|
||||
${input}
|
||||
</file>
|
||||
`;
|
||||
|
||||
export const diffFence = (input: string) => `<diff>
|
||||
${input}
|
||||
</diff>`;
|
||||
|
||||
export const contextFence = (input: string) => `<context>
|
||||
${input}
|
||||
</context>`;
|
||||
|
||||
export const commitDiff = (
|
||||
input: string,
|
||||
extra?: string[],
|
||||
) => `${blocks(extra)}${diffFence(input)}
|
||||
Generate a conventional commit message for the included context. If you are unable to generate a reasonable option, ask for more context.`;
|
||||
|
||||
export const review = (input: string[]) => `${blocks(input)}
|
||||
Review the included context for any potential bugs, best practices, performance issues, or security flaws.
|
||||
|
||||
Today's date is ${new Date().toISOString()}, your version info is likely out-of-date.
|
||||
|
||||
Let me know if you need any more context to complete your review.`;
|
||||
|
|
@ -24,7 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||
SOFTWARE. */
|
||||
|
||||
import c from "chalk";
|
||||
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki";
|
||||
import type { BundledLanguage, BundledTheme, HighlighterCore } from "shiki";
|
||||
|
||||
// From @shikijs/vscode-textmate
|
||||
enum FontStyle {
|
||||
|
|
@ -37,7 +37,7 @@ enum FontStyle {
|
|||
}
|
||||
|
||||
export function codeToANSI(
|
||||
highlighter: HighlighterGeneric<BundledLanguage, BundledTheme>,
|
||||
highlighter: HighlighterCore,
|
||||
code: string,
|
||||
lang: BundledLanguage,
|
||||
theme: BundledTheme,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WriteStream } from "node:tty";
|
||||
import { spinner } from "@clack/prompts";
|
||||
import type {
|
||||
AsyncIterableStream,
|
||||
LanguageModelUsage,
|
||||
|
|
@ -7,34 +8,29 @@ import type {
|
|||
} from "ai";
|
||||
import styles from "ansi-styles";
|
||||
import { chalkStderr, supportsColorStderr } from "chalk";
|
||||
import stringWidth from "string-width";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
|
||||
export default class Renderer {
|
||||
private lineCount = 0;
|
||||
private charsSinceNL = 0;
|
||||
private memOk = true;
|
||||
private buffer = "";
|
||||
private unsafeIndex = 0;
|
||||
private start = 0;
|
||||
private stop = 0;
|
||||
|
||||
constructor(
|
||||
private isPlain = false,
|
||||
private isPlainErr = false,
|
||||
) {
|
||||
process.stdout.addListener("drain", () => {
|
||||
this.memOk = true;
|
||||
});
|
||||
process.stderr.addListener("drain", () => {
|
||||
this.memOk = true;
|
||||
});
|
||||
}
|
||||
) {}
|
||||
|
||||
private write(text: string, pipe: WriteStream = process.stdout) {
|
||||
this.memOk = pipe.write(text);
|
||||
pipe.write(text);
|
||||
}
|
||||
|
||||
private moveClearReset(y: number, pipe: WriteStream = process.stdout) {
|
||||
this.memOk = pipe.moveCursor(-9999, y);
|
||||
this.memOk = pipe.clearScreenDown();
|
||||
pipe.moveCursor(-9999, y);
|
||||
pipe.clearScreenDown();
|
||||
this.resetCounts();
|
||||
}
|
||||
|
||||
|
|
@ -45,16 +41,17 @@ export default class Renderer {
|
|||
|
||||
private checkContainment(text: string, pipe: WriteStream = process.stdout) {
|
||||
const [columns, rows] = pipe.getWindowSize();
|
||||
this.charsSinceNL += text.length;
|
||||
this.charsSinceNL += stringWidth(text);
|
||||
if (text.includes("\n")) {
|
||||
this.lineCount += text.match(/\n/g)?.length ?? 1;
|
||||
this.charsSinceNL = text.length - text.lastIndexOf("\n") - 1;
|
||||
this.charsSinceNL =
|
||||
stringWidth(text) - stringWidth(text.slice(0, text.lastIndexOf("\n")));
|
||||
} else if (this.charsSinceNL > columns) {
|
||||
this.lineCount++;
|
||||
this.charsSinceNL = this.charsSinceNL - columns;
|
||||
}
|
||||
|
||||
if (this.lineCount >= Math.max(rows - 10, 1)) {
|
||||
if (this.lineCount >= Math.max(rows - 4, 1)) {
|
||||
this.moveClearReset(-this.lineCount, pipe);
|
||||
}
|
||||
}
|
||||
|
|
@ -87,9 +84,22 @@ export default class Renderer {
|
|||
async renderStream(stream: AsyncIterableStream<TextStreamPart<ToolSet>>) {
|
||||
this.resetCounts();
|
||||
|
||||
const spin = spinner({
|
||||
withGuide: false,
|
||||
indicator: "timer",
|
||||
onCancel: () => {
|
||||
process.exit(130);
|
||||
},
|
||||
});
|
||||
if (!this.isPlain) {
|
||||
spin.start("Waiting for response");
|
||||
}
|
||||
|
||||
for await (const part of stream) {
|
||||
switch (part.type) {
|
||||
case "reasoning-start": {
|
||||
this.start = Date.now();
|
||||
spin.clear();
|
||||
if (supportsColorStderr)
|
||||
this.write(
|
||||
`${styles.dim.open}${styles.italic.open}`,
|
||||
|
|
@ -111,6 +121,8 @@ export default class Renderer {
|
|||
} else {
|
||||
this.write("\n--- Done Thinking ---\n", process.stderr);
|
||||
}
|
||||
const time = (Date.now() - this.start) / 1000;
|
||||
this.write(`Thought for ${time.toFixed(2)}s\n`, process.stderr);
|
||||
|
||||
if (supportsColorStderr)
|
||||
this.write(
|
||||
|
|
@ -121,6 +133,8 @@ export default class Renderer {
|
|||
break;
|
||||
}
|
||||
case "text-start": {
|
||||
if (this.start === 0) this.start = Date.now();
|
||||
spin.clear();
|
||||
this.write("\n");
|
||||
break;
|
||||
}
|
||||
|
|
@ -133,6 +147,7 @@ export default class Renderer {
|
|||
break;
|
||||
}
|
||||
case "text-end": {
|
||||
this.stop = Date.now();
|
||||
if (this.buffer) {
|
||||
await this.render(this.buffer);
|
||||
this.buffer = "";
|
||||
|
|
@ -142,21 +157,15 @@ export default class Renderer {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (!this.memOk) await Bun.sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
async renderStats(
|
||||
usage: PromiseLike<LanguageModelUsage>,
|
||||
start: number,
|
||||
stop: number,
|
||||
) {
|
||||
async renderStats(usage: PromiseLike<LanguageModelUsage>) {
|
||||
const { inputTokens, outputTokens } = await usage;
|
||||
const context = (inputTokens ?? 0) / 1000;
|
||||
const output = (outputTokens ?? 0) / 1000;
|
||||
const time = (stop - start) / 1000;
|
||||
const template = `Context: ${context.toFixed(1)}k, Output: ${output.toFixed(1)}k, Time: ${time.toFixed(2)}s\n`;
|
||||
const toks = (outputTokens ?? 0) / ((this.stop - this.start) / 1000);
|
||||
const template = `Context: ${context.toFixed(1)}k, Output: ${output.toFixed(1)}k, Toks: ${toks.toFixed(2)}/s\n`;
|
||||
this.write(chalkStderr.dim(template), process.stderr);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,32 @@
|
|||
import { Cell, Table } from "@cliffy/table";
|
||||
import chalk from "chalk";
|
||||
import CliTable3 from "cli-table3";
|
||||
import {
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
createHighlighter,
|
||||
type HighlighterGeneric,
|
||||
} from "shiki";
|
||||
import { marked, Renderer, type Tokens } from "marked";
|
||||
import type { BundledLanguage, HighlighterCore } from "shiki";
|
||||
import { createHighlighterCore } from "shiki/core";
|
||||
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
|
||||
import stringWidth from "string-width";
|
||||
import terminalLink from "terminal-link";
|
||||
import wrapAnsi from "wrap-ansi";
|
||||
import { codeToANSI } from "./codeToANSI";
|
||||
|
||||
let highlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | undefined;
|
||||
let highlighter: HighlighterCore | undefined;
|
||||
let highligherLangs = "";
|
||||
|
||||
export async function renderMarkdown(
|
||||
text: PromiseLike<string> | string,
|
||||
columns: number,
|
||||
): Promise<string> {
|
||||
const md = await text;
|
||||
class SWCell extends Cell {
|
||||
public override get length() {
|
||||
return stringWidth(this.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const langs = md
|
||||
.match(/```[a-z]+\n/g)
|
||||
?.map((lang) => lang.replace("```", "").trimEnd());
|
||||
class LmcRenderer extends Renderer {
|
||||
private columns: number;
|
||||
|
||||
if (langs && langs?.join() !== highligherLangs) {
|
||||
try {
|
||||
highligherLangs = langs.join();
|
||||
highlighter = await createHighlighter({
|
||||
langs,
|
||||
themes: ["kanagawa-wave", "kanagawa-lotus"],
|
||||
});
|
||||
} catch {}
|
||||
constructor(columns: number) {
|
||||
super();
|
||||
this.columns = columns;
|
||||
}
|
||||
|
||||
let currentTable = new CliTable3();
|
||||
|
||||
const rendered = Bun.markdown.render(md, {
|
||||
heading: (children, { level }) => {
|
||||
override heading({ tokens, depth }: Tokens.Heading) {
|
||||
const colors = [
|
||||
chalk.cyan.underline,
|
||||
chalk.blue,
|
||||
|
|
@ -44,73 +35,188 @@ export async function renderMarkdown(
|
|||
chalk.blueBright,
|
||||
chalk.greenBright,
|
||||
];
|
||||
const color = colors[level - 1] ?? chalk.white;
|
||||
return color.bold(`${"#".repeat(level)} ${children}\n\n`);
|
||||
},
|
||||
paragraph: (children) => `${children}\n\n`,
|
||||
blockquote: (children) => {
|
||||
const lines = children.split("\n");
|
||||
return lines
|
||||
.map((line) => `${chalk.dim("> ")}${line}`)
|
||||
.join("\n")
|
||||
.replace(/\n$/, "\n\n");
|
||||
},
|
||||
code: (children, meta) => {
|
||||
let toRender = children;
|
||||
const color = colors[depth - 1] ?? chalk.white;
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return color.bold(`${"#".repeat(depth)} ${text}\n`);
|
||||
}
|
||||
|
||||
if (highlighter && meta?.language) {
|
||||
override paragraph({ tokens }: Tokens.Paragraph) {
|
||||
const text = wrapAnsi(this.parser.parseInline(tokens), this.columns - 4);
|
||||
return `\n${text}\n`;
|
||||
}
|
||||
|
||||
override blockquote({ tokens }: Tokens.Blockquote) {
|
||||
const text = this.parser.parse(tokens).trim();
|
||||
const lines = `\n${text}\n`.split("\n");
|
||||
const toRender = lines.map((line) => `> ${line}`).join("\n");
|
||||
return `\n${toRender.replaceAll(">", chalk.dim(">"))}\n`;
|
||||
}
|
||||
|
||||
override code({ text, lang }: Tokens.Code) {
|
||||
let toRender = text;
|
||||
|
||||
if (highlighter && lang) {
|
||||
try {
|
||||
toRender = codeToANSI(
|
||||
highlighter,
|
||||
children,
|
||||
meta.language as BundledLanguage,
|
||||
text,
|
||||
lang as BundledLanguage,
|
||||
"kanagawa-wave",
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return `\`\`\`${chalk.blue(meta?.language ?? "")}\n${toRender}\`\`\`\n`;
|
||||
},
|
||||
list: (children, { depth }) =>
|
||||
`${depth > 0 ? "\n" : ""}${children.trimEnd()}${depth === 0 ? "\n\n" : ""}`,
|
||||
listItem: (children, { index, depth, ordered, start, checked }) => {
|
||||
const indent = " ".repeat(depth);
|
||||
return `\n\`\`\`${chalk.blue(lang ?? "")}\n${toRender}\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
override list({ items, ordered, start }: Tokens.List) {
|
||||
const num = start || 1;
|
||||
const renderedItems = items
|
||||
.map((item, index) =>
|
||||
this.listitem(item, ordered ? num + index : undefined),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `\n${renderedItems}\n`;
|
||||
}
|
||||
|
||||
override listitem({ tokens }: Tokens.ListItem, num?: number) {
|
||||
const text = this.parser.parse(tokens).trim();
|
||||
let marker = "";
|
||||
if (checked !== undefined) {
|
||||
marker = checked ? "[✓]" : "[ ]";
|
||||
} else if (ordered) {
|
||||
const n = (start ?? 1) + index;
|
||||
marker = `${n}.`;
|
||||
if (num !== undefined) {
|
||||
marker = `${num}.`;
|
||||
} else {
|
||||
marker = "•";
|
||||
}
|
||||
return `${indent} ${marker} ${children}\n`;
|
||||
},
|
||||
hr: () => `${chalk.dim("─".repeat(columns))}\n\n`,
|
||||
table: (_children) => {
|
||||
const toRender = currentTable.toString();
|
||||
currentTable = new CliTable3();
|
||||
return `${toRender}\n\n`;
|
||||
},
|
||||
tr: (children) => {
|
||||
if (children) currentTable.push(children.split(":tablesep:").slice(1));
|
||||
return "";
|
||||
},
|
||||
td: (children) => `:tablesep:${children}`,
|
||||
th: (children) => {
|
||||
currentTable.options.head.push(children);
|
||||
return "";
|
||||
},
|
||||
strong: (children) => chalk.bold(children),
|
||||
emphasis: (children) => chalk.italic(children),
|
||||
strikethrough: (children) => chalk.grey.strikethrough(children),
|
||||
codespan: (children) => chalk.bgWhite.black(` ${children} `),
|
||||
link: (children, { href }) =>
|
||||
chalk.blueBright.underline(terminalLink(children, href)),
|
||||
image: () => "",
|
||||
text: (children) => children,
|
||||
html: () => "",
|
||||
|
||||
const item = `${marker} ${text}`;
|
||||
return item
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
override checkbox({ checked }: Tokens.Checkbox): string {
|
||||
return checked ? "[✓] " : "[ ] ";
|
||||
}
|
||||
|
||||
override hr() {
|
||||
return `\n${chalk.dim("─".repeat(this.columns))}\n`;
|
||||
}
|
||||
|
||||
override table({ header, rows }: Tokens.Table) {
|
||||
const headers = header.map((col) => {
|
||||
const text = this.parser.parseInline(col.tokens);
|
||||
return new SWCell(chalk.red.bold(text));
|
||||
});
|
||||
|
||||
return rendered.replace(/\n\n$/, "\n");
|
||||
const body = rows.map((row) =>
|
||||
row.map((col) => {
|
||||
const text = this.parser.parseInline(col.tokens);
|
||||
return new SWCell(text);
|
||||
}),
|
||||
);
|
||||
|
||||
const cols = this.columns - (headers.length * 3 + 1);
|
||||
const maxWidth = Math.floor(cols / headers.length);
|
||||
const table = new Table()
|
||||
.header(headers)
|
||||
.body(body)
|
||||
.border()
|
||||
.maxColWidth(maxWidth)
|
||||
.toString();
|
||||
|
||||
return `\n${table}\n`;
|
||||
}
|
||||
|
||||
override strong({ tokens }: Tokens.Strong) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return chalk.bold.rgb(245, 245, 245)(text);
|
||||
}
|
||||
|
||||
override em({ tokens }: Tokens.Em) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return chalk.italic.rgb(245, 245, 245)(text);
|
||||
}
|
||||
|
||||
override del({ tokens }: Tokens.Del) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return chalk.grey.strikethrough(text);
|
||||
}
|
||||
|
||||
override codespan({ text }: Tokens.Codespan) {
|
||||
return chalk.bgRgb(165, 165, 192).rgb(0, 0, 0).bold(text);
|
||||
}
|
||||
|
||||
override link({ href, tokens }: Tokens.Link) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return chalk.blueBright.underline(terminalLink(text, href));
|
||||
}
|
||||
|
||||
override image({ text: _ }: Tokens.Image) {
|
||||
return chalk.red("\nImages are unsupported (for now)\n");
|
||||
}
|
||||
|
||||
override text(token: Tokens.Text | Tokens.Escape) {
|
||||
if (token.type === "text" && token.tokens) {
|
||||
const parsed = this.parser.parseInline(token.tokens);
|
||||
return wrapAnsi(parsed, this.columns - 12);
|
||||
}
|
||||
return this.replaceLatex(token.text);
|
||||
}
|
||||
|
||||
override html({ text }: Tokens.HTML | Tokens.Tag) {
|
||||
if (text === "<br>") return this.br();
|
||||
return chalk.red(text);
|
||||
}
|
||||
|
||||
override br() {
|
||||
return "\n";
|
||||
}
|
||||
|
||||
private replaceLatex(text: string) {
|
||||
return text
|
||||
.replaceAll("$\\rightarrow$", "-->")
|
||||
.replaceAll("$\\leftarrow$", "<--")
|
||||
.replaceAll("$\\leftrightarrow$", "<->")
|
||||
.replaceAll("$\\Rightarrow$", "==>")
|
||||
.replaceAll("$\\Leftarrow$", "<==")
|
||||
.replaceAll("$\\Leftrightarrow$", "<=>")
|
||||
.replaceAll("$\\to$", "->");
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdown(
|
||||
text: PromiseLike<string> | string,
|
||||
columns: number,
|
||||
): Promise<string> {
|
||||
const md = await text;
|
||||
|
||||
const langs = md
|
||||
.match(/```\S+\n/g)
|
||||
?.map((lang) => lang.replace("```", "").trimEnd())
|
||||
.sort();
|
||||
|
||||
if (langs && langs?.join() !== highligherLangs) {
|
||||
const { bundledThemes, bundledLanguages, bundledLanguagesAlias } =
|
||||
await import("shiki/bundle/full");
|
||||
try {
|
||||
highligherLangs = langs.join();
|
||||
highlighter = await createHighlighterCore({
|
||||
langs: langs.map(
|
||||
(lang) =>
|
||||
bundledLanguages[lang as BundledLanguage] ||
|
||||
bundledLanguagesAlias[lang],
|
||||
),
|
||||
themes: [bundledThemes["kanagawa-wave"]],
|
||||
engine: createOnigurumaEngine(import("shiki/wasm")),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const renderer = new LmcRenderer(columns);
|
||||
const rendered = await marked.parse(md, { renderer });
|
||||
|
||||
return rendered.trimStart();
|
||||
}
|
||||
|
|
|
|||
180
src/tts.ts
Normal file
180
src/tts.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import readline from "node:readline";
|
||||
import consumers from "node:stream/consumers";
|
||||
import util from "node:util";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import chalk from "chalk";
|
||||
import { ExecaError, execa } from "execa";
|
||||
import { Window } from "happy-dom";
|
||||
import wrapAnsi from "wrap-ansi";
|
||||
import { handleTopLevel } from "./errors";
|
||||
|
||||
async function main() {
|
||||
const start = Date.now();
|
||||
|
||||
const { values: args, positionals } = util.parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
wait: { type: "boolean" },
|
||||
w: { type: "boolean" },
|
||||
pause: { type: "boolean" },
|
||||
p: { type: "boolean" },
|
||||
url: { type: "boolean" },
|
||||
u: { type: "boolean" },
|
||||
edit: { type: "boolean" },
|
||||
e: { type: "boolean" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const waitAndCombine = args.wait ?? args.w;
|
||||
const pausePlayer = args.pause ?? args.p;
|
||||
const readURL = args.url ?? args.u;
|
||||
const editContent = args.edit ?? args.e;
|
||||
|
||||
let input = positionals.join(" ").trim();
|
||||
|
||||
if (!input) input = (await consumers.text(process.stdin)).trim();
|
||||
|
||||
if (!input) {
|
||||
throw new Error("No input was given. Use positional arguments or stdin.");
|
||||
}
|
||||
|
||||
if (readURL) {
|
||||
const result = await fetch(input);
|
||||
const window = new Window({ url: input });
|
||||
const doc = window.document;
|
||||
doc.write(await result.text());
|
||||
await window.happyDOM.waitUntilComplete();
|
||||
const readable = new Readability(doc).parse();
|
||||
if (!readable?.textContent) {
|
||||
throw new Error(
|
||||
"Unable to read webpage. This usually means it's blocking scrapers.",
|
||||
);
|
||||
}
|
||||
input = readable.textContent.trim();
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const cwd = path.join(home, ".ttsc");
|
||||
|
||||
if (editContent) {
|
||||
const file = path.join(cwd, "input.txt");
|
||||
await fs.writeFile(file, input, "utf8");
|
||||
const editor = process.env.EDITOR || "nano";
|
||||
await execa({
|
||||
cwd,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})`${editor} ${file}`;
|
||||
input = await fs.readFile(file, "utf8");
|
||||
}
|
||||
|
||||
console.log(chalk.blue("Welcome to TTSC!"));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"Beginning TTS processing... (This will take extra time the first time)",
|
||||
),
|
||||
);
|
||||
|
||||
const wavs = fs.glob(path.join(cwd, "*.wav"));
|
||||
for await (const wav of wavs) await fs.rm(wav);
|
||||
|
||||
const koko = execa({
|
||||
cwd,
|
||||
input,
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
})`uv run -- python -u main.py`;
|
||||
|
||||
if (!waitAndCombine) {
|
||||
koko.catch(() => {});
|
||||
koko.then(() => {
|
||||
const stop = Date.now();
|
||||
const duration = ((stop - start) / 1000).toFixed(2);
|
||||
console.log(chalk.green(`\nProcessing finished: ${duration}s`));
|
||||
});
|
||||
}
|
||||
|
||||
let first = true;
|
||||
const files = [];
|
||||
const rl = readline.createInterface({ input: koko.stdout });
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
let info: { file: number; text: string };
|
||||
try {
|
||||
info = JSON.parse(trimmed);
|
||||
} catch {
|
||||
console.log(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first) {
|
||||
first = false;
|
||||
const stop = Date.now();
|
||||
const duration = ((stop - start) / 1000).toFixed(2);
|
||||
console.log(chalk.green(`\nTime-to-audio: ${duration}s`));
|
||||
}
|
||||
|
||||
const file = path.join(cwd, `${info.file}.wav`);
|
||||
|
||||
if (waitAndCombine) {
|
||||
files.push(file);
|
||||
process.stdout.write(".");
|
||||
} else {
|
||||
const [columns] = process.stdout.getWindowSize();
|
||||
const text = wrapAnsi(info.text, columns);
|
||||
console.log(`\n${chalk.blue(file)}\n${text}\n`);
|
||||
await execa({
|
||||
cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})`mpv${pausePlayer ? " --pause" : ""} ${file}`;
|
||||
}
|
||||
}
|
||||
|
||||
await koko;
|
||||
if (!waitAndCombine) return;
|
||||
|
||||
const stop = Date.now();
|
||||
const duration = ((stop - start) / 1000).toFixed(2);
|
||||
console.log(chalk.green(`\nProcessing finished: ${duration}s`));
|
||||
|
||||
console.log(chalk.yellow(`Combining audio with sox...\n`));
|
||||
try {
|
||||
await execa({ cwd, shell: true })`sox ${files} output.wav`;
|
||||
} catch (err) {
|
||||
if (err instanceof ExecaError && err.code === "ENOENT") {
|
||||
console.error("Sox not found. Unable to combine outputs.");
|
||||
console.log(chalk.yellow(`Audio available in ${cwd}`));
|
||||
return;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const [columns] = process.stdout.getWindowSize();
|
||||
console.log(wrapAnsi(input, columns));
|
||||
|
||||
console.log(
|
||||
chalk.green(`Playing audio from ${path.join(cwd, "output.wav")}`),
|
||||
);
|
||||
await execa({
|
||||
cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})`mpv${pausePlayer ? " --pause" : ""} output.wav`;
|
||||
}
|
||||
|
||||
main().catch(handleTopLevel);
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { renderMarkdown } from "./src/render/markdown.ts";
|
||||
|
||||
const text = await Bun.file("test.md").text();
|
||||
|
||||
const [columns] = process.stdout.getWindowSize();
|
||||
process.stdout.write(await renderMarkdown(text, columns));
|
||||
|
|
@ -1,29 +1,39 @@
|
|||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
// For nodejs:
|
||||
"lib": ["esnext"],
|
||||
"types": ["node"],
|
||||
// and npm install -D @types/node
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
// Style Options
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowArbitraryExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
tts.ts
98
tts.ts
|
|
@ -1,98 +0,0 @@
|
|||
import os from "node:os";
|
||||
import { parseArgs } from "node:util";
|
||||
import { $, which } from "bun";
|
||||
import chalk from "chalk";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
wait: { type: "boolean" },
|
||||
w: { type: "boolean" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const wait = args.wait ?? args.w;
|
||||
const text = await Bun.stdin.text();
|
||||
|
||||
if (!text) {
|
||||
console.error("No input given.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const cwd = `${home}/.ttsc/`;
|
||||
$.cwd(cwd);
|
||||
|
||||
await $`rm *.wav`.nothrow();
|
||||
|
||||
console.log(chalk.blue("Welcome to TTSC!"));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"Beginning TTS processing... (This will take extra time the first time)",
|
||||
),
|
||||
);
|
||||
|
||||
const koko = Bun.spawn({
|
||||
cmd: ["uv", "run", "--", "python", "-u", "main.py"],
|
||||
cwd,
|
||||
stdin: Buffer.from(text),
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
if (!wait) {
|
||||
koko.exited.then(() => console.log(chalk.green("\nProcessing finished.")));
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
for await (const line of koko.stdout) {
|
||||
const decoded = decoder.decode(line).trim();
|
||||
|
||||
let infos: { file: number; text: string }[];
|
||||
try {
|
||||
infos = Bun.JSONL.parse(decoded) as { file: number; text: string }[];
|
||||
} catch {
|
||||
console.log(decoded);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const info of infos) {
|
||||
const file = `${home}/.ttsc/${info.file}.wav`;
|
||||
console.log(`\n${chalk.blue(file)}:\n${info.text}`);
|
||||
|
||||
if (!wait) {
|
||||
await Bun.spawn({
|
||||
cmd: ["mpv", file],
|
||||
cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).exited;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await koko.exited;
|
||||
if (!wait) process.exit();
|
||||
|
||||
console.log(chalk.green("\nProcessing finished."));
|
||||
|
||||
console.log(chalk.yellow(`Combining audio with sox...`));
|
||||
if (which("sox")) {
|
||||
await $`sox *.wav output.wav`;
|
||||
console.log(chalk.green(`Playing audio from ${cwd}output.wav`));
|
||||
} else {
|
||||
console.error("Sox not found. Unable to combine outputs.");
|
||||
console.log(chalk.yellow(`Audio available in ${cwd}`));
|
||||
process.exit();
|
||||
}
|
||||
|
||||
await Bun.spawn({
|
||||
cmd: ["mpv", "--pause", "output.wav"],
|
||||
cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).exited;
|
||||
Loading…
Add table
Add a link
Reference in a new issue