Compare commits

...
Sign in to create a new pull request.

82 commits

Author SHA1 Message Date
159547e799 fix(lmc): handle interrupt signal gracefully 2026-07-13 19:30:26 -04:00
a8cbfb3665 refactor(lmc): remove intermediate vars and directly access CLI args 2026-06-27 03:31:03 -04:00
4aaa80898d feat(lmc): support multiple files via -f flag and fix empty context 2026-06-26 17:46:21 -04:00
5b6c65d30f refactor(lmc): use short property for options and simplify flags 2026-06-26 17:09:22 -04:00
b1730bfaf4 refactor(lmc): modularize prompt generation and add XML-like fences 2026-06-26 17:09:20 -04:00
da317b7177 fix(markdown): trim whitespace from parsed list item content 2026-06-24 11:54:09 -04:00
49c4138a1c feat(tts): add --pause flag to control mpv playback 2026-06-20 14:45:30 -04:00
e1033398e9 fix(markdown): correct list item whitespace and indentation 2026-06-19 15:14:00 -04:00
5b138df664 refactor(errors): extract isErrorLike and use ExecaError class in tts 2026-06-19 14:33:56 -04:00
bf5e223abf refactor: rename flag variables for improved clarity and intent 2026-06-19 14:28:16 -04:00
d157978d52 docs: update AGENTS.md to reflect Node.js/pnpm migration 2026-06-18 18:24:02 -04:00
e2253b912d chore(deps): update dependencies and configure pnpm minimum release age 2026-06-18 17:47:09 -04:00
4725f148f3 chore: migrate @cliffy/table to native jsr protocol & fix prompt typos 2026-06-18 16:13:39 -04:00
4bccbcc9ee feat(cli): add --file flag for file review and refactor prompt generation
- Inject current date into review prompts to improve LLM context accuracy
- Remove unused `module` field from package.json
2026-06-17 22:48:54 -04:00
7652e62d47 refactor(entries): simplify stdin reading with node:stream/consumers 2026-06-17 12:22:35 -04:00
19272bc0b4 refactor: flatten source directory structure 2026-06-17 01:40:53 -04:00
eec90209d2 refactor(prompts): extract prompt templates to dedicated module 2026-06-17 01:25:27 -04:00
3c3dab1525 chore: move entry files to src/ directory and update paths 2026-06-17 01:12:40 -04:00
f8e0f9a368 refactor(errors): extract top-level error handling into shared utility 2026-06-17 00:48:19 -04:00
56352d87ec fix(entry): improve error message formatting
- Relocate `util.parseArgs()` inside the main function for cleaner
  scoping
- Update `.catch` handlers in `index.ts` and `tts.ts` to wrap text to
  terminal width and colorize errors in red
2026-06-16 19:02:23 -04:00
f3146ab087 feat(lmc): add --git-message and --review modes, standardize args casing 2026-06-16 16:57:51 -04:00
fc7d9101e9 refactor(tts): remove InputError class and use native Error 2026-06-16 16:45:17 -04:00
710d2e7593 build: switch binary bundler to esbuild
- Add explicit `--format=esm` flag to `build`
- Update `rolldown` to ^1.1.1 and add `esbuild` ^0.28.1 as a dependency
- Create `pnpm-workspace.yaml` with `allowBuilds: esbuild` to support native prebuilt binaries across platforms
2026-06-15 02:42:20 -04:00
dd6df6dd6d refactor(tts): CLI argument parsing and input handling 2026-06-15 02:21:40 -04:00
749559e19c fix(tts): sox combination order; refactor input logic flow
- Replace manual `process.stdin` listeners with `events.once()`
- Switch from `process.exit()` to `return` for cleaner async execution paths
- Update Node.js module imports to use default exports consistently
2026-06-12 19:43:24 -04:00
0e07268bfb chore(docs): update README.md 2026-06-12 03:32:28 -04:00
4674cca337 refactor(tts): replace execa shell command with native fs.rm iteration 2026-06-12 02:39:15 -04:00
32f9dd5495 refactor(tts): replace string interpolation with path.join() 2026-06-09 23:30:42 -04:00
7bf8951b62 fix(tts): --edit file handling and file globbing 2026-06-09 23:30:42 -04:00
574ebdc8a1 fix(markdown): handle <br> tokens and adjust codespan text color 2026-06-09 23:30:35 -04:00
21dac683a2 refactor: migrate from bun to node types and fix discrepancies 2026-06-08 20:17:22 -04:00
a03e6e94a9 feat(tts): add --edit flag and fix CLI argument parsing
- Improved input trimming and console output formatting in `tts.ts`
- Updated codespan colors in the markdown renderer for better visual contrast
- Disabled `noPropertyAccessFromIndexSignature` in tsconfig.json
2026-06-08 16:10:45 -04:00
39cb573619 refactor(tts): make cjs compatible and add sea config 2026-06-08 15:17:07 -04:00
3235a08633 refactor(tts): replace Bun-specific APIs with execa and Node.js stdlib
- Replace `$` and `Bun.spawn` with `execa` for process execution
- Swap `Bun.JSONL.parse` for native `JSON.parse` and use `node:readline` for stdin handling
2026-06-07 22:13:19 -04:00
4912864a75 chore: update build scripts and refine markdown rendering 2026-06-07 22:01:29 -04:00
bbee38be80 refactor(build): switch from esbuild to rolldown 2026-06-05 23:02:25 -04:00
6392cf3b76 chore: update package.json to reflect reality 2026-06-04 02:24:53 -04:00
43c3402931 chore(deps): consolidate all dependencies into devDependencies and clear production fields
- Move runtime packages from `dependencies` to `devDependencies`
- Remove `peerDependencies` as it is no longer required
2026-06-04 02:24:53 -04:00
f668037582 chore(config): pin @types/bun, tighten TypeScript strictness, and refactor config logic
- Pin `@types/bun` to `^1.3.14` for reproducible builds instead of using `latest`
- Update `tsconfig.json` with stricter type-checking flags and reorganize compiler options
- Refactor `initConfig` to DRY up the model default logic and add a null guard before process exit
2026-06-04 02:24:53 -04:00
d98ddb3ca7 build: migrate to commonjs and add node single executable app support
- Switch module type to commonjs in package.json
- Update esbuild target to node22 and remove explicit ESM format flag
- Add `bin` script for building a native binary using `node --build-sea`
- Introduce sea-config.json with code cache enabled for faster startup
2026-06-04 02:24:53 -04:00
cd3749b07e build: replace bun install/build with pnpm/esbuild 2026-06-04 02:24:32 -04:00
3e96007f1f refactor: migrate shiki to core API with dynamic language/theme loading
- Update code block regex to support symbols/numbers in language identifiers
- Adjust codespan background color to a softer shade
2026-06-03 16:50:44 -04:00
6372dfc41f refactor: extract latex symbol replacement into private method 2026-05-31 00:20:05 -04:00
fc4934af4a refactor: move wrapAnsi from blockquote/listitem to text tokens 2026-05-31 00:20:05 -04:00
c277418349 fix: list/blockquote newlines and table width 2026-05-26 16:56:52 -04:00
a887e1b185 fix: improve markdown rendering whitespace and list numbering 2026-05-25 23:21:24 -04:00
0c71e2cd54 refactor: replace Bun APIs with Node.js equivalents
Replace Bun.argv with process.argv, Bun.file() with fs/promises,
Bun.stringWidth with string-width package, and Bun.wrapAnsi with
wrap-ansi package for better compatibility.
2026-05-25 23:13:16 -04:00
b212fee200 refactor: replace Bun.markdown with marked library and custom renderer 2026-05-25 19:35:47 -04:00
4f9e7a7a1a style: enhance markdown inline formatting colors 2026-05-24 02:01:11 -04:00
03dee78e0c fix: change URL flag from boolean to string type for proper URL passing 2026-05-14 04:34:01 -04:00
7441a9fc19 docs: update README with new flags, examples, and dependencies 2026-05-14 03:08:01 -04:00
0ed6a23334 refactor: simplify renderer drain handling and expand LaTeX arrow replacements 2026-05-14 02:52:49 -04:00
7367cfdfe3 fix: blockquote and list whitespace handling in markdown rendering 2026-05-12 16:34:32 -04:00
af52e89928 fix: improve markdown rendering with proper wide character support 2026-05-12 14:16:07 -04:00
91175c70b6 feat: add URL support to TTS for fetching web page text 2026-05-12 00:32:48 -04:00
8cb1191fde refactor: simplify model selection in config initialization 2026-05-11 19:42:32 -04:00
7578e0ed2a fix: improve markdown rendering and codespan styling 2026-05-11 19:42:10 -04:00
efb44c4414 refactor: state rendering 2026-05-08 08:54:57 -04:00
e91a8a20a1 fix: config improvements 2026-05-07 21:04:36 -04:00
a6422c51aa feat: show reasoning duration after thinking phase 2026-05-05 04:08:00 -04:00
7f0498fc84 fix: improve config loading flow and table column width 2026-05-04 22:02:19 -04:00
decfd2bde0 fix: improve markdown list rendering and show messages for unsupported 2026-05-04 18:28:21 -04:00
c051f340a8 fix: indent wrapped list items and remove codespan padding 2026-05-04 16:45:38 -04:00
22fc4c874f fix: correct markdown rendering column widths and list formatting 2026-05-04 16:44:44 -04:00
ea5b3728a5 feat: wrap TTS output text to terminal width 2026-05-04 00:05:51 -04:00
b9610663a6 refactor: adjust markdown line wrapping 2026-05-03 23:11:12 -04:00
5e733a4e14 refactor: spinner handling and markdown line wrapping 2026-05-03 22:15:37 -04:00
9da57fe2fc refactor: replace cli-table3 with @cliffy/table 2026-05-03 22:15:08 -04:00
8ff27c5de8 fix: only clear spinner when it was actually spinning 2026-05-03 21:21:56 -04:00
3c9a5e625a feat: add interactive config setup and baseURL support 2026-05-03 21:10:39 -04:00
d9bffdec2b feat: spinner while waiting for response 2026-05-03 02:29:27 -04:00
79fd0a93eb fix: model saving 2026-05-03 01:47:10 -04:00
57acb09a18 feat(tts): add processing duration timing 2026-05-02 22:22:09 -04:00
f5c8bdfe99 refactor: conversation into a class 2026-05-02 02:09:43 -04:00
94895aacd5 style: improve codespan styling and checkbox markers 2026-05-01 21:23:21 -04:00
7b730631b2 docs: README with usage, architecture, and dependency info 2026-04-30 16:20:38 -04:00
a9d3db5b94 feat(tts): pause mpv when using --wait flag 2026-04-30 15:49:18 -04:00
aecb3a6c10 fix: pin exact dependency versions in tts/pyproject.toml 2026-04-29 01:00:57 -04:00
adb0fe234a refactor: rename project from aicl to lmc 2026-04-28 17:17:21 -04:00
b3fb6fd66f feat: enhance TTS CLI with sox combining and wait flag 2026-04-28 16:56:21 -04:00
6b75836647 feat: add ttsc CLI tool for TTS streaming 2026-04-28 02:49:46 -04:00
4112295144 feature: implement base tts with uv and kokoro 2026-04-28 02:38:46 -04:00
25 changed files with 4560 additions and 518 deletions

View file

@ -1,26 +1,36 @@
# aicl
# 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

127
README.md
View file

@ -1,15 +1,130 @@
# aicl
# 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
View file

@ -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=="],
}
}

View file

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

View file

@ -1,35 +1,41 @@
{
"name": "aicl",
"module": "index.ts",
"name": "lmc",
"type": "module",
"private": true,
"bin": {
"aicl": "out/index.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 build:bundle && bun run shebang",
"build:bundle": "bun build --target=bun --production --sourcemap --outdir=out index.ts",
"build:bin": "bun build --compile --outfile=out/aicl index.ts",
"shebang": "sed -i '1i#!\\/usr\\/bin\\/env bun' out/index.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

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
minimumReleaseAge: 4320
allowBuilds:
esbuild: true

6
sea-config-tts.json Normal file
View file

@ -0,0 +1,6 @@
{
"main": "dist/tts.js",
"output": "dist/ttsc",
"useCodeCache": true,
"disableExperimentalSEAWarning": true
}

6
sea-config.json Normal file
View file

@ -0,0 +1,6 @@
{
"main": "dist/index.js",
"output": "dist/lmc",
"useCodeCache": true,
"disableExperimentalSEAWarning": true
}

79
src/config.ts Normal file
View 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;
}

View file

@ -1,24 +0,0 @@
import os from "node:os";
import chalk from "chalk";
const home = os.homedir();
const configFile = Bun.file(`${home}/.aicl_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
View 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),
);
}
}

View file

@ -1,24 +0,0 @@
import os from "node:os";
import type { ModelMessage } from "ai";
const home = os.homedir();
export const convoFile = Bun.file(`${home}/.aicl_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
View 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
View 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
View 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.`;

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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;
const langs = md
.match(/```[a-z]+\n/g)
?.map((lang) => lang.replace("```", "").trimEnd());
if (langs && langs?.join() !== highligherLangs) {
try {
highligherLangs = langs.join();
highlighter = await createHighlighter({
langs,
themes: ["kanagawa-wave", "kanagawa-lotus"],
});
} catch {}
class SWCell extends Cell {
public override get length() {
return stringWidth(this.toString());
}
}
let currentTable = new CliTable3();
class LmcRenderer extends Renderer {
private columns: number;
const rendered = Bun.markdown.render(md, {
heading: (children, { level }) => {
constructor(columns: number) {
super();
this.columns = columns;
}
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
View 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);

View file

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

View file

@ -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
}
}

13
tts/main.py Executable file
View file

@ -0,0 +1,13 @@
from kokoro import KPipeline
import soundfile as sf
import sys
import json
text = sys.stdin.read()
pipeline = KPipeline(lang_code="a", repo_id="hexgrad/Kokoro-82M")
generator = pipeline(text, voice="af_heart", speed=1.2)
for i, (gs, ps, audio) in enumerate(generator):
sf.write(f"{i}.wav", audio, 24000)
print(json.dumps({"file": i, "text": gs}))

8
tts/pyproject.toml Normal file
View file

@ -0,0 +1,8 @@
[project]
name = "tts"
version = "0.1.0"
requires-python = "==3.12.*"
dependencies = [
"kokoro==0.9.4",
"soundfile==0.13.1",
]

2127
tts/uv.lock generated Normal file

File diff suppressed because it is too large Load diff