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