Compare commits

..

22 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
14 changed files with 324 additions and 244 deletions

1
.npmrc
View file

@ -1 +0,0 @@
@jsr:registry=https://npm.jsr.io

View file

@ -1,26 +1,36 @@
# lmc # lmc
Bun CLI tool for streaming LLM chat with inline markdown rendering. TypeScript CLI tool for streaming LLM chat with inline markdown rendering.
## Commands ## Commands
``` ```
bun run dev # dev run pnpm run dev # dev run (rolldown bundle + node)
bun run prod # run bundled output pnpm run check # biome lint + format + organize imports + typecheck
bun run check # biome lint + format + organize imports 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 ## 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/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 ## 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. - **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 ## Style

View file

@ -1,84 +0,0 @@
#!/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);
});

View file

@ -1,6 +1,5 @@
{ {
"name": "lmc", "name": "lmc",
"module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"bin": { "bin": {
@ -8,35 +7,35 @@
"ttsc": "dist/tts.js" "ttsc": "dist/tts.js"
}, },
"scripts": { "scripts": {
"dev": "pnpm run bundle index.ts &>/dev/null && node dist/index.js", "dev": "pnpm run bundle src/index.ts >/dev/null && node dist/index.js",
"check": "biome check --write && pnpm run tc", "check": "biome check --write && pnpm run tc",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"build": "pnpm run check && pnpm run bundle --minify index.ts && pnpm run bundle --minify tts.ts", "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 index.ts && pnpm run bundle:bin --minify tts.ts && pnpm run bin", "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": "rolldown --dir=dist --platform=node --transform.target=node22 --format=esm",
"bundle:bin": "esbuild --bundle --outdir=dist --platform=node --target=node26 --format=cjs", "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", "bin": "node --build-sea sea-config.json && node --build-sea sea-config-tts.json",
"tc": "tsc --noEmit --incremental" "tc": "tsc --noEmit --incremental"
}, },
"devDependencies": { "devDependencies": {
"@ai-sdk/openai-compatible": "^2.0.41", "@ai-sdk/openai-compatible": "^2.0.50",
"@biomejs/biome": "2.4.12", "@biomejs/biome": "2.4.12",
"@clack/prompts": "^1.3.0", "@clack/prompts": "^1.5.1",
"@cliffy/table": "npm:@jsr/cliffy__table", "@cliffy/table": "jsr:^1.2.1",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@types/node": "^25.9.2", "@types/node": "^25.9.3",
"ai": "^6.0.161", "ai": "^6.0.206",
"ansi-styles": "^6.2.3", "ansi-styles": "^6.2.3",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"execa": "^9.6.1", "execa": "^9.6.1",
"happy-dom": "^20.10.2", "happy-dom": "^20.10.3",
"marked": "^18.0.4", "marked": "^18.0.5",
"rolldown": "^1.1.1", "rolldown": "^1.1.1",
"shiki": "^4.0.2", "shiki": "^4.2.0",
"string-width": "^8.2.1", "string-width": "^8.2.1",
"terminal-link": "^5.0.0", "terminal-link": "^5.0.0",
"typescript": "^5", "typescript": "^5.9.3",
"wrap-ansi": "^10.0.0" "wrap-ansi": "^10.0.0"
} }
} }

199
pnpm-lock.yaml generated
View file

@ -9,26 +9,26 @@ importers:
.: .:
devDependencies: devDependencies:
'@ai-sdk/openai-compatible': '@ai-sdk/openai-compatible':
specifier: ^2.0.41 specifier: ^2.0.50
version: 2.0.48(zod@4.4.3) version: 2.0.50(zod@4.4.3)
'@biomejs/biome': '@biomejs/biome':
specifier: 2.4.12 specifier: 2.4.12
version: 2.4.12 version: 2.4.12
'@clack/prompts': '@clack/prompts':
specifier: ^1.3.0 specifier: ^1.5.1
version: 1.4.0 version: 1.5.1
'@cliffy/table': '@cliffy/table':
specifier: npm:@jsr/cliffy__table specifier: jsr:^1.2.1
version: '@jsr/cliffy__table@1.2.1' version: '@jsr/cliffy__table@1.2.1'
'@mozilla/readability': '@mozilla/readability':
specifier: ^0.6.0 specifier: ^0.6.0
version: 0.6.0 version: 0.6.0
'@types/node': '@types/node':
specifier: ^25.9.2 specifier: ^25.9.3
version: 25.9.2 version: 25.9.3
ai: ai:
specifier: ^6.0.161 specifier: ^6.0.206
version: 6.0.191(zod@4.4.3) version: 6.0.206(zod@4.4.3)
ansi-styles: ansi-styles:
specifier: ^6.2.3 specifier: ^6.2.3
version: 6.2.3 version: 6.2.3
@ -42,17 +42,17 @@ importers:
specifier: ^9.6.1 specifier: ^9.6.1
version: 9.6.1 version: 9.6.1
happy-dom: happy-dom:
specifier: ^20.10.2 specifier: ^20.10.3
version: 20.10.2 version: 20.10.3
marked: marked:
specifier: ^18.0.4 specifier: ^18.0.5
version: 18.0.4 version: 18.0.5
rolldown: rolldown:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
shiki: shiki:
specifier: ^4.0.2 specifier: ^4.2.0
version: 4.1.0 version: 4.2.0
string-width: string-width:
specifier: ^8.2.1 specifier: ^8.2.1
version: 8.2.1 version: 8.2.1
@ -60,7 +60,7 @@ importers:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0 version: 5.0.0
typescript: typescript:
specifier: ^5 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
wrap-ansi: wrap-ansi:
specifier: ^10.0.0 specifier: ^10.0.0
@ -68,20 +68,20 @@ importers:
packages: packages:
'@ai-sdk/gateway@3.0.120': '@ai-sdk/gateway@3.0.132':
resolution: {integrity: sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==} resolution: {integrity: sha512-sFZFGk6aVSNKmgDrb14efaAbvM71AB3UUvaTsU+bvhWP9jrkRrwix/jFX8IoOAJk0/X7Xf7p3m0J2O2G6ljZbA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai-compatible@2.0.48': '@ai-sdk/openai-compatible@2.0.50':
resolution: {integrity: sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==} resolution: {integrity: sha512-HyuxddF2Yv5G8qxK/0uksAINjQ4h6TpwOqHuqzsCM0u78/JWAW2OXcIplQeB44PIAORgPjbMzrw9DhnPYHMskA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.27': '@ai-sdk/provider-utils@4.0.29':
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} resolution: {integrity: sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
@ -147,12 +147,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@clack/core@1.3.1': '@clack/core@1.4.1':
resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} resolution: {integrity: sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==}
engines: {node: '>= 20.12.0'} engines: {node: '>= 20.12.0'}
'@clack/prompts@1.4.0': '@clack/prompts@1.5.1':
resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==}
engines: {node: '>= 20.12.0'} engines: {node: '>= 20.12.0'}
'@emnapi/core@1.11.0': '@emnapi/core@1.11.0':
@ -444,32 +444,32 @@ packages:
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shikijs/core@4.1.0': '@shikijs/core@4.2.0':
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} resolution: {integrity: sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/engine-javascript@4.1.0': '@shikijs/engine-javascript@4.2.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} resolution: {integrity: sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.1.0': '@shikijs/engine-oniguruma@4.2.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} resolution: {integrity: sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/langs@4.1.0': '@shikijs/langs@4.2.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} resolution: {integrity: sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/primitive@4.1.0': '@shikijs/primitive@4.2.0':
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} resolution: {integrity: sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/themes@4.1.0': '@shikijs/themes@4.2.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/types@4.1.0': '@shikijs/types@4.2.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} resolution: {integrity: sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==}
engines: {node: '>=20'} engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
@ -491,11 +491,8 @@ packages:
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/node@25.9.1': '@types/node@25.9.3':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/node@25.9.2':
resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -513,8 +510,8 @@ packages:
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
ai@6.0.191: ai@6.0.206:
resolution: {integrity: sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ==} resolution: {integrity: sha512-hVdB5PozMMxmkPBfbCxkFOn0eVeCMi/imkfPvk65cxL4O8oIrvjUxDUX8dGkdkWG1No+R7e7D9ti2DHO61Z4YQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
@ -575,8 +572,8 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
eventsource-parser@3.0.8: eventsource-parser@3.1.0:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
execa@9.6.1: execa@9.6.1:
@ -604,8 +601,8 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'} engines: {node: '>=18'}
happy-dom@20.10.2: happy-dom@20.10.3:
resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==} resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
has-flag@5.0.1: has-flag@5.0.1:
@ -643,8 +640,8 @@ packages:
json-schema@0.4.0: json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
marked@18.0.4: marked@18.0.5:
resolution: {integrity: sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==} resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
hasBin: true hasBin: true
@ -692,8 +689,8 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
property-information@7.1.0: property-information@7.2.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
regex-recursion@6.0.2: regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@ -717,8 +714,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
shiki@4.1.0: shiki@4.2.0:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} resolution: {integrity: sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
signal-exit@4.1.0: signal-exit@4.1.0:
@ -834,24 +831,24 @@ packages:
snapshots: snapshots:
'@ai-sdk/gateway@3.0.120(zod@4.4.3)': '@ai-sdk/gateway@3.0.132(zod@4.4.3)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
'@vercel/oidc': 3.2.0 '@vercel/oidc': 3.2.0
zod: 4.4.3 zod: 4.4.3
'@ai-sdk/openai-compatible@2.0.48(zod@4.4.3)': '@ai-sdk/openai-compatible@2.0.50(zod@4.4.3)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
zod: 4.4.3 zod: 4.4.3
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)': '@ai-sdk/provider-utils@4.0.29(zod@4.4.3)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.8 eventsource-parser: 3.1.0
zod: 4.4.3 zod: 4.4.3
'@ai-sdk/provider@3.0.10': '@ai-sdk/provider@3.0.10':
@ -893,14 +890,14 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.12': '@biomejs/cli-win32-x64@2.4.12':
optional: true optional: true
'@clack/core@1.3.1': '@clack/core@1.4.1':
dependencies: dependencies:
fast-wrap-ansi: 0.2.2 fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5 sisteransi: 1.0.5
'@clack/prompts@1.4.0': '@clack/prompts@1.5.1':
dependencies: dependencies:
'@clack/core': 1.3.1 '@clack/core': 1.4.1
fast-string-width: 3.0.2 fast-string-width: 3.0.2
fast-wrap-ansi: 0.2.2 fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5 sisteransi: 1.0.5
@ -1071,40 +1068,40 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@4.1.0': '@shikijs/core@4.2.0':
dependencies: dependencies:
'@shikijs/primitive': 4.1.0 '@shikijs/primitive': 4.2.0
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4
hast-util-to-html: 9.0.5 hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.1.0': '@shikijs/engine-javascript@4.2.0':
dependencies: dependencies:
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6 oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.1.0': '@shikijs/engine-oniguruma@4.2.0':
dependencies: dependencies:
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.1.0': '@shikijs/langs@4.2.0':
dependencies: dependencies:
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/primitive@4.1.0': '@shikijs/primitive@4.2.0':
dependencies: dependencies:
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@shikijs/themes@4.1.0': '@shikijs/themes@4.2.0':
dependencies: dependencies:
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/types@4.1.0': '@shikijs/types@4.2.0':
dependencies: dependencies:
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -1128,11 +1125,7 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/node@25.9.1': '@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
'@types/node@25.9.2':
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
@ -1142,17 +1135,17 @@ snapshots:
'@types/ws@8.18.1': '@types/ws@8.18.1':
dependencies: dependencies:
'@types/node': 25.9.1 '@types/node': 25.9.3
'@ungap/structured-clone@1.3.1': {} '@ungap/structured-clone@1.3.1': {}
'@vercel/oidc@3.2.0': {} '@vercel/oidc@3.2.0': {}
ai@6.0.191(zod@4.4.3): ai@6.0.206(zod@4.4.3):
dependencies: dependencies:
'@ai-sdk/gateway': 3.0.120(zod@4.4.3) '@ai-sdk/gateway': 3.0.132(zod@4.4.3)
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
zod: 4.4.3 zod: 4.4.3
@ -1166,7 +1159,7 @@ snapshots:
buffer-image-size@0.6.4: buffer-image-size@0.6.4:
dependencies: dependencies:
'@types/node': 25.9.1 '@types/node': 25.9.3
ccount@2.0.1: {} ccount@2.0.1: {}
@ -1223,7 +1216,7 @@ snapshots:
'@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1 '@esbuild/win32-x64': 0.28.1
eventsource-parser@3.0.8: {} eventsource-parser@3.1.0: {}
execa@9.6.1: execa@9.6.1:
dependencies: dependencies:
@ -1261,9 +1254,9 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1 '@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1 is-stream: 4.0.1
happy-dom@20.10.2: happy-dom@20.10.3:
dependencies: dependencies:
'@types/node': 25.9.1 '@types/node': 25.9.3
'@types/whatwg-mimetype': 3.0.2 '@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1 '@types/ws': 8.18.1
buffer-image-size: 0.6.4 buffer-image-size: 0.6.4
@ -1285,7 +1278,7 @@ snapshots:
hast-util-whitespace: 3.0.0 hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0 html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1 mdast-util-to-hast: 13.2.1
property-information: 7.1.0 property-information: 7.2.0
space-separated-tokens: 2.0.2 space-separated-tokens: 2.0.2
stringify-entities: 4.0.4 stringify-entities: 4.0.4
zwitch: 2.0.4 zwitch: 2.0.4
@ -1308,7 +1301,7 @@ snapshots:
json-schema@0.4.0: {} json-schema@0.4.0: {}
marked@18.0.4: {} marked@18.0.5: {}
mdast-util-to-hast@13.2.1: mdast-util-to-hast@13.2.1:
dependencies: dependencies:
@ -1362,7 +1355,7 @@ snapshots:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0
property-information@7.1.0: {} property-information@7.2.0: {}
regex-recursion@6.0.2: regex-recursion@6.0.2:
dependencies: dependencies:
@ -1401,14 +1394,14 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
shiki@4.1.0: shiki@4.2.0:
dependencies: dependencies:
'@shikijs/core': 4.1.0 '@shikijs/core': 4.2.0
'@shikijs/engine-javascript': 4.1.0 '@shikijs/engine-javascript': 4.2.0
'@shikijs/engine-oniguruma': 4.1.0 '@shikijs/engine-oniguruma': 4.2.0
'@shikijs/langs': 4.1.0 '@shikijs/langs': 4.2.0
'@shikijs/themes': 4.1.0 '@shikijs/themes': 4.2.0
'@shikijs/types': 4.1.0 '@shikijs/types': 4.2.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4

View file

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

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

@ -51,7 +51,7 @@ export default class Renderer {
this.charsSinceNL = this.charsSinceNL - columns; 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); this.moveClearReset(-this.lineCount, pipe);
} }
} }
@ -84,7 +84,13 @@ export default class Renderer {
async renderStream(stream: AsyncIterableStream<TextStreamPart<ToolSet>>) { async renderStream(stream: AsyncIterableStream<TextStreamPart<ToolSet>>) {
this.resetCounts(); this.resetCounts();
const spin = spinner({ withGuide: false, indicator: "timer" }); const spin = spinner({
withGuide: false,
indicator: "timer",
onCancel: () => {
process.exit(130);
},
});
if (!this.isPlain) { if (!this.isPlain) {
spin.start("Waiting for response"); spin.start("Waiting for response");
} }

View file

@ -76,6 +76,7 @@ class LmcRenderer extends Renderer {
this.listitem(item, ordered ? num + index : undefined), this.listitem(item, ordered ? num + index : undefined),
) )
.join("\n"); .join("\n");
return `\n${renderedItems}\n`; return `\n${renderedItems}\n`;
} }
@ -91,7 +92,8 @@ class LmcRenderer extends Renderer {
const item = `${marker} ${text}`; const item = `${marker} ${text}`;
return item return item
.split("\n") .split("\n")
.map((line) => (line.length > 0 ? ` ${line}` : "")) .filter((line) => line.length > 0)
.map((line) => ` ${line}`)
.join("\n"); .join("\n");
} }

View file

@ -1,20 +1,17 @@
#!/usr/bin/env node #!/usr/bin/env node
import events from "node:events";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
import readline from "node:readline"; import readline from "node:readline";
import consumers from "node:stream/consumers";
import util from "node:util"; import util from "node:util";
import { Readability } from "@mozilla/readability"; import { Readability } from "@mozilla/readability";
import chalk from "chalk"; import chalk from "chalk";
import { execa } from "execa"; import { ExecaError, execa } from "execa";
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import wrapAnsi from "wrap-ansi"; import wrapAnsi from "wrap-ansi";
import { handleTopLevel } from "./errors";
class InputError extends Error {
override name = "InputError";
}
async function main() { async function main() {
const start = Date.now(); const start = Date.now();
@ -24,6 +21,8 @@ async function main() {
options: { options: {
wait: { type: "boolean" }, wait: { type: "boolean" },
w: { type: "boolean" }, w: { type: "boolean" },
pause: { type: "boolean" },
p: { type: "boolean" },
url: { type: "boolean" }, url: { type: "boolean" },
u: { type: "boolean" }, u: { type: "boolean" },
edit: { type: "boolean" }, edit: { type: "boolean" },
@ -33,27 +32,20 @@ async function main() {
allowPositionals: true, allowPositionals: true,
}); });
const wait = args.wait ?? args.w; const waitAndCombine = args.wait ?? args.w;
const url = args.url ?? args.u; const pausePlayer = args.pause ?? args.p;
const edit = args.edit ?? args.e; const readURL = args.url ?? args.u;
const editContent = args.edit ?? args.e;
let input = positionals.join(" ").trim(); let input = positionals.join(" ").trim();
if (!input) { if (!input) input = (await consumers.text(process.stdin)).trim();
process.stdin.setEncoding("utf8");
process.stdin.on("data", (data) => {
input += data;
});
await events.once(process.stdin, "end");
}
if (!input) { if (!input) {
throw new InputError( throw new Error("No input was given. Use positional arguments or stdin.");
"No input was given. Use positional arguments or stdin.",
);
} }
if (url) { if (readURL) {
const result = await fetch(input); const result = await fetch(input);
const window = new Window({ url: input }); const window = new Window({ url: input });
const doc = window.document; const doc = window.document;
@ -61,7 +53,7 @@ async function main() {
await window.happyDOM.waitUntilComplete(); await window.happyDOM.waitUntilComplete();
const readable = new Readability(doc).parse(); const readable = new Readability(doc).parse();
if (!readable?.textContent) { if (!readable?.textContent) {
throw new InputError( throw new Error(
"Unable to read webpage. This usually means it's blocking scrapers.", "Unable to read webpage. This usually means it's blocking scrapers.",
); );
} }
@ -71,7 +63,7 @@ async function main() {
const home = os.homedir(); const home = os.homedir();
const cwd = path.join(home, ".ttsc"); const cwd = path.join(home, ".ttsc");
if (edit) { if (editContent) {
const file = path.join(cwd, "input.txt"); const file = path.join(cwd, "input.txt");
await fs.writeFile(file, input, "utf8"); await fs.writeFile(file, input, "utf8");
const editor = process.env.EDITOR || "nano"; const editor = process.env.EDITOR || "nano";
@ -101,7 +93,7 @@ async function main() {
stderr: "inherit", stderr: "inherit",
})`uv run -- python -u main.py`; })`uv run -- python -u main.py`;
if (!wait) { if (!waitAndCombine) {
koko.catch(() => {}); koko.catch(() => {});
koko.then(() => { koko.then(() => {
const stop = Date.now(); const stop = Date.now();
@ -135,7 +127,7 @@ async function main() {
const file = path.join(cwd, `${info.file}.wav`); const file = path.join(cwd, `${info.file}.wav`);
if (wait) { if (waitAndCombine) {
files.push(file); files.push(file);
process.stdout.write("."); process.stdout.write(".");
} else { } else {
@ -147,12 +139,12 @@ async function main() {
stdin: "ignore", stdin: "ignore",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
})`mpv ${file}`; })`mpv${pausePlayer ? " --pause" : ""} ${file}`;
} }
} }
await koko; await koko;
if (!wait) return; if (!waitAndCombine) return;
const stop = Date.now(); const stop = Date.now();
const duration = ((stop - start) / 1000).toFixed(2); const duration = ((stop - start) / 1000).toFixed(2);
@ -162,7 +154,7 @@ async function main() {
try { try {
await execa({ cwd, shell: true })`sox ${files} output.wav`; await execa({ cwd, shell: true })`sox ${files} output.wav`;
} catch (err) { } catch (err) {
if (err instanceof Object && "code" in err && err.code === "ENOENT") { if (err instanceof ExecaError && err.code === "ENOENT") {
console.error("Sox not found. Unable to combine outputs."); console.error("Sox not found. Unable to combine outputs.");
console.log(chalk.yellow(`Audio available in ${cwd}`)); console.log(chalk.yellow(`Audio available in ${cwd}`));
return; return;
@ -182,10 +174,7 @@ async function main() {
stdin: "ignore", stdin: "ignore",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
})`mpv --pause output.wav`; })`mpv${pausePlayer ? " --pause" : ""} output.wav`;
} }
main().catch((err) => { main().catch(handleTopLevel);
console.error(err);
process.exit(1);
});