diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6260b7..231a2d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,4 @@ jobs: - run: npm install --no-audit --no-fund - run: npm run build - run: npm run lint --if-present - - run: npm test + - run: npm run test:coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16636e7..30f9c50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,5 +37,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} NPM_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + NPM_CONFIG_PROVENANCE: true HUSKY: 0 run: npx semantic-release diff --git a/.gitignore b/.gitignore index 5cd9d1f..6eddc75 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,21 @@ dist/ .DS_Store npm-debug.log* +# Local npm cache used by pack:dry +.npm-cache/ + +# Coverage reports +coverage/ + +# Editor settings (keep local) +.vscode/ +.idea/ + +# Local test artifacts +*.tgz + +# Project-local override files (avoid accidental commits) +.openrouterrc +.openrouterrc.json +.openrouterrc.yaml +.openrouterrc.yml diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fbc135..99d799f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "analyzer", + "behavior", "Commitizen", "commitlint", "letuscode", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff7d488 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# 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. +- `tests/` Vitest specs (e.g., `config.spec.ts`, `openrouter.spec.ts`). +- `docs/` design and configuration notes; `scripts/` release/build helpers. +- 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. +- `npm run lint` — ESLint over `src/**`. +- `npm run commit` — Commitizen prompt for Conventional Commits. +- 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'`). +- Use helpers to protect secrets; never log API keys. Config files are written with chmod `600` when possible. +- 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/README.md b/README.md index 72c23a9..d08727c 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,48 @@ # openrouter-cli -[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org) -[![semantic-release](https://img.shields.io/badge/semantic--release-automated-green?logo=semantic-release)](https://semantic-release.gitbook.io) -[![npm (beta)](https://img.shields.io/npm/v/%40letuscode%2Fopenrouter-cli/beta)](https://www.npmjs.com/package/@letuscode/openrouter-cli) - -Releases are automated: Conventional Commits determine version bumps and semantic-release publishes stable builds from `main` and prereleases on the `beta` channel from dev branches. Use `npm run commit` for a guided, standards-compliant commit message. - -CLI tool for OpenRouter (OpenAI-compatible). Early v0.1.0 skeleton. - -Installation -- npm: `npm i -g @letuscode/openrouter-cli` (or run locally with `npx @letuscode/openrouter-cli`) - -Setup -- Set API key via env: `export OPENROUTER_API_KEY=...` (or `OPENAI_API_KEY`) -- Or persist it: `openrouter config --api-key sk-...` - - Full guide: see `docs/CONFIGURATION.md` - -Defaults -- Domain: `https://openrouter.ai/api/v1` -- Model: `meta-llama/llama-3.1-8b-instruct` - -Commands -- `openrouter config [--domain URL] [--model NAME] [--api-key KEY] [--profile NAME] [--list]` -- `openrouter test [--profile NAME]` — calls `/models` to verify connectivity -- `openrouter ask "your question" [-m MODEL] [-s SYSTEM] [--profile NAME] [--no-stream]` -- `openrouter repl [--profile NAME]` — interactive chat (streaming). Commands: `exit`, `/model`, `/system` - -Config -- Stored at `~/.config/openrouter-cli/config.json` with chmod 600 when possible. -- API keys are never logged; printed configs redact the key. - -Project-local overrides (.openrouterrc) -- Place a `.openrouterrc` in the project root (JSON or YAML) to override `domain` and `model` for that project. -- Example JSON: - { - "domain": "http://localhost:11434/v1", - "model": "gemma2:9b-instruct" - } - -Profiles -- Store named profiles in the global config under `profiles`. -- Select a profile at runtime via `--profile `. -- Update/create a profile via: `openrouter config --profile dev --domain ... --model ...`. - - More details: see `docs/CONFIGURATION.md` - -Publishing -Stable releases (main) -- Conventional Commits drive versioning via semantic-release. -- Merge to `main` with `feat`, `fix`, or `BREAKING CHANGE` commits triggers release: - - Publishes to npm with the next semver. - - Creates Git tag (`vX.Y.Z`) and GitHub Release. - - Note: CHANGELOG.md is generated in CI but not committed back (tags/releases are the source of truth). - -Pre-releases -- Beta snapshots publish from dev branches (`story/**`, `feature/**`, `release/**`). -- Install beta builds: `npm i @letuscode/openrouter-cli@beta` -- Driven by commit messages; semver prerelease scheme. -- Beta builds are npm-only; stable tags are created on `main`. - -Contributing -- Commit style: Conventional Commits (e.g., `feat: add repl /help`, `fix: handle URL join`) drive releases. -- Local hooks (Husky): - - commit-msg: commitlint enforces Conventional Commits. - - pre-commit: lint-staged runs ESLint with `--fix` on staged files. - - pre-push: runs `npm test` and `npm run build`. -- Setup: run `npm install` once to install hooks (postinstall runs `husky install`). -- Manual runs: - - `npx commitlint --from HEAD~1 --to HEAD` (check last commit) - - `npx lint-staged` (run staged linting) - - `npm run lint && npm test && npm run build` - -Conventional Commits examples -- `feat: add config command to update model` -- `fix: preserve /api/v1 path when joining URLs` -- `docs: add contributing section` -- `chore: set up semantic-release` -- `refactor: extract URL join helper` -- `perf: speed up SSE parsing` -- `test: add URL join tests` -- `feat!: change default model` with a footer: - - `BREAKING CHANGE: default model is now meta-llama/llama-3.1-8b-instruct` - -Guided commit (Commitizen) -- Run `npm run commit` for an interactive prompt that produces a valid Conventional Commit message. +OpenAI‑compatible CLI for OpenRouter. Ask questions, run a REPL, and manage per‑project or global settings. + +Requirements +- Node.js 18.17+ (ESM) + +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]` +- `openrouter repl` — interactive chat + - REPL commands: `exit`, `/model `, `/system `, `/format md|plain`, `/stream on|off` + +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) +- Project overrides: add `.openrouterrc` (JSON or YAML) in your project root + - Example `.openrouterrc` (JSON): + { + "domain": "http://localhost:11434/v1", + "model": "gemma2:9b-instruct" + } +- 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`. + +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 - MIT diff --git a/bin/openrouter b/bin/openrouter old mode 100644 new mode 100755 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e7f0d99..e4a769d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,6 +17,9 @@ 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 - Path: `~/.config/openrouter-cli/config.json` @@ -38,16 +41,16 @@ Defaults: ### Managing Global Config via CLI -- Update base (global) config: - - `openrouter config --domain https://openrouter.ai/api/v1 --model meta-llama/llama-3.1-8b-instruct` -- Create/update a profile: - - `openrouter config --profile dev --domain http://localhost:11434/v1 --model gemma2:9b-instruct` - Persist an API key (optional): - Global: `openrouter config --api-key sk-...` - Profile: `openrouter config --profile dev --api-key sk-...` - Inspect config and profiles (redacted): - `openrouter config --list` +### Changing Provider, Domain, or Model + +- Use `openrouter init` to choose or change provider, domain, and default model. Re‑run `init` any time to update these defaults. + ## Project‑Local Overrides (.openrouterrc) Place a `.openrouterrc` in your project root to set project‑specific defaults. This file should not contain secrets. @@ -84,14 +87,17 @@ 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) - Pick a profile for a command: - `openrouter test --profile dev` - `openrouter ask --profile dev "Hello world"` - `openrouter repl --profile dev` -- Override model/domain for a single invocation: - - `openrouter ask -m other-model "Hello"` + +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 @@ -129,6 +135,10 @@ Example resolution for `openrouter ask --profile dev "Hi"` in a project with `.o - `openrouter ask --profile dev "Hello"` - Project override file: - `./.openrouterrc` (JSON/YAML) + - Tip: This is optional and best for users running the CLI inside a project directory. For global one‑off use, rely on `openrouter init` defaults. - API key env vars: - `OPENROUTER_API_KEY` (preferred), `OPENAI_API_KEY` - +- Run the wizard: + - `openrouter init` +- Apply a provider preset: + - `openrouter config --provider openrouter|openai|custom` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..ae635fd --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +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` +- Build: `npm run build` +- Guided commit: `npm run commit` +- Optional hooks: `npm run dev:hooks` to install Husky locally + - commit‑msg: commitlint + - pre‑commit: lint‑staged (ESLint fix on staged files) + - 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 a22a7da..042f880 100644 --- a/docs/DECISION.md +++ b/docs/DECISION.md @@ -123,35 +123,39 @@ This document tracks major design choices, scope, and phased roadmap for the **o - ESLint on staged files pre-commit; tests pre-push. -## Iteration 2 (v0.2.0) — Plan +## Iteration 2 (v0.2.0) — Status ### Scope -- Project-local `.openrouterrc` overrides. -- Profiles (e.g. `--profile dev`) with per-profile domain/model/system overrides. +- 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: - - CLI flags > env vars > project `.openrouterrc` > global config. +- 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: - - Global: `~/.config/openrouter-cli/config.json` (existing) - - Project: `./.openrouterrc` (JSON or YAML; v0.2: JSON only) + - Global: `~/.config/openrouter-cli/config.json` (stores base values, optional profiles) + - Project: `./.openrouterrc` or `.openrouterrc.{json,yaml,yml}` (overrides domain/model per project) - Profiles: - - Specify via `--profile ` on CLI and REPL. - - Global and project configs can define `profiles` object; missing keys fall back to base config. + - Select via `--profile ` on CLI and REPL. + - Global config can define `profiles` object; missing keys fall back to base config. ### CLI/UX -- Add `--profile` to `ask`, `repl`, and `test`. -- `config` command: support reading/writing base values only in v0.2; profiles are read-only initially. +- `--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` with merge logic and precedence. ✅ Completed +- Implement loader for `.openrouterrc` (JSON/YAML) with merge logic and precedence. ✅ Completed - Add profile resolver with fallback. ✅ Completed -- Update help/docs and examples. ✅ Completed -- Add unit tests for precedence and profiles. ⏳ Partial (URL join test added; profile tests next) +- 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 -- YAML/TOML formats, keychain storage, remote profiles. +- TOML formats, keychain storage, remote profiles, tool plugins/MCP phases. diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..33e9f86 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,39 @@ +# 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. +- [ ] Apply consistent style to `--help`, `ask` (non‑stream output), `repl` prompts and tips. +- [ ] 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). + - [ ] Auto‑init skip in non‑TTY (mock isTTY and stub wizard). + - [ ] testConnection error mapping (HTTP 4xx/5xx → friendly messages). + - [ ] REPL non‑stream render path snapshot (strip ANSI). + - [ ] 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/docs/TOMORROW_PLAN.md b/docs/TOMORROW_PLAN.md deleted file mode 100644 index 3c5459d..0000000 --- a/docs/TOMORROW_PLAN.md +++ /dev/null @@ -1,113 +0,0 @@ -# OpenRouter CLI — Tomorrow’s Plan (Iteration 2 follow‑ups) - -This document outlines the concrete tasks to implement next, with scope, UX, acceptance criteria, and test notes. - -## 1) Patch Release (installation + bin) - -Goal: End users can install and run without any flags or permission issues. - -Tasks -- Verify package.json has no consumer lifecycle hooks (prepare/postinstall) invoking Husky. Keep dev-only `dev:hooks` for contributors. -- Ship executable wrapper `bin/openrouter` with shebang; ensure `prepack` sets `chmod +x` and includes `bin/**` in `files`. -- Publish a patch release (e.g., 1.2.x) from `main`. - -Acceptance -- `npm i -g @letuscode/openrouter-cli` installs without `--ignore-scripts`. -- `openrouter --help` works on a clean machine. -- `npx @letuscode/openrouter-cli --help` works. - -Validation -- Manual install test on a fresh Node env. -- CI release job green. - -## 2) Markdown Rendering in CLI - -Goal: Render Markdown answers nicely in the terminal for non-streamed output; keep streaming responsive. - -UX -- `--format auto|plain|md` (default: auto → md for non-stream; plain for stream). -- REPL commands: `/format md|plain`, `/stream on|off`. -- Non-stream path (askOnce): pretty-print markdown → ANSI. -- Stream path: default plain; optional buffer+render later (not MVP). - -Implementation -- Add `src/shared/format.ts` using a lightweight renderer (e.g., `marked` + `marked-terminal` or `markdown-it` + ANSI styling). -- Wire in `ask` non-stream path; REPL respects current format/stream settings. - -Acceptance -- `openrouter ask --no-stream "# Title\n\n- item\n\n\`code\``" shows headings/lists/code with ANSI styling. -- REPL: `/format md` + `/stream off` then ask → renders pretty once. - -Tests -- Unit: markdown → ANSI smoke snapshots (minimal). -- E2E (optional): CLI with `--no-stream` returns colored output (strip ANSI to assert structure). - -Docs -- README: document `--format` and REPL commands. - -## 3) Interactive Onboarding (init) - -Goal: First-time setup wizard to configure provider, domain, API key, model (and optional profile) interactively. - -UX -- New command: `openrouter init` (TTY prompts). -- Auto-prompt in `ask/test/repl` if missing API key and in a TTY; skip with `--no-init`. -- Steps: - 1) Provider preset: OpenRouter | OpenAI | Claude (via OpenRouter) | Custom - 2) Domain from preset (editable) - 3) API key (masked input). Note env alternatives. - 4) Default model (suggest from preset) - 5) Optional profile name (default: `default`) - 6) Validate (attempt `/models` when possible), then save (chmod 600), never echo key. - -Provider Presets (MVP) -- OpenRouter: domain `https://openrouter.ai/api/v1`, env `OPENROUTER_API_KEY`, model `meta-llama/llama-3.1-8b-instruct`. -- OpenAI: domain `https://api.openai.com/v1`, env `OPENAI_API_KEY`, model `gpt-4o-mini` (example). -- Claude (MVP via OpenRouter): recommend selecting OpenRouter provider and choosing a Claude model there. Direct Anthropic support planned later. -- Custom: enter domain/env guidance; user supplies model. - -Implementation -- Add `prompts` or `enquirer` as a dep for TTY prompts. -- Extend config schema with optional `provider` field. -- Add `openrouter config --provider ` to apply preset quickly. -- Respect `--no-init` global option to skip prompting. - -Acceptance -- Running `openrouter init` writes config and profiles as chosen; subsequent `openrouter test` succeeds (with valid key). -- First-run `openrouter ask` with no key triggers wizard in interactive shells and skips in CI/non-TTY. - -Tests -- Non-interactive init path (with flags) writes expected config. -- Preset mapping test: provider → domain/env name/model defaults. - -Docs -- README: “Getting Started” using `openrouter init`. -- CONFIGURATION.md: provider presets and env vars. - -## 4) Additional Tests - -- Config precedence with YAML `.openrouterrc` (project > profile > global). -- `config --list` output shape (keys redacted). -- Optional: timeout + error formatting (once implemented). - -## 5) CI and Release Polishing - -- Ensure release job env includes `NPM_CONFIG_PROVENANCE=true` (stable provenance). -- Beta workflow already skips duplicate versions; keep as-is for now. -- Optional: run build/lint/test gates before beta publish. - ---- - -## Command Reference (new/changed) - -- `openrouter init` — interactive first-time setup (provider, domain, key, model, profile) -- `openrouter ask --format auto|plain|md` — output formatting -- `openrouter repl` commands: `/format md|plain`, `/stream on|off` -- `openrouter config --provider ` — apply provider preset - -## Out of Scope (for tomorrow) - -- Direct Anthropic client (Claude) integration. -- Streaming markdown reflow (advanced buffering/live formatting). -- Tool plugins / MCP phases from roadmap. - diff --git a/package-lock.json b/package-lock.json index 0b85f66..63f0e74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,13 @@ "": { "name": "@letuscode/openrouter-cli", "version": "1.0.0-beta.1", - "hasInstallScript": true, "license": "MIT", "dependencies": { "commander": "^14.0.0", "js-yaml": "^4.1.0" }, "bin": { - "openrouter": "dist/index.js" + "openrouter": "bin/openrouter" }, "devDependencies": { "@commitlint/cli": "^19.4.0", @@ -31,6 +30,7 @@ "@types/node": "^24.3.1", "@typescript-eslint/eslint-plugin": "^8.42.0", "@typescript-eslint/parser": "^8.42.0", + "@vitest/coverage-v8": "^2.1.1", "commitizen": "^4.3.0", "eslint": "^9.35.0", "globals": "^15.9.0", @@ -45,6 +45,31 @@ "node": ">=18.17" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@ampproject/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -60,6 +85,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", @@ -70,6 +105,43 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1214,6 +1286,128 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1470,6 +1664,17 @@ "@octokit/openapi-types": "^25.1.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -2593,6 +2798,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -4034,6 +4272,13 @@ "readable-stream": "^2.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4867,6 +5112,36 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -5276,6 +5551,13 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5693,6 +5975,87 @@ "node": "^18.17 || >=20.6.1" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -6503,6 +6866,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -6690,6 +7081,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9719,6 +10120,13 @@ "node": ">=4" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9828,6 +10236,23 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11219,6 +11644,32 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -11242,6 +11693,30 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -11387,6 +11862,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-extensions": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", @@ -11998,6 +12509,25 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 4176bea..7d32064 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,14 @@ "dev": "node --loader ts-node/esm src/index.ts", "lint": "eslint \"src/**/*.{js,ts,mjs,cjs}\"", "test": "vitest run --reporter=default", + "test:coverage": "vitest run --reporter=default --coverage", "test:watch": "vitest --watch", - "prepare": "husky", + "dev:hooks": "husky install", "prepublishOnly": "npm run build", "prepack": "npm run build && node scripts/prepack.mjs", - "postinstall": "husky install", "release": "semantic-release", - "commit": "cz" + "commit": "cz", + "pack:dry": "npm pack --dry-run --cache ./.npm-cache" }, "keywords": [ "openrouter", @@ -53,6 +54,7 @@ "js-yaml": "^4.1.0" }, "devDependencies": { + "@vitest/coverage-v8": "^2.1.1", "@commitlint/cli": "^19.4.0", "@commitlint/config-conventional": "^19.4.0", "@commitlint/cz-commitlint": "^19.4.0", diff --git a/src/main.ts b/src/main.ts index 8f2b3b2..fac940c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,13 @@ import { Command } from "commander"; import { readConfig, updateConfig, ensureConfigDir, updateProfile, resolveConfig } from "./shared/config.js"; -import { getApiKey, getDefaultConfig, maskKey } from "./shared/env.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"; -export async function main() { +export function buildProgram() { const program = new Command(); program .name("openrouter") @@ -13,13 +16,11 @@ export async function main() { program .command("config") - .description("Show or update configuration") - .option("--domain ", "Set API domain (OpenAI-compatible)") - .option("--model ", "Set default model") + .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: { domain?: string; model?: string; apiKey?: string; profile?: string; list?: boolean }) => { + .action(async (opts: { apiKey?: string; profile?: string; list?: boolean }) => { await ensureConfigDir(); if (opts.list) { const cfg = await readConfig(); @@ -34,15 +35,13 @@ export async function main() { return; } - const changes: Record = {}; - - if (opts.domain) changes.domain = opts.domain; - if (opts.model) changes.model = opts.model; + 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 as any); + await updateProfile(opts.profile, changes); } else { await updateConfig(changes); } @@ -57,14 +56,13 @@ export async function main() { .command("test") .description("Check API connectivity via /models") .option("--profile ", "Use a named profile") - .action(async (opts: { profile?: string }) => { + .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 apiKey = getApiKey(await readConfig()) || cfg.apiKey; - if (!apiKey) { - console.error("Missing API key. Set OPENROUTER_API_KEY / OPENAI_API_KEY or 'openrouter config --api-key'."); - process.exitCode = 2; - return; - } + 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; } + const apiKey = r.apiKey; const domain = cfg.domain ?? getDefaultConfig().domain; const res = await testConnection({ domain, apiKey }); console.log(JSON.stringify(res, null, 2)); @@ -72,22 +70,22 @@ export async function main() { program .command("ask") - .description("One-shot question to the chat model") + .description("One-shot question. Use 'openrouter init' to change defaults.") .argument("", "User prompt") - .option("-m, --model ", "Override model") .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") - .action(async (prompt: string, options: { model?: string; system?: string; stream?: boolean; profile?: string }) => { + .option("--no-init", "Do not run interactive init when missing API key") + .action(async (prompt: string, options: { system?: string; stream?: boolean; profile?: string; format?: OutputFormat; init?: boolean }) => { const eff = await resolveConfig(options.profile); - const apiKey = getApiKey(await readConfig()) || eff.apiKey; - if (!apiKey) { - console.error("Missing API key. Set OPENROUTER_API_KEY / OPENAI_API_KEY or 'openrouter config --api-key'."); - process.exitCode = 2; - return; - } - const model = options.model || eff.model || getDefaultConfig().model; + 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; } + const apiKey = r.apiKey; + const model = eff.model || getDefaultConfig().model; const domain = eff.domain || getDefaultConfig().domain; + const format: OutputFormat = (options.format as OutputFormat) || "auto"; const chatOptions: ChatOptions = { domain, apiKey, @@ -100,23 +98,23 @@ export async function main() { process.stdout.write("\n"); } else { const out = await askOnce(chatOptions, [{ role: "user", content: prompt }]); - console.log(out); + const pretty = renderText(out, { format, streaming: false }); + console.log(pretty); } }); program .command("repl") - .description("Interactive chat with streaming. Commands: exit, /model, /system") + .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") - .action(async (options: { model?: string; profile?: string }) => { + .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 apiKey = getApiKey(await readConfig()) || eff.apiKey; - if (!apiKey) { - console.error("Missing API key. Set OPENROUTER_API_KEY / OPENAI_API_KEY or 'openrouter config --api-key'."); - process.exitCode = 2; - return; - } + 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; } + const apiKey = r.apiKey; await startRepl({ apiKey, domain: eff.domain ?? getDefaultConfig().domain, @@ -124,5 +122,17 @@ export async function main() { }); }); + program + .command("init") + .description("Interactive first-time setup (provider, domain, key, model, profile)") + .action(async () => { + await runInitWizard(); + }); + + return program; +} + +export async function main() { + const program = buildProgram(); await program.parseAsync(process.argv); } diff --git a/src/repl.ts b/src/repl.ts index fe26c91..f438fa9 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -1,5 +1,6 @@ import readline from "node:readline"; -import { streamChat } from "./shared/openrouter.js"; +import { streamChat, askOnce } from "./shared/openrouter.js"; +import { renderText, OutputFormat } from "./shared/format.js"; type ReplOptions = { apiKey: string; @@ -10,6 +11,8 @@ type ReplOptions = { export async function startRepl(opts: ReplOptions) { let currentModel = opts.initialModel; let system: string | undefined; + let format: OutputFormat = "plain"; // default plain for streaming sessions + let streaming = true; const history: { role: "user" | "assistant"; content: string }[] = []; const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); @@ -17,7 +20,7 @@ export async function startRepl(opts: ReplOptions) { prompt(); rl.prompt(); - console.log("Type 'exit' to quit. Commands: /model , /system "); + console.log("Type 'exit' to quit. Commands: /model , /system , /format , /stream . Use 'openrouter init' to change defaults."); rl.on("line", async (line) => { const input = line.trim(); @@ -41,14 +44,42 @@ export async function startRepl(opts: ReplOptions) { rl.prompt(); 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"); + } + 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 { - await streamChat({ domain: opts.domain, apiKey: opts.apiKey, model: currentModel, system, stream: true }, [ - ...history, - ]); - process.stdout.write("\n"); + if (streaming) { + await streamChat({ domain: opts.domain, apiKey: opts.apiKey, model: currentModel, system, stream: true }, [ + ...history, + ]); + process.stdout.write("\n"); + } else { + const text = await askOnce({ domain: opts.domain, apiKey: opts.apiKey, model: currentModel, system, stream: false }, [ + ...history, + ]); + const pretty = renderText(text, { format, streaming: false }); + process.stdout.write(pretty + "\n"); + } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); } @@ -57,4 +88,3 @@ export async function startRepl(opts: ReplOptions) { await new Promise((resolve) => rl.on("close", () => resolve())); } - diff --git a/src/shared/auth.ts b/src/shared/auth.ts new file mode 100644 index 0000000..6fe3a93 --- /dev/null +++ b/src/shared/auth.ts @@ -0,0 +1,24 @@ +import { readConfig, resolveConfig } from './config.js'; +import { getApiKey } from './env.js'; +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'."; + +export async function ensureApiKey(profile?: string, allowInit?: boolean): Promise { + const eff = await resolveConfig(profile); + let apiKey = getApiKey(await readConfig()) || eff.apiKey; + + if (!apiKey && process.stdout.isTTY && allowInit !== false) { + const ok = await runInitWizard(); + if (ok) { + const refreshed = await resolveConfig(profile); + apiKey = getApiKey(await readConfig()) || refreshed.apiKey; + } + } + + 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 ff06c70..506f737 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -6,6 +6,7 @@ export type CliConfig = { domain?: string; model?: string; apiKey?: string; // optional persisted key + provider?: 'openrouter' | 'openai' | 'custom'; profiles?: Record>; }; diff --git a/src/shared/format.ts b/src/shared/format.ts new file mode 100644 index 0000000..190e649 --- /dev/null +++ b/src/shared/format.ts @@ -0,0 +1,63 @@ +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 { + 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); + return input; +} + +export function toAnsiMarkdown(md: string): string { + const lines = md.split(/\r?\n/); + const out: string[] = []; + let inFence = false; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + if (/^```/.test(line.trim())) { + inFence = !inFence; + out.push(ansiDim("")); + continue; + } + if (inFence) { + out.push(ansiCyan(" " + line)); + continue; + } + // Headings + if (/^###\s+/.test(line)) { + out.push(ansiBold(line.replace(/^###\s+/, "").trim())); + continue; + } + if (/^##\s+/.test(line)) { + out.push(ansiBold(line.replace(/^##\s+/, "").trim())); + continue; + } + if (/^#\s+/.test(line)) { + out.push(ansiBold(line.replace(/^#\s+/, "").trim())); + continue; + } + // Lists + if (/^\s*[-*+]\s+/.test(line)) { + line = line.replace(/^\s*[-*+]\s+/, " • "); + out.push(line); + continue; + } + // Inline code: `code` + line = line.replace(/`([^`]+)`/g, (_, m1: string) => ansiDim(m1)); + out.push(line); + } + return out.join("\n"); +} + +// Minimal ANSI helpers +function ansiBold(s: string) { + return "\x1b[1m" + s + "\x1b[22m"; +} +function ansiDim(s: string) { + return "\x1b[2m" + s + "\x1b[22m"; +} +function ansiCyan(s: string) { + return "\x1b[36m" + s + "\x1b[39m"; +} + diff --git a/src/shared/init.ts b/src/shared/init.ts new file mode 100644 index 0000000..49005b1 --- /dev/null +++ b/src/shared/init.ts @@ -0,0 +1,66 @@ +import readline from 'node:readline'; +import { updateConfig, updateProfile } from './config.js'; +import type { CliConfig } from './config.js'; +import { getDefaultConfig } from './env.js'; +import { testConnection } from './openrouter.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 === 'custom') return { provider: 'custom' as const, domain: '', model: '' }; + // default: openrouter + const d = getDefaultConfig(); + return { provider: 'openrouter' as const, domain: d.domain, model: d.model }; +} + +export async function runInitWizard(): Promise { + 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 + + console.log('=== OpenRouter CLI — Init ==='); + const provInput = (await ask('Provider [openrouter|openai|custom] (default: openrouter): ')) as Provider | ''; + const provider: Provider = (provInput === 'openai' || provInput === 'custom') ? provInput : 'openrouter'; + const preset = choosePreset(provider); + + const domain = (await ask(`API domain (default: ${preset.domain || 'none'}): `)) || preset.domain; + const model = (await ask(`Default model (default: ${preset.model || 'none'}): `)) || preset.model; + + let apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY || ''; + if (!apiKey) { + apiKey = await askHidden('API key (leave blank to skip): '); + } + + const profile = await ask('Profile name (optional): '); + + let save = true; + if (apiKey && domain) { + try { + await testConnection({ domain, apiKey }); + console.log('✓ Connection OK'); + } catch (e) { + console.log('! Connection failed:', e instanceof Error ? e.message : String(e)); + const ans = (await ask('Save settings anyway? [y/N]: ')).toLowerCase(); + save = ans === 'y' || ans === 'yes'; + } + } + + 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 }; + if (apiKey) changes.apiKey = apiKey; + if (profile) { + await updateProfile(profile, changes); + console.log(`Saved to profile '${profile}' in ~/.config/openrouter-cli/config.json`); + } else { + await updateConfig(changes); + console.log('Saved to base config at ~/.config/openrouter-cli/config.json'); + } + } else { + console.log('Nothing saved.'); + } + + rl.close(); + return save; +} diff --git a/tests/config-redaction.spec.ts b/tests/config-redaction.spec.ts new file mode 100644 index 0000000..f28abe7 --- /dev/null +++ b/tests/config-redaction.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +let cfg: typeof import('../src/shared/config.js'); +let main: typeof import('../src/main.js'); + +let origHome: string; +let origCwd: string; + +async function mkdtemp(base: string) { + return fs.mkdtemp(path.join(base, 'orcli-')); +} + +beforeEach(async () => { + origHome = os.homedir(); + origCwd = process.cwd(); + const tmpHome = await mkdtemp(os.tmpdir()); + process.env.HOME = tmpHome; + const tmpProject = await mkdtemp(os.tmpdir()); + process.chdir(tmpProject); + cfg = await import('../src/shared/config.js'); + main = await import('../src/main.js'); +}); + +afterEach(async () => { + process.env.HOME = origHome; + process.chdir(origCwd); +}); + +describe('config --list redaction', () => { + it('masks base and profile api keys in output', async () => { + await fs.mkdir(cfg.paths.CONFIG_DIR, { recursive: true }); + await cfg.updateConfig({ apiKey: 'sk-global-12345678', model: 'm', domain: 'https://d' }); + await cfg.updateProfile('dev', { apiKey: 'sk-dev-ABCDEFGH', model: 'x' }); + + const program = main.buildProgram(); + let out = ''; + const origLog = console.log; + console.log = (s?: any) => { out += (typeof s === 'string' ? s : String(s)); }; + try { + await program.parseAsync(['node', 'cli', 'config', '--list']); + } finally { + console.log = origLog; + } + const json = JSON.parse(out); + expect(json.apiKey).toContain('****'); + expect(json.apiKey.startsWith('sk-g')).toBeTruthy(); + expect(json.apiKey.endsWith('5678')).toBeTruthy(); + expect(json.profiles.dev.apiKey).toContain('****'); + expect(json.profiles.dev.apiKey.startsWith('sk-d')).toBeTruthy(); + expect(json.profiles.dev.apiKey.endsWith('EFGH')).toBeTruthy(); + }); +}); diff --git a/tests/config-yaml.spec.ts b/tests/config-yaml.spec.ts new file mode 100644 index 0000000..6e4dc43 --- /dev/null +++ b/tests/config-yaml.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +let cfgModule: typeof import('../src/shared/config.js'); + +let origHome: string; +let origCwd: string; + +async function mkdtemp(base: string) { + const dir = await fs.mkdtemp(path.join(base, 'orcli-')); + return dir; +} + +beforeEach(async () => { + origHome = os.homedir(); + origCwd = process.cwd(); + const tmpHome = await mkdtemp(os.tmpdir()); + process.env.HOME = tmpHome; + const tmpProject = await mkdtemp(os.tmpdir()); + process.chdir(tmpProject); + cfgModule = await import('../src/shared/config.js'); +}); + +afterEach(async () => { + process.env.HOME = origHome; + process.chdir(origCwd); +}); + +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.updateProfile('dev', { domain: 'https://dev.example/v1', model: 'dev-model' }); + + const rcPath = path.join(process.cwd(), '.openrouterrc.yaml'); + await fs.writeFile(rcPath, 'model: project-model\n'); + + const eff = await cfgModule.resolveConfig('dev'); + expect(eff.domain).toBe('https://dev.example/v1'); + expect(eff.model).toBe('project-model'); + expect(eff.apiKey).toBe('sk-global'); + }); +}); + diff --git a/tests/format.spec.ts b/tests/format.spec.ts new file mode 100644 index 0000000..0e419a0 --- /dev/null +++ b/tests/format.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { renderText } from '../src/shared/format.js'; + +describe('markdown rendering (ANSI)', () => { + it('renders headings, lists, and inline code with ANSI', () => { + const src = '# Title\n\n- item\n\n`code`'; + const out = renderText(src, { format: 'md', streaming: false }); + // Bold heading (\x1b[1m ... \x1b[22m) + expect(out).toMatch(/\x1b\[1m.*Title.*\x1b\[22m/); + // Bullet replacement + expect(out).toContain('• item'); + // Inline code dim + expect(out).toMatch(/\x1b\[2mcode\x1b\[22m/); + }); +}); + diff --git a/tests/help.spec.ts b/tests/help.spec.ts new file mode 100644 index 0000000..5c9799d --- /dev/null +++ b/tests/help.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +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()); + expect(names).toEqual(expect.arrayContaining(['init', 'config', 'ask', 'repl', 'test'])); + + 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 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 replHelp = repl.helpInformation(); + expect(replHelp).toContain('/format'); + expect(replHelp).toContain('/stream'); + }); +}); + diff --git a/tests/openrouter-ask-error.spec.ts b/tests/openrouter-ask-error.spec.ts new file mode 100644 index 0000000..fd39cf3 --- /dev/null +++ b/tests/openrouter-ask-error.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { askOnce, type ChatOptions } from '../src/shared/openrouter.js'; + +const realFetch = globalThis.fetch; + +describe('askOnce error handling', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + globalThis.fetch = realFetch as any; + }); + + it('throws HTTP error with JSON body', async () => { + globalThis.fetch = (async () => ({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ error: 'test-error' }) + })) as any; + + const opts: ChatOptions = { + domain: 'https://example.com/v1', + apiKey: 'sk-test', + model: 'm', + stream: false, + }; + 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 new file mode 100644 index 0000000..496df73 --- /dev/null +++ b/tests/openrouter-stream.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { streamChat, type ChatOptions } from '../src/shared/openrouter.js'; + +const realFetch = globalThis.fetch; +const enc = new TextEncoder(); + +describe('streamChat SSE parsing', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + globalThis.fetch = realFetch as any; + }); + + 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' + ]; + const reader = { + i: 0, + async read(): Promise<{ done: boolean; value?: Uint8Array }> { + if (this.i < chunks.length) { + const v = enc.encode(chunks[this.i++]); + return { done: false, value: v }; + } + return { done: true }; + } + }; + globalThis.fetch = (async () => ({ + ok: true, + body: { getReader: () => reader } + })) as any; + + const writes: string[] = []; + const origWrite = process.stdout.write; + (process.stdout.write as any) = (s: string | Uint8Array) => { + const str = typeof s === 'string' ? s : Buffer.from(s).toString('utf8'); + writes.push(str); + return true; + }; + + try { + 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; + } + expect(writes.join('')).toBe('Hello world'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..891499c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'lcov'], + reportsDirectory: './coverage', + exclude: ['dist/**', 'tests/**'], + }, + }, +}); +