diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2cfad8f..c263c96 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,4 +19,3 @@ Explain the change and why it’s needed. - [ ] Linted locally (`npm run lint`) and tests pass (`npm test`). - [ ] If user‑facing, commit message uses `feat:` or `fix:` (or `BREAKING CHANGE` where appropriate). - [ ] Updated docs if needed. - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 231a2d5..3a17012 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, master ] + branches: [main, master] pull_request: - branches: [ main, master ] + branches: [main, master] jobs: build: diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 0e30bd0..ab53c79 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -19,4 +19,3 @@ jobs: - name: Lint commit messages run: | npx commitlint --from=${{ github.event.pull_request.base.sha }} --to=${{ github.sha }} --verbose - diff --git a/.husky/pre-push b/.husky/pre-push index 70d3d75..b59b08a 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -4,4 +4,4 @@ if [ -n "$CI" ] || [ "$HUSKY" = "0" ] || [ -n "$HUSKY_SKIP_HOOKS" ]; then exit 0 fi -npm test && npm run build +npm run lint --fix && npm run format:check && npm test && npm run build diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..f8b0a39 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 100, + "singleQuote": true, + "semi": true, + "trailingComma": "es5", + "arrowParens": "always", + "tabWidth": 2 +} diff --git a/.releaserc.json b/.releaserc.json index 0400df1..29a946b 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,7 +1,5 @@ { - "branches": [ - "main" - ], + "branches": ["main"], "plugins": [ ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }], ["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }], diff --git a/AGENTS.md b/AGENTS.md index ff7d488..13d3554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Repository Guidelines ## Project Structure & Module Organization + - `src/` TypeScript source (ESM). Entry: `src/index.ts` → `main.ts`; shared helpers in `src/shared/`. - `bin/` CLI launcher (`openrouter`) that loads `dist/index.js`. - `dist/` build output (generated by `npm run build`). Do not edit. @@ -9,6 +10,7 @@ - Config files: global `~/.config/openrouter-cli/config.json`; project overrides `.openrouterrc(.json|.yaml|.yml)`. ## Build, Test, and Development Commands + - `npm run dev` — run CLI in TS directly (ts-node ESM). Example: `npm run dev -- ask "Hello"`. - `npm run build` — compile TypeScript to `dist/`. - `npm test` / `npm run test:watch` — run Vitest once / in watch mode. @@ -17,6 +19,7 @@ - After build: `openrouter --help` (via `bin/openrouter`) or `node dist/index.js`. ## Coding Style & Naming Conventions + - TypeScript, ESM, 2-space indentation, single quotes allowed; prefer explicit return types for exported functions. - File names: lowercase (e.g., `main.ts`, `repl.ts`, `shared/openrouter.ts`). - ESM import paths in TS include `.js` (e.g., `import { x } from './main.js'`). @@ -24,14 +27,17 @@ - Linting via flat-config ESLint (`eslint.config.js`); pre-commit runs `lint-staged`. ## Testing Guidelines + - Framework: Vitest. Place tests in `tests/*.spec.ts` with clear, isolated cases. - Cover config precedence, URL joining, and CLI option parsing. Example: see `tests/openrouter.spec.ts` for `joinUrl`. - Run `npm test` locally; keep tests deterministic (no network). Mock I/O when needed. ## Commit & Pull Request Guidelines + - Follow Conventional Commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`; use scopes when helpful (e.g., `feat(config): ...`). Use `npm run commit`. - PRs: concise description, linked issues, CLI examples (commands/output), and updated docs when behavior changes. Ensure `lint`, `test`, and `build` pass. ## Security & Configuration Tips + - Prefer `OPENROUTER_API_KEY`/`OPENAI_API_KEY`; avoid committing secrets. Use `openrouter config --api-key` only if persistence is required. - Example: `openrouter config --model meta-llama/llama-3.1-8b-instruct --domain https://openrouter.ai/api/v1`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fbe4d..f393012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,15 @@ ## 1.0.0-beta.1 (2025-09-07) - ### ⚠ BREAKING CHANGES -* **story/iteration-one:** new features +- **story/iteration-one:** new features ### Features -* add initial project structure and configuration files ([03c77bd](https://github.com/jwill9999/openrouter-cli/commit/03c77bdb9eef1a5daa496383e6708625419fc6b2)) -* **story/iteration-one:** add husky, github workflows, releases and tags, npm release ([147b78c](https://github.com/jwill9999/openrouter-cli/commit/147b78cee9d56fd1d6cfe33e382db215e7b7e760)) -* **story/iteration-one:** fix release workflow ([ac42562](https://github.com/jwill9999/openrouter-cli/commit/ac42562cf525ae42e5804b25a080bf5bc877dfa0)) - +- add initial project structure and configuration files ([03c77bd](https://github.com/jwill9999/openrouter-cli/commit/03c77bdb9eef1a5daa496383e6708625419fc6b2)) +- **story/iteration-one:** add husky, github workflows, releases and tags, npm release ([147b78c](https://github.com/jwill9999/openrouter-cli/commit/147b78cee9d56fd1d6cfe33e382db215e7b7e760)) +- **story/iteration-one:** fix release workflow ([ac42562](https://github.com/jwill9999/openrouter-cli/commit/ac42562cf525ae42e5804b25a080bf5bc877dfa0)) ### Bug Fixes -* **story/iteration-one:** Fix failing release ([496821b](https://github.com/jwill9999/openrouter-cli/commit/496821b94161f14f717b791dfd686f0cbe798479)) +- **story/iteration-one:** Fix failing release ([496821b](https://github.com/jwill9999/openrouter-cli/commit/496821b94161f14f717b791dfd686f0cbe798479)) diff --git a/README.md b/README.md index 8cee9d4..6a235b0 100644 --- a/README.md +++ b/README.md @@ -3,49 +3,97 @@ [![npm latest](https://img.shields.io/npm/v/@letuscode/openrouter-cli)](https://www.npmjs.com/package/@letuscode/openrouter-cli) [![npm beta](https://img.shields.io/npm/v/@letuscode/openrouter-cli/beta)](https://www.npmjs.com/package/@letuscode/openrouter-cli?activeTab=versions) -OpenAI‑compatible CLI for OpenRouter. Ask questions, run a REPL, and manage per‑project or global settings. +OpenAI‑compatible CLI for OpenRouter. Ask questions, chat in a REPL, and fuzzy‑search models. -Requirements -- Node.js 18.17+ (ESM) +You can change your model any time. In a terminal, run `openrouter models` to browse, or in the REPL type `/model` to search inline. Tip: search for `free` to see free models. + +## Install -Install - Global: `npm i -g @letuscode/openrouter-cli` - One‑off: `npx @letuscode/openrouter-cli --help` -Quick start -1) Run the wizard: `openrouter init` (select provider, set domain/model, and add an API key) -2) Ask something: `openrouter ask --no-stream "Hello!"` -3) Chat interactively: `openrouter repl` - -Core commands -- `openrouter init` — interactive setup (provider, domain, key, model, profile) -- `openrouter config` — view config or set API key - - Examples: - - `openrouter config --list` - - `openrouter config --api-key sk-...` (stores in base config) - - `openrouter config --profile dev --api-key sk-...` (stores in profile) -- `openrouter test` — verify connectivity (`/models`) - - `openrouter test [--profile dev] [--no-init]` -- `openrouter ask` — one‑shot prompt - - `openrouter ask "your question" [-s SYSTEM] [--format auto|plain|md] [--profile NAME] [--no-stream] [--no-init]` +Tip: Running `openrouter` with no args starts the setup wizard and then opens the REPL (in a terminal). + +### Requirements + +- Node.js 18.17+ (ESM) + +## Quick start + +1. Create an API key: https://openrouter.ai/keys +2. Run setup: `openrouter` (or `openrouter init`) — enter your key if asked, then pick a model +3. Ask once: `openrouter ask "Hello!"` — formatted answer by default +4. Chat: `openrouter repl` — formatted replies; toggle streaming when you like + +## Everyday commands + +- `openrouter` (or `openrouter init`) — setup; uses the OpenRouter domain automatically; asks for a key only if missing; lets you pick a model; opens the REPL afterwards +- `openrouter ask "…"` — answer a single question (formatted by default) - `openrouter repl` — interactive chat - - REPL commands: `exit`, `/model `, `/system `, `/format md|plain`, `/stream on|off` + - In the REPL: + - `/model` → inline search; type a few letters, pick a match + - `/model ` → set a specific model + - `/format md|plain` → formatted or plain replies (non‑stream) + - `/stream on|off` → stream tokens or wait for a full reply + - `exit` → quit +- `openrouter models [query]` — browse models (fuzzy search) in a terminal; prints a table in non‑TTY +- `openrouter config --list` — show current settings (keys are masked) +- `openrouter config --api-key sk-…` — set your key once (or use the env var below) + +## Behavior & defaults + +- Ask: non‑stream + markdown rendering by default. Add `--stream` to stream tokens. +- REPL: streaming OFF by default; markdown rendering for full replies; inline `/model` search. -Configuration -- API key via env (recommended): `export OPENROUTER_API_KEY=...` (or `OPENAI_API_KEY`) -- Global config file: `~/.config/openrouter-cli/config.json` (chmod 600 where possible; keys never logged) +## Configuration + +- API key via env (recommended): `export OPENROUTER_API_KEY=…` (or `OPENAI_API_KEY`) +- Global config file: `~/.config/openrouter-cli/config.json` (private; keys never logged) - Project overrides: add `.openrouterrc` (JSON or YAML) in your project root - Example `.openrouterrc` (JSON): { - "domain": "http://localhost:11434/v1", - "model": "gemma2:9b-instruct" + "domain": "https://openrouter.ai/api/v1", + "model": "openrouter/auto" } -- Changing default provider, domain, or model: re‑run `openrouter init` (this is the only way to update these defaults). -- More details: see `docs/CONFIGURATION.md`. +- Domain: fixed to the OpenRouter domain today (no prompt); kept in config for future provider choices +- Change your default model any time by running `openrouter` again +- Precedence: project rc > profile > global; env keys override persisted keys + +## Model search + +- `openrouter models` opens an interactive search in a terminal (type 2–3 letters) +- `openrouter models llama` starts with “llama” suggestions; prints a table in non‑TTY + +### Example: inline model search in REPL + +```text +(openai/gpt-oss-20b:free) > /model +Search models (>=2 chars, blank to cancel): free +Matches: +1. openai/gpt-oss-120b:free — OpenAI: gpt-oss-120b (free) +2. openai/gpt-oss-20b:free — OpenAI: gpt-oss-20b (free) +… +Pick 1-10 or type a model id: +``` + +## Output & accessibility + +- Non‑stream answers render markdown (bold/italic, headings, lists, inline code). Streaming prints raw tokens for responsiveness. +- A “Thinking” spinner shows while waiting; colors/spinners honor `NO_COLOR` and TTY detection. + +## Troubleshooting + +- Missing API key: set `OPENROUTER_API_KEY` or run `openrouter` again. View current config: `openrouter config --list`. +- “Policy / free endpoints” error: open https://openrouter.ai/settings/privacy and enable free endpoints, or choose a different model (`openrouter models`). +- Picker shows a table: run in a terminal (TTY). Check: `node -p "process.stdout.isTTY && process.stdin.isTTY"`. +- Friendly errors are shown; details are logged to `~/.config/openrouter-cli/cli.log`. + +## Advanced flags (optional) + +- Ask: `--stream`, `--format auto|plain|md`, `-s, --system `, `--profile `, `--no-init` +- Models: `--non-interactive` +- Config (debug): `--danger-reset`, `--override-json ''` -Troubleshooting -- “Missing API key”: set `OPENROUTER_API_KEY` or run `openrouter init` (or `openrouter config --api-key sk-...`). -- Non‑TTY/CI: pass `--no-init` to skip interactive prompts. +## License -License - MIT diff --git a/bin/openrouter b/bin/openrouter index 883d8e1..b1d5c53 100755 --- a/bin/openrouter +++ b/bin/openrouter @@ -1,3 +1,2 @@ #!/usr/bin/env node import('../dist/index.js'); - diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 8d7637c..f545120 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -4,6 +4,6 @@ module.exports = { 'subject-case': [0], // Allow long auto-generated release notes from semantic-release 'body-max-line-length': [0], - 'footer-max-line-length': [0] + 'footer-max-line-length': [0], }, }; diff --git a/docs/CODE_REVIEW.md b/docs/CODE_REVIEW.md index 6c0de73..886e3fc 100644 --- a/docs/CODE_REVIEW.md +++ b/docs/CODE_REVIEW.md @@ -1,124 +1,83 @@ - Yes — Iteration 1 is complete. Here’s a focused code review with prioritized improvements based on best practices - and SOLID principles. - - Top Priority (Correctness + UX) - - - REPL conversation state - - Issue: REPL streams assistant output to stdout but does not add assistant messages to history; subsequent - turns only include prior user messages. - - Fix: Capture streamed text (aggregate deltas) and append as an assistant message to history after each - turn. - - Side benefit: Enables proper multi-turn context. - - Side benefit: Enables proper multi-turn context. - - - HTTP robustness - - Add request timeouts via AbortController to avoid hanging calls. - - Normalize network errors and surface actionable messages (timeout, 401/403, 429 with retry-after). - - Respect HTTP_PROXY/HTTPS_PROXY/NO_PROXY and allow --timeout flag. - - - CLI ergonomics - - ask should accept stdin when prompt arg is omitted: echo "hi" | openrouter ask. - - Add --verbose for debug info (domain/model, request id if available). - - Improve test output (summarize model count, maybe first 3 model IDs). - - Security + Config - - - Secrets handling - - Current: never logs API keys (good), redact in config (good). - - Improve: redact any error payload that might echo credentials (defense-in-depth). - - Option: add --no-store on config --api-key to avoid writing key to disk (env only). - - Option: add --no-store on config --api-key to avoid writing key to disk (env only). - - - Config precedence - - Document and enforce: CLI flags > env > global config (current behavior aligns, document it explicitly). - - Add openrouter config --unset to remove persisted values. - - Validate --domain (must be http/https). - - - File permissions - - You set 600/700 best-effort (good). On non-POSIX systems, log a warning if chmod fails. - - Structure + SOLID - - - Single responsibility - - Split src/main.ts subcommands into files: commands/config.ts, commands/test.ts, commands/ask.ts, commands/ - repl.ts to isolate logic and keep main.ts as composition root. - - Extract an SseStreamer utility for streaming parsing (reusable in REPL and ask). - - Extract an SseStreamer utility for streaming parsing (reusable in REPL and ask). - - - Abstractions - - Introduce a ChatClient class with clear interface: chat(messages, opts), stream(messages, onDelta, opts). +Yes — Iteration 1 is complete. Here’s a focused code review with prioritized improvements based on best practices +and SOLID principles. + +Top Priority (Correctness + UX) + +- REPL conversation state - Issue: REPL streams assistant output to stdout but does not add assistant messages to history; subsequent + turns only include prior user messages. - Fix: Capture streamed text (aggregate deltas) and append as an assistant message to history after each + turn. - Side benefit: Enables proper multi-turn context. - Side benefit: Enables proper multi-turn context. +- HTTP robustness - Add request timeouts via AbortController to avoid hanging calls. - Normalize network errors and surface actionable messages (timeout, 401/403, 429 with retry-after). - Respect HTTP_PROXY/HTTPS_PROXY/NO_PROXY and allow --timeout flag. +- CLI ergonomics - ask should accept stdin when prompt arg is omitted: echo "hi" | openrouter ask. - Add --verbose for debug info (domain/model, request id if available). - Improve test output (summarize model count, maybe first 3 model IDs). + +Security + Config + +- Secrets handling + - Current: never logs API keys (good), redact in config (good). + - Improve: redact any error payload that might echo credentials (defense-in-depth). + - Option: add --no-store on config --api-key to avoid writing key to disk (env only). + - Option: add --no-store on config --api-key to avoid writing key to disk (env only). +- Config precedence - Document and enforce: CLI flags > env > global config (current behavior aligns, document it explicitly). - Add openrouter config --unset to remove persisted values. - Validate --domain (must be http/https). +- File permissions - You set 600/700 best-effort (good). On non-POSIX systems, log a warning if chmod fails. + +Structure + SOLID + +- Single responsibility - Split src/main.ts subcommands into files: commands/config.ts, commands/test.ts, commands/ask.ts, commands/ + repl.ts to isolate logic and keep main.ts as composition root. - Extract an SseStreamer utility for streaming parsing (reusable in REPL and ask). - Extract an SseStreamer utility for streaming parsing (reusable in REPL and ask). +- Abstractions - Introduce a ChatClient class with clear interface: chat(messages, opts), stream(messages, onDelta, opts). This aids testing and swapping backends later (OpenAI/Ollama/Anthropic). - - - Dependency inversion - - Pass fetch (and logger) as injectable dependencies where feasible to ease testing/mocking. - - Networking + Streaming - - - SSE parsing - - Current manual buffer parsing works but can be brittle. Consider eventsource-parser for resilient SSE - handling (when you’re ready to add deps). - - Ensure you handle multi-line data events; guard against partial frames across chunks. - - Ensure you handle multi-line data events; guard against partial frames across chunks. - - - Headers - - Optionally include X-Title and HTTP-Referer headers for OpenRouter’s dashboards if desired (non-breaking). - - Type Safety + TS Hygiene - - - Types - - Define and export explicit types for API responses (minimal shapes used), e.g., ChatCompletionChunk, - ModelList. - - Enable noUncheckedIndexedAccess (optional) to catch missing choices?.[0] workflows. - - Enable noUncheckedIndexedAccess (optional) to catch missing choices?.[0] workflows. - - - Lint/Format - - You’ve set ESLint flat config with TS + globals (good). Consider adding Prettier or eslint-plugin-format +- Dependency inversion - Pass fetch (and logger) as injectable dependencies where feasible to ease testing/mocking. + +Networking + Streaming + +- SSE parsing - Current manual buffer parsing works but can be brittle. Consider eventsource-parser for resilient SSE + handling (when you’re ready to add deps). - Ensure you handle multi-line data events; guard against partial frames across chunks. - Ensure you handle multi-line data events; guard against partial frames across chunks. +- Headers - Optionally include X-Title and HTTP-Referer headers for OpenRouter’s dashboards if desired (non-breaking). + +Type Safety + TS Hygiene + +- Types - Define and export explicit types for API responses (minimal shapes used), e.g., ChatCompletionChunk, + ModelList. - Enable noUncheckedIndexedAccess (optional) to catch missing choices?.[0] workflows. - Enable noUncheckedIndexedAccess (optional) to catch missing choices?.[0] workflows. +- Lint/Format - You’ve set ESLint flat config with TS + globals (good). Consider adding Prettier or eslint-plugin-format later for consistent formatting. - Testing +Testing - - Unit tests to add (high value, low effort) - - joinUrl(base, path) — path preservation (e.g., /api/v1 + chat/completions). - - Config I/O: read/write, redaction, chmod best-effort no-throw. - - Env precedence: env > config; masking behavior. - - Streaming parser: simulate SSE frames (with and without chunk boundaries). - - E2E smoke (later) - - CLI test mocked via Nock or MSW Node to avoid network dependency. - - ask --no-stream pipeline with mocked responses. +- Unit tests to add (high value, low effort) + - joinUrl(base, path) — path preservation (e.g., /api/v1 + chat/completions). + - Config I/O: read/write, redaction, chmod best-effort no-throw. + - Env precedence: env > config; masking behavior. + - Streaming parser: simulate SSE frames (with and without chunk boundaries). +- E2E smoke (later) + - CLI test mocked via Nock or MSW Node to avoid network dependency. + - ask --no-stream pipeline with mocked responses. - CI/CD +CI/CD - - CI install step - - In release.yml, replace npm ci with npm install --no-audit --no-fund (no lockfile checked in). You already - use npm install elsewhere; make it consistent to avoid failures. - - In release.yml, replace npm ci with npm install --no-audit --no-fund (no lockfile checked in). You already +- CI install step - In release.yml, replace npm ci with npm install --no-audit --no-fund (no lockfile checked in). You already + use npm install elsewhere; make it consistent to avoid failures. - In release.yml, replace npm ci with npm install --no-audit --no-fund (no lockfile checked in). You already use npm install elsewhere; make it consistent to avoid failures. - - - Beta guarding (optional) - - Current beta publishes on every push (now safe-guarded from duplicates). Consider gating publish to only +- Beta guarding (optional) - Current beta publishes on every push (now safe-guarded from duplicates). Consider gating publish to only run when commits include feat/fix/perf or a label like publish-beta. - - - Provenance - - You’ve set id-token and provenance (good). Add NPM_CONFIG_PROVENANCE=true in the release step env if you +- Provenance - You’ve set id-token and provenance (good). Add NPM_CONFIG_PROVENANCE=true in the release step env if you want to ensure provenance for stable too. - Docs +Docs - - README - - Document precedence order clearly. - - Add stdin usage example for ask. - - Add REPL commands table and mention multi-turn context. +- README + - Document precedence order clearly. + - Add stdin usage example for ask. + - Add REPL commands table and mention multi-turn context. - Package metadata +Package metadata - - Author field is empty; consider filling author and contributors for clarity. - - Consider preferGlobal: true to hint it’s a CLI. +- Author field is empty; consider filling author and contributors for clarity. +- Consider preferGlobal: true to hint it’s a CLI. - Misc UX +Misc UX - - openrouter config default behavior shows current config (good). Add --json (explicit) and a terser human- +- openrouter config default behavior shows current config (good). Add --json (explicit) and a terser human- readable output by default (optional). - - repl command: add /help to list commands; echo current model/system on start. +- repl command: add /help to list commands; echo current model/system on start. - If you want, I can implement a first pass of the critical fixes (REPL history capture, stdin for ask, timeout - support, CI npm install change) in a PR-sized patch and add a couple of unit tests for joinUrl and config I/O. \ No newline at end of file +If you want, I can implement a first pass of the critical fixes (REPL history capture, stdin for ask, timeout +support, CI npm install change) in a PR-sized patch and add a couple of unit tests for joinUrl and config I/O. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e4a769d..1253b20 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,18 +6,20 @@ This guide explains how configuration works in the CLI, how to set API keys secu Configuration comes from multiple sources. At runtime, values are resolved using this precedence (highest → lowest): -1) CLI flags (e.g., `--profile`, `-m/--model`, `--domain`) -2) Environment variables (API key: `OPENROUTER_API_KEY` or `OPENAI_API_KEY`) -3) Project `.openrouterrc` (JSON or YAML in your project root) -4) Global profile (`profiles.` in `~/.config/openrouter-cli/config.json`) -5) Global base config (`~/.config/openrouter-cli/config.json`) -6) Built‑in defaults +1. CLI flags (e.g., `--profile`, `-m/--model`, `--domain`) +2. Environment variables (API key: `OPENROUTER_API_KEY` or `OPENAI_API_KEY`) +3. Project `.openrouterrc` (JSON or YAML in your project root) +4. Global profile (`profiles.` in `~/.config/openrouter-cli/config.json`) +5. Global base config (`~/.config/openrouter-cli/config.json`) +6. Built‑in defaults Defaults: + - Domain: `https://openrouter.ai/api/v1` - Model: `meta-llama/llama-3.1-8b-instruct` Interactive onboarding: + - Run `openrouter init` to select a provider preset, set domain/model, and (optionally) persist an API key. Commands like `ask`, `test`, and `repl` auto‑prompt on missing keys in TTY unless `--no-init` is provided. ## Global Config @@ -25,6 +27,7 @@ Interactive onboarding: - Path: `~/.config/openrouter-cli/config.json` - Stores base values and optional `profiles` object. - Example: + ```json { "domain": "https://openrouter.ai/api/v1", @@ -37,6 +40,7 @@ Interactive onboarding: } } ``` + - Permissions: written with chmod 600 where possible. Keys are redacted when printed. ### Managing Global Config via CLI @@ -56,13 +60,16 @@ Interactive onboarding: Place a `.openrouterrc` in your project root to set project‑specific defaults. This file should not contain secrets. - JSON example (`./.openrouterrc` or `./.openrouterrc.json`): + ```json { "domain": "http://localhost:11434/v1", "model": "gemma2:9b-instruct" } ``` + - YAML example (`./.openrouterrc.yaml` or `.yml`): + ```yaml domain: http://localhost:11434/v1 model: gemma2:9b-instruct @@ -78,6 +85,7 @@ Use an environment variable for your API key. This is the safest approach and av - Also supported: `OPENAI_API_KEY` Examples: + - bash/zsh: - `export OPENROUTER_API_KEY=sk-...` (place in `~/.bashrc` or `~/.zshrc` to persist) - fish: @@ -88,6 +96,7 @@ Examples: CI/CD: store the key as a secret and inject it into the job environment. Auto‑init behavior + - In interactive terminals, `openrouter ask|test|repl` will trigger `openrouter init` if no API key is available. Pass `--no-init` to skip. ## CLI Flags (Per‑Command) @@ -96,12 +105,13 @@ Auto‑init behavior - `openrouter test --profile dev` - `openrouter ask --profile dev "Hello world"` - `openrouter repl --profile dev` - + Note: To change the default domain or model, re‑run `openrouter init`. Per‑invocation model overrides have been removed to keep usage simple. ## Putting It Together: Precedence Example resolution for `openrouter ask --profile dev "Hi"` in a project with `.openrouterrc`: + - CLI flags: profile=dev (wins over all below) - Env: API key from `OPENROUTER_API_KEY`, if set - Project `.openrouterrc`: overrides domain/model for this project diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ae635fd..8c92b2d 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -3,10 +3,12 @@ This project uses Conventional Commits and semantic‑release. Releases are automated from `main`; prereleases publish from feature branches. Basics + - Write descriptive, minimal PRs that include a brief summary, usage examples, and updated docs when behavior changes. - Follow Conventional Commits (e.g., `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...`). Use scopes when helpful: `feat(config): ...`. Local workflow + - Install deps: `npm install` - Lint: `npm run lint` - Test: `npm test` @@ -18,9 +20,11 @@ Local workflow - pre‑push: tests + build Tooling tips + - If tests fail in a restricted shell due to worker/thread sandboxing, run them in CI or a normal local shell (where Vitest can spawn workers). CI is the source of truth. - If `npm pack --dry-run` fails due to cache permissions, use the local cache script: `npm run pack:dry` (sets `--cache ./.npm-cache`). To fix your user cache permanently, run: `sudo chown -R $(id -u):$(id -g) ~/.npm`. CI/Release + - CI runs build, lint, and tests on Node 18.x and 20.x. - Release workflow uses semantic‑release with provenance enabled. diff --git a/docs/DECISION.md b/docs/DECISION.md index 042f880..55b387b 100644 --- a/docs/DECISION.md +++ b/docs/DECISION.md @@ -7,6 +7,7 @@ This document tracks major design choices, scope, and phased roadmap for the **o ## Iteration 1 (v0.1.x) ### Scope + - npm-first TypeScript CLI (alias: `openrouter`). - Global config at `~/.config/openrouter-cli/config.json` (no project-local rc yet). - Defaults: @@ -24,10 +25,12 @@ This document tracks major design choices, scope, and phased roadmap for the **o - One-shot `openrouter ask` command ### Security + - Never log API keys. - Config file chmod 600 where possible. ### Out of Scope (queued for later) + - Project-local `.openrouterrc` overrides. - Profiles (e.g. `--profile dev`). - Additional backends (Ollama, OpenAI, Anthropic) via `--domain` or `--backend`. @@ -36,17 +39,20 @@ This document tracks major design choices, scope, and phased roadmap for the **o - Packaging expansion (Homebrew, winget, PyPI). ### Rationale + - Optimize for frictionless `npm i -g` and `npx` demos for Node-first users. - Stay provider-agnostic via OpenAI-compatible API and configurable domain. - Ship streaming UX and exit semantics early for smooth demos. ### Next Checkpoints + - Wire skeleton code into repo and basic CI (build + lint + test). ✅ Completed - Release automation: adopt semantic-release with Conventional Commits. ✅ Completed - Release from `main` via CI (no manual tagging). ✅ Completed (shipped as `v1.0.0`) - Plan Iteration 2: `.openrouterrc` & profiles. ✅ Completed ### Acceptance (Completed as v1.0.0) + - All scope items delivered (CLI, config, defaults, `config`/`test`/`ask`/`repl` with streaming, API key handling). - Security constraints honored (no key logging; best‑effort chmod 600). - CI in place (build, lint, test) and production release automation configured. @@ -57,6 +63,7 @@ This document tracks major design choices, scope, and phased roadmap for the **o ## Extension Roadmap ### Phase 1 — Tool Plugins (Manual Mode) + - Local tool discovery: - `~/.config/openrouter-cli/tools.d/*.js` - `./.openrouter/tools/*.js` @@ -77,6 +84,7 @@ This document tracks major design choices, scope, and phased roadmap for the **o - Deliverable: starter tool `web.search` (DuckDuckGo, no key). ### Phase 2 — Tool Calling (Auto Mode) + - Expose tools as OpenAI-compatible `tools` (function-calling) in chat requests. - On `tool_call`, execute plugin and loop response as `tool` message. - Modes: `--tools off|manual|auto` (default manual). @@ -84,6 +92,7 @@ This document tracks major design choices, scope, and phased roadmap for the **o - Evidence logging (opt-in) for compliance. ### Phase 3 — MCP Client Integration + - Minimal MCP client (stdio first; WS/HTTP later). - Config: - `~/.config/openrouter-cli/mcp.d/*.json` @@ -101,6 +110,7 @@ This document tracks major design choices, scope, and phased roadmap for the **o --- ## Cross-Cutting Later Items + - `.openrouterrc` project overrides & profiles. - Keychain-backed secret storage (macOS/Win/gnome-keyring). - Enhanced packaging: @@ -110,8 +120,8 @@ This document tracks major design choices, scope, and phased roadmap for the **o --- - ### Release Process (v0.1.x) + - Releasing - Stable releases occur on `main` via semantic-release. Conventional Commits decide bump. - Git tags (`vX.Y.Z`) and GitHub Releases are created by CI; npm publish is automated. @@ -122,15 +132,16 @@ This document tracks major design choices, scope, and phased roadmap for the **o - commitlint + Conventional Commits enforced in PR and via local Husky hooks. - ESLint on staged files pre-commit; tests pre-push. - ## Iteration 2 (v0.2.0) — Status ### Scope + - Project-local `.openrouterrc` overrides (JSON and YAML). - Profiles (e.g. `--profile dev`) with per-profile overrides. - Init-driven defaults: provider/domain/model set via `openrouter init` (re-run to change). ### Design + - Config precedence (effective): - CLI flags (e.g., `--profile`, output/stream options) > env vars (API key) > project `.openrouterrc` (JSON/YAML) > global config > built-in defaults. - Files: @@ -141,21 +152,25 @@ This document tracks major design choices, scope, and phased roadmap for the **o - Global config can define `profiles` object; missing keys fall back to base config. ### CLI/UX + - `--profile` available on `ask`, `repl`, and `test`. - `init` is the only way to change provider/domain/model (interactive wizard; auto-prompts in TTY when key is missing unless `--no-init`). - `config` is narrowed to listing and API key persistence (base or profile); domain/model/provider flags removed. - Removed per-invocation model override (`ask -m`). ### Security + - Same key-handling guarantees; never log secrets. - Config file written with chmod 600 where possible; keys redacted in `--list` output. - Project file is not created automatically; only read if present. ### Tasks + - Implement loader for `.openrouterrc` (JSON/YAML) with merge logic and precedence. ✅ Completed - Add profile resolver with fallback. ✅ Completed - Update help/docs and examples (consumer-focused README, CONFIGURATION). ✅ Completed - Add tests: URL join; YAML rc precedence; config redaction; streaming SSE parsing; ask error handling; CLI help shape. ✅ Completed ### Out of Scope for v0.2 + - TOML formats, keychain storage, remote profiles, tool plugins/MCP phases. diff --git a/docs/TODO.md b/docs/TODO.md index 33e9f86..071ba6e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,6 +1,7 @@ # Next TODOs (Iteration Planning) ## Top Priority — CLI Visual Polish + - [ ] Discuss desired look/feel (Claude/Codex‑style): banner/logo, colors, spacing. - [ ] Pick libraries: `chalk` (colors), `boxen` (frames), `gradient-string` (optional), `ora` (spinners), `table`/`cli-table3` (tabular output). - [ ] Optional image/avatar in terminal (TTY only): evaluate `terminal-image` with fallback to ASCII art. @@ -8,6 +9,7 @@ - [ ] Accessibility: color‑safe palette, no-color fallback via `NO_COLOR`/TTY detection. ## Tests & Coverage + - [ ] Add coverage thresholds in Vitest (start: 70% lines/branches; raise gradually). - [ ] Add tests: - [ ] ask (non‑stream) markdown snapshot (ANSI stripped before assert). @@ -17,23 +19,28 @@ - [ ] Optional: CLI E2E smoke (pack → run `--help`) in CI artifact. ## Error Handling & Timeouts + - [ ] Add request timeout to fetch and surface timeouts clearly. - [ ] Normalize error output (network/DNS/401/429) with concise guidance. ## Init Flow Polish + - [ ] Improve prompts copy; add final summary/confirm before save. - [ ] Consider non‑interactive flags for automation: `init --provider --domain --model --api-key --profile`. - [ ] Keep domain/model changes only via `init` (docs reflect this). ## Docs + - [ ] README: add short, copy‑paste examples (ask md render, brief REPL transcript). - [ ] Consider a small GIF/screencast once UI polish lands. ## CI & Packaging + - [ ] Upload coverage (e.g., Codecov) [optional]. - [ ] Attach `npm pack --dry-run` tarball as CI artifact for review. ## Open Questions (to discuss) + - [ ] Keep `.openrouterrc` project overrides long‑term or simplify later? - [ ] Image/avatar in terminal: ship ASCII art only, or inline images when supported? - [ ] Non‑interactive `init` flags: needed now for CI/scripts? diff --git a/eslint.config.js b/eslint.config.js index df0ac51..ede9fcc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,59 +1,62 @@ // ESLint flat config -import js from "@eslint/js"; -import tseslint from "@typescript-eslint/eslint-plugin"; -import tsparser from "@typescript-eslint/parser"; -import globals from "globals"; +import js from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; +import globals from 'globals'; +import prettier from 'eslint-config-prettier'; export default [ - { ignores: ["dist/**", "node_modules/**"] }, + { ignores: ['dist/**', 'node_modules/**'] }, js.configs.recommended, { - files: ["**/*.js", "**/*.mjs", "**/*.cjs"], + files: ['**/*.js', '**/*.mjs', '**/*.cjs'], languageOptions: { ecmaVersion: 2022, - sourceType: "module", + sourceType: 'module', globals: { ...globals.node, ...globals.es2022, }, }, rules: { - "no-console": "off", - "no-undef": "off" - } + 'no-console': 'off', + 'no-undef': 'off', + }, }, { - files: ["**/*.ts"], + files: ['**/*.ts'], languageOptions: { ecmaVersion: 2022, - sourceType: "module", + sourceType: 'module', parser: tsparser, globals: { ...globals.node, ...globals.es2022, - fetch: "readonly", - Response: "readonly", - URL: "readonly", - ReadableStream: "readonly", - TextDecoder: "readonly" + fetch: 'readonly', + Response: 'readonly', + URL: 'readonly', + ReadableStream: 'readonly', + TextDecoder: 'readonly', }, }, plugins: { - "@typescript-eslint": tseslint, + '@typescript-eslint': tseslint, }, rules: { - "no-console": "off", - "no-empty": ["error", { "allowEmptyCatch": true }], + 'no-console': 'off', + '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", + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', { - "args": "after-used", - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - } - ] - } - } + args: 'after-used', + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + }, + }, + // Disable formatting-related ESLint rules; let Prettier handle formatting + prettier, ]; diff --git a/existing_structure.md b/existing_structure.md index d951726..3f88a39 100644 --- a/existing_structure.md +++ b/existing_structure.md @@ -63,6 +63,7 @@ src/index.ts ─▶ src/main.ts ─▶ commands - `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 @@ -107,4 +108,3 @@ Notes: - 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 c0f6780..db88b91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,9 +39,11 @@ "@vitest/coverage-v8": "^2.1.1", "commitizen": "^4.3.0", "eslint": "^9.35.0", + "eslint-config-prettier": "^9.1.0", "globals": "^15.9.0", "husky": "^9.1.6", "lint-staged": "^15.2.10", + "prettier": "^3.3.3", "semantic-release": "^23.1.1", "ts-node": "^10.9.2", "typescript": "^5.9.2", @@ -4810,6 +4812,19 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -10757,6 +10772,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", diff --git a/package.json b/package.json index d207d6e..f3433e3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "build": "tsc -p .", "dev": "node --loader ts-node/esm src/index.ts", "lint": "eslint \"src/**/*.{js,ts,mjs,cjs}\"", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run --reporter=default", "test:coverage": "vitest run --reporter=default --coverage", "test:watch": "vitest --watch", @@ -77,16 +79,22 @@ "@typescript-eslint/parser": "^8.42.0", "commitizen": "^4.3.0", "eslint": "^9.35.0", + "eslint-config-prettier": "^9.1.0", "globals": "^15.9.0", "husky": "^9.1.6", "lint-staged": "^15.2.10", + "prettier": "^3.3.3", "semantic-release": "^23.1.1", "ts-node": "^10.9.2", "typescript": "^5.9.2", "vitest": "^2.1.1" }, "lint-staged": { - "src/**/*.{ts,js,mjs,cjs}": "eslint --fix --max-warnings=0" + "src/**/*.{ts,js,mjs,cjs}": [ + "eslint --fix --max-warnings=0", + "prettier --write" + ], + "**/*.{json,md,yml,yaml}": "prettier --write" }, "config": { "commitizen": { diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs index d7e76ab..4d63467 100644 --- a/scripts/prepack.mjs +++ b/scripts/prepack.mjs @@ -12,4 +12,3 @@ async function main() { } main().catch(() => process.exit(0)); - diff --git a/src/commands/models.ts b/src/commands/models.ts index 5667971..47057db 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -2,7 +2,7 @@ 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 { fetchModelsCached, fuzzyIds } from '../shared/models.js'; import Table from 'cli-table3'; export function registerModelsCommand(program: Command) { @@ -16,7 +16,8 @@ export function registerModelsCommand(program: Command) { const domain = eff.domain || getDefaultConfig().domain; const apiKey = eff.apiKey; // do not print - const interactive = process.stdin.isTTY && process.stdout.isTTY && !opts.nonInteractive && !query; + // Default: interactive picker in TTY, even when a query is provided (query seeds initial results) + const interactive = process.stdin.isTTY && process.stdout.isTTY && !opts.nonInteractive; if (interactive) { try { console.log(banner()); @@ -28,7 +29,8 @@ export function registerModelsCommand(program: Command) { try { const list = await fetchModelsCached({ domain, apiKey }); modelsList = list; - initial = list.slice(0, 25).map(m => m.id); + const base = query ? fuzzyIds(query, list, 25) : list.slice(0, 25).map((m) => m.id); + initial = base.length ? base : initial; } catch {} const stableSuggest = async (input: string) => { const q = input || ''; @@ -36,7 +38,8 @@ export function registerModelsCommand(program: Command) { 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 toChoices = (ids: string[]) => + ids.map((id) => ({ name: id, value: id, message: id })); const ans = await promptFn({ type: 'autocomplete', name: 'model', @@ -62,7 +65,7 @@ export function registerModelsCommand(program: Command) { 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); + const meta = list.find((m) => m.id === id); table.push([id, meta?.name || '']); } console.log(table.toString()); diff --git a/src/index.ts b/src/index.ts index 6e46497..b79d2a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { main } from "./main.js"; -import { logError } from "./shared/logger.js"; +import { main } from './main.js'; +import { logError } from './shared/logger.js'; // Entrypoint main().catch(async (err) => { diff --git a/src/main.ts b/src/main.ts index 5abf275..e05e4a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,72 +1,132 @@ -import { Command } from "commander"; -import { readConfig, updateConfig, ensureConfigDir, updateProfile, resolveConfig } from "./shared/config.js"; -import type { CliConfig } from "./shared/config.js"; -import { getDefaultConfig, maskKey } from "./shared/env.js"; -import { testConnection, askOnce, ChatOptions, streamChat } from "./shared/openrouter.js"; -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"; +import { Command } from 'commander'; +import { + readConfig, + updateConfig, + ensureConfigDir, + updateProfile, + resolveConfig, + resetConfig, + overrideConfig, +} from './shared/config.js'; +import type { CliConfig } from './shared/config.js'; +import { getDefaultConfig, maskKey } from './shared/env.js'; +import { testConnection, askOnce, ChatOptions, streamChat } from './shared/openrouter.js'; +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 { isPolicyError, handlePolicyError } from './shared/errors.js'; +import { registerModelsCommand } from './commands/models.js'; export function buildProgram() { const program = new Command(); - program - .name("openrouter") - .description("OpenRouter CLI") - .version("0.1.0"); + program.name('openrouter').description('OpenRouter CLI').version('0.1.0'); + + // Attach styled help (banner + examples) + attachStyledHelp(program); // Attach styled help (banner + examples) attachStyledHelp(program); program - .command("config") - .description("Show configuration or update API key") - .option("--api-key ", "Persist API key (use env for ephemeral)") - .option("--profile ", "Select profile to read/update (default: base)") - .option("--list", "List profiles and current base config") - .action(async (opts: { apiKey?: string; profile?: string; list?: boolean }) => { - await ensureConfigDir(); - if (opts.list) { - const cfg = await readConfig(); - const redacted = JSON.parse(JSON.stringify(cfg)); - if (redacted.apiKey) redacted.apiKey = maskKey(redacted.apiKey); - if (redacted.profiles) { - for (const p of Object.keys(redacted.profiles)) { - if (redacted.profiles[p]?.apiKey) redacted.profiles[p]!.apiKey = maskKey(redacted.profiles[p]!.apiKey as string); + .command('config') + .description('Show configuration or update API key') + .option('--api-key ', 'Persist API key (use env for ephemeral)') + .option('--profile ', 'Select profile to read/update (default: base)') + .option('--list', 'List profiles and current base config') + .option('--danger-reset', 'Danger: delete global config (debug)') + .option('--override-json ', 'Danger: replace global config with JSON (debug)') + .action( + async (opts: { + apiKey?: string; + profile?: string; + list?: boolean; + dangerReset?: boolean; + overrideJson?: string; + }) => { + await ensureConfigDir(); + if (opts.dangerReset) { + await resetConfig(); + } + if (opts.overrideJson) { + try { + const parsed = JSON.parse(opts.overrideJson) as CliConfig; + await overrideConfig(parsed); + } catch { + console.error('Invalid JSON for --override-json'); + process.exitCode = 2; + return; } } - console.log(JSON.stringify(redacted, null, 2)); - return; - } + if (opts.list) { + const cfg = await readConfig(); + const redacted = JSON.parse(JSON.stringify(cfg)); + if (redacted.apiKey) redacted.apiKey = maskKey(redacted.apiKey); + if (redacted.profiles) { + for (const p of Object.keys(redacted.profiles)) { + if (redacted.profiles[p]?.apiKey) + redacted.profiles[p]!.apiKey = maskKey(redacted.profiles[p]!.apiKey as string); + } + } + console.log(JSON.stringify(redacted, null, 2)); + return; + } - const changes: Partial = {}; - // Only support updating API key here; domain/model are managed via `init` - if (opts.apiKey) changes.apiKey = opts.apiKey; // never log this + const changes: Partial = {}; + // Only support updating API key here; domain/model are managed via `init` + if (opts.apiKey) changes.apiKey = opts.apiKey; // never log this - if (Object.keys(changes).length > 0) { - if (opts.profile) { - await updateProfile(opts.profile, changes); - } else { - await updateConfig(changes); + if (Object.keys(changes).length > 0) { + if (opts.profile) { + await updateProfile(opts.profile, changes); + } else { + await updateConfig(changes); + } } - } - const cfg = await readConfig(); - const redacted = { ...cfg, apiKey: cfg.apiKey ? maskKey(cfg.apiKey) : undefined }; - console.log(JSON.stringify(redacted, null, 2)); - }); + const cfg = await readConfig(); + const redacted = { ...cfg, apiKey: cfg.apiKey ? maskKey(cfg.apiKey) : undefined }; + console.log(JSON.stringify(redacted, null, 2)); + + // If running in a TTY, drop into REPL with effective defaults + if (process.stdout.isTTY) { + try { + const eff = await resolveConfig(opts.profile); + const { ensureApiKey } = await import('./shared/auth.js'); + const r = await ensureApiKey(opts.profile, true); + if (!('ok' in r) || !r.ok) { + console.error(r.message); + process.exitCode = 2; + return; + } + const apiKey = r.apiKey; + await startRepl({ + apiKey, + domain: eff.domain ?? getDefaultConfig().domain, + initialModel: eff.model ?? getDefaultConfig().model, + }); + } catch (error) { + console.error('Failed to start REPL:', error); + process.exitCode = 1; + } + } + } + ); program - .command("test") - .description("Check API connectivity via /models") - .option("--profile ", "Use a named profile") - .option("--no-init", "Do not run interactive init when missing API key") + .command('test') + .description('Check API connectivity via /models') + .option('--profile ', 'Use a named profile') + .option('--no-init', 'Do not run interactive init when missing API key') .action(async (opts: { profile?: string; init?: boolean }) => { const cfg = await resolveConfig(opts.profile); const { ensureApiKey } = await import('./shared/auth.js'); const r = await ensureApiKey(opts.profile, opts.init); - if (!('ok' in r) || !r.ok) { console.error(r.message); process.exitCode = 2; return; } + if (!('ok' in r) || !r.ok) { + console.error(r.message); + process.exitCode = 2; + return; + } const apiKey = r.apiKey; const domain = cfg.domain ?? getDefaultConfig().domain; const res = await testConnection({ domain, apiKey }); @@ -74,23 +134,37 @@ export function buildProgram() { }); program - .command("ask") + .command('ask') .description("One-shot question. Use 'openrouter init' to change defaults.") - .argument("", "User prompt") - .option("-s, --system ", "System prompt") - .option("--format ", "Output format: auto|plain|md (default: auto)") - .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 function (this: import('commander').Command, prompt: string, options: { system?: string; stream?: boolean; profile?: string; format?: OutputFormat; init?: boolean }) { + .argument('', 'User prompt') + .option('-s, --system ', 'System prompt') + .option('--format ', 'Output format: auto|plain|md (default: auto)') + .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 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); - if (!('ok' in r) || !r.ok) { console.error(r.message); process.exitCode = 2; return; } + if (!('ok' in r) || !r.ok) { + console.error(r.message); + process.exitCode = 2; + return; + } const apiKey = r.apiKey; const model = eff.model || getDefaultConfig().model; const domain = eff.domain || getDefaultConfig().domain; - const format: OutputFormat = (options.format as OutputFormat) || "md"; + 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'; @@ -106,25 +180,68 @@ export function buildProgram() { 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"); + 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'); + } catch (err) { + if (isPolicyError(err)) { + await handlePolicyError({ where: 'ask-stream', tty: !!process.stdout.isTTY }); + return; + } + throw err; } finally { - if (!stopped) { spinner.stop(); } + if (!stopped) { + spinner.stop(); + } } } else { const spinner = showSpinner('Thinking…'); try { spinner.start(); - const out = await askOnce(chatOptions, [{ role: "user", content: prompt }]); - const pretty = renderText(out, { format, streaming: false }); + const result = await askOnce(chatOptions, [{ role: 'user', content: prompt }]); + const pretty = renderText(result.text, { 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"); + process.stdout.write(answerHeader(model) + '\n'); + process.stdout.write(pretty + '\n'); + + // Display usage information if available + if (result.usage) { + const usage = result.usage; + const tokens = usage.total_tokens || usage.completion_tokens + usage.prompt_tokens; + const cost = usage.total_cost || usage.cost; + let usageInfo = `\nTokens: ${tokens}`; + if (usage.prompt_tokens && usage.completion_tokens) { + usageInfo += ` (prompt: ${usage.prompt_tokens}, completion: ${usage.completion_tokens})`; + } + if (cost) { + usageInfo += ` | Cost: $${cost.toFixed(6)}`; + } + process.stdout.write(usageInfo + '\n'); + } + + process.stdout.write(infoFooter({ model, domain }) + '\n'); + } catch (err) { + if (isPolicyError(err)) { + await handlePolicyError({ where: 'ask', tty: !!process.stdout.isTTY }); + return; + } + throw err; } finally { spinner.stop(); } @@ -132,16 +249,22 @@ export function buildProgram() { }); program - .command("repl") - .description("Interactive chat with streaming. Commands: exit, /model, /system, /format, /stream") - .option("-m, --model ", "Override model for this session") - .option("--profile ", "Use a named profile") - .option("--no-init", "Do not run interactive init when missing API key") + .command('repl') + .description( + 'Interactive chat with streaming. Commands: exit, /model, /system, /format, /stream' + ) + .option('-m, --model ', 'Override model for this session') + .option('--profile ', 'Use a named profile') + .option('--no-init', 'Do not run interactive init when missing API key') .action(async (options: { model?: string; profile?: string; init?: boolean }) => { const eff = await resolveConfig(options.profile); const { ensureApiKey } = await import('./shared/auth.js'); const r = await ensureApiKey(options.profile, options.init); - if (!('ok' in r) || !r.ok) { console.error(r.message); process.exitCode = 2; return; } + if (!('ok' in r) || !r.ok) { + console.error(r.message); + process.exitCode = 2; + return; + } const apiKey = r.apiKey; await startRepl({ apiKey, @@ -151,10 +274,32 @@ export function buildProgram() { }); program - .command("init") - .description("Interactive first-time setup (provider, domain, key, model, profile)") + .command('init') + .description('Interactive first-time setup (provider, domain, key, model, profile)') .action(async () => { await runInitWizard(); + // After init, drop into REPL with effective defaults when in a TTY + if (process.stdout.isTTY) { + try { + const eff = await resolveConfig(); + const { ensureApiKey } = await import('./shared/auth.js'); + const r = await ensureApiKey(undefined, false); // avoid re-entering init from here + if (!('ok' in r) || !r.ok) { + console.error(r.message); + process.exitCode = 2; + return; + } + const apiKey = r.apiKey; + await startRepl({ + apiKey, + domain: eff.domain ?? getDefaultConfig().domain, + initialModel: eff.model ?? getDefaultConfig().model, + }); + } catch (error) { + console.error('Failed to start REPL after init:', error); + process.exitCode = 1; + } + } }); // Additional commands @@ -165,5 +310,10 @@ export function buildProgram() { export async function main() { const program = buildProgram(); + if (process.argv.length <= 2) { + // No args: launch init flow by default + await program.parseAsync(['node', 'openrouter', 'init']); + return; + } await program.parseAsync(process.argv); } diff --git a/src/repl.ts b/src/repl.ts index da1b053..2e22ca3 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -1,9 +1,11 @@ -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"; +import readline from 'node:readline'; +import { streamChat, askOnce, getCredits } 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'; +// dynamic imports used when needed to avoid readline conflicts +import { fetchModelsCached, fuzzyIds } from './shared/models.js'; type ReplOptions = { apiKey: string; @@ -14,108 +16,305 @@ type ReplOptions = { export async function startRepl(opts: ReplOptions) { let currentModel = opts.initialModel; let system: string | undefined; - let format: OutputFormat = "md"; // default rendered markdown for non-stream outputs + let format: OutputFormat = 'md'; // default rendered markdown for non-stream outputs let streaming = false; - const history: { role: "user" | "assistant"; content: string }[] = []; + const history: { role: 'user' | 'assistant'; content: string }[] = []; + let shouldExit = false; - 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(); + // Session tracking + let sessionTokens = 0; + let sessionCost = 0; + let sessionRequests = 0; - console.log(tipBox()); + async function promptUser(): Promise { + if (shouldExit) return; - // Keep REPL alive on Ctrl+C (SIGINT); show prompt again - rl.on('SIGINT', () => { - process.stdout.write("\n"); - rl.prompt(); - }); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + prompt: '', + historySize: 100, + escapeCodeTimeout: 50, + removeHistoryDuplicates: true, + }); - rl.on("line", async (line) => { - const input = line.trim(); - if (!input) { - rl.prompt(); - return; - } - if (input === "exit") { - rl.close(); - return; - } - if (input.startsWith("/model ")) { - currentModel = input.slice("/model ".length).trim(); - prompt(); - rl.prompt(); - return; - } - if (input.startsWith("/system ")) { - system = input.slice("/system ".length).trim(); - console.log("[system set]"); - rl.prompt(); - return; + const prompt = () => rl.setPrompt(styledPrompt(currentModel)); + prompt(); + + if (history.length === 0) { + console.log(tipBox()); } - if (input.startsWith("/format ")) { - const val = input.slice("/format ".length).trim(); - if (val === "md" || val === "plain") { - format = val; - console.log(`[format: ${format}]`); - } else { - console.log("Usage: /format md|plain"); + + rl.on('line', async (line) => { + const input = line.trim(); + + if (!input) { + rl.prompt(); + return; + } + if (input === 'exit') { + shouldExit = true; + rl.close(); + return; } - rl.prompt(); - return; - } - if (input.startsWith("/stream ")) { - const val = input.slice("/stream ".length).trim(); - if (val === "on") streaming = true; - else if (val === "off") streaming = false; - else console.log("Usage: /stream on|off"); - console.log(`[stream: ${streaming ? "on" : "off"}]`); - rl.prompt(); - return; - } - const userMsg = { role: "user" as const, content: input }; - history.push(userMsg); - try { - if (streaming) { - const spinner = showSpinner('Thinking'); - let stopped = false; + // Close the readline interface after getting input + rl.close(); + if (input === '/model') { try { + const spinner = showSpinner('Loading models…'); 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(); } + const list = await fetchModelsCached({ domain: opts.domain, apiKey: opts.apiKey }); + spinner.stop(); + + // Create a new readline interface for model selection + const modelRl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + + const askModel = (q: string) => + new Promise((res) => modelRl.question(q, (ans) => res(ans.trim()))); + + const query = await askModel('Search models (>=2 chars, blank to cancel): '); + if (!query) { + modelRl.close(); + await promptUser(); + return; + } + + const ids = fuzzyIds(query, list, 10); + if (!ids.length) { + console.log('No matches.'); + modelRl.close(); + await promptUser(); + return; + } + + console.log('Matches:'); + ids.forEach((id, i) => { + const meta = list.find((m) => m.id === id); + const name = meta?.name ? ` — ${meta.name}` : ''; + console.log(`${i + 1}. ${id}${name}`); + }); + + const sel = await askModel(`Pick 1-${ids.length} or type a model id: `); + let chosen = ''; + const n = Number(sel); + if (sel && Number.isInteger(n) && n >= 1 && n <= ids.length) { + chosen = ids[n - 1]; + } else if (sel) { + chosen = sel; + } + + if (chosen) { + currentModel = chosen; + console.log(`[model: ${currentModel}]`); + } + + modelRl.close(); + } catch (e) { + await logError(e, 'repl-model-picker'); + console.log("Tip: run 'openrouter models' in another terminal to browse models."); } - } else { - const spinner = showSpinner('Thinking…'); + + // Ensure clean terminal state before restarting + process.stdout.write('\n'); + await promptUser(); + return; + } + if (input.startsWith('/model ')) { + currentModel = input.slice('/model '.length).trim(); + console.log(`[model: ${currentModel}]`); + await promptUser(); + return; + } + if (input.startsWith('/system ')) { + system = input.slice('/system '.length).trim(); + console.log('[system set]'); + await promptUser(); + return; + } + if (input.startsWith('/format ')) { + const val = input.slice('/format '.length).trim(); + if (val === 'md' || val === 'plain') { + format = val; + console.log(`[format: ${format}]`); + } else { + console.log('Usage: /format md|plain'); + } + await promptUser(); + return; + } + if (input.startsWith('/stream ')) { + const val = input.slice('/stream '.length).trim(); + if (val === 'on') streaming = true; + else if (val === 'off') streaming = false; + else console.log('Usage: /stream on|off'); + console.log(`[stream: ${streaming ? 'on' : 'off'}]`); + await promptUser(); + return; + } + if (input === '/stats') { + console.log(`\nSession Statistics:`); + console.log(`Model: ${currentModel}`); + console.log(`Total tokens: ${sessionTokens}`); + console.log(`Total requests: ${sessionRequests}`); + if (sessionCost > 0) { + console.log(`Total cost: $${sessionCost.toFixed(6)}`); + } else { + console.log(`Total cost: Free`); + } + console.log( + `Average tokens per request: ${sessionRequests > 0 ? Math.round(sessionTokens / sessionRequests) : 0}` + ); + await promptUser(); + return; + } + if (input === '/billing') { try { + const spinner = showSpinner('Fetching billing info…'); 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 { + const credits = await getCredits({ domain: opts.domain, apiKey: opts.apiKey }); spinner.stop(); + + console.log(`\nOpenRouter Account Credits:`); + if (credits.data) { + const data = credits.data; + console.log(`Total credits purchased: $${(data.total_credits || 0).toFixed(6)}`); + console.log(`Total usage: $${(data.total_usage || 0).toFixed(6)}`); + console.log( + `Current balance: $${((data.total_credits || 0) - (data.total_usage || 0)).toFixed(6)}` + ); + if (data.credit_limit) { + console.log(`Credit limit: $${data.credit_limit.toFixed(6)}`); + } + } else { + console.log(`Credits info: ${JSON.stringify(credits, null, 2)}`); + } + } catch (err) { + console.error('Failed to fetch billing info:', err); } + await promptUser(); + return; } - } catch (err) { - await logError(err, 'repl'); - console.error('A technical issue occurred. Please try again.'); - } + + const userMsg = { role: 'user' as const, content: input }; + history.push(userMsg); + + // Process the message and then prompt + try { + if (streaming) { + 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 spinner = showSpinner('Thinking…'); + try { + spinner.start(); + const result = await askOnce( + { + domain: opts.domain, + apiKey: opts.apiKey, + model: currentModel, + system, + stream: false, + }, + [...history] + ); + const pretty = renderText(result.text, { format, streaming: false }); + process.stdout.write(pretty + '\n'); + + // Display usage information if available + if (result.usage) { + const usage = result.usage; + const tokens = usage.total_tokens || usage.completion_tokens + usage.prompt_tokens; + const cost = usage.total_cost || usage.cost || 0; + + // Update session totals + sessionTokens += tokens; + sessionCost += cost; + sessionRequests += 1; + + let usageInfo = `\nTokens: ${tokens}`; + if (usage.prompt_tokens && usage.completion_tokens) { + usageInfo += ` (prompt: ${usage.prompt_tokens}, completion: ${usage.completion_tokens})`; + } + + if (cost > 0) { + usageInfo += ` | Cost: $${cost.toFixed(6)}`; + } else { + usageInfo += ` | Free`; + } + + // Show session totals + usageInfo += `\nSession: ${sessionTokens} tokens`; + if (sessionCost > 0) { + usageInfo += `, $${sessionCost.toFixed(6)} total`; + } else { + usageInfo += `, free`; + } + usageInfo += ` (${sessionRequests} requests)`; + + process.stdout.write(usageInfo + '\n'); + } + } finally { + spinner.stop(); + } + } + } catch (err) { + if ((await import('./shared/errors.js')).isPolicyError(err)) { + const { handlePolicyError } = await import('./shared/errors.js'); + await handlePolicyError({ + where: 'repl', + tty: !!process.stdout.isTTY, + interactivePrompt: false, + }); + } else { + await logError(err, 'repl'); + console.error('A technical issue occurred. Please try again.'); + } + } + + // Continue with next prompt + await promptUser(); + }); + + // Start the initial prompt rl.prompt(); - }); + await new Promise((resolve) => rl.once('close', resolve)); + } - await new Promise((resolve) => rl.on("close", () => resolve())); + await promptUser(); } diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 6fe3a93..55b2147 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -4,9 +4,13 @@ import { runInitWizard } from './init.js'; export type EnsureApiKeyResult = { ok: true; apiKey: string } | { ok: false; message: string }; -const MISSING_KEY_MSG = "Missing API key. Set OPENROUTER_API_KEY / OPENAI_API_KEY or run 'openrouter init'."; +const MISSING_KEY_MSG = + "Missing API key. Set OPENROUTER_API_KEY / OPENAI_API_KEY or run 'openrouter init'."; -export async function ensureApiKey(profile?: string, allowInit?: boolean): Promise { +export async function ensureApiKey( + profile?: string, + allowInit?: boolean +): Promise { const eff = await resolveConfig(profile); let apiKey = getApiKey(await readConfig()) || eff.apiKey; @@ -21,4 +25,3 @@ export async function ensureApiKey(profile?: string, allowInit?: boolean): Promi if (!apiKey) return { ok: false, message: MISSING_KEY_MSG }; return { ok: true, apiKey }; } - diff --git a/src/shared/config.ts b/src/shared/config.ts index 063ea98..36ade32 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -1,6 +1,6 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import os from "node:os"; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; export type CliConfig = { domain?: string; @@ -10,8 +10,8 @@ export type CliConfig = { profiles?: Record>; }; -const CONFIG_DIR = path.join(os.homedir(), ".config", "openrouter-cli"); -const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); +const CONFIG_DIR = path.join(os.homedir(), '.config', 'openrouter-cli'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); export async function ensureConfigDir() { await fs.mkdir(CONFIG_DIR, { recursive: true }); @@ -23,11 +23,11 @@ export async function ensureConfigDir() { export async function readConfig(): Promise { try { - const txt = await fs.readFile(CONFIG_FILE, "utf8"); + const txt = await fs.readFile(CONFIG_FILE, 'utf8'); const json = JSON.parse(txt) as CliConfig; return json; } catch (err: any) { - if (err?.code === "ENOENT") return {}; + if (err?.code === 'ENOENT') return {}; throw err; } } @@ -50,7 +50,6 @@ export async function updateProfile(profile: string, patch: Partial) const current = await readConfig(); const profiles = { ...(current.profiles || {}) } as NonNullable; const cur = profiles[profile] || {}; - const { profiles: _, ...safePatch } = patch; profiles[profile] = { ...cur, ...safePatch }; await updateConfig({ profiles }); @@ -59,18 +58,18 @@ export async function updateProfile(profile: string, patch: Partial) // Project-local overrides: .openrouterrc(.json|.yaml|.yml) export async function readProjectRc(cwd = process.cwd()): Promise> { const candidates = [ - path.join(cwd, ".openrouterrc"), - path.join(cwd, ".openrouterrc.json"), - path.join(cwd, ".openrouterrc.yaml"), - path.join(cwd, ".openrouterrc.yml"), + path.join(cwd, '.openrouterrc'), + path.join(cwd, '.openrouterrc.json'), + path.join(cwd, '.openrouterrc.yaml'), + path.join(cwd, '.openrouterrc.yml'), ]; for (const file of candidates) { try { - const txt = await fs.readFile(file, "utf8"); + const txt = await fs.readFile(file, 'utf8'); const parsed = await parseRc(txt, path.extname(file)); return (parsed || {}) as Partial; } catch (err: any) { - if (err?.code === "ENOENT") continue; + if (err?.code === 'ENOENT') continue; throw err; } } @@ -79,8 +78,8 @@ export async function readProjectRc(cwd = process.cwd()): Promise { const prof = profile ? global.profiles?.[profile] || {} : {}; return { ...base, ...prof, ...project }; } + +// Danger helpers for debugging/testing +export async function resetConfig(): Promise { + try { + await fs.unlink(paths.CONFIG_FILE); + return true; + } catch (e: any) { + if (e?.code === 'ENOENT') return false; + throw e; + } +} + +export async function overrideConfig(newConfig: CliConfig): Promise { + await ensureConfigDir(); + const data = JSON.stringify(newConfig, null, 2); + await fs.writeFile(paths.CONFIG_FILE, data, { mode: 0o600 }); + try { + await fs.chmod(paths.CONFIG_FILE, 0o600); + } catch {} +} diff --git a/src/shared/env.ts b/src/shared/env.ts index 04e74bc..0dc093a 100644 --- a/src/shared/env.ts +++ b/src/shared/env.ts @@ -1,9 +1,9 @@ -import type { CliConfig } from "./config.js"; +import type { CliConfig } from './config.js'; export function getDefaultConfig() { return { - domain: "https://openrouter.ai/api/v1", - model: "meta-llama/llama-3.1-8b-instruct", + domain: 'https://openrouter.ai/api/v1', + model: 'meta-llama/llama-3.1-8b-instruct', } as const; } @@ -14,7 +14,6 @@ export function getApiKey(cfg: CliConfig): string | undefined { export function maskKey(key: string) { if (!key) return key; - if (key.length <= 8) return "*".repeat(key.length); - return key.slice(0, 4) + "****" + key.slice(-4); + if (key.length <= 8) return '*'.repeat(key.length); + return key.slice(0, 4) + '****' + key.slice(-4); } - diff --git a/src/shared/errors.ts b/src/shared/errors.ts new file mode 100644 index 0000000..3c81cfe --- /dev/null +++ b/src/shared/errors.ts @@ -0,0 +1,60 @@ +import { logError } from './logger.js'; +import { spawn } from 'node:child_process'; + +export const PRIVACY_URL = 'https://openrouter.ai/settings/privacy'; + +export function isPolicyError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /data policy/i.test(msg) || /Free model publication/i.test(msg); +} + +export async function handlePolicyError(opts: { + where: string; + tty: boolean; + interactivePrompt?: boolean; +}): Promise { + const hint = `This model may require enabling free endpoints that can publish prompts.\nOpen privacy settings: ${PRIVACY_URL}`; + // Always log details; keep terminal output friendly + await logError(new Error(`[policy] in ${opts.where}`)); + if (!opts.tty) { + console.error(hint + "\nTip: run 'openrouter models' to pick another model."); + return; + } + if (opts.interactivePrompt === false) { + console.error(hint + "\nTip: run 'openrouter models' to pick another model."); + return; + } + try { + const enq = await import('enquirer'); + const promptFn: any = (enq as any).prompt ?? (enq as any).default?.prompt; + if (typeof promptFn === 'function') { + console.error(hint); + const ans = await promptFn({ + type: 'confirm', + name: 'open', + message: 'Open settings now?', + initial: true, + }); + if (ans?.open) await openUrl(PRIVACY_URL); + else console.error("You can also run 'openrouter models' to choose a different model."); + return; + } + } catch { + // ignore + } + console.error(hint); +} + +async function openUrl(url: string) { + const platform = process.platform; + const trySpawn = (cmd: string, args: string[]) => + new Promise((resolve) => { + const p = spawn(cmd, args, { stdio: 'ignore', detached: true }); + p.on('error', () => resolve()); + p.unref(); + resolve(); + }); + if (platform === 'darwin') return trySpawn('open', [url]); + if (platform === 'win32') return trySpawn('cmd', ['/c', 'start', '', url]); + return trySpawn('xdg-open', [url]); +} diff --git a/src/shared/format.ts b/src/shared/format.ts index d9c08c7..299d78e 100644 --- a/src/shared/format.ts +++ b/src/shared/format.ts @@ -1,11 +1,14 @@ -export type OutputFormat = "auto" | "plain" | "md"; +export type OutputFormat = 'auto' | 'plain' | 'md'; // Very small markdown → ANSI formatter (headings, lists, code) -export function renderText(input: string, opts: { format: OutputFormat; streaming: boolean }): string { +export function renderText( + input: string, + opts: { format: OutputFormat; streaming: boolean } +): string { const mode = opts.format; if (opts.streaming) return input; // keep streaming plain for responsiveness - if (mode === "plain") return input; - if (mode === "md" || mode === "auto") return toAnsiMarkdown(input); + if (mode === 'plain') return input; + if (mode === 'md' || mode === 'auto') return toAnsiMarkdown(input); return input; } @@ -17,29 +20,29 @@ export function toAnsiMarkdown(md: string): string { let line = lines[i]; if (/^```/.test(line.trim())) { inFence = !inFence; - out.push(ansiDim("")); + out.push(ansiDim('')); continue; } if (inFence) { - out.push(ansiCyan(" " + line)); + out.push(ansiCyan(' ' + line)); continue; } // Headings if (/^###\s+/.test(line)) { - out.push(ansiBold(line.replace(/^###\s+/, "").trim())); + out.push(ansiBold(line.replace(/^###\s+/, '').trim())); continue; } if (/^##\s+/.test(line)) { - out.push(ansiBold(line.replace(/^##\s+/, "").trim())); + out.push(ansiBold(line.replace(/^##\s+/, '').trim())); continue; } if (/^#\s+/.test(line)) { - out.push(ansiBold(line.replace(/^#\s+/, "").trim())); + out.push(ansiBold(line.replace(/^#\s+/, '').trim())); continue; } // Lists if (/^\s*[-*+]\s+/.test(line)) { - line = line.replace(/^\s*[-*+]\s+/, " • "); + line = line.replace(/^\s*[-*+]\s+/, ' • '); // fall through for inline formatting below } // Inline code: `code` @@ -48,23 +51,29 @@ export function toAnsiMarkdown(md: string): string { 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)); + 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"); + return out.join('\n'); } // Minimal ANSI helpers function ansiBold(s: string) { - return "\x1b[1m" + s + "\x1b[22m"; + return '\x1b[1m' + s + '\x1b[22m'; } function ansiDim(s: string) { - return "\x1b[2m" + s + "\x1b[22m"; + return '\x1b[2m' + s + '\x1b[22m'; } function ansiCyan(s: string) { - return "\x1b[36m" + s + "\x1b[39m"; + return '\x1b[36m' + s + '\x1b[39m'; } function ansiItalic(s: string) { - return "\x1b[3m" + s + "\x1b[23m"; + return '\x1b[3m' + s + '\x1b[23m'; } diff --git a/src/shared/init.ts b/src/shared/init.ts index 5e58da3..08d1b69 100644 --- a/src/shared/init.ts +++ b/src/shared/init.ts @@ -1,15 +1,20 @@ import readline from 'node:readline'; -import { updateConfig, updateProfile } from './config.js'; +import { updateConfig, updateProfile, readConfig } from './config.js'; import type { CliConfig } from './config.js'; -import { getDefaultConfig } from './env.js'; +import { getDefaultConfig, getApiKey } from './env.js'; import { testConnection } from './openrouter.js'; import { fetchModelsCached, fuzzyIds } from './models.js'; -import { banner } from './ui.js'; +import { banner, warnBox } from './ui.js'; type Provider = 'openrouter' | 'openai' | 'custom'; function choosePreset(p: Provider) { - if (p === 'openai') return { provider: 'openai' as const, domain: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }; + if (p === 'openai') + return { + provider: 'openai' as const, + domain: 'https://api.openai.com/v1', + model: 'gpt-4o-mini', + }; if (p === 'custom') return { provider: 'custom' as const, domain: '', model: '' }; // default: openrouter const d = getDefaultConfig(); @@ -17,7 +22,11 @@ function choosePreset(p: Provider) { } export async function runInitWizard(): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); 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 @@ -25,14 +34,20 @@ export async function runInitWizard(): Promise { const provider: Provider = 'openrouter'; const preset = choosePreset(provider); - const domain = (await ask(`API domain (default: ${preset.domain || 'none'}): `)) || preset.domain; - 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) { - apiKey = await askHidden('API key (leave blank to skip): '); + // For MCP we only support OpenRouter, so use the default domain without prompting + const domain = preset.domain; + if (process.stdout.isTTY) { + console.log(`Using API domain: ${domain}`); + } + // Prefer interactive fuzzy search for model in TTY; fall back to plain prompt in non-TTY + let model = preset.model; + if (!process.stdout.isTTY) { + model = (await ask(`Default model (default: ${preset.model || 'none'}): `)) || preset.model; } + let apiKey = getApiKey(await readConfig()) || ''; + if (!apiKey) apiKey = await askHidden('API key (leave blank to skip): '); + const profile = await ask('Profile name (optional): '); // Interactive model picker (TTY only) @@ -47,15 +62,15 @@ export async function runInitWizard(): Promise { try { const list = await fetchModelsCached({ domain, apiKey: apiKey || undefined }); modelsList = list; - initial = list.slice(0, 25).map(m => m.id); + 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 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 toChoices = (ids: string[]) => ids.map((id) => ({ name: id, value: id, message: id })); const ans = await promptFn({ type: 'autocomplete', name: 'model', @@ -85,16 +100,30 @@ export async function runInitWizard(): Promise { try { await testConnection({ domain, apiKey }); console.log('✓ Connection OK'); - } catch (e) { - console.log('! Connection failed:', e instanceof Error ? e.message : String(e)); + } catch { + console.log(warnBox('Connection failed. Please verify your API key and settings.')); + console.log('Visit https://openrouter.ai/keys to create a key.'); + console.log( + "Set via env: export OPENROUTER_API_KEY='sk-...' or persist with: openrouter config --api-key sk-..." + ); const ans = (await ask('Save settings anyway? [y/N]: ')).toLowerCase(); save = ans === 'y' || ans === 'yes'; } + } else if (!apiKey) { + console.log(warnBox('An API key is required to connect.')); + console.log('Create a key at https://openrouter.ai/keys'); + console.log( + "Set via env: export OPENROUTER_API_KEY='sk-...' or persist with: openrouter config --api-key sk-..." + ); } if (save) { // Persist selections. If a profile is provided, write under that profile; otherwise write to base config. - const changes: Partial = { provider, domain: domain || undefined, model: model || undefined }; + const changes: Partial = { + provider, + domain: domain || undefined, + model: model || undefined, + }; if (apiKey) changes.apiKey = apiKey; if (profile) { await updateProfile(profile, changes); diff --git a/src/shared/logger.ts b/src/shared/logger.ts index 27e8259..db15056 100644 --- a/src/shared/logger.ts +++ b/src/shared/logger.ts @@ -4,7 +4,11 @@ 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); } + try { + return JSON.stringify(err); + } catch { + return String(err); + } } export async function logError(err: unknown, context?: string): Promise { @@ -25,4 +29,3 @@ export async function logError(err: unknown, context?: string): Promise { // swallow all logging errors } } - diff --git a/src/shared/models.ts b/src/shared/models.ts index b25944d..f3f2177 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -11,7 +11,11 @@ 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 { +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; @@ -30,17 +34,25 @@ export async function fetchModelsCached(opts: { domain: string; apiKey?: string; } export function fuzzyIds(input: string, list: ModelMeta[], limit = 25): string[] { - if (!input) return list.slice(0, limit).map(m => m.id); + 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); + return fuse + .search(input) + .slice(0, limit) + .map((r) => r.item.id); } -export function debouncedSuggestFactory(args: { domain: string; apiKey?: string; debounceMs?: number; spinnerLabel?: string }) { +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; @@ -72,7 +84,10 @@ export function debouncedSuggestFactory(args: { domain: string; apiKey?: string; if ((input?.length ?? 0) >= 2 && process.stdout.isTTY) { if (!spinner) { - spinner = ora({ text: args.spinnerLabel || 'Searching models…', isEnabled: isColorSupported() }); + spinner = ora({ + text: args.spinnerLabel || 'Searching models…', + isEnabled: isColorSupported(), + }); spinner.start(); } else if (!spinner.isSpinning) { spinner.start(); diff --git a/src/shared/openrouter.ts b/src/shared/openrouter.ts index 65a1894..b05fd50 100644 --- a/src/shared/openrouter.ts +++ b/src/shared/openrouter.ts @@ -1,4 +1,4 @@ -type Message = { role: "system" | "user" | "assistant"; content: string }; +type Message = { role: 'system' | 'user' | 'assistant'; content: string }; export type ChatOptions = { domain: string; @@ -7,11 +7,11 @@ export type ChatOptions = { system?: string; stream?: boolean; onFirstToken?: () => void; // optional UI hook - onDone?: () => void; // optional UI hook + onDone?: () => void; // optional UI hook }; export async function testConnection({ domain, apiKey }: { domain: string; apiKey: string }) { - const url = joinUrl(domain, "models"); + const url = joinUrl(domain, 'models'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}`, @@ -24,9 +24,25 @@ 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"); +export async function getCredits({ domain, apiKey }: { domain: string; apiKey: string }) { + const url = joinUrl(domain, 'credits'); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!res.ok) { + const body = await safeJson(res); + throw new Error(`HTTP ${res.status} ${res.statusText}: ${JSON.stringify(body)}`); + } + 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 }); @@ -38,17 +54,20 @@ export async function listModels(opts: { domain: string; apiKey?: string }): Pro 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"); +export async function askOnce( + opts: ChatOptions, + messages: Message[] +): Promise<{ text: string; usage?: any }> { + const url = joinUrl(opts.domain, 'chat/completions'); const body = { model: opts.model, messages: normalizeMessages(opts, messages), stream: false, }; const res = await fetch(url, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiKey}`, }, body: JSON.stringify(body), @@ -59,20 +78,21 @@ export async function askOnce(opts: ChatOptions, messages: Message[]): Promise r[0].length), 'Command'.length) + 2; + const col1Width = Math.max(...rows.map((r) => r[0].length), 'Command'.length) + 2; const lines = [ `${pad('Command', col1Width)}Example`, `${'-'.repeat(col1Width)}${'-'.repeat(32)}`, @@ -95,7 +95,8 @@ export function styledPrompt(model: string): string { export function tipBox(): string { const lines = [ - palette.dim("Type 'exit' to quit. Commands: ") + '/model , /system , /format , /stream ', + palette.dim("Type 'exit' to quit. Commands: ") + + '/model , /system , /format , /stream , /stats, /billing', palette.dim("Use 'openrouter init' to change defaults."), ].join('\n'); return boxen(lines, { padding: 1, borderStyle: 'single' }); @@ -103,6 +104,16 @@ export function tipBox(): string { export function showSpinner(label: string) { const enabled = isColorSupported(); - const spinner = ora({ text: palette.dim(label), isEnabled: enabled, stream: process.stderr as any }); + const spinner = ora({ + text: palette.dim(label), + isEnabled: enabled, + stream: process.stderr as any, + }); return spinner; } + +export function warnBox(text: string): string { + // Emphasize with red text and a clear border; remains readable without color + const body = palette.err(text); + return boxen(body, { padding: 1, borderStyle: 'round' }); +} diff --git a/tests/config-redaction.spec.ts b/tests/config-redaction.spec.ts index f28abe7..f141a8a 100644 --- a/tests/config-redaction.spec.ts +++ b/tests/config-redaction.spec.ts @@ -38,7 +38,9 @@ describe('config --list redaction', () => { const program = main.buildProgram(); let out = ''; const origLog = console.log; - console.log = (s?: any) => { out += (typeof s === 'string' ? s : String(s)); }; + console.log = (s?: any) => { + out += typeof s === 'string' ? s : String(s); + }; try { await program.parseAsync(['node', 'cli', 'config', '--list']); } finally { diff --git a/tests/config-yaml.spec.ts b/tests/config-yaml.spec.ts index 6e4dc43..64daa0c 100644 --- a/tests/config-yaml.spec.ts +++ b/tests/config-yaml.spec.ts @@ -31,7 +31,11 @@ afterEach(async () => { describe('project YAML rc precedence', () => { it('reads .openrouterrc.yaml and overrides model', async () => { await fs.mkdir(cfgModule.paths.CONFIG_DIR, { recursive: true }); - await cfgModule.updateConfig({ domain: 'https://global.example/v1', model: 'global-model', apiKey: 'sk-global' }); + await cfgModule.updateConfig({ + domain: 'https://global.example/v1', + model: 'global-model', + apiKey: 'sk-global', + }); await cfgModule.updateProfile('dev', { domain: 'https://dev.example/v1', model: 'dev-model' }); const rcPath = path.join(process.cwd(), '.openrouterrc.yaml'); @@ -43,4 +47,3 @@ describe('project YAML rc precedence', () => { expect(eff.apiKey).toBe('sk-global'); }); }); - diff --git a/tests/config.spec.ts b/tests/config.spec.ts index b27ea23..d376cce 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -39,7 +39,11 @@ describe('config precedence and profiles', () => { it('merges global base + profile + project rc (project wins)', async () => { // Write global base config await fs.mkdir(cfgModule.paths.CONFIG_DIR, { recursive: true }); - await cfgModule.updateConfig({ domain: 'https://global.example/v1', model: 'global-model', apiKey: 'sk-global' }); + await cfgModule.updateConfig({ + domain: 'https://global.example/v1', + model: 'global-model', + apiKey: 'sk-global', + }); await cfgModule.updateProfile('dev', { domain: 'https://dev.example/v1', model: 'dev-model' }); // Write project rc overriding model only diff --git a/tests/format.spec.ts b/tests/format.spec.ts index 0e419a0..07f34de 100644 --- a/tests/format.spec.ts +++ b/tests/format.spec.ts @@ -13,4 +13,3 @@ describe('markdown rendering (ANSI)', () => { expect(out).toMatch(/\x1b\[2mcode\x1b\[22m/); }); }); - diff --git a/tests/help.spec.ts b/tests/help.spec.ts index 5c9799d..bdce7b5 100644 --- a/tests/help.spec.ts +++ b/tests/help.spec.ts @@ -4,24 +4,23 @@ import { buildProgram } from '../src/main.js'; describe('CLI help output', () => { it('lists core commands and omits ask model override', () => { const program = buildProgram(); - const names = program.commands.map(c => c.name()); + const names = program.commands.map((c) => c.name()); expect(names).toEqual(expect.arrayContaining(['init', 'config', 'ask', 'repl', 'test'])); - const ask = program.commands.find(c => c.name() === 'ask')!; + const ask = program.commands.find((c) => c.name() === 'ask')!; const askHelp = ask.helpInformation(); expect(askHelp).not.toContain('--model'); expect(askHelp).toContain('--format'); expect(askHelp).toContain("Use 'openrouter init' to change defaults"); - const cfg = program.commands.find(c => c.name() === 'config')!; + const cfg = program.commands.find((c) => c.name() === 'config')!; const cfgHelp = cfg.helpInformation(); expect(cfgHelp).toContain('--api-key'); expect(cfgHelp).not.toMatch(/--domain|--model|--provider/); - const repl = program.commands.find(c => c.name() === 'repl')!; + const repl = program.commands.find((c) => c.name() === 'repl')!; const replHelp = repl.helpInformation(); expect(replHelp).toContain('/format'); expect(replHelp).toContain('/stream'); }); }); - diff --git a/tests/models.spec.ts b/tests/models.spec.ts index 6479b15..b3f36a6 100644 --- a/tests/models.spec.ts +++ b/tests/models.spec.ts @@ -4,12 +4,14 @@ 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' }, - ] })) + 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' }, + ], + })), }; }); @@ -34,19 +36,26 @@ describe('models helper', () => { 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 }); + 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 }); + (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/openrouter-ask-error.spec.ts b/tests/openrouter-ask-error.spec.ts index fd39cf3..72740b7 100644 --- a/tests/openrouter-ask-error.spec.ts +++ b/tests/openrouter-ask-error.spec.ts @@ -16,7 +16,7 @@ describe('askOnce error handling', () => { ok: false, status: 400, statusText: 'Bad Request', - json: async () => ({ error: 'test-error' }) + json: async () => ({ error: 'test-error' }), })) as any; const opts: ChatOptions = { @@ -25,8 +25,8 @@ describe('askOnce error handling', () => { model: 'm', stream: false, }; - await expect(askOnce(opts, [{ role: 'user', content: 'hi' }])) - .rejects.toThrow(/HTTP 400 Bad Request: .*test-error.*/); + await expect(askOnce(opts, [{ role: 'user', content: 'hi' }])).rejects.toThrow( + /HTTP 400 Bad Request: .*test-error.*/ + ); }); }); - diff --git a/tests/openrouter-stream.spec.ts b/tests/openrouter-stream.spec.ts index 496df73..7efebcd 100644 --- a/tests/openrouter-stream.spec.ts +++ b/tests/openrouter-stream.spec.ts @@ -15,8 +15,8 @@ describe('streamChat SSE parsing', () => { it('writes deltas to stdout for chunks and stops at [DONE]', async () => { const chunks = [ 'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n' + - 'data: {"choices":[{"delta":{"content":"world"}}]}\n\n', - 'data: [DONE]\n\n' + 'data: {"choices":[{"delta":{"content":"world"}}]}\n\n', + 'data: [DONE]\n\n', ]; const reader = { i: 0, @@ -26,11 +26,11 @@ describe('streamChat SSE parsing', () => { return { done: false, value: v }; } return { done: true }; - } + }, }; globalThis.fetch = (async () => ({ ok: true, - body: { getReader: () => reader } + body: { getReader: () => reader }, })) as any; const writes: string[] = []; @@ -42,7 +42,12 @@ describe('streamChat SSE parsing', () => { }; try { - const opts: ChatOptions = { domain: 'https://example.com/v1', apiKey: 'sk', model: 'm', stream: true }; + const opts: ChatOptions = { + domain: 'https://example.com/v1', + apiKey: 'sk', + model: 'm', + stream: true, + }; await streamChat(opts, [{ role: 'user', content: 'hi' }]); } finally { process.stdout.write = origWrite; diff --git a/tests/openrouter.spec.ts b/tests/openrouter.spec.ts index 90f3ce9..b405cc4 100644 --- a/tests/openrouter.spec.ts +++ b/tests/openrouter.spec.ts @@ -3,13 +3,14 @@ import { joinUrl } from '../src/shared/openrouter.js'; describe('joinUrl', () => { it('preserves base path when joining', () => { - expect(joinUrl('https://openrouter.ai/api/v1', 'models').toString()) - .toBe('https://openrouter.ai/api/v1/models'); + expect(joinUrl('https://openrouter.ai/api/v1', 'models').toString()).toBe( + 'https://openrouter.ai/api/v1/models' + ); }); it('handles trailing and leading slashes correctly', () => { - expect(joinUrl('https://example.com/root/', '/chat/completions').toString()) - .toBe('https://example.com/root/chat/completions'); + expect(joinUrl('https://example.com/root/', '/chat/completions').toString()).toBe( + 'https://example.com/root/chat/completions' + ); }); }); - diff --git a/tests/ui-answer.spec.ts b/tests/ui-answer.spec.ts index 8936413..2922a11 100644 --- a/tests/ui-answer.spec.ts +++ b/tests/ui-answer.spec.ts @@ -14,4 +14,3 @@ describe('answer render blocks', () => { expect(s).toContain('openrouter.ai'); }); }); - diff --git a/vitest.config.ts b/vitest.config.ts index 891499c..19a0739 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,4 +10,3 @@ export default defineConfig({ }, }, }); -