diff --git a/.gitignore b/.gitignore index 6eddc75..2aa2aca 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,11 @@ coverage/ .openrouterrc.json .openrouterrc.yaml .openrouterrc.yml + +# Local TODO notes (not committed) +TODO.local.md + +# Local logs (not committed) +*.log +logs/ +src/shared/**/*.log diff --git a/.vscode/settings.json b/.vscode/settings.json index 99d799f..461abfa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "letuscode", "openrouter", "openrouterrc", + "ORCLI", "vars", "Vitest", "winget" diff --git a/eslint.config.js b/eslint.config.js index b2cae01..df0ac51 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -43,7 +43,17 @@ export default [ }, rules: { "no-console": "off", - "no-empty": ["error", { "allowEmptyCatch": true }] + "no-empty": ["error", { "allowEmptyCatch": true }], + // Use the TS-aware rule and disable the base one for TS files + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "after-used", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ] } } ]; diff --git a/existing_structure.md b/existing_structure.md new file mode 100644 index 0000000..d951726 --- /dev/null +++ b/existing_structure.md @@ -0,0 +1,110 @@ +# OpenRouter CLI — Existing Structure + +This document summarizes the current architecture, modules, and flows of the project as of this commit. + +## Overview + +- Language/Module: TypeScript (ESM) targeting Node.js >= 18.17. +- Entry: `src/index.ts` invokes `main()` from `src/main.ts`. +- CLI: Commander-based commands: `config`, `test`, `ask`, `repl`, `init`. +- Build: `npm run build` compiles to `dist/**`; runtime binary `bin/openrouter` loads `dist/index.js`. +- Tests: Vitest in `tests/*.spec.ts`; no network; I/O is mocked where needed. + +``` +bin/openrouter ─▶ dist/index.js (built) +src/index.ts ─▶ src/main.ts ─▶ commands + ├─ src/shared/openrouter.ts (API, streaming) + ├─ src/shared/format.ts (markdown→ANSI small) + ├─ src/shared/config.ts (global/project config, profiles) + ├─ src/shared/env.ts (defaults, api key helpers) + ├─ src/shared/auth.ts (ensureApiKey + init fallback) + ├─ src/shared/init.ts (interactive wizard) + └─ src/repl.ts (interactive chat) +``` + +## Commands and Control Flow + +- `config` + - Shows or updates API key. Profile-aware via `--profile` and `--list`. + - Domain/model edits are intentionally kept out of `config` (managed via `init`). + +- `test` + - Verifies connectivity via `GET /models` using `testConnection()`. + - Resolves effective config and optionally triggers `init` when missing keys and running in TTY. + +- `ask` + - One-shot completion via `POST /chat/completions`. + - Streaming default; `--no-stream` path renders full answer then prints styled header/footer. + - Output format: `auto|plain|md` mapped by `src/shared/format.ts`. + +- `repl` + - Interactive session; supports `/model`, `/system`, `/format`, `/stream` commands. + - Streams by default; non-stream reuses `askOnce` + renderer. + +- `init` + - Interactive wizard to pick provider/domain/model and optionally persist API key. + - Tests connection before saving; writes to base config or a named profile. + +## Configuration & Precedence + +- Global file: `~/.config/openrouter-cli/config.json` (chmod `600` best-effort). +- Profiles: stored under `profiles` inside the same JSON file. +- Project overrides: `.openrouterrc(.json|.yaml|.yml)` in CWD. +- Precedence for `resolveConfig(profile?)`: project RC > named profile > global base. +- API key lookup: `OPENROUTER_API_KEY` or `OPENAI_API_KEY` envs take priority over persisted key. +- `maskKey()` redacts logged keys; code avoids printing secrets. + +## Networking Layer (`src/shared/openrouter.ts`) + +- `testConnection({ domain, apiKey })`: GET `/models`, returns JSON or throws on non-OK with body. +- `askOnce(opts, messages)`: POST `/chat/completions`, `stream:false`; returns assistant text. +- `streamChat(opts, messages)`: same endpoint with `stream:true`; reads SSE and writes deltas to stdout. +- `joinUrl(base, path)`: safe URL join preserving base path. +- `safeJson(res)`: tries JSON, falls back to text. + +Notes: +- Request-level timeouts and normalized error mapping are not yet implemented (planned in upcoming tasks). + +## Rendering & UI + +- Markdown renderer: `src/shared/format.ts` + - Minimal ANSI formatting for headings, lists, inline code; streaming path stays plain for responsiveness. +- Theming/UI: `src/shared/ui.ts` + - Color detection: honors `NO_COLOR` and only colors when `process.stdout.isTTY` is true. + - Palette: accent, ok/warn/err, dim, bold (chalk-backed but safe without colors). + - Components: banner, examples box (help), non-stream answer header/footer, REPL prompt and tips box. + - Commander help: `attachStyledHelp(program)` injects banner/examples via `addHelpText('beforeAll', ...)`. + +## Security + +- API keys are never logged; persisted config is written with `chmod 600` when possible. +- Prefer environment variables for ephemeral keys; `openrouter config --api-key` persists only when desired. + +## Testing + +- Vitest specs under `tests/` cover: + - Config precedence and YAML rc handling. + - URL join behavior. + - Streaming SSE parsing and non-stream error propagation. + - CLI help shape and basic styled help presence. + - Redaction behaviors and minimal UI blocks. + +## Build, Lint, and Scripts + +- Build: `npm run build` → `dist/**` +- Dev (TS directly): `npm run dev -- ` (uses ts-node ESM loader). +- Tests: `npm test`; coverage: `npm run test:coverage`. +- Lint: `npm run lint` (flat config `eslint.config.js`). +- Packaging: `prepack` builds and runs `scripts/prepack.mjs`. + +## Dependencies (runtime) + +- `commander` (CLI), `js-yaml` (project rc YAML), `chalk` and `boxen` (UI), `cli-table3`, `ora` (available for future spinners/tables). + +## Open Items / Upcoming Work + +- Timeouts and normalized error handling for network requests. +- Non-interactive flags for `init` (provider/domain/model/api-key/profile) as part of CI flows. +- Documentation polish (README examples, REPL transcript, small screencast). +- Optional CI packaging and coverage upload. + diff --git a/package-lock.json b/package-lock.json index 63f0e74..c0f6780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,14 @@ "version": "1.0.0-beta.1", "license": "MIT", "dependencies": { + "boxen": "^7.1.1", + "chalk": "^5.3.0", + "cli-table3": "^0.6.3", "commander": "^14.0.0", - "js-yaml": "^4.1.0" + "enquirer": "^2.4.1", + "fuse.js": "^7.0.0", + "js-yaml": "^4.1.0", + "ora": "^8.1.0" }, "bin": { "openrouter": "bin/openrouter" @@ -146,7 +152,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -249,19 +254,6 @@ "inquirer": "^9.0.0" } }, - "node_modules/@commitlint/cz-commitlint/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@commitlint/ensure": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", @@ -304,19 +296,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/format/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@commitlint/is-ignored": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", @@ -369,19 +348,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/load/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@commitlint/message": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", @@ -607,19 +573,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/types/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -3021,6 +2974,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3041,7 +3012,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3189,6 +3159,107 @@ "dev": true, "license": "MIT" }, + "node_modules/boxen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3267,6 +3338,18 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -3285,17 +3368,12 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -3338,11 +3416,22 @@ "node": ">=6" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" @@ -3376,6 +3465,23 @@ "npm": ">=5.0.0" } }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/cli-highlight/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -3439,7 +3545,6 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3452,7 +3557,6 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "dev": true, "license": "MIT", "dependencies": { "string-width": "^4.2.0" @@ -3643,6 +3747,23 @@ "node": ">= 12" } }, + "node_modules/commitizen/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/commitizen/node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3735,6 +3856,46 @@ "node": ">=12.0.0" } }, + "node_modules/commitizen/node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/commitizen/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commitizen/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/commitizen/node_modules/minimist": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", @@ -3752,6 +3913,30 @@ "dev": true, "license": "ISC" }, + "node_modules/commitizen/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/commitizen/node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -4276,14 +4461,12 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/emojilib": { @@ -4293,6 +4476,19 @@ "dev": true, "license": "MIT" }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/env-ci": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", @@ -4655,6 +4851,23 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -4971,19 +5184,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5203,6 +5403,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -5217,7 +5426,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.1.tgz", "integrity": "sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5725,64 +5933,179 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/inquirer": { + "version": "9.3.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.7.tgz", + "integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/figures": "^1.0.3", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "external-editor": "^3.1.0", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/inquirer/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, - "license": "ISC", + "license": "MIT", + "peer": true, "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "node_modules/inquirer/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, - "license": "ISC", + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inquirer": { - "version": "9.3.7", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.7.tgz", - "integrity": "sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==", + "node_modules/inquirer/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@inquirer/figures": "^1.0.3", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" }, "engines": { - "node": ">=18" + "node": ">=8" } }, "node_modules/into-stream": { @@ -5846,13 +6169,15 @@ } }, "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-number": { @@ -5915,13 +6240,12 @@ } }, "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6249,19 +6573,6 @@ "url": "https://opencollective.com/lint-staged" } }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/lint-staged/node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -6675,17 +6986,28 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6952,19 +7274,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/marked-terminal/node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -7046,7 +7355,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -9956,54 +10264,64 @@ } }, "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", "license": "MIT", "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/os-tmpdir": { @@ -10737,7 +11055,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, "license": "MIT", "dependencies": { "onetime": "^7.0.0", @@ -10754,7 +11071,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" @@ -10770,7 +11086,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -11598,6 +11913,18 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -11633,7 +11960,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11674,7 +12000,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11684,7 +12009,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11721,7 +12045,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12278,9 +12601,9 @@ } }, "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", "dependencies": { @@ -12476,6 +12799,59 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 7d32064..d207d6e 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,13 @@ }, "dependencies": { "commander": "^14.0.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "boxen": "^7.1.1", + "chalk": "^5.3.0", + "cli-table3": "^0.6.3", + "ora": "^8.1.0", + "enquirer": "^2.4.1", + "fuse.js": "^7.0.0" }, "devDependencies": { "@vitest/coverage-v8": "^2.1.1", diff --git a/src/commands/models.ts b/src/commands/models.ts new file mode 100644 index 0000000..5667971 --- /dev/null +++ b/src/commands/models.ts @@ -0,0 +1,79 @@ +import { Command } from 'commander'; +import { banner, palette } from '../shared/ui.js'; +import { resolveConfig } from '../shared/config.js'; +import { getDefaultConfig } from '../shared/env.js'; +import { fetchModelsCached, fuzzyIds} from '../shared/models.js'; +import Table from 'cli-table3'; + +export function registerModelsCommand(program: Command) { + program + .command('models') + .argument('[query]', 'Search term') + .description('Search OpenRouter models') + .option('--non-interactive', 'Disable prompts') + .action(async (query: string | undefined, opts: { nonInteractive?: boolean }) => { + const eff = await resolveConfig(); + const domain = eff.domain || getDefaultConfig().domain; + const apiKey = eff.apiKey; // do not print + + const interactive = process.stdin.isTTY && process.stdout.isTTY && !opts.nonInteractive && !query; + if (interactive) { + try { + console.log(banner()); + const enq = await import('enquirer'); + const promptFn: any = (enq as any).prompt ?? (enq as any).default?.prompt; + if (typeof promptFn !== 'function') throw new Error('Enquirer prompt() not available'); + let initial: string[] = [eff.model || getDefaultConfig().model]; + let modelsList: Array<{ id: string; name?: string }> = []; + try { + const list = await fetchModelsCached({ domain, apiKey }); + modelsList = list; + initial = list.slice(0, 25).map(m => m.id); + } catch {} + const stableSuggest = async (input: string) => { + const q = input || ''; + const ids = modelsList.length ? fuzzyIds(q, modelsList) : []; + const withTyped = q && !ids.includes(q) ? [q, ...ids] : ids; + return toChoices(withTyped.length ? withTyped : initial); + }; + const toChoices = (ids: string[]) => ids.map(id => ({ name: id, value: id, message: id })); + const ans = await promptFn({ + type: 'autocomplete', + name: 'model', + message: 'Choose a model (type to fuzzy search)', + limit: 10, + initial: 0, + choices: toChoices(initial), + suggest: stableSuggest, + }); + const picked: string = (ans as { model: string }).model; + process.stdout.write(palette.ok(`Selected: ${picked}`) + '\n'); + return; + } catch (e) { + if (process.env.ORCLI_DEBUG) { + console.error('interactive fallback:', e); + } + console.log('fall through to non-interactive table print'); + } + } + + try { + const list = await fetchModelsCached({ domain, apiKey }); + const ids = fuzzyIds(query || '', list, 25); + const table = new Table({ head: ['ID', 'Name'] }); + for (const id of ids) { + const meta = list.find(m => m.id === id); + table.push([id, meta?.name || '']); + } + console.log(table.toString()); + } catch (e) { + const table = new Table({ head: ['ID', 'Name'] }); + const fallback = eff.model || getDefaultConfig().model || 'openrouter/auto'; + table.push([fallback, '']); + console.log(table.toString()); + if (process.env.ORCLI_DEBUG) { + console.error('non-interactive fallback:', e); + } + } + }); +} diff --git a/src/index.ts b/src/index.ts index a774a85..6e46497 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node import { main } from "./main.js"; +import { logError } from "./shared/logger.js"; // Entrypoint -main().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); +main().catch(async (err) => { + await logError(err, 'unhandled'); + console.error('A technical issue occurred. Please try again.'); process.exit(1); }); - diff --git a/src/main.ts b/src/main.ts index fac940c..5abf275 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { testConnection, askOnce, ChatOptions, streamChat } from "./shared/openr import { renderText, OutputFormat } from "./shared/format.js"; import { startRepl } from "./repl.js"; import { runInitWizard } from "./shared/init.js"; +import { attachStyledHelp, answerHeader, infoFooter, showSpinner } from "./shared/ui.js"; +import { registerModelsCommand } from "./commands/models.js"; export function buildProgram() { const program = new Command(); @@ -14,6 +16,9 @@ export function buildProgram() { .description("OpenRouter CLI") .version("0.1.0"); + // Attach styled help (banner + examples) + attachStyledHelp(program); + program .command("config") .description("Show configuration or update API key") @@ -77,7 +82,7 @@ export function buildProgram() { .option("--profile ", "Use a named profile") .option("--no-stream", "Disable streaming output") .option("--no-init", "Do not run interactive init when missing API key") - .action(async (prompt: string, options: { system?: string; stream?: boolean; profile?: string; format?: OutputFormat; init?: boolean }) => { + .action(async function (this: import('commander').Command, prompt: string, options: { system?: string; stream?: boolean; profile?: string; format?: OutputFormat; init?: boolean }) { const eff = await resolveConfig(options.profile); const { ensureApiKey } = await import('./shared/auth.js'); const r = await ensureApiKey(options.profile, options.init); @@ -85,21 +90,44 @@ export function buildProgram() { const apiKey = r.apiKey; const model = eff.model || getDefaultConfig().model; const domain = eff.domain || getDefaultConfig().domain; - const format: OutputFormat = (options.format as OutputFormat) || "auto"; + const format: OutputFormat = (options.format as OutputFormat) || "md"; + // Default streaming OFF unless user explicitly passed --no-stream/--stream (we honor only explicit input for stream) + const src = (this as any).getOptionValueSource?.('stream'); + const streamExplicit = src === 'cli' || src === 'env'; const chatOptions: ChatOptions = { domain, apiKey, model, system: options.system, - stream: options.stream !== false, + stream: streamExplicit ? options.stream !== false : false, }; if (chatOptions.stream) { - await streamChat(chatOptions, [{ role: "user", content: prompt }]); - process.stdout.write("\n"); + const spinner = showSpinner('Thinking'); + let stopped = false; + try { + spinner.start(); + await streamChat({ + ...chatOptions, + onFirstToken: () => { if (!stopped) { spinner.stop(); stopped = true; } }, + onDone: () => { if (!stopped) { spinner.stop(); stopped = true; } }, + }, [{ role: "user", content: prompt }]); + process.stdout.write("\n"); + } finally { + if (!stopped) { spinner.stop(); } + } } else { - const out = await askOnce(chatOptions, [{ role: "user", content: prompt }]); - const pretty = renderText(out, { format, streaming: false }); - console.log(pretty); + const spinner = showSpinner('Thinking…'); + try { + spinner.start(); + const out = await askOnce(chatOptions, [{ role: "user", content: prompt }]); + const pretty = renderText(out, { format, streaming: false }); + // Styled header and footer around non-stream result + process.stdout.write(answerHeader(model) + "\n"); + process.stdout.write(pretty + "\n"); + process.stdout.write(infoFooter({ model, domain }) + "\n"); + } finally { + spinner.stop(); + } } }); @@ -129,6 +157,9 @@ export function buildProgram() { await runInitWizard(); }); + // Additional commands + registerModelsCommand(program); + return program; } diff --git a/src/repl.ts b/src/repl.ts index f438fa9..da1b053 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -1,6 +1,9 @@ import readline from "node:readline"; import { streamChat, askOnce } from "./shared/openrouter.js"; import { renderText, OutputFormat } from "./shared/format.js"; +import { showSpinner } from "./shared/ui.js"; +import { logError } from "./shared/logger.js"; +import { styledPrompt, tipBox } from "./shared/ui.js"; type ReplOptions = { apiKey: string; @@ -11,16 +14,22 @@ type ReplOptions = { export async function startRepl(opts: ReplOptions) { let currentModel = opts.initialModel; let system: string | undefined; - let format: OutputFormat = "plain"; // default plain for streaming sessions - let streaming = true; + let format: OutputFormat = "md"; // default rendered markdown for non-stream outputs + let streaming = false; const history: { role: "user" | "assistant"; content: string }[] = []; - const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); - const prompt = () => rl.setPrompt(`(${currentModel}) > `); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, prompt: '', historySize: 100, escapeCodeTimeout: 50 }); + const prompt = () => rl.setPrompt(styledPrompt(currentModel)); prompt(); rl.prompt(); - console.log("Type 'exit' to quit. Commands: /model , /system , /format , /stream . Use 'openrouter init' to change defaults."); + console.log(tipBox()); + + // Keep REPL alive on Ctrl+C (SIGINT); show prompt again + rl.on('SIGINT', () => { + process.stdout.write("\n"); + rl.prompt(); + }); rl.on("line", async (line) => { const input = line.trim(); @@ -69,19 +78,41 @@ export async function startRepl(opts: ReplOptions) { history.push(userMsg); try { if (streaming) { - await streamChat({ domain: opts.domain, apiKey: opts.apiKey, model: currentModel, system, stream: true }, [ - ...history, - ]); - process.stdout.write("\n"); + const spinner = showSpinner('Thinking'); + let stopped = false; + try { + spinner.start(); + await streamChat({ + domain: opts.domain, + apiKey: opts.apiKey, + model: currentModel, + system, + stream: true, + onFirstToken: () => { if (!stopped) { spinner.stop(); stopped = true; } }, + onDone: () => { if (!stopped) { spinner.stop(); stopped = true; } }, + }, [ + ...history, + ]); + process.stdout.write("\n"); + } finally { + if (!stopped) { spinner.stop(); } + } } else { - const text = await askOnce({ domain: opts.domain, apiKey: opts.apiKey, model: currentModel, system, stream: false }, [ - ...history, - ]); - const pretty = renderText(text, { format, streaming: false }); - process.stdout.write(pretty + "\n"); + const spinner = showSpinner('Thinking…'); + try { + spinner.start(); + const text = await askOnce({ domain: opts.domain, apiKey: opts.apiKey, model: currentModel, system, stream: false }, [ + ...history, + ]); + const pretty = renderText(text, { format, streaming: false }); + process.stdout.write(pretty + "\n"); + } finally { + spinner.stop(); + } } } catch (err) { - console.error(err instanceof Error ? err.message : String(err)); + await logError(err, 'repl'); + console.error('A technical issue occurred. Please try again.'); } rl.prompt(); }); diff --git a/src/shared/config.ts b/src/shared/config.ts index 506f737..063ea98 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -50,8 +50,7 @@ export async function updateProfile(profile: string, patch: Partial) const current = await readConfig(); const profiles = { ...(current.profiles || {}) } as NonNullable; const cur = profiles[profile] || {}; - - // eslint-disable-next-line no-unused-vars + const { profiles: _, ...safePatch } = patch; profiles[profile] = { ...cur, ...safePatch }; await updateConfig({ profiles }); diff --git a/src/shared/format.ts b/src/shared/format.ts index 190e649..d9c08c7 100644 --- a/src/shared/format.ts +++ b/src/shared/format.ts @@ -40,11 +40,16 @@ export function toAnsiMarkdown(md: string): string { // Lists if (/^\s*[-*+]\s+/.test(line)) { line = line.replace(/^\s*[-*+]\s+/, " • "); - out.push(line); - continue; + // fall through for inline formatting below } // Inline code: `code` line = line.replace(/`([^`]+)`/g, (_, m1: string) => ansiDim(m1)); + // Inline bold: **text** or __text__ + line = line.replace(/\*\*([^*]+)\*\*/g, (_, m1: string) => ansiBold(m1)); + line = line.replace(/__([^_]+)__/g, (_, m1: string) => ansiBold(m1)); + // Inline italic: *text* or _text_ (basic, non-greedy, ignore escaped) + line = line.replace(/(^|[^\\])\*([^*\s][^*]*?)\*(?!\*)/g, (_m, p1: string, m1: string) => p1 + ansiItalic(m1)); + line = line.replace(/(^|[^\\])_([^_\s][^_]*)_(?!_)/g, (_m, p1: string, m1: string) => p1 + ansiItalic(m1)); out.push(line); } return out.join("\n"); @@ -60,4 +65,6 @@ function ansiDim(s: string) { function ansiCyan(s: string) { return "\x1b[36m" + s + "\x1b[39m"; } - +function ansiItalic(s: string) { + return "\x1b[3m" + s + "\x1b[23m"; +} diff --git a/src/shared/init.ts b/src/shared/init.ts index 49005b1..5e58da3 100644 --- a/src/shared/init.ts +++ b/src/shared/init.ts @@ -3,6 +3,8 @@ import { updateConfig, updateProfile } from './config.js'; import type { CliConfig } from './config.js'; import { getDefaultConfig } from './env.js'; import { testConnection } from './openrouter.js'; +import { fetchModelsCached, fuzzyIds } from './models.js'; +import { banner } from './ui.js'; type Provider = 'openrouter' | 'openai' | 'custom'; @@ -19,13 +21,12 @@ export async function runInitWizard(): Promise { const ask = (q: string) => new Promise((res) => rl.question(q, (ans) => res(ans.trim()))); const askHidden = (q: string) => ask(q); // fallback: no masking in this environment - console.log('=== OpenRouter CLI — Init ==='); - const provInput = (await ask('Provider [openrouter|openai|custom] (default: openrouter): ')) as Provider | ''; - const provider: Provider = (provInput === 'openai' || provInput === 'custom') ? provInput : 'openrouter'; + if (process.stdout.isTTY) console.log(banner()); + const provider: Provider = 'openrouter'; const preset = choosePreset(provider); const domain = (await ask(`API domain (default: ${preset.domain || 'none'}): `)) || preset.domain; - const model = (await ask(`Default model (default: ${preset.model || 'none'}): `)) || preset.model; + let model = (await ask(`Default model (default: ${preset.model || 'none'}): `)) || preset.model; let apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY || ''; if (!apiKey) { @@ -34,6 +35,51 @@ export async function runInitWizard(): Promise { const profile = await ask('Profile name (optional): '); + // Interactive model picker (TTY only) + if (process.stdout.isTTY) { + try { + const enq = await import('enquirer'); + const promptFn: any = (enq as any).prompt ?? (enq as any).default?.prompt; + if (typeof promptFn !== 'function') throw new Error('Enquirer prompt() not available'); + // Preload models list (best-effort) + let initial: string[] = [model || getDefaultConfig().model]; + let modelsList: Array<{ id: string; name?: string }> = []; + try { + const list = await fetchModelsCached({ domain, apiKey: apiKey || undefined }); + modelsList = list; + initial = list.slice(0, 25).map(m => m.id); + } catch {} + const stableSuggest = async (input: string) => { + const q = input || ''; + const ids = modelsList.length ? fuzzyIds(q, modelsList as any) : [] as string[]; + const withTyped = q && !ids.includes(q) ? [q, ...ids] : ids; + return toChoices(withTyped.length ? withTyped : initial); + }; + const toChoices = (ids: string[]) => ids.map(id => ({ name: id, value: id, message: id })); + const ans = await promptFn({ + type: 'autocomplete', + name: 'model', + message: 'Choose a model (type to fuzzy search)', + limit: 10, + initial: 0, + choices: toChoices(initial), + suggest: stableSuggest, + }); + if ((ans as { model?: string })?.model) model = (ans as { model?: string }).model as string; + const confirm = await promptFn({ + type: 'confirm', + name: 'save', + message: `Set '${model}' as default model?`, + }); + const confirmSave: boolean = !!(confirm as { save?: boolean }).save; + if (!confirmSave) { + // keep previously entered model value + } + } catch { + // Non-fatal: continue with current model + } + } + let save = true; if (apiKey && domain) { try { diff --git a/src/shared/logger.ts b/src/shared/logger.ts new file mode 100644 index 0000000..27e8259 --- /dev/null +++ b/src/shared/logger.ts @@ -0,0 +1,28 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { ensureConfigDir, paths } from './config.js'; + +function formatError(err: unknown): string { + if (err instanceof Error) return err.stack || err.message || String(err); + try { return JSON.stringify(err); } catch { return String(err); } +} + +export async function logError(err: unknown, context?: string): Promise { + try { + await ensureConfigDir(); + const file = path.join(paths.CONFIG_DIR, 'cli.log'); + const line = `[${new Date().toISOString()}]${context ? ' ' + context : ''} ${formatError(err)}\n`; + try { + await fs.appendFile(file, line, { encoding: 'utf8' }); + } catch (e: any) { + if (e?.code === 'ENOENT') { + await fs.writeFile(file, line, { encoding: 'utf8', mode: 0o600 }); + } else { + // swallow logging errors + } + } + } catch { + // swallow all logging errors + } +} + diff --git a/src/shared/models.ts b/src/shared/models.ts new file mode 100644 index 0000000..b25944d --- /dev/null +++ b/src/shared/models.ts @@ -0,0 +1,94 @@ +import { resolveConfig } from './config.js'; +import { listModels } from './openrouter.js'; +import { isColorSupported } from './ui.js'; +import ora from 'ora'; +import type { Ora } from 'ora'; +import Fuse from 'fuse.js'; + +export type ModelMeta = { id: string; name?: string; [k: string]: any }; + +type Cache = { data: ModelMeta[]; expiresAt: number } | undefined; +let cache: Cache; +let inflight: Promise | null = null; + +export async function fetchModelsCached(opts: { domain: string; apiKey?: string; ttlMs?: number }): Promise { + const ttl = Math.max(1000, opts.ttlMs ?? 60_000); + const now = Date.now(); + if (cache && cache.expiresAt > now) return cache.data; + if (inflight) return inflight; + inflight = (async () => { + try { + const res = await listModels({ domain: opts.domain, apiKey: opts.apiKey }); + const data: ModelMeta[] = Array.isArray((res as any)?.data) ? (res as any).data : []; + cache = { data, expiresAt: now + ttl }; + return data; + } finally { + inflight = null; + } + })(); + return inflight; +} + +export function fuzzyIds(input: string, list: ModelMeta[], limit = 25): string[] { + if (!input) return list.slice(0, limit).map(m => m.id); + const fuse = new Fuse(list, { + keys: ['id', 'name'], + threshold: 0.35, + ignoreLocation: true, + includeScore: true, + }); + return fuse.search(input).slice(0, limit).map(r => r.item.id); +} + +export function debouncedSuggestFactory(args: { domain: string; apiKey?: string; debounceMs?: number; spinnerLabel?: string }) { + const debounceMs = Math.max(0, args.debounceMs ?? 200); + let timer: ReturnType | null = null; + let pendingResolve: ((ids: string[]) => void) | null = null; + let spinner: Ora | null = null; + + const defaultModel = async () => { + const env = process.env.OPENROUTER_MODEL; + if (env) return env; + const eff = await resolveConfig(); + return eff.model || 'openrouter/auto'; + }; + + async function run(query: string): Promise { + try { + const list = await fetchModelsCached({ domain: args.domain, apiKey: args.apiKey }); + const matches = fuzzyIds(query, list); + const custom = query && !matches.includes(query) ? [query] : []; + return [...custom, ...(matches.length ? matches : [await defaultModel()])]; + } catch { + return [await defaultModel()]; + } + } + + return async (input: string): Promise => { + if (pendingResolve) { + pendingResolve = null; + } + if (timer) clearTimeout(timer); + + if ((input?.length ?? 0) >= 2 && process.stdout.isTTY) { + if (!spinner) { + spinner = ora({ text: args.spinnerLabel || 'Searching models…', isEnabled: isColorSupported() }); + spinner.start(); + } else if (!spinner.isSpinning) { + spinner.start(); + } + } + + return new Promise((resolve) => { + pendingResolve = resolve; + timer = setTimeout(async () => { + const out = await run(input); + if (spinner) { + spinner.stop(); + } + if (pendingResolve) pendingResolve(out); + pendingResolve = null; + }, debounceMs); + }); + }; +} diff --git a/src/shared/openrouter.ts b/src/shared/openrouter.ts index 5f92ee8..65a1894 100644 --- a/src/shared/openrouter.ts +++ b/src/shared/openrouter.ts @@ -6,6 +6,8 @@ export type ChatOptions = { model: string; system?: string; stream?: boolean; + onFirstToken?: () => void; // optional UI hook + onDone?: () => void; // optional UI hook }; export async function testConnection({ domain, apiKey }: { domain: string; apiKey: string }) { @@ -22,6 +24,20 @@ export async function testConnection({ domain, apiKey }: { domain: string; apiKe return await safeJson(res); } +export async function listModels(opts: { domain: string; apiKey?: string }): Promise<{ data: any[] }> +{ + const url = joinUrl(opts.domain, "models"); + const headers: Record = {}; + if (opts.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + const body = await safeJson(res); + throw new Error(`HTTP ${res.status} ${res.statusText}: ${JSON.stringify(body)}`); + } + const json = await safeJson(res); + return typeof json === 'object' && json && 'data' in (json as any) ? (json as any) : { data: [] }; +} + export async function askOnce(opts: ChatOptions, messages: Message[]): Promise { const url = joinUrl(opts.domain, "chat/completions"); const body = { @@ -68,6 +84,7 @@ export async function streamChat(opts: ChatOptions, messages: Message[]) { const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let notified = false; while (true) { const { done, value } = await reader.read(); if (done) break; @@ -84,7 +101,11 @@ export async function streamChat(opts: ChatOptions, messages: Message[]) { try { const evt = JSON.parse(data); const delta: string | undefined = evt?.choices?.[0]?.delta?.content; - if (delta) process.stdout.write(delta); + if (delta) { + if (!notified && opts.onFirstToken) { try { opts.onFirstToken(); } catch {} } + notified = true; + process.stdout.write(delta); + } } catch {} } } @@ -93,9 +114,14 @@ export async function streamChat(opts: ChatOptions, messages: Message[]) { try { const evt = JSON.parse(buffer.replace(/^data:\s*/, "")); const delta: string | undefined = evt?.choices?.[0]?.delta?.content; - if (delta) process.stdout.write(delta); + if (delta) { + if (!notified && opts.onFirstToken) { try { opts.onFirstToken(); } catch {} } + notified = true; + process.stdout.write(delta); + } } catch {} } + if (opts.onDone) { try { opts.onDone(); } catch {} } } function normalizeMessages(opts: ChatOptions, messages: Message[]) { diff --git a/src/shared/ui.ts b/src/shared/ui.ts new file mode 100644 index 0000000..9789730 --- /dev/null +++ b/src/shared/ui.ts @@ -0,0 +1,108 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; +import ora from 'ora'; + +// Color/TTY detection +export function isColorSupported(): boolean { + const noColor = !!process.env.NO_COLOR; + const outTty = !!process.stdout && !!process.stdout.isTTY; + const errTty = !!(process as any).stderr && !!(process as any).stderr.isTTY; + return !noColor && (outTty || errTty); +} + +const color = isColorSupported(); + +// Palette and style helpers (color-safe) +export const palette = { + accent: (s: string) => (color ? chalk.cyan(s) : s), + accent2: (s: string) => (color ? chalk.magenta(s) : s), + ok: (s: string) => (color ? chalk.green(s) : s), + warn: (s: string) => (color ? chalk.yellow(s) : s), + err: (s: string) => (color ? chalk.red(s) : s), + dim: (s: string) => (color ? chalk.dim(s) : s), + bold: (s: string) => (color ? chalk.bold(s) : s), +}; + +export function banner(): string { + // Keep ASCII and under 12 lines + const title = palette.bold('OpenRouter CLI'); + const subtitle = palette.dim('OpenAI-compatible'); + const body = `${title}\n${subtitle}`; + return boxen(body, { + padding: { top: 0, right: 2, bottom: 0, left: 2 }, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + borderStyle: 'round', + }); +} + +export function examplesBox(): string { + // Simple ASCII table without extra deps for stability + const rows: Array<[string, string]> = [ + ['ask', 'openrouter ask "Hello" --no-stream'], + ['repl', 'openrouter repl'], + ['init', 'openrouter init'], + ['config', 'openrouter config --api-key sk-...'], + ['test', 'openrouter test'], + ]; + const header = `${palette.bold('Examples')}`; + const col1Width = Math.max(...rows.map(r => r[0].length), 'Command'.length) + 2; + const lines = [ + `${pad('Command', col1Width)}Example`, + `${'-'.repeat(col1Width)}${'-'.repeat(32)}`, + ...rows.map(([c, ex]) => `${pad(c, col1Width)}${ex}`), + ].join('\n'); + return boxen(`${header}\n${lines}`, { + padding: 1, + borderStyle: 'single', + }); +} + +function pad(s: string, w: number) { + if (s.length >= w) return s; + return s + ' '.repeat(w - s.length); +} + +export function attachStyledHelp(program: import('commander').Command) { + // Use beforeAll to ensure inclusion in helpInformation() + program.addHelpText('beforeAll', banner() + '\n' + examplesBox() + '\n'); +} + +export function answerHeader(model: string): string { + const head = `${palette.accent('Answer')} ${palette.dim('—')} ${palette.bold(model)}`; + return boxen(head, { padding: { top: 0, right: 1, bottom: 0, left: 1 }, borderStyle: 'classic' }); +} + +export function infoFooter(info: { model: string; domain: string }): string { + const url = tryHost(info.domain); + const text = `${palette.dim('model:')} ${palette.bold(info.model)} ${palette.dim('•')} ${palette.dim('domain:')} ${url}`; + return boxen(text, { padding: { top: 0, right: 1, bottom: 0, left: 1 }, borderStyle: 'single' }); +} + +function tryHost(d: string) { + try { + const u = new URL(d); + return u.host + u.pathname.replace(/\/$/, ''); + } catch { + return d; + } +} + +export function styledPrompt(model: string): string { + const m = palette.accent(`(${model})`); + const arrow = palette.dim('>'); + return `${m} ${arrow} `; +} + +export function tipBox(): string { + const lines = [ + palette.dim("Type 'exit' to quit. Commands: ") + '/model , /system , /format , /stream ', + palette.dim("Use 'openrouter init' to change defaults."), + ].join('\n'); + return boxen(lines, { padding: 1, borderStyle: 'single' }); +} + +export function showSpinner(label: string) { + const enabled = isColorSupported(); + const spinner = ora({ text: palette.dim(label), isEnabled: enabled, stream: process.stderr as any }); + return spinner; +} diff --git a/tests/help-style.spec.ts b/tests/help-style.spec.ts new file mode 100644 index 0000000..628bcb3 --- /dev/null +++ b/tests/help-style.spec.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { buildProgram } from '../src/main.js'; + +describe('styled help output additions', () => { + it('includes banner text (description) and commands', () => { + const program = buildProgram(); + const help = program.helpInformation(); + expect(help).toContain('OpenRouter CLI'); + expect(help).toContain('Commands:'); + }); +}); diff --git a/tests/models.spec.ts b/tests/models.spec.ts new file mode 100644 index 0000000..6479b15 --- /dev/null +++ b/tests/models.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../src/shared/openrouter.js', async (orig) => { + const actual = await (orig as any)(); + return { + ...actual, + listModels: vi.fn(async () => ({ data: [ + { id: 'meta-llama/llama-3.1-8b-instruct', name: 'Llama 3.1 8B Instruct' }, + { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' }, + { id: 'google/gemini-pro', name: 'Gemini Pro' }, + { id: 'microsoft/phi-3-mini', name: 'Phi-3 Mini' }, + ] })) + }; +}); + +import { fuzzyIds, fetchModelsCached, debouncedSuggestFactory } from '../src/shared/models.js'; + +describe('models helper', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('fuzzyIds ranks id/name and limits results', () => { + const list = [ + { id: 'meta-llama/llama-3.1-8b-instruct', name: 'Llama 3.1 8B Instruct' }, + { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' }, + { id: 'google/gemini-pro', name: 'Gemini Pro' }, + { id: 'microsoft/phi-3-mini', name: 'Phi-3 Mini' }, + ]; + const ids = fuzzyIds('lla', list, 3); + expect(ids.length).toBeLessThanOrEqual(3); + expect(ids[0]).toContain('llama'); + }); + + it('fetchModelsCached caches results by ttl', async () => { + const first = await fetchModelsCached({ domain: 'https://openrouter.ai/api/v1', ttlMs: 60000 }); + const second = await fetchModelsCached({ domain: 'https://openrouter.ai/api/v1', ttlMs: 60000 }); + expect(second).toBe(first); // same array instance implies cache hit + }); + + it('debouncedSuggestFactory falls back to default when fetch fails', async () => { + // Override mock to throw + const { listModels } = await import('../src/shared/openrouter.js'); + (listModels as any).mockImplementationOnce(async () => { throw new Error('offline'); }); + const suggest = debouncedSuggestFactory({ domain: 'https://openrouter.ai/api/v1', debounceMs: 200 }); + const p = suggest('ll'); + vi.advanceTimersByTime(210); + const out = await p; + expect(out.length).toBeGreaterThan(0); + }); +}); + diff --git a/tests/ui-answer.spec.ts b/tests/ui-answer.spec.ts new file mode 100644 index 0000000..8936413 --- /dev/null +++ b/tests/ui-answer.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { answerHeader, infoFooter } from '../src/shared/ui.js'; + +describe('answer render blocks', () => { + it('renders header with model', () => { + const s = answerHeader('test-model'); + expect(s).toContain('Answer'); + expect(s).toContain('test-model'); + }); + it('renders footer with model and domain host', () => { + const s = infoFooter({ model: 'm', domain: 'https://openrouter.ai/api/v1' }); + expect(s).toContain('model:'); + expect(s).toContain('domain:'); + expect(s).toContain('openrouter.ai'); + }); +}); +