From 7ad13f44e776ef19b09d47944fa92c00b5cf376f Mon Sep 17 00:00:00 2001 From: ctrlcakepro <291766549+ctrlcakepro@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:46:28 +0800 Subject: [PATCH 1/2] fix: doctor/exit-code bugs, npx-cache warning, add key wizard; release 0.1.4 - Fix `doctor` reporting configuration/credentials as ready while embedding.base_url/model were still the install template's placeholder values. - Fix the CLI entrypoint always exiting 0: main()'s return value (notably doctor's pass/fail code) was never applied to process.exitCode, so scripted checks against the exit code always saw success. - Warn when `chilon-recall qoder` is run from an npx temporary cache, since the generated .qoder/mcp.json embeds that ephemeral path and breaks silently on the next cache clear or version bump. - Add `chilon-recall key`: a hidden-input prompt for a provider API key that calls the provider's /models endpoint, suggests an embedding and reranker model, and prints ready-to-run env var commands. The key is used for a single request and never written to disk. - Bump package/plugin/Python distribution/server version to 0.1.4 and update the pinned npx examples in both READMEs. --- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 7 ++ README.md | 30 ++++--- README.zh-CN.md | 30 ++++--- package-lock.json | 4 +- package.json | 2 +- pyproject.toml | 2 +- python/chilon_recall/__init__.py | 2 +- scripts/cli.mjs | 101 +++++++++++++++++++-- src/keyWizard.mjs | 34 +++++++ src/models.mjs | 55 ++++++++++++ src/qoder.mjs | 19 ++++ src/secretPrompt.mjs | 58 ++++++++++++ src/server.mjs | 2 +- tests/node/cli-key.test.mjs | 51 +++++++++++ tests/node/cli.test.mjs | 149 +++++++++++++++++++++++++++++++ tests/node/mcp-stdio.test.mjs | 2 +- tests/node/models.test.mjs | 60 +++++++++++++ tests/node/qoder.test.mjs | 47 +++++++++- 19 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 src/keyWizard.mjs create mode 100644 src/models.mjs create mode 100644 src/secretPrompt.mjs create mode 100644 tests/node/cli-key.test.mjs create mode 100644 tests/node/cli.test.mjs create mode 100644 tests/node/models.test.mjs diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6aab424..41ec2c7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chilon-recall", - "version": "0.1.3", + "version": "0.1.4", "description": "Local-first knowledge retrieval for learning and serious knowledge work.", "author": { "name": "ctrlcakepro", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1581277..a81a598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.1.4] - 2026-09-18 + +- Add `chilon-recall key`: a hidden-input prompt for a provider API key that calls the provider's own `/models` endpoint, suggests an embedding and reranker model, and prints ready-to-run `$env:`/`setx`/`export` commands. The key is used for a single request and is never written to disk. +- Fix `doctor` reporting `configuration.ready`/`credentials_ready: true` while `embedding.base_url`/`model` were still the install template's placeholder values. +- Fix the CLI entrypoint always exiting `0`: `main()`'s return value (notably `doctor`'s pass/fail code) was never applied to `process.exitCode`, so scripted checks against the exit code always saw success. +- Warn when `chilon-recall qoder` is run from an `npx` temporary cache: the generated `.qoder/mcp.json` embeds that ephemeral path, which breaks silently on the next cache clear or version bump. + ## [0.1.3] - 2026-09-18 - Add `rag_sync`: a staged, content-hash incremental synchronization of the knowledge index. Unchanged files reuse their existing vectors; added, modified, and deleted files are reconciled. diff --git a/README.md b/README.md index fb43323..c23822d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Local-first knowledge retrieval for learning and serious knowledge work.** -[![version](https://img.shields.io/badge/version-0.1.3-blue.svg)](CHANGELOG.md) +[![version](https://img.shields.io/badge/version-0.1.4-blue.svg)](CHANGELOG.md) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org) [![python](https://img.shields.io/badge/python-%3E%3D3.10-brightgreen.svg)](https://www.python.org) @@ -45,19 +45,21 @@ It is an independent retrieval companion in the [Chilon Knowledge Work Harness]( Run the command below once. It creates a private configuration and a managed Python engine; it never stores API keys in the package or configuration file. ```powershell -npx -y chilon-recall@0.1.3 install C:\path\to\your\documents +npx -y chilon-recall@0.1.4 install C:\path\to\your\documents ``` ### 2. Set your provider key -Open the generated `chilon-recall.json` to choose the provider endpoint and model, then set the key only in your environment. Run `doctor` to confirm the setup. +Open the generated `chilon-recall.json` and replace the placeholder `embedding.base_url` (`https://api.example.com/v1`) and `embedding.model` (`your-embedding-model`) with your real provider values, then set the key only in your environment. `doctor` treats those placeholders as not-ready and will not report `configuration.ready: true` until you edit them. ```powershell $env:RAG_MANAGER_CONFIG = "C:\path\to\your\documents\chilon-recall.json" $env:RAG_API_KEY = "your-provider-key" -npx -y chilon-recall@0.1.3 doctor +npx -y chilon-recall@0.1.4 doctor ``` +Not sure which model to pick? `chilon-recall key --base-url https://api.example.com/v1` prompts for the key once (hidden input), calls the provider's own `/models` endpoint to suggest an embedding and reranker model, and prints ready-to-run `$env:`/`setx`/`export` commands with the key already filled in. The key is used for that one request only — it is never written to a file. + ### 3. Connect one client Start with [Codex](#codex), [Claude Desktop](#claude-desktop), or [Qoder](#qoder). The client starts the local server for you; you do not need to keep a separate terminal open. @@ -96,7 +98,7 @@ Requires Node.js 20+ and Python 3.10+. Use the published, pinned npm release to create a private configuration and install the isolated Python engine with one command: ```powershell -npx -y chilon-recall@0.1.3 install C:\path\to\your\documents +npx -y chilon-recall@0.1.4 install C:\path\to\your\documents ``` This writes `chilon-recall.json` in the document directory and creates a persistent managed Python engine in the operating system's user-data area. Both files are required for local operation; credentials remain outside both of them. @@ -110,9 +112,11 @@ To validate the runtime and private configuration: ```powershell $env:RAG_MANAGER_CONFIG = "C:\path\to\your\documents\chilon-recall.json" $env:RAG_API_KEY = "your-provider-key" -npx -y chilon-recall@0.1.3 doctor +npx -y chilon-recall@0.1.4 doctor ``` +`doctor` exits `0` only when Python, the managed engine, the configuration, and its credentials are all ready — otherwise `1`, so it is safe to gate a script on. It also refuses to call the placeholder `embedding.base_url`/`model` from the install template "ready". + > If you installed from npm, skip ahead to [Connect an MCP client](#connect-an-mcp-client). The remaining subsections are for source checkouts and custom configurations. ### 2. Manual private configuration @@ -149,6 +153,8 @@ The server uses `stdio`, so it normally runs under an MCP client rather than in Use absolute paths in client configuration. They are more reliable than assuming a launch directory. +> **Windows:** prefer `"command": "node"` with an absolute path to `cli.mjs`/`server.mjs` over `"command": "npx"`. Some MCP clients spawn `command` directly (bypassing the shell), and on Windows `npx` is a `.cmd` shim that a direct, non-shell spawn cannot resolve — the client reports the command as not found even though it works from a terminal. `node ` avoids the shim entirely. + | Client | Configuration entry point | | --- | --- | | [Codex](#codex) | `~/.codex/config.toml`, `codex mcp add`, or ChatGPT desktop **Settings → MCP servers** | @@ -172,12 +178,12 @@ tool_timeout_sec = 1800 default_tools_approval_mode = "writes" ``` -**npm release** — run `npx -y chilon-recall@0.1.3 setup` first under the same OS account. A pinned version prevents an unexpected package upgrade from changing a working MCP server. +**npm release** — run `npx -y chilon-recall@0.1.4 setup` first under the same OS account. A pinned version prevents an unexpected package upgrade from changing a working MCP server. ```toml [mcp_servers.chilon-recall] command = "npx" -args = ["-y", "chilon-recall@0.1.3", "mcp"] +args = ["-y", "chilon-recall@0.1.4", "mcp"] env_vars = ["RAG_MANAGER_CONFIG", "RAG_API_KEY", "RAG_RERANK_API_KEY"] startup_timeout_sec = 15 tool_timeout_sec = 1800 @@ -236,7 +242,7 @@ For an npm release, replace `command` and `args` with the following and omit `CH ```json "command": "npx", -"args": ["-y", "chilon-recall@0.1.3", "mcp"] +"args": ["-y", "chilon-recall@0.1.4", "mcp"] ``` Set `RAG_API_KEY` in the environment inherited by Claude Desktop, or add it only to your private local client configuration when your operating system cannot provide it. Claude Desktop stores `env` values in a local JSON file, so restrict file permissions and never commit that file. On Windows, use the virtual environment's `python.exe` path. @@ -246,11 +252,13 @@ Set `RAG_API_KEY` in the environment inherited by Claude Desktop, or add it only The Qoder client loads MCP servers from its own settings, and project-level skills and rules from the `.qoder/` directory. Generate all three from a checkout or an npm install: ```powershell -npx -y chilon-recall@0.1.3 qoder C:\path\to\your\project +npx -y chilon-recall@0.1.4 qoder C:\path\to\your\project ``` This writes `.qoder/mcp.json`, `.qoder/skills//SKILL.md` for every bundled skill, and `.qoder/rules/chilon-recall.md`. Add `--force` to regenerate over existing files. +> **Generating from `npx` embeds an unstable path.** `npx` unpacks the package into a temporary per-run cache (e.g. `...\npm-cache\_npx\\...` on Windows), and the `node`/`cli.mjs` path written into `.qoder/mcp.json` points there. Clearing the npm cache or bumping the pinned version moves that path and the MCP server stops starting, with no error beyond Qoder failing to load it. The command detects this and prints a warning; prefer running `chilon-recall qoder` from a stable install (`npm install -g chilon-recall@0.1.4`, or a source checkout) so the generated path survives cache clears. + Qoder does not read `.qoder/mcp.json` automatically; it is a shareable snippet. Open **Qoder client Settings → MCP → My Servers → + Add**, paste its contents, and replace the `RAG_MANAGER_CONFIG` placeholder with your private configuration path: ```json @@ -338,7 +346,7 @@ The publication check rejects likely secrets, personal email addresses, and user ## Limits -- Version 0.1.3 indexes UTF-8 `.md`, `.txt`, `.rst`, and `.csv` text. Convert PDFs to reviewed text first; scanned PDFs need OCR. +- Version 0.1.4 indexes UTF-8 `.md`, `.txt`, `.rst`, and `.csv` text. Convert PDFs to reviewed text first; scanned PDFs need OCR. - The included chunker recognizes Markdown `#` and `##` headings. It does not yet parse tables, citations, or document-native structure semantically. - `rag_build` is a deliberate full rebuild. Use `rag_sync` for content-hash incremental synchronization; it always writes a new staged FAISS index so row IDs remain aligned with metadata. - Local embedding and reranker models are not bundled in the first release. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7ad53b4..ff9aadf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ **面向学习与严肃知识工作的本地优先知识检索引擎。** -[![version](https://img.shields.io/badge/version-0.1.3-blue.svg)](CHANGELOG.md) +[![version](https://img.shields.io/badge/version-0.1.4-blue.svg)](CHANGELOG.md) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org) [![python](https://img.shields.io/badge/python-%3E%3D3.10-brightgreen.svg)](https://www.python.org) @@ -45,19 +45,21 @@ Chilon Recall 可将你自己的文本资料转换为私有、来源可追溯的 运行一次下面的命令。它会创建私有配置与受管 Python engine;不会把 API key 写入 package 或配置文件。 ```powershell -npx -y chilon-recall@0.1.3 install C:\path\to\your\documents +npx -y chilon-recall@0.1.4 install C:\path\to\your\documents ``` ### 2. 设置 provider key -打开生成的 `chilon-recall.json`,选择 provider endpoint 与 model,再只在环境变量中设置密钥。运行 `doctor` 确认环境可用。 +打开生成的 `chilon-recall.json`,把占位的 `embedding.base_url`(`https://api.example.com/v1`)和 `embedding.model`(`your-embedding-model`)改成你实际使用的 provider 值,再只在环境变量中设置密钥。`doctor` 会把这两个占位值视为"未就绪",在你修改之前不会报告 `configuration.ready: true`。 ```powershell $env:RAG_MANAGER_CONFIG = "C:\path\to\your\documents\chilon-recall.json" $env:RAG_API_KEY = "your-provider-key" -npx -y chilon-recall@0.1.3 doctor +npx -y chilon-recall@0.1.4 doctor ``` +不确定该填哪个 model?`chilon-recall key --base-url https://api.example.com/v1` 会提示你粘贴一次 key(终端隐藏输入),调用该 provider 自己的 `/models` 接口,推荐一个 embedding 和一个 reranker 模型,并打印出已经填好真实 key、可直接复制运行的 `$env:` / `setx` / `export` 命令。这个 key 只用于这一次请求,绝不会被写入任何文件。 + ### 3. 连接一个客户端 从 [Codex](#codex)、[Claude Desktop](#claude-desktop) 或 [Qoder](#qoder) 开始即可。客户端会替你启动本地 server,无需另开终端长期运行。 @@ -96,7 +98,7 @@ Chilon Recall 同时支持直接检索和可复用的学习工作流: 使用已发布且固定版本的 npm package,只需一条命令即可创建私有配置并安装独立 Python engine: ```powershell -npx -y chilon-recall@0.1.3 install C:\path\to\your\documents +npx -y chilon-recall@0.1.4 install C:\path\to\your\documents ``` 该命令会在资料目录写入 `chilon-recall.json`,并在操作系统用户数据目录创建持久的受管 Python engine。这两个文件是本地运行所必需的;凭据不会写入其中任何一个。 @@ -110,9 +112,11 @@ npx -y chilon-recall@0.1.3 install C:\path\to\your\documents ```powershell $env:RAG_MANAGER_CONFIG = "C:\path\to\your\documents\chilon-recall.json" $env:RAG_API_KEY = "your-provider-key" -npx -y chilon-recall@0.1.3 doctor +npx -y chilon-recall@0.1.4 doctor ``` +只有当 Python、托管 engine、配置文件及其凭据都就绪时,`doctor` 才会以退出码 `0` 结束;否则退出码为 `1`,可以放心用于脚本化验收。安装模板里的占位 `embedding.base_url`/`model` 也不会被视为"就绪"。 + > 如果你通过 npm 安装,现在可以直接前往 [连接 MCP 客户端](#连接-mcp-客户端)。以下小节面向源码 checkout 和需要自定义配置的用户。 ### 2. 手动私有配置 @@ -151,6 +155,8 @@ npm start 客户端配置应使用绝对路径,避免依赖不确定的启动目录。 +> **Windows 提示:** 建议用 `"command": "node"` 加 `cli.mjs`/`server.mjs` 的绝对路径,而不是 `"command": "npx"`。部分 MCP 客户端会直接 spawn `command`(绕过 shell),而 Windows 上的 `npx` 是一个 `.cmd` shim,直接、非 shell 的 spawn 无法解析它——客户端会报"找不到该命令",即使它在终端里能正常运行。用 `node <绝对路径>` 可以完全绕开这个 shim。 + | 客户端 | 配置入口 | | --- | --- | | [Codex](#codex) | `~/.codex/config.toml`、`codex mcp add`,或 ChatGPT 桌面端 **Settings → MCP servers** | @@ -174,12 +180,12 @@ tool_timeout_sec = 1800 default_tools_approval_mode = "writes" ``` -**npm 已发布版本** —— 先在同一操作系统账户下运行 `npx -y chilon-recall@0.1.3 setup`。固定版本可避免 package 意外升级改变已正常工作的 MCP server。 +**npm 已发布版本** —— 先在同一操作系统账户下运行 `npx -y chilon-recall@0.1.4 setup`。固定版本可避免 package 意外升级改变已正常工作的 MCP server。 ```toml [mcp_servers.chilon-recall] command = "npx" -args = ["-y", "chilon-recall@0.1.3", "mcp"] +args = ["-y", "chilon-recall@0.1.4", "mcp"] env_vars = ["RAG_MANAGER_CONFIG", "RAG_API_KEY", "RAG_RERANK_API_KEY"] startup_timeout_sec = 15 tool_timeout_sec = 1800 @@ -238,7 +244,7 @@ bundle 会在 `CHILON_RECALL_ROOT` 中运行 `node scripts/cli.mjs mcp`。如果 ```json "command": "npx", -"args": ["-y", "chilon-recall@0.1.3", "mcp"] +"args": ["-y", "chilon-recall@0.1.4", "mcp"] ``` 应在 Claude Desktop 能继承的系统环境中设置 `RAG_API_KEY`;若操作系统无法提供,只能把它加入你本机的私有客户端配置。Claude Desktop 会把 `env` 值保存在本地 JSON 中,因此请限制文件权限,且绝不能提交该配置。Windows 用户应指向虚拟环境中的 `python.exe`。 @@ -248,11 +254,13 @@ bundle 会在 `CHILON_RECALL_ROOT` 中运行 `node scripts/cli.mjs mcp`。如果 Qoder 客户端从自身设置中加载 MCP server,并从项目内的 `.qoder/` 目录加载项目级 skills 与 rules。可用一条命令生成这三部分: ```powershell -npx -y chilon-recall@0.1.3 qoder C:\path\to\your\project +npx -y chilon-recall@0.1.4 qoder C:\path\to\your\project ``` 该命令会写入 `.qoder/mcp.json`、每个内置 skill 对应的 `.qoder/skills//SKILL.md`,以及 `.qoder/rules/chilon-recall.md`。若要覆盖已有文件,请加 `--force`。 +> **用 `npx` 生成会写入一个不稳定的路径。** `npx` 会把包解压到一个临时的、按次运行的缓存目录(Windows 上类似 `...\npm-cache\_npx\\...`),写入 `.qoder/mcp.json` 的 `node`/`cli.mjs` 路径就指向那里。清理 npm 缓存或升级固定版本号都会移动这个路径,导致 MCP server 悄悄起不来,且没有明显报错——只会看到 Qoder 加载失败。该命令会检测到这种情况并打印警告;建议先做一次稳定安装(`npm install -g chilon-recall@0.1.4`,或使用源码 checkout),再从那个安装位置运行 `chilon-recall qoder`,这样生成的路径才不会因清缓存而失效。 + Qoder 不会自动读取 `.qoder/mcp.json`,它只是一份可共享的配置片段。请打开 **Qoder 客户端 Settings → MCP → My Servers → + Add**,粘贴其内容,并把 `RAG_MANAGER_CONFIG` 占位符替换为你的私有配置路径: ```json @@ -340,7 +348,7 @@ npm audit --audit-level=high ## 已知限制 -- v0.1.3 只索引 UTF-8 `.md`、`.txt`、`.rst`、`.csv`。PDF 应先转换为经过核对的文本,扫描版需 OCR。 +- v0.1.4 只索引 UTF-8 `.md`、`.txt`、`.rst`、`.csv`。PDF 应先转换为经过核对的文本,扫描版需 OCR。 - 分块器识别 Markdown `#` 与 `##` 标题,尚未语义解析表格、引文或原生文档结构。 - `rag_build` 保留为全量重建入口;`rag_sync` 使用内容哈希做增量同步,并在 staging 中重建 FAISS,以保持行 ID 与元数据严格对齐。 - 首版不内置本地 embedding/reranker 模型。 diff --git a/package-lock.json b/package-lock.json index 1866807..d0494a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chilon-recall", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chilon-recall", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 66d8d43..339453f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chilon-recall", - "version": "0.1.3", + "version": "0.1.4", "description": "A local-first MCP knowledge engine for grounded learning, document recall, and serious knowledge work.", "type": "module", "bin": { diff --git a/pyproject.toml b/pyproject.toml index d29e7ac..1d44138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "chilon-recall-engine" -version = "0.1.3" +version = "0.1.4" description = "FAISS indexing and retrieval engine for Chilon Recall" readme = "README.md" requires-python = ">=3.10" diff --git a/python/chilon_recall/__init__.py b/python/chilon_recall/__init__.py index 1ea9fcd..236f5cc 100644 --- a/python/chilon_recall/__init__.py +++ b/python/chilon_recall/__init__.py @@ -1,3 +1,3 @@ """Chilon Recall indexing and retrieval engine.""" -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/scripts/cli.mjs b/scripts/cli.mjs index 50ee2dc..1bf723e 100644 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -16,6 +16,9 @@ import { venvPython } from "../src/runtime.mjs"; import { installQoder } from "../src/qoder.mjs"; +import { renderKeySetup } from "../src/keyWizard.mjs"; +import { listProviderModels } from "../src/models.mjs"; +import { promptSecret } from "../src/secretPrompt.mjs"; import { startStdioServer } from "../src/server.mjs"; const help = `Chilon Recall — local-first MCP knowledge retrieval @@ -27,6 +30,10 @@ Usage: Create a private config in a document directory. chilon-recall qoder [--force] Generate the Qoder client surface (.qoder/mcp.json, skills, rules). + chilon-recall key [--base-url ] [--env ] + Paste a provider API key once (hidden input), see live model + choices, and get copy-paste environment variable commands. + The key is used for a single request and is never saved to disk. chilon-recall setup Create or update the isolated Python engine. chilon-recall doctor Check Node, Python engine, and private configuration. chilon-recall mcp Start the stdio MCP server (the default command). @@ -82,6 +89,9 @@ function writeJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } +const PLACEHOLDER_EMBEDDING_BASE_URL = "https://api.example.com/v1"; +const PLACEHOLDER_EMBEDDING_MODEL = "your-embedding-model"; + async function doctor() { const report = { version: await packageVersion(), @@ -119,13 +129,20 @@ async function doctor() { const rerankerCredentialAvailable = config.data.reranker.enabled ? Boolean(process.env[config.data.reranker.api_key_env || config.data.embedding.api_key_env]) : true; + const usesPlaceholderEndpoint = config.data.embedding.base_url === PLACEHOLDER_EMBEDDING_BASE_URL; + const usesPlaceholderModel = config.data.embedding.model === PLACEHOLDER_EMBEDDING_MODEL; report.configuration = { - ready: true, + ready: !usesPlaceholderEndpoint && !usesPlaceholderModel, path: config.configPath, embedding_credential_available: embeddingCredentialAvailable, reranker_credential_available: rerankerCredentialAvailable, - credentials_ready: embeddingCredentialAvailable && rerankerCredentialAvailable + credentials_ready: embeddingCredentialAvailable && rerankerCredentialAvailable && !usesPlaceholderEndpoint && !usesPlaceholderModel }; + if (usesPlaceholderEndpoint || usesPlaceholderModel) { + report.configuration.error = `${config.configPath} still has the install template's placeholder embedding.${ + usesPlaceholderEndpoint ? "base_url" : "model" + }. Edit it to your provider's real endpoint and model (or run \`chilon-recall key\` to find one) before building an index.`; + } } catch (error) { report.configuration.error = error.message; } @@ -138,6 +155,70 @@ async function doctor() { : 1; } +function flagValue(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +export async function keyWizard(args, { io = {} } = {}) { + const prompt = io.promptSecret || promptSecret; + const fetchModels = io.listProviderModels || listProviderModels; + const write = io.write || ((text) => process.stdout.write(text)); + + const envName = flagValue(args, "--env") || "RAG_API_KEY"; + let baseUrl = flagValue(args, "--base-url"); + if (!baseUrl) { + try { + const config = await readConfig(resolveConfigPath()); + baseUrl = config.data.embedding.base_url; + } catch { + // No usable config yet; --base-url is required below. + } + } + if (!baseUrl) { + throw new Error( + "Pass --base-url (e.g. https://api.siliconflow.cn/v1), or set RAG_MANAGER_CONFIG to a config that already has one." + ); + } + + const apiKey = await prompt(`Paste your ${envName} for ${baseUrl} (input is hidden): `); + if (!apiKey) { + throw new Error("No API key entered."); + } + + const modelIds = await fetchModels({ baseUrl, apiKey }); + const setup = renderKeySetup({ baseUrl, apiKey, envName, rerankEnvName: "RAG_RERANK_API_KEY", modelIds }); + const rec = setup.recommendation; + + write(`\nFound ${setup.modelCount} models at ${baseUrl}.\n`); + write( + rec.suggestedEmbeddingModel + ? `Suggested embedding model: ${rec.suggestedEmbeddingModel}\n` + : "No obvious embedding model found by name; check the provider's docs for the right one.\n" + ); + if (rec.embedding.length > 1) { + write(`Other embedding-looking models: ${rec.embedding.filter((id) => id !== rec.suggestedEmbeddingModel).slice(0, 5).join(", ")}\n`); + } + if (rec.suggestedRerankModel) { + write(`Suggested reranker model: ${rec.suggestedRerankModel}\n`); + if (rec.rerank.length > 1) { + write(`Other rerank-looking models: ${rec.rerank.filter((id) => id !== rec.suggestedRerankModel).slice(0, 5).join(", ")}\n`); + } + } + + write(`\nRun ONE line below to make ${envName} available to chilon-recall:\n\n`); + write(` PowerShell, this window only:\n ${setup.commands.powershell.thisWindowOnly}\n`); + write(` PowerShell, persists for new windows (run once):\n ${setup.commands.powershell.persistent}\n\n`); + write(` bash/zsh, this shell only:\n ${setup.commands.bash.thisShellOnly}\n`); + write(` bash/zsh, persists for new shells (run once):\n ${setup.commands.bash.persistent}\n\n`); + for (const note of setup.notes) { + write(`Note: ${note}\n`); + } + write(`\nThen save the model choice with rag_save_config, or edit embedding.model / reranker.model in your chilon-recall.json.\n`); + + return setup; +} + export async function main(argv = process.argv.slice(2)) { const command = argv[0] || "mcp"; if (["help", "--help", "-h"].includes(command)) { @@ -186,6 +267,10 @@ export async function main(argv = process.argv.slice(2)) { writeJson(await installQoder(positional[0], { force })); return 0; } + if (command === "key") { + await keyWizard(argv.slice(1)); + return 0; + } if (command === "doctor") return doctor(); if (command === "mcp") { process.env.CHILON_RECALL_PYTHON = await resolveEnginePython(); @@ -197,8 +282,12 @@ export async function main(argv = process.argv.slice(2)) { const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (invokedDirectly) { - main().catch((error) => { - process.stderr.write(`Chilon Recall CLI failed: ${error.message}\n`); - process.exitCode = 1; - }); + main() + .then((code) => { + if (typeof code === "number") process.exitCode = code; + }) + .catch((error) => { + process.stderr.write(`Chilon Recall CLI failed: ${error.message}\n`); + process.exitCode = 1; + }); } diff --git a/src/keyWizard.mjs b/src/keyWizard.mjs new file mode 100644 index 0000000..d25b8d9 --- /dev/null +++ b/src/keyWizard.mjs @@ -0,0 +1,34 @@ +import { recommendModels } from "./models.mjs"; + +// Turns a fetched model list into copy-paste-ready environment variable commands. +// This is pure formatting: it never writes the key anywhere, it only renders text +// for the caller (the CLI) to print to the user's own terminal. +export function renderKeySetup({ baseUrl, apiKey, envName, rerankEnvName, modelIds }) { + const recommendation = recommendModels(modelIds); + const notes = [ + "This output only goes to your terminal; chilon-recall never writes the key to a file.", + "setx / the persistent export only affect new terminals — restart your MCP client after running them." + ]; + if (rerankEnvName && rerankEnvName !== envName) { + notes.push( + `If your reranker uses a different credential, re-run \`chilon-recall key --env ${rerankEnvName}\` for it.` + ); + } + return { + baseUrl, + envName, + modelCount: modelIds.length, + recommendation, + commands: { + powershell: { + thisWindowOnly: `$env:${envName} = "${apiKey}"`, + persistent: `setx ${envName} "${apiKey}"` + }, + bash: { + thisShellOnly: `export ${envName}="${apiKey}"`, + persistent: `echo 'export ${envName}="${apiKey}"' >> ~/.bashrc # or ~/.zshrc` + } + }, + notes + }; +} diff --git a/src/models.mjs b/src/models.mjs new file mode 100644 index 0000000..d9e7793 --- /dev/null +++ b/src/models.mjs @@ -0,0 +1,55 @@ +// Live model discovery for the OpenAI-compatible `/models` endpoint. +// +// The API key passed in here is used for exactly one outbound HTTP request and is +// never written to disk, logged, or echoed back inside any structured result. + +const DEFAULT_TIMEOUT_MS = 15_000; + +const EMBEDDING_HINTS = [/embed/i, /bge/i, /gte/i, /m3e/i, /text-embedding/i]; +const RERANK_HINTS = [/rerank/i]; + +function normalizeBaseUrl(baseUrl) { + return baseUrl.replace(/\/+$/, ""); +} + +export async function listProviderModels({ baseUrl, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch }) { + const url = `${normalizeBaseUrl(baseUrl)}/models`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response; + try { + response = await fetchImpl(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal + }); + } catch (error) { + if (error.name === "AbortError") { + throw new Error(`Timed out contacting ${url} after ${timeoutMs} ms.`); + } + throw new Error(`Could not reach ${url}: ${error.message}`); + } finally { + clearTimeout(timer); + } + if (!response.ok) { + throw new Error( + `Provider rejected the request (${response.status} ${response.statusText}). Check the base URL and API key.` + ); + } + const payload = await response.json(); + const entries = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : []; + const ids = entries.map((entry) => (typeof entry === "string" ? entry : entry?.id)).filter((id) => typeof id === "string"); + return [...new Set(ids)].sort(); +} + +export function recommendModels(modelIds) { + const embedding = modelIds.filter((id) => EMBEDDING_HINTS.some((pattern) => pattern.test(id))); + const rerank = modelIds.filter((id) => RERANK_HINTS.some((pattern) => pattern.test(id))); + const other = modelIds.filter((id) => !embedding.includes(id) && !rerank.includes(id)); + return { + embedding, + rerank, + other, + suggestedEmbeddingModel: embedding[0] || null, + suggestedRerankModel: rerank[0] || null + }; +} diff --git a/src/qoder.mjs b/src/qoder.mjs index bce2907..00652c4 100644 --- a/src/qoder.mjs +++ b/src/qoder.mjs @@ -24,6 +24,14 @@ export const QODER_ENV_VARS = [ const RULE_FILE = "chilon-recall.md"; +// npx unpacks the package into a per-invocation cache directory (e.g. `npm-cache/_npx/` on +// Windows, `~/.npm/_npx/` elsewhere). `packageRoot` there is real but not durable: clearing +// the npm cache or bumping the pinned version moves it, silently breaking any mcp.json generated +// from it. Detect that case so callers can tell the user to install first. +export function looksLikeEphemeralNpxCache(root) { + return root.split(path.sep).includes("_npx"); +} + const RULE_BODY = `# Chilon Recall retrieval rules Apply when a request depends on the local Chilon Recall knowledge base. @@ -115,12 +123,23 @@ export async function installQoder(directory, { force = false, root = packageRoo throw error; } + const ephemeralNpxCache = looksLikeEphemeralNpxCache(root); + return { directory: qoderDirectory, written, mcp_server: QODER_SERVER_NAME, forwarded_env: QODER_ENV_VARS, + ephemeral_npx_cache: ephemeralNpxCache, next: [ + ...(ephemeralNpxCache + ? [ + `Warning: this was generated by npx, so .qoder/mcp.json points into a temporary npx cache (${root}). ` + + "Clearing the npm cache or bumping the pinned version moves that path and breaks the server silently. " + + "Run `npm install -g chilon-recall@` (or use a source checkout) and re-run `chilon-recall qoder` " + + "from that install so the generated path is durable." + ] + : []), "Open Qoder client Settings -> MCP -> My Servers -> + Add and paste .qoder/mcp.json, replacing the RAG_MANAGER_CONFIG placeholder.", "Provide RAG_API_KEY (and RAG_RERANK_API_KEY when reranking is enabled) through the environment Qoder inherits; never commit them.", "Restart the Qoder client so the generated skills and rules are loaded." diff --git a/src/secretPrompt.mjs b/src/secretPrompt.mjs new file mode 100644 index 0000000..7021768 --- /dev/null +++ b/src/secretPrompt.mjs @@ -0,0 +1,58 @@ +import readline from "node:readline"; + +// Reads one line of sensitive input without echoing it to the terminal or any log. +// Falls back to an unmasked single-line read when stdin is not an interactive TTY +// (piped input), since there is no terminal to mask against. +export function promptSecret(label, { input = process.stdin, output = process.stdout } = {}) { + return new Promise((resolve, reject) => { + if (!input.isTTY) { + const rl = readline.createInterface({ input, terminal: false }); + rl.question("", (answer) => { + rl.close(); + resolve(answer.trim()); + }); + return; + } + + output.write(label); + input.setRawMode(true); + input.resume(); + input.setEncoding("utf8"); + let value = ""; + + const cleanup = () => { + input.setRawMode(false); + input.pause(); + input.removeListener("data", onData); + }; + + const onData = (char) => { + switch (char) { + case "\n": + case "\r": + case "": + cleanup(); + output.write("\n"); + resolve(value.trim()); + return; + case "": + cleanup(); + output.write("\n"); + reject(new Error("Cancelled.")); + return; + case "": + case "\b": + if (value.length > 0) { + value = value.slice(0, -1); + output.write("\b \b"); + } + return; + default: + value += char; + output.write("*"); + } + }; + + input.on("data", onData); + }); +} diff --git a/src/server.mjs b/src/server.mjs index ab8624e..1157e1f 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -90,7 +90,7 @@ function studyPacket(mode, task, question, retrieval, sections, notes) { export function createChilonRecallServer({ configPath = resolveConfigPath(), confirmations } = {}) { const confirmationStore = confirmations || new ConfirmationStore(); const server = new McpServer( - { name: "chilon-recall", version: "0.1.3" }, + { name: "chilon-recall", version: "0.1.4" }, { instructions: "Chilon Recall is a local, source-backed knowledge engine. Check rag_status before build or recovery work. Prefer read-only retrieval tools. rag_build, rag_clear_index, and rag_restore_index require a preview followed by a matching confirmation token; never bypass that sequence. Report source paths and evidence limits, and do not claim that retrieved text proves more than it contains." diff --git a/tests/node/cli-key.test.mjs b/tests/node/cli-key.test.mjs new file mode 100644 index 0000000..d2bfe5e --- /dev/null +++ b/tests/node/cli-key.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { keyWizard } from "../../scripts/cli.mjs"; + +test("keyWizard requires --base-url when no config is available", async () => { + await assert.rejects( + () => keyWizard([], { io: { promptSecret: async () => "sk-test" } }), + /--base-url/ + ); +}); + +test("keyWizard rejects an empty key without calling the provider", async () => { + let fetched = false; + await assert.rejects( + () => + keyWizard(["--base-url", "https://api.example.com/v1"], { + io: { + promptSecret: async () => "", + listProviderModels: async () => { + fetched = true; + return []; + } + } + }), + /No API key entered/ + ); + assert.equal(fetched, false); +}); + +test("keyWizard prints copy-paste commands and a model recommendation", async () => { + const written = []; + const result = await keyWizard(["--base-url", "https://api.siliconflow.cn/v1", "--env", "RAG_API_KEY"], { + io: { + promptSecret: async () => "sk-test-key", + listProviderModels: async ({ baseUrl, apiKey }) => { + assert.equal(baseUrl, "https://api.siliconflow.cn/v1"); + assert.equal(apiKey, "sk-test-key"); + return ["BAAI/bge-m3", "Qwen/Qwen3-Reranker-8B"]; + }, + write: (text) => written.push(text) + } + }); + + assert.equal(result.recommendation.suggestedEmbeddingModel, "BAAI/bge-m3"); + assert.equal(result.recommendation.suggestedRerankModel, "Qwen/Qwen3-Reranker-8B"); + const output = written.join(""); + assert.match(output, /Suggested embedding model: BAAI\/bge-m3/); + assert.match(output, /setx RAG_API_KEY "sk-test-key"/); + assert.match(output, /export RAG_API_KEY="sk-test-key"/); +}); diff --git a/tests/node/cli.test.mjs b/tests/node/cli.test.mjs new file mode 100644 index 0000000..2892706 --- /dev/null +++ b/tests/node/cli.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +import { main } from "../../scripts/cli.mjs"; + +const execFileAsync = promisify(execFile); +const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../scripts/cli.mjs"); + +async function withCapturedStdout(fn) { + const chunks = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk) => { + chunks.push(chunk.toString()); + return true; + }; + try { + const result = await fn(); + return { result, output: chunks.join("") }; + } finally { + process.stdout.write = original; + } +} + +async function withEnv(overrides, fn) { + const previous = {}; + for (const [key, value] of Object.entries(overrides)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +test("doctor treats install-template placeholder embedding.base_url/model as not ready", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "chilon-doctor-")); + const configPath = path.join(directory, "chilon-recall.json"); + await fs.writeFile( + configPath, + JSON.stringify( + { + version: 1, + project_dir: ".", + rag_dir: "./.chilon-recall", + file_extensions: [".md"], + embedding: { + adapter: "openai-compatible", + base_url: "https://api.example.com/v1", + model: "your-embedding-model", + api_key_env: "CHILON_RECALL_TEST_PLACEHOLDER_KEY" + }, + reranker: { enabled: false, adapter: "cohere-compatible" }, + chunking: { max_chars: 800, overlap_chars: 100, min_chars: 40 }, + retrieval: { retrieve_top_k: 20, rerank_top_n: 5 }, + build: { batch_size: 16 } + }, + null, + 2 + ) + ); + + try { + await withEnv( + { RAG_MANAGER_CONFIG: configPath, CHILON_RECALL_TEST_PLACEHOLDER_KEY: "sk-real-key" }, + async () => { + const { output } = await withCapturedStdout(() => main(["doctor"])); + const report = JSON.parse(output); + assert.equal(report.configuration.ready, false); + assert.equal(report.configuration.embedding_credential_available, true); + assert.equal(report.configuration.credentials_ready, false); + assert.match(report.configuration.error, /placeholder/); + assert.match(report.configuration.error, /base_url/); + } + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +test("doctor reports configuration ready once the placeholder values are replaced", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "chilon-doctor-")); + const configPath = path.join(directory, "chilon-recall.json"); + await fs.writeFile( + configPath, + JSON.stringify( + { + version: 1, + project_dir: ".", + rag_dir: "./.chilon-recall", + file_extensions: [".md"], + embedding: { + adapter: "openai-compatible", + base_url: "https://api.siliconflow.cn/v1", + model: "BAAI/bge-m3", + api_key_env: "CHILON_RECALL_TEST_PLACEHOLDER_KEY" + }, + reranker: { enabled: false, adapter: "cohere-compatible" }, + chunking: { max_chars: 800, overlap_chars: 100, min_chars: 40 }, + retrieval: { retrieve_top_k: 20, rerank_top_n: 5 }, + build: { batch_size: 16 } + }, + null, + 2 + ) + ); + + try { + await withEnv( + { RAG_MANAGER_CONFIG: configPath, CHILON_RECALL_TEST_PLACEHOLDER_KEY: "sk-real-key" }, + async () => { + const { output } = await withCapturedStdout(() => main(["doctor"])); + const report = JSON.parse(output); + assert.equal(report.configuration.ready, true); + assert.equal(report.configuration.credentials_ready, true); + assert.equal(report.configuration.error, undefined); + } + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +test("CLI exit code reflects an unknown command as failure", async () => { + await assert.rejects( + () => execFileAsync(process.execPath, [cliPath, "not-a-real-command"]), + (error) => { + assert.equal(error.code, 1); + assert.match(error.stderr, /Unknown command/); + return true; + } + ); +}); + +test("CLI exit code reflects a recognized command as success", async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, [cliPath, "help"]); + assert.equal(stderr, ""); + assert.match(stdout, /Chilon Recall/); +}); diff --git a/tests/node/mcp-stdio.test.mjs b/tests/node/mcp-stdio.test.mjs index 8bae388..58957d2 100644 --- a/tests/node/mcp-stdio.test.mjs +++ b/tests/node/mcp-stdio.test.mjs @@ -80,7 +80,7 @@ test("stdio MCP discovers tools and invokes rag_status", async () => { env, stderr: "pipe" }); - const client = new Client({ name: "chilon-recall-test", version: "0.1.3" }); + const client = new Client({ name: "chilon-recall-test", version: "0.1.4" }); try { await client.connect(transport); const listed = await client.listTools(); diff --git a/tests/node/models.test.mjs b/tests/node/models.test.mjs new file mode 100644 index 0000000..6780f7d --- /dev/null +++ b/tests/node/models.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { listProviderModels, recommendModels } from "../../src/models.mjs"; +import { renderKeySetup } from "../../src/keyWizard.mjs"; + +test("listProviderModels normalizes the base URL and parses OpenAI-style model lists", async () => { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ + data: [{ id: "BAAI/bge-m3" }, { id: "Qwen/Qwen3-Reranker-8B" }, { id: "BAAI/bge-m3" }] + }) + }; + }; + + const ids = await listProviderModels({ + baseUrl: "https://api.siliconflow.cn/v1/", + apiKey: "sk-test", + fetchImpl + }); + + assert.deepEqual(ids, ["BAAI/bge-m3", "Qwen/Qwen3-Reranker-8B"]); + assert.equal(calls[0].url, "https://api.siliconflow.cn/v1/models"); + assert.equal(calls[0].init.headers.Authorization, "Bearer sk-test"); +}); + +test("listProviderModels surfaces provider errors without leaking the key", async () => { + const fetchImpl = async () => ({ ok: false, status: 401, statusText: "Unauthorized" }); + await assert.rejects( + () => listProviderModels({ baseUrl: "https://api.example.com/v1", apiKey: "sk-secret", fetchImpl }), + /401/ + ); +}); + +test("recommendModels groups by embedding/rerank keyword hints", () => { + const rec = recommendModels(["BAAI/bge-m3", "Qwen/Qwen3-Reranker-8B", "some-other-model"]); + assert.equal(rec.suggestedEmbeddingModel, "BAAI/bge-m3"); + assert.equal(rec.suggestedRerankModel, "Qwen/Qwen3-Reranker-8B"); + assert.deepEqual(rec.other, ["some-other-model"]); +}); + +test("renderKeySetup never writes the key anywhere but the returned command text", () => { + const setup = renderKeySetup({ + baseUrl: "https://api.siliconflow.cn/v1", + apiKey: "sk-super-secret", + envName: "RAG_API_KEY", + rerankEnvName: "RAG_RERANK_API_KEY", + modelIds: ["BAAI/bge-m3", "Qwen/Qwen3-Reranker-8B"] + }); + + assert.equal(setup.commands.powershell.thisWindowOnly, '$env:RAG_API_KEY = "sk-super-secret"'); + assert.equal(setup.commands.powershell.persistent, 'setx RAG_API_KEY "sk-super-secret"'); + assert.match(setup.commands.bash.thisShellOnly, /^export RAG_API_KEY="sk-super-secret"$/); + assert.ok(setup.notes.some((note) => note.includes("never writes the key to a file"))); +}); diff --git a/tests/node/qoder.test.mjs b/tests/node/qoder.test.mjs index bf1c56a..6f0401a 100644 --- a/tests/node/qoder.test.mjs +++ b/tests/node/qoder.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { installQoder, qoderMcpConfig, QODER_SERVER_NAME } from "../../src/qoder.mjs"; +import { installQoder, looksLikeEphemeralNpxCache, qoderMcpConfig, QODER_SERVER_NAME } from "../../src/qoder.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -53,6 +53,51 @@ test("installs skills and rules without writing credentials", async () => { } }); +test("detects an npx temporary cache root by its `_npx` cache segment", () => { + assert.equal( + looksLikeEphemeralNpxCache( + path.join("C:", "Users", "ROG", "AppData", "Local", "npm-cache", "_npx", "96c86a08a0f332a4", "node_modules", "chilon-recall") + ), + true + ); + assert.equal(looksLikeEphemeralNpxCache(path.join(os.tmpdir(), "_npx", "abc123", "node_modules", "chilon-recall")), true); + assert.equal(looksLikeEphemeralNpxCache("/opt/chilon-recall"), false); + assert.equal(looksLikeEphemeralNpxCache(root), false); +}); + +async function copyDirectory(source, destination) { + await fs.mkdir(destination, { recursive: true }); + for (const entry of await fs.readdir(source, { withFileTypes: true })) { + const from = path.join(source, entry.name); + const to = path.join(destination, entry.name); + if (entry.isDirectory()) await copyDirectory(from, to); + else await fs.copyFile(from, to); + } +} + +test("warns in `next` when qoder is generated from an npx cache root, and stays silent otherwise", async () => { + const cacheDirectory = await temporaryDirectory(); + const npxRoot = path.join(cacheDirectory, "npm-cache", "_npx", "abc123def456", "node_modules", "chilon-recall"); + await copyDirectory(path.join(root, "skills"), path.join(npxRoot, "skills")); + + const npxProjectDirectory = await temporaryDirectory(); + const stableProjectDirectory = await temporaryDirectory(); + try { + const npxResult = await installQoder(npxProjectDirectory, { node: "node", root: npxRoot }); + assert.equal(npxResult.ephemeral_npx_cache, true); + assert.ok(npxResult.next.some((line) => line.includes("Warning: this was generated by npx"))); + assert.ok(npxResult.next.some((line) => line.includes(npxRoot))); + + const stableResult = await installQoder(stableProjectDirectory, { node: "node", root }); + assert.equal(stableResult.ephemeral_npx_cache, false); + assert.ok(!stableResult.next.some((line) => line.includes("Warning: this was generated by npx"))); + } finally { + await fs.rm(cacheDirectory, { recursive: true, force: true }); + await fs.rm(npxProjectDirectory, { recursive: true, force: true }); + await fs.rm(stableProjectDirectory, { recursive: true, force: true }); + } +}); + test("refuses to overwrite generated files without --force", async () => { const directory = await temporaryDirectory(); try { From 8a59bc64aa20a7b53b163b2add9669d5f8735f81 Mon Sep 17 00:00:00 2001 From: ctrlcakepro <291766549+ctrlcakepro@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:57:24 +0800 Subject: [PATCH 2/2] fix: paste hang in key wizard, key leak in errors, URL doubling, raw ZodError output, silent setup - secretPrompt: onData treated a whole pasted chunk as one character, so a paste with any newline (trailing or embedded) fell into the default case and hung forever instead of resolving/rejecting. Iterate per character. - models: a header-unsafe key (e.g. one with an embedded newline) made Headers.append throw with the raw key embedded in its own message, which was then wrapped straight into the thrown error, leaking the plaintext key and mislabeling the failure as a network problem. Validate the key before ever calling fetch, and redact it from any error that still slips through. - models: reranker model names matching /bge/i etc. also landed in the embedding bucket, so recommendModels could suggest a reranker as the embedding model. Exclude rerank matches from the embedding bucket first. - keyWizard: the "persists" commands (setx / >> ~/.bashrc) write the plaintext key to the registry/disk if the user runs them, and land in shell history/scrollback either way; the notes didn't say so. - providers.py / models.mjs: a base_url that already ends with the target suffix (e.g. "/embeddings") no longer gets it appended twice. - config: ZodError#message is a JSON dump of its issues; that was surfacing verbatim in MCP tool errors and doctor's report for something as ordinary as a typo'd config key. Added describeConfigError to turn it into a sentence. - runtime/cli: setupEngine (venv + pip install) ran silently for up to a minute with zero output, looking hung. Stream phase announcements and the underlying process output to stderr, keeping stdout's single-JSON contract intact. --- python/chilon_recall/providers.py | 12 +++- scripts/cli.mjs | 25 +++++--- src/config.mjs | 12 ++++ src/keyWizard.mjs | 3 +- src/models.mjs | 39 ++++++++++++- src/runtime.mjs | 19 ++++++- src/secretPrompt.mjs | 53 +++++++++-------- src/server.mjs | 3 +- tests/node/config.test.mjs | 23 ++++++++ tests/node/models.test.mjs | 83 +++++++++++++++++++++++++++ tests/node/runtime.test.mjs | 36 +++++++++++- tests/node/secretPrompt.test.mjs | 94 +++++++++++++++++++++++++++++++ tests/python/test_providers.py | 41 ++++++++++++++ 13 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 tests/node/secretPrompt.test.mjs create mode 100644 tests/python/test_providers.py diff --git a/python/chilon_recall/providers.py b/python/chilon_recall/providers.py index 31c268c..b4f72c6 100644 --- a/python/chilon_recall/providers.py +++ b/python/chilon_recall/providers.py @@ -12,11 +12,21 @@ def _headers(api_key: str) -> dict[str, str]: return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} +def _join_endpoint(base_url: str, suffix: str) -> str: + trimmed = base_url.rstrip("/") + # base_url is documented as the provider's OpenAI-compatible root (e.g. ".../v1"), + # but a config that already points at the full endpoint (e.g. ".../v1/embeddings", + # copied from provider docs) must not get the suffix appended a second time. + if trimmed.lower().endswith(suffix.lower()): + return trimmed + return trimmed + suffix + + def embed_texts(config: dict[str, Any], texts: list[str], *, document: bool) -> np.ndarray: settings = config["embedding"] prefix = settings.get("doc_prefix" if document else "query_prefix", "") inputs = [prefix + text for text in texts] - endpoint = settings["base_url"].rstrip("/") + "/embeddings" + endpoint = _join_endpoint(settings["base_url"], "/embeddings") with httpx.Client(timeout=60.0, trust_env=False) as client: response = client.post( endpoint, diff --git a/scripts/cli.mjs b/scripts/cli.mjs index 1bf723e..91a5eb9 100644 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -4,7 +4,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { readConfig, resolveConfigPath } from "../src/config.mjs"; +import { describeConfigError, readConfig, resolveConfigPath } from "../src/config.mjs"; import { fileExists, findBootstrapPython, @@ -72,11 +72,11 @@ export async function initializeConfig(directory, { force = false } = {}) { return configPath; } -export async function installProject(directory, { force = false, setup = setupEngine } = {}) { +export async function installProject(directory, { force = false, setup = setupEngine, onProgress, onOutput } = {}) { if (!directory) { throw new Error("install requires a document directory."); } - const engine = await setup(); + const engine = await setup({ onProgress, onOutput }); const configPath = await initializeConfig(directory, { force }); return { config: configPath, @@ -85,6 +85,17 @@ export async function installProject(directory, { force = false, setup = setupEn }; } +// `setup`/`install` print a single JSON result on stdout (scripts parse it), so +// engine-setup progress — otherwise up to a minute of total silence while pip +// installs faiss/numpy/httpx — goes to stderr instead, where it can't corrupt that +// JSON but still reaches an interactive terminal. +function cliProgress() { + return { + onProgress: (message) => process.stderr.write(`${message}\n`), + onOutput: (chunk) => process.stderr.write(chunk) + }; +} + function writeJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } @@ -144,7 +155,7 @@ async function doctor() { }. Edit it to your provider's real endpoint and model (or run \`chilon-recall key\` to find one) before building an index.`; } } catch (error) { - report.configuration.error = error.message; + report.configuration.error = describeConfigError(error); } writeJson(report); return report.bootstrap_python.ready && @@ -230,7 +241,7 @@ export async function main(argv = process.argv.slice(2)) { return 0; } if (command === "setup") { - writeJson(await setupEngine()); + writeJson(await setupEngine(cliProgress())); return 0; } if (command === "install") { @@ -240,7 +251,7 @@ export async function main(argv = process.argv.slice(2)) { if (args.filter((arg) => arg !== "--force").length > 1) { throw new Error("`install` accepts at most one document directory."); } - writeJson(await installProject(directory, { force })); + writeJson(await installProject(directory, { force, ...cliProgress() })); return 0; } if (command === "init") { @@ -287,7 +298,7 @@ if (invokedDirectly) { if (typeof code === "number") process.exitCode = code; }) .catch((error) => { - process.stderr.write(`Chilon Recall CLI failed: ${error.message}\n`); + process.stderr.write(`Chilon Recall CLI failed: ${describeConfigError(error)}\n`); process.exitCode = 1; }); } diff --git a/src/config.mjs b/src/config.mjs index 495e17c..c35e920 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -87,6 +87,18 @@ export const configSchema = z } }); +// A ZodError's own `.message` is a JSON dump of `.issues` (Zod v3 default), so any +// caller that surfaces `error.message` directly (a CLI report, an MCP tool error) +// ends up printing raw internal error-object JSON at the user instead of a sentence. +export function describeConfigError(error) { + if (error instanceof z.ZodError) { + return error.issues + .map((issue) => `${issue.path.length ? issue.path.join(".") : "(root)"}: ${issue.message}`) + .join("; "); + } + return error instanceof Error ? error.message : String(error); +} + function stripBom(text) { return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; } diff --git a/src/keyWizard.mjs b/src/keyWizard.mjs index d25b8d9..ac92077 100644 --- a/src/keyWizard.mjs +++ b/src/keyWizard.mjs @@ -6,7 +6,8 @@ import { recommendModels } from "./models.mjs"; export function renderKeySetup({ baseUrl, apiKey, envName, rerankEnvName, modelIds }) { const recommendation = recommendModels(modelIds); const notes = [ - "This output only goes to your terminal; chilon-recall never writes the key to a file.", + "This output only goes to your terminal; chilon-recall itself never writes the key to a file.", + "The \"persists\" commands do, though, if you choose to run them: `setx` stores the key in your Windows user environment (registry), and the `>> ~/.bashrc` line appends it in plaintext to that file. Either command will also remain in your shell history and terminal scrollback.", "setx / the persistent export only affect new terminals — restart your MCP client after running them." ]; if (rerankEnvName && rerankEnvName !== envName) { diff --git a/src/models.mjs b/src/models.mjs index d9e7793..ab26eed 100644 --- a/src/models.mjs +++ b/src/models.mjs @@ -12,8 +12,33 @@ function normalizeBaseUrl(baseUrl) { return baseUrl.replace(/\/+$/, ""); } +// baseUrl is documented as the provider's OpenAI-compatible root (e.g. ".../v1"), but a +// value that already points at the full endpoint (e.g. ".../v1/models", copied from +// provider docs) must not get the suffix appended a second time. +function joinEndpoint(baseUrl, suffix) { + const trimmed = normalizeBaseUrl(baseUrl); + return trimmed.toLowerCase().endsWith(suffix.toLowerCase()) ? trimmed : `${trimmed}${suffix}`; +} + +// HTTP header values can't contain CR/LF or other control characters; a runtime's +// Headers implementation rejects them, and does so by embedding the offending value +// (the key itself) verbatim in its own error message. Reject those keys ourselves +// first so that value never reaches Headers, is never quoted back at the caller, and +// isn't misreported as a network problem. +const INVALID_HEADER_VALUE_CHARS = /[\r\n\0]/; + +function redactKey(message, apiKey) { + return apiKey ? message.split(apiKey).join("[REDACTED]") : message; +} + export async function listProviderModels({ baseUrl, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch }) { - const url = `${normalizeBaseUrl(baseUrl)}/models`; + const url = joinEndpoint(baseUrl, "/models"); + if (INVALID_HEADER_VALUE_CHARS.test(apiKey)) { + throw new Error( + "The API key contains a line break or control character, so it can't be sent as an HTTP header. " + + "Check that you copied only the key itself, with no extra newline." + ); + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); let response; @@ -26,7 +51,10 @@ export async function listProviderModels({ baseUrl, apiKey, timeoutMs = DEFAULT_ if (error.name === "AbortError") { throw new Error(`Timed out contacting ${url} after ${timeoutMs} ms.`); } - throw new Error(`Could not reach ${url}: ${error.message}`); + // Belt-and-suspenders: even if some other error ends up embedding the key + // (a custom fetchImpl, a future runtime change), never let it leave this + // function unredacted. + throw new Error(`Could not reach ${url}: ${redactKey(error.message, apiKey)}`); } finally { clearTimeout(timer); } @@ -42,8 +70,13 @@ export async function listProviderModels({ baseUrl, apiKey, timeoutMs = DEFAULT_ } export function recommendModels(modelIds) { - const embedding = modelIds.filter((id) => EMBEDDING_HINTS.some((pattern) => pattern.test(id))); + // Rerank hints take priority: some embedding patterns (e.g. /bge/i) also match + // reranker model names like "bge-reranker-large", so rerank matches must be + // excluded from the embedding bucket rather than the other way around. const rerank = modelIds.filter((id) => RERANK_HINTS.some((pattern) => pattern.test(id))); + const embedding = modelIds.filter( + (id) => !rerank.includes(id) && EMBEDDING_HINTS.some((pattern) => pattern.test(id)) + ); const other = modelIds.filter((id) => !embedding.includes(id) && !rerank.includes(id)); return { embedding, diff --git a/src/runtime.mjs b/src/runtime.mjs index bbedfe0..1e8fe58 100644 --- a/src/runtime.mjs +++ b/src/runtime.mjs @@ -47,7 +47,11 @@ export async function fileExists(target) { } } -export function runProcess(command, args, { cwd = packageRoot, env = process.env, timeoutMs = 10 * 60 * 1000 } = {}) { +export function runProcess( + command, + args, + { cwd = packageRoot, env = process.env, timeoutMs = 10 * 60 * 1000, onOutput } = {} +) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, @@ -63,9 +67,11 @@ export function runProcess(command, args, { cwd = packageRoot, env = process.env }, timeoutMs); child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); + onOutput?.(chunk.toString("utf8")); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); + onOutput?.(chunk.toString("utf8")); }); child.on("error", (error) => { clearTimeout(timer); @@ -122,23 +128,34 @@ export async function resolveEnginePython(options = {}) { export async function setupEngine(options = {}) { const env = options.env || process.env; const root = options.packageRoot || packageRoot; + // Installing the engine downloads and builds faiss/numpy/httpx, which routinely + // takes the better part of a minute with no output of its own reaching the + // caller — it looks hung. onProgress announces each phase and onOutput (wired + // through to every runProcess call below via the `...options` spread) streams + // the underlying pip/venv output live, so callers can show real progress + // without touching this function's own return value or stdout contract. + const onProgress = options.onProgress || (() => {}); const runtime = runtimeHome(options); const environment = venvDir(options); const enginePython = venvPython(options); const bootstrap = await findBootstrapPython({ ...options, env }); await fs.mkdir(runtime, { recursive: true }); if (!(await fileExists(enginePython))) { + onProgress("Creating a private Python virtual environment..."); await runProcess(bootstrap.command, [...bootstrap.args, "-m", "venv", environment], { ...options, env }); } + onProgress("Installing the engine and its dependencies (faiss, numpy, httpx) — this can take a minute..."); await runProcess( enginePython, ["-m", "pip", "install", "--disable-pip-version-check", "--upgrade", path.resolve(root)], { ...options, env, timeoutMs: 20 * 60 * 1000 } ); + onProgress("Verifying the installation..."); await runProcess(enginePython, ["-c", "import faiss, httpx, numpy, chilon_recall; print('ok')"], { ...options, env }); + onProgress("Engine ready."); return { runtime_home: runtime, python: enginePython, diff --git a/src/secretPrompt.mjs b/src/secretPrompt.mjs index 7021768..84958fd 100644 --- a/src/secretPrompt.mjs +++ b/src/secretPrompt.mjs @@ -26,30 +26,35 @@ export function promptSecret(label, { input = process.stdin, output = process.st input.removeListener("data", onData); }; - const onData = (char) => { - switch (char) { - case "\n": - case "\r": - case "": - cleanup(); - output.write("\n"); - resolve(value.trim()); - return; - case "": - cleanup(); - output.write("\n"); - reject(new Error("Cancelled.")); - return; - case "": - case "\b": - if (value.length > 0) { - value = value.slice(0, -1); - output.write("\b \b"); - } - return; - default: - value += char; - output.write("*"); + // A single "data" event can carry more than one character: pasting into a raw-mode + // stdin delivers the whole clipboard chunk (including any newlines it contains) as + // one event, not one event per character. Iterate so paste and keystrokes behave alike. + const onData = (chunk) => { + for (const char of chunk) { + switch (char) { + case "\n": + case "\r": + case "": + cleanup(); + output.write("\n"); + resolve(value.trim()); + return; + case "": + cleanup(); + output.write("\n"); + reject(new Error("Cancelled.")); + return; + case "": + case "\b": + if (value.length > 0) { + value = value.slice(0, -1); + output.write("\b \b"); + } + break; + default: + value += char; + output.write("*"); + } } }; diff --git a/src/server.mjs b/src/server.mjs index 1157e1f..1bc2478 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -8,6 +8,7 @@ import { z } from "zod"; import { applyConfigPatch, + describeConfigError, publicConfig, readConfig, resolveConfigPath, @@ -37,7 +38,7 @@ function textAndStructured(data) { function toolError(error) { return { isError: true, - content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] + content: [{ type: "text", text: describeConfigError(error) }] }; } diff --git a/tests/node/config.test.mjs b/tests/node/config.test.mjs index 1d92338..d0f6c5f 100644 --- a/tests/node/config.test.mjs +++ b/tests/node/config.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; import { applyConfigPatch, configSchema, + describeConfigError, publicConfig, readConfig, writeConfigAtomically @@ -66,6 +67,28 @@ test("configuration is resolved, redacted, patched, and written atomically", asy assert.equal(await fs.readFile(`${configPath}.bak`, "utf8"), JSON.stringify(config(project, rag))); }); +test("describeConfigError turns a ZodError into a readable sentence instead of raw issue JSON", () => { + // Regression: ZodError#message is `JSON.stringify(issues)`; a caller that surfaces + // error.message directly (a CLI report, an MCP tool error) printed that raw JSON + // dump at the user for something as ordinary as a typo'd config key. + const valid = config("../kb", "../kb/.chilon-recall"); + let error; + try { + configSchema.parse({ ...valid, extra_field: "oops" }); + } catch (caught) { + error = caught; + } + assert.ok(error, "expected configSchema.parse to throw"); + const message = describeConfigError(error); + assert.doesNotMatch(message, /^\[/); + assert.doesNotMatch(message, /"code"/); + assert.match(message, /extra_field/); +}); + +test("describeConfigError passes through a plain Error's message unchanged", () => { + assert.equal(describeConfigError(new Error("plain failure")), "plain failure"); +}); + test("rag_dir must remain inside project_dir", async () => { const temp = await fs.mkdtemp(path.join(os.tmpdir(), "chilon-boundary-")); const project = path.join(temp, "knowledge"); diff --git a/tests/node/models.test.mjs b/tests/node/models.test.mjs index 6780f7d..26960c7 100644 --- a/tests/node/models.test.mjs +++ b/tests/node/models.test.mjs @@ -29,6 +29,19 @@ test("listProviderModels normalizes the base URL and parses OpenAI-style model l assert.equal(calls[0].init.headers.Authorization, "Bearer sk-test"); }); +test("listProviderModels does not double-append /models when baseUrl already ends with it", async () => { + // Regression: a baseUrl copied from provider docs that already points at the full + // "/models" endpoint used to become ".../v1/models/models". + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: [] }) }; + }; + + await listProviderModels({ baseUrl: "https://api.example.com/v1/models/", apiKey: "sk-test", fetchImpl }); + assert.equal(calls[0].url, "https://api.example.com/v1/models"); +}); + test("listProviderModels surfaces provider errors without leaking the key", async () => { const fetchImpl = async () => ({ ok: false, status: 401, statusText: "Unauthorized" }); await assert.rejects( @@ -37,6 +50,46 @@ test("listProviderModels surfaces provider errors without leaking the key", asyn ); }); +test("listProviderModels rejects a key with an embedded newline before ever calling fetch, and never echoes it", async () => { + // Regression: a key with a raw newline (e.g. from a corrupted paste) made the + // runtime's Headers implementation throw "Headers.append: \"Bearer sk-one\\nsk-two\" + // is an invalid header value.", and that raw error.message — key included — was + // wrapped straight into the thrown "Could not reach ..." error. That both leaked + // the plaintext key (models.mjs promises it is "never ... logged, or echoed back") + // and mislabeled a malformed-credential problem as a network failure. + let called = false; + const fetchImpl = async () => { + called = true; + throw new TypeError('Headers.append: "Bearer sk-one\nsk-two" is an invalid header value.'); + }; + + await assert.rejects( + () => listProviderModels({ baseUrl: "https://api.example.com/v1", apiKey: "sk-one\nsk-two", fetchImpl }), + (error) => { + assert.doesNotMatch(error.message, /sk-one/); + assert.doesNotMatch(error.message, /sk-two/); + assert.doesNotMatch(error.message, /Could not reach/); + return true; + } + ); + assert.equal(called, false, "fetch must not be attempted with a header-unsafe key"); +}); + +test("listProviderModels redacts the key from an error message even if it leaks through fetch itself", async () => { + const fetchImpl = async () => { + throw new Error('Simulated leak: header value "Bearer sk-weird-key" was rejected'); + }; + + await assert.rejects( + () => listProviderModels({ baseUrl: "https://api.example.com/v1", apiKey: "sk-weird-key", fetchImpl }), + (error) => { + assert.doesNotMatch(error.message, /sk-weird-key/); + assert.match(error.message, /\[REDACTED\]/); + return true; + } + ); +}); + test("recommendModels groups by embedding/rerank keyword hints", () => { const rec = recommendModels(["BAAI/bge-m3", "Qwen/Qwen3-Reranker-8B", "some-other-model"]); assert.equal(rec.suggestedEmbeddingModel, "BAAI/bge-m3"); @@ -44,6 +97,16 @@ test("recommendModels groups by embedding/rerank keyword hints", () => { assert.deepEqual(rec.other, ["some-other-model"]); }); +test("recommendModels does not suggest a reranker as the embedding model when names overlap", () => { + // "bge-reranker-large" matches both the /bge/i embedding hint and the /rerank/i + // rerank hint; it must land only in the rerank bucket. + const rec = recommendModels(["bge-reranker-large", "zhihu-pinxi-gte-large"]); + assert.deepEqual(rec.embedding, ["zhihu-pinxi-gte-large"]); + assert.deepEqual(rec.rerank, ["bge-reranker-large"]); + assert.equal(rec.suggestedEmbeddingModel, "zhihu-pinxi-gte-large"); + assert.equal(rec.suggestedRerankModel, "bge-reranker-large"); +}); + test("renderKeySetup never writes the key anywhere but the returned command text", () => { const setup = renderKeySetup({ baseUrl: "https://api.siliconflow.cn/v1", @@ -58,3 +121,23 @@ test("renderKeySetup never writes the key anywhere but the returned command text assert.match(setup.commands.bash.thisShellOnly, /^export RAG_API_KEY="sk-super-secret"$/); assert.ok(setup.notes.some((note) => note.includes("never writes the key to a file"))); }); + +test("renderKeySetup warns that the persistent commands themselves write the plaintext key to disk/registry", () => { + // The program not writing the key to a file doesn't mean the user won't: the + // "persists" commands it hands out (setx / >> ~/.bashrc) do exactly that if run, + // and also land in shell history and terminal scrollback. The notes must say so + // rather than leaving the earlier "never writes ... to a file" claim to imply + // those commands are equally hands-off. + const setup = renderKeySetup({ + baseUrl: "https://api.siliconflow.cn/v1", + apiKey: "sk-super-secret", + envName: "RAG_API_KEY", + rerankEnvName: "RAG_API_KEY", + modelIds: ["BAAI/bge-m3"] + }); + + const persistenceWarning = setup.notes.find((note) => note.includes("setx") && note.includes("registry")); + assert.ok(persistenceWarning, "expected a note explaining setx/~/.bashrc persist the key themselves"); + assert.match(persistenceWarning, /bashrc/); + assert.match(persistenceWarning, /shell history|scrollback/); +}); diff --git a/tests/node/runtime.test.mjs b/tests/node/runtime.test.mjs index 3f00ff3..5a5d6ec 100644 --- a/tests/node/runtime.test.mjs +++ b/tests/node/runtime.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import test from "node:test"; import { initializeConfig, installProject } from "../../scripts/cli.mjs"; -import { parsePythonVersion, runtimeHome, supportsPython, venvDir, venvPython } from "../../src/runtime.mjs"; +import { parsePythonVersion, runProcess, runtimeHome, supportsPython, venvDir, venvPython } from "../../src/runtime.mjs"; test("runtime paths respect platform defaults and explicit overrides", () => { const windows = { env: { LOCALAPPDATA: "C:\\Local" }, platform: "win32", home: "C:\\Users\\Demo" }; @@ -53,3 +53,37 @@ test("install does not create a configuration when engine setup fails", async () await assert.rejects(installProject(directory, { setup: async () => { throw new Error("engine setup failed"); } }), /engine setup failed/); await assert.rejects(fs.access(directory), /ENOENT/); }); + +test("install forwards progress callbacks to the injected setup function", async () => { + // Regression: engine setup (venv + pip install) can run silently for the better + // part of a minute; installProject must pass onProgress/onOutput through to + // setupEngine so the CLI can surface live progress instead of looking hung. + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "chilon-install-progress-")); + let receivedOptions; + const onProgress = () => {}; + const onOutput = () => {}; + await installProject(directory, { + setup: async (options) => { + receivedOptions = options; + return { python: "managed-python" }; + }, + onProgress, + onOutput + }); + assert.equal(receivedOptions.onProgress, onProgress); + assert.equal(receivedOptions.onOutput, onOutput); + await fs.rm(directory, { recursive: true, force: true }); +}); + +test("runProcess streams output live via onOutput in addition to buffering it", async () => { + const chunks = []; + const result = await runProcess( + process.execPath, + ["-e", "process.stdout.write('hello '); process.stderr.write('world');"], + { onOutput: (chunk) => chunks.push(chunk) } + ); + assert.equal(result.stdout, "hello "); + assert.equal(result.stderr, "world"); + // stdout/stderr are independent streams, so don't assume arrival order. + assert.deepEqual(chunks.sort(), ["hello ", "world"]); +}); diff --git a/tests/node/secretPrompt.test.mjs b/tests/node/secretPrompt.test.mjs new file mode 100644 index 0000000..761b1d5 --- /dev/null +++ b/tests/node/secretPrompt.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { promptSecret } from "../../src/secretPrompt.mjs"; + +// Simulates a raw-mode TTY stdin. Node delivers each keystroke as its own "data" event, +// but a paste is delivered as a single "data" event carrying the whole clipboard chunk +// (including any newlines it contains) — call emit() once per keystroke or once per paste +// to reproduce either shape. +function fakeTty() { + const emitter = new EventEmitter(); + emitter.isTTY = true; + emitter.setRawMode = () => {}; + emitter.resume = () => {}; + emitter.pause = () => {}; + emitter.setEncoding = () => {}; + return emitter; +} + +function fakeOutput() { + let written = ""; + return { + write: (chunk) => { + written += chunk; + return true; + }, + get written() { + return written; + } + }; +} + +test("promptSecret resolves on character-by-character typing", async () => { + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + for (const char of "sk-test-key") input.emit("data", char); + input.emit("data", "\n"); + assert.equal(await pending, "sk-test-key"); +}); + +test("promptSecret resolves when a paste chunk carries a trailing newline", async () => { + // Regression: a single "data" event of "sk-test-key\n" used to fall through every + // case in the switch (none matched the whole multi-character string) into the + // default branch, splicing the raw chunk (newline included) into value and never + // resolving or rejecting — the wizard hung forever on the most common input path. + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + input.emit("data", "sk-test-key\n"); + assert.equal(await pending, "sk-test-key"); +}); + +test("promptSecret resolves when a paste chunk has no trailing newline, then Enter is pressed", async () => { + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + input.emit("data", "sk-test-key"); + input.emit("data", "\n"); + assert.equal(await pending, "sk-test-key"); +}); + +test("promptSecret submits at the first embedded newline in a multi-line paste", async () => { + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + input.emit("data", "sk-one\nsk-two"); + assert.equal(await pending, "sk-one"); +}); + +test("promptSecret treats Ctrl-D (EOT) as submit", async () => { + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + input.emit("data", "sk-test-key"); + assert.equal(await pending, "sk-test-key"); +}); + +test("promptSecret rejects on Ctrl-C", async () => { + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + input.emit("data", "sk-"); + await assert.rejects(() => pending, /Cancelled/); +}); + +test("promptSecret honors backspace within a pasted chunk", async () => { + const input = fakeTty(); + const output = fakeOutput(); + const pending = promptSecret("Key: ", { input, output }); + input.emit("data", "sk-test-keyy\n"); + assert.equal(await pending, "sk-test-key"); +}); diff --git a/tests/python/test_providers.py b/tests/python/test_providers.py new file mode 100644 index 0000000..d6042b4 --- /dev/null +++ b/tests/python/test_providers.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python")) + +from chilon_recall.providers import _join_endpoint # noqa: E402 + + +class JoinEndpointTests(unittest.TestCase): + def test_appends_suffix_to_a_bare_root(self): + self.assertEqual( + _join_endpoint("https://api.example.com/v1", "/embeddings"), + "https://api.example.com/v1/embeddings", + ) + + def test_strips_trailing_slash_before_appending(self): + self.assertEqual( + _join_endpoint("https://api.example.com/v1/", "/embeddings"), + "https://api.example.com/v1/embeddings", + ) + + def test_does_not_double_append_when_base_url_already_has_the_suffix(self): + # Regression: a base_url copied from provider docs that already ends in + # "/embeddings" used to become ".../v1/embeddings/embeddings". + self.assertEqual( + _join_endpoint("https://api.example.com/v1/embeddings", "/embeddings"), + "https://api.example.com/v1/embeddings", + ) + + def test_does_not_double_append_with_a_trailing_slash_on_the_full_endpoint(self): + self.assertEqual( + _join_endpoint("https://api.example.com/v1/embeddings/", "/embeddings"), + "https://api.example.com/v1/embeddings", + ) + + +if __name__ == "__main__": + unittest.main()