diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..41583e3
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+@jsr:registry=https://npm.jsr.io
diff --git a/AGENTS.md b/AGENTS.md
index 4f1015f..b0bb2e1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,36 +1,26 @@
# lmc
-TypeScript CLI tool for streaming LLM chat with inline markdown rendering.
+Bun CLI tool for streaming LLM chat with inline markdown rendering.
## Commands
```
-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/
+bun run dev # dev run
+bun run prod # run bundled output
+bun run check # biome lint + format + organize imports
```
## Architecture
-- `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
+- `index.ts` — CLI entry point, orchestration
- `src/render/index.ts` — `Renderer` class: streams LLM response, buffers markdown, renders to stdout/stderr
-- `src/render/markdown.ts` — `renderMarkdown()`: markdown via `marked` + Shiki + `@cliffy/table`
-- `src/render/codeToANSI.ts` — Shiki token output to ANSI escape sequences
+- `src/render/markdown.ts` — `renderMarkdown()`: syntax-highlighted markdown via `@comark/ansi` + Shiki
## Gotchas
-- **Node.js** — Use modern Node APIs (`node:fs/promises`, `node:stream/consumers`, etc.).
+- **Bun only** — not Node. Use `Bun.file()`, `Bun.argv`, `Bun.sleep()`.
- **No tests** — none exist.
-- **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/`.
+- **Biome config**: double quotes, space indent, auto-import-organize on. Run `bun run check` before committing.
## Style
diff --git a/index.ts b/index.ts
new file mode 100644
index 0000000..45cdd57
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+import util from "node:util";
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+import { streamText } from "ai";
+
+import { initConfig, loadConfig } from "./src/config";
+import { Conversation } from "./src/conversation";
+import Renderer from "./src/render";
+
+const { values: args, positionals } = util.parseArgs({
+ args: process.argv.slice(2),
+ options: {
+ model: { type: "string" },
+ m: { type: "string" },
+ new: { type: "boolean" },
+ n: { type: "boolean" },
+ config: { type: "boolean" },
+ c: { type: "boolean" },
+ plain: { type: "boolean" },
+ plainOut: { type: "boolean" },
+ plainErr: { type: "boolean" },
+ v: { 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 configFlag = args.config ?? args.c;
+
+ if (args.v) {
+ console.log("0.0.0");
+ return;
+ }
+
+ const config = await loadConfig(configFlag);
+
+ if (configFlag || !config) {
+ await initConfig(config);
+ return;
+ }
+
+ const model = modelFlag ?? config.model;
+
+ 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 (!newConvo) {
+ await convo.load();
+ if (modelFlag) convo.model = modelFlag;
+ }
+
+ const prompt = positionals.join(" ") || "Introduce yourself.";
+ 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((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/package.json b/package.json
index b27a415..23a5b72 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,6 @@
{
"name": "lmc",
+ "module": "index.ts",
"type": "module",
"private": true,
"bin": {
@@ -7,35 +8,35 @@
"ttsc": "dist/tts.js"
},
"scripts": {
- "dev": "pnpm run bundle src/index.ts >/dev/null && node dist/index.js",
+ "dev": "pnpm run bundle 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",
+ "build": "pnpm run check && pnpm run bundle --minify index.ts && pnpm run bundle --minify tts.ts",
+ "build:bin": "pnpm run check && pnpm run bundle:bin --minify index.ts && pnpm run bundle:bin --minify 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",
+ "@ai-sdk/openai-compatible": "^2.0.41",
"@biomejs/biome": "2.4.12",
- "@clack/prompts": "^1.5.1",
- "@cliffy/table": "jsr:^1.2.1",
+ "@clack/prompts": "^1.3.0",
+ "@cliffy/table": "npm:@jsr/cliffy__table",
"@mozilla/readability": "^0.6.0",
- "@types/node": "^25.9.3",
- "ai": "^6.0.206",
+ "@types/node": "^25.9.2",
+ "ai": "^6.0.161",
"ansi-styles": "^6.2.3",
"chalk": "^5.6.2",
"esbuild": "^0.28.1",
"execa": "^9.6.1",
- "happy-dom": "^20.10.3",
- "marked": "^18.0.5",
+ "happy-dom": "^20.10.2",
+ "marked": "^18.0.4",
"rolldown": "^1.1.1",
- "shiki": "^4.2.0",
+ "shiki": "^4.0.2",
"string-width": "^8.2.1",
"terminal-link": "^5.0.0",
- "typescript": "^5.9.3",
+ "typescript": "^5",
"wrap-ansi": "^10.0.0"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d46eb49..22f3aaa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,26 +9,26 @@ importers:
.:
devDependencies:
'@ai-sdk/openai-compatible':
- specifier: ^2.0.50
- version: 2.0.50(zod@4.4.3)
+ specifier: ^2.0.41
+ version: 2.0.48(zod@4.4.3)
'@biomejs/biome':
specifier: 2.4.12
version: 2.4.12
'@clack/prompts':
- specifier: ^1.5.1
- version: 1.5.1
+ specifier: ^1.3.0
+ version: 1.4.0
'@cliffy/table':
- specifier: jsr:^1.2.1
+ specifier: npm:@jsr/cliffy__table
version: '@jsr/cliffy__table@1.2.1'
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
'@types/node':
- specifier: ^25.9.3
- version: 25.9.3
+ specifier: ^25.9.2
+ version: 25.9.2
ai:
- specifier: ^6.0.206
- version: 6.0.206(zod@4.4.3)
+ specifier: ^6.0.161
+ version: 6.0.191(zod@4.4.3)
ansi-styles:
specifier: ^6.2.3
version: 6.2.3
@@ -42,17 +42,17 @@ importers:
specifier: ^9.6.1
version: 9.6.1
happy-dom:
- specifier: ^20.10.3
- version: 20.10.3
+ specifier: ^20.10.2
+ version: 20.10.2
marked:
- specifier: ^18.0.5
- version: 18.0.5
+ specifier: ^18.0.4
+ version: 18.0.4
rolldown:
specifier: ^1.1.1
version: 1.1.1
shiki:
- specifier: ^4.2.0
- version: 4.2.0
+ specifier: ^4.0.2
+ version: 4.1.0
string-width:
specifier: ^8.2.1
version: 8.2.1
@@ -60,7 +60,7 @@ importers:
specifier: ^5.0.0
version: 5.0.0
typescript:
- specifier: ^5.9.3
+ specifier: ^5
version: 5.9.3
wrap-ansi:
specifier: ^10.0.0
@@ -68,20 +68,20 @@ importers:
packages:
- '@ai-sdk/gateway@3.0.132':
- resolution: {integrity: sha512-sFZFGk6aVSNKmgDrb14efaAbvM71AB3UUvaTsU+bvhWP9jrkRrwix/jFX8IoOAJk0/X7Xf7p3m0J2O2G6ljZbA==}
+ '@ai-sdk/gateway@3.0.120':
+ resolution: {integrity: sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/openai-compatible@2.0.50':
- resolution: {integrity: sha512-HyuxddF2Yv5G8qxK/0uksAINjQ4h6TpwOqHuqzsCM0u78/JWAW2OXcIplQeB44PIAORgPjbMzrw9DhnPYHMskA==}
+ '@ai-sdk/openai-compatible@2.0.48':
+ resolution: {integrity: sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/provider-utils@4.0.29':
- resolution: {integrity: sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==}
+ '@ai-sdk/provider-utils@4.0.27':
+ resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -147,12 +147,12 @@ packages:
cpu: [x64]
os: [win32]
- '@clack/core@1.4.1':
- resolution: {integrity: sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==}
+ '@clack/core@1.3.1':
+ resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==}
engines: {node: '>= 20.12.0'}
- '@clack/prompts@1.5.1':
- resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==}
+ '@clack/prompts@1.4.0':
+ resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==}
engines: {node: '>= 20.12.0'}
'@emnapi/core@1.11.0':
@@ -444,32 +444,32 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
- '@shikijs/core@4.2.0':
- resolution: {integrity: sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==}
+ '@shikijs/core@4.1.0':
+ resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
engines: {node: '>=20'}
- '@shikijs/engine-javascript@4.2.0':
- resolution: {integrity: sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==}
+ '@shikijs/engine-javascript@4.1.0':
+ resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
engines: {node: '>=20'}
- '@shikijs/engine-oniguruma@4.2.0':
- resolution: {integrity: sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==}
+ '@shikijs/engine-oniguruma@4.1.0':
+ resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
engines: {node: '>=20'}
- '@shikijs/langs@4.2.0':
- resolution: {integrity: sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==}
+ '@shikijs/langs@4.1.0':
+ resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
engines: {node: '>=20'}
- '@shikijs/primitive@4.2.0':
- resolution: {integrity: sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==}
+ '@shikijs/primitive@4.1.0':
+ resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
engines: {node: '>=20'}
- '@shikijs/themes@4.2.0':
- resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==}
+ '@shikijs/themes@4.1.0':
+ resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
engines: {node: '>=20'}
- '@shikijs/types@4.2.0':
- resolution: {integrity: sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==}
+ '@shikijs/types@4.1.0':
+ resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
@@ -491,8 +491,11 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
- '@types/node@25.9.3':
- resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
+ '@types/node@25.9.1':
+ resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
+
+ '@types/node@25.9.2':
+ resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -510,8 +513,8 @@ packages:
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
- ai@6.0.206:
- resolution: {integrity: sha512-hVdB5PozMMxmkPBfbCxkFOn0eVeCMi/imkfPvk65cxL4O8oIrvjUxDUX8dGkdkWG1No+R7e7D9ti2DHO61Z4YQ==}
+ ai@6.0.191:
+ resolution: {integrity: sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -572,8 +575,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
- eventsource-parser@3.1.0:
- resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
+ eventsource-parser@3.0.8:
+ resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
execa@9.6.1:
@@ -601,8 +604,8 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
- happy-dom@20.10.3:
- resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==}
+ happy-dom@20.10.2:
+ resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==}
engines: {node: '>=20.0.0'}
has-flag@5.0.1:
@@ -640,8 +643,8 @@ packages:
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
- marked@18.0.5:
- resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
+ marked@18.0.4:
+ resolution: {integrity: sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==}
engines: {node: '>= 20'}
hasBin: true
@@ -689,8 +692,8 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
- property-information@7.2.0:
- resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -714,8 +717,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- shiki@4.2.0:
- resolution: {integrity: sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==}
+ shiki@4.1.0:
+ resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
engines: {node: '>=20'}
signal-exit@4.1.0:
@@ -831,24 +834,24 @@ packages:
snapshots:
- '@ai-sdk/gateway@3.0.132(zod@4.4.3)':
+ '@ai-sdk/gateway@3.0.120(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
- '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
+ '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@vercel/oidc': 3.2.0
zod: 4.4.3
- '@ai-sdk/openai-compatible@2.0.50(zod@4.4.3)':
+ '@ai-sdk/openai-compatible@2.0.48(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
- '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
+ '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
- '@ai-sdk/provider-utils@4.0.29(zod@4.4.3)':
+ '@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0
- eventsource-parser: 3.1.0
+ eventsource-parser: 3.0.8
zod: 4.4.3
'@ai-sdk/provider@3.0.10':
@@ -890,14 +893,14 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.12':
optional: true
- '@clack/core@1.4.1':
+ '@clack/core@1.3.1':
dependencies:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
- '@clack/prompts@1.5.1':
+ '@clack/prompts@1.4.0':
dependencies:
- '@clack/core': 1.4.1
+ '@clack/core': 1.3.1
fast-string-width: 3.0.2
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
@@ -1068,40 +1071,40 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
- '@shikijs/core@4.2.0':
+ '@shikijs/core@4.1.0':
dependencies:
- '@shikijs/primitive': 4.2.0
- '@shikijs/types': 4.2.0
+ '@shikijs/primitive': 4.1.0
+ '@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
- '@shikijs/engine-javascript@4.2.0':
+ '@shikijs/engine-javascript@4.1.0':
dependencies:
- '@shikijs/types': 4.2.0
+ '@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
- '@shikijs/engine-oniguruma@4.2.0':
+ '@shikijs/engine-oniguruma@4.1.0':
dependencies:
- '@shikijs/types': 4.2.0
+ '@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
- '@shikijs/langs@4.2.0':
+ '@shikijs/langs@4.1.0':
dependencies:
- '@shikijs/types': 4.2.0
+ '@shikijs/types': 4.1.0
- '@shikijs/primitive@4.2.0':
+ '@shikijs/primitive@4.1.0':
dependencies:
- '@shikijs/types': 4.2.0
+ '@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
- '@shikijs/themes@4.2.0':
+ '@shikijs/themes@4.1.0':
dependencies:
- '@shikijs/types': 4.2.0
+ '@shikijs/types': 4.1.0
- '@shikijs/types@4.2.0':
+ '@shikijs/types@4.1.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -1125,7 +1128,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
- '@types/node@25.9.3':
+ '@types/node@25.9.1':
+ dependencies:
+ undici-types: 7.24.6
+
+ '@types/node@25.9.2':
dependencies:
undici-types: 7.24.6
@@ -1135,17 +1142,17 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
- '@types/node': 25.9.3
+ '@types/node': 25.9.1
'@ungap/structured-clone@1.3.1': {}
'@vercel/oidc@3.2.0': {}
- ai@6.0.206(zod@4.4.3):
+ ai@6.0.191(zod@4.4.3):
dependencies:
- '@ai-sdk/gateway': 3.0.132(zod@4.4.3)
+ '@ai-sdk/gateway': 3.0.120(zod@4.4.3)
'@ai-sdk/provider': 3.0.10
- '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
+ '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@opentelemetry/api': 1.9.1
zod: 4.4.3
@@ -1159,7 +1166,7 @@ snapshots:
buffer-image-size@0.6.4:
dependencies:
- '@types/node': 25.9.3
+ '@types/node': 25.9.1
ccount@2.0.1: {}
@@ -1216,7 +1223,7 @@ snapshots:
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
- eventsource-parser@3.1.0: {}
+ eventsource-parser@3.0.8: {}
execa@9.6.1:
dependencies:
@@ -1254,9 +1261,9 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
- happy-dom@20.10.3:
+ happy-dom@20.10.2:
dependencies:
- '@types/node': 25.9.3
+ '@types/node': 25.9.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
@@ -1278,7 +1285,7 @@ snapshots:
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
- property-information: 7.2.0
+ property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
@@ -1301,7 +1308,7 @@ snapshots:
json-schema@0.4.0: {}
- marked@18.0.5: {}
+ marked@18.0.4: {}
mdast-util-to-hast@13.2.1:
dependencies:
@@ -1355,7 +1362,7 @@ snapshots:
dependencies:
parse-ms: 4.0.0
- property-information@7.2.0: {}
+ property-information@7.1.0: {}
regex-recursion@6.0.2:
dependencies:
@@ -1394,14 +1401,14 @@ snapshots:
shebang-regex@3.0.0: {}
- shiki@4.2.0:
+ shiki@4.1.0:
dependencies:
- '@shikijs/core': 4.2.0
- '@shikijs/engine-javascript': 4.2.0
- '@shikijs/engine-oniguruma': 4.2.0
- '@shikijs/langs': 4.2.0
- '@shikijs/themes': 4.2.0
- '@shikijs/types': 4.2.0
+ '@shikijs/core': 4.1.0
+ '@shikijs/engine-javascript': 4.1.0
+ '@shikijs/engine-oniguruma': 4.1.0
+ '@shikijs/langs': 4.1.0
+ '@shikijs/themes': 4.1.0
+ '@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 8ad854b..5ed0b5a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,2 @@
-minimumReleaseAge: 4320
allowBuilds:
esbuild: true
diff --git a/src/config.ts b/src/config/index.ts
similarity index 100%
rename from src/config.ts
rename to src/config/index.ts
diff --git a/src/conversation.ts b/src/conversation/index.ts
similarity index 100%
rename from src/conversation.ts
rename to src/conversation/index.ts
diff --git a/src/errors.ts b/src/errors.ts
deleted file mode 100644
index 2eac281..0000000
--- a/src/errors.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-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);
-}
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index 56c93ed..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/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);
diff --git a/src/prompts.ts b/src/prompts.ts
deleted file mode 100644
index 31fae99..0000000
--- a/src/prompts.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-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) => `
-${input}
-
-`;
-
-export const diffFence = (input: string) => `
-${input}
-`;
-
-export const contextFence = (input: string) => `
-${input}
-`;
-
-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.`;
diff --git a/src/render/index.ts b/src/render/index.ts
index 2819467..01fce63 100644
--- a/src/render/index.ts
+++ b/src/render/index.ts
@@ -51,7 +51,7 @@ export default class Renderer {
this.charsSinceNL = this.charsSinceNL - columns;
}
- if (this.lineCount >= Math.max(rows - 4, 1)) {
+ if (this.lineCount >= Math.max(rows - 10, 1)) {
this.moveClearReset(-this.lineCount, pipe);
}
}
@@ -84,13 +84,7 @@ export default class Renderer {
async renderStream(stream: AsyncIterableStream>) {
this.resetCounts();
- const spin = spinner({
- withGuide: false,
- indicator: "timer",
- onCancel: () => {
- process.exit(130);
- },
- });
+ const spin = spinner({ withGuide: false, indicator: "timer" });
if (!this.isPlain) {
spin.start("Waiting for response");
}
diff --git a/src/render/markdown.ts b/src/render/markdown.ts
index 7190de8..4450ce0 100644
--- a/src/render/markdown.ts
+++ b/src/render/markdown.ts
@@ -76,7 +76,6 @@ class LmcRenderer extends Renderer {
this.listitem(item, ordered ? num + index : undefined),
)
.join("\n");
-
return `\n${renderedItems}\n`;
}
@@ -92,8 +91,7 @@ class LmcRenderer extends Renderer {
const item = `${marker} ${text}`;
return item
.split("\n")
- .filter((line) => line.length > 0)
- .map((line) => ` ${line}`)
+ .map((line) => (line.length > 0 ? ` ${line}` : ""))
.join("\n");
}
diff --git a/src/tts.ts b/tts.ts
similarity index 82%
rename from src/tts.ts
rename to tts.ts
index e6aab05..559ecf6 100644
--- a/src/tts.ts
+++ b/tts.ts
@@ -1,17 +1,20 @@
#!/usr/bin/env node
+import events from "node:events";
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 { execa } from "execa";
import { Window } from "happy-dom";
import wrapAnsi from "wrap-ansi";
-import { handleTopLevel } from "./errors";
+
+class InputError extends Error {
+ override name = "InputError";
+}
async function main() {
const start = Date.now();
@@ -21,8 +24,6 @@ async function main() {
options: {
wait: { type: "boolean" },
w: { type: "boolean" },
- pause: { type: "boolean" },
- p: { type: "boolean" },
url: { type: "boolean" },
u: { type: "boolean" },
edit: { type: "boolean" },
@@ -32,20 +33,27 @@ async function main() {
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;
+ const wait = args.wait ?? args.w;
+ const url = args.url ?? args.u;
+ const edit = 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.");
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (data) => {
+ input += data;
+ });
+ await events.once(process.stdin, "end");
}
- if (readURL) {
+ if (!input) {
+ throw new InputError(
+ "No input was given. Use positional arguments or stdin.",
+ );
+ }
+
+ if (url) {
const result = await fetch(input);
const window = new Window({ url: input });
const doc = window.document;
@@ -53,7 +61,7 @@ async function main() {
await window.happyDOM.waitUntilComplete();
const readable = new Readability(doc).parse();
if (!readable?.textContent) {
- throw new Error(
+ throw new InputError(
"Unable to read webpage. This usually means it's blocking scrapers.",
);
}
@@ -63,7 +71,7 @@ async function main() {
const home = os.homedir();
const cwd = path.join(home, ".ttsc");
- if (editContent) {
+ if (edit) {
const file = path.join(cwd, "input.txt");
await fs.writeFile(file, input, "utf8");
const editor = process.env.EDITOR || "nano";
@@ -93,7 +101,7 @@ async function main() {
stderr: "inherit",
})`uv run -- python -u main.py`;
- if (!waitAndCombine) {
+ if (!wait) {
koko.catch(() => {});
koko.then(() => {
const stop = Date.now();
@@ -127,7 +135,7 @@ async function main() {
const file = path.join(cwd, `${info.file}.wav`);
- if (waitAndCombine) {
+ if (wait) {
files.push(file);
process.stdout.write(".");
} else {
@@ -139,12 +147,12 @@ async function main() {
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
- })`mpv${pausePlayer ? " --pause" : ""} ${file}`;
+ })`mpv ${file}`;
}
}
await koko;
- if (!waitAndCombine) return;
+ if (!wait) return;
const stop = Date.now();
const duration = ((stop - start) / 1000).toFixed(2);
@@ -154,7 +162,7 @@ async function main() {
try {
await execa({ cwd, shell: true })`sox ${files} output.wav`;
} catch (err) {
- if (err instanceof ExecaError && err.code === "ENOENT") {
+ if (err instanceof Object && "code" in err && err.code === "ENOENT") {
console.error("Sox not found. Unable to combine outputs.");
console.log(chalk.yellow(`Audio available in ${cwd}`));
return;
@@ -174,7 +182,10 @@ async function main() {
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
- })`mpv${pausePlayer ? " --pause" : ""} output.wav`;
+ })`mpv --pause output.wav`;
}
-main().catch(handleTopLevel);
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});